feat(other): init

This commit is contained in:
2026-01-19 03:06:04 +08:00
commit 2b97d4ef6c
195 changed files with 30912 additions and 0 deletions

BIN
src/.DS_Store vendored Normal file

Binary file not shown.

12
src/App.vue Normal file
View File

@@ -0,0 +1,12 @@
<template>
<AppProvider>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</AppProvider>
</template>
<script setup>
import AppProvider from '@/components/common/AppProvider.vue'
// App
</script>

13
src/api/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { request } from '@/utils'
export default {
getUser: () => request.get('/user'),
refreshToken: () => request.post('/auth/refreshToken', null, { noNeedTip: true }),
// 上传
uploadImg: (data) =>
request.post('/upload', data, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
}

BIN
src/assets/images/404.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src/assets/images/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

1
src/assets/svg/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 448 512"><path fill="currentColor" d="M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-92.2 312.9c-63.4 0-85.4-28.6-97.1-64.1c-16.3-51-21.5-84.3-63-84.3c-22.4 0-45.1 16.1-45.1 61.2c0 35.2 18 57.2 43.3 57.2c28.6 0 47.6-21.3 47.6-21.3l11.7 31.9s-19.8 19.4-61.2 19.4c-51.3 0-79.9-30.1-79.9-85.8c0-57.9 28.6-92 82.5-92c73.5 0 80.8 41.4 100.8 101.9c8.8 26.8 24.2 46.2 61.2 46.2c24.9 0 38.1-5.5 38.1-19.1c0-19.9-21.8-22-49.9-28.6c-30.4-7.3-42.5-23.1-42.5-48c0-40 32.3-52.4 65.2-52.4c37.4 0 60.1 13.6 63 46.6l-36.7 4.4c-1.5-15.8-11-22.4-28.6-22.4c-16.1 0-26 7.3-26 19.8c0 11 4.8 17.6 20.9 21.3c32.7 7.1 71.8 12 71.8 57.5c.1 36.7-30.7 50.6-76.1 50.6z"></path></svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -0,0 +1,51 @@
<template>
<v-chart :loading="loading" class="chart" :option="option" />
</template>
<script setup>
import { use } from 'echarts/core'
import { BarChart, PieChart } from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
DataZoomComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
use([
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent,
BarChart,
CanvasRenderer,
PieChart,
DataZoomComponent,
])
import VChart, { THEME_KEY } from 'vue-echarts'
import { provide } from 'vue'
provide(THEME_KEY, 'white')
defineProps({
option: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: true,
},
})
</script>
<style lang="scss" scoped>
.chart {
width: 100%;
height: 100%;
}
</style>

97
src/components/Editor.vue Normal file
View File

@@ -0,0 +1,97 @@
<template>
<div ref="_ref" style="border: 1px solid #ccc">
<Toolbar
style="border-bottom: 1px solid #ccc"
:editor="editorRef"
:default-config="toolbarConfig"
mode="default"
/>
<Editor
v-model="htmlValue"
:style="{ height: height + 'px', overflowY: 'hidden' }"
:default-config="editorConfig"
mode="default"
@on-created="handleCreated"
/>
</div>
</template>
<script setup>
import '@wangeditor/editor/dist/css/style.css' // 引入 css
import { onBeforeUnmount, shallowRef, ref } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import api from '@/api'
const props = defineProps({
valueHtml: {
type: String,
default: '',
},
height: {
type: Number,
default: 500,
},
})
const emit = defineEmits(['update:valueHtml'])
const editorRef = shallowRef()
const _ref = ref(null)
const toolbarConfig = {}
const editorConfig = { placeholder: '请输入游戏介绍...', MENU_CONF: {} }
const htmlValue = computed({
get() {
return props.valueHtml
},
set(value) {
emit('update:valueHtml', value)
},
})
editorConfig.MENU_CONF['uploadImage'] = {
allowedFileTypes: ['image/*'],
base64LimitSize: 5 * 1024, // 5kb
// 超时时间,默认为 10 秒
timeout: 5 * 1000, // 5 秒
async customUpload(file, insertFn) {
await upFile(file, insertFn)
},
}
editorConfig.MENU_CONF['uploadVideo'] = {
allowedFileTypes: ['video/*'],
async customUpload(file, insertFn) {
await upFile(file, insertFn)
},
}
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
const handleCreated = (editor) => {
editorRef.value = editor // 记录 editor 实例,重要!
editor.on('fullScreen', () => {
_ref.value.style.zIndex = 10
})
}
const upFile = async (file, insertFn) => {
const formData = new FormData()
formData.append('file', file)
const res = await api.uploadImg(formData)
insertFn(res.data.data)
}
</script>
<style lang="scss" scoped></style>

62
src/components/Upload.vue Normal file
View File

@@ -0,0 +1,62 @@
<template>
<n-upload
v-model:file-list="List"
:max="max"
list-type="image-card"
:custom-request="customRequest"
@before-upload="beforeUpload"
>
点击上传
</n-upload>
</template>
<script setup>
import api from '@/api'
const prop = defineProps({
list: {
type: Array,
default: () => [],
},
max: {
type: Number,
default: 1,
},
})
const emit = defineEmits(['update:list'])
const List = computed({
get() {
return prop.list
},
set(val) {
emit('update:list', val)
},
})
// 图片上传限制
const beforeUpload = ({ file }) => {
if (!(file.file?.type === 'image/png' || file.file?.type === 'image/jpeg')) {
$message.error('只能上传png或jpg格式的图片文件,请重新上传')
return false
}
return true
}
const customRequest = async ({ file, onFinish, onError }) => {
try {
const formData = new FormData()
formData.append('file', file.file)
const res = await api.uploadImg(formData)
$message.success(res.msg)
file.url = res.data.data
onFinish()
} catch (error) {
onError()
throw error
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,23 @@
<template>
<footer f-c-c flex-col text-14 color="#6a6a6a">
<p>
Copyright © 2022-present
<a
href="https://github.com/zclzone"
target="__blank"
hover="decoration-underline color-primary"
>
Ronnie Zhang
</a>
</p>
<p>
<a
href="http://beian.miit.gov.cn/"
target="__blank"
hover="decoration-underline color-primary"
>
赣ICP备2020015008号-1
</a>
</p>
</footer>
</template>

View File

@@ -0,0 +1,30 @@
<template>
<n-config-provider
wh-full
:locale="zhCN"
:date-locale="dateZhCN"
:theme="appStore.isDark ? darkTheme : undefined"
:theme-overrides="naiveThemeOverrides"
>
<slot />
</n-config-provider>
</template>
<script setup>
import { zhCN, dateZhCN, darkTheme } from 'naive-ui'
import { useCssVar } from '@vueuse/core'
import { kebabCase } from 'lodash-es'
import { naiveThemeOverrides } from '~/settings'
import { useAppStore } from '@/store'
const appStore = useAppStore()
function setupCssVar() {
const common = naiveThemeOverrides.common
for (const key in common) {
useCssVar(`--${kebabCase(key)}`, document.documentElement).value = common[key] || ''
if (key === 'primaryColor') window.localStorage.setItem('__THEME_COLOR__', common[key] || '')
}
}
setupCssVar()
</script>

View File

@@ -0,0 +1,162 @@
<template>
<div ref="wrapper" class="wrapper" @mousewheel.prevent="handleMouseWheel">
<template v-if="showArrow && isOverflow">
<div class="left dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: 120 })">
<icon-ic:baseline-keyboard-arrow-left />
</div>
<div class="right dark:bg-dark!" @click="handleMouseWheel({ wheelDelta: -120 })">
<icon-ic:baseline-keyboard-arrow-right />
</div>
</template>
<div
ref="content"
class="content"
:class="{ overflow: isOverflow && showArrow }"
:style="{
transform: `translateX(${translateX}px)`,
}"
>
<slot />
</div>
</div>
</template>
<script setup>
import { debounce, useResize } from '@/utils'
defineProps({
showArrow: {
type: Boolean,
default: true,
},
})
const translateX = ref(0)
const content = ref(null)
const wrapper = ref(null)
const isOverflow = ref(false)
const refreshIsOverflow = debounce(() => {
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
isOverflow.value = contentWidth > wrapperWidth
resetTranslateX(wrapperWidth, contentWidth)
}, 200)
function handleMouseWheel(e) {
const { wheelDelta } = e
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
/**
* @wheelDelta 平行滚动的值 >0 右移 <0: 左移
* @translateX 内容translateX的值
* @wrapperWidth 容器的宽度
* @contentWidth 内容的宽度
*/
if (wheelDelta < 0) {
if (wrapperWidth > contentWidth && translateX.value < -10) return
if (wrapperWidth <= contentWidth && contentWidth + translateX.value - wrapperWidth < -10) return
}
if (wheelDelta > 0 && translateX.value > 10) {
return
}
translateX.value += wheelDelta
resetTranslateX(wrapperWidth, contentWidth)
}
const resetTranslateX = debounce(function (wrapperWidth, contentWidth) {
if (!isOverflow.value) {
translateX.value = 0
} else if (-translateX.value > contentWidth - wrapperWidth) {
translateX.value = wrapperWidth - contentWidth
} else if (translateX.value > 0) {
translateX.value = 0
}
}, 200)
const observers = ref([])
onMounted(() => {
refreshIsOverflow()
observers.value.push(useResize(document.body, refreshIsOverflow))
observers.value.push(useResize(content.value, refreshIsOverflow))
})
onBeforeUnmount(() => {
observers.value.forEach((item) => {
item?.disconnect()
})
})
function handleScroll(x, width) {
const wrapperWidth = wrapper.value?.offsetWidth
const contentWidth = content.value?.offsetWidth
if (contentWidth <= wrapperWidth) return
// 当 x 小于可视范围的最小值时
if (x < -translateX.value + 150) {
translateX.value = -(x - 150)
resetTranslateX(wrapperWidth, contentWidth)
}
// 当 x 大于可视范围的最大值时
if (x + width > -translateX.value + wrapperWidth) {
translateX.value = wrapperWidth - (x + width)
resetTranslateX(wrapperWidth, contentWidth)
}
}
defineExpose({
handleScroll,
})
</script>
<style lang="scss" scoped>
.wrapper {
display: flex;
background-color: #fff;
z-index: 9;
overflow: hidden;
position: relative;
.content {
padding: 0 10px;
display: flex;
align-items: center;
flex-wrap: nowrap;
transition: transform 0.5s;
&.overflow {
padding-left: 30px;
padding-right: 30px;
}
}
.left,
.right {
background-color: #fff;
position: absolute;
top: 0;
bottom: 0;
margin: auto;
width: 20px;
height: 35px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
border: 1px solid #e0e0e6;
border-radius: 2px;
z-index: 2;
cursor: pointer;
}
.left {
left: 0;
}
.right {
right: 0;
}
}
</style>

View File

@@ -0,0 +1,22 @@
<script setup>
/** 自定义图标 */
const props = defineProps({
/** 图标名称(assets/svg下的文件名) */
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
})
</script>
<template>
<TheIcon type="custom" v-bind="props" />
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
const props = defineProps({
icon: {
type: String,
required: true,
},
prefix: {
type: String,
default: 'icon-custom',
},
color: {
type: String,
default: 'currentColor',
},
})
const symbolId = computed(() => `#${props.prefix}-${props.icon}`)
</script>
<template>
<svg aria-hidden="true" width="1em" height="1em">
<use :xlink:href="symbolId" :fill="color" />
</svg>
</template>

View File

@@ -0,0 +1,33 @@
<script setup>
import { renderIcon, renderCustomIcon } from '@/utils'
const props = defineProps({
icon: {
type: String,
required: true,
},
size: {
type: Number,
default: 14,
},
color: {
type: String,
default: undefined,
},
/** iconify | custom */
type: {
type: String,
default: 'iconify',
},
})
const iconCom = computed(() =>
props.type === 'iconify'
? renderIcon(props.icon, { size: props.size, color: props.color })
: renderCustomIcon(props.icon, { size: props.size, color: props.color })
)
</script>
<template>
<component :is="iconCom" />
</template>

View File

@@ -0,0 +1,18 @@
<template>
<transition name="fade-slide" mode="out-in" appear>
<section class="cus-scroll-y wh-full flex-col bg-[#f5f6fb] p-15 dark:bg-hex-121212">
<slot />
<!-- <AppFooter v-if="showFooter" mt-15 /> -->
<n-back-top :bottom="20" />
</section>
</transition>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
})
</script>

View File

@@ -0,0 +1,33 @@
<template>
<AppPage :show-footer="showFooter">
<header v-if="showHeader" mb-15 min-h-45 flex items-center justify-between px-15>
<slot v-if="$slots.header" name="header" />
<template v-else>
<h2 text-22 font-normal text-hex-333 dark:text-hex-ccc>{{ title || route.meta?.title }}</h2>
<slot name="action" />
</template>
</header>
<n-card flex-1 rounded-10>
<slot />
</n-card>
</AppPage>
</template>
<script setup>
defineProps({
showFooter: {
type: Boolean,
default: false,
},
showHeader: {
type: Boolean,
default: true,
},
title: {
type: String,
default: undefined,
},
})
const route = useRoute()
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div
bg="#fafafc"
min-h-60
flex
items-start
justify-between
b-1
rounded-8
p-15
bc-ccc
dark:bg-black
>
<n-space wrap :size="[35, 15]">
<slot />
</n-space>
<div flex-shrink-0>
<n-button secondary type="primary" @click="emit('reset')">重置</n-button>
<n-button ml-20 type="primary" @click="emit('search')">搜索</n-button>
</div>
</div>
</template>
<script setup>
const emit = defineEmits(['search', 'reset'])
</script>

View File

@@ -0,0 +1,34 @@
<template>
<div flex items-center>
<label
v-if="!isNullOrWhitespace(label)"
w-80
flex-shrink-0
:style="{ width: labelWidth + 'px' }"
>
{{ label }}
</label>
<div :style="{ width: contentWidth + 'px' }" flex-shrink-0>
<slot />
</div>
</div>
</template>
<script setup>
import { isNullOrWhitespace } from '@/utils'
defineProps({
label: {
type: String,
default: '',
},
labelWidth: {
type: Number,
default: 80,
},
contentWidth: {
type: Number,
default: 220,
},
})
</script>

View File

