Initial project import
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user