feat: reorganize admin llm settings
This commit is contained in:
+51
-24
@@ -5,7 +5,7 @@ import { adminAuth } from './auth'
|
||||
import { DashboardOutlined, SettingOutlined, ControlOutlined, SmileOutlined,
|
||||
GithubOutlined, PictureOutlined, TeamOutlined, AppstoreOutlined,
|
||||
NotificationOutlined, SafetyCertificateOutlined, AuditOutlined,
|
||||
LogoutOutlined } from '@ant-design/icons-vue'
|
||||
LogoutOutlined, CloudServerOutlined, FundOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -14,20 +14,29 @@ const selectedKeys = ref<string[]>([String(route.name)])
|
||||
|
||||
watch(() => route.name, (n) => { selectedKeys.value = [String(n)] })
|
||||
|
||||
const nav = computed(() => [
|
||||
{ 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: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [
|
||||
const navGroups = computed(() => [
|
||||
{ label: '概览', items: [
|
||||
{ key: 'Dashboard', icon: DashboardOutlined, label: '仪表盘' },
|
||||
] },
|
||||
{ label: 'AI 配置', items: [
|
||||
{ key: 'ModelService', icon: CloudServerOutlined, label: '模型服务' },
|
||||
{ key: 'Personas', icon: SmileOutlined, label: 'AI 性格' },
|
||||
{ key: 'Avatars', icon: GithubOutlined, label: 'AI 形象' },
|
||||
{ key: 'Stickers', icon: PictureOutlined, label: '表情包库' },
|
||||
] },
|
||||
{ label: '产品配置', items: [
|
||||
{ key: 'ProductBasic', icon: SettingOutlined, label: '品牌与基础设置' },
|
||||
{ key: 'FeatureLimits', icon: ControlOutlined, label: '功能与额度' },
|
||||
{ key: 'SysCategories', icon: AppstoreOutlined, label: '默认分类' },
|
||||
] },
|
||||
{ label: '运营', items: [
|
||||
{ key: 'Users', icon: TeamOutlined, label: '用户管理' },
|
||||
{ key: 'PushCampaigns', icon: NotificationOutlined, label: '推送管理' },
|
||||
] },
|
||||
...(adminAuth.identity.value?.role === 'super_admin' ? [{ label: '安全', items: [
|
||||
{ key: 'AdminAccounts', icon: SafetyCertificateOutlined, label: '管理员账号' },
|
||||
{ key: 'Audit', icon: AuditOutlined, label: '操作审计' },
|
||||
] : []),
|
||||
] }] : []),
|
||||
])
|
||||
|
||||
watch(adminAuth.identity, value => {
|
||||
@@ -43,20 +52,23 @@ async function logout() {
|
||||
<template>
|
||||
<router-view v-if="route.meta.public || route.name === 'ChangePassword'" />
|
||||
<a-layout v-else 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
|
||||
<a-layout-sider v-model:collapsed="collapsed" collapsible theme="light" :width="224"
|
||||
breakpoint="lg" class="app-sider">
|
||||
<div class="brand-lockup">
|
||||
<FundOutlined />
|
||||
<span>记之 Admin</span>
|
||||
</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-item-group v-for="group in navGroups" :key="group.label" :title="group.label">
|
||||
<a-menu-item v-for="item in group.items" :key="item.key">
|
||||
<component :is="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu-item-group>
|
||||
</a-menu>
|
||||
<div style="position:absolute;bottom:16px;left:16px;right:16px">
|
||||
<a-button type="text" block style="text-align:left" @click="logout">
|
||||
<div class="logout-area">
|
||||
<a-button type="text" block @click="logout">
|
||||
<template #icon><LogoutOutlined /></template>退出登录
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -68,9 +80,24 @@ async function logout() {
|
||||
<a-tag>{{ adminAuth.identity.value?.role }}</a-tag>
|
||||
</a-space>
|
||||
</a-layout-header>
|
||||
<a-layout-content style="margin: 18px 20px; padding: 20px; background: #fff; border-radius: 8px; min-height: 360px;">
|
||||
<a-layout-content class="app-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-sider { position: sticky; top: 0; height: 100vh; overflow: auto; border-right: 1px solid #eef0f3; }
|
||||
.brand-lockup { display: flex; align-items: center; gap: 10px; height: 60px; padding: 0 22px; overflow: hidden; color: #191f26; font-size: 16px; font-weight: 700; white-space: nowrap; }
|
||||
.brand-lockup :first-child { color: #00a67d; font-size: 20px; }
|
||||
.logout-area { position: sticky; bottom: 0; padding: 12px 16px 16px; background: #fff; }
|
||||
.logout-area .ant-btn { text-align: left; }
|
||||
.app-content { min-height: 360px; margin: 18px 20px; padding: 24px; border-radius: 14px; background: #fff; }
|
||||
:deep(.ant-menu-item-group-title) { padding: 18px 24px 6px; color: #8b949e; font-size: 11px; font-weight: 600; letter-spacing: .08em; }
|
||||
:deep(.ant-menu-item) { min-height: 42px; }
|
||||
:deep(.ant-layout-sider-collapsed .ant-menu-item-group-title) { height: 12px; padding: 6px 0; overflow: hidden; color: transparent; }
|
||||
@media (max-width: 760px) {
|
||||
.app-content { margin: 10px; padding: 16px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,6 +56,16 @@ export const api = {
|
||||
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}`),
|
||||
llmSettings: () => http.get('/api/admin/llm/settings').then(r => r.data),
|
||||
updateLlmSettings: (data: {
|
||||
protocol: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
}) => http.put('/api/admin/llm/settings', data).then(r => r.data),
|
||||
updateLlmApiKey: (apiKey: string) => http.put('/api/admin/llm/api-key', { apiKey }).then(r => r.data),
|
||||
deleteLlmApiKey: () => http.delete('/api/admin/llm/api-key').then(r => r.data),
|
||||
testLlm: () => http.post('/api/admin/llm/test').then(r => r.data),
|
||||
pushCampaigns: (params: { page: number; limit: number }) => http.get('/api/admin/push/campaigns', { params }).then(r => r.data),
|
||||
estimatePushCampaign: (data: any) => http.post('/api/admin/push/campaigns/estimate', data).then(r => r.data),
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { api } from '../api'
|
||||
|
||||
interface Config {
|
||||
id: number
|
||||
key: string
|
||||
value: string
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useAdminConfigs(defaults: Record<string, string>) {
|
||||
const configs = ref<Record<string, Config>>({})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await api.configs() as Config[]
|
||||
const map: Record<string, Config> = {}
|
||||
for (const config of list) map[config.key] = config
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
if (!map[key]) map[key] = { id: 0, key, value, version: 0 }
|
||||
}
|
||||
configs.value = map
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '配置加载失败'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function value(key: string) {
|
||||
return configs.value[key]?.value ?? defaults[key] ?? ''
|
||||
}
|
||||
|
||||
function numberValue(key: string) {
|
||||
const parsed = Number(value(key))
|
||||
return Number.isFinite(parsed) ? parsed : Number(defaults[key] ?? 0)
|
||||
}
|
||||
|
||||
function boolValue(key: string) {
|
||||
return value(key) === 'true'
|
||||
}
|
||||
|
||||
function setValue(key: string, next: string | number | boolean | null) {
|
||||
const normalized = String(next ?? '')
|
||||
const current = configs.value[key]
|
||||
if (current) current.value = normalized
|
||||
else configs.value[key] = { id: 0, key, value: normalized, version: 0 }
|
||||
}
|
||||
|
||||
async function save(keys: string[]) {
|
||||
saving.value = true
|
||||
try {
|
||||
for (const key of keys) {
|
||||
const config = configs.value[key]
|
||||
if (!config) continue
|
||||
const saved = config.id > 0
|
||||
? await api.updateConfig(config.id, config.value)
|
||||
: await api.createConfig(config.key, config.value)
|
||||
config.id = saved.id
|
||||
config.version = saved.version
|
||||
}
|
||||
message.success('配置已保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '配置保存失败'))
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, saving, value, numberValue, boolValue, setValue, save, load }
|
||||
}
|
||||
|
||||
export function readError(error: any, fallback: string) {
|
||||
return error?.response?.data?.detail ||
|
||||
error?.response?.data?.message ||
|
||||
error?.response?.data?.error ||
|
||||
error?.message || fallback
|
||||
}
|
||||
@@ -8,8 +8,11 @@ const router = createRouter({
|
||||
{ path: '/change-password', name: 'ChangePassword', component: () => import('../views/ChangePassword.vue') },
|
||||
{ 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: '/settings', redirect: '/ai/model' },
|
||||
{ path: '/configs', redirect: '/product/basic' },
|
||||
{ path: '/ai/model', name: 'ModelService', component: () => import('../views/ModelService.vue') },
|
||||
{ path: '/product/basic', name: 'ProductBasic', component: () => import('../views/ProductBasic.vue') },
|
||||
{ path: '/product/features', name: 'FeatureLimits', component: () => import('../views/FeatureLimits.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') },
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<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,95 @@
|
||||
<script setup lang="ts">
|
||||
import { ControlOutlined, SafetyCertificateOutlined, ThunderboltOutlined } from '@ant-design/icons-vue'
|
||||
import { useAdminConfigs } from '../composables/useAdminConfigs'
|
||||
|
||||
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', 'feature.screenshot_bookkeeping_enabled']
|
||||
const onboardingKeys = ['permission.default.ai_enabled', 'quota.default_ai_chat_limit', 'quota.default_ai_chat_period']
|
||||
const keys = [...limitKeys, ...featureKeys, ...onboardingKeys]
|
||||
const config = useAdminConfigs({
|
||||
'limit.daily_ai_messages': '200',
|
||||
'limit.daily_ai_messages_per_user': '50',
|
||||
'limit.max_monthly_budget': '99999999',
|
||||
'feature.ocr_enabled': 'true',
|
||||
'feature.voice_enabled': 'true',
|
||||
'feature.ai_auto_book': 'true',
|
||||
'feature.sticker_enabled': 'true',
|
||||
'feature.screenshot_bookkeeping_enabled': 'true',
|
||||
'permission.default.ai_enabled': 'true',
|
||||
'quota.default_ai_chat_limit': '50',
|
||||
'quota.default_ai_chat_period': 'day',
|
||||
})
|
||||
const features = [
|
||||
{ key: 'feature.ocr_enabled', label: 'OCR 小票识别', detail: '允许拍照提取账单信息' },
|
||||
{ key: 'feature.voice_enabled', label: '语音记账', detail: '允许使用语音输入记账' },
|
||||
{ key: 'feature.ai_auto_book', label: 'AI 自动入账', detail: '识别到明确记账意图后直接入账' },
|
||||
{ key: 'feature.sticker_enabled', label: '表情包功能', detail: '开放聊天表情面板与 AI 表情回复' },
|
||||
{ key: 'feature.screenshot_bookkeeping_enabled', label: '截图自动记账', detail: '允许 Android 截图识别与自动记账' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>功能与额度</h1><p>集中管理能力开关、全局成本阈值和新用户默认 AI 权限。</p></div>
|
||||
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
|
||||
</header>
|
||||
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 10 }" />
|
||||
<section v-else class="settings-panel">
|
||||
<div class="section-heading"><ThunderboltOutlined /><div><h2>功能开关</h2><p>关闭后对应入口和服务能力将不可用。</p></div></div>
|
||||
<div class="switch-list">
|
||||
<div v-for="item in features" :key="item.key" class="switch-row">
|
||||
<div><strong>{{ item.label }}</strong><span>{{ item.detail }}</span></div>
|
||||
<a-switch :checked="config.boolValue(item.key)" @change="(value: boolean) => config.setValue(item.key, value)" />
|
||||
</div>
|
||||
</div>
|
||||
<a-divider />
|
||||
<div class="section-heading"><ControlOutlined /><div><h2>全局限制</h2><p>限制 AI 调用量和用户可设置的预算边界。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="全站每日 AI 消息上限">
|
||||
<a-input-number :value="config.numberValue('limit.daily_ai_messages')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="每人每日 AI 消息上限">
|
||||
<a-input-number :value="config.numberValue('limit.daily_ai_messages_per_user')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.daily_ai_messages_per_user', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="最大月预算金额">
|
||||
<a-input-number :value="config.numberValue('limit.max_monthly_budget')" :min="0" style="width:100%" @change="(value: number | null) => config.setValue('limit.max_monthly_budget', value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider />
|
||||
<div class="section-heading"><SafetyCertificateOutlined /><div><h2>新用户 AI 权限</h2><p>只应用于保存后注册的新用户,现有用户权限不变。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="默认启用 AI">
|
||||
<a-switch :checked="config.boolValue('permission.default.ai_enabled')" @change="(value: boolean) => config.setValue('permission.default.ai_enabled', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="默认对话次数" extra="0 表示不限次数。">
|
||||
<a-input-number :value="config.numberValue('quota.default_ai_chat_limit')" :min="0" :max="1000000" style="width:100%" @change="(value: number | null) => config.setValue('quota.default_ai_chat_limit', value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="额度重置周期">
|
||||
<a-select :value="config.value('quota.default_ai_chat_period')" @change="(value: string) => config.setValue('quota.default_ai_chat_period', value)">
|
||||
<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-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.switch-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 16px; }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 14px 16px; border-radius: 12px; background: #f6f7f9; }
|
||||
.switch-row strong, .switch-row span { display: block; }
|
||||
.switch-row span { margin-top: 3px; color: #5e6772; font-size: 12px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
@media (max-width: 760px) { .page-heading { flex-direction: column; } .switch-list, .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,244 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { CloudServerOutlined, KeyOutlined } from '@ant-design/icons-vue'
|
||||
import { api } from '../api'
|
||||
import { readError } from '../composables/useAdminConfigs'
|
||||
|
||||
interface ApiKeyState {
|
||||
configured: boolean
|
||||
masked: string
|
||||
source: 'database' | 'environment' | 'none'
|
||||
canManage: boolean
|
||||
}
|
||||
|
||||
interface LlmSettings {
|
||||
protocol: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
maxTokens: number
|
||||
temperature: number
|
||||
apiKey: ApiKeyState
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const savingKey = ref(false)
|
||||
const deletingKey = ref(false)
|
||||
const apiKeyInput = ref('')
|
||||
const testResult = ref<{ ok: boolean; detail: string } | null>(null)
|
||||
const keyState = ref<ApiKeyState>({ configured: false, masked: '', source: 'none', canManage: false })
|
||||
const form = reactive({
|
||||
protocol: 'responses',
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
maxTokens: 1024,
|
||||
temperature: 0.7,
|
||||
})
|
||||
|
||||
const protocols = [
|
||||
{ value: 'responses', label: 'Responses', detail: 'OpenAI 新版接口,支持 Agent 工具调用' },
|
||||
{ value: 'chat_completions', label: 'Chat Completions', detail: '兼容 OpenAI 风格的聊天补全服务' },
|
||||
{ value: 'messages', label: 'Messages', detail: '兼容 Anthropic Claude Messages API' },
|
||||
]
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try { apply(await api.llmSettings()) }
|
||||
catch (error: any) { message.error(readError(error, '模型配置加载失败')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
function apply(settings: LlmSettings) {
|
||||
form.protocol = settings.protocol || 'responses'
|
||||
form.baseUrl = settings.baseUrl || 'https://api.openai.com/v1'
|
||||
form.model = settings.model || 'gpt-4o-mini'
|
||||
form.maxTokens = settings.maxTokens ?? 1024
|
||||
form.temperature = settings.temperature ?? 0.7
|
||||
keyState.value = settings.apiKey
|
||||
}
|
||||
|
||||
async function saveSettings(showSuccess = true) {
|
||||
saving.value = true
|
||||
try {
|
||||
apply(await api.updateLlmSettings({ ...form }))
|
||||
if (showSuccess) message.success('模型参数已保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, '模型参数保存失败'))
|
||||
throw error
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function saveApiKey() {
|
||||
if (!apiKeyInput.value.trim()) {
|
||||
message.warning('请输入新的 API Key')
|
||||
return
|
||||
}
|
||||
savingKey.value = true
|
||||
try {
|
||||
apply(await api.updateLlmApiKey(apiKeyInput.value))
|
||||
apiKeyInput.value = ''
|
||||
message.success('API Key 已加密保存')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, 'API Key 保存失败'))
|
||||
} finally { savingKey.value = false }
|
||||
}
|
||||
|
||||
async function deleteApiKey() {
|
||||
deletingKey.value = true
|
||||
try {
|
||||
apply(await api.deleteLlmApiKey())
|
||||
message.success(keyState.value.source === 'environment'
|
||||
? '数据库密钥已删除,当前回退使用环境变量'
|
||||
: 'API Key 已删除')
|
||||
} catch (error: any) {
|
||||
message.error(readError(error, 'API Key 删除失败'))
|
||||
} finally { deletingKey.value = false }
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
await saveSettings(false)
|
||||
const result = await api.testLlm()
|
||||
testResult.value = { ok: result.ok, detail: result.detail || result.error || '' }
|
||||
result.ok ? message.success('LLM 连接正常') : message.error(result.error || '连接失败')
|
||||
} catch (error: any) {
|
||||
testResult.value = { ok: false, detail: readError(error, '连接失败') }
|
||||
} finally { testing.value = false }
|
||||
}
|
||||
|
||||
function sourceLabel(source: ApiKeyState['source']) {
|
||||
if (source === 'database') return '后台加密配置'
|
||||
if (source === 'environment') return '环境变量回退'
|
||||
return '未配置'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>模型服务</h1>
|
||||
<p>配置 AI 服务连接、模型参数和访问密钥。测试连接会先保存当前模型参数。</p>
|
||||
</div>
|
||||
<a-space>
|
||||
<a-button :loading="testing" @click="testConnection">测试连接</a-button>
|
||||
<a-button type="primary" :loading="saving" @click="saveSettings()">保存参数</a-button>
|
||||
</a-space>
|
||||
</header>
|
||||
|
||||
<a-skeleton v-if="loading" active :paragraph="{ rows: 9 }" />
|
||||
<template v-else>
|
||||
<a-alert
|
||||
v-if="testResult"
|
||||
:type="testResult.ok ? 'success' : 'error'"
|
||||
:message="testResult.ok ? '连接成功' : '连接失败'"
|
||||
:description="testResult.detail"
|
||||
show-icon
|
||||
closable
|
||||
class="result-alert"
|
||||
@close="testResult = null" />
|
||||
|
||||
<section class="settings-panel" aria-labelledby="connection-title">
|
||||
<div class="section-heading">
|
||||
<CloudServerOutlined />
|
||||
<div><h2 id="connection-title">连接与生成参数</h2><p>这些参数用于通用对话;OCR 等识别任务继续使用各自的低温配置。</p></div>
|
||||
</div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="API 协议" class="span-full">
|
||||
<a-radio-group v-model:value="form.protocol" class="protocol-grid">
|
||||
<label v-for="protocol in protocols" :key="protocol.value" class="protocol-option">
|
||||
<a-radio :value="protocol.value" />
|
||||
<span><strong>{{ protocol.label }}</strong><small>{{ protocol.detail }}</small></span>
|
||||
</label>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="API 地址" class="span-full" extra="填写服务根地址,系统会按协议自动追加请求路径。">
|
||||
<a-input v-model:value="form.baseUrl" placeholder="https://api.openai.com/v1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="模型名称">
|
||||
<a-input v-model:value="form.model" placeholder="gpt-4o-mini" />
|
||||
</a-form-item>
|
||||
<a-form-item label="最大输出 Token" extra="单次通用回复的最大输出量,范围 64–4096。">
|
||||
<a-input-number v-model:value="form.maxTokens" :min="64" :max="4096" style="width:100%" />
|
||||
</a-form-item>
|
||||
<a-form-item label="温度" extra="0 更稳定,数值越高回复越灵活;默认 0.7。">
|
||||
<a-input-number v-model:value="form.temperature" :min="0" :max="2" :step="0.1" style="width:100%" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel key-panel" aria-labelledby="key-title">
|
||||
<div class="section-heading key-heading">
|
||||
<KeyOutlined />
|
||||
<div><h2 id="key-title">API Key</h2><p>密钥保存后只显示尾号,完整值不会通过后台接口返回。</p></div>
|
||||
<a-tag :color="keyState.configured ? 'green' : 'default'">{{ sourceLabel(keyState.source) }}</a-tag>
|
||||
</div>
|
||||
<div class="key-status">
|
||||
<span class="status-label">当前密钥</span>
|
||||
<strong>{{ keyState.configured ? keyState.masked : '尚未配置' }}</strong>
|
||||
</div>
|
||||
<template v-if="keyState.canManage">
|
||||
<div class="key-editor">
|
||||
<a-input-password
|
||||
v-model:value="apiKeyInput"
|
||||
autocomplete="new-password"
|
||||
placeholder="输入新的 API Key,保存后将替换当前密钥"
|
||||
@press-enter="saveApiKey" />
|
||||
<a-button type="primary" :loading="savingKey" @click="saveApiKey">保存密钥</a-button>
|
||||
<a-popconfirm
|
||||
v-if="keyState.source === 'database'"
|
||||
title="删除后台保存的密钥?"
|
||||
description="删除后将回退使用 LLM_API_KEY;若未配置环境变量,AI 服务会停用。"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
@confirm="deleteApiKey">
|
||||
<a-button danger :loading="deletingKey">删除</a-button>
|
||||
</a-popconfirm>
|
||||
</div>
|
||||
<p class="key-note">服务端需配置 <code>Secrets__EncryptionKey</code> 才能加密保存,值为 Base64 编码的 32 字节密钥。</p>
|
||||
</template>
|
||||
<a-alert v-else type="info" message="只有超级管理员可以替换或删除 API Key。" show-icon />
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; line-height: 1.3; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; background: #fff; }
|
||||
.settings-panel + .settings-panel { margin-top: 18px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 22px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #5b6bf5; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
.span-full { grid-column: 1 / -1; }
|
||||
.protocol-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; width: 100%; }
|
||||
.protocol-option { display: flex; align-items: flex-start; gap: 4px; min-height: 82px; padding: 14px; border: 1px solid #e4e7eb; border-radius: 12px; cursor: pointer; transition: border-color .18s ease-out, background-color .18s ease-out; }
|
||||
.protocol-option:hover { border-color: #9ba5fa; background: #f8f9ff; }
|
||||
.protocol-option:has(.ant-radio-checked) { border-color: #5b6bf5; background: #f3f4ff; }
|
||||
.protocol-option strong, .protocol-option small { display: block; }
|
||||
.protocol-option small { margin-top: 4px; color: #5e6772; line-height: 1.45; }
|
||||
.key-heading { align-items: center; }
|
||||
.key-heading > div { flex: 1; }
|
||||
.key-status { display: flex; align-items: baseline; gap: 18px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: #f6f7f9; }
|
||||
.status-label { color: #5e6772; }
|
||||
.key-status strong { font-variant-numeric: tabular-nums; letter-spacing: .04em; }
|
||||
.key-editor { display: grid; grid-template-columns: minmax(240px, 1fr) auto auto; gap: 10px; }
|
||||
.key-note { margin: 10px 0 0; color: #5e6772; font-size: 12px; }
|
||||
.key-note code { color: #414eb8; }
|
||||
.result-alert { margin-bottom: 18px; }
|
||||
@media (max-width: 760px) {
|
||||
.page-heading { flex-direction: column; }
|
||||
.form-grid, .protocol-grid, .key-editor { grid-template-columns: 1fr; }
|
||||
.settings-panel { padding: 18px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { BgColorsOutlined, BookOutlined } from '@ant-design/icons-vue'
|
||||
import { useAdminConfigs } from '../composables/useAdminConfigs'
|
||||
|
||||
const keys = [
|
||||
'brand.app_name', 'brand.slogan', 'brand.logo_url',
|
||||
'system.default_ledger_name', 'system.max_ledgers_per_user',
|
||||
]
|
||||
const config = useAdminConfigs({
|
||||
'brand.app_name': '记之',
|
||||
'brand.slogan': '会聊天的记账本 · 让 AI 帮你管钱',
|
||||
'brand.logo_url': '',
|
||||
'system.default_ledger_name': '日常账本',
|
||||
'system.max_ledgers_per_user': '10',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="page-heading">
|
||||
<div><h1>品牌与基础设置</h1><p>管理客户端品牌展示和新用户账本的默认规则。</p></div>
|
||||
<a-button type="primary" :loading="config.saving.value" @click="config.save(keys)">保存设置</a-button>
|
||||
</header>
|
||||
<a-skeleton v-if="config.loading.value" active :paragraph="{ rows: 7 }" />
|
||||
<section v-else class="settings-panel">
|
||||
<div class="section-heading"><BgColorsOutlined /><div><h2>品牌展示</h2><p>名称、标语和 Logo 会用于客户端的公共品牌位置。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="App 名称">
|
||||
<a-input :value="config.value('brand.app_name')" @input="(e:any) => config.setValue('brand.app_name', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="App 标语">
|
||||
<a-input :value="config.value('brand.slogan')" @input="(e:any) => config.setValue('brand.slogan', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Logo URL" class="span-full" extra="请填写客户端可直接访问的 HTTPS 图片地址。">
|
||||
<a-input :value="config.value('brand.logo_url')" placeholder="https://..." @input="(e:any) => config.setValue('brand.logo_url', e.target.value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-divider />
|
||||
<div class="section-heading"><BookOutlined /><div><h2>新用户账本</h2><p>仅影响之后创建的账号和账本,不会覆盖现有用户数据。</p></div></div>
|
||||
<a-form layout="vertical" class="form-grid">
|
||||
<a-form-item label="默认账本名">
|
||||
<a-input :value="config.value('system.default_ledger_name')" @input="(e:any) => config.setValue('system.default_ledger_name', e.target.value)" />
|
||||
</a-form-item>
|
||||
<a-form-item label="每人最多账本数">
|
||||
<a-input-number :value="config.numberValue('system.max_ledgers_per_user')" :min="1" :max="50" style="width:100%" @change="(value: number | null) => config.setValue('system.max_ledgers_per_user', value)" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page { max-width: 1040px; margin: 0 auto; color: #191f26; }
|
||||
.page-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
|
||||
.page-heading h1 { margin: 0 0 6px; font-size: 24px; }
|
||||
.page-heading p, .section-heading p { margin: 0; color: #5e6772; line-height: 1.6; }
|
||||
.settings-panel { padding: 24px; border: 1px solid #eef0f3; border-radius: 14px; }
|
||||
.section-heading { display: flex; align-items: flex-start; gap: 12px; margin-bottom: 20px; }
|
||||
.section-heading > :first-child { margin-top: 3px; color: #00a67d; font-size: 20px; }
|
||||
.section-heading h2 { margin: 0 0 4px; font-size: 17px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }
|
||||
.span-full { grid-column: 1 / -1; }
|
||||
@media (max-width: 760px) { .page-heading { flex-direction: column; } .form-grid { grid-template-columns: 1fr; } .settings-panel { padding: 18px; } }
|
||||
</style>
|
||||
@@ -1,234 +0,0 @@
|
||||
<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,45 @@
|
||||
using System.Security.Cryptography;
|
||||
using MiaoJiZhang.Api.Services;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace MiaoJiZhang.Api.Tests;
|
||||
|
||||
public sealed class LlmSecretProtectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Protect_RoundTripsWithoutEmbeddingPlaintext()
|
||||
{
|
||||
var encryptionKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Secrets:EncryptionKey"] = encryptionKey,
|
||||
})
|
||||
.Build();
|
||||
var protector = new LlmSecretProtector(configuration);
|
||||
|
||||
var encrypted = protector.Protect("sk-test-secret-1234");
|
||||
|
||||
Assert.DoesNotContain("sk-test-secret-1234", encrypted);
|
||||
Assert.Equal("sk-test-secret-1234", protector.Unprotect(encrypted));
|
||||
Assert.Equal("••••1234", LlmSecretProtector.Mask("sk-test-secret-1234"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Protect_RejectsMissingEncryptionKey()
|
||||
{
|
||||
var protector = new LlmSecretProtector(new ConfigurationBuilder().Build());
|
||||
|
||||
var exception = Assert.Throws<InvalidOperationException>(
|
||||
() => protector.Protect("sk-test"));
|
||||
|
||||
Assert.Contains("Secrets__EncryptionKey", exception.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Defaults_UseStableGenerationParameters()
|
||||
{
|
||||
Assert.Equal("1024", AppConfigDefaults.Values["llm.max_tokens"]);
|
||||
Assert.Equal("0.7", AppConfigDefaults.Values["llm.temperature"]);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(AppDbContext db) : ControllerBase
|
||||
[ApiController]
|
||||
[AdminAuth]
|
||||
[Route("api/admin")]
|
||||
public class AdminController(
|
||||
AppDbContext db,
|
||||
LlmSecretProtector llmSecrets,
|
||||
OpenAiVisionClient llmClient) : ControllerBase
|
||||
{
|
||||
[HttpGet("dashboard")] public async Task<IActionResult> Dashboard() { var tu = await db.Users.CountAsync(); var ta = await db.Users.CountAsync(u => u.LastLoginAt.HasValue && u.LastLoginAt.Value >= ChinaClock.ToUtc(ChinaClock.Now.Date) && u.LastLoginAt.Value < ChinaClock.ToUtc(ChinaClock.Now.Date.AddDays(1))); var tt = await db.Transactions.IgnoreQueryFilters().CountAsync(); var ai = await db.Transactions.IgnoreQueryFilters().CountAsync(t => TransactionSourceRules.AiAssisted.Contains(t.Source)); var at = await db.Transactions.IgnoreQueryFilters().Where(t => TransactionSourceRules.AiAssisted.Contains(t.Source)).CountAsync(); var ud = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { users = new { total = tu, activeToday = ta }, transactions = new { total = tt, aiBooked = ai }, aiAccuracy = at == 0 ? 0 : Math.Round((1.0 - (double)ud / at) * 100, 1), undoRate = at == 0 ? 0 : Math.Round((double)ud / at * 100, 1), aiMessages = await db.ChatMessages.CountAsync(m => m.Role == ChatRole.Assistant) }); }
|
||||
|
||||
@@ -38,9 +41,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
return BadRequest(new { error = "secret_env_only", detail = "密钥只能通过服务端环境变量修改" });
|
||||
cfg.Value = req.Value;
|
||||
cfg.Version++;
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
cfg.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
[HttpPost("configs")]
|
||||
public async Task<IActionResult> CreateConfig([FromBody] CreateConfigRequest req)
|
||||
@@ -51,9 +56,11 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
if (await db.AppConfigs.AnyAsync(c => c.Key == req.Key))
|
||||
return Conflict(new { error = "key_exists", detail = "该配置 Key 已存在,请用 PUT 更新" });
|
||||
var cfg = new AppConfig { Key = req.Key.Trim(), Value = req.Value ?? "", Version = 1, UpdatedAt = DateTime.UtcNow };
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
db.AppConfigs.Add(cfg);
|
||||
await db.SaveChangesAsync();
|
||||
if (cfg.Key.StartsWith("llm.", StringComparison.OrdinalIgnoreCase))
|
||||
llmClient.InvalidateConfiguration();
|
||||
return Ok(new { cfg.Id, cfg.Key, cfg.Value, cfg.Version });
|
||||
}
|
||||
|
||||
[HttpGet("personas")] public async Task<IActionResult> ListPersonas() => Ok(await db.AiPersonas.OrderBy(p => p.Key).ToListAsync());
|
||||
@@ -71,9 +78,150 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
[HttpPut("stickers/{id:long}")] public async Task<IActionResult> UpdateSticker(long id, [FromBody] UpsertStickerRequest req) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); s.Key = req.Key; s.Label = req.Label; s.GroupKey = req.GroupKey; s.TriggerTags = req.TriggerTags; s.ImageUrl = req.ImageUrl; s.IsEnabled = req.IsEnabled; await db.SaveChangesAsync(); return Ok(s); }
|
||||
[HttpDelete("stickers/{id:long}")] public async Task<IActionResult> DeleteSticker(long id) { var s = await db.Stickers.FindAsync(id); if (s is null) return NotFound(); db.Stickers.Remove(s); await db.SaveChangesAsync(); return NoContent(); }
|
||||
|
||||
[HttpPost("llm/test")] public async Task<IActionResult> TestLlm([FromServices] ILlmClient llm) { var (ok, error) = await llm.TestConnectionAsync(); return Ok(new { ok, error = ok ? (string?)null : error, detail = ok ? "LLM 连接正常" : error }); }
|
||||
[HttpGet("llm/settings")]
|
||||
public async Task<IActionResult> GetLlmSettings()
|
||||
{
|
||||
var values = await db.AppConfigs
|
||||
.Where(config => config.Key.StartsWith("llm."))
|
||||
.ToDictionaryAsync(config => config.Key, config => config.Value);
|
||||
string Read(string key) => values.GetValueOrDefault(
|
||||
key,
|
||||
AppConfigDefaults.Values[key]);
|
||||
|
||||
var encrypted = values.GetValueOrDefault(LlmSecretProtector.ConfigKey);
|
||||
var source = "none";
|
||||
var masked = "";
|
||||
if (llmSecrets.TryUnprotect(encrypted, out var databaseKey))
|
||||
{
|
||||
source = "database";
|
||||
masked = LlmSecretProtector.Mask(databaseKey);
|
||||
}
|
||||
else
|
||||
{
|
||||
var environmentKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(environmentKey))
|
||||
{
|
||||
source = "environment";
|
||||
masked = LlmSecretProtector.Mask(environmentKey);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
protocol = Read("llm.protocol"),
|
||||
baseUrl = Read("llm.base_url"),
|
||||
model = Read("llm.model"),
|
||||
maxTokens = int.TryParse(Read("llm.max_tokens"), out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096) : 1024,
|
||||
temperature = double.TryParse(
|
||||
Read("llm.temperature"),
|
||||
System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2) : 0.7,
|
||||
apiKey = new
|
||||
{
|
||||
configured = source != "none",
|
||||
masked,
|
||||
source,
|
||||
canManage = AdminRequestContext.Principal(HttpContext)?.Role ==
|
||||
AdminRoles.SuperAdmin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("llm/settings")]
|
||||
public async Task<IActionResult> UpdateLlmSettings(
|
||||
[FromBody] UpdateLlmSettingsRequest request)
|
||||
{
|
||||
var protocol = request.Protocol.Trim().ToLowerInvariant();
|
||||
if (protocol is not ("chat_completions" or "responses" or "messages"))
|
||||
return BadRequest(new { error = "protocol_invalid", detail = "API 协议不受支持" });
|
||||
if (!Uri.TryCreate(request.BaseUrl, UriKind.Absolute, out var baseUri) ||
|
||||
baseUri.Scheme is not ("http" or "https"))
|
||||
return BadRequest(new { error = "base_url_invalid", detail = "API 地址必须是有效的 HTTP 或 HTTPS 地址" });
|
||||
if (string.IsNullOrWhiteSpace(request.Model))
|
||||
return BadRequest(new { error = "model_required", detail = "模型名称不能为空" });
|
||||
if (request.MaxTokens is < 64 or > 4096)
|
||||
return BadRequest(new { error = "max_tokens_invalid", detail = "最大输出 Token 必须在 64 到 4096 之间" });
|
||||
if (request.Temperature is < 0 or > 2)
|
||||
return BadRequest(new { error = "temperature_invalid", detail = "温度必须在 0 到 2 之间" });
|
||||
|
||||
var values = new Dictionary<string, string>
|
||||
{
|
||||
["llm.protocol"] = protocol,
|
||||
["llm.base_url"] = request.BaseUrl.Trim().TrimEnd('/'),
|
||||
["llm.model"] = request.Model.Trim(),
|
||||
["llm.max_tokens"] = request.MaxTokens.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
["llm.temperature"] = request.Temperature.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
};
|
||||
await UpsertConfigsAsync(values);
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPut("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> UpdateLlmApiKey([FromBody] UpdateLlmApiKeyRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ApiKey))
|
||||
return BadRequest(new { error = "api_key_required", detail = "API Key 不能为空" });
|
||||
if (request.ApiKey.Trim().Length > 8192)
|
||||
return BadRequest(new { error = "api_key_too_long", detail = "API Key 长度异常" });
|
||||
|
||||
string protectedValue;
|
||||
try { protectedValue = llmSecrets.Protect(request.ApiKey); }
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
return Problem(
|
||||
statusCode: StatusCodes.Status503ServiceUnavailable,
|
||||
title: "密钥加密尚未配置",
|
||||
detail: exception.Message);
|
||||
}
|
||||
await UpsertConfigsAsync(new Dictionary<string, string>
|
||||
{
|
||||
[LlmSecretProtector.ConfigKey] = protectedValue,
|
||||
});
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpDelete("llm/api-key")]
|
||||
[AdminAuth(AdminRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> DeleteLlmApiKey()
|
||||
{
|
||||
var config = await db.AppConfigs.FirstOrDefaultAsync(
|
||||
item => item.Key == LlmSecretProtector.ConfigKey);
|
||||
if (config is not null)
|
||||
{
|
||||
db.AppConfigs.Remove(config);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
llmClient.InvalidateConfiguration();
|
||||
return await GetLlmSettings();
|
||||
}
|
||||
|
||||
[HttpPost("llm/test")]
|
||||
public async Task<IActionResult> TestLlm()
|
||||
{
|
||||
llmClient.InvalidateConfiguration();
|
||||
var (ok, error) = await llmClient.TestConnectionAsync();
|
||||
return Ok(new
|
||||
{
|
||||
ok,
|
||||
error = ok ? (string?)null : error,
|
||||
detail = ok ? "LLM 连接正常" : error,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("configs/init")] public async Task<IActionResult> InitConfigs() { var now = DateTime.UtcNow; var defaults = new Dictionary<string, string> { ["brand.app_name"] = "记之", ["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱", ["brand.logo_url"] = "", ["llm.protocol"] = "responses", ["llm.base_url"] = "https://api.openai.com/v1", ["llm.model"] = "gpt-4o-mini", ["llm.max_tokens"] = "1024", ["llm.temperature"] = "0.8", ["limit.daily_ai_messages"] = "200", ["limit.daily_ai_messages_per_user"] = "50", ["limit.max_monthly_budget"] = "99999999", ["feature.ocr_enabled"] = "true", ["feature.voice_enabled"] = "true", ["feature.ai_auto_book"] = "true", ["feature.sticker_enabled"] = "true", ["system.default_ledger_name"] = "日常账本", ["system.max_ledgers_per_user"] = "10", ["feature.screenshot_bookkeeping_enabled"] = "true", ["permission.default.ai_enabled"] = "true", ["quota.default_ai_chat_limit"] = "50", ["quota.default_ai_chat_period"] = "day" }; var existing = await db.AppConfigs.Select(c => c.Key).ToListAsync(); var added = 0; foreach (var (key, value) in defaults) { if (!existing.Contains(key)) { db.AppConfigs.Add(new AppConfig { Key = key, Value = value, Version = 1, UpdatedAt = now }); added++; } } if (added > 0) await db.SaveChangesAsync(); return Ok(new { added, total = defaults.Count }); }
|
||||
[HttpPost("configs/init")]
|
||||
public async Task<IActionResult> InitConfigs()
|
||||
{
|
||||
var added = await AppConfigDefaults.EnsureAsync(db);
|
||||
return Ok(new { added, total = AppConfigDefaults.Values.Count });
|
||||
}
|
||||
|
||||
[HttpGet("categories")] public async Task<IActionResult> ListSystemCategories() => Ok(await db.Categories.Where(c => c.UserId == null && !c.IsDeleted).OrderBy(c => c.Type).ThenBy(c => c.SortOrder).ToListAsync());
|
||||
[HttpPost("categories")]
|
||||
@@ -264,17 +412,53 @@ public class AdminController(AppDbContext db) : ControllerBase
|
||||
|
||||
[HttpGet("users/{id:long}/stats")] public async Task<IActionResult> UserStats(long id) { var u = await db.Users.FindAsync(id); if (u is null) return NotFound(); var txCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id); var aiCount = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && TransactionSourceRules.AiAssisted.Contains(t.Source)); var undone = await db.Transactions.IgnoreQueryFilters().CountAsync(t => t.UserId == id && t.IsDeleted && TransactionSourceRules.AiAssisted.Contains(t.Source)); return Ok(new { totalTransactions = txCount, aiBooked = aiCount, aiAccuracy = aiCount == 0 ? 0 : Math.Round((1.0 - (double)undone / aiCount) * 100, 1) }); }
|
||||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.Equals("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
private static bool IsSecret(string key) =>
|
||||
key.StartsWith("llm.api_key", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("secret", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.Contains("password", StringComparison.OrdinalIgnoreCase) ||
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
key.EndsWith("token", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private async Task UpsertConfigsAsync(IReadOnlyDictionary<string, string> values)
|
||||
{
|
||||
var keys = values.Keys.ToArray();
|
||||
var existing = await db.AppConfigs
|
||||
.Where(config => keys.Contains(config.Key))
|
||||
.ToDictionaryAsync(config => config.Key);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var (key, value) in values)
|
||||
{
|
||||
if (existing.TryGetValue(key, out var config))
|
||||
{
|
||||
config.Value = value;
|
||||
config.Version++;
|
||||
config.UpdatedAt = now;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateConfigRequest(string Value);
|
||||
public record CreateConfigRequest(string Key, string Value);
|
||||
public record UpsertPersonaRequest(string Key, string Name, string Description, string SampleLine, string PromptTemplate, bool IsEnabled = true);
|
||||
public record UpsertAvatarRequest(string Key, string Name, string SpeechTic, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertStickerRequest(string Key, string Label, string GroupKey, string? TriggerTags, string? ImageUrl, bool IsEnabled = true);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpsertCategoryRequest(string Name, string IconKey, string Type);
|
||||
public record UpdateLlmSettingsRequest(
|
||||
string Protocol,
|
||||
string BaseUrl,
|
||||
string Model,
|
||||
int MaxTokens,
|
||||
double Temperature);
|
||||
public record UpdateLlmApiKeyRequest(string ApiKey);
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ builder.Services.AddScoped<AiChatQuotaService>();
|
||||
builder.Services.AddScoped<BudgetPushService>();
|
||||
builder.Services.AddScoped<AdminSessionService>();
|
||||
builder.Services.AddScoped<AdminBootstrapService>();
|
||||
builder.Services.AddSingleton<LlmSecretProtector>();
|
||||
builder.Services.AddSingleton<PushTokenProtector>();
|
||||
builder.Services.AddScoped<AiPermissionFilter>();
|
||||
builder.Services.AddHttpClient("LlmClient");
|
||||
@@ -165,6 +166,7 @@ using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
await AppConfigDefaults.EnsureAsync(db);
|
||||
await scope.ServiceProvider.GetRequiredService<AdminBootstrapService>().EnsureAsync();
|
||||
if (app.Environment.IsDevelopment())
|
||||
await DbSeeder.SeedAsync(db);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using MiaoJiZhang.Domain.Entities;
|
||||
using MiaoJiZhang.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public static class AppConfigDefaults
|
||||
{
|
||||
public static readonly IReadOnlyDictionary<string, string> Values =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["brand.app_name"] = "记之",
|
||||
["brand.slogan"] = "会聊天的记账本 · 让 AI 帮你管钱",
|
||||
["brand.logo_url"] = "",
|
||||
["llm.protocol"] = "responses",
|
||||
["llm.base_url"] = "https://api.openai.com/v1",
|
||||
["llm.model"] = "gpt-4o-mini",
|
||||
["llm.max_tokens"] = "1024",
|
||||
["llm.temperature"] = "0.7",
|
||||
["limit.daily_ai_messages"] = "200",
|
||||
["limit.daily_ai_messages_per_user"] = "50",
|
||||
["limit.max_monthly_budget"] = "99999999",
|
||||
["feature.ocr_enabled"] = "true",
|
||||
["feature.voice_enabled"] = "true",
|
||||
["feature.ai_auto_book"] = "true",
|
||||
["feature.sticker_enabled"] = "true",
|
||||
["feature.screenshot_bookkeeping_enabled"] = "true",
|
||||
["permission.default.ai_enabled"] = "true",
|
||||
["quota.default_ai_chat_limit"] = "50",
|
||||
["quota.default_ai_chat_period"] = "day",
|
||||
["system.default_ledger_name"] = "日常账本",
|
||||
["system.max_ledgers_per_user"] = "10",
|
||||
};
|
||||
|
||||
public static async Task<int> EnsureAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var existing = await db.AppConfigs
|
||||
.Select(config => config.Key)
|
||||
.ToListAsync(ct);
|
||||
var keys = existing.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var now = DateTime.UtcNow;
|
||||
var added = 0;
|
||||
foreach (var (key, value) in Values)
|
||||
{
|
||||
if (keys.Contains(key)) continue;
|
||||
db.AppConfigs.Add(new AppConfig
|
||||
{
|
||||
Key = key,
|
||||
Value = value,
|
||||
Version = 1,
|
||||
UpdatedAt = now,
|
||||
});
|
||||
added++;
|
||||
}
|
||||
if (added > 0) await db.SaveChangesAsync(ct);
|
||||
return added;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiaoJiZhang.Api.Services;
|
||||
|
||||
public sealed class LlmSecretProtector(IConfiguration configuration)
|
||||
{
|
||||
public const string ConfigKey = "llm.api_key_encrypted";
|
||||
private const string Prefix = "v1";
|
||||
|
||||
public string Protect(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new ArgumentException("API Key 不能为空", nameof(value));
|
||||
var key = ReadEncryptionKey();
|
||||
var nonce = RandomNumberGenerator.GetBytes(12);
|
||||
var plaintext = Encoding.UTF8.GetBytes(value.Trim());
|
||||
var ciphertext = new byte[plaintext.Length];
|
||||
var tag = new byte[16];
|
||||
using var aes = new AesGcm(key, tag.Length);
|
||||
aes.Encrypt(nonce, plaintext, ciphertext, tag);
|
||||
return string.Join(':', Prefix,
|
||||
Convert.ToBase64String(nonce),
|
||||
Convert.ToBase64String(ciphertext),
|
||||
Convert.ToBase64String(tag));
|
||||
}
|
||||
|
||||
public string Unprotect(string protectedValue)
|
||||
{
|
||||
var parts = protectedValue.Split(':');
|
||||
if (parts.Length != 4 || parts[0] != Prefix)
|
||||
throw new CryptographicException("不支持的密钥密文格式");
|
||||
var nonce = Convert.FromBase64String(parts[1]);
|
||||
var ciphertext = Convert.FromBase64String(parts[2]);
|
||||
var tag = Convert.FromBase64String(parts[3]);
|
||||
var plaintext = new byte[ciphertext.Length];
|
||||
using var aes = new AesGcm(ReadEncryptionKey(), tag.Length);
|
||||
aes.Decrypt(nonce, ciphertext, tag, plaintext);
|
||||
return Encoding.UTF8.GetString(plaintext);
|
||||
}
|
||||
|
||||
public bool TryUnprotect(string? protectedValue, out string value)
|
||||
{
|
||||
value = "";
|
||||
if (string.IsNullOrWhiteSpace(protectedValue)) return false;
|
||||
try
|
||||
{
|
||||
value = Unprotect(protectedValue);
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is ArgumentException or FormatException or
|
||||
CryptographicException or InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static string Mask(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
var suffixLength = Math.Min(4, value.Length);
|
||||
return $"••••{value[^suffixLength..]}";
|
||||
}
|
||||
|
||||
private byte[] ReadEncryptionKey()
|
||||
{
|
||||
var raw = configuration["Secrets:EncryptionKey"];
|
||||
byte[]? key = null;
|
||||
try { key = string.IsNullOrWhiteSpace(raw) ? null : Convert.FromBase64String(raw); }
|
||||
catch (FormatException) { }
|
||||
if (key?.Length != 32)
|
||||
throw new InvalidOperationException(
|
||||
"请通过 Secrets__EncryptionKey 配置 base64 编码的 32 字节密钥后再保存 API Key");
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -13,22 +13,26 @@ namespace MiaoJiZhang.Api.Services;
|
||||
public partial class OpenAiVisionClient : ILlmClient
|
||||
{
|
||||
private readonly IServiceScopeFactory _sf;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<OpenAiVisionClient> _logger;
|
||||
private readonly LlmSecretProtector _secretProtector;
|
||||
private string? _baseUrl, _apiKey, _model, _protocol;
|
||||
private int _maxTokens = 1024;
|
||||
private double _temperature = 0.7;
|
||||
private DateTime _last = DateTime.MinValue;
|
||||
private static readonly object _lk = new();
|
||||
|
||||
public OpenAiVisionClient(
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
IServiceScopeFactory sf,
|
||||
IHttpClientFactory hf,
|
||||
LlmSecretProtector secretProtector,
|
||||
ILogger<OpenAiVisionClient> logger)
|
||||
{
|
||||
_sf = sf;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_logger = logger;
|
||||
_http = hf.CreateClient("LlmClient");
|
||||
_http.Timeout = TimeSpan.FromSeconds(120);
|
||||
_secretProtector = secretProtector;
|
||||
_logger = logger;
|
||||
}
|
||||
public bool IsEnabled { get { Load(); return !string.IsNullOrEmpty(_apiKey); } }
|
||||
|
||||
@@ -679,14 +683,22 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
|
||||
public async Task<(bool, string?)> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
if (_protocol != "responses")
|
||||
return (false, "AI Agent 记账要求使用 Responses 协议");
|
||||
|
||||
try
|
||||
{
|
||||
var tool = new AgentToolDefinition(
|
||||
Load();
|
||||
if (string.IsNullOrEmpty(_apiKey)) return (false, "API Key 为空");
|
||||
|
||||
try
|
||||
{
|
||||
if (_protocol != "responses")
|
||||
{
|
||||
var reply = await L(
|
||||
"你正在执行连接测试,只回复 OK。",
|
||||
"测试连接",
|
||||
ct);
|
||||
return string.IsNullOrWhiteSpace(reply)
|
||||
? (false, "模型没有返回内容")
|
||||
: (true, null);
|
||||
}
|
||||
var tool = new AgentToolDefinition(
|
||||
"diagnostic_echo",
|
||||
"连接测试时必须调用的无副作用工具",
|
||||
JsonSerializer.Deserialize<JsonElement>(
|
||||
@@ -716,8 +728,13 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
var msgs = new List<object>();
|
||||
if (_protocol == "messages") msgs.Add(new { role = "user", content = s + "\n\n" + u });
|
||||
else { msgs.Add(new { role = "system", content = s }); msgs.Add(new { role = "user", content = u }); }
|
||||
await SA(BuildBody(msgs, _maxTokens, 0.7), onToken, ct);
|
||||
}
|
||||
await SA(BuildBody(msgs, _maxTokens, _temperature), onToken, ct);
|
||||
}
|
||||
|
||||
public void InvalidateConfiguration()
|
||||
{
|
||||
lock (_lk) _last = DateTime.MinValue;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
@@ -728,23 +745,36 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
try
|
||||
{
|
||||
using var scope = _sf.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
config.GetValueOrDefault("llm.base_url", "https://api.openai.com/v1") ??
|
||||
"").TrimEnd('/');
|
||||
_model = Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
config.GetValueOrDefault("llm.model", "gpt-4o-mini");
|
||||
_protocol = Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
config.GetValueOrDefault("llm.protocol", "chat_completions");
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var config = db.AppConfigs.ToDictionary(x => x.Key, x => x.Value);
|
||||
_apiKey = _secretProtector.TryUnprotect(
|
||||
config.GetValueOrDefault(LlmSecretProtector.ConfigKey),
|
||||
out var protectedApiKey)
|
||||
? protectedApiKey
|
||||
: Environment.GetEnvironmentVariable("LLM_API_KEY") ?? "";
|
||||
_baseUrl = (
|
||||
config.GetValueOrDefault("llm.base_url") ??
|
||||
Environment.GetEnvironmentVariable("LLM_BASE_URL") ??
|
||||
"https://api.openai.com/v1").TrimEnd('/');
|
||||
_model = config.GetValueOrDefault("llm.model") ??
|
||||
Environment.GetEnvironmentVariable("LLM_MODEL") ??
|
||||
"gpt-4o-mini";
|
||||
_protocol = config.GetValueOrDefault("llm.protocol") ??
|
||||
Environment.GetEnvironmentVariable("LLM_PROTOCOL") ??
|
||||
"responses";
|
||||
_maxTokens = int.TryParse(
|
||||
config.GetValueOrDefault("llm.max_tokens"),
|
||||
out var maxTokens)
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_last = DateTime.UtcNow;
|
||||
? Math.Clamp(maxTokens, 64, 4096)
|
||||
: 1024;
|
||||
_temperature = double.TryParse(
|
||||
config.GetValueOrDefault("llm.temperature"),
|
||||
NumberStyles.Float,
|
||||
CultureInfo.InvariantCulture,
|
||||
out var temperature)
|
||||
? Math.Clamp(temperature, 0, 2)
|
||||
: 0.7;
|
||||
_last = DateTime.UtcNow;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
@@ -754,7 +784,7 @@ public partial class OpenAiVisionClient : ILlmClient
|
||||
}
|
||||
|
||||
async Task<string?> L(string sys, string user, CancellationToken ct)
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, 0.7), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
{ Load(); var msgs = new List<object>(); if (_protocol == "messages") msgs.Add(new { role = "user", content = sys + "\n\n" + user }); else { msgs.Add(new { role = "system", content = sys }); msgs.Add(new { role = "user", content = user }); } var (j, _) = await CA(BuildBody(msgs, _maxTokens, _temperature), ct); if (j is null) return null; var content = EX(j).Trim(); return content.Length > 0 ? content : null; }
|
||||
|
||||
object BuildBody(List<object> msgs, int maxT, double temp) => _protocol switch
|
||||
{ "messages" => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp }, "responses" => new { model = _model, input = msgs, max_output_tokens = maxT, temperature = temp, thinking = new { type = "disabled" } }, _ => new { model = _model, messages = msgs, max_tokens = maxT, temperature = temp } };
|
||||
|
||||
@@ -99,7 +99,7 @@ public static class DbSeeder
|
||||
|
||||
new AppConfig { Key = "llm.model", Value = "gpt-4o-mini", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.max_tokens", Value = "1024", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.8", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "llm.temperature", Value = "0.7", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages", Value = "200", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.daily_ai_messages_per_user", Value = "50", Version = 1, UpdatedAt = now },
|
||||
new AppConfig { Key = "limit.max_monthly_budget", Value = "99999999", Version = 1, UpdatedAt = now },
|
||||
|
||||
+13
-1
@@ -44,7 +44,8 @@
|
||||
```bash
|
||||
cd backend
|
||||
export Admin__BootstrapUsername='admin'
|
||||
export Admin__BootstrapPassword='replace-with-a-random-password-of-at-least-12-characters'
|
||||
export Admin__BootstrapPassword='replace-with-a-password-longer-than-5-characters'
|
||||
export Secrets__EncryptionKey='base64-encoded-32-byte-key'
|
||||
dotnet build
|
||||
# 重启
|
||||
powershell -Command "Get-Process dotnet | Stop-Process -Force"
|
||||
@@ -56,6 +57,17 @@ dotnet run --project MiaoJiZhang.Api
|
||||
引导变量。正式环境必须使用 HTTPS 并保持 `Admin__CookieSecure=true`。本地纯 HTTP 调试时才可
|
||||
临时设置 `Admin__CookieSecure=false`。
|
||||
|
||||
后台“AI 配置 → 模型服务”可以保存和替换 LLM API Key。实际 API Key 使用 AES-GCM
|
||||
加密后写入配置表,服务端只需通过 `Secrets__EncryptionKey` 提供一个固定的 32 字节
|
||||
加密主密钥;页面和接口只显示 API Key 尾号。可使用 PowerShell 生成:
|
||||
|
||||
```powershell
|
||||
[Convert]::ToBase64String([Security.Cryptography.RandomNumberGenerator]::GetBytes(32))
|
||||
```
|
||||
|
||||
请将该值保存到部署平台的密钥管理中,不要提交到仓库。更换或丢失主密钥会导致后台已保存的
|
||||
LLM API Key 无法解密。旧的 `LLM_API_KEY` 仍作为回退配置;后台保存的密钥优先。
|
||||
|
||||
### 2. Admin Web
|
||||
```powershell
|
||||
cd admin-web
|
||||
|
||||
Reference in New Issue
Block a user