@@ -0,0 +1,55 @@
<template>
<n-modal
v-model:show="show"
:style="{ width }"
preset="card"
:title="title"
size="huge"
:bordered="false"
>
<slot />
<template v-if="showFooter" #footer>
<footer flex justify-end>
<slot name="footer">
<n-button @click="show = false">取消</n-button>
<n-button :loading="loading" ml-20 type="primary" @click="emit('onSave')">保存</n-button>
</slot>
</footer>
</template>
</n-modal>
</template>
<script setup>
const props = defineProps({
width: {
type: String,
default: '600px',
},
title: {
type: String,
default: '',
},
showFooter: {
type: Boolean,
default: true,
},
visible: {
type: Boolean,
required: true,
},
loading: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:visible', 'onSave'])
const show = computed({
get() {
return props.visible
},
set(v) {
emit('update:visible', v)
},
})
</script>

View File

@@ -0,0 +1,149 @@
<template>
<QueryBar v-if="$slots.queryBar" mb-30 @search="handleSearch" @reset="handleReset">
<slot name="queryBar" />
</QueryBar>
<n-data-table
:remote="remote"
:loading="loading"
:scroll-x="scrollX"
:columns="columns"
:data="tableData"
:row-key="(row) => row[rowKey]"
:pagination="isPagination ? pagination : false"
@update:checked-row-keys="onChecked"
@update:page="onPageChange"
/>
</template>
<script setup>
import { utils, writeFile } from 'xlsx'
const props = defineProps({
/**
* @remote true: 后端分页 false 前端分页
*/
remote: {
type: Boolean,
default: true,
},
/**
* @remote 是否分页
*/
isPagination: {
type: Boolean,
default: true,
},
scrollX: {
type: Number,
default: 1200,
},
rowKey: {
type: String,
default: 'id',
},
columns: {
type: Array,
required: true,
},
/** queryBar中的参数 */
queryItems: {
type: Object,
default() {
return {}
},
},
/** 补充参数(可选) */
extraParams: {
type: Object,
default() {
return {}
},
},
/**
* ! 约定接口入参出参
* * 分页模式需约定分页接口入参
* @pageSize 分页参数一页展示多少条默认10
* @pageNo 分页参数页码默认1
* * 需约定接口出参
* @pageData 分页模式必须,非分页模式如果没有pageData则取上一层data
* @total 分页模式必须非分页模式如果没有total则取上一层data.length
*/
getData: {
type: Function,
required: true,
},
})
const emit = defineEmits(['update:queryItems', 'onChecked', 'onDataChange'])
const loading = ref(false)
const initQuery = { ...props.queryItems }
const tableData = ref([])
const pagination = reactive({ page: 1, pageSize: 10 })
async function handleQuery() {
try {
loading.value = true
let paginationParams = {}
// 如果非分页模式或者使用前端分页,则无需传分页参数
if (props.isPagination && props.remote) {
paginationParams = { pageNo: pagination.page, pageSize: pagination.pageSize }
}
const { data } = await props.getData({
...props.queryItems,
...props.extraParams,
...paginationParams,
})
tableData.value = data?.pageData || data
pagination.itemCount = data.total ?? data.length
} catch (error) {
tableData.value = []
pagination.itemCount = 0
} finally {
emit('onDataChange', tableData.value)
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
handleQuery()
}
async function handleReset() {
const queryItems = { ...props.queryItems }
for (const key in queryItems) {
queryItems[key] = ''
}
emit('update:queryItems', { ...queryItems, ...initQuery })
await nextTick()
pagination.page = 1
handleQuery()
}
function onPageChange(currentPage) {
pagination.page = currentPage
if (props.remote) {
handleQuery()
}
}
function onChecked(rowKeys) {
if (props.columns.some((item) => item.type === 'selection')) {
emit('onChecked', rowKeys)
}
}
function handleExport(columns = props.columns, data = tableData.value) {
if (!data?.length) return $message.warning('没有数据')
const columnsData = columns.filter((item) => !!item.title && !item.hideInExcel)
const thKeys = columnsData.map((item) => item.key)
const thData = columnsData.map((item) => item.title)
const trData = data.map((item) => thKeys.map((key) => item[key]))
const sheet = utils.aoa_to_sheet([thData, ...trData])
const workBook = utils.book_new()
utils.book_append_sheet(workBook, sheet, '数据报表')
writeFile(workBook, '数据报表.xlsx')
}
defineExpose({
handleSearch,
handleReset,
handleExport,
})
</script>

1
src/composables/index.js Normal file
View File

@@ -0,0 +1 @@
export { default as useCRUD } from './useCRUD'

103
src/composables/useCRUD.js Normal file
View File

@@ -0,0 +1,103 @@
import { isNullOrWhitespace } from '@/utils'
const ACTIONS = {
view: '查看',
edit: '编辑',
add: '新增',
}
export default function ({ name, initForm = {}, doCreate, doDelete, doUpdate, refresh }) {
const modalVisible = ref(false)
const modalAction = ref('')
const modalTitle = computed(() => ACTIONS[modalAction.value] + name)
const modalLoading = ref(false)
const modalFormRef = ref(null)
const modalForm = ref({ ...initForm })
/** 新增 */
function handleAdd() {
modalAction.value = 'add'
modalVisible.value = true
modalForm.value = { ...initForm }
}
/** 修改 */
function handleEdit(row) {
modalAction.value = 'edit'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 查看 */
function handleView(row) {
modalAction.value = 'view'
modalVisible.value = true
modalForm.value = { ...row }
}
/** 保存 */
function handleSave() {
if (!['edit', 'add'].includes(modalAction.value)) {
modalVisible.value = false
return
}
modalFormRef.value?.validate(async (err) => {
if (err) return
const actions = {
add: {
api: () => doCreate(modalForm.value),
cb: () => $message.success('新增成功'),
},
edit: {
api: () => doUpdate(modalForm.value),
cb: () => $message.success('编辑成功'),
},
}
const action = actions[modalAction.value]
try {
modalLoading.value = true
const data = await action.api()
action.cb()
modalLoading.value = modalVisible.value = false
data && refresh(data)
} catch (error) {
modalLoading.value = false
}
})
}
/** 删除 */
function handleDelete(id, confirmOptions) {
if (isNullOrWhitespace(id)) return
$dialog.confirm({
content: '确定删除?',
async confirm() {
try {
modalLoading.value = true
const data = await doDelete(id)
$message.success('删除成功')
modalLoading.value = false
refresh(data)
} catch (error) {
modalLoading.value = false
}
},
...confirmOptions,
})
}
return {
modalVisible,
modalAction,
modalTitle,
modalLoading,
handleAdd,
handleDelete,
handleEdit,
handleView,
handleSave,
modalForm,
modalFormRef,
}
}

43
src/hooks/useScript.js Normal file
View File

@@ -0,0 +1,43 @@
import { onMounted, onUnmounted, ref } from 'vue'
export function useScript(opts) {
const isLoading = ref(false)
const error = ref(false)
const success = ref(false)
let script
const promise = new Promise((resolve, reject) => {
onMounted(() => {
script = document.createElement('script')
script.type = 'text/javascript'
script.charset = 'utf-8'
script.onload = function () {
isLoading.value = false
success.value = true
error.value = false
resolve('')
}
script.onerror = function (err) {
isLoading.value = false
success.value = false
error.value = true
reject(err)
}
script.src = opts.src
document.head.appendChild(script)
})
})
onUnmounted(() => {
script && script.remove()
})
return {
isLoading,
error,
success,
toPromise: () => promise,
}
}

View File

@@ -0,0 +1,18 @@
<template>
<router-view v-slot="{ Component, route }">
<KeepAlive :include="keepAliveNames">
<div h-full w-full>
<component :is="Component" v-if="!tagStore.reloading" :key="route.fullPath" />
</div>
</KeepAlive>
</router-view>
</template>
<script setup>
import { useTagsStore } from '@/store'
const tagStore = useTagsStore()
const keepAliveNames = computed(() => {
return tagStore.tags.filter((item) => item.keepAlive).map((item) => item.name)
})
</script>

View File

@@ -0,0 +1,30 @@
<template>
<n-breadcrumb>
<n-breadcrumb-item
v-for="item in route.matched.filter((item) => !!item.meta?.title)"
:key="item.path"
@click="handleBreadClick(item.path)"
>
<component :is="getIcon(item.meta)" />
{{ item.meta.title }}
</n-breadcrumb-item>
</n-breadcrumb>
</template>
<script setup>
import { renderCustomIcon, renderIcon } from '@/utils'
const router = useRouter()
const route = useRoute()
function handleBreadClick(path) {
if (path === route.path) return
router.push(path)
}
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<n-icon mr20 size="18" style="cursor: pointer" @click="toggle">
<icon-ant-design:fullscreen-exit-outlined v-if="isFullscreen" />
<icon-ant-design:fullscreen-outlined v-else />
</n-icon>
</template>
<script setup>
import { useFullscreen } from '@vueuse/core'
const { isFullscreen, toggle } = useFullscreen()
</script>

View File

@@ -0,0 +1,11 @@
<template>
<n-icon mr-20 size="18" style="cursor: pointer" @click="handleLinkClick">
<icon-mdi:github />
</n-icon>
</template>
<script setup>
function handleLinkClick() {
window.open('https://github.com/zclzone/vue-naive-admin')
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<n-icon size="20" cursor-pointer @click="appStore.switchCollapsed">
<icon-mdi:format-indent-increase v-if="appStore.collapsed" />
<icon-mdi:format-indent-decrease v-else />
</n-icon>
</template>
<script setup>
import { useAppStore } from '@/store'
const appStore = useAppStore()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<n-popover trigger="click" placement="bottom" @update:show="handlePopoverShow">
<template #trigger>
<n-badge :value="count" mr-20 cursor-pointer>
<n-icon size="18" color-black dark="color-hex-fff">
<icon-material-symbols:notifications-outline />
</n-icon>
</n-badge>
</template>
<n-tabs v-model:value="activeTab" type="line" justify-content="space-around" animated>
<n-tab-pane
v-for="tab in tabs"
:key="tab.name"
:name="tab.name"
:tab="tab.title + `(${tab.messages.length})`"
>
<ul class="cus-scroll-y max-h-200 w-220">
<li
v-for="(item, index) in tab.messages"
:key="index"
class="flex-col py-12"
border-t="1 solid gray-200"
:style="index > 0 ? '' : 'border: none;'"
>
<span mb-4 text-ellipsis>{{ item.content }}</span>
<span text-hex-bbb>{{ item.time }}</span>
</li>
</ul>
</n-tab-pane>
</n-tabs>
</n-popover>
</template>
<script setup>
import { formatDateTime } from '@/utils'
const activeTab = ref('')
const tabs = [
{
name: 'zan',
title: '点赞',
messages: [
{ content: '你的文章《XX》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《YY》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《AA》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《BB》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《CC》收到一条点赞', time: formatDateTime() },
{ content: '你的文章《DD》收到一条点赞', time: formatDateTime() },
],
},
{
name: 'star',
title: '关注',
messages: [
{ content: '张三 关注了你', time: formatDateTime() },
{ content: '王五 关注了你', time: formatDateTime() },
],
},
{
name: 'comment',
title: '评论',
messages: [
{ content: '张三 评论了你的文章《XX》"学到了"', time: formatDateTime() },
{ content: '李四 评论了你的文章《YY》"不如Vue"', time: formatDateTime() },
],
},
]
const count = ref(tabs.map((item) => item.messages).flat().length)
watch(activeTab, (v) => {
if (count.value === 0) return
const tabIndex = tabs.findIndex((item) => item.name === v)
count.value -= tabs[tabIndex].messages.length
if (count.value < 0) count.value = 0
})
function handlePopoverShow(show) {
if (show && !activeTab.value) {
activeTab.value = tabs[0]?.name
}
}
</script>

View File

@@ -0,0 +1,18 @@
<script setup>
import { useAppStore } from '@/store'
import { useDark, useToggle } from '@vueuse/core'
const appStore = useAppStore()
const isDark = useDark()
const toggleDark = () => {
appStore.toggleDark()
useToggle(isDark)()
}
</script>
<template>
<n-icon mr-20 cursor-pointer size="18" @click="toggleDark">
<icon-mdi-moon-waning-crescent v-if="isDark" />
<icon-mdi-white-balance-sunny v-else />
</n-icon>
</template>

View File

@@ -0,0 +1,37 @@
<template>
<n-dropdown :options="options" @select="handleSelect">
<div flex cursor-pointer items-center>
<img :src="userStore.avatar" mr10 h-35 w-35 rounded-full />
<span>{{ userStore.name }}</span>
</div>
</n-dropdown>
</template>
<script setup>
import { useUserStore } from '@/store'
import { renderIcon } from '@/utils'
const userStore = useUserStore()
const options = [
{
label: '退出登录',
key: 'logout',
icon: renderIcon('mdi:exit-to-app', { size: '14px' }),
},
]
function handleSelect(key) {
if (key === 'logout') {
$dialog.confirm({
title: '提示',
type: 'info',
content: '确认退出?',
confirm() {
userStore.logout()
$message.success('已退出登录')
},
})
}
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div flex items-center>
<MenuCollapse />
<BreadCrumb ml-15 hidden sm:block />
</div>
<div ml-auto flex items-center>
<!-- <MessageNotification /> -->
<ThemeMode />
<!-- <GithubSite /> -->
<FullScreen />
<UserAvatar />
</div>
</template>
<script setup>
import BreadCrumb from './components/BreadCrumb.vue'
import MenuCollapse from './components/MenuCollapse.vue'
import FullScreen from './components/FullScreen.vue'
import UserAvatar from './components/UserAvatar.vue'
// import GithubSite from './components/GithubSite.vue'
import ThemeMode from './components/ThemeMode.vue'
// import MessageNotification from './components/MessageNotification.vue'
</script>

View File

@@ -0,0 +1,15 @@
<template>
<router-link h-60 f-c-c to="/">
<img src="@/assets/images/logo.png" height="42" />
<h2 v-show="!appStore.collapsed" ml-10 max-w-140 flex-shrink-0 text-16 font-bold color-primary>
{{ title }}
</h2>
</router-link>
</template>
<script setup>
import { useAppStore } from '@/store'
const title = import.meta.env.VITE_TITLE
const appStore = useAppStore()
</script>

View File

@@ -0,0 +1,120 @@
<template>
<n-menu
ref="menu"
class="side-menu"
accordion
:indent="18"
:collapsed-icon-size="22"
:collapsed-width="64"
:options="menuOptions"
:value="activeKey"
@update:value="handleMenuSelect"
/>
</template>
<script setup>
import { usePermissionStore } from '@/store'
import { renderCustomIcon, renderIcon, isExternal } from '@/utils'
const router = useRouter()
const curRoute = useRoute()
const permissionStore = usePermissionStore()
const activeKey = computed(() => curRoute.meta?.activeMenu || curRoute.name)
const menuOptions = computed(() => {
return permissionStore.menus.map((item) => getMenuItem(item)).sort((a, b) => a.order - b.order)
})
const menu = ref(null)
watch(curRoute, async () => {
await nextTick()
menu.value?.showOption()
})
function resolvePath(basePath, path) {
if (isExternal(path)) return path
return (
'/' +
[basePath, path]
.filter((path) => !!path && path !== '/')
.map((path) => path.replace(/(^\/)|(\/$)/g, ''))
.join('/')
)
}
function getMenuItem(route, basePath = '') {
let menuItem = {
label: (route.meta && route.meta.title) || route.name,
key: route.name,
path: resolvePath(basePath, route.path),
icon: getIcon(route.meta),
order: route.meta?.order || 0,
}
const visibleChildren = route.children
? route.children.filter((item) => item.name && !item.isHidden)
: []
if (!visibleChildren.length) return menuItem
// if (visibleChildren.length === 1) {
// // 单个子路由处理
// const singleRoute = visibleChildren[0]
// menuItem = {
// ...menuItem,
// label: singleRoute.meta?.title || singleRoute.name,
// key: singleRoute.name,
// path: resolvePath(menuItem.path, singleRoute.path),
// icon: getIcon(singleRoute.meta),
// }
// const visibleItems = singleRoute.children
// ? singleRoute.children.filter((item) => item.name && !item.isHidden)
// : []
// if (visibleItems.length === 1) {
// menuItem = getMenuItem(visibleItems[0], menuItem.path)
// } else if (visibleItems.length > 1) {
// menuItem.children = visibleItems
// .map((item) => getMenuItem(item, menuItem.path))
// .sort((a, b) => a.order - b.order)
// }
// } else {
menuItem.children = visibleChildren
.map((item) => getMenuItem(item, menuItem.path))
.sort((a, b) => a.order - b.order)
// }
return menuItem
}
function getIcon(meta) {
if (meta?.customIcon) return renderCustomIcon(meta.customIcon, { size: 18 })
if (meta?.icon !== '无' && meta?.icon) return renderIcon(meta.icon, { size: 18 })
return null
}
function handleMenuSelect(_, item) {
if (isExternal(item.path)) {
window.open(item.path)
} else {
router.push(item.path)
}
}
</script>
<style lang="scss">
.side-menu:not(.n-menu--collapsed) {
.n-menu-item-content {
&::before {
left: 5px;
right: 5px;
}
&.n-menu-item-content--selected,
&:hover {
&::before {
border-left: 4px solid var(--primary-color);
}
}
}
}
</style>

View File

@@ -0,0 +1,9 @@
<script setup>
import SideLogo from './components/SideLogo.vue'
import SideMenu from './components/SideMenu.vue'
</script>
<template>
<SideLogo />
<SideMenu />
</template>

View File

@@ -0,0 +1,118 @@
<template>
<n-dropdown
:show="show"
:options="options"
:x="x"
:y="y"
placement="bottom-start"
@clickoutside="handleHideDropdown"
@select="handleSelect"
/>
</template>
<script setup>
import { useTagsStore } from '@/store'
import { renderIcon } from '@/utils'
const props = defineProps({
show: {
type: Boolean,
default: false,
},
currentPath: {
type: String,
default: '',
},
x: {
type: Number,
default: 0,
},
y: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:show'])
const tagsStore = useTagsStore()
const options = computed(() => [
{
label: '重新加载',
key: 'reload',
disabled: props.currentPath !== tagsStore.activeTag,
icon: renderIcon('mdi:refresh', { size: '14px' }),
},
{
label: '关闭',
key: 'close',
disabled: tagsStore.tags.length <= 1,
icon: renderIcon('mdi:close', { size: '14px' }),
},
{
label: '关闭其他',
key: 'close-other',
disabled: tagsStore.tags.length <= 1,
icon: renderIcon('mdi:arrow-expand-horizontal', { size: '14px' }),
},
{
label: '关闭左侧',
key: 'close-left',
disabled: tagsStore.tags.length <= 1 || props.currentPath === tagsStore.tags[0].path,
icon: renderIcon('mdi:arrow-expand-left', { size: '14px' }),
},
{
label: '关闭右侧',
key: 'close-right',
disabled:
tagsStore.tags.length <= 1 ||
props.currentPath === tagsStore.tags[tagsStore.tags.length - 1].path,
icon: renderIcon('mdi:arrow-expand-right', { size: '14px' }),
},
])
const route = useRoute()
const actionMap = new Map([
[
'reload',
() => {
tagsStore.reloadTag(route.path, route.meta?.keepAlive)
},
],
[
'close',
() => {
tagsStore.removeTag(props.currentPath)
},
],
[
'close-other',
() => {
tagsStore.removeOther(props.currentPath)
},
],
[
'close-left',
() => {
tagsStore.removeLeft(props.currentPath)
},
],
[
'close-right',
() => {
tagsStore.removeRight(props.currentPath)
},
],
])
function handleHideDropdown() {
emit('update:show', false)
}
function handleSelect(key) {
const actionFn = actionMap.get(key)
actionFn && actionFn()
handleHideDropdown()
}
</script>

View File

@@ -0,0 +1,106 @@
<template>
<ScrollX ref="scrollXRef" class="bg-white dark:bg-dark!">
<n-tag
v-for="tag in tagsStore.tags"
ref="tabRefs"
:key="tag.path"
class="mx-5 cursor-pointer rounded-4 px-15 hover:color-primary"
:type="tagsStore.activeTag === tag.path ? 'primary' : 'default'"
:closable="tagsStore.tags.length > 1"
@click="handleTagClick(tag.path)"
@close.stop="tagsStore.removeTag(tag.path)"
@contextmenu.prevent="handleContextMenu($event, tag)"
>
<template v-if="tag.icon" #icon>
<TheIcon :icon="tag.icon" class="mr-4" />
</template>
{{ tag.title }}
</n-tag>
<ContextMenu
v-if="contextMenuOption.show"
v-model:show="contextMenuOption.show"
:current-path="contextMenuOption.currentPath"
:x="contextMenuOption.x"
:y="contextMenuOption.y"
/>
</ScrollX>
</template>
<script setup>
import ContextMenu from './ContextMenu.vue'
import { useTagsStore } from '@/store'
import ScrollX from '@/components/common/ScrollX.vue'
const route = useRoute()
const router = useRouter()
const tagsStore = useTagsStore()
const tabRefs = ref([])
const scrollXRef = ref(null)
const contextMenuOption = reactive({
show: false,
x: 0,
y: 0,
currentPath: '',
})
watch(
() => route.path,
() => {
const { name, fullPath: path } = route
const title = route.meta?.title
const icon = route.meta?.icon
const keepAlive = route.meta?.keepAlive
tagsStore.addTag({ name, path, title, icon, keepAlive })
},
{ immediate: true }
)
watch(
() => tagsStore.activeIndex,
async (activeIndex) => {
await nextTick()
const activeTabElement = tabRefs.value[activeIndex]?.$el
if (!activeTabElement) return
const { offsetLeft: x, offsetWidth: width } = activeTabElement
scrollXRef.value?.handleScroll(x + width, width)
},
{ immediate: true }
)
const handleTagClick = (path) => {
tagsStore.setActiveTag(path)
router.push(path)
}
function showContextMenu() {
contextMenuOption.show = true
}
function hideContextMenu() {
contextMenuOption.show = false
}
function setContextMenu(x, y, currentPath) {
Object.assign(contextMenuOption, { x, y, currentPath })
}
// 右击菜单
async function handleContextMenu(e, tagItem) {
const { clientX, clientY } = e
hideContextMenu()
setContextMenu(clientX, clientY, tagItem.path)
await nextTick()
showContextMenu()
}
</script>
<style>
.n-tag__close {
box-sizing: content-box;
border-radius: 50%;
font-size: 12px;
padding: 2px;
transform: scale(0.9);
transform: translateX(5px);
transition: all 0.3s;
}
</style>

42
src/layout/index.vue Normal file
View File

@@ -0,0 +1,42 @@
<template>
<n-layout has-sider wh-full>
<n-layout-sider
bordered
collapse-mode="width"
:collapsed-width="64"
:width="220"
:native-scrollbar="false"
:collapsed="appStore.collapsed"
>
<SideBar />
</n-layout-sider>
<article flex-col flex-1 overflow-hidden>
<header
border-b="1 solid #eee"
class="flex items-center bg-white px-15"
dark="bg-dark border-0"
:style="`height: ${header.height}px`"
>
<AppHeader />
</header>
<section v-if="tags.visible" hidden border-b bc-eee sm:block dark:border-0>
<AppTags :style="{ height: `${tags.height}px` }" />
</section>
<section flex-1 overflow-hidden bg-hex-f5f6fb dark:bg-hex-101014>
<AppMain />
</section>
</article>
</n-layout>
</template>
<script setup>
import AppHeader from './components/header/index.vue'
import SideBar from './components/sidebar/index.vue'
import AppMain from './components/AppMain.vue'
import AppTags from './components/tags/index.vue'
import { useAppStore } from '@/store'
import { header, tags } from '~/settings'
const appStore = useAppStore()
</script>

49
src/main.js Normal file
View File

@@ -0,0 +1,49 @@
/** 重置样式 */
import '@/styles/reset.css'
import 'uno.css'
import '@/styles/global.scss'
import 'virtual:svg-icons-register'
import { createApp } from 'vue'
import { setupRouter } from '@/router'
import { setupStore } from '@/store'
import App from './App.vue'
import { setupNaiveDiscreteApi } from './utils'
import { initApiEndpoint } from '@/utils/api-config'
async function setupApp() {
const app = createApp(App)
// 初始化接口配置
initApiEndpoint()
setupStore(app)
setupNaiveDiscreteApi()
await setupRouter(app)
app.directive('perms', {
mounted: (el, binding) => {
const { value } = binding
const permissions = JSON.parse(localStorage.getItem('roles'))
const all_permission = '*'
if (Array.isArray(value)) {
if (value.length > 0) {
const hasPermission = permissions.some((key) => {
return all_permission === key || value.includes(key)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
} else {
throw new Error('like v-perms="[\'auth/menu/edit\']"')
}
},
})
app.mount('#app')
}
setupApp()

View File

@@ -0,0 +1,9 @@
import { createPageLoadingGuard } from './page-loading-guard'
import { createPageTitleGuard } from './page-title-guard'
import { createPermissionGuard } from './permission-guard'
export function setupRouterGuard(router) {
createPageLoadingGuard(router)
createPermissionGuard(router)
createPageTitleGuard(router)
}

View File

@@ -0,0 +1,15 @@
export function createPageLoadingGuard(router) {
router.beforeEach(() => {
window.$loadingBar?.start()
})
router.afterEach(() => {
setTimeout(() => {
window.$loadingBar?.finish()
}, 200)
})
router.onError(() => {
window.$loadingBar?.error()
})
}

View File

@@ -0,0 +1,12 @@
const baseTitle = import.meta.env.VITE_TITLE
export function createPageTitleGuard(router) {
router.afterEach((to) => {
const pageTitle = to.meta?.title
if (pageTitle) {
document.title = `${pageTitle} | ${baseTitle}`
} else {
document.title = baseTitle
}
})
}

View File

@@ -0,0 +1,34 @@
import { getToken, isNullOrWhitespace } from '@/utils'
import { addDynamicRoutes } from '@/router'
const WHITE_LIST = ['/login', '/404']
export function createPermissionGuard(router) {
router.beforeEach(async (to) => {
const token = getToken()
/** 没有token的情况 */
if (isNullOrWhitespace(token)) {
if (WHITE_LIST.includes(to.path)) return true
return { path: 'login', query: { ...to.query, redirect: to.path } }
}
/** 有token的情况 */
if (to.path === '/login') return { path: '/' }
// 确保动态路由已加载
if (token && !router.hasRoute('Dashboard')) {
try {
await addDynamicRoutes()
// 如果当前路径不存在,重定向到工作台
if (to.path !== '/' && !router.hasRoute(to.name)) {
return { path: '/workbench' }
}
} catch (error) {
console.error('动态路由加载失败:', error)
return { path: '/login' }
}
}
return true
})
}

87
src/router/index.js Normal file
View File

@@ -0,0 +1,87 @@
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
import { setupRouterGuard } from './guard'
import { basicRoutes, EMPTY_ROUTE, NOT_FOUND_ROUTE } from './routes'
import { getToken, isNullOrWhitespace } from '@/utils'
import { usePermissionStore } from '@/store'
const isHash = false
export const router = createRouter({
history: isHash ? createWebHashHistory('/') : createWebHistory('/'),
routes: basicRoutes,
scrollBehavior: () => ({ left: 0, top: 0 }),
})
export async function setupRouter(app) {
await addDynamicRoutes()
setupRouterGuard(router)
app.use(router)
}
export async function addDynamicRoutes() {
const token = getToken()
// 没有token情况
if (isNullOrWhitespace(token)) {
router.addRoute(EMPTY_ROUTE)
return
}
// 有token的情况
try {
const permissionStore = usePermissionStore()
const accessRoutes = permissionStore.generateRoutes()
// 确保路由按正确顺序添加
accessRoutes.forEach((route) => {
if (!router.hasRoute(route.name)) {
router.addRoute(route)
}
})
// 移除空路由添加404路由
if (router.hasRoute(EMPTY_ROUTE.name)) {
router.removeRoute(EMPTY_ROUTE.name)
}
router.addRoute(NOT_FOUND_ROUTE)
// 确保根路径重定向到工作台
// if (!router.hasRoute('workbench')) {
// const workbenchRoute = {
// name: 'workbench',
// path: '/',
// component: () => import('@/views/workbench/index.vue'),
// redirect: '/workbench',
// // children: [
// // {
// // name: 'Workbench',
// // path: 'workbench',
// // component: () => import('@/views/workbench/index.vue'),
// // meta: {
// // title: '工作台',
// // icon: 'mdi:index',
// // order: 0,
// // },
// // },
// // ],
// }
// router.addRoute(workbenchRoute)
// }
// console.log(router)
} catch (error) {
console.error(error)
throw error
}
}
export function getRouteNames(routes) {
return routes.map((route) => getRouteName(route)).flat(1)
}
function getRouteName(route) {
const names = [route.name]
if (route.subMenu && route.subMenu.length) {
names.push(...route.subMenu.map((item) => getRouteName(item)).flat(1))
}
return names
}

View File

@@ -0,0 +1,31 @@
export const basicRoutes = [
{
name: '404',
path: '/404',
component: () => import('@/views/error-page/404.vue'),
isHidden: true,
},
{
name: 'Login',
path: '/login',
component: () => import('@/views/login/index.vue'),
isHidden: true,
meta: {
title: '登录页',
},
},
]
export const NOT_FOUND_ROUTE = {
name: 'NotFound',
path: '/:pathMatch(.*)*',
redirect: '/404',
isHidden: true,
}
export const EMPTY_ROUTE = {
name: 'Empty',
path: '/:pathMatch(.*)*',
component: null,
}

7
src/store/index.js Normal file
View File

@@ -0,0 +1,7 @@
import { createPinia } from 'pinia'
export function setupStore(app) {
app.use(createPinia())
}
export * from './modules'

View File

@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { useDark } from '@vueuse/core'
const isDark = useDark()
export const useAppStore = defineStore('app', {
state() {
return {
collapsed: false,
isDark,
}
},
actions: {
switchCollapsed() {
this.collapsed = !this.collapsed
},
setCollapsed(collapsed) {
this.collapsed = collapsed
},
/** 设置暗黑模式 */
setDark(isDark) {
this.isDark = isDark
},
/** 切换/关闭 暗黑模式 */
toggleDark() {
this.isDark = !this.isDark
},
},
})

View File

@@ -0,0 +1,4 @@
export * from './app'
export * from './permission'
export * from './tags'
export * from './user'

View File

@@ -0,0 +1,132 @@
import { defineStore } from 'pinia'
import { basicRoutes } from '@/router/routes'
import { RouterView } from 'vue-router'
const Layout = () => import('@/layout/index.vue')
// 匹配views里面所有的.vue文件动态引入
const modules = import.meta.glob('/src/views/**/*.vue')
//
export function getModulesKey() {
return Object.keys(modules).map((item) => item.replace('/src/views/', '').replace('.vue', ''))
}
// 动态加载组件
export function loadRouteView(component) {
try {
const key = Object.keys(modules).find((key) => {
return key.includes(`${component}.vue`)
})
if (key) {
return modules[key]
}
throw Error(`找不到组件${component},请确保组件路径正确`)
} catch (error) {
console.error(error)
return RouterView
}
}
// function hasPermission(route, role) {
// // * 不需要权限直接返回true
// if (!route.meta?.requireAuth) return true
// const routeRole = route.meta?.role ? route.meta.role : []
// // * 登录用户没有角色或者路由没有设置角色判定为没有权限
// if (!role.length || !routeRole.length) return false
// // * 路由指定的角色包含任一登录用户角色则判定有权限
// return role.some((item) => routeRole.includes(item))
// }
// 过滤异步路由
function filterAsyncRoutes(routes = [], firstRoute = true) {
const ret = []
routes.forEach((route) => {
// 过滤掉type为3的路由
if (route.type === 3) return
const isHidden = route.is_show === 1 ? false : true
const meta = {
requireAuth: true,
title: route.name,
icon: route.icon,
order: route.sort,
}
const curRoute = {
path: route.route,
name: route.route,
isHidden,
meta,
children: [],
}
// if (route.route === '/' && firstRoute) {
// curRoute['redirect'] = route.subMenu[0].route
// } else if (route.subMenu && route.type === 1) {
// curRoute['redirect'] = `${route.subMenu[0].route}`
// }
if (route.subMenu && route.subMenu.length) {
curRoute.children = filterAsyncRoutes(route.subMenu, false)
} else {
Reflect.deleteProperty(curRoute, 'children')
}
switch (route.type) {
case 1:
curRoute.component = firstRoute ? Layout : RouterView
break
case 2:
curRoute.component = loadRouteView(route.components)
break
}
ret.push(curRoute)
})
return ret
}
// 递归寻找type为3的路由
function findType3Routes(routes = []) {
const ret = []
routes.forEach((route) => {
if (route.type === 3) {
ret.push(route.api_route)
}
if (route.subMenu && route.subMenu.length) {
ret.push(...findType3Routes(route.subMenu))
}
})
return ret
}
export const usePermissionStore = defineStore('permission', {
state() {
return {
accessRoutes: [],
}
},
getters: {
routes() {
return basicRoutes.concat(this.accessRoutes)
},
menus() {
return this.routes.filter((route) => route.name && !route.isHidden)
},
},
actions: {
generateRoutes() {
const menus = JSON.parse(localStorage.getItem('menu'))
const accessRoutes = filterAsyncRoutes(menus)
window.localStorage.setItem('roles', JSON.stringify(findType3Routes(menus)))
this.accessRoutes = accessRoutes
return accessRoutes
},
resetPermission() {
this.$reset()
},
},
})

View File

@@ -0,0 +1,6 @@
import { sStorage } from '@/utils'
export const activeTag = sStorage.get('activeTag')
export const tags = sStorage.get('tags')
export const WITHOUT_TAG_PATHS = ['/404', '/login']

View File

@@ -0,0 +1,83 @@
import { defineStore } from 'pinia'
import { activeTag, tags, WITHOUT_TAG_PATHS } from './helpers'
import { router } from '@/router'
import { sStorage } from '@/utils'
export const useTagsStore = defineStore('tag', {
state() {
return {
tags: tags || [],
activeTag: activeTag || '',
reloading: false,
}
},
getters: {
activeIndex() {
return this.tags.findIndex((item) => item.path === this.activeTag)
},
},
actions: {
setActiveTag(path) {
this.activeTag = path
sStorage.set('activeTag', path)
},
setTags(tags) {
this.tags = tags
sStorage.set('tags', tags)
},
addTag(tag = {}) {
if (WITHOUT_TAG_PATHS.includes(tag.path)) return
let findItem = this.tags.find((item) => item.path === tag.path)
if (findItem) findItem = tag
else this.setTags([...this.tags, tag])
this.setActiveTag(tag.path)
},
async reloadTag(path, keepAlive) {
const findItem = this.tags.find((item) => item.path === path)
// 更新key可让keepAlive失效
if (findItem && keepAlive) findItem.keepAlive = false
$loadingBar.start()
this.reloading = true
await nextTick()
this.reloading = false
findItem.keepAlive = keepAlive
setTimeout(() => {
document.documentElement.scrollTo({ left: 0, top: 0 })
$loadingBar.finish()
}, 100)
},
removeTag(path) {
this.setTags(this.tags.filter((tag) => tag.path !== path))
if (path === this.activeTag) {
router.push(this.tags[this.tags.length - 1].path)
}
},
removeOther(curPath = this.activeTag) {
this.setTags(this.tags.filter((tag) => tag.path === curPath))
if (curPath !== this.activeTag) {
router.push(this.tags[this.tags.length - 1].path)
}
},
removeLeft(curPath) {
const curIndex = this.tags.findIndex((item) => item.path === curPath)
const filterTags = this.tags.filter((item, index) => index >= curIndex)
this.setTags(filterTags)
if (!filterTags.find((item) => item.path === this.activeTag)) {
router.push(filterTags[filterTags.length - 1].path)
}
},
removeRight(curPath) {
const curIndex = this.tags.findIndex((item) => item.path === curPath)
const filterTags = this.tags.filter((item, index) => index <= curIndex)
this.setTags(filterTags)
if (!filterTags.find((item) => item.path === this.activeTag)) {
router.push(filterTags[filterTags.length - 1].path)
}
},
resetTags() {
this.setTags([])
this.setActiveTag('')
},
},
})

View File

@@ -0,0 +1,52 @@
import { defineStore } from 'pinia'
// import { resetRouter } from '@/router'
import { useTagsStore, usePermissionStore } from '@/store'
import { removeToken, toLogin } from '@/utils'
// import api from '@/api'
export const useUserStore = defineStore('user', {
state() {
return {
userInfo: {},
}
},
getters: {
userId() {
return this.userInfo?.id
},
name() {
return this.userInfo?.name
},
avatar() {
return this.userInfo?.avatar || 'https://v2.xxapi.cn/api/head?return=302'
},
role() {
return this.userInfo?.role || []
},
},
actions: {
async getUserInfo() {
// try {
// const res = await api.getUser()
// const { id, name, avatar, role } = res.data
// this.userInfo = { id, name, avatar, role }
// return Promise.resolve(res.data)
// } catch (error) {
// return Promise.reject(error)
// }
},
async logout() {
const { resetTags } = useTagsStore()
const { resetPermission } = usePermissionStore()
removeToken()
resetTags()
resetPermission()
// resetRouter()
this.$reset()
toLogin()
},
setUserInfo(userInfo = {}) {
this.userInfo = { ...this.userInfo, ...userInfo }
},
},
})

74
src/styles/global.scss Normal file
View File

@@ -0,0 +1,74 @@
html,
body {
width: 100%;
height: 100%;
overflow: hidden;
}
html {
font-size: 4px; // * 1rem = 4px 方便unocss计算在unocss中 1字体单位 = 0.25rem,相当于 1等份 = 1px
}
body {
font-size: 16px;
}
#app {
width: 100%;
height: 100%;
}
/* transition fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.fade-slide-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 自定义滚动条样式 */
.cus-scroll {
overflow: auto;
&::-webkit-scrollbar {
width: 8px;
height: 8px;
}
}
.cus-scroll-x {
overflow-x: auto;
&::-webkit-scrollbar {
width: 0;
height: 8px;
}
}
.cus-scroll-y {
overflow-y: auto;
&::-webkit-scrollbar {
width: 8px;
height: 0;
}
}
.cus-scroll,
.cus-scroll-x,
.cus-scroll-y {
&::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 4px;
}
&:hover {
&::-webkit-scrollbar-thumb {
background: #bfbfbf;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}
}
}

35
src/styles/reset.css Normal file
View File

@@ -0,0 +1,35 @@
html {
box-sizing: border-box;
}
*,
::before,
::after {
margin: 0;
padding: 0;
box-sizing: inherit;
}
a {
text-decoration: none;
color: inherit;
}
a:hover,
a:link,
a:visited,
a:active {
text-decoration: none;
}
ol,
ul {
list-style: none;
}
input,
textarea {
outline: none;
border: none;
resize: none;
}

61
src/utils/api-config.js Normal file
View File

@@ -0,0 +1,61 @@
// 判断是否为开发环境
const isDev = import.meta.env.DEV
// API接口线路配置管理
export const API_ENDPOINTS = {
primary: {
name: '主要线路',
baseURL: import.meta.env.VITE_BASE_API,
timeout: 10000,
},
backup1: {
name: '备用线路',
baseURL: import.meta.env.VITE_BASE_API_1,
timeout: 10000,
},
}
// 调试信息
console.log('=== 多接口配置信息 ===')
console.log('当前环境:', isDev ? '开发环境' : '生产环境')
console.log('API配置:', API_ENDPOINTS)
console.log('环境变量 VITE_BASE_API:', import.meta.env.VITE_BASE_API)
console.log('环境变量 VITE_BASE_API_1:', import.meta.env.VITE_BASE_API_1)
console.log('是否使用代理:', import.meta.env.VITE_USE_PROXY)
// 当前使用的接口线路
let currentEndpoint = 'primary'
// 获取当前接口配置
export function getCurrentEndpoint() {
return {
key: currentEndpoint,
...API_ENDPOINTS[currentEndpoint],
}
}
// 设置当前接口
export function setCurrentEndpoint(endpoint) {
if (API_ENDPOINTS[endpoint]) {
currentEndpoint = endpoint
localStorage.setItem('api_endpoint', endpoint)
return true
}
return false
}
// 获取所有可用接口
export function getAvailableEndpoints() {
return Object.entries(API_ENDPOINTS).map(([key, config]) => ({
key,
...config,
}))
}
// 初始化时从本地存储恢复接口选择
export function initApiEndpoint() {
const savedEndpoint = localStorage.getItem('api_endpoint')
if (savedEndpoint && API_ENDPOINTS[savedEndpoint]) {
currentEndpoint = savedEndpoint
}
}

17
src/utils/auth/auth.js Normal file
View File

@@ -0,0 +1,17 @@
import { router } from '@/router'
export function toLogin() {
const currentRoute = unref(router.currentRoute)
const needRedirect =
!currentRoute.meta.requireAuth && !['/404', '/login'].includes(router.currentRoute.value.path)
router.replace({
path: '/login',
query: needRedirect ? { ...currentRoute.query, redirect: currentRoute.path } : {},
})
}
export function toFourZeroFour() {
router.replace({
path: '/404',
})
}

2
src/utils/auth/index.js Normal file
View File

@@ -0,0 +1,2 @@
export * from './auth'
export * from './token'

33
src/utils/auth/token.js Normal file
View File

@@ -0,0 +1,33 @@
import { lStorage } from '@/utils'
import api from '@/api'
const TOKEN_CODE = 'access_token'
const DURATION = 6 * 60 * 60
export function getToken() {
return lStorage.get(TOKEN_CODE)
}
export function setToken(token) {
lStorage.set(TOKEN_CODE, token, DURATION)
}
export function removeToken() {
lStorage.remove(TOKEN_CODE)
}
export async function refreshAccessToken() {
const tokenItem = lStorage.getItem(TOKEN_CODE)
if (!tokenItem) {
return
}
const { time } = tokenItem
// token生成或者刷新后30分钟内不执行刷新
if (new Date().getTime() - time <= 1000 * 60 * 30) return
try {
const res = await api.refreshToken()
setToken(res.data.token)
} catch (error) {
console.error(error)
}
}

View File

@@ -0,0 +1,90 @@
import dayjs from 'dayjs'
/**
* @desc 格式化时间
* @param {(Object|string|number)} time
* @param {string} format
* @returns {string | null}
*/
export function formatDateTime(time = undefined, format = 'YYYY-MM-DD HH:mm:ss') {
return dayjs(time).format(format)
}
export function formatDate(date = undefined, format = 'YYYY-MM-DD') {
return formatDateTime(date, format)
}
/**
* @desc 函数节流
* @param {Function} fn
* @param {Number} wait
* @returns {Function}
*/
export function throttle(fn, wait) {
var context, args
var previous = 0
return function () {
var now = +new Date()
context = this
args = arguments
if (now - previous > wait) {
fn.apply(context, args)
previous = now
}
}
}
/**
* @desc 函数防抖
* @param {Function} func
* @param {number} wait
* @param {boolean} immediate
* @return {*}
*/
export function debounce(method, wait, immediate) {
let timeout
return function (...args) {
let context = this
if (timeout) {
clearTimeout(timeout)
}
// 立即执行需要两个条件一是immediate为true二是timeout未被赋值或被置为null
if (immediate) {
/**
* 如果定时器不存在则立即执行并设置一个定时器wait毫秒后将定时器置为null
* 这样确保立即执行后wait毫秒内不会被再次触发
*/
let callNow = !timeout
timeout = setTimeout(() => {
timeout = null
}, wait)
if (callNow) {
method.apply(context, args)
}
} else {
// 如果immediate为false则函数wait毫秒后执行
timeout = setTimeout(() => {
/**
* args是一个类数组对象所以使用fn.apply
* 也可写作method.call(context, ...args)
*/
method.apply(context, args)
}, wait)
}
}
}
/**
*
* @param {HTMLElement} el
* @param {Function} cb
* @return {ResizeObserver}
*/
export function useResize(el, cb) {
const observer = new ResizeObserver((entries) => {
cb(entries[0].contentRect)
})
observer.observe(el)
return observer
}

12
src/utils/common/icon.js Normal file
View File

@@ -0,0 +1,12 @@
import { h } from 'vue'
import { Icon } from '@iconify/vue'
import { NIcon } from 'naive-ui'
import SvgIcon from '@/components/icon/SvgIcon.vue'
export function renderIcon(icon, props = { size: 12 }) {
return () => h(NIcon, props, { default: () => h(Icon, { icon }) })
}
export function renderCustomIcon(icon, props = { size: 12 }) {
return () => h(NIcon, props, { default: () => h(SvgIcon, { icon }) })
}

View File

@@ -0,0 +1,4 @@
export * from './common'
export * from './is'
export * from './icon'
export * from './naiveTools'

119
src/utils/common/is.js Normal file
View File

@@ -0,0 +1,119 @@
const toString = Object.prototype.toString
export function is(val, type) {
return toString.call(val) === `[object ${type}]`
}
export function isDef(val) {
return typeof val !== 'undefined'
}
export function isUndef(val) {
return typeof val === 'undefined'
}
export function isNull(val) {
return val === null
}
export function isWhitespace(val) {
return val === ''
}
export function isObject(val) {
return !isNull(val) && is(val, 'Object')
}
export function isArray(val) {
return val && Array.isArray(val)
}
export function isString(val) {
return is(val, 'String')
}
export function isNumber(val) {
return is(val, 'Number')
}
export function isBoolean(val) {
return is(val, 'Boolean')
}
export function isDate(val) {
return is(val, 'Date')
}
export function isRegExp(val) {
return is(val, 'RegExp')
}
export function isFunction(val) {
return typeof val === 'function'
}
export function isPromise(val) {
return is(val, 'Promise') && isObject(val) && isFunction(val.then) && isFunction(val.catch)
}
export function isElement(val) {
return isObject(val) && !!val.tagName
}
export function isWindow(val) {
return typeof window !== 'undefined' && isDef(window) && is(val, 'Window')
}
export function isNullOrUndef(val) {
return isNull(val) || isUndef(val)
}
export function isNullOrWhitespace(val) {
return isNullOrUndef(val) || isWhitespace(val)
}
/** 空数组 | 空字符串 | 空对象 | 空Map | 空Set */
export function isEmpty(val) {
if (isArray(val) || isString(val)) {
return val.length === 0
}
if (val instanceof Map || val instanceof Set) {
return val.size === 0
}
if (isObject(val)) {
return Object.keys(val).length === 0
}
return false
}
/**
* * 类似mysql的IFNULL函数
* * 第一个参数为null/undefined/'' 则返回第二个参数作为备用值,否则返回第一个参数
* @param {Number|Boolean|String} val
* @param {Number|Boolean|String} def
* @returns
*/
export function ifNull(val, def = '') {
return isNullOrWhitespace(val) ? def : val
}
export function isUrl(path) {
const reg =
/(((^https?:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)$/
return reg.test(path)
}
/**
* @param {string} path
* @returns {Boolean}
*/
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}
export const isServer = typeof window === 'undefined'
export const isClient = !isServer

View File

@@ -0,0 +1,99 @@
import * as NaiveUI from 'naive-ui'
import { isNullOrUndef } from '@/utils'
import { naiveThemeOverrides as themeOverrides } from '~/settings'
import { useAppStore } from '@/store/modules/app'
export function setupMessage(NMessage) {
let loadingMessage = null
class Message {
/**
* 规则:
* * loading message只显示一个新的message会替换正在显示的loading message
* * loading message不会自动清除除非被替换成非loading message非loading message默认2秒后自动清除
*/
removeMessage(message = loadingMessage, duration = 2000) {
setTimeout(() => {
if (message) {
message.destroy()
message = null
}
}, duration)
}
showMessage(type, content, option = {}) {
if (loadingMessage && loadingMessage.type === 'loading') {
// 如果存在则替换正在显示的loading message
loadingMessage.type = type
loadingMessage.content = content
if (type !== 'loading') {
// 非loading message需设置自动清除
this.removeMessage(loadingMessage, option.duration)
}
} else {
// 不存在正在显示的loading则新建一个message,如果新建的message是loading message则将message赋值存储下来
let message = NMessage[type](content, option)
if (type === 'loading') {
loadingMessage = message
}
}
}
loading(content) {
this.showMessage('loading', content, { duration: 0 })
}
success(content, option = {}) {
this.showMessage('success', content, option)
}
error(content, option = {}) {
this.showMessage('error', content, option)
}
info(content, option = {}) {
this.showMessage('info', content, option)
}
warning(content, option = {}) {
this.showMessage('warning', content, option)
}
}
return new Message()
}
export function setupDialog(NDialog) {
NDialog.confirm = function (option = {}) {
const showIcon = !isNullOrUndef(option.title)
return NDialog[option.type || 'warning']({
showIcon,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: option.confirm,
onNegativeClick: option.cancel,
onMaskClick: option.cancel,
...option,
})
}
return NDialog
}
export function setupNaiveDiscreteApi() {
const appStore = useAppStore()
const configProviderProps = computed(() => ({
theme: appStore.isDark ? NaiveUI.darkTheme : undefined,
themeOverrides,
}))
const { message, dialog, notification, loadingBar } = NaiveUI.createDiscreteApi(
['message', 'dialog', 'notification', 'loadingBar'],
{ configProviderProps }
)
window.$loadingBar = loadingBar
window.$notification = notification
window.$message = setupMessage(message)
window.$dialog = setupDialog(dialog)
}

35
src/utils/http/helpers.js Normal file
View File

@@ -0,0 +1,35 @@
import { useUserStore } from '@/store'
export function addBaseParams(params) {
if (!params.userId) {
params.userId = useUserStore().userId
}
}
export function resolveResError(code, message) {
switch (code) {
case 400:
message = message ?? '请求参数错误'
break
case 401:
message = message ?? '登录已过期'
useUserStore().logout()
break
case 403:
message = message ?? '没有权限'
break
case 404:
message = message ?? '资源或接口不存在'
break
case 500:
message = message ?? '服务器异常'
break
case 402:
message = message ?? '无权限访问'
break
default:
message = message ?? `${code}】: 未知异常!`
break
}
return message
}

107
src/utils/http/index.js Normal file
View File

@@ -0,0 +1,107 @@
import axios from 'axios'
import { resReject, resResolve, reqReject, reqResolve } from './interceptors'
import { getCurrentEndpoint, getAvailableEndpoints } from '../api-config'
export function createAxios(options = {}) {
const defaultOptions = {
timeout: 12000,
}
const service = axios.create({
...defaultOptions,
...options,
})
service.interceptors.request.use(reqResolve, reqReject)
service.interceptors.response.use(resResolve, resReject)
return service
}
// 创建支持多接口的请求实例
export function createMultiEndpointRequest() {
let instances = null
// 延迟创建axios实例
function ensureInstances() {
if (!instances) {
instances = {}
const endpoints = getAvailableEndpoints()
console.log('创建axios实例接口列表:', endpoints)
endpoints.forEach((endpoint) => {
console.log(`创建实例 ${endpoint.key}:`, endpoint.baseURL)
instances[endpoint.key] = createAxios({
baseURL: endpoint.baseURL,
timeout: endpoint.timeout,
})
})
}
return instances
}
return {
// 使用当前选中的接口发送请求
async request(config) {
const instances = ensureInstances()
const currentEndpoint = getCurrentEndpoint()
console.log('当前接口配置:', currentEndpoint)
console.log('可用实例:', Object.keys(instances))
const instance = instances[currentEndpoint.key]
if (!instance) {
throw new Error(`接口实例不存在: ${currentEndpoint.key}`)
}
console.log(`使用接口: ${currentEndpoint.name} (${currentEndpoint.baseURL})`)
console.log('请求配置:', config)
return await instance(config)
},
// 获取当前接口实例
getCurrentInstance() {
const instances = ensureInstances()
const currentEndpoint = getCurrentEndpoint()
return instances[currentEndpoint.key]
},
// 获取所有接口实例
getAllInstances() {
return ensureInstances()
},
}
}
// export const request = createAxios({
// baseURL: import.meta.env.VITE_BASE_API,
// })
// 支持多接口的请求实例
export const multiRequest = createMultiEndpointRequest()
// 创建自动适配多接口的request实例
const multiEndpointInstance = createMultiEndpointRequest()
// 创建支持axios方法的request实例
export const request = {
// 基础请求方法
request: (config) => multiEndpointInstance.request(config),
// 自动适配axios方法
get: (url, config = {}) => multiEndpointInstance.request({ method: 'get', url, ...config }),
post: (url, data, config = {}) =>
multiEndpointInstance.request({ method: 'post', url, data, ...config }),
put: (url, data, config = {}) =>
multiEndpointInstance.request({ method: 'put', url, data, ...config }),
delete: (url, config = {}) => multiEndpointInstance.request({ method: 'delete', url, ...config }),
patch: (url, data, config = {}) =>
multiEndpointInstance.request({ method: 'patch', url, data, ...config }),
head: (url, config = {}) => multiEndpointInstance.request({ method: 'head', url, ...config }),
options: (url, config = {}) =>
multiEndpointInstance.request({ method: 'options', url, ...config }),
// 获取当前接口实例用于直接访问axios实例
getCurrentInstance: () => multiEndpointInstance.getCurrentInstance(),
getAllInstances: () => multiEndpointInstance.getAllInstances(),
}

View File

@@ -0,0 +1,63 @@
import { getToken } from '@/utils'
import { resolveResError } from './helpers'
export function reqResolve(config) {
// if (config.url.includes('/admin')) {
// config.baseURL = import.meta.env.VITE_ADMIN_API
// } else {
// config.baseURL = import.meta.env.VITE_BASE_API
// }
// 处理不需要token的请求
if (config.noNeedToken) {
return config
}
const token = getToken()
if (!token) {
return Promise.reject({ code: 401, message: '登录已过期,请重新登录!' })
}
/**
* * 加上 token
* ! 认证方案: JWT Bearer
*/
config.headers.Authorization = config.headers.Authorization || 'Bearer ' + token
return config
}
export function reqReject(error) {
return Promise.reject(error)
}
export function resResolve(response) {
// TODO: 处理不同的 response.headers
const { data, status, config, statusText } = response
if (data?.code !== 200) {
const code = data?.code ?? status
/** 根据code处理对应的操作并返回处理后的message */
const message = resolveResError(code, data?.msg ?? statusText)
/** 需要错误提醒 */
!config.noNeedTip && window.$message?.error(message)
return Promise.reject({ code, message, error: data || response })
}
return Promise.resolve(data)
}
export function resReject(error) {
if (!error || !error.response) {
const code = error?.code
/** 根据code处理对应的操作并返回处理后的message */
const message = resolveResError(code, error.message)
window.$message?.error(message)
return Promise.reject({ code, message, error })
}
const { data, status, config } = error.response
const code = data?.code ?? status
const message = resolveResError(code, data?.message ?? error.message)
/** 需要错误提醒 */
!config?.noNeedTip && window.$message?.error(message)
return Promise.reject({ code, message, error: error.response?.data || error.response })
}

4
src/utils/index.js Normal file
View File

@@ -0,0 +1,4 @@
export * from './common'
export * from './storage'
export * from './http'
export * from './auth'

View File

@@ -0,0 +1,21 @@
import { createStorage } from './storage'
const prefixKey = 'Vue_Naive_Admin_'
export const createLocalStorage = function (option = {}) {
return createStorage({
prefixKey: option.prefixKey || '',
storage: localStorage,
})
}
export const createSessionStorage = function (option = {}) {
return createStorage({
prefixKey: option.prefixKey || '',
storage: sessionStorage,
})
}
export const lStorage = createLocalStorage({ prefixKey })
export const sStorage = createSessionStorage({ prefixKey })

View File

@@ -0,0 +1,55 @@
import { isNullOrUndef } from '@/utils'
class Storage {
constructor(option) {
this.storage = option.storage
this.prefixKey = option.prefixKey
}
getKey(key) {
return `${this.prefixKey}${key}`.toUpperCase()
}
set(key, value, expire) {
const stringData = JSON.stringify({
value,
time: Date.now(),
expire: !isNullOrUndef(expire) ? new Date().getTime() + expire * 1000 : null,
})
this.storage.setItem(this.getKey(key), stringData)
}
get(key) {
const { value } = this.getItem(key, {})
return value
}
getItem(key, def = null) {
const val = this.storage.getItem(this.getKey(key))
if (!val) return def
try {
const data = JSON.parse(val)
const { value, time, expire } = data
if (isNullOrUndef(expire) || expire > new Date().getTime()) {
return { value, time }
}
this.remove(key)
return def
} catch (error) {
this.remove(key)
return def
}
}
remove(key) {
this.storage.removeItem(this.getKey(key))
}
clear() {
this.storage.clear()
}
}
export function createStorage({ prefixKey = '', storage = sessionStorage }) {
return new Storage({ prefixKey, storage })
}

View File

@@ -0,0 +1,6 @@
import { request } from '@/utils'
export default {
getList: (data) => request.post('/store/classify', data),
addClass: (data) => request.post('/store/classify/edit', data),
}

View File

@@ -0,0 +1,261 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-button v-perms="['/admin/store/classify/edit']" type="primary" @click="handleAdd(1)">
新增商户分类
</n-button>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
:row-key="rowKey"
children-key="Classify"
/>
<n-modal v-model:show="showModal">
<n-card
style="width: 400px"
:title="modelTitle"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<!-- {{ formValue }} -->
<n-form ref="formRef" label-placement="left" :model="formValue" :rules="rules">
<n-form-item label="上级分类:" path="sid">
<n-select
v-model:value="formValue.sid"
:options="options"
placeholder="请选择上级分类"
/>
</n-form-item>
<n-form-item label="分类图标:" path="icon">
<Upload v-model:list="formValue.icon" />
</n-form-item>
<n-form-item label="分类名称:" path="name">
<n-input v-model:value="formValue.name" placeholder="请输入商户分类名称" />
</n-form-item>
<n-form-item label="分类状态:" path="status">
<n-switch v-model:value="formValue.status" :checked-value="1" :unchecked-value="2" />
</n-form-item>
<n-form-item>
<div class="m-auto">
<n-button
class="m-auto"
type="primary"
attr-type="button"
@click="handleValidateClick"
>
提交
</n-button>
<n-button class="ml-10" @click="clear">取消</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import { onMounted, h, withDirectives, resolveDirective } from 'vue'
import api from './api'
import { NButton } from 'naive-ui'
import Upload from '@/components/Upload.vue'
const vPerms = resolveDirective('perms')
const loading = ref(false)
const rowKey = (row) => {
return row.Classify || []
}
const columns = ref([
{
title: 'ID',
align: 'center',
key: 'ID',
},
{
title: '分类名称',
align: 'center',
key: 'name',
},
{
title: '状态',
align: 'center',
slot: 'status',
render(row) {
return h('span', row.status === 1 ? '正常' : '禁用')
},
},
{
title: '操作',
align: 'center',
slot: 'action',
render(row) {
return [
withDirectives(
h(
NButton,
{
type: 'primary',
size: 'small',
onClick: () => {
formValue.value = {
...row,
icon:
row.icon.length > 0
? [
{
url: row.icon,
name: '图片',
status: 'finished',
},
]
: [],
}
handleAdd(2)
},
},
() => '编辑'
),
[[vPerms, ['/admin/store/classify/edit']]]
),
]
},
},
])
const options = ref([])
const data = ref([])
const formRef = ref(null)
const rules = {
name: {
required: true,
message: '请输入商户分类名称',
},
icon: {
required: true,
type: 'array',
message: '请上传分类图标',
},
sid: {
required: true,
type: 'number',
message: '请选择分类',
},
}
const formValue = ref({
sid: null,
name: '',
status: 1,
icon: [],
})
const showModal = ref(false)
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
onUpdatePageSize: (pageSize) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getList()
},
})
onMounted(() => {
getList()
getOptions()
})
const getList = async () => {
loading.value = true
try {
const res = await api.getList({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data
pagination.value.itemCount = res.data.total
} catch (error) {
$message.error(error.msg)
}
loading.value = false
}
const getOptions = async () => {
const res = await api.getList({
pageNum: 1,
pageSize: 9999999999,
})
options.value = [
{
label: '顶级分类',
value: 0,
},
]
res.data.data.forEach((item) => {
options.value.push({
label: item.name,
value: item.ID,
})
})
}
const modelTitle = ref('')
const handleAdd = (e) => {
modelTitle.value = e === 1 ? '新增商户分类' : '编辑商户分类'
showModal.value = true
}
const clear = () => {
formValue.value = {
sid: 0,
name: '',
status: 1,
icon: [],
}
showModal.value = false
}
const handleValidateClick = async (e) => {
e.preventDefault()
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
console.log(formValue.value)
await api.addClass({
...formValue.value,
icon: formValue.value.icon[0].url,
})
$message.success('成功')
clear()
getList()
getOptions()
} catch (error) {
$message.error(error.msg)
}
} else {
$message.error('Invalid')
}
})
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,11 @@
import { request } from '@/utils'
export default {
getList: (data) => request.post('/store', data),
addMer: (data) => request.post('/store/edit', data),
getMerType: () => request.post('/store/getOther'),
// 一键登录
login: (data) => request.post('/store/easy/login', data),
// 退积分
outJf: (data) => request.post('/store/set/integral', data),
}

View File

@@ -0,0 +1,588 @@
<template>
<CommonPage show-footer :title="$route.title">
<!-- {{ formValue }} -->
<n-button v-perms="['/store/edit']" type="primary" @click="handleAdd(1)">新增商户</n-button>
<n-grid class="mb-10" x-gap="12" cols="6" collapsed>
<n-gi>
<div class="flex items-center">
<div class="w-150">商户名称</div>
<n-input v-model:value="QuryVal.StoreName" type="text" placeholder="请输入商户名称" />
</div>
</n-gi>
<n-gi>
<div class="flex items-center">
<div class="w-150">商户状态</div>
<n-select
v-model:value="QuryVal.Status"
placeholder="请选择商户状态"
clearable
:options="[
{
label: '正常',
value: 1,
},
{
label: '禁用',
value: 2,
},
]"
/>
</div>
</n-gi>
<n-gi>
<n-button type="primary" @click="getList">查询</n-button>
<n-button class="ml10" @click="clearQuryVal">重置</n-button>
</n-gi>
</n-grid>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<n-drawer v-model:show="showModal" :width="502" placement="right">
<n-drawer-content :title="drawerTitle" closable>
<n-form
ref="formRef"
label-placement="left"
label-align="left"
label-width="120px"
:model="formValue"
:rules="rules"
size="medium"
>
<n-form-item label="商户名称:" path="name">
<n-input
v-model:value="formValue.name"
:disabled="isEdit"
placeholder="请输入商户名称"
/>
</n-form-item>
<n-form-item label="负责人姓名:" path="username">
<n-input
v-model:value="formValue.username"
:disabled="isEdit"
placeholder="请输入负责人姓名"
/>
</n-form-item>
<n-form-item label="商户手机号:" path="phone">
<n-input
v-model:value="formValue.phone"
:disabled="isEdit"
placeholder="请输入商户手机号"
/>
</n-form-item>
<n-form-item label="商户座机:" path="mobile">
<n-input v-model:value="formValue.mobile" placeholder="请输入商户座机" />
</n-form-item>
<n-form-item label="商户地址:" path="address">
<n-input v-model:value="formValue.address" placeholder="请输入商户地址" />
</n-form-item>
<n-form-item label="经营类目:" path="store_class_id">
<n-select
v-model:value="formValue.store_class_id"
label-field="name"
value-field="ID"
clearable
placeholder="请选择经营类目"
:options="classOptions"
/>
</n-form-item>
<n-form-item v-if="!isEdit" label="商户密码:" path="password">
<n-input v-model:value="formValue.password" placeholder="请输入商户密码" />
</n-form-item>
<n-form-item v-else label="修改密码:" path="password">
<n-input v-model:value="formValue.password" placeholder="不修改密码请留空" />
</n-form-item>
<!-- <n-form-item label="商户类型:" path="bType">-->
<!-- <n-select-->
<!-- v-model:value="formValue.bType"-->
<!-- label-field="name"-->
<!-- value-field="ID"-->
<!-- placeholder="请选择商户类型"-->
<!-- clearable-->
<!-- :options="typeOptions"-->
<!-- />-->
<!-- </n-form-item>-->
<n-form-item label="手续费收取类型:" path="scaleType">
<n-select
v-model:value="formValue.scaleType"
placeholder="请选择手续费收取类型"
clearable
:options="[
{
label: '百分比',
value: 1,
},
{
label: '数值',
value: 2,
},
]"
/>
</n-form-item>
<n-form-item label="手续费比例:" path="scale">
<n-input-number v-model:value="formValue.scale" placeholder="请输入手续费比例" />
</n-form-item>
<n-form-item label="提现额度:" path="withdraw_amount">
<n-input-number
v-model:value="formValue.withdraw_amount"
:step="1000"
placeholder="请输入提现额度"
/>
</n-form-item>
<n-form-item label="兑换额度:" path="exchange_amount">
<n-input-number
v-model:value="formValue.exchange_amount"
:step="1000"
placeholder="请输入兑换额度"
/>
</n-form-item>
<!-- <n-form-item label="聚合积分额度:" path="quota">
<n-input-number v-model:value="formValue.quota" placeholder="请输入聚合积分额度" />
</n-form-item>
<n-form-item label="聚合兑换比例:" path="ratio">
<n-input-number
v-model:value="formValue.ratio"
placeholder="请输入聚合兑换比例"
:precision="2"
/>
</n-form-item> -->
<!-- <n-form-item label="聚合appid:" path="appid">
<n-input v-model:value="formValue.appid" placeholder="请输入聚合appid" />
</n-form-item>
<n-form-item label="聚合appKey:" path="appkey">
<n-input v-model:value="formValue.appkey" placeholder="请输入聚合appKey" />
</n-form-item>
<n-form-item label="聚合查询接口:" path="check_url">
<n-input v-model:value="formValue.check_url" placeholder="请输入聚合查询接口" />
</n-form-item>
<n-form-item label="聚合扣除接口:" path="edit_url">
<n-input v-model:value="formValue.edit_url" placeholder="请输入聚合扣除接口" />
</n-form-item> -->
<n-form-item label="商户状态:" path="status">
<n-switch v-model:value="formValue.status" :checked-value="1" :unchecked-value="2" />
</n-form-item>
<n-form-item label="商户序号:" path="sort">
<n-input-number
v-model:value="formValue.sort"
placeholder="请输入商户排序序号"
:min="0"
:max="99999999"
/>
</n-form-item>
<n-form-item>
<n-button
class="m-auto w-200"
attr-type="button"
type="primary"
@click="handleValidateClick"
>
提交
</n-button>
<!-- <n-button class="m-auto w-200" @click="handleClearValidateClick">重置</n-button> -->
</n-form-item>
</n-form>
</n-drawer-content>
</n-drawer>
<!-- 退积分 -->
<n-modal v-model:show="showModalJf">
<n-card
style="width: 600px"
title="退积分"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form ref="formRefJf" :model="model" :rules="rulesJf" label-placement="left">
<n-grid :cols="24" :x-gap="24">
<n-form-item-gi :span="12" label="商家名称:">
<n-input v-model:value="model.name" disabled placeholder="商家名称" />
</n-form-item-gi>
<n-form-item-gi :span="24" label="退积分:" path="number">
<n-input-number
v-model:value="model.number"
placeholder="请输入积分"
clearable
:precision="3"
/>
</n-form-item-gi>
<n-form-item-gi span="24">
<n-button class="w-100" attr-type="button" type="primary" @click="handleOutClick">
提交
</n-button>
<n-button class="ml-10 w-100" @click="handleClearOutClick">取消</n-button>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import { onMounted, ref, h, withDirectives, resolveDirective } from 'vue'
import { NButton } from 'naive-ui'
import api from './api'
const vPerms = resolveDirective('perms')
const isEdit = computed(() => drawerTitle.value === '编辑商户')
const showModalJf = ref(false)
const formRefJf = ref(null)
const model = ref({
name: '',
bid: null,
number: null,
})
const rulesJf = ref({
number: {
required: true,
type: 'number',
message: '请输入退积分',
trigger: 'blur',
},
})
const handleOutClick = (e) => {
e.preventDefault()
formRefJf.value?.validate(async (errors) => {
if (!errors) {
try {
await api.outJf({
bid: model.value.bid,
number: model.value.number,
})
$message.success('成功')
handleClearOutClick()
await getMertype()
await getList()
showModalJf.value = false
} catch (error) {
$message.error(error.msg)
}
} else {
$message.error('Invalid')
}
})
}
const handleClearOutClick = () => {
formRefJf.value?.restoreValidation()
model.value = {
number: null,
}
showModalJf.value = false
}
const columns = ref([
{
title: '商户名称',
align: 'center',
key: 'name',
},
{
title: '电话',
align: 'center',
key: 'phone',
},
{
title: '状态',
align: 'center',
slot: 'status',
render(row) {
return h('span', row.status === 1 ? '正常' : '禁用')
},
},
{
title: '余额',
align: 'center',
key: 'integral',
},
{
title: '创建时间',
align: 'center',
key: 'add_time',
},
{
title: '操作',
align: 'center',
slot: 'action',
render: (row) => {
return [
withDirectives(
h(
NButton,
{
type: 'primary',
text: true,
size: 'small',
onClick: () => {
formValue.value = { ...row }
Reflect.deleteProperty(formValue.value, 'password')
handleAdd(2)
},
},
() => '编辑'
),
[[vPerms, ['/admin/store/edit']]]
),
// withDirectives(
// h(
// NButton,
// {
// class: 'ml-10',
// type: 'primary',
// text: true,
// size: 'small',
// onClick: () => {
// model.value.name = row.name
// model.value.bid = row.bid
// showModalJf.value = true
// },
// },
// () => '退积分'
// ),
// [[vPerms, ['/admin/store/set/integral']]]
// ),
withDirectives(
h(
NButton,
{
class: 'ml-10',
type: 'primary',
text: true,
size: 'small',
onClick: async () => {
const res = await api.login({
bid: row.bid,
})
window.open(
`${import.meta.env.VITE_MER_LOGIN_URL}?redirect=/workbench&type=${
res.data.type
}&tk=${res.data.token}&api=${localStorage.getItem('api_endpoint')}`
)
},
},
() => '一键登录'
),
[[vPerms, ['/admin/store/easy/login']]]
),
]
},
},
])
const data = ref([])
const loading = ref(false)
const showModal = ref(false)
const formRef = ref(null)
const drawerTitle = ref('新增商户')
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
onUpdatePageSize: (pageSize) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getList()
},
})
const QuryVal = ref({
StoreName: '',
Status: null,
})
let formValue = ref({
name: '',
username: '',
phone: '',
mobile: '',
address: '',
store_class_id: null,
local: '',
password: '',
scaleType: null,
scale: null,
quota: null,
ratio: null,
status: 2,
sort: 0,
withdraw_amount: 0,
exchange_amount: 0,
})
const rules = {
name: {
required: true,
message: '请输入商户名称',
trigger: 'blur',
},
username: {
required: true,
message: '请输入负责人姓名',
trigger: 'blur',
},
phone: {
required: true,
message: '请输入商户手机号',
trigger: 'blur',
},
mobile: {
required: false,
message: '请输入商户座机',
trigger: 'blur',
},
address: {
required: true,
message: '请输入商户地址',
trigger: 'blur',
},
local: {
required: true,
message: '请搜索商户经纬度',
trigger: 'blur',
},
store_class_id: {
required: true,
type: 'number',
message: '请选择经营类目',
trigger: 'change',
},
// password: {
// required: true,
// message: '请输入商户密码',
// trigger: 'blur',
// },
scaleType: {
required: true,
type: 'number',
message: '请选择手续费收取类型',
trigger: 'change',
},
scale: {
required: true,
type: 'number',
message: '请输入手续费比例',
trigger: 'blur',
},
withdraw_amount: {
required: true,
type: 'number',
message: '请输入提现额度',
trigger: 'blur',
},
exchange_amount: {
required: true,
type: 'number',
message: '请输入兑换额度',
trigger: 'blur',
},
status: {
type: 'number',
message: '请选择商户状态',
trigger: 'change',
},
}
onMounted(() => {
getList()
getMertype()
})
const getList = async () => {
loading.value = true
const res = await api.getList({
...QuryVal.value,
PageNum: pagination.value.page,
PageSize: pagination.value.pageSize,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
loading.value = false
}
const classOptions = ref([])
const typeOptions = ref([])
const getMertype = async () => {
const res = await api.getMerType()
classOptions.value = res.data.class
typeOptions.value = res.data.type
}
const clearQuryVal = () => {
QuryVal.value = {
StoreName: '',
Status: null,
}
getList()
}
const handleAdd = (e) => {
drawerTitle.value = e === 1 ? '新增商户' : '编辑商户'
showModal.value = true
}
const handleValidateClick = (e) => {
e.preventDefault()
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
await api.addMer(formValue.value)
$message.success('成功')
handleClearValidateClick()
await getMertype()
await getList()
showModal.value = false
} catch (error) {
$message.error(error.msg)
}
} else {
$message.error('Invalid')
}
})
}
const handleClearValidateClick = () => {
formRef.value?.restoreValidation()
formValue.value = {
name: '',
username: '',
phone: '',
mobile: '',
address: '',
classId: null,
local: '',
password: '',
bType: null,
scaleType: null,
scale: null,
status: 2,
quota: null,
ratio: null,
sort: 0,
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,6 @@
import { request } from '@/utils'
export default {
getMerType: (data) => request.post('/typesof', data),
addMerType: (data) => request.post('/typesof/edit', data),
}

View File

@@ -0,0 +1,189 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-button v-perms="['/admin/typesof/edit']" type="primary" @click="handleAdd(1)">
新增商户类型
</n-button>
<!-- {{ formValue }} -->
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<n-modal v-model:show="showModal">
<n-card
style="width: 400px"
:title="modelTitle"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form ref="formRef" label-placement="left" :model="formValue" :rules="rules">
<n-form-item label="商户类型:" path="name">
<n-input v-model:value="formValue.name" placeholder="请输入商户类型" />
</n-form-item>
<n-form-item label="商户状态:" path="status">
<n-switch v-model:value="formValue.status" :checked-value="1" :unchecked-value="2" />
</n-form-item>
<n-form-item>
<div class="m-auto">
<n-button
class="m-auto"
type="primary"
attr-type="button"
@click="handleValidateClick"
>
提交
</n-button>
<n-button class="ml-10" @click="clear">取消</n-button>
</div>
</n-form-item>
</n-form>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import { onMounted, h, withDirectives, resolveDirective } from 'vue'
import api from './api'
import { NButton } from 'naive-ui'
const vPerms = resolveDirective('perms')
const loading = ref(false)
const columns = ref([
{
title: 'ID',
align: 'center',
key: 'ID',
},
{
title: '商户类型',
align: 'center',
key: 'name',
},
{
title: '状态',
align: 'center',
slot: 'status',
render(row) {
return h('span', row.status === 1 ? '正常' : '禁用')
},
},
{
title: '操作',
align: 'center',
slot: 'action',
render(row) {
return [
withDirectives(
h(
NButton,
{
type: 'primary',
size: 'small',
onClick: () => {
formValue.value = row
handleAdd(2)
},
},
() => '编辑'
),
[[vPerms, ['/admin/typesof/edit']]]
),
]
},
},
])
const data = ref([])
const formRef = ref(null)
const rules = {
name: {
required: true,
message: '请输入商户分类名称',
},
}
const formValue = ref({
name: '',
status: 1,
})
const showModal = ref(false)
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
onUpdatePageSize: (pageSize) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getList()
},
})
onMounted(() => {
getList()
})
const getList = async () => {
loading.value = true
try {
const res = await api.getMerType({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data
pagination.value.itemCount = res.data.total
} catch (error) {
$message.error(error.msg)
}
loading.value = false
}
const modelTitle = ref('')
const handleAdd = (e) => {
modelTitle.value = e === 1 ? '新增商户类型' : '编辑商户类型'
showModal.value = true
}
const clear = () => {
formValue.value = {
name: '',
status: 1,
}
showModal.value = false
}
const handleValidateClick = async (e) => {
e.preventDefault()
formRef.value?.validate(async (errors) => {
if (!errors) {
try {
await api.addMerType(formValue.value)
$message.success('成功')
clear()
getList()
} catch (error) {
$message.error(error.msg)
}
} else {
$message.error('Invalid')
}
})
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,8 @@
import { request } from '@/utils'
export default {
// 获取入驻审核列表
getAuditList: (data) => request.post('/process/store', data),
// 通过审核/不通过
passAudit: (data) => request.post('/process/store/edit', data),
}

View File

@@ -0,0 +1,211 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<!-- 详情 -->
<n-drawer v-model:show="active" :width="502" placement="right">
<n-drawer-content title="商户入驻详情">
<div>
<div>商户名称:{{ nowRow.name }}</div>
<div mt-10>用户姓名:{{ nowRow.username }}</div>
<div mt-10>联系电话:{{ nowRow.phone }}</div>
<div mt-10>开户行:{{ nowRow.bank }}</div>
<div mt-10>银行卡号:{{ nowRow.bank_card }}</div>
<!-- <div mt-10>商户类型:{{ atype.name }}</div>-->
<div mt-10>经营类目:{{ btype.name }}</div>
<div mt-10>
<div>营业执照:</div>
<n-image width="100" :src="nowRow.license" />
</div>
<div mt-10>
<div>法人身份证正面:</div>
<n-image width="100" :src="nowRow.front" />
</div>
<div mt-10>
<div>法人身份证反面:</div>
<n-image width="100" :src="nowRow.back" />
</div>
<div mt-10>
<div>门头照:</div>
<n-image-group>
<n-image
v-for="(item, index) in nowRow.img"
:key="index"
mr-10
width="100"
:src="item"
/>
</n-image-group>
</div>
</div>
<div m-auto w-full flex justify-center>
<n-button mr-20 type="primary" @click="ok">通过</n-button>
<n-button mr-20 type="warning" @click="noOk">不通过</n-button>
<n-button @click="active = false">关闭</n-button>
</div>
</n-drawer-content>
</n-drawer>
</CommonPage>
</template>
<script setup>
import { h, withDirectives, resolveDirective } from 'vue'
import api from './api'
import api1 from '../mer_list/api'
import { NButton } from 'naive-ui'
const vPerms = resolveDirective('perms')
const loading = ref(false)
const nowRow = ref({})
const active = ref(false)
const columns = ref([
{
title: '商户名称',
align: 'center',
key: 'name',
},
{
title: '用户姓名',
align: 'center',
key: 'username',
},
{
title: '联系电话',
align: 'center',
key: 'phone',
},
{
title: '开户银行',
align: 'center',
key: 'bank',
},
{
title: '银行卡号',
align: 'center',
key: 'bank_card',
},
{
title: '操作',
align: 'center',
slot: 'detail',
render: (row) => {
return [
withDirectives(
h(
NButton,
{
type: 'primary',
text: true,
onClick: () => {
nowRow.value = {
...row,
img: row.img.split(','),
}
console.log(nowRow.value)
active.value = true
},
},
{
default: () => '详情',
}
),
[[vPerms, ['/admin/process/store/edit']]]
),
]
},
},
])
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
},
})
onMounted(() => {
getData()
getMertype()
})
const getData = async () => {
loading.value = true
const res = await api.getAuditList({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
loading.value = false
}
const classOptions = ref([])
// const typeOptions = ref([])
const getMertype = async () => {
const res = await api1.getMerType()
classOptions.value = res.data.class
// typeOptions.value = res.data.type
}
// const atype = computed(() => {
// return typeOptions.value.find((item) => {
// if (item.ID === nowRow.value.bType) return item
// })
// })
const btype = computed(() => {
return classOptions.value.find((item) => {
if (item.ID === nowRow.value.store_class_id) return item
})
})
const ok = async () => {
$dialog.warning({
title: '提示',
content: '同意后无法撤销,确认同意吗?',
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
const res = await api.passAudit({
bid: nowRow.value.bid,
status: 1,
})
$message.success(res.msg)
clear()
},
onNegativeClick: () => {
$message.warning('已取消操作')
},
})
}
const noOk = async () => {
const res = await api.passAudit({
bid: nowRow.value.bid,
status: 2,
})
$message.success(res.msg)
clear()
}
const clear = () => {
nowRow.value = {}
active.value = false
getData()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,55 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: '商户管理',
path: '/merchant',
component: Layout,
redirect: '/mer_list',
meta: {
title: '商户管理',
icon: 'mdi:account-multiple',
order: 10,
},
children: [
{
name: 'Merlist',
path: 'mer_list',
component: () => import('./mer_list/index.vue'),
meta: {
title: '商户列表',
icon: 'mdi:account-multiple',
order: 10,
},
},
{
name: 'Classlist',
path: 'mer_class',
component: () => import('./mer_class/index.vue'),
meta: {
title: '商户分类',
icon: 'mdi:account-multiple',
order: 10,
},
},
{
name: 'Mertype',
path: 'mer_type',
component: () => import('./mer_type/index.vue'),
meta: {
title: '商户类型',
icon: 'mdi:account-multiple',
order: 10,
},
},
{
name: 'Merverify',
path: 'mer_verify',
component: () => import('./mer_verify/index.vue'),
meta: {
title: '入驻审核',
icon: 'mdi:account-multiple',
order: 10,
},
},
],
}

View File

@@ -0,0 +1,7 @@
import { request } from '@/utils'
export default {
getHotlist: (data) => request.post('/goods', data),
getHotStatus: (data) => request.post('/goods/process', data),
updateType: (data) => request.post('/goods/edit/activity', data),
}

View File

@@ -0,0 +1,698 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<CommonPage show-footer :title="$route.title">
<!-- {{ queryParams }} -->
<n-grid class="mb-10" x-gap="12">
<n-gi span="12" mt-10 flex items-center>
<span w-100>筛选条件:</span>
<n-input-group>
<n-select
v-model:value="queryParams.selectKey"
:style="{ width: '25%' }"
:options="selectOptions"
placeholder="请选择"
/>
<n-input v-model:value="queryParams.word" :style="{ width: '50%' }" />
</n-input-group>
</n-gi>
<n-gi :span="24" mt-10>
<div>
<span>审核状态</span>
<n-radio-group v-model:value="queryParams.status">
<n-radio-button
v-for="song in [
{
label: '已审核',
value: 1,
},
{
label: '拒绝',
value: 2,
},
{
label: '待审核',
value: 3,
},
]"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-gi>
<n-gi :span="24" mt-10>
<div>
<span>商品类型:</span>
<n-radio-group v-model:value="queryParams.type">
<n-radio-button
v-for="song in [
{
label: '普通商品',
value: 0,
},
{
label: '活动商品',
value: 1,
},
{
label: '积分商品',
value: 2,
},
{
label: '摇球商品',
value: 3,
},
]"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-gi>
<n-gi span="24" mt-10 flex items-center>
<n-button type="primary" @click="getList">查询</n-button>
<n-button ml-10 @click="clear">重置</n-button>
</n-gi>
<n-gi span="24" mt-10 flex items-center>
<n-button strong secondary type="primary" @click="veeifys()">批量审核</n-button>
<n-button strong secondary ml-10 type="primary" @click="changeGoodsType(0)">
设为普通商品
</n-button>
<n-button strong secondary ml-10 type="warning" @click="changeGoodsType(1)">
设为活动商品
</n-button>
<n-button strong secondary ml-10 type="info" @click="changeGoodsType(2)">
设为兑换商品
</n-button>
<n-button strong secondary ml-10 type="error" @click="changeGoodsType(3)">
设为摇球机活动商品
</n-button>
</n-gi>
</n-grid>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
:row-key="(row) => row.gid"
:checked-row-keys="queryParams.checkedRowKeysRef"
remote
@update:checked-row-keys="handleCheck"
/>
<!-- 拒绝 -->
<n-modal v-model:show="isNoteModel">
<n-card
style="width: 500px"
title="拒绝信息"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-input v-model:value="notesVal" type="textarea" placeholder="请输入拒绝理由...." />
<div m-auto p-10>
<n-button type="primary" @click="veeify">确定</n-button>
<n-button ml-10 @click="clear">取消</n-button>
</div>
</n-card>
</n-modal>
<!-- 豆子设置 -->
<n-modal v-model:show="isDzModel">
<n-card
style="width: 500px"
title="赠送/比例"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form ref="formRef" :model="nowRow" :rules="rules" label-placement="left">
<n-grid :cols="24">
<n-form-item-gi :span="20" label="商品赠送豆子" path="pulse">
<n-input-number
v-model:value="nowRow.pulse"
clearable
placeholder="请输入赠送豆子数量...."
:min="0"
/>
</n-form-item-gi>
<n-form-item-gi :span="20" label="商品赠送积分" path="integral">
<n-input-number
v-model:value="nowRow.integral"
clearable
placeholder="请输入赠送积分数量...."
:min="0"
/>
</n-form-item-gi>
<n-form-item-gi :span="18" label="商品分佣类型" path="commission_type">
<n-select
v-model:value="nowRow.commission_type"
placeholder="请选择分佣类型"
clearable
:options="[
{
label: '百分比',
value: 1,
},
{
label: '数值',
value: 2,
},
]"
/>
</n-form-item-gi>
<n-form-item-gi :span="20" label="商品分佣比例" path="commission">
<n-input-number
v-model:value="nowRow.commission"
clearable
placeholder="请输入分佣比例...."
:min="0"
/>
</n-form-item-gi>
<n-form-item-gi :span="20" label="商品抵扣比例" path="discount">
<n-input-number
v-model:value="nowRow.discount"
clearable
placeholder="请输入抵扣比例...."
:min="0"
:precision="0"
>
<template #suffix>%</template>
</n-input-number>
</n-form-item-gi>
<n-form-item-gi :span="20" label="豆子过期时间" path="expiration">
<n-input-number
v-model:value="nowRow.expiration"
clearable
placeholder="请输入豆子过期时间"
:min="0"
/>
</n-form-item-gi>
<n-form-item-gi :span="12">
<div m-auto p-10>
<n-button type="primary" @click="veeify">确定</n-button>
<n-button ml-10 @click="clear">取消</n-button>
</div>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
</n-modal>
<!-- 商品详情 -->
<n-drawer v-model:show="showDrawer" :width="502">
<n-drawer-content title="商品详情" closable>
<n-space vertical>
<div>
<span>商品分类:</span>
<span>{{ goodInfo.class_name }}</span>
</div>
<div>
<span>商品名称:</span>
<span>{{ goodInfo.name }}</span>
</div>
<div flex items-center>
<span>封面:</span>
<n-image width="100" :src="goodInfo.cover" />
</div>
<div flex items-center>
<span>轮播图:</span>
<div w-400 overflow-auto>
<n-image
v-for="(url, index) in goodInfo.rotation?.split(',')"
:key="index"
width="100"
:src="url"
/>
</div>
</div>
<div>
<span>商品价格:</span>
<span>{{ goodInfo.number }}</span>
</div>
<div>
<span>市场价格:</span>
<span>{{ goodInfo.market_num }}</span>
</div>
<div>
<span>商品库存:</span>
<span>{{ goodInfo.stock }}</span>
</div>
<div>
<span>商品简介:</span>
<span>{{ goodInfo.profile }}</span>
</div>
<div>
<span>商品详情:</span>
<div v-html="goodInfo.details"></div>
</div>
</n-space>
</n-drawer-content>
</n-drawer>
</CommonPage>
</template>
<script setup>
import api from './api'
import { NButton, NImage, NSpace, NEllipsis, NTag } from 'naive-ui'
import { h, withDirectives, resolveDirective } from 'vue'
const vPerms = resolveDirective('perms')
const loading = ref(false)
const isNoteModel = ref(false)
const isDzModel = ref(false)
const goodInfo = ref({})
const showDrawer = ref(false)
const notesVal = ref('')
const formRef = ref(null)
const selectOptions = ref([
{
label: '商品名称',
value: 0,
},
{
label: '商家名称',
value: 1,
},
])
const queryParams = ref({
selectKey: 0,
word: '',
type: '',
status: 0,
checkedRowKeysRef: [],
})
const handleCheck = (row) => {
queryParams.value.checkedRowKeysRef = row
}
const changeGoodsType = async (type) => {
if (queryParams.value.checkedRowKeysRef.length === 0) return $message.info('没有选中商品')
await api.updateType({
type: type,
gid: queryParams.value.checkedRowKeysRef,
})
getList()
queryParams.value.checkedRowKeysRef = []
}
const rules = {
pulse: {
required: true,
type: 'number',
message: '请输入赠送豆子数量',
trigger: 'blur',
},
integral: {
required: true,
type: 'number',
message: '请输入赠送积分数量',
trigger: 'blur',
},
commission_type: {
required: true,
type: 'number',
message: '请选择分佣类型',
trigger: 'change',
},
commission: {
required: true,
type: 'number',
message: '请输入分佣比例',
trigger: 'blur',
},
discount: {
required: true,
type: 'number',
message: '请输入抵扣比例',
trigger: 'blur',
},
}
const nowRow = ref({})
const nowKey = ref(null)
const columns = ref([
{
type: 'selection',
},
{
title: '商品名称',
slot: 'name',
align: 'center',
render: (row) => {
return h(
NEllipsis,
{
style: 'max-width: 200px',
},
{
default: () => row.name,
}
)
},
},
{
title: '商家名称',
slot: 'store_name',
align: 'center',
render: (row) => {
return h(
'span',
{},
{
default: () => row.Store.name,
}
)
},
},
{
title: '商品封面',
slot: 'cover',
align: 'center',
render(row) {
return h(NImage, {
src: row.cover,
width: '30',
})
},
},
{
title: '商品分类',
slot: 'Classify',
align: 'center',
render(row) {
return h(
'div',
{},
{
default: () => row.Classify.name,
}
)
},
},
{
title: '商品类型',
slot: 'type',
align: 'center',
render(row) {
const obj = {
0: {
type: 'success',
text: '普通商品',
},
1: {
type: 'warning',
text: '活动商品',
},
2: {
type: 'info',
text: '兑换商品',
},
3: {
type: 'error',
text: '摇球机活动商品',
},
}
return h(
NTag,
{
type: obj[row.type].type,
},
{
default: () => obj[row.type].text,
}
)
},
},
{
title: '商品价格(元)',
key: 'number',
align: 'center',
},
{
title: '抵扣后价格(元)',
key: 'discount_price',
align: 'center',
},
{
title: '积分抵扣(元)',
key: 'exchange',
align: 'center',
},
{
title: '抵扣比例(%',
key: 'discount',
align: 'center',
},
{
title: '商品库存',
key: 'stock',
align: 'center',
},
{
title: '赠送豆子',
key: 'pulse',
align: 'center',
},
{
title: '赠送积分',
key: 'integral',
align: 'center',
},
{
title: '分佣类型',
slot: 'commission_type',
align: 'center',
render(row) {
return row.commission_type === 1 ? '百分比' : '数值'
},
},
{
title: '分佣比例',
key: 'commission',
align: 'center',
},
{
title: '商品状态',
slot: 'status',
align: 'center',
render(row) {
return row.status === 3 ? '待审核' : row.status === 1 ? '已审核' : '已拒绝'
},
},
{
title: '备注',
key: 'notes',
align: 'center',
},
{
title: '操作',
slot: 'action',
align: 'center',
render(row) {
let el = []
if (row.status === 3) {
el = [
h(
NSpace,
{
justify: 'center',
},
{
default: () => [
withDirectives(
h(
NButton,
{
type: 'primary',
text: true,
onClick: () => {
nowKey.value = 1
nowRow.value = { ...row }
veeify()
},
},
() => '审核通过'
),
[[vPerms, ['/admin/goods/process']]]
),
withDirectives(
h(
NButton,
{
type: 'error',
text: true,
onClick: () => {
nowKey.value = 2
nowRow.value = { ...row }
isNoteModel.value = true
},
},
() => '审核拒绝'
),
[[vPerms, ['/admin/goods/process']]]
),
withDirectives(
h(
NButton,
{
type: 'warning',
text: true,
onClick: () => {
nowKey.value = 3
goodInfo.value = { ...row }
showDrawer.value = true
},
},
() => '商品详情'
),
[[vPerms, ['/admin/goods/process']]]
),
],
}
),
]
} else {
el = [
withDirectives(
h(
NButton,
{
type: 'info',
text: true,
onClick: () => {
nowRow.value = { ...row }
nowKey.value = 3
isDzModel.value = true
},
},
{
default: () => '赠送/比例',
}
),
[[vPerms, ['/admin/goods/process']]]
),
]
}
return el
},
},
])
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
onUpdatePageSize: (pageSize) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getList()
},
})
onMounted(() => {
getList()
})
const getList = async () => {
loading.value = true
try {
const query_data = {
status: queryParams.value.status,
type: queryParams.value.type,
}
query_data[queryParams.value.selectKey === 0 ? 'name' : 'store_name'] = queryParams.value.word
const res = await api.getHotlist({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
...query_data,
})
data.value = res.data.data.sort((a, b) => b.status - a.status) || []
pagination.value.itemCount = res.data.total
} catch (error) {
$message.error(error.msg)
}
loading.value = false
}
const clear = () => {
isNoteModel.value = false
isDzModel.value = false
notesVal.value = ''
nowRow.value = {}
queryParams.value = {
selectKey: 0,
word: '',
type: '',
status: 0,
checkedRowKeysRef: [],
}
getList()
}
const veeify = async () => {
try {
let data = {}
if (nowKey.value === 1 || nowKey.value === 2) {
data = {
gid: [nowRow.value.gid],
status: nowKey.value,
notes: notesVal.value,
}
await api.getHotStatus(data)
await getList()
// clear()
} else {
formRef.value?.validate(async (errors) => {
if (!errors) {
data = {
...nowRow.value,
gid: [nowRow.value.gid],
}
await api.getHotStatus(data)
await getList()
// clear()
}
})
}
} finally {
isNoteModel.value = false
isDzModel.value = false
}
}
const veeifys = async () => {
if (queryParams.value.checkedRowKeysRef.length === 0) return $message.info('没有选中商品')
await api.getHotStatus({
gid: queryParams.value.checkedRowKeysRef,
status: 1,
notes: '',
})
clear()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,6 @@
import { request } from '@/utils'
export default {
getPointlist: (data) => request.post('/point/goods', data),
setPointStatus: (data) => request.post('/point/goods/process', data),
}

View File

@@ -0,0 +1,280 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<CommonPage show-footer :title="$route.title">
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<!-- 拒绝 -->
<n-modal v-model:show="isNoteModel">
<n-card
style="width: 500px"
title="拒绝信息"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-input v-model:value="notesVal" type="textarea" placeholder="请输入拒绝理由...." />
<div m-auto p-10>
<n-button type="primary" @click="veeify">确定</n-button>
<n-button ml-10 @click="clear">取消</n-button>
</div>
</n-card>
</n-modal>
<!-- 商品详情 -->
<n-drawer v-model:show="showDrawer" :width="502">
<n-drawer-content title="商品详情" closable>
<n-space vertical>
<div>
<span>商品分类</span>
<span>{{ goodInfo.class_name }}</span>
</div>
<div>
<span>商品名称</span>
<span>{{ goodInfo.name }}</span>
</div>
<div flex items-center>
<span>封面</span>
<n-image width="100" :src="goodInfo.cover" />
</div>
<div flex items-center>
<span w-90>轮播图</span>
<div flex flex-wrap>
<n-image
v-for="(url, index) in goodInfo.rotation?.split(',')"
:key="index"
width="100"
:src="url"
/>
</div>
</div>
<div>
<span>商品价格</span>
<span>{{ goodInfo.number }}</span>
</div>
<div>
<span>市场价格</span>
<span>{{ goodInfo.market_num }}</span>
</div>
<div>
<span>商品库存</span>
<span>{{ goodInfo.stock }}</span>
</div>
<div>
<span>商品简介</span>
<span>{{ goodInfo.profile }}</span>
</div>
<div>
<span>商品详情</span>
<div v-html="goodInfo.details"></div>
</div>
</n-space>
</n-drawer-content>
</n-drawer>
</CommonPage>
</template>
<script setup>
import api from './api'
import { NEllipsis, NButton, NImage, NSpace } from 'naive-ui'
import { h, withDirectives, resolveDirective } from 'vue'
const vPerms = resolveDirective('perms')
const loading = ref(false)
const isNoteModel = ref(false)
const goodInfo = ref({})
const showDrawer = ref(false)
const notesVal = ref('')
const nowRow = ref({})
const nowKey = ref(null)
const columns = ref([
{
title: '商品名称',
slot: 'name',
align: 'center',
render: (row) => {
return h(
NEllipsis,
{
style: 'max-width: 200px',
},
{
default: () => row.name,
}
)
},
},
{
title: '商品封面',
slot: 'cover',
align: 'center',
render(row) {
return h(NImage, {
src: row.cover,
width: '30',
})
},
},
{
title: '商品分类',
key: 'class_name',
align: 'center',
},
{
title: '商品价格',
key: 'number',
align: 'center',
},
{
title: '商品库存',
key: 'stock',
align: 'center',
},
{
title: '商品状态',
slot: 'status',
align: 'center',
render(row) {
return row.status === 3 ? '待审核' : row.status === 1 ? '已审核' : '已拒绝'
},
},
{
title: '备注',
key: 'notes',
align: 'center',
},
{
title: '操作',
slot: 'action',
align: 'center',
render(row) {
if (row.status === 3) {
return [
h(
NSpace,
{
justify: 'center',
},
{
default: () => [
withDirectives(
h(
NButton,
{
type: 'primary',
text: true,
onClick: () => {
nowKey.value = 1
nowRow.value = { ...row }
veeify()
},
},
() => '审核通过'
),
[[vPerms, ['/admin/point/goods/process']]]
),
withDirectives(
h(
NButton,
{
type: 'error',
text: true,
onClick: () => {
nowKey.value = 2
nowRow.value = { ...row }
isNoteModel.value = true
},
},
() => '审核拒绝'
),
[[vPerms, ['/admin/point/goods/process']]]
),
withDirectives(
h(
NButton,
{
type: 'info',
text: true,
onClick: () => {
nowKey.value = 3
goodInfo.value = { ...row }
showDrawer.value = true
},
},
() => '商品详情'
),
[[vPerms, ['/admin/point/goods/process']]]
),
],
}
),
]
}
},
},
])
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
onUpdatePageSize: (pageSize) => {
pagination.value.pageSize = pageSize
pagination.value.page = 1
getList()
},
})
onMounted(() => {
getList()
})
const getList = async () => {
loading.value = true
try {
const res = await api.getPointlist({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
} catch (error) {
$message.error(error.msg)
}
loading.value = false
}
const clear = () => {
isNoteModel.value = false
notesVal.value = ''
}
const veeify = async () => {
await api.setPointStatus({
gid: nowRow.value.gid,
status: nowKey.value,
notes: notesVal.value,
})
getList()
clear()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,35 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: '商品管理',
path: '/commodity',
component: Layout,
redirect: '/commodity_class',
meta: {
title: '商品管理',
icon: 'mdi:account-multiple',
order: 10,
},
children: [
{
name: 'HotList',
path: 'hot_list',
component: () => import('./hot_list/index.vue'),
meta: {
title: '活动商品',
icon: 'mdi:account-multiple',
order: 10,
},
},
// {
// name: 'PointList',
// path: 'point_list',
// component: () => import('./point/index.vue'),
// meta: {
// title: '积分商品',
// icon: 'mdi:account-multiple',
// order: 10,
// },
// },
],
}

View File

@@ -0,0 +1,16 @@
<template>
<AppPage>
<n-result m-auto status="404" description="抱歉,您访问的页面不存在。">
<template #icon>
<img src="@/assets/images/404.webp" width="500" />
</template>
<template #footer>
<n-button @click="replace('/workbench')">返回首页</n-button>
</template>
</n-result>
</AppPage>
</template>
<script setup>
const { replace } = useRouter()
</script>

View File

@@ -0,0 +1,25 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: 'ErrorPage',
path: '/error-page',
component: Layout,
redirect: '/error-page/404',
isHidden: true,
meta: {
title: '错误页',
icon: 'mdi:alert-circle-outline',
order: 99,
},
children: [
{
name: 'ERROR-404',
path: '404',
component: () => import('./404.vue'),
meta: {
title: '404',
icon: 'tabler:error-404',
},
},
],
}

14
src/views/finance/api.js Normal file
View File

@@ -0,0 +1,14 @@
import { request } from '@/utils'
export default {
getData: (data) => request.post('/store/withdraw', data),
// 审核提现
passAudit: (data) => request.post('/store/withdraw/edit', data),
getYdata: (data) => request.post('/store/amount/withdraw', data),
ydataEdit: (data) => request.post('/store/amount/withdraw/edit', data),
// 溯源统计
suyuanData: (data) => request.post('/pulse/count', data),
// 获取游戏大厅
getGameData: (data) => request.post('/game/list', data),
}

415
src/views/finance/index.vue Normal file
View File

@@ -0,0 +1,415 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-row gutter="12">
<n-col :span="24">
<div flex>
<n-card w-400>
<n-statistic label="总提现金额(含已驳回)" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.total"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="服务费" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.commission || 0 / 100"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="待处理金额" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.service"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="已审核金额" tabular-nums>
<n-number-animation ref="numberAnimationInstRef" :from="0" :to="cardData.count" />
</n-statistic>
</n-card>
</div>
</n-col>
<n-col :span="24">
<div mt-10>
<span w-80>提现状态:</span>
<n-radio-group v-model:value="queryData.status" class="ml-10">
<n-radio-button
v-for="song in songs"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-col>
<n-col :span="24">
<div mt-10 flex items-center>
<span w-80>号码搜索:</span>
<n-input v-model:value="queryData.word" style="width: 25%" placeholder="请输入手机号码" />
</div>
</n-col>
<n-col :span="10">
<div mt-10 flex items-center>
<span w-80>申请时间:</span>
<n-date-picker
v-model:formatted-value="queryData.time"
value-format="yyyy-MM-dd"
type="daterange"
clearable
/>
</div>
</n-col>
<n-col :span="24">
<div mt-10>
<n-button type="primary" @click="getList">搜索</n-button>
<n-button ml-10 @click="clearQueryData">重置</n-button>
</div>
</n-col>
</n-row>
<n-data-table
class="mt-10"
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<!-- 打款记录 -->
<n-modal v-model:show="showModal">
<n-card
style="width: 500px"
title="打款记录"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form ref="formRef" :model="model" :rules="rules" label-placement="left">
<n-grid :cols="1" :x-gap="24">
<n-form-item-gi :span="12" label="打款截图" path="img">
<Upload v-model:list="model.img" />
</n-form-item-gi>
<n-form-item-gi>
<div w-full flex justify-center>
<n-button type="primary" @click="ok">确定</n-button>
<n-button class="ml-10" @click="clear">关闭</n-button>
</div>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import api from './api'
import { NButton, NImage, NTag } from 'naive-ui'
import Upload from '@/components/Upload.vue'
import { h, withDirectives, resolveDirective } from 'vue'
const vPerms = resolveDirective('perms')
const vRole = [[vPerms, ['/admin/store/withdraw/edit']]]
const showModal = ref(false)
const queryData = ref({
status: '',
word: '',
time: '',
})
const cardData = ref({
total: 0,
service: 0,
count: 0,
commission: 0,
})
const songs = ref([
{
value: 1,
label: '已审核',
},
{
value: 2,
label: '已驳回',
},
{
value: 3,
label: '待审核',
},
])
const nowRow = ref({})
const formRef = ref(null)
const model = ref({
img: [],
})
const rules = {
img: { required: true, type: 'array', message: '请上传打款截图' },
}
const columns = ref([
{
title: '名字',
key: 'name',
align: 'center',
},
{
title: '电话',
key: 'phone',
align: 'center',
},
{
title: '银行名称',
key: 'bank',
align: 'center',
},
{
title: '银行卡号',
key: 'bank_card',
align: 'center',
},
{
title: '账户名称',
key: 'bank_name',
align: 'center',
},
{
title: '法人',
key: 'bank_user',
align: 'center',
},
{
title: '提现金额',
key: 'integral',
align: 'center',
},
{
title: '上次留存余额',
key: 'balance',
align: 'center',
},
{
title: '服务费',
key: 'commission',
align: 'center',
},
{
title: '实际到账',
key: 'number',
align: 'center',
},
{
title: '剩余余额',
key: 'residue',
align: 'center',
},
{
title: '手续费比例',
slot: 'commission_number',
align: 'center',
render: (row) => {
return h(
'span',
{},
{
default: () => `${row.commission_number}%`,
}
)
},
},
{
title: '手续费类型',
key: 'commission_type',
align: 'center',
render: (row) => {
return h(
NTag,
{
type: row.commission_type === 1 ? 'success' : 'warning',
},
{
default: () => (row.commission_type === 1 ? '百分比' : '固定值'),
}
)
},
},
{
title: '审核状态',
align: 'center',
slot: 'status',
render: (row) => {
return h(
NTag,
{
type: row.status === 1 ? 'success' : row.status === 2 ? 'error' : 'warning',
},
{
default: () => (row.status === 1 ? '已审核' : row.status === 2 ? '已驳回' : '待审核'),
}
)
},
},
{
title: '申请时间',
key: 'add_time',
align: 'center',
},
{
title: '打款截图',
slot: 'img',
render: (row) => {
return h(NImage, {
src: row.status_img,
width: '50',
})
},
},
{
title: '操作',
align: 'center',
slot: 'action',
render: (row) => {
if (row.status === 3) {
return [
withDirectives(
h(
NButton,
{
text: true,
type: 'primary',
onClick: () => {
nowRow.value = row
showModal.value = true
},
},
{
default: () => '审核',
}
),
vRole
),
withDirectives(
h(
NButton,
{
class: 'ml-10',
text: true,
type: 'error',
onClick: () => {
nowRow.value = row
refuse()
},
},
{
default: () => '拒绝',
}
),
vRole
),
]
}
},
},
])
const loading = ref(false)
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
})
onMounted(() => {
getList()
})
const getList = async () => {
loading.value = true
const query_data = {
Status: queryData.value.status || '',
Phone: queryData.value.word || '',
StartTime: queryData.value.time === null ? '' : queryData.value.time[0] || '',
EndTime: queryData.value.time === null ? '' : queryData.value.time[1] || '',
}
const res = await api.getData({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
...query_data,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
cardData.value.total = res.data.all
cardData.value.service = res.data.audit_integral
cardData.value.commission = res.data.audit_commission
cardData.value.count = res.data.success_integral
loading.value = false
}
const clear = async () => {
model.value = {
img: [],
}
showModal.value = false
formRef.value?.restoreValidation()
await getList()
}
const ok = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
const res = await api.passAudit({
bid: nowRow.value.bid,
wid: nowRow.value.wid,
img: model.value.img[0].url,
status: 1,
})
$message.success(res.msg)
clear()
}
})
}
const refuse = async () => {
const res = await api.passAudit({
bid: nowRow.value.bid,
wid: nowRow.value.wid,
img: model.value.img[0]?.url || '',
status: 2,
})
clear()
$message.success(res.msg)
}
const clearQueryData = () => {
queryData.value = {
status: '',
time: null,
word: '',
}
getList()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,25 @@
const Layout = () => import('@/layout/index.vue')
export default {
name: '财务管理',
path: '/finance',
component: Layout,
redirect: '/finance_list',
meta: {
title: '财务管理',
icon: 'mdi:account-multiple',
order: 10,
},
children: [
{
name: 'financelist',
path: 'finance_list',
component: () => import('./index.vue'),
meta: {
title: '商户提现',
icon: 'mdi:account-multiple',
order: 10,
},
},
],
}

View File

@@ -0,0 +1,279 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-grid class="mb-10" x-gap="12">
<n-gi :span="24">
<div flex>
<n-card w-250>
<n-statistic label="总赢积分" tabular-nums>
<n-number-animation ref="numberAnimationInstRef" :from="0" :to="cardData.win" />
</n-statistic>
</n-card>
<n-card ml-10 w-250>
<n-statistic label="总豆子" tabular-nums>
<n-number-animation ref="numberAnimationInstRef" :from="0" :to="cardData.pulse" />
</n-statistic>
</n-card>
</div>
</n-gi>
<n-gi :span="12">
<div mt-10 flex items-center>
<div w-100>筛选条件:</div>
<n-input-group>
<n-select
v-model:value="queryParams.selectKey"
:style="{ width: '30rem' }"
:options="selectOptions"
placeholder="请选择"
/>
<n-input v-model:value="queryParams.word" :style="{ width: '50rem' }" />
</n-input-group>
</div>
</n-gi>
<n-gi :span="24">
<div mt-10 flex items-center>
<div mr-10>游戏名称:</div>
<n-select
v-model:value="queryParams.hall_id"
w-250
:options="gamelist"
placeholder="请选择游戏"
/>
</div>
</n-gi>
<n-gi :span="24" mt-10>
<div>
<span>豆子状态</span>
<n-radio-group v-model:value="queryParams.Status">
<n-radio-button
v-for="song in songs"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-gi>
<n-gi :span="24" mt-10>
<div>
<span>活动赠送</span>
<n-radio-group v-model:value="queryParams.Type">
<n-radio-button
v-for="song in songs1"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-gi>
<n-gi :span="24">
<div mt-10 flex items-center>
<div>时间筛选</div>
<n-date-picker
v-model:formatted-value="queryParams.time"
value-format="yyyy-MM-dd HH:mm:ss"
type="datetimerange"
clearable
/>
</div>
</n-gi>
<n-gi span="24" mt-10 flex items-center>
<n-button type="primary" @click="getList">查询</n-button>
<n-button ml-10 @click="clear">重置</n-button>
</n-gi>
</n-grid>
<n-data-table
class="mt-10"
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
</CommonPage>
</template>
<script setup>
import api from './api'
import dayjs from 'dayjs'
const queryParams = ref({})
const cardData = ref({})
const selectOptions = [
{
label: '商家电话',
value: 0,
},
{
label: '用户电话',
value: 1,
},
]
const songs = ref([
{
value: 1,
label: '未使用',
},
{
value: 3,
label: '用户赢',
},
{
value: 4,
label: '用户输',
},
{
value: 5,
label: '已过期',
},
])
const gamelist = ref([])
const songs1 = ref([
{
value: 5,
label: '注册赠送',
},
{
value: 6,
label: '签到赠送',
},
{
value: 7,
label: '平台赠送',
},
])
const loading = ref(false)
const columns = ref([
{
title: '游戏名称',
align: 'center',
slot: 'game_name',
render: (row) => {
const res = gamelist.value.find((item) => item.value === Number(row.hall_id))
return h('span', null, {
default: () => res?.label || '',
})
},
},
{
title: '订单ID',
align: 'center',
key: 'order_id',
},
{
title: '期数',
align: 'center',
key: 'periods',
},
{
title: '投注豆子',
align: 'center',
key: 'number',
},
{
title: '赢积分',
align: 'center',
key: 'integral',
},
{
title: '用户电话',
align: 'center',
key: 'user_phone',
},
{
title: '商户电话',
align: 'center',
key: 'merchant_phone',
},
{
title: '投注时间',
align: 'center',
key: 'add_time',
},
{
title: '过期时间',
align: 'center',
slot: 'expire',
render: (row) => {
return h('span', null, {
default: () => dayjs(row.expire).format('YYYY-MM-DD HH:mm:ss'),
})
},
},
])
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
})
onMounted(() => {
getList()
getGameList()
})
const getGameList = async () => {
const res = await api.getGameData({ pageSize: 9999999, pageNum: 1 })
gamelist.value = res.data.data.map((item) => ({ value: item.ID, label: item.name }))
}
const getList = async () => {
loading.value = true
const query_data = {
...queryParams.value,
StartTime: queryParams.value.time?.[0] || '',
EndTime: queryParams.value.time?.[1] || '',
}
switch (queryParams.value.selectKey) {
case 0:
query_data['merchant_phone'] = queryParams.value.word
break
case 1:
query_data['user_phone'] = queryParams.value.word
break
}
delete query_data.time
delete query_data.word
const res = await api.suyuanData({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
...query_data,
})
data.value = res.data.result || []
pagination.value.itemCount = res.data.count
cardData.value.win = res.data.win
cardData.value.pulse = res.data.pulse
loading.value = false
}
const clear = () => {
queryParams.value = {
word: '',
selectKey: null,
Status: '',
time: null,
Type: '',
hall_id: '',
}
getList()
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,414 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-row gutter="12">
<n-col :span="24">
<div flex>
<n-card w-400>
<n-statistic label="总提现金额(含已驳回)" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.total / 100"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="待处理金额" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.service || 0 / 100"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="服务费" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.commission || 0 / 100"
:precision="2"
/>
</n-statistic>
</n-card>
<n-card ml-10 w-400>
<n-statistic label="已审核金额" tabular-nums>
<n-number-animation
ref="numberAnimationInstRef"
:from="0"
:to="cardData.count || 0 / 100"
/>
</n-statistic>
</n-card>
</div>
</n-col>
<n-col :span="24">
<div mt-10>
<span w-80>提现状态:</span>
<n-radio-group v-model:value="queryData.status" class="ml-10">
<n-radio-button
v-for="song in songs"
:key="song.value"
:value="song.value"
:label="song.label"
/>
</n-radio-group>
</div>
</n-col>
<n-col :span="24">
<div mt-10 flex items-center>
<span w-80>号码搜索:</span>
<n-input v-model:value="queryData.word" style="width: 25%" placeholder="请输入手机号码" />
</div>
</n-col>
<n-col :span="10">
<div mt-10 flex items-center>
<span w-80>申请时间:</span>
<n-date-picker
v-model:formatted-value="queryData.time"
value-format="yyyy-MM-dd"
type="daterange"
clearable
/>
</div>
</n-col>
<n-col :span="24">
<div mt-10>
<n-button type="primary" @click="getList">搜索</n-button>
<n-button ml-10 @click="clearQueryData">重置</n-button>
</div>
</n-col>
</n-row>
<n-data-table
class="mt-10"
:loading="loading"
:columns="columns"
:data="data"
:pagination="pagination"
:bordered="false"
remote
/>
<!-- 打款记录 -->
<n-modal v-model:show="showModal">
<n-card
style="width: 500px"
title="打款记录"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-form ref="formRef" :model="model" :rules="rules" label-placement="left">
<n-grid :cols="1" :x-gap="24">
<n-form-item-gi :span="12" label="打款截图" path="img">
<Upload v-model:list="model.img" />
</n-form-item-gi>
<n-form-item-gi>
<div w-full flex justify-center>
<n-button type="primary" @click="ok">确定</n-button>
<n-button class="ml-10" @click="clear">关闭</n-button>
</div>
</n-form-item-gi>
</n-grid>
</n-form>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import api from './api'
import { NButton, NImage, NTag } from 'naive-ui'
import Upload from '@/components/Upload.vue'
import { h, withDirectives, resolveDirective } from 'vue'
const vPerms = resolveDirective('perms')
const vRole = [[vPerms, ['/admin/store/amount/withdraw/edit']]]
const showModal = ref(false)
const queryData = ref({
status: '',
word: '',
time: '',
})
const cardData = ref({
total: 0,
service: 0,
count: 0,
commission: 0,
})
const songs = ref([
{
value: 1,
label: '已审核',
},
{
value: 2,
label: '已驳回',
},
{
value: 3,
label: '待审核',
},
])
const nowRow = ref({})
const formRef = ref(null)
const model = ref({
img: [],
})
const rules = {
img: { required: true, type: 'array', message: '请上传打款截图' },
}
const columns = ref([
{
title: '名字',
key: 'name',
align: 'center',
},
{
title: '电话',
key: 'phone',
align: 'center',
},
{
title: '银行名称',
key: 'bank',
align: 'center',
},
{
title: '银行卡号',
key: 'bank_card',
align: 'center',
},
{
title: '账户名称',
key: 'bank_name',
align: 'center',
},
{
title: '法人',
key: 'bank_user',
align: 'center',
},
{
title: '提现金额',
key: 'integral',
align: 'center',
},
{
title: '服务费',
key: 'commission',
align: 'center',
},
{
title: '实际到账',
key: 'number',
align: 'center',
},
{
title: '剩余余额',
key: 'residue',
align: 'center',
},
{
title: '手续费比例',
slot: 'commission_number',
align: 'center',
render: (row) => {
return h(
'span',
{},
{
default: () => `${row.commission_number}%`,
}
)
},
},
{
title: '手续费类型',
key: 'commission_type',
align: 'center',
render: (row) => {
return h(
NTag,
{
type: row.commission_type === 1 ? 'success' : 'warning',
},
{
default: () => (row.commission_type === 1 ? '百分比' : '固定值'),
}
)
},
},
{
title: '审核状态',
align: 'center',
slot: 'status',
render: (row) => {
return h(
NTag,
{
type: row.status === 1 ? 'success' : row.status === 2 ? 'error' : 'warning',
},
{
default: () => (row.status === 1 ? '已审核' : row.status === 2 ? '已驳回' : '待审核'),
}
)
},
},
{
title: '申请时间',
key: 'add_time',
align: 'center',
},
{
title: '打款截图',
slot: 'img',
render: (row) => {
return h(NImage, {
src: row.status_img,
width: '50',
})
},
},
{
title: '操作',
align: 'center',
slot: 'action',
render: (row) => {
if (row.status === 3) {
return [
withDirectives(
h(
NButton,
{
text: true,
type: 'primary',
onClick: () => {
nowRow.value = row
showModal.value = true
},
},
{
default: () => '审核',
}
),
vRole
),
withDirectives(
h(
NButton,
{
class: 'ml-10',
text: true,
type: 'error',
onClick: () => {
nowRow.value = row
refuse()
},
},
{
default: () => '拒绝',
}
),
vRole
),
]
}
},
},
])
const loading = ref(false)
const data = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getList()
},
})
onMounted(() => {
getList()
})
const getList = async () => {
loading.value = true
const query_data = {
Status: queryData.value.status || '',
Phone: queryData.value.word || '',
StartTime: queryData.value.time === null ? '' : queryData.value.time[0] || '',
EndTime: queryData.value.time === null ? '' : queryData.value.time[1] || '',
}
const res = await api.getYdata({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
...query_data,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
cardData.value.total = res.data.all
cardData.value.service = res.data.audit_number
cardData.value.commission = res.data.audit_commission
cardData.value.count = res.data.success_amount
loading.value = false
}
const clear = async () => {
model.value = {
img: [],
}
showModal.value = false
formRef.value?.restoreValidation()
await getList()
}
const ok = () => {
formRef.value?.validate(async (errors) => {
if (!errors) {
const res = await api.passAudit({
bid: nowRow.value.bid,
wid: nowRow.value.wid,
img: model.value.img[0].url,
status: 1,
})
$message.success(res.msg)
clear()
}
})
}
const refuse = async () => {
const res = await api.passAudit({
bid: nowRow.value.bid,
wid: nowRow.value.wid,
img: model.value.img[0]?.url || '',
status: 2,
})
clear()
$message.success(res.msg)
}
const clearQueryData = () => {
queryData.value = {
status: '',
time: null,
word: '',
}
getList()
}
</script>
<style lang="scss" scoped></style>

49
src/views/game/api.js Normal file
View File

@@ -0,0 +1,49 @@
import { request } from '@/utils'
export default {
getData: () => request.post('/getisStart'),
setStatus: (data) => request.post('/isStart', data),
getDS: () => request.post('/getBetting'),
setDS: (data) => request.post('/setBetting', data),
getKJList: () => request.post('/draw'),
// 获取统计
getStatistics: (data) => request.post('/user/betting/list', data),
// log
getLog: () => request.post('/log'),
// 宙斯详情
getDetail: (data) => request.post('/log/betting/list', data),
// 获取游戏大厅
getGameList: (data) => request.post('/game/list', data),
// 添加修改游戏
addGame: (data) => request.post('/game/edit', data),
// 当前投注用户
nowUser: () => request.post('/now/draw/user', {}),
// 全部投注用户
allUser: () => request.post('/all/draw/user', {}),
// 转盘相关
// 游戏状态
getisBalloonStart: () => request.post('/getisTurntableStart'),
// 修改游戏状态
setisBalloonStart: (data) => request.post('/isTurntableStart', data),
// 你猜
setZsNum: () => request.post('/setTurntable'),
// 全部开奖记录
getBalloonList: () => request.post('/turntable/draw'),
// 本局投注记录
getBalloonUser: () => request.post('/now/turntable/draw/user'),
// 全部投注记录
getAllBalloonUser: () => request.post('/all/turntable/draw/user'),
// 统计全部投注和中奖列表
getTjList: () => request.post('/user/turntable/list'),
// 宙斯记录
getZsBalloon: (data) => request.post('/turntable/log', data),
// 宙斯中奖
getZsBalloonUser: (data) => request.post('/log/turntable/list', data),
}

View File

@@ -0,0 +1,348 @@
<template>
<CommonPage show-footer :title="$route.title">
<div flex items-center>
<div mr-20 flex>
<div>游戏状态</div>
<n-switch
v-model:value="gameStatus"
:checked-value="1"
:unchecked-value="2"
@update:value="handleUpdateValue1"
/>
</div>
<div ml-20 flex items-center>
<div>本局记录</div>
<n-button type="primary" @click="openModal(1)">预览</n-button>
</div>
<div ml-20 flex items-center>
<div>全部记录</div>
<n-button type="primary" @click="openModal(2)">预览</n-button>
</div>
</div>
<div flex>
<div>
预开期数
<span text-25>{{ list[0]?.Periods || 0 }}</span>
</div>
<div ml-20>
剩余开奖时间
<span text-25>{{ time || 0 }}</span>
</div>
<div ml-20>
本局总下注
<span text-25>{{ totalA || 0 }}</span>
</div>
</div>
<div>
<div>
<span text-25>{{ data[0]?.Periods || 0 }}</span>
期开奖结果
<span text-20>{{ data[0]?.Name || 0 }}</span>
</div>
</div>
<n-spin size="large" :show="show">
<div flex flex-wrap justify-between>
<n-card
v-for="item in list"
:key="item.ID"
class="mb-10 mt-10 h-120 w-250 flex-shrink-0 cursor-pointer"
:title="item.name"
>
<p text-25 op-60 :style="{ color: item.count === 0 ? 'green' : 'red' }">
{{ item.count }}
</p>
</n-card>
<div h-0 w-250></div>
<div h-0 w-250></div>
</div>
</n-spin>
<n-spin size="large" :show="show1">
<div flex flex-wrap justify-between>
<n-card
v-for="item in list1"
:key="item.ID"
class="mb-10 mt-10 h-150 w-250 flex-shrink-0 cursor-pointer"
:title="`${item.NumName}(${item.Name})`"
>
<p text-25 op-60 :style="{ color: item.Total === 0 ? 'green' : 'red' }">
{{ item.Total }}
</p>
<n-popconfirm @positive-click="handleUpdateValue(item.ID)">
<template #trigger>
<n-button>你猜</n-button>
</template>
一切都将一去杳然任何人都无法将其捕获
</n-popconfirm>
</n-card>
<div h-0 w-250></div>
<div h-0 w-250></div>
<div h-0 w-250></div>
<div h-0 w-250></div>
</div>
</n-spin>
<n-modal v-model:show="showModal">
<n-card
style="width: 900px"
:title="modalTitle"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<template v-if="keyModal === 1">
<div>
<span>
总投注(豆子):
<span text-red>{{ nowData.total_number }}</span>
</span>
<span ml-20>
总积分:
<span text-red>{{ nowData.total_integral }}</span>
</span>
</div>
<n-data-table
:loading="nowData.Loading"
:columns="nowData.Columns"
:data="nowData.data"
:max-height="700"
:pagination="false"
:bordered="false"
remote
@update:sorter="nowData.handleSorterChange"
/>
</template>
<template v-else>
<div>
<span>
总投注(豆子):
<span text-red>{{ allData.total_number }}</span>
</span>
<span ml-20>
总积分:
<span text-red>{{ allData.total_integral }}</span>
</span>
</div>
<n-data-table
:loading="allData.Loading"
:columns="allData.Columns"
:data="allData.data"
:max-height="700"
:pagination="false"
:bordered="false"
remote
@update:sorter="allData.handleSorterChange"
/>
</template>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import api from '../../api'
import { getToken } from '@/utils'
const ws = new WebSocket(`wss://${import.meta.env.VITE_TRUN_WS_URL}`)
const ws1 = new WebSocket(`wss://${import.meta.env.VITE_TRUN_WS1_URL}`)
const gameStatus = ref(2)
const t_1 = ref()
const t_2 = ref()
const show = ref(true)
const show1 = ref(true)
const list = ref([])
const list1 = ref([])
const totalA = ref(0)
const time = ref(0)
const data = ref([])
onBeforeUnmount(() => {
clearInterval(t_1.value)
clearInterval(t_2.value)
})
ws.onopen = () => {
t_1.value = setInterval(() => {
ws.send('ping')
}, 2500)
}
ws1.onopen = () => {
t_2.value = setInterval(() => {
ws1.send('ping')
}, 2500)
}
ws.onmessage = (e) => {
const res = JSON.parse(e.data)
list.value = res.betting.sort((a, b) => b.Total - a.Total)
show.value = false
list1.value = res.list.sort((a, b) => b.Total - a.Total)
show1.value = false
totalA.value = res.total
}
ws1.onmessage = (e) => {
const res = JSON.parse(e.data)
switch (res.code) {
case 200:
// let num = res.data
time.value = res.data
break
case 301:
$message.error(res.msg)
break
}
}
onMounted(() => {
get_status()
get_kj_jl()
})
const get_kj_jl = async () => {
try {
const res = await api.getBalloonList()
data.value = res.data.data
} catch (error) {
$message.error(error.msg)
throw error
}
}
const get_status = async () => {
const res = await api.getisBalloonStart()
gameStatus.value = res.data.diceStart
}
const handleUpdateValue = async (e) => {
const res = await api.setDS({
status: 1,
id: e,
user_id: getToken(),
Periods: list.value[0]?.Periods,
})
$message.success(res.msg)
}
const handleUpdateValue1 = async (e) => {
await api.setisBalloonStart({
start: e,
})
$message.success('修改成功')
get_status()
}
const showModal = ref(false)
const keyModal = ref(null)
const modalTitle = ref('')
const openModal = (type) => {
keyModal.value = type
showModal.value = true
modalTitle.value = type === 1 ? '本局记录' : '全部记录'
if (type === 1) return fetchData(nowData)
fetchData(allData)
}
const { value: tempCol } = ref([
{
title: '昵称',
key: 'User',
align: 'center',
},
{
title: '电话',
key: 'Phone',
align: 'center',
},
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '下注豆子',
key: 'TotalCount',
align: 'center',
sorter: true,
sortOrder: false,
},
{
title: '赢积分',
key: 'NumberSum',
align: 'center',
sorter: true,
sortOrder: false,
},
{
title: '购买选项',
key: 'DrawTime',
align: 'center',
},
])
const { value: nowData } = ref({
Loading: false,
Columns: [...tempCol],
data: [],
total_number: 0,
total_integral: 0,
handleSorterChange: (sorter) => sortData(sorter, nowData),
api: api.getBalloonUser,
})
const { value: allData } = ref({
Loading: false,
Columns: [...tempCol],
data: [],
total_number: 0,
total_integral: 0,
handleSorterChange: (sorter) => sortData(sorter, allData),
api: api.getAllBalloonUser,
})
const fetchData = async (target) => {
target.Loading = true
const res = await target.api()
target.data = res.data.data || []
target.total_integral = res.data.total_integral
target.total_number = res.data.total_number
target.Loading = false
}
const sortData = (sorter, target) => {
if (!target.Loading) {
target.Loading = true
switch (sorter.columnKey) {
case 'TotalCount':
target.Columns[3].sortOrder = !sorter ? false : sorter.order
target.data = target.data.sort((a, b) => {
if (sorter.order === 'descend') return b.TotalCount - a.TotalCount
return a.TotalCount - b.TotalCount
})
break
case 'NumberSum':
target.Columns[4].sortOrder = !sorter ? false : sorter.order
target.data = target.data.sort((a, b) => {
if (sorter.order === 'descend') return b.NumberSum - a.NumberSum
return a.NumberSum - b.NumberSum
})
break
}
target.Loading = false
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,214 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-grid class="mb-10" x-gap="12" :cols="4">
<n-gi>
<n-date-picker
v-model:formatted-value="range"
value-format="yyyy-MM-dd"
type="daterange"
clearable
/>
</n-gi>
<n-gi>
<n-button type="primary" @click="getList">搜索</n-button>
<n-button ml-10 @click="clear">重置</n-button>
</n-gi>
</n-grid>
<div w-full flex items-center>
<Echarts :loading="loading" :option="option" />
<Echarts :loading="loading" :option="option1" />
</div>
<div w-full flex items-center justify-between>
<n-card title="开奖记录" :bordered="false" content-style="padding: 0;">
<n-data-table
:max-height="500"
:loading="loading"
:columns="columns"
:data="data"
:bordered="true"
:virtual-scroll="true"
remote
/>
</n-card>
</div>
</CommonPage>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api.js'
import Echarts from '@/components/Echarts.vue'
import dayjs from 'dayjs'
const loading = ref(false)
const range = ref(null)
const option = ref({
title: {
text: '单期下注(豆子)/赔付(积分) 统计',
left: 'center',
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
data: [],
},
yAxis: {
type: 'value',
},
series: [
{
name: '下注(豆子)',
data: [],
type: 'bar',
},
{
name: '赔付(积分)',
data: [],
type: 'bar',
},
],
dataZoom: [
{
type: 'inside',
},
{
type: 'slider',
},
],
})
const option1 = ref({
title: {
text: '总下注(豆子)/总赔付(积分)',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'right',
},
series: [
{
type: 'pie',
radius: '50%',
data: [],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
})
const data = ref([])
const columns = ref([
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '开奖号码',
key: 'Name',
align: 'center',
},
{
title: '下注',
key: 'NumberSum',
align: 'center',
},
{
title: '赔付',
key: 'TotalCount',
align: 'center',
},
{
title: '时间',
key: 'Date',
align: 'center',
},
])
onMounted(() => {
getList()
})
const clear = () => {
range.value = null
getList()
}
const getList = async () => {
loading.value = true
const dataObj = {
StartTime: dayjs().format('YYYY-MM-DD'),
EndTime: dayjs().format('YYYY-MM-DD'),
}
if (range.value) {
dataObj.StartTime = range.value[0]
dataObj.EndTime = range.value[1]
}
const res = await api.getTjList(dataObj)
const newData = res.data.data || []
data.value = newData
option.value.xAxis.data = []
option.value.series[0].data = []
option.value.series[1].data = []
option1.value.series[0].data = []
if (newData.length > 0) {
res.data.data.forEach((item) => {
const a = (
((res.data.total * 10) / (res.data.total * 10 + res.data.totalDices)) *
100
).toFixed(2)
const b = ((res.data.totalDices / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(
2
)
option.value.xAxis.data.push(`${item.Periods}期-${item.Name}`)
option.value.series[0].name = `下注(豆子): ${a}%`
option.value.series[0].data.push(item.NumberSum * 10)
option.value.series[1].name = `赔付(积分): ${b}%`
option.value.series[1].data.push(item.TotalCount)
})
const a = (((res.data.total * 10) / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(
2
)
const b = ((res.data.totalDices / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(2)
option1.value.series[0].data.push({ value: res.data.total * 10, name: `总下注: ${a}%` })
option1.value.series[0].data.push({
value: res.data.totalDices,
name: `总赔付: ${b}%`,
})
}
loading.value = false
}
</script>
<style lang="scss" scoped>
.chart {
width: 50%;
height: 400px;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-data-table
:max-height="500"
:loading="loading"
:columns="columns"
:data="data"
:bordered="true"
:virtual-scroll="true"
:pagination="pagination"
remote
/>
<!-- -->
<n-modal v-model:show="showModal">
<n-card
style="width: 800px"
title="宙斯的眷顾"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-data-table
:loading="zsLoading"
:columns="zsColumns"
:data="zsData"
:pagination="zsPagination"
:bordered="false"
/>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import api from '../../api.js'
import { NButton } from 'naive-ui'
import { h, ref, onMounted } from 'vue'
const loading = ref(false)
const columns = ref([
{
title: '期数',
key: 'periods',
align: 'center',
},
{
title: '开奖号码',
key: 'betting_name',
align: 'center',
},
{
title: '开奖数字',
key: 'betting_number',
align: 'center',
},
{
title: '操作人',
key: 'name',
align: 'center',
},
{
title: '开奖时间',
key: 'draw_time',
align: 'center',
},
{
title: '操作时间',
key: 'add_time',
align: 'center',
},
{
title: '操作IP',
key: 'ip',
align: 'center',
},
{
title: '操作',
slot: 'action',
align: 'center',
render: (row) => {
return [
h(
NButton,
{
strong: true,
secondary: true,
onClick: () => {
openModal(row)
},
},
{ default: () => '查看' }
),
]
},
},
])
const data = ref([])
onMounted(() => {
getData()
})
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getData()
},
})
const getData = async () => {
const res = await api.getZsBalloon({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
}
const showModal = ref(false)
const zsLoading = ref(false)
const zsColumns = ref([
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '数字',
key: 'PeriodsNum',
align: 'center',
},
{
title: '总投注(豆子)',
key: 'NumberSum',
align: 'center',
},
{
title: '赔付(积分)',
key: 'TotalCount',
align: 'center',
},
{
title: '选中用户',
key: 'User',
align: 'center',
},
{
title: '电话号码',
key: 'Phone',
align: 'center',
},
])
const zsData = ref([])
const zsPagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
zsPagination.value.page = page
getData()
},
})
const openModal = async (row) => {
showModal.value = true
zsLoading.value = true
const res = await api.getZsBalloonUser({
periods: row.periods,
draw_time: row.draw_time,
pageNum: zsPagination.value.page,
pageSize: zsPagination.value.pageSize,
})
zsData.value = res.data.data || []
zsPagination.value.itemCount = res.data.total
zsLoading.value = false
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,384 @@
<template>
<CommonPage show-footer :title="$route.title">
<div flex items-center justify-between>
<div mr-20 flex>
<div>游戏状态</div>
<n-switch
v-model:value="val1"
:checked-value="1"
:unchecked-value="2"
@update:value="handleUpdateValue1"
/>
</div>
<div flex items-center>
<div ml-20 flex items-center>
<div>开奖记录</div>
<n-button type="primary" @click="openData">预览</n-button>
</div>
<div ml-20 flex items-center>
<div>本局记录</div>
<n-button type="primary" @click="openJl(1)">预览</n-button>
</div>
<div ml-20 flex items-center>
<div>全部记录</div>
<n-button type="primary" @click="openJl(2)">预览</n-button>
</div>
</div>
</div>
<div flex>
<div>
预开期数
<span text-25>{{ list[0]?.Periods || 0 }}</span>
</div>
<div ml-20>
剩余开奖时间
<span text-25>{{ time || 0 }}</span>
</div>
<div ml-20>
本局总下注
<span text-25>{{ totalA || 0 }}</span>
</div>
</div>
<div>
<div>
<span text-25>{{ data[0]?.Periods || 0 }}</span>
期开奖结果
<span text-20>{{ data[0]?.Name || 0 }}</span>
</div>
</div>
<n-spin size="large" :show="show">
<div flex flex-wrap justify-between>
<n-card
v-for="item in list"
:key="item.ID"
class="mb-10 mt-10 h-120 w-250 flex-shrink-0 cursor-pointer"
:title="item.name"
>
<p text-25 op-60 :style="{ color: item.count === 0 ? 'green' : 'red' }">
{{ item.count }}
</p>
</n-card>
<div h-0 w-250></div>
<div h-0 w-250></div>
</div>
</n-spin>
<n-spin size="large" :show="show1">
<div flex flex-wrap justify-between>
<n-card
v-for="item in list1"
:key="item.ID"
class="mb-10 mt-10 h-150 w-250 flex-shrink-0 cursor-pointer"
:title="`${item.NumName}(${item.Name})`"
>
<p text-25 op-60 :style="{ color: item.Total === 0 ? 'green' : 'red' }">
{{ item.Total }}
</p>
<n-popconfirm @positive-click="handleUpdateValue(item.ID)">
<template #trigger>
<n-button>你猜</n-button>
</template>
一切都将一去杳然任何人都无法将其捕获
</n-popconfirm>
</n-card>
<div h-0 w-250></div>
<div h-0 w-250></div>
<div h-0 w-250></div>
<div h-0 w-250></div>
</div>
</n-spin>
<!-- 开奖记录 -->
<n-modal v-model:show="showModal">
<n-card
style="width: 900px"
title="开奖记录"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
:max-height="600"
:pagination="false"
:bordered="false"
remote
/>
</n-card>
</n-modal>
<!-- 游戏记录 -->
<n-modal v-model:show="jlModal">
<n-card
style="width: 900px"
:title="jlTitle"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<div>
<span>
总投注(豆子):
<span text-red>{{ jlData.total_number }}</span>
</span>
<span ml-20>
总积分:
<span text-red>{{ jlData.total_integral }}</span>
</span>
</div>
<n-data-table
:loading="jlLoading"
:columns="jlColumns"
:data="jlData.data"
:max-height="600"
:pagination="false"
:bordered="false"
remote
@update:sorter="handleSorterChange"
/>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import { h } from 'vue'
import api from '../../api'
import { getToken } from '@/utils'
const ws = new WebSocket(`wss://${import.meta.env.VITE_WS_URL}`)
const ws1 = new WebSocket(`wss://${import.meta.env.VITE_WS1_URL}`)
const list = ref([])
const list1 = ref([])
const val1 = ref(null)
// const val = ref(null)
const time = ref(null)
watch(time, (val) => {
if (Number(val) === 0) {
setTimeout(() => {
get_kj_jl()
}, 2500)
}
})
const show = ref(true)
const show1 = ref(true)
ws.onopen = () => {
console.log('1连接成功')
setInterval(() => {
ws.send('ping')
}, 2500)
}
const totalA = ref(null)
ws.onmessage = (msg) => {
const res = JSON.parse(msg.data)
list.value = res.betting.sort((a, b) => b.Total - a.Total)
show.value = false
list1.value = res.list.sort((a, b) => b.Total - a.Total)
show1.value = false
totalA.value = res.total
}
ws1.onopen = () => {
console.log('2连接成功')
setInterval(() => {
ws.send('ping')
}, 2500)
}
ws1.onmessage = (msg) => {
const res = JSON.parse(msg.data)
switch (res.code) {
case 200:
// let num = res.data
time.value = res.data
break
case 301:
$message.error(res.msg)
break
}
}
onMounted(() => {
// get_data()
get_data1()
get_kj_jl()
})
// const get_data = async () => {
// const res = await api.getDS()
// val.value = res.data.diceStatus
// }
const get_data1 = async () => {
const res = await api.getData()
val1.value = res.data.diceStart
}
onBeforeUnmount(() => {
ws.close()
})
const handleUpdateValue = async (e) => {
const res = await api.setDS({
status: 1,
id: e,
user_id: getToken(),
Periods: list.value[0]?.Periods,
})
$message.success(res.msg)
// get_data()
}
const handleUpdateValue1 = async (e) => {
const res = await api.setStatus({
start: e,
})
$message.success(res.msg)
get_data1()
}
const showModal = ref(false)
const loading = ref(false)
const columns = ref([
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '开奖号码',
key: 'Name',
align: 'center',
},
{
title: '开奖时间',
key: 'DrawTime',
align: 'center',
},
{
title: '开奖号码',
slot: 'Num',
align: 'center',
render: (row) => {
return h('p', `${row.Start}-${row.End}`)
},
},
])
const data = ref([])
const openData = () => {
showModal.value = true
get_kj_jl()
}
const get_kj_jl = async () => {
try {
loading.value = true
const res = await api.getKJList()
console.log(res)
data.value = res.data.data
loading.value = false
} catch (error) {
$message.error(error.msg)
throw error
}
}
const jlModal = ref(false)
const jlTitle = ref('')
const jlData = ref({})
const jlColumns = ref([
{
title: '昵称',
key: 'User',
align: 'center',
},
{
title: '电话',
key: 'Phone',
align: 'center',
},
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '下注豆子',
key: 'TotalCount',
align: 'center',
sorter: true,
sortOrder: false,
},
{
title: '赢积分',
key: 'NumberSum',
align: 'center',
sorter: true,
sortOrder: false,
},
{
title: '购买号码',
key: 'PeriodsNum',
align: 'center',
},
{
title: '时间',
key: 'DrawTime',
align: 'center',
},
])
const jlLoading = ref(false)
const openJl = async (e) => {
try {
jlModal.value = true
jlTitle.value = e === 1 ? '本局游戏记录' : '全部游戏记录'
jlLoading.value = true
const res = e === 1 ? await api.nowUser() : await api.allUser()
jlData.value = res.data
} catch (error) {
$message.error(error.msg)
}
jlLoading.value = false
}
const handleSorterChange = (sorter) => {
if (!jlLoading.value) {
jlLoading.value = true
switch (sorter.columnKey) {
case 'TotalCount':
jlColumns.value[3].sortOrder = !sorter ? false : sorter.order
jlData.value.data = jlData.value.data.sort((a, b) => {
if (sorter.order === 'descend') return b.TotalCount - a.TotalCount
return a.TotalCount - b.TotalCount
})
break
case 'NumberSum':
jlColumns.value[4].sortOrder = !sorter ? false : sorter.order
jlData.value.data = jlData.value.data.sort((a, b) => {
if (sorter.order === 'descend') return b.NumberSum - a.NumberSum
return a.NumberSum - b.NumberSum
})
break
}
jlLoading.value = false
}
}
</script>
<style lang="scss" scoped></style>

View File

@@ -0,0 +1,214 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-grid class="mb-10" x-gap="12" :cols="4">
<n-gi>
<n-date-picker
v-model:formatted-value="range"
value-format="yyyy-MM-dd"
type="daterange"
clearable
/>
</n-gi>
<n-gi>
<n-button type="primary" @click="getList">搜索</n-button>
<n-button ml-10 @click="clear">重置</n-button>
</n-gi>
</n-grid>
<div w-full flex items-center>
<Echarts :loading="loading" :option="option" />
<Echarts :loading="loading" :option="option1" />
</div>
<div w-full flex items-center justify-between>
<n-card title="开奖记录" :bordered="false" content-style="padding: 0;">
<n-data-table
:max-height="500"
:loading="loading"
:columns="columns"
:data="data"
:bordered="true"
:virtual-scroll="true"
remote
/>
</n-card>
</div>
</CommonPage>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import api from '../../api.js'
import Echarts from '@/components/Echarts.vue'
import dayjs from 'dayjs'
const loading = ref(false)
const range = ref(null)
const option = ref({
title: {
text: '单期下注(豆子)/赔付(积分) 统计',
left: 'center',
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true,
},
xAxis: {
type: 'category',
data: [],
},
yAxis: {
type: 'value',
},
series: [
{
name: '下注(豆子)',
data: [],
type: 'bar',
},
{
name: '赔付(积分)',
data: [],
type: 'bar',
},
],
dataZoom: [
{
type: 'inside',
},
{
type: 'slider',
},
],
})
const option1 = ref({
title: {
text: '总下注(豆子)/总赔付(积分)',
left: 'center',
},
tooltip: {
trigger: 'item',
},
legend: {
orient: 'vertical',
left: 'right',
},
series: [
{
type: 'pie',
radius: '50%',
data: [],
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)',
},
},
},
],
})
const data = ref([])
const columns = ref([
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '购买号码',
key: 'Name',
align: 'center',
},
{
title: '下注',
key: 'NumberSum',
align: 'center',
},
{
title: '赔付',
key: 'TotalCount',
align: 'center',
},
{
title: '时间',
key: 'Date',
align: 'center',
},
])
onMounted(() => {
getList()
})
const clear = () => {
range.value = null
getList()
}
const getList = async () => {
loading.value = true
const dataObj = {
StartTime: dayjs().format('YYYY-MM-DD'),
EndTime: dayjs().format('YYYY-MM-DD'),
}
if (range.value) {
dataObj.StartTime = range.value[0]
dataObj.EndTime = range.value[1]
}
const res = await api.getStatistics(dataObj)
const newData = res.data.data || []
data.value = newData
option.value.xAxis.data = []
option.value.series[0].data = []
option.value.series[1].data = []
option1.value.series[0].data = []
if (newData.length > 0) {
res.data.data.forEach((item) => {
const a = (
((res.data.total * 10) / (res.data.total * 10 + res.data.totalDices)) *
100
).toFixed(2)
const b = ((res.data.totalDices / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(
2
)
option.value.xAxis.data.push(`${item.Periods}期-${item.Name}`)
option.value.series[0].name = `下注(豆子): ${a}%`
option.value.series[0].data.push(item.NumberSum * 10)
option.value.series[1].name = `赔付(积分): ${b}%`
option.value.series[1].data.push(item.TotalCount)
})
const a = (((res.data.total * 10) / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(
2
)
const b = ((res.data.totalDices / (res.data.total * 10 + res.data.totalDices)) * 100).toFixed(2)
option1.value.series[0].data.push({ value: res.data.total * 10, name: `总下注: ${a}%` })
option1.value.series[0].data.push({
value: res.data.totalDices,
name: `总赔付: ${b}%`,
})
}
loading.value = false
}
</script>
<style lang="scss" scoped>
.chart {
width: 50%;
height: 400px;
}
</style>

View File

@@ -0,0 +1,189 @@
<template>
<CommonPage show-footer :title="$route.title">
<n-data-table
:max-height="500"
:loading="loading"
:columns="columns"
:data="data"
:bordered="true"
:virtual-scroll="true"
:pagination="pagination"
remote
/>
<!-- -->
<n-modal v-model:show="showModal">
<n-card
style="width: 800px"
title="宙斯的眷顾"
:bordered="false"
size="huge"
role="dialog"
aria-modal="true"
>
<n-data-table
:loading="zsLoading"
:columns="zsColumns"
:data="zsData"
:pagination="zsPagination"
:bordered="false"
/>
</n-card>
</n-modal>
</CommonPage>
</template>
<script setup>
import api from '../../api.js'
import { NButton } from 'naive-ui'
import { h, ref, onMounted } from 'vue'
const loading = ref(false)
const columns = ref([
{
title: '期数',
key: 'periods',
align: 'center',
},
{
title: '开奖号码',
key: 'betting_name',
align: 'center',
},
{
title: '开奖数字',
key: 'betting_number',
align: 'center',
},
{
title: '操作人',
key: 'name',
align: 'center',
},
{
title: '开奖时间',
key: 'draw_time',
align: 'center',
},
{
title: '操作时间',
key: 'add_time',
align: 'center',
},
{
title: '操作IP',
key: 'ip',
align: 'center',
},
{
title: '操作',
slot: 'action',
align: 'center',
render: (row) => {
return [
h(
NButton,
{
strong: true,
secondary: true,
onClick: () => {
openModal(row)
},
},
{ default: () => '查看' }
),
]
},
},
])
const data = ref([])
onMounted(() => {
getData()
})
const pagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
pagination.value.page = page
getData()
},
})
const getData = async () => {
const res = await api.getLog({
pageNum: pagination.value.page,
pageSize: pagination.value.pageSize,
})
data.value = res.data.data || []
pagination.value.itemCount = res.data.total
}
const showModal = ref(false)
const zsLoading = ref(false)
const zsColumns = ref([
{
title: '期数',
key: 'Periods',
align: 'center',
},
{
title: '数字',
key: 'PeriodsNum',
align: 'center',
},
{
title: '总投注(豆子)',
key: 'NumberSum',
align: 'center',
},
{
title: '赔付(积分)',
key: 'TotalCount',
align: 'center',
},
{
title: '选中用户',
key: 'User',
align: 'center',
},
{
title: '电话号码',
key: 'Phone',
align: 'center',
},
])
const zsData = ref([])
const zsPagination = ref({
page: 1,
pageSize: 10,
itemCount: 0,
onChange: (page) => {
zsPagination.value.page = page
getData()
},
})
const openModal = async (row) => {
showModal.value = true
zsLoading.value = true
const res = await api.getDetail({
periods: row.periods,
draw_time: row.draw_time,
pageNum: zsPagination.value.page,
pageSize: zsPagination.value.pageSize,
})
zsData.value = res.data.data || []
zsPagination.value.itemCount = res.data.total
zsLoading.value = false
}
</script>
<style lang="scss" scoped></style>

Some files were not shown because too many files have changed in this diff Show More