初始化

This commit is contained in:
2026-09-17 14:44:18 +08:00
parent aa37fc4119
commit d2946f9512
24 changed files with 1006 additions and 92 deletions

37
pnpm-lock.yaml generated
View File

@@ -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
View 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

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
import { get, post } from '../request.js'
export function dashboardStatsApi() {
return get('dashboard/stats')
}

View 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
View 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)
}

View 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
View 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)
}

View File

@@ -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

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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 } }
}
})

View File

@@ -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
View 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>

View 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>

View 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>

View File

@@ -1,11 +0,0 @@
<script setup lang="ts">
</script>
<template>
你好
</template>
<style scoped>
</style>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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/, '')
}