Initial project import
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>admin-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1854
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "admin-web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"vue": "^3.5.39",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.2",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.1.1",
|
||||
"vue-tsc": "^3.3.5"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const collapsed = ref(false)
|
||||
const selectedKeys = ref<string[]>([String(route.name)])
|
||||
|
||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||
|
||||
const nav = [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
{ key: 'Settings', icon: ControlOutlined, label: '系统设置' },
|
||||
{ key: 'Configs', icon: SettingOutlined, label: '品牌配置' },
|
||||
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
|
||||
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
||||
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout style="min-height: 100vh">
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="200"
|
||||
style="border-right: 1px solid #f0f0f0">
|
||||
<div style="padding: 18px 20px; font-size: 16px; font-weight: 700; white-space: nowrap; overflow: hidden;">
|
||||
<span style="color:#25211E;margin-right:6px">✎</span>记之 Admin
|
||||
</div>
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" :style="{ borderRight: 0 }"
|
||||
@click="({key}: {key: string}) => router.push({name: key})">
|
||||
<a-menu-item v-for="n in nav" :key="n.key">
|
||||
<component :is="n.icon" />
|
||||
<span>{{ n.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
<div style="position:absolute;bottom:16px;left:20px;font-size:11px;color:#bbb">v20260718-1630</div>
|
||||
</a-layout-sider>
|
||||
<a-layout>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 10px; min-height: 360px;">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
import axios from 'axios'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
const ADMIN_KEY = localStorage.getItem('miaoji_admin_key') || ''
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE || '',
|
||||
headers: { 'X-Admin-Key': ADMIN_KEY },
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
r => r,
|
||||
err => {
|
||||
if (err.response?.status === 401) {
|
||||
message.error('管理密钥无效,请在 localStorage 设置 miaoji_admin_key')
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export const api = {
|
||||
dashboard: () => http.get('/api/admin/dashboard').then(r => r.data),
|
||||
configs: () => http.get('/api/admin/configs').then(r => r.data),
|
||||
updateConfig: (id: number, value: string) => http.put(`/api/admin/configs/${id}`, { value }).then(r => r.data),
|
||||
createConfig: (key: string, value: string) => http.post('/api/admin/configs', { key, value }).then(r => r.data),
|
||||
personas: () => http.get('/api/admin/personas').then(r => r.data),
|
||||
createPersona: (d: any) => http.post('/api/admin/personas', d).then(r => r.data),
|
||||
updatePersona: (id: number, d: any) => http.put(`/api/admin/personas/${id}`, d).then(r => r.data),
|
||||
deletePersona: (id: number) => http.delete(`/api/admin/personas/${id}`),
|
||||
avatars: () => http.get('/api/admin/avatars').then(r => r.data),
|
||||
createAvatar: (d: any) => http.post('/api/admin/avatars', d).then(r => r.data),
|
||||
updateAvatar: (id: number, d: any) => http.put(`/api/admin/avatars/${id}`, d).then(r => r.data),
|
||||
deleteAvatar: (id: number) => http.delete(`/api/admin/avatars/${id}`),
|
||||
stickers: () => http.get('/api/admin/stickers').then(r => r.data),
|
||||
createSticker: (d: any) => http.post('/api/admin/stickers', d).then(r => r.data),
|
||||
updateSticker: (id: number, d: any) => http.put(`/api/admin/stickers/${id}`, d).then(r => r.data),
|
||||
deleteSticker: (id: number) => http.delete(`/api/admin/stickers/${id}`),
|
||||
users: (params: any) => http.get('/api/admin/users', { params }).then(r => r.data),
|
||||
toggleBan: (id: number) => http.put(`/api/admin/users/${id}/ban`).then(r => r.data),
|
||||
updateUserPermissions: (id: number, ai: boolean) => http.put(`/api/admin/users/${id}/permissions`, { ai }).then(r => r.data),
|
||||
updateAiChatQuota: (id: number, limit: number, period: string, resetUsage: boolean) => http.put(`/api/admin/users/${id}/ai-quota`, { limit, period, resetUsage }).then(r => r.data),
|
||||
cancelAccountClosure: (id: number) => http.put(`/api/admin/users/${id}/cancel-closure`).then(r => r.data),
|
||||
userStats: (id: number) => http.get(`/api/admin/users/${id}/stats`).then(r => r.data),
|
||||
sysCategories: () => http.get('/api/admin/categories').then(r => r.data),
|
||||
createSysCategory: (d: { name: string; iconKey: string; type: string }) => http.post('/api/admin/categories', d).then(r => r.data),
|
||||
updateSysCategory: (id: number, d: { name: string; iconKey: string; type: string }) => http.put(`/api/admin/categories/${id}`, d).then(r => r.data),
|
||||
deleteSysCategory: (id: number) => http.delete(`/api/admin/categories/${id}`),
|
||||
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
|
||||
}
|
||||
|
||||
export default http
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{ path: '/dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') },
|
||||
{ path: '/settings', name: 'Settings', component: () => import('../views/Settings.vue') },
|
||||
{ path: '/configs', name: 'Configs', component: () => import('../views/Configs.vue') },
|
||||
{ path: '/categories', name: 'SysCategories', component: () => import('../views/SysCategories.vue') },
|
||||
{ path: '/personas', name: 'Personas', component: () => import('../views/Personas.vue') },
|
||||
{ path: '/avatars', name: 'Avatars', component: () => import('../views/Avatars.vue') },
|
||||
{ path: '/stickers', name: 'Stickers', component: () => import('../views/Stickers.vue') },
|
||||
{ path: '/users', name: 'Users', component: () => import('../views/Users.vue') },
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,14 @@
|
||||
const shanghaiDateFormatter = new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
export function formatShanghaiDate(value?: string | null): string {
|
||||
if (!value) return ''
|
||||
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value) ? value : value + 'Z'
|
||||
const date = new Date(normalized)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return shanghaiDateFormatter.format(date).replaceAll('/', '-')
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Avatar { id: number; key: string; defaultName: string; speechTic: string; imageUrl: string | null; isEnabled: boolean }
|
||||
|
||||
const list = ref<Avatar[]>([])
|
||||
const loading = ref(true)
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<Avatar | null>(null)
|
||||
const form = ref({ key: '', name: '', speechTic: '', imageUrl: '', isEnabled: true })
|
||||
|
||||
const cols = [
|
||||
{ title: 'Key', dataIndex: 'key', key: 'key', width: 90 },
|
||||
{ title: '默认名', dataIndex: 'defaultName', key: 'name', width: 100 },
|
||||
{ title: '口癖', dataIndex: 'speechTic', key: 'tic', width: 80 },
|
||||
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
|
||||
{ title: '', key: 'act', width: 150 },
|
||||
]
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try { list.value = await api.avatars() } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
editing.value = null
|
||||
form.value = { key: '', name: '', speechTic: '', imageUrl: '', isEnabled: true }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(a: Avatar) {
|
||||
editing.value = a
|
||||
form.value = { key: a.key, name: a.defaultName, speechTic: a.speechTic, imageUrl: a.imageUrl || '', isEnabled: a.isEnabled }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = { ...form.value, imageUrl: form.value.imageUrl || null }
|
||||
if (editing.value) await api.updateAvatar(editing.value.id, data)
|
||||
else await api.createAvatar(data)
|
||||
message.success(editing.value ? '已更新' : '已创建')
|
||||
modalVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
await api.deleteAvatar(id)
|
||||
message.success('已删除')
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>AI 形象管理</h2>
|
||||
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建形象</a-button>
|
||||
</div>
|
||||
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'on'">
|
||||
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
|
||||
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
|
||||
<a-button size="small" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="modalVisible" :title="editing ? '编辑形象' : '新建形象'" @ok="save" :width="500">
|
||||
<a-form layout="vertical" style="margin-top:8px">
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="cat" /></a-form-item></a-col>
|
||||
<a-col :span="12"><a-form-item label="默认名"><a-input v-model:value="form.name" placeholder="小账喵" /></a-form-item></a-col>
|
||||
</a-row>
|
||||
<a-form-item label="口癖后缀">
|
||||
<a-input v-model:value="form.speechTic" placeholder="喵 / 汪 / 留空=无口癖" />
|
||||
<div style="color:#999;font-size:11px;margin-top:4px">口癖跟随形象——决策 20</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="头像图片 URL"><a-input v-model:value="form.imageUrl" placeholder="可选,CDN 地址" /></a-form-item>
|
||||
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
interface Config { id: number; key: string; value: string; version: number; updatedAt: string }
|
||||
|
||||
const list = ref<Config[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// Grouped by prefix
|
||||
const configMeta: Record<string, { label: string; desc: string; type: 'text' | 'number' | 'url' | 'switch' | 'select'; options?: string[] }> = {
|
||||
'brand.app_name': { label: 'App 名称', desc: 'App 内展示名称', type: 'text' },
|
||||
'brand.slogan': { label: 'App 标语', desc: '启动页/关于页口号', type: 'text' },
|
||||
'brand.logo_url': { label: 'Logo URL', desc: '品牌 Logo 远程地址', type: 'url' },
|
||||
'limit.daily_ai_messages': { label: '全局日限额', desc: '全站每日 AI 消息上限', type: 'number' },
|
||||
'limit.daily_ai_messages_per_user': { label: '每人日限额', desc: '单用户每日 AI 消息上限', type: 'number' },
|
||||
'limit.max_monthly_budget': { label: '最大月预算', desc: '用户可设置的最高月预算金额', type: 'number' },
|
||||
'feature.ocr_enabled': { label: 'OCR 拍照识别', desc: '是否开放 OCR 小票识别功能', type: 'switch' },
|
||||
'feature.voice_enabled': { label: '语音输入', desc: '是否开放语音记账功能', type: 'switch' },
|
||||
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图后是否直接写库', type: 'switch' },
|
||||
'feature.sticker_enabled': { label: '表情包功能', desc: '是否开放表情包面板和 AI 表情回复', type: 'switch' },
|
||||
'system.default_ledger_name': { label: '默认账本名', desc: '新用户注册时自动创建', type: 'text' },
|
||||
'system.max_ledgers_per_user': { label: '每人最多账本', desc: '单用户可创建账本上限', type: 'number' },
|
||||
}
|
||||
|
||||
const groups = [
|
||||
{ key: 'brand', label: '品牌', prefix: 'brand.' },
|
||||
{ key: 'limit', label: '限额', prefix: 'limit.' },
|
||||
{ key: 'feature', label: '功能开关', prefix: 'feature.' },
|
||||
{ key: 'system', label: '系统', prefix: 'system.' },
|
||||
]
|
||||
|
||||
const grouped = computed(() => {
|
||||
const map: Record<string, Config[]> = {}
|
||||
for (const cfg of list.value) {
|
||||
const g = groups.find(g => cfg.key.startsWith(g.prefix))
|
||||
const k = g?.key || 'other'
|
||||
if (!map[k]) map[k] = []
|
||||
map[k].push(cfg)
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
onMounted(refresh)
|
||||
async function refresh() { loading.value = true; try { list.value = await api.configs() } finally { loading.value = false } }
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editItem = ref<Config | null>(null)
|
||||
const editValue = ref('')
|
||||
const editingMeta = computed(() => editItem.value ? configMeta[editItem.value.key] : null)
|
||||
|
||||
function openEdit(cfg: Config) { editItem.value = cfg; editValue.value = cfg.value; editVisible.value = true }
|
||||
async function saveEdit() {
|
||||
if (!editItem.value) return
|
||||
await api.updateConfig(editItem.value.id, editValue.value)
|
||||
message.success(`已更新 ${editItem.value.key}`)
|
||||
editVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||
<h2 style="margin:0">品牌配置</h2>
|
||||
<a-button @click="refresh">刷新</a-button>
|
||||
</div>
|
||||
|
||||
<a-tabs>
|
||||
<a-tab-pane v-for="g in groups" :key="g.key" :tab="g.label">
|
||||
<a-row :gutter="[16,12]">
|
||||
<a-col v-for="cfg in grouped[g.key]" :key="cfg.id" :span="8">
|
||||
<a-card size="small" hoverable @click="openEdit(cfg)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start">
|
||||
<div>
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:2px">{{ configMeta[cfg.key]?.label || cfg.key }}</div>
|
||||
<div style="color:#999;font-size:11px;margin-bottom:6px">{{ configMeta[cfg.key]?.desc || '' }}</div>
|
||||
</div>
|
||||
<a-tag color="blue" style="margin-left:8px">v{{ cfg.version }}</a-tag>
|
||||
</div>
|
||||
<div v-if="configMeta[cfg.key]?.type === 'switch'"
|
||||
style="margin-top:6px;font-size:18px">
|
||||
<span v-if="cfg.value === 'true'" style="color:#00B386">✅ 已开启</span>
|
||||
<span v-else style="color:#ccc">❌ 已关闭</span>
|
||||
</div>
|
||||
<div v-else style="margin-top:6px;font-size:16px;font-weight:700;word-break:break-all">
|
||||
{{ cfg.key.includes('key') ? '••••••••' : cfg.value || '(空)' }}
|
||||
</div>
|
||||
<div style="color:#999;font-size:10px;margin-top:4px">{{ formatShanghaiDate(cfg.updatedAt) }}</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
|
||||
<a-modal v-model:open="editVisible" :title="`编辑配置: ${editItem?.key}`" @ok="saveEdit" :width="440">
|
||||
<div style="margin-bottom:10px;color:#999;font-size:12px">{{ editingMeta?.desc }}</div>
|
||||
<template v-if="editingMeta?.type === 'switch'">
|
||||
<a-switch
|
||||
:checked="editValue === 'true'"
|
||||
@change="(v: boolean) => editValue = String(v)"
|
||||
checked-children="开启" un-checked-children="关闭" />
|
||||
</template>
|
||||
<template v-else-if="editingMeta?.type === 'number'">
|
||||
<a-input-number v-model:value="(editValue as any)" style="width:100%" />
|
||||
</template>
|
||||
<template v-else-if="editingMeta?.type === 'select' && editingMeta.options">
|
||||
<a-select v-model:value="editValue" style="width:100%" :options="editingMeta.options.map(o=>({value:o,label:o}))" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<a-input v-model:value="editValue" />
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { RobotOutlined, CheckCircleOutlined, RiseOutlined,
|
||||
TeamOutlined, UserOutlined, MessageOutlined, DollarOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface DashData {
|
||||
users: { total: number; activeToday: number }
|
||||
transactions: { total: number; aiBooked: number }
|
||||
aiAccuracy: number
|
||||
undoRate: number
|
||||
aiMessages: number
|
||||
}
|
||||
|
||||
const dash = ref<DashData | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try { dash.value = await api.dashboard() } finally { loading.value = false }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
|
||||
<template v-else-if="dash">
|
||||
<h2 style="margin-bottom:18px">📊 仪表盘</h2>
|
||||
|
||||
<a-row :gutter="14" style="margin-bottom:14px">
|
||||
<a-col :span="6">
|
||||
<a-card size="small"><a-statistic title="总用户" :value="dash.users.total"><template #prefix><TeamOutlined /></template></a-statistic></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small"><a-statistic title="今日活跃" :value="dash.users.activeToday"><template #prefix><UserOutlined /></template></a-statistic></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small"><a-statistic title="总账单" :value="dash.transactions.total" /></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small"><a-statistic title="AI 记账" :value="dash.transactions.aiBooked"><template #prefix><RobotOutlined /></template></a-statistic></a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="14" style="margin-bottom:14px">
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic title="AI 准确率" :value="dash.aiAccuracy" suffix="%"
|
||||
:value-style="{ color: dash.aiAccuracy >= 70 ? '#00B386' : '#F0642D' }">
|
||||
<template #prefix><CheckCircleOutlined /></template></a-statistic>
|
||||
<a-progress :percent="dash.aiAccuracy" :showInfo="false" size="small"
|
||||
:strokeColor="dash.aiAccuracy >= 70 ? '#00B386' : '#F0642D'" style="margin-top:6px" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic title="撤销率" :value="dash.undoRate" suffix="%"
|
||||
:value-style="{ color: dash.undoRate <= 20 ? '#00B386' : '#F5A623' }">
|
||||
<template #prefix><RiseOutlined /></template></a-statistic>
|
||||
<a-progress :percent="dash.undoRate" :showInfo="false" size="small"
|
||||
:strokeColor="dash.undoRate <= 20 ? '#00B386' : '#F5A623'" style="margin-top:6px" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small"><a-statistic title="AI 消息总数" :value="dash.aiMessages"><template #prefix><MessageOutlined /></template></a-statistic></a-card>
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<a-card size="small">
|
||||
<a-statistic title="估算 Token 成本" :value="(dash.aiMessages * 500 * 0.01 / 1000).toFixed(2)" prefix="$">
|
||||
<template #prefix><DollarOutlined /></template>
|
||||
</a-statistic>
|
||||
<div style="font-size:10px;color:#999;margin-top:4px">基于 500 token/条 × $0.01/1K 估算</div>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="14">
|
||||
<a-col :span="12">
|
||||
<a-alert type="info" show-icon
|
||||
message="AI 准确率 = (AI记账总数 − 撤销数) / AI记账总数。撤销率高 → 需要调整 AI 性格 Prompt 模板。" />
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-alert type="warning" show-icon
|
||||
message="注意:撤销率、AI 消息数、估算成本是运营核心指标,建议每周跟踪,及时优化 Prompt 和限流阈值。" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Persona { id: number; key: string; name: string; description: string; sampleLine: string; promptTemplate: string; isEnabled: boolean; version: number }
|
||||
|
||||
const list = ref<Persona[]>([])
|
||||
const loading = ref(true)
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<Persona | null>(null)
|
||||
const form = ref({
|
||||
key: '', name: '', description: '', sampleLine: '', promptTemplate: '', isEnabled: true,
|
||||
})
|
||||
|
||||
const cols = [
|
||||
{ title: 'Key', dataIndex: 'key', key: 'key', width: 110 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 100 },
|
||||
{ title: '描述', dataIndex: 'description', key: 'desc', ellipsis: true },
|
||||
{ title: '版本', dataIndex: 'version', key: 'ver', width: 60 },
|
||||
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
|
||||
{ title: '', key: 'act', width: 150 },
|
||||
]
|
||||
|
||||
onMounted(refresh)
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try { list.value = await api.personas() } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
editing.value = null
|
||||
form.value = { key: '', name: '', description: '', sampleLine: '', promptTemplate: '', isEnabled: true }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(p: Persona) {
|
||||
editing.value = p
|
||||
form.value = { key: p.key, name: p.name, description: p.description, sampleLine: p.sampleLine, promptTemplate: p.promptTemplate, isEnabled: p.isEnabled }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = { ...form.value, isEnabled: form.value.isEnabled }
|
||||
if (editing.value) await api.updatePersona(editing.value.id, data)
|
||||
else await api.createPersona(data)
|
||||
message.success(editing.value ? '已更新(版本号+1)' : '已创建')
|
||||
modalVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
await api.deletePersona(id)
|
||||
message.success('已删除')
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>AI 性格管理</h2>
|
||||
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建性格</a-button>
|
||||
</div>
|
||||
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'on'">
|
||||
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
|
||||
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
|
||||
<a-button size="small" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="modalVisible" :title="editing ? '编辑性格' : '新建性格'" @ok="save" :width="560">
|
||||
<a-form layout="vertical" style="margin-top:8px">
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="sassy_cat" /></a-form-item></a-col>
|
||||
<a-col :span="12"><a-form-item label="名称"><a-input v-model:value="form.name" placeholder="毒舌猫娘" /></a-form-item></a-col>
|
||||
</a-row>
|
||||
<a-form-item label="描述"><a-input v-model:value="form.description" placeholder="一句话描述" /></a-form-item>
|
||||
<a-form-item label="示例台词"><a-input v-model:value="form.sampleLine" placeholder="这句会展示给用户选性格时看" /></a-form-item>
|
||||
<a-form-item label="Prompt 模板">
|
||||
<a-textarea v-model:value="form.promptTemplate" :rows="5"
|
||||
placeholder="系统提示词模板,支持 {tic} 占位符,此字段可调不发版" />
|
||||
<div style="color:#999;font-size:11px;margin-top:4px">
|
||||
⚡ 调这个不需要发版,前端实时生效。可用占位符:<code>{'{tic}'}</code> = 口癖(喵/汪/无)
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
interface Config { id: number; key: string; value: string; version: number }
|
||||
|
||||
const configs = ref<Record<string, Config>>({})
|
||||
const loading = ref(true)
|
||||
const testing = ref(false)
|
||||
const testOk = ref<boolean | null>(null)
|
||||
const testMsg = ref('')
|
||||
|
||||
const llmKeys = ['llm.protocol','llm.base_url','llm.model','llm.max_tokens','llm.temperature']
|
||||
const limitKeys = ['limit.daily_ai_messages','limit.daily_ai_messages_per_user','limit.max_monthly_budget']
|
||||
const featureKeys = ['feature.ocr_enabled','feature.voice_enabled','feature.ai_auto_book','feature.sticker_enabled']
|
||||
const permissionKeys = ['permission.default.ai_enabled']
|
||||
const quotaDefaultKeys = ['quota.default_ai_chat_limit','quota.default_ai_chat_period']
|
||||
const systemKeys = ['system.default_ledger_name','system.max_ledgers_per_user','brand.app_name','brand.slogan','brand.logo_url']
|
||||
|
||||
const protocols = [
|
||||
{ value: 'chat_completions', label: 'Chat Completions', desc: 'OpenAI Chat Completions API。POST /v1/chat/completions', endpoint: '/chat/completions' },
|
||||
{ value: 'responses', label: 'Responses', desc: 'OpenAI Responses API(新版)。POST /v1/responses', endpoint: '/responses' },
|
||||
{ value: 'messages', label: 'Messages', desc: 'Anthropic Messages API。POST /v1/messages,用于 Claude 系列', endpoint: '/messages' },
|
||||
]
|
||||
|
||||
const meta: Record<string, { label: string; desc: string; type: string }> = {
|
||||
'llm.protocol': { label: 'API 协议', desc: '', type: 'protocol' },
|
||||
'llm.base_url': { label: 'API 地址', desc: '', type: 'url' }, 'llm.model': { label: '模型名称', desc: '', type: 'text' },
|
||||
'llm.max_tokens': { label: '最大输出 Token', desc: '单次请求上限', type: 'number' },
|
||||
'llm.temperature': { label: '温度 (Temperature)', desc: '0=确定 1=创意', type: 'number' },
|
||||
'limit.daily_ai_messages': { label: '全站每日 AI 消息上限', desc: '', type: 'number' },
|
||||
'limit.daily_ai_messages_per_user': { label: '每人每日 AI 消息上限', desc: '', type: 'number' },
|
||||
'limit.max_monthly_budget': { label: '最大月预算金额', desc: '', type: 'number' },
|
||||
'feature.ocr_enabled': { label: 'OCR 小票识别', desc: '拍照记账功能', type: 'switch' },
|
||||
'feature.voice_enabled': { label: '语音记账', desc: '语音输入转文字', type: 'switch' },
|
||||
'feature.ai_auto_book': { label: 'AI 自动入账', desc: 'AI 识别记账意图直接入账', type: 'switch' },
|
||||
'feature.sticker_enabled': { label: '表情包功能', desc: '聊天表情包面板 + AI 表情回复', type: 'switch' },
|
||||
'permission.default.ai_enabled': { label: '新用户默认启用 AI', desc: '仅影响保存后注册的新用户,现有用户权限不变', type: 'switch' },
|
||||
'quota.default_ai_chat_limit': { label: '新用户默认 AI 对话次数', desc: '0 表示不限次数', type: 'number' },
|
||||
'quota.default_ai_chat_period': { label: '新用户默认额度周期', desc: '按上海时区自然周期重置', type: 'period' },
|
||||
'system.default_ledger_name': { label: '新用户默认账本名', desc: '', type: 'text' },
|
||||
'system.max_ledgers_per_user': { label: '每人最多账本数', desc: '', type: 'number' },
|
||||
'brand.app_name': { label: 'App 名称', desc: '', type: 'text' },
|
||||
'brand.slogan': { label: 'App 标语', desc: '', type: 'text' },
|
||||
'brand.logo_url': { label: 'Logo URL', desc: '', type: 'url' },
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const list = await api.configs()
|
||||
const map: Record<string, Config> = {}
|
||||
for (const c of list) map[c.key] = c
|
||||
configs.value = map
|
||||
if (!map['llm.protocol']) setVal('llm.protocol', 'chat_completions')
|
||||
if (!map['quota.default_ai_chat_limit']) setVal('quota.default_ai_chat_limit', 50)
|
||||
if (!map['quota.default_ai_chat_period']) setVal('quota.default_ai_chat_period', 'day')
|
||||
selProtocol.value = getVal('llm.protocol') || 'chat_completions'
|
||||
} finally { loading.value = false }
|
||||
})
|
||||
|
||||
function getVal(key: string): any {
|
||||
const c = configs.value[key]
|
||||
if (!c) return ''
|
||||
const m = meta[key]
|
||||
if (m?.type === 'number') return Number(c.value) || 0
|
||||
if (m?.type === 'switch') return c.value === 'true'
|
||||
return c.value
|
||||
}
|
||||
|
||||
function setVal(key: string, val: any) {
|
||||
const str = typeof val === 'boolean' ? String(val) : String(val)
|
||||
if (configs.value[key]) configs.value[key].value = str
|
||||
else configs.value[key] = { id: 0, key, value: str, version: 0 }
|
||||
}
|
||||
|
||||
const selProtocol = ref('chat_completions')
|
||||
|
||||
async function saveSection(keys: string[]) {
|
||||
setVal('llm.protocol', selProtocol.value)
|
||||
const tasks = keys.filter(k => configs.value[k]).map(async k => {
|
||||
const cfg = configs.value[k]
|
||||
try {
|
||||
if (cfg.id > 0) {
|
||||
const r = await api.updateConfig(cfg.id, cfg.value)
|
||||
if (cfg) cfg.id = r.id
|
||||
} else {
|
||||
const r = await api.createConfig(cfg.key, cfg.value)
|
||||
if (cfg) cfg.id = r.id
|
||||
}
|
||||
} catch (e: any) {
|
||||
message.error(k + ' 保存失败: ' + (e?.response?.data?.detail || e?.response?.data?.error || e.message))
|
||||
}
|
||||
})
|
||||
if (tasks.length === 0) { message.warning('没有可保存的配置'); return }
|
||||
await Promise.all(tasks)
|
||||
message.success('已保存 ' + tasks.length + ' 项配置')
|
||||
}
|
||||
|
||||
async function testLlm() {
|
||||
testing.value = true; testOk.value = null; testMsg.value = ''
|
||||
try {
|
||||
const r = await api.testLlm()
|
||||
testOk.value = r.ok
|
||||
testMsg.value = r.detail || r.error || ''
|
||||
if (r.ok) message.success('LLM 连接正常')
|
||||
else message.error(r.error || '连接失败')
|
||||
} catch (e: any) {
|
||||
testOk.value = false
|
||||
const data = e?.response?.data
|
||||
testMsg.value = data?.detail || data?.error || data?.message || e.message || '未知错误'
|
||||
message.error('连接失败')
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" style="text-align:center;padding:60px"><a-spin size="large" /></div>
|
||||
<template v-else>
|
||||
<h2 style="margin-bottom:18px">系统设置</h2>
|
||||
|
||||
<a-card title="LLM 大模型配置" size="small" style="margin-bottom:14px">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button size="small" @click="testLlm" :loading="testing">测试连接</a-button>
|
||||
<a-button size="small" type="primary" @click="saveSection(llmKeys)">保存</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<template v-if="testOk !== null">
|
||||
<a-alert :type="testOk ? 'success' : 'error'" :message="testOk ? '连接成功' : '连接失败'" :description="testMsg" show-icon style="margin-bottom:14px" closable />
|
||||
</template>
|
||||
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="API 协议" :span="2">
|
||||
<a-radio-group v-model:value="selProtocol" style="width:100%">
|
||||
<a-row :gutter="[8,8]">
|
||||
<a-col v-for="p in protocols" :key="p.value" :span="8">
|
||||
<a-radio :value="p.value" style="display:block">
|
||||
<span style="font-weight:600">{{ p.label }}</span>
|
||||
<div style="font-size:11px;color:#999;margin-top:2px;white-space:normal">{{ p.desc }}</div>
|
||||
<div style="font-size:10px;color:#bbb;margin-top:2px;font-family:monospace">{{ p.endpoint }}</div>
|
||||
</a-radio>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-radio-group>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="API 地址" :span="2">
|
||||
<a-input :value="getVal('llm.base_url')" @change="(e:any)=>setVal('llm.base_url', e.target.value)" placeholder="https://api.openai.com/v1" />
|
||||
<div style="color:#999;font-size:11px;margin-top:2px">会自动拼上协议路径</div>
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="API Key" :span="2">
|
||||
<a-alert type="info" message="API Key 仅通过服务器环境变量 LLM_API_KEY 配置,后台不会读取或显示完整密钥。" show-icon />
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="模型名称" :span="2">
|
||||
<a-input :value="getVal('llm.model')" @change="(e:any)=>setVal('llm.model', e.target.value)" placeholder="gpt-4o-mini" />
|
||||
</a-descriptions-item>
|
||||
|
||||
<a-descriptions-item label="最大输出 Token" :span="1">
|
||||
<a-input-number :value="getVal('llm.max_tokens')" @change="(val:any)=>setVal('llm.max_tokens', val)" style="width:100%" :min="1" />
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="温度" :span="1">
|
||||
<a-input-number :value="getVal('llm.temperature')" @change="(val:any)=>setVal('llm.temperature', val)" style="width:100%" :min="0" :max="2" :step="0.1" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="限额配置" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(limitKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in limitKeys" :key="k" :label="meta[k]?.label" :span="1">
|
||||
<a-input-number :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="0" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="功能开关" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(featureKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in featureKeys" :key="k" :label="meta[k]?.label" :span="1">
|
||||
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="注册默认权限" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(permissionKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in permissionKeys" :key="k" :label="meta[k]?.label">
|
||||
<a-switch :checked="getVal(k)" @change="(val:boolean)=>setVal(k, val)" />
|
||||
<span style="margin-left:10px;color:#8c8c8c">{{ meta[k]?.desc }}</span>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="新用户 AI 对话额度" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(quotaDefaultKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item label="默认次数">
|
||||
<a-input-number
|
||||
:value="getVal('quota.default_ai_chat_limit')"
|
||||
@change="(val:any)=>setVal('quota.default_ai_chat_limit', val)"
|
||||
style="width:100%"
|
||||
:min="0"
|
||||
:max="1000000" />
|
||||
<div style="color:#999;font-size:11px;margin-top:3px">0 表示不限次数</div>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="重置周期">
|
||||
<a-select
|
||||
:value="getVal('quota.default_ai_chat_period') || 'day'"
|
||||
@change="(val:string)=>setVal('quota.default_ai_chat_period', val)"
|
||||
style="width:100%">
|
||||
<a-select-option value="day">每天</a-select-option>
|
||||
<a-select-option value="week">每周(周一开始)</a-select-option>
|
||||
<a-select-option value="month">每月</a-select-option>
|
||||
</a-select>
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-card title="系统 & 品牌" size="small" style="margin-bottom:14px">
|
||||
<template #extra><a-button size="small" type="primary" @click="saveSection(systemKeys)">保存</a-button></template>
|
||||
<a-descriptions :column="2" size="small" bordered>
|
||||
<a-descriptions-item v-for="k in systemKeys" :key="k" :label="meta[k]?.label" :span="k==='brand.slogan'||k==='brand.logo_url'?2:1">
|
||||
<a-input-number v-if="k==='system.max_ledgers_per_user'" :value="getVal(k)" @change="(val:any)=>setVal(k, val)" style="width:100%" :min="1" :max="50" />
|
||||
<a-input v-else :value="getVal(k)" @change="(e:any)=>setVal(k, e.target.value)" />
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-card>
|
||||
</template>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Sticker { id: number; key: string; label: string; groupKey: string; triggerTags: string | null; imageUrl: string | null; isEnabled: boolean }
|
||||
|
||||
const list = ref<Sticker[]>([])
|
||||
const loading = ref(true)
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<Sticker | null>(null)
|
||||
const form = ref({ key: '', label: '', groupKey: 'classic', triggerTags: '', imageUrl: '', isEnabled: true })
|
||||
|
||||
const cols = [
|
||||
{ title: 'Key', dataIndex: 'key', key: 'key', width: 110 },
|
||||
{ title: '名称', dataIndex: 'label', key: 'label', width: 100 },
|
||||
{ title: '分组', dataIndex: 'groupKey', key: 'group', width: 90 },
|
||||
{ title: '触发标签', dataIndex: 'triggerTags', key: 'tags', width: 200 },
|
||||
{ title: '状态', dataIndex: 'isEnabled', key: 'on', width: 70 },
|
||||
{ title: '', key: 'act', width: 150 },
|
||||
]
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try { list.value = await api.stickers() } finally { loading.value = false }
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
editing.value = null
|
||||
form.value = { key: '', label: '', groupKey: 'classic', triggerTags: '', imageUrl: '', isEnabled: true }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(s: Sticker) {
|
||||
editing.value = s
|
||||
form.value = { key: s.key, label: s.label, groupKey: s.groupKey, triggerTags: s.triggerTags || '', imageUrl: s.imageUrl || '', isEnabled: s.isEnabled }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const data = { ...form.value, triggerTags: form.value.triggerTags || null, imageUrl: form.value.imageUrl || null }
|
||||
if (editing.value) await api.updateSticker(editing.value.id, data)
|
||||
else await api.createSticker(data)
|
||||
message.success(editing.value ? '已更新' : '已创建')
|
||||
modalVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function del(id: number) {
|
||||
await api.deleteSticker(id)
|
||||
message.success('已删除')
|
||||
refresh()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>表情包库</h2>
|
||||
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建表情包</a-button>
|
||||
</div>
|
||||
<a-table :columns="cols" :dataSource="list" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'tags'">
|
||||
<template v-if="record.triggerTags">
|
||||
<a-tag v-for="t in (record.triggerTags || '').split(',').filter(Boolean)" :key="t" color="blue" style="margin:1px 2px">{{ t }}</a-tag>
|
||||
</template>
|
||||
<span v-else style="color:#ccc">-</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'on'">
|
||||
<a-tag :color="record.isEnabled ? 'green' : 'default'">{{ record.isEnabled ? '启用' : '禁用' }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" style="margin-right:6px" @click="openEdit(record)"><EditOutlined /></a-button>
|
||||
<a-popconfirm title="确定删除?" @confirm="del(record.id)">
|
||||
<a-button size="small" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:open="modalVisible" :title="editing ? '编辑表情包' : '新建表情包'" @ok="save" :width="520">
|
||||
<a-form layout="vertical" style="margin-top:8px">
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12"><a-form-item label="Key"><a-input v-model:value="form.key" placeholder="salary" /></a-form-item></a-col>
|
||||
<a-col :span="12"><a-form-item label="名称"><a-input v-model:value="form.label" placeholder="发工资啦" /></a-form-item></a-col>
|
||||
</a-row>
|
||||
<a-row :gutter="12">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="分组">
|
||||
<a-select v-model:value="form.groupKey">
|
||||
<a-select-option value="ai_exclusive">🤖 AI 专属</a-select-option>
|
||||
<a-select-option value="classic">📦 经典</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12"><a-form-item label="图片 URL"><a-input v-model:value="form.imageUrl" placeholder="可选 CDN 地址" /></a-form-item></a-col>
|
||||
</a-row>
|
||||
<a-form-item label="触发标签">
|
||||
<a-input v-model:value="form.triggerTags" placeholder="over_budget,salary,forgive,逗号分隔" />
|
||||
<div style="color:#999;font-size:11px;margin-top:4px">标签匹配用户场景,AI 自动选择对应表情包</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="是否启用"><a-switch v-model:checked="form.isEnabled" /></a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
interface Cat { id: number; name: string; iconKey: string; type: string; sortOrder: number; isDeleted: boolean }
|
||||
|
||||
const list = ref<Cat[]>([])
|
||||
const loading = ref(true)
|
||||
const modalVisible = ref(false)
|
||||
const editing = ref<Cat | null>(null)
|
||||
const form = ref({ name: '', iconKey: 'tag', type: 'expense' })
|
||||
|
||||
const iconOptions = [
|
||||
{ value: 'food', label: '🍜 餐饮' }, { value: 'cup', label: '🥤 饮品' },
|
||||
{ value: 'cart', label: '🛒 购物' }, { value: 'metro', label: '🚇 交通' },
|
||||
{ value: 'house', label: '🏠 住房' }, { value: 'game', label: '🎮 娱乐' },
|
||||
{ value: 'pill', label: '💊 医疗' }, { value: 'book', label: '📖 学习' },
|
||||
{ value: 'shirt', label: '👕 服饰' }, { value: 'gift', label: '🎁 人情' },
|
||||
{ value: 'plane', label: '✈️ 旅行' }, { value: 'tag', label: '🏷️ 其他' },
|
||||
{ value: 'money', label: '💰 工资' }, { value: 'briefcase', label: '💼 兼职' },
|
||||
{ value: 'chart', label: '📈 理财' }, { value: 'card', label: '💳 报销' },
|
||||
{ value: 'sparkle', label: '✨ 奖金' },
|
||||
]
|
||||
|
||||
const expenseCats = () => list.value.filter(c => c.type === 'Expense')
|
||||
const incomeCats = () => list.value.filter(c => c.type === 'Income')
|
||||
|
||||
onMounted(refresh)
|
||||
async function refresh() { loading.value = true; try { list.value = await api.sysCategories() } finally { loading.value = false } }
|
||||
|
||||
function openNew() { editing.value = null; form.value = { name: '', iconKey: 'tag', type: 'expense' }; modalVisible.value = true }
|
||||
function openEdit(c: Cat) {
|
||||
editing.value = c
|
||||
form.value = { name: c.name, iconKey: c.iconKey, type: c.type === 'Income' ? 'income' : 'expense' }
|
||||
modalVisible.value = true
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const d = { name: form.value.name, iconKey: form.value.iconKey, type: form.value.type }
|
||||
if (editing.value) await api.updateSysCategory(editing.value.id, d)
|
||||
else await api.createSysCategory(d)
|
||||
message.success(editing.value ? '已更新' : '已创建')
|
||||
modalVisible.value = false
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function del(id: number) { await api.deleteSysCategory(id); message.success('已删除(软删)'); refresh() }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>系统默认分类</h2>
|
||||
<a-button type="primary" @click="openNew"><PlusOutlined /> 新建分类</a-button>
|
||||
</div>
|
||||
<p style="color:#999;font-size:12px;margin-bottom:14px">
|
||||
此处管理新用户注册时获得的默认分类。删除后已分配用户的分类不受影响(软删除,仅对新用户隐藏)。
|
||||
</p>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-card title="💸 支出分类" size="small">
|
||||
<a-table :columns="[
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 55 },
|
||||
{ title: '图标', key: 'icon', width: 55 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '', key: 'act', width: 90 },
|
||||
]" :dataSource="expenseCats()" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'icon'">
|
||||
<span style="font-size:18px">{{ iconOptions.find(i=>i.value===record.iconKey)?.label?.split(' ')[0] || '🏷️' }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" type="link" @click="openEdit(record)"><EditOutlined /></a-button>
|
||||
<a-popconfirm title="确定软删除?" @confirm="del(record.id)">
|
||||
<a-button size="small" type="link" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-card title="💰 收入分类" size="small">
|
||||
<a-table :columns="[
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 55 },
|
||||
{ title: '图标', key: 'icon', width: 55 },
|
||||
{ title: '名称', dataIndex: 'name', key: 'name' },
|
||||
{ title: '', key: 'act', width: 90 },
|
||||
]" :dataSource="incomeCats()" :loading="loading" rowKey="id" size="small" :pagination="{ pageSize: 10 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'icon'">
|
||||
<span style="font-size:18px">{{ iconOptions.find(i=>i.value===record.iconKey)?.label?.split(' ')[0] || '🏷️' }}</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" type="link" @click="openEdit(record)"><EditOutlined /></a-button>
|
||||
<a-popconfirm title="确定软删除?" @confirm="del(record.id)">
|
||||
<a-button size="small" type="link" danger><DeleteOutlined /></a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-modal v-model:open="modalVisible" :title="editing ? '编辑分类' : '新建系统分类'" @ok="save" :width="400">
|
||||
<a-form layout="vertical" style="margin-top:8px">
|
||||
<a-form-item label="分类名称"><a-input v-model:value="form.name" placeholder="如:宠物" /></a-form-item>
|
||||
<a-form-item label="图标">
|
||||
<a-select v-model:value="form.iconKey" :options="iconOptions" />
|
||||
</a-form-item>
|
||||
<a-form-item label="类型">
|
||||
<a-radio-group v-model:value="form.type">
|
||||
<a-radio value="expense">💸 支出</a-radio>
|
||||
<a-radio value="income">💰 收入</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,226 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '../api'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { formatShanghaiDate } from '../utils/time'
|
||||
|
||||
interface UserItem {
|
||||
id: number; username: string; nickname: string | null; appMode: string;
|
||||
isBanned: boolean; createdAt: string; lastLoginAt: string | null;
|
||||
aiEnabled: boolean;
|
||||
aiChatLimit: number; aiChatUsed: number; aiChatRemaining: number;
|
||||
aiChatPeriod: 'day' | 'week' | 'month'; aiChatResetAt: string;
|
||||
accountClosureRequestedAt: string | null; accountClosureScheduledAt: string | null;
|
||||
companion: { avatarKey: string; personaKey: string; customName: string | null } | null;
|
||||
txCount: number; aiTxCount: number;
|
||||
}
|
||||
|
||||
const users = ref<UserItem[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
const page = ref(1)
|
||||
const limit = 15
|
||||
|
||||
const cols = [
|
||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
|
||||
{ title: '用户名', dataIndex: 'username', key: 'un', width: 120 },
|
||||
{ title: '模式', dataIndex: 'appMode', key: 'mode', width: 80 },
|
||||
{ title: 'AI 权限', key: 'aiPermission', width: 100 },
|
||||
{ title: 'AI 对话额度', key: 'aiQuota', width: 150 },
|
||||
{ title: 'AI 伙伴', key: 'comp', width: 150 },
|
||||
{ title: '账单(总/AI)', key: 'tx' },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '注册时间', dataIndex: 'createdAt', key: 'reg', width: 110 },
|
||||
{ title: '最后登录', dataIndex: 'lastLoginAt', key: 'login', width: 110 },
|
||||
{ title: '', key: 'act', width: 250 },
|
||||
]
|
||||
|
||||
// Stats modal
|
||||
const statsVisible = ref(false)
|
||||
const statsUser = ref('')
|
||||
const statsData = ref<any>(null)
|
||||
const quotaVisible = ref(false)
|
||||
const quotaSaving = ref(false)
|
||||
const quotaUser = ref<UserItem | null>(null)
|
||||
const quotaLimit = ref(50)
|
||||
const quotaPeriod = ref<'day' | 'week' | 'month'>('day')
|
||||
const quotaResetUsage = ref(false)
|
||||
|
||||
const periodLabels = { day: '每天', week: '每周', month: '每月' }
|
||||
const periodLabel = (value: UserItem['aiChatPeriod']) => periodLabels[value]
|
||||
|
||||
onMounted(refresh)
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.users({ search: search.value || undefined, page: page.value, limit })
|
||||
users.value = res.list
|
||||
total.value = res.total
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function doSearch() { page.value = 1; refresh() }
|
||||
|
||||
async function toggleBan(u: UserItem) {
|
||||
const res = await api.toggleBan(u.id)
|
||||
message.success(res.isBanned ? `已封禁 ${u.username}` : `已解封 ${u.username}`)
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function toggleAiPermission(u: UserItem, enabled: boolean) {
|
||||
const previous = u.aiEnabled
|
||||
u.aiEnabled = enabled
|
||||
try {
|
||||
await api.updateUserPermissions(u.id, enabled)
|
||||
message.success(enabled ? '已开放 AI 功能' : '已关闭该用户全部 AI 功能')
|
||||
} catch {
|
||||
u.aiEnabled = previous
|
||||
message.error('AI 权限修改失败')
|
||||
}
|
||||
}
|
||||
|
||||
function editQuota(u: UserItem) {
|
||||
quotaUser.value = u
|
||||
quotaLimit.value = u.aiChatLimit
|
||||
quotaPeriod.value = u.aiChatPeriod
|
||||
quotaResetUsage.value = false
|
||||
quotaVisible.value = true
|
||||
}
|
||||
|
||||
async function saveQuota() {
|
||||
const user = quotaUser.value
|
||||
if (!user) return
|
||||
quotaSaving.value = true
|
||||
try {
|
||||
await api.updateAiChatQuota(user.id, quotaLimit.value, quotaPeriod.value, quotaResetUsage.value)
|
||||
message.success('AI 对话额度已更新')
|
||||
quotaVisible.value = false
|
||||
await refresh()
|
||||
} catch {
|
||||
message.error('AI 对话额度更新失败')
|
||||
} finally {
|
||||
quotaSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelClosure(u: UserItem) {
|
||||
await api.cancelAccountClosure(u.id)
|
||||
message.success('已取消 ' + u.username + ' 的注销流程')
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function showStats(u: UserItem) {
|
||||
statsUser.value = u.nickname || u.username
|
||||
statsVisible.value = true
|
||||
statsData.value = await api.userStats(u.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2>用户管理</h2>
|
||||
<a-button @click="refresh"><ReloadOutlined /> 刷新</a-button>
|
||||
</div>
|
||||
<div style="margin-bottom:14px;display:flex;gap:8px">
|
||||
<a-input-search v-model:value="search" placeholder="搜索用户名..." style="max-width:280px" @search="doSearch" />
|
||||
</div>
|
||||
<a-table :columns="cols" :dataSource="users" :loading="loading" rowKey="id" size="small"
|
||||
:pagination="{ current: page, total, pageSize: limit, showTotal: (t:number) => `共 ${t} 人`, onChange: (p:number) => { page=p; refresh() } }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'mode'">
|
||||
<a-tag :color="record.appMode === 'ai' ? 'blue' : 'green'">{{ record.appMode === 'ai' ? '全AI' : '普通' }}</a-tag>
|
||||
</template>
|
||||
<template v-if="column.key === 'aiPermission'">
|
||||
<a-switch
|
||||
:checked="record.aiEnabled"
|
||||
checked-children="开"
|
||||
un-checked-children="关"
|
||||
@change="(value:boolean) => toggleAiPermission(record, value)" />
|
||||
</template>
|
||||
<template v-if="column.key === 'aiQuota'">
|
||||
<a-button type="link" size="small" @click="editQuota(record)" style="padding:0">
|
||||
{{ record.aiChatUsed }}/{{ record.aiChatLimit === 0 ? '不限' : record.aiChatLimit }}
|
||||
· {{ periodLabel(record.aiChatPeriod) }}
|
||||
</a-button>
|
||||
<div style="font-size:10px;color:#999">
|
||||
{{ formatShanghaiDate(record.aiChatResetAt) }} 重置
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'comp'">
|
||||
<span v-if="record.companion">形象:{{ record.companion.avatarKey }} · 性格:{{ record.companion.personaKey }}</span>
|
||||
<span v-else style="color:#ccc">未设置</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'tx'">
|
||||
{{ record.txCount }} <span style="color:#999">(AI:{{ record.aiTxCount }})</span>
|
||||
</template>
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag v-if="record.accountClosureScheduledAt" color="orange">注销中</a-tag>
|
||||
<a-tag v-else :color="record.isBanned ? 'red' : 'default'">{{ record.isBanned ? '已封' : '正常' }}</a-tag>
|
||||
<div v-if="record.accountClosureScheduledAt" style="font-size:10px;color:#999;margin-top:3px">
|
||||
{{ formatShanghaiDate(record.accountClosureScheduledAt) }} 删除
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="column.key === 'reg'">
|
||||
{{ formatShanghaiDate(record.createdAt) }}
|
||||
</template>
|
||||
<template v-if="column.key === 'login'">
|
||||
{{ record.lastLoginAt ? formatShanghaiDate(record.lastLoginAt) : '从未' }}
|
||||
</template>
|
||||
<template v-if="column.key === 'act'">
|
||||
<a-button size="small" style="margin-right:6px" @click="editQuota(record)">额度</a-button>
|
||||
<a-button size="small" style="margin-right:6px" @click="showStats(record)">📊 统计</a-button>
|
||||
<a-popconfirm v-if="record.accountClosureScheduledAt" title="确定取消该用户的注销流程?" @confirm="cancelClosure(record)">
|
||||
<a-button size="small" type="primary">取消注销</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm v-else :title="record.isBanned ? '确定解封?' : '确定封禁?'" @confirm="toggleBan(record)">
|
||||
<a-button size="small" :danger="!record.isBanned">{{ record.isBanned ? '解封' : '封禁' }}</a-button>
|
||||
</a-popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal
|
||||
v-model:open="quotaVisible"
|
||||
title="调整 AI 对话额度"
|
||||
:confirm-loading="quotaSaving"
|
||||
ok-text="保存"
|
||||
cancel-text="取消"
|
||||
@ok="saveQuota">
|
||||
<a-alert
|
||||
v-if="quotaUser"
|
||||
type="info"
|
||||
:message="quotaUser.nickname || quotaUser.username"
|
||||
show-icon
|
||||
style="margin-bottom:14px" />
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="周期内可用次数">
|
||||
<a-input-number v-model:value="quotaLimit" :min="0" :max="1000000" style="width:100%" />
|
||||
<div style="font-size:11px;color:#999;margin-top:4px">设置为 0 表示不限次数。</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="重置周期">
|
||||
<a-select v-model:value="quotaPeriod">
|
||||
<a-select-option value="day">每天(上海时间 00:00)</a-select-option>
|
||||
<a-select-option value="week">每周(周一 00:00)</a-select-option>
|
||||
<a-select-option value="month">每月(每月 1 日 00:00)</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-checkbox v-model:checked="quotaResetUsage">立即将本周期已用次数清零</a-checkbox>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="statsVisible" :title="`${statsUser} 使用统计`" :footer="null" :width="420">
|
||||
<a-row :gutter="12" v-if="statsData">
|
||||
<a-col :span="8"><a-card size="small"><a-statistic title="总账单" :value="statsData.totalTransactions" /></a-card></a-col>
|
||||
<a-col :span="8"><a-card size="small"><a-statistic title="AI 记账" :value="statsData.aiBooked" /></a-card></a-col>
|
||||
<a-col :span="8">
|
||||
<a-card size="small">
|
||||
<a-statistic title="AI 准确率" :value="statsData.aiAccuracy" suffix="%"
|
||||
:value-style="{ color: statsData.aiAccuracy >= 70 ? '#00B386' : '#F0642D' }" />
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': { target: 'http://localhost:5000', changeOrigin: true } },
|
||||
},
|
||||
build: { outDir: 'dist', assetsDir: 'assets' },
|
||||
})
|
||||
Reference in New Issue
Block a user