初始化
This commit is contained in:
37
pnpm-lock.yaml
generated
37
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@ant-design/icons-vue':
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(vue@3.5.13)
|
||||
'@fortawesome/fontawesome-free':
|
||||
specifier: ^6.7.2
|
||||
version: 6.7.2
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
version: 4.1.4(vite@6.3.3(jiti@2.4.2)(lightningcss@1.29.2)(sass@1.88.0))
|
||||
@@ -23,9 +26,6 @@ importers:
|
||||
ant-design-vue:
|
||||
specifier: ^4.1.1
|
||||
version: 4.2.6(vue@3.5.13)
|
||||
aos:
|
||||
specifier: ^2.3.4
|
||||
version: 2.3.4
|
||||
axios:
|
||||
specifier: ^1.6.2
|
||||
version: 1.9.0
|
||||
@@ -263,6 +263,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2':
|
||||
resolution: {integrity: sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0':
|
||||
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
|
||||
|
||||
@@ -632,9 +636,6 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3.2.0'
|
||||
|
||||
aos@2.3.4:
|
||||
resolution: {integrity: sha512-zh/ahtR2yME4I51z8IttIt4lC1Nw0ktsFtmeDzID1m9naJnWXhCoARaCgNOGXb5CLy3zm+wqmRAEgMYB5E2HUw==}
|
||||
|
||||
array-tree-filter@2.1.0:
|
||||
resolution: {integrity: sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==}
|
||||
|
||||
@@ -659,9 +660,6 @@ packages:
|
||||
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
|
||||
engines: {node: '>= 14.16.0'}
|
||||
|
||||
classlist-polyfill@1.2.0:
|
||||
resolution: {integrity: sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -893,12 +891,6 @@ packages:
|
||||
lodash-es@4.17.21:
|
||||
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
|
||||
|
||||
lodash.debounce@4.0.8:
|
||||
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
|
||||
|
||||
lodash.throttle@4.1.1:
|
||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||
|
||||
lodash@4.17.21:
|
||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||
|
||||
@@ -908,6 +900,7 @@ packages:
|
||||
|
||||
lucide-vue-next@0.507.0:
|
||||
resolution: {integrity: sha512-n0AZRmez4xq5vu5ZOfnrhC5wKBpu5tpwDHA6Ue2+sKyJv3USAPxqIerXLbdYbkHRW1tVZ3U9GE1MACsu6X/Z7Q==}
|
||||
deprecated: Package deprecated. Please use @lucide/vue instead.
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
@@ -1229,6 +1222,8 @@ snapshots:
|
||||
'@esbuild/win32-x64@0.25.3':
|
||||
optional: true
|
||||
|
||||
'@fortawesome/fontawesome-free@6.7.2': {}
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
@@ -1547,12 +1542,6 @@ snapshots:
|
||||
vue-types: 3.0.2(vue@3.5.13)
|
||||
warning: 4.0.3
|
||||
|
||||
aos@2.3.4:
|
||||
dependencies:
|
||||
classlist-polyfill: 1.2.0
|
||||
lodash.debounce: 4.0.8
|
||||
lodash.throttle: 4.1.1
|
||||
|
||||
array-tree-filter@2.1.0: {}
|
||||
|
||||
async-validator@4.2.5: {}
|
||||
@@ -1581,8 +1570,6 @@ snapshots:
|
||||
dependencies:
|
||||
readdirp: 4.1.2
|
||||
|
||||
classlist-polyfill@1.2.0: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -1787,10 +1774,6 @@ snapshots:
|
||||
|
||||
lodash-es@4.17.21: {}
|
||||
|
||||
lodash.debounce@4.0.8: {}
|
||||
|
||||
lodash.throttle@4.1.1: {}
|
||||
|
||||
lodash@4.17.21: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
||||
10
pnpm-workspace.yaml
Normal file
10
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
allowBuilds:
|
||||
'@parcel/watcher': true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
vue-demi: true
|
||||
onlyBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- core-js
|
||||
- esbuild
|
||||
- vue-demi
|
||||
21
src/App.vue
21
src/App.vue
@@ -1,10 +1,23 @@
|
||||
<script setup>
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
<!-- 统一设计语言:「橘子汽水」珊瑚橙(与小程序一致,见 docs/design-language.md) -->
|
||||
<a-config-provider
|
||||
:locale="zhCN"
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#FF6B35',
|
||||
colorInfo: '#FF6B35',
|
||||
colorLink: '#FF6B35',
|
||||
colorBgLayout: '#FFF9F2',
|
||||
borderRadius: 10,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
<style lang='scss' scoped>
|
||||
|
||||
</style>
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
5
src/api/request/dashboard.js
Normal file
5
src/api/request/dashboard.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
export function dashboardStatsApi() {
|
||||
return get('dashboard/stats')
|
||||
}
|
||||
9
src/api/request/feedback.js
Normal file
9
src/api/request/feedback.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
export function feedbackListApi(params) {
|
||||
return get('feedback/list', params)
|
||||
}
|
||||
|
||||
export function feedbackHandleApi(id) {
|
||||
return post('feedback/handle', { id })
|
||||
}
|
||||
9
src/api/request/level.js
Normal file
9
src/api/request/level.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
export function levelListApi() {
|
||||
return get('level/list')
|
||||
}
|
||||
|
||||
export function levelSaveApi(data) {
|
||||
return post('level/save', data)
|
||||
}
|
||||
9
src/api/request/module.js
Normal file
9
src/api/request/module.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
export function moduleListApi() {
|
||||
return get('module/list')
|
||||
}
|
||||
|
||||
export function moduleSaveApi(data) {
|
||||
return post('module/save', data)
|
||||
}
|
||||
13
src/api/request/tool.js
Normal file
13
src/api/request/tool.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { get, post } from '../request.js'
|
||||
|
||||
export function toolListApi(params) {
|
||||
return get('tools/all', { page: 1, pageSize: 50, includeDisabled: true, ...params })
|
||||
}
|
||||
|
||||
export function toolSaveApi(data) {
|
||||
return post('tools/save', data)
|
||||
}
|
||||
|
||||
export function toolToggleApi(data) {
|
||||
return post('tools/toggle', data)
|
||||
}
|
||||
@@ -28,6 +28,20 @@ export function userListApi(credentials) {
|
||||
return get(`${prefix}/list`, credentials)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户等级
|
||||
*/
|
||||
export function userSetLevelApi(data) {
|
||||
return post(`${prefix}/set-level`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 启停用户
|
||||
*/
|
||||
export function userStatusApi(data) {
|
||||
return post(`${prefix}/status`, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @param credentials
|
||||
|
||||
34
src/components/BarRank.vue
Normal file
34
src/components/BarRank.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
/** 工具使用 TOP 榜(手写条形,不引入图表库;撞色条形轮换) */
|
||||
const props = defineProps({
|
||||
items: { type: Array, default: () => [] }, // [{tool_key, name, count}]
|
||||
})
|
||||
|
||||
const BAR_COLORS = ['#FF6B35', '#FFC53D', '#3ECFB2', '#4DA8FF', '#FF7BA9', '#8B7CF6']
|
||||
|
||||
function width(count) {
|
||||
const max = Math.max(1, ...(props.items || []).map((i) => i.count))
|
||||
return `${Math.round((count / max) * 100)}%`
|
||||
}
|
||||
|
||||
function barColor(i) {
|
||||
return BAR_COLORS[i % BAR_COLORS.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!items.length" class="text-sm text-slate-400 py-8 text-center">暂无使用数据</div>
|
||||
<div v-for="(it, i) in items" :key="it.tool_key" class="flex items-center gap-3">
|
||||
<span class="w-5 text-xs text-slate-400 text-right">{{ i + 1 }}</span>
|
||||
<span class="w-24 shrink-0 truncate text-sm text-stone-700" :title="it.name">{{ it.name || it.tool_key }}</span>
|
||||
<div class="flex-1 h-5 bg-[#FFF3EC] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{ width: width(it.count), background: `linear-gradient(90deg, ${barColor(i)}CC, ${barColor(i)})` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="w-12 text-right text-sm font-semibold text-[#E8531A]">{{ it.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
60
src/components/TrendChart.vue
Normal file
60
src/components/TrendChart.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup>
|
||||
/** 手写 SVG 折线趋势图(不引入图表库) */
|
||||
defineProps({
|
||||
points: { type: Array, default: () => [] }, // [{date:'2026-08-29', count:0}]
|
||||
color: { type: String, default: '#FF6B35' },
|
||||
})
|
||||
|
||||
function scaleY() {
|
||||
return 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
computed: {
|
||||
layout() {
|
||||
const W = 640
|
||||
const H = 220
|
||||
const pad = { l: 36, r: 16, t: 16, b: 28 }
|
||||
const pts = this.points || []
|
||||
const max = Math.max(1, ...pts.map((p) => p.count))
|
||||
const innerW = W - pad.l - pad.r
|
||||
const innerH = H - pad.t - pad.b
|
||||
const n = Math.max(1, pts.length - 1)
|
||||
const coords = pts.map((p, i) => ({
|
||||
x: pad.l + (innerW * i) / n,
|
||||
y: pad.t + innerH - (innerH * p.count) / max,
|
||||
...p,
|
||||
}))
|
||||
return { W, H, pad, max, coords }
|
||||
},
|
||||
path() {
|
||||
return this.layout.coords.map((c, i) => `${i === 0 ? 'M' : 'L'}${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ')
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :viewBox="`0 0 ${layout.W} ${layout.H}`" class="w-full">
|
||||
<!-- 横向网格线 -->
|
||||
<line v-for="i in 4" :key="i" :x1="layout.pad.l" :x2="layout.W - layout.pad.r"
|
||||
:y1="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3"
|
||||
:y2="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3" stroke="#E2E8F0" stroke-width="1" />
|
||||
<!-- 折线 -->
|
||||
<polyline :points="layout.coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ')"
|
||||
fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<circle v-for="c in layout.coords" :key="c.date" :cx="c.x" :cy="c.y" r="3.5" :fill="color" />
|
||||
<!-- 数值标签 -->
|
||||
<text v-for="c in layout.coords" :key="'v' + c.date" :x="c.x" :y="c.y - 10" text-anchor="middle"
|
||||
class="fill-slate-500" font-size="11">{{ c.count }}</text>
|
||||
<!-- 日期标签 -->
|
||||
<text v-for="c in layout.coords" :key="'d' + c.date" :x="c.x" :y="layout.H - 8" text-anchor="middle"
|
||||
class="fill-slate-400" font-size="10">{{ c.date.slice(5) }}</text>
|
||||
<!-- 最大值刻度 -->
|
||||
<text :x="layout.pad.l - 6" :y="layout.pad.t + 4" text-anchor="end" class="fill-slate-400" font-size="10">
|
||||
{{ layout.max }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
70
src/layouts/AdminLayout.vue
Normal file
70
src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const menus = [
|
||||
{ key: '/dashboard', label: '数据看板' },
|
||||
{ key: '/tools', label: '工具管理' },
|
||||
{ key: '/modules', label: '模块管理' },
|
||||
{ key: '/levels', label: '等级管理' },
|
||||
{ key: '/users', label: '用户管理' },
|
||||
{ key: '/feedbacks', label: '反馈管理' },
|
||||
]
|
||||
|
||||
const selectedKeys = computed(() => [route.path])
|
||||
const nickname = computed(() => userStore.user?.nick_name || '管理员')
|
||||
|
||||
function onMenuClick({ key }) {
|
||||
router.push(key)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
userStore.logout()
|
||||
router.replace({ name: 'Login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="min-h-screen">
|
||||
<a-layout-sider theme="light" width="208" class="border-r border-orange-100/70">
|
||||
<div class="px-6 py-5 flex items-center gap-3">
|
||||
<!-- 品牌标:橙色渐变圆角块 + 火箭贴纸 -->
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-gradient-to-br from-[#FF9F4D] to-[#FB4E14] flex items-center justify-center shadow-[0_6px_14px_rgba(251,78,20,0.35)]"
|
||||
>
|
||||
<svg viewBox="0 0 64 64" class="w-6 h-6">
|
||||
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
|
||||
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-base font-extrabold tracking-tight text-stone-800">工具百宝箱</div>
|
||||
<div class="text-xs text-stone-400 mt-0.5">管理后台</div>
|
||||
</div>
|
||||
</div>
|
||||
<a-menu mode="inline" :selected-keys="selectedKeys" :items="menus" @click="onMenuClick" />
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<!-- AntD header 默认深底会压过工具类,用内联样式固定奶白 -->
|
||||
<a-layout-header
|
||||
class="flex items-center justify-between px-6 !h-14 border-b border-orange-100/70"
|
||||
style="background: rgba(255, 255, 255, 0.92)"
|
||||
>
|
||||
<div class="text-stone-700 font-medium">{{ route.meta.title }}</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-stone-600">{{ nickname }}</span>
|
||||
<a-button size="small" @click="logout">退出</a-button>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content class="p-6">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white py-4 px-6 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Logo -->
|
||||
<img src="https://via.placeholder.com/40" alt="Logo" class="h-8 w-8 rounded-full">
|
||||
<span class="font-bold">My App</span>
|
||||
</div>
|
||||
|
||||
<!-- Menu -->
|
||||
<nav class="hidden md:flex space-x-4">
|
||||
<a href="#" class="hover:text-gray-300">首页</a>
|
||||
<a href="#" class="hover:text-gray-300">About</a>
|
||||
<a href="#" class="hover:text-gray-300">Services</a>
|
||||
<a href="#" class="hover:text-gray-300">Contact</a>
|
||||
</nav>
|
||||
|
||||
<!-- User Profile -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<button class="text-sm bg-gray-800 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded">
|
||||
Personal Center
|
||||
</button>
|
||||
<img src="https://via.placeholder.com/40" alt="User Avatar" class="h-8 w-8 rounded-full cursor-pointer">
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
</style>
|
||||
@@ -1,15 +1,24 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import MainLayout from '@/layouts/Header.vue'
|
||||
|
||||
// 在路由配置中添加个人中心路由
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
children: [
|
||||
{ path: '', name: 'Home', component: () => import('@/views/index/index.vue') },
|
||||
]
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/dashboard/Dashboard.vue'), meta: { title: '数据看板' } },
|
||||
{ path: 'tools', name: 'ToolManage', component: () => import('@/views/tools/ToolManage.vue'), meta: { title: '工具管理' } },
|
||||
{ path: 'modules', name: 'ModuleManage', component: () => import('@/views/modules/ModuleManage.vue'), meta: { title: '模块管理' } },
|
||||
{ path: 'levels', name: 'LevelManage', component: () => import('@/views/levels/LevelManage.vue'), meta: { title: '等级管理' } },
|
||||
{ path: 'users', name: 'UserList', component: () => import('@/views/users/UserList.vue'), meta: { title: '用户管理' } },
|
||||
{ path: 'feedbacks', name: 'FeedbackList', component: () => import('@/views/feedbacks/FeedbackList.vue'), meta: { title: '反馈管理' } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -18,21 +27,17 @@ const router = createRouter({
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
return savedPosition || { top: 0 }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
router.beforeEach((to) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
if (to.name !== 'Login' && !userStore.isAuthenticated) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if (to.name === 'Login' && userStore.isAuthenticated) {
|
||||
return { path: '/' }
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
router.beforeEach((to, from) => {
|
||||
const userStore = useUserStore()
|
||||
if (to.meta.requiresAuth && !userStore.user) {
|
||||
return { name: 'Login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss";
|
||||
|
||||
/* 「橘子汽水」全局底色:避免 layout 之外露出默认深色 */
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
background-color: #fff9f2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: #3d2a1e;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'MiSans', 'HarmonyOS Sans',
|
||||
'Helvetica Neue', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
79
src/views/Login.vue
Normal file
79
src/views/Login.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
const form = reactive({ username: '', password: '', remember: true })
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.username || !form.password) {
|
||||
message.warning('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login(
|
||||
{ username: form.username, password: form.password, remember: form.remember },
|
||||
() => router.replace(route.query.redirect || '/'),
|
||||
)
|
||||
} catch {
|
||||
/* 业务错误已由 request 拦截器提示 */
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gradient-to-br from-[#FFF3E8] via-[#FFF9F2] to-[#FFE8DC] flex items-center justify-center px-4 relative overflow-hidden">
|
||||
<!-- 背景贴纸装饰(手绘描边风,纯装饰) -->
|
||||
<svg viewBox="0 0 64 64" class="w-24 h-24 absolute left-[12%] top-[16%] opacity-80 animate-pulse">
|
||||
<path d="M32 6l7.6 15.9 17.4 2.3-12.8 12.1 3.3 17.2L32 45l-15.5 7.5 3.3-17.2L7 24.2l17.4-2.3z"
|
||||
fill="#FFC53D" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-16 h-16 absolute right-[14%] top-[24%] opacity-80">
|
||||
<path d="M32 6l7.6 15.9 17.4 2.3-12.8 12.1 3.3 17.2L32 45l-15.5 7.5 3.3-17.2L7 24.2l17.4-2.3z"
|
||||
fill="#3ECFB2" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-20 h-20 absolute left-[18%] bottom-[14%] opacity-80">
|
||||
<path d="M9 45a23 23 0 0 1 46 0" fill="none" stroke="#FF6B35" stroke-width="7" stroke-linecap="round" />
|
||||
<path d="M17 45a15 15 0 0 1 30 0" fill="none" stroke="#FFC53D" stroke-width="7" stroke-linecap="round" />
|
||||
<path d="M25 45a7 7 0 0 1 14 0" fill="none" stroke="#3ECFB2" stroke-width="7" stroke-linecap="round" />
|
||||
</svg>
|
||||
<svg viewBox="0 0 64 64" class="w-14 h-14 absolute right-[18%] bottom-[18%] opacity-80">
|
||||
<path d="M36 4L14 36h12l-4 24 24-34H32l4-22z" fill="#FF7BA9" stroke="#3D2A1E" stroke-width="3" stroke-linejoin="round" />
|
||||
</svg>
|
||||
|
||||
<div class="w-full max-w-sm bg-white/95 rounded-3xl border border-orange-100 shadow-[0_18px_44px_rgba(107,47,15,0.12)] p-8 relative z-10">
|
||||
<div class="mb-8 text-center">
|
||||
<!-- 品牌标 -->
|
||||
<div class="mx-auto w-14 h-14 rounded-2xl bg-gradient-to-br from-[#FF9F4D] to-[#FB4E14] flex items-center justify-center shadow-[0_10px_22px_rgba(251,78,20,0.35)] mb-4">
|
||||
<svg viewBox="0 0 64 64" class="w-8 h-8">
|
||||
<path d="M32 4c9 7 13 17 13 27l-5 9H24l-5-9C19 21 23 11 32 4z" fill="#fff" />
|
||||
<circle cx="32" cy="24" r="6" fill="#FFE8DC" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-2xl font-extrabold tracking-tight text-stone-800">工具百宝箱</div>
|
||||
<div class="text-sm text-stone-400 mt-1">管理后台</div>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" @submit.prevent="onSubmit">
|
||||
<a-form-item label="账号">
|
||||
<a-input v-model:value="form.username" placeholder="请输入账号" size="large" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码">
|
||||
<a-input-password v-model:value="form.password" placeholder="请输入密码" size="large" />
|
||||
</a-form-item>
|
||||
<a-checkbox v-model:checked="form.remember" class="mb-4">记住我</a-checkbox>
|
||||
<a-button type="primary" html-type="submit" block size="large" :loading="loading">登 录</a-button>
|
||||
</a-form>
|
||||
|
||||
<div class="mt-6 text-center text-xs text-stone-400">开发环境种子账号:admin / admin123</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
57
src/views/dashboard/Dashboard.vue
Normal file
57
src/views/dashboard/Dashboard.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { dashboardStatsApi } from '@/api/request/dashboard'
|
||||
import TrendChart from '@/components/TrendChart.vue'
|
||||
import BarRank from '@/components/BarRank.vue'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({ userCount: 0, todayActive: 0, totalUsage: 0, weekTrend: [], toolsTop: [] })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
stats.value = await dashboardStatsApi()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const cards = [
|
||||
{ key: 'userCount', label: '累计用户', bg: '#FFE8DC', deep: '#E8531A', icon: 'M32 6a12 12 0 1 1 0 24 12 12 0 0 1 0-24zM10 56c2-12 10-18 22-18s20 6 22 18' },
|
||||
{ key: 'todayActive', label: '今日活跃', bg: '#DFF7F0', deep: '#0FA385', icon: 'M32 8l7.6 15.9L57 26.2 44.2 38.3l3.3 17.2L32 47.5 16.5 55.5l3.3-17.2L7 26.2l17.4-2.3z' },
|
||||
{ key: 'totalUsage', label: '累计使用次数', bg: '#FFF4D6', deep: '#B57F00', icon: 'M36 4L14 36h12l-4 24 24-34H32l4-22z' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="py-20 text-center text-slate-400">加载中…</div>
|
||||
<div v-else class="flex flex-col gap-6">
|
||||
<!-- 统计卡(彩色淡底 + 深色数字 + 描边小图标) -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="c in cards" :key="c.key"
|
||||
class="rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]"
|
||||
:style="{ background: c.bg }"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-stone-500">{{ c.label }}</div>
|
||||
<svg viewBox="0 0 64 64" class="w-7 h-7 opacity-80">
|
||||
<path :d="c.icon" fill="none" :stroke="c.deep" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-extrabold mt-2 tabular-nums" :style="{ color: c.deep }">{{ stats[c.key] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 7 日趋势 -->
|
||||
<div class="bg-white rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]">
|
||||
<div class="font-semibold text-stone-700 mb-3">近 7 日使用趋势</div>
|
||||
<TrendChart :points="stats.weekTrend" />
|
||||
</div>
|
||||
|
||||
<!-- 工具使用 TOP10 -->
|
||||
<div class="bg-white rounded-2xl border border-orange-100/60 p-5 shadow-[0_10px_26px_rgba(107,47,15,0.06)]">
|
||||
<div class="font-semibold text-stone-700 mb-4">工具使用 TOP10</div>
|
||||
<BarRank :items="stats.toolsTop" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
68
src/views/feedbacks/FeedbackList.vue
Normal file
68
src/views/feedbacks/FeedbackList.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { feedbackListApi, feedbackHandleApi } from '@/api/request/feedback'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({ page: 1, pageSize: 20, status: undefined })
|
||||
|
||||
const columns = [
|
||||
{ title: '内容', dataIndex: 'content', key: 'content' },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 110 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await feedbackListApi({ ...query, status: query.status ?? undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function handle(record) {
|
||||
await feedbackHandleApi(record.id)
|
||||
record.status = 1
|
||||
message.success('已标记处理')
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-radio-group v-model:value="query.status" @change="load">
|
||||
<a-radio-button :value="undefined">全部</a-radio-button>
|
||||
<a-radio-button :value="0">待处理</a-radio-button>
|
||||
<a-radio-button :value="1">已处理</a-radio-button>
|
||||
</a-radio-group>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'default' : 'warning'">
|
||||
{{ record.status === 1 ? '已处理' : '待处理' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="record.status === 0" type="link" size="small" @click="handle(record)">标记已处理</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
你好
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
131
src/views/levels/LevelManage.vue
Normal file
131
src/views/levels/LevelManage.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { levelListApi, levelSaveApi } from '@/api/request/level'
|
||||
import { moduleListApi } from '@/api/request/module'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const modules = ref([])
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, levelKey: '', name: '', modules: [], sort: 0, isEnabled: 1, remark: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: '等级', key: 'name' },
|
||||
{ title: 'level_key', dataIndex: 'levelKey', key: 'levelKey', width: 110 },
|
||||
{ title: '包含模块', key: 'modules' },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '状态', key: 'isEnabled', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await levelListApi()
|
||||
list.value = res.list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
const res = await moduleListApi()
|
||||
modules.value = res.list
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadModules()
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, levelKey: '', name: '', modules: [], sort: list.value.length + 1, isEnabled: 1, remark: '' })
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, { ...record })
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || (!form.id && !form.levelKey)) {
|
||||
message.warning('请填写等级标识和名称')
|
||||
return
|
||||
}
|
||||
if (!form.modules.length) {
|
||||
message.warning('至少勾选一个模块')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await levelSaveApi({ ...form })
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const moduleName = (key) => modules.value.find((m) => m.moduleKey === key)?.name || key
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-slate-500">等级 = 一组功能模块授权;变更即时对小程序生效</div>
|
||||
<a-button type="primary" @click="openCreate">新增等级</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'modules'">
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<a-tag v-for="m in record.modules" :key="m" color="orange">{{ moduleName(m) }}</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.isEnabled === 1 ? 'success' : 'default'">
|
||||
{{ record.isEnabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑等级' : '新增等级'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="等级标识 level_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '如 v1 / v2'">
|
||||
<a-input v-model:value="form.levelKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" placeholder="如 基础版 / 进阶版" />
|
||||
</a-form-item>
|
||||
<a-form-item label="包含模块" extra="勾选即授予该等级可用的功能模块">
|
||||
<a-select v-model:value="form.modules" mode="multiple" placeholder="勾选模块"
|
||||
:options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea v-model:value="form.remark" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
110
src/views/modules/ModuleManage.vue
Normal file
110
src/views/modules/ModuleManage.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { moduleListApi, moduleSaveApi } from '@/api/request/module'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, moduleKey: '', name: '', icon: '', description: '', sort: 0, isEnabled: 1 })
|
||||
|
||||
const columns = [
|
||||
{ title: '模块', key: 'name' },
|
||||
{ title: 'module_key', dataIndex: 'moduleKey', key: 'moduleKey' },
|
||||
{ title: '工具数', dataIndex: 'toolCount', key: 'toolCount', width: 90 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '状态', key: 'isEnabled', width: 80 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await moduleListApi()
|
||||
list.value = res.list
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, moduleKey: '', name: '', icon: '', description: '', sort: 0, isEnabled: 1 })
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, { ...record })
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || (!form.id && !form.moduleKey)) {
|
||||
message.warning('请填写模块标识和名称')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await moduleSaveApi({ ...form })
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex justify-end">
|
||||
<a-button type="primary" @click="openCreate">新增模块</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading" :pagination="false">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-8 h-8 rounded-lg bg-[#FFF1E8] items-center justify-center text-[#E8531A] font-bold">
|
||||
{{ (record.name || '?').slice(0, 1) }}
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-tag :color="record.isEnabled === 1 ? 'success' : 'default'">
|
||||
{{ record.isEnabled === 1 ? '启用' : '停用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑模块' : '新增模块'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="模块标识 module_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '如 image / doc'">
|
||||
<a-input v-model:value="form.moduleKey" :disabled="!!form.id" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
165
src/views/tools/ToolManage.vue
Normal file
165
src/views/tools/ToolManage.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { toolListApi, toolSaveApi, toolToggleApi } from '@/api/request/tool'
|
||||
import { moduleListApi } from '@/api/request/module'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const modules = ref([])
|
||||
const query = reactive({ page: 1, pageSize: 20, moduleKey: undefined, keyword: '' })
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = reactive({ id: 0, toolKey: '', moduleKey: undefined, name: '', icon: '', description: '', sort: 0, isEnabled: 1, isHot: 0 })
|
||||
|
||||
const columns = [
|
||||
{ title: '工具', key: 'name' },
|
||||
{ title: 'tool_key', dataIndex: 'toolKey', key: 'toolKey' },
|
||||
{ title: '所属模块', dataIndex: 'moduleName', key: 'moduleName' },
|
||||
{ title: '使用量', dataIndex: 'usageCount', key: 'usageCount', width: 90 },
|
||||
{ title: '推荐位', key: 'isHot', width: 80 },
|
||||
{ title: '状态', key: 'isEnabled', width: 80 },
|
||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 70 },
|
||||
{ title: '操作', key: 'action', width: 80 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await toolListApi({ ...query, moduleKey: query.moduleKey || undefined, keyword: query.keyword || undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModules() {
|
||||
modules.value = await moduleListApi()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadModules()
|
||||
})
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, { id: 0, toolKey: '', moduleKey: undefined, name: '', icon: '', description: '', sort: 0, isEnabled: 1, isHot: 0 })
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(record) {
|
||||
Object.assign(form, {
|
||||
id: record.id,
|
||||
toolKey: record.toolKey,
|
||||
moduleKey: record.moduleKey,
|
||||
name: record.name,
|
||||
icon: record.icon,
|
||||
description: record.description,
|
||||
sort: record.sort,
|
||||
isEnabled: record.isEnabled,
|
||||
isHot: record.isHot,
|
||||
})
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.name || !form.moduleKey || (!form.id && !form.toolKey)) {
|
||||
message.warning('请填写完整:工具标识 / 名称 / 所属模块')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await toolSaveApi({ ...form })
|
||||
message.success('已保存')
|
||||
drawerOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(record, field) {
|
||||
await toolToggleApi({ id: record.id, field, value: record[field] })
|
||||
message.success('已更新')
|
||||
load()
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<a-select v-model:value="query.moduleKey" placeholder="全部模块" allowClear style="width: 160px"
|
||||
:options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" @change="load" />
|
||||
<a-input-search v-model:value="query.keyword" placeholder="名称 / tool_key" style="width: 240px"
|
||||
allowClear @search="load" />
|
||||
<div class="flex-1" />
|
||||
<a-button type="primary" @click="openCreate">新增工具</a-button>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-8 h-8 rounded-lg bg-[#FFF1E8] items-center justify-center text-[#E8531A] font-bold">
|
||||
{{ (record.name || '?').slice(0, 1) }}
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isHot'">
|
||||
<a-switch :checked="record.isHot === 1" size="small"
|
||||
@change="(v) => { record.isHot = v ? 1 : 0; toggle(record, 'is_hot') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'isEnabled'">
|
||||
<a-switch :checked="record.isEnabled === 1" size="small"
|
||||
@change="(v) => { record.isEnabled = v ? 1 : 0; toggle(record, 'is_enabled') }" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="openEdit(record)">编辑</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-drawer v-model:open="drawerOpen" :title="form.id ? '编辑工具' : '新增工具'" width="420">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="工具标识 tool_key" :extra="form.id ? '编辑时不可修改(跨端契约)' : '须与小程序注册表 src/config/tools.ts 一致'">
|
||||
<a-input v-model:value="form.toolKey" :disabled="!!form.id" placeholder="如 img-compress" />
|
||||
</a-form-item>
|
||||
<a-form-item label="名称">
|
||||
<a-input v-model:value="form.name" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属模块">
|
||||
<a-select v-model:value="form.moduleKey" :options="modules.map((m) => ({ value: m.moduleKey, label: m.name }))" />
|
||||
</a-form-item>
|
||||
<a-form-item label="描述">
|
||||
<a-textarea v-model:value="form.description" :rows="2" />
|
||||
</a-form-item>
|
||||
<a-form-item label="排序值">
|
||||
<a-input-number v-model:value="form.sort" :min="0" class="w-full" />
|
||||
</a-form-item>
|
||||
<a-form-item label="启用">
|
||||
<a-switch :checked="form.isEnabled === 1" @change="(v) => (form.isEnabled = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="工作台推荐位">
|
||||
<a-switch :checked="form.isHot === 1" @change="(v) => (form.isHot = v ? 1 : 0)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-button type="primary" block :loading="saving" @click="save">保存</a-button>
|
||||
</div>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
96
src/views/users/UserList.vue
Normal file
96
src/views/users/UserList.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { userListApi, userSetLevelApi, userStatusApi } from '@/api/request/user'
|
||||
import { levelListApi } from '@/api/request/level'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const levels = ref([])
|
||||
const query = reactive({ page: 1, pageSize: 20, keyword: '' })
|
||||
|
||||
const columns = [
|
||||
{ title: '用户', key: 'name' },
|
||||
{ title: 'openid', dataIndex: 'openid', key: 'openid', ellipsis: true },
|
||||
{ title: '等级', key: 'level', width: 140 },
|
||||
{ title: '使用次数', dataIndex: 'usageCount', key: 'usageCount', width: 100 },
|
||||
{ title: '最近使用', dataIndex: 'lastUsedAt', key: 'lastUsedAt', width: 170 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||||
{ title: '状态', key: 'status', width: 80 },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await userListApi({ ...query, keyword: query.keyword || undefined })
|
||||
list.value = res.list
|
||||
total.value = res.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLevels() {
|
||||
const res = await levelListApi()
|
||||
levels.value = res.list.filter((l) => l.isEnabled === 1)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadLevels()
|
||||
})
|
||||
|
||||
async function setLevel(record, levelKey) {
|
||||
await userSetLevelApi({ userId: record.id, levelKey })
|
||||
record.levelKey = levelKey
|
||||
const target = levels.value.find((l) => l.levelKey === levelKey)
|
||||
record.levelName = target?.name || levelKey
|
||||
message.success(`已设为 ${record.levelName}`)
|
||||
}
|
||||
|
||||
async function toggleStatus(record) {
|
||||
await userStatusApi({ userId: record.id, status: record.status })
|
||||
message.success(record.status === 1 ? '已启用' : '已禁用')
|
||||
}
|
||||
|
||||
function onTableChange(pag) {
|
||||
query.page = pag.current
|
||||
query.pageSize = pag.pageSize
|
||||
load()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<a-input-search v-model:value="query.keyword" placeholder="昵称 / openid" style="width: 280px" allowClear
|
||||
@search="load" />
|
||||
|
||||
<a-table :columns="columns" :data-source="list" row-key="id" :loading="loading"
|
||||
:pagination="{ current: query.page, pageSize: query.pageSize, total, showSizeChanger: false }"
|
||||
@change="onTableChange">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'name'">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-flex w-9 h-9 rounded-xl bg-[#FFF1E8] items-center justify-center text-[#E8531A]">
|
||||
<template v-if="record.avatarUrl">
|
||||
<img :src="record.avatarUrl" class="w-9 h-9 rounded-xl object-cover" alt="" />
|
||||
</template>
|
||||
<template v-else>{{ (record.nickname || '微').slice(0, 1) }}</template>
|
||||
</span>
|
||||
<span class="font-medium text-slate-700">{{ record.nickname }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'level'">
|
||||
<a-select :value="record.levelKey" size="small" style="width: 110px"
|
||||
:options="levels.map((l) => ({ value: l.levelKey, label: l.name }))"
|
||||
@change="(v) => setLevel(record, v)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-switch :checked="record.status === 1" size="small"
|
||||
@change="(v) => { record.status = v ? 1 : 0; toggleStatus(record) }" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,8 +18,9 @@ export default defineConfig({
|
||||
port: 8888,
|
||||
proxy: {
|
||||
'/api': {
|
||||
// target: 'http://127.0.0.1:18007/api/',
|
||||
target: 'http://127.0.0.1:18001/api/',
|
||||
// 注意:target 不要带 /api 路径后缀(http-proxy 会把 target 路径拼回请求前缀)
|
||||
// GoFrame 路由是 /login、/user/list 等,rewrite 已去掉 /api 前缀
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user