Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
+48
View File
@@ -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>
+52
View File
@@ -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
+10
View File
@@ -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')
+18
View File
@@ -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
+14
View File
@@ -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('/', '-')
}
+91
View File
@@ -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>
+117
View File
@@ -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>
+86
View File
@@ -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>
+97
View File
@@ -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>
+234
View File
@@ -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>
+108
View File
@@ -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>
+122
View File
@@ -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>
+226
View File
@@ -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>