This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
@@ -0,0 +1,3 @@
# \_core
此目录包含应用程序正常运行所需的基本视图。这些视图是应用程序布局中使用的视图。
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { About } from '@vben/common-ui';
defineOptions({ name: 'About' });
</script>
<template>
<About />
</template>
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, ref } from 'vue';
import { AuthenticationForgetPassword, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'ForgetPassword' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: 'example@example.com',
},
fieldName: 'email',
label: $t('authentication.email'),
rules: z
.string()
.min(1, { message: $t('authentication.emailTip') })
.email($t('authentication.emailValidErrorTip')),
},
];
});
function handleSubmit(value: Recordable<any>) {
void value;
}
</script>
<template>
<AuthenticationForgetPassword
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>
@@ -0,0 +1,48 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import { computed } from 'vue';
import { AuthenticationLogin, z } from '@vben/common-ui';
import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
// 模板自带的演示账号下拉、滑块验证码、手机号/扫码/第三方登录、注册和找回密码
// 都删掉了——后端没有对应的接口,留着只会让人点进死路。滑块验证码尤其要注意:
// 它是纯前端的,挡不住脚本,只挡真人。真要防爆破得在服务端做失败计数。
const formSchema = computed((): VbenFormSchema[] => [
{
component: 'VbenInput',
componentProps: { placeholder: '用户名' },
fieldName: 'username',
label: '用户名',
rules: z.string().min(1, { message: '请输入用户名' }),
},
{
component: 'VbenInputPassword',
componentProps: { placeholder: '密码' },
fieldName: 'password',
label: '密码',
rules: z.string().min(1, { message: '请输入密码' }),
},
]);
</script>
<template>
<AuthenticationLogin
:form-schema="formSchema"
:loading="authStore.loginLoading"
:show-code-login="false"
:show-forget-password="false"
:show-qrcode-login="false"
:show-register="false"
:show-third-party-login="false"
sub-title="请使用管理员分配的账号登录"
title="真羊 AI 客服 · 管理后台"
@submit="authStore.authLogin"
/>
</template>
@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import type { Recordable } from '@vben/types';
import { computed, h, ref } from 'vue';
import { AuthenticationRegister, z } from '@vben/common-ui';
import { $t } from '@vben/locales';
defineOptions({ name: 'Register' });
const loading = ref(false);
const formSchema = computed((): VbenFormSchema[] => {
return [
{
component: 'VbenInput',
componentProps: {
placeholder: $t('authentication.usernameTip'),
},
fieldName: 'username',
label: $t('authentication.username'),
rules: z.string().min(1, { message: $t('authentication.usernameTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: $t('authentication.password'),
},
fieldName: 'password',
label: $t('authentication.password'),
renderComponentContent() {
return {
strengthText: () => $t('authentication.passwordStrength'),
};
},
rules: z.string().min(1, { message: $t('authentication.passwordTip') }),
},
{
component: 'VbenInputPassword',
componentProps: {
placeholder: $t('authentication.confirmPassword'),
},
dependencies: {
rules(values) {
const { password } = values;
return z
.string({ error: $t('authentication.passwordTip') })
.min(1, { message: $t('authentication.passwordTip') })
.refine((value) => value === password, {
message: $t('authentication.confirmPasswordTip'),
});
},
triggerFields: ['password'],
},
fieldName: 'confirmPassword',
label: $t('authentication.confirmPassword'),
},
{
component: 'VbenCheckbox',
fieldName: 'agreePolicy',
renderComponentContent: () => ({
default: () =>
h('span', [
$t('authentication.agree'),
h(
'a',
{
class: 'vben-link ml-1 ',
href: '',
},
`${$t('authentication.privacyPolicy')} & ${$t('authentication.terms')}`,
),
]),
}),
rules: z.boolean().refine((value) => !!value, {
message: $t('authentication.agreeTip'),
}),
},
];
});
function handleSubmit(value: Recordable<any>) {
void value;
}
</script>
<template>
<AuthenticationRegister
:form-schema="formSchema"
:loading="loading"
@submit="handleSubmit"
/>
</template>
@@ -0,0 +1,7 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
</script>
<template>
<Fallback status="coming-soon" />
</template>
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback403Demo' });
</script>
<template>
<Fallback status="403" />
</template>
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback500Demo' });
</script>
<template>
<Fallback status="500" />
</template>
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'Fallback404Demo' });
</script>
<template>
<Fallback status="404" />
</template>
@@ -0,0 +1,9 @@
<script lang="ts" setup>
import { Fallback } from '@vben/common-ui';
defineOptions({ name: 'FallbackOfflineDemo' });
</script>
<template>
<Fallback status="offline" />
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import type { BasicOption } from '@vben/types';
import type { VbenFormSchema } from '#/adapter/form';
import { computed, onMounted, ref } from 'vue';
import { ProfileBaseSetting } from '@vben/common-ui';
import { getUserInfoApi } from '#/api';
const profileBaseSettingRef = ref();
const MOCK_ROLES_OPTIONS: BasicOption[] = [
{
label: '管理员',
value: 'super',
},
{
label: '用户',
value: 'user',
},
{
label: '测试',
value: 'test',
},
];
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'realName',
component: 'Input',
label: '姓名',
},
{
fieldName: 'username',
component: 'Input',
label: '用户名',
},
{
fieldName: 'roles',
component: 'Select',
componentProps: {
mode: 'tags',
options: MOCK_ROLES_OPTIONS,
},
label: '角色',
},
{
fieldName: 'introduction',
component: 'Textarea',
label: '个人简介',
},
];
});
onMounted(async () => {
const data = await getUserInfoApi();
profileBaseSettingRef.value.getFormApi().setValues(data);
});
</script>
<template>
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Profile } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
</script>
<template>
<Profile
v-model:model-value="tabsValue"
title="个人中心"
:user-info="userStore.userInfo"
:tabs="tabs"
>
<template #content>
<ProfileBase v-if="tabsValue === 'basic'" />
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
</template>
</Profile>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileNotificationSetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '其他用户的消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'systemMessage',
label: '系统消息',
description: '系统消息将以站内信的形式通知',
},
{
value: true,
fieldName: 'todoTask',
label: '待办任务',
description: '待办任务将以站内信的形式通知',
},
];
});
</script>
<template>
<ProfileNotificationSetting :form-schema="formSchema" />
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { VbenFormSchema } from '#/adapter/form';
import { computed } from 'vue';
import { ProfilePasswordSetting, z } from '@vben/common-ui';
import { message } from 'ant-design-vue';
const formSchema = computed((): VbenFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
component: 'VbenInputPassword',
componentProps: {
placeholder: '请输入旧密码',
},
},
{
fieldName: 'newPassword',
label: '新密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
},
},
{
fieldName: 'confirmPassword',
label: '确认密码',
component: 'VbenInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
});
},
triggerFields: ['newPassword'],
},
},
];
});
function handleSubmit() {
message.success('密码修改成功');
}
</script>
<template>
<ProfilePasswordSetting
class="w-1/3"
:form-schema="formSchema"
@submit="handleSubmit"
/>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { ProfileSecuritySetting } from '@vben/common-ui';
const formSchema = computed(() => {
return [
{
value: true,
fieldName: 'accountPassword',
label: '账户密码',
description: '当前密码强度:强',
},
{
value: true,
fieldName: 'securityPhone',
label: '密保手机',
description: '已绑定手机:138****8293',
},
{
value: true,
fieldName: 'securityQuestion',
label: '密保问题',
description: '未设置密保问题,密保问题可有效保护账户安全',
},
{
value: true,
fieldName: 'securityEmail',
label: '备用邮箱',
description: '已绑定邮箱:ant***sign.com',
},
{
value: false,
fieldName: 'securityMfa',
label: 'MFA 设备',
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
},
];
});
</script>
<template>
<ProfileSecuritySetting :form-schema="formSchema" />
</template>
@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { ArchiveConversation, ArchiveMessage } from '#/api/archive';
import { onMounted, ref } from 'vue';
import {
Button,
Card,
Drawer,
Empty,
Image as AImage,
Table,
Tag,
} from 'ant-design-vue';
import {
fetchArchiveConversations,
fetchArchiveMediaAccessUrls,
fetchArchiveMessages,
} from '#/api/archive';
const loading = ref(false);
const messageLoading = ref(false);
const conversations = ref<ArchiveConversation[]>([]);
const nextCursor = ref('');
const hasMore = ref(false);
const selected = ref<ArchiveConversation | null>(null);
const messages = ref<ArchiveMessage[]>([]);
const messageCursor = ref('');
const messageHasMore = ref(false);
const mediaUrls = ref<Record<string, string>>({});
function formatSize(value: number) {
if (!Number.isFinite(value) || value <= 0) return '未知大小';
const units = ['B', 'KB', 'MB', 'GB'];
let size = value;
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index += 1;
}
return `${size >= 10 || index === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value || '-';
const parts = new Intl.DateTimeFormat('zh-CN', {
day: '2-digit',
hour: '2-digit',
hourCycle: 'h23',
minute: '2-digit',
month: '2-digit',
second: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
}).formatToParts(date);
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]));
return `${values.year}-${values.month}-${values.day} ${values.hour}:${values.minute}:${values.second}`;
}
function isBrowserAudio(mimeType: string) {
return !['audio/amr', 'audio/silk'].includes(String(mimeType).toLowerCase());
}
function attachmentStatusLabel(status: string) {
const labels: Record<string, string> = {
source_not_cached: '源文件未缓存,请先在企业微信中下载',
upload_failed: '上传失败,下次启动将自动重试',
};
return labels[status] || status;
}
async function loadMediaUrls(items: ArchiveMessage[]) {
const ids = [
...new Set(
items.flatMap((item) =>
(item.attachments || [])
.filter((attachment) => attachment.status === 'ready')
.map((attachment) => attachment.id),
),
),
].filter((id) => !mediaUrls.value[id]);
for (let index = 0; index < ids.length; index += 200) {
try {
const data = await fetchArchiveMediaAccessUrls(ids.slice(index, index + 200));
const additions = Object.fromEntries(
data.items.map((item) => [item.id, item.url]),
);
mediaUrls.value = { ...mediaUrls.value, ...additions };
} catch {
// 消息正文仍可正常查看;素材签名失败时保留状态提示,刷新后可重试。
}
}
}
async function load(reset = true) {
loading.value = true;
try {
const data = await fetchArchiveConversations(
50,
reset ? '' : nextCursor.value,
);
conversations.value = reset
? data.items
: [...conversations.value, ...data.items];
nextCursor.value = data.next_cursor;
hasMore.value = data.has_more;
} finally {
loading.value = false;
}
}
async function openConversation(record: Record<string, any>) {
selected.value = record as ArchiveConversation;
messages.value = [];
messageCursor.value = '';
mediaUrls.value = {};
await loadMessages(true);
}
async function loadMessages(reset = false) {
if (!selected.value) return;
messageLoading.value = true;
try {
const data = await fetchArchiveMessages(
selected.value.id,
100,
reset ? '' : messageCursor.value,
);
messages.value = reset ? data.items : [...messages.value, ...data.items];
messageCursor.value = data.next_cursor;
messageHasMore.value = data.has_more;
await loadMediaUrls(data.items);
} finally {
messageLoading.value = false;
}
}
const TYPE_LABEL: Record<string, string> = {
application: '应用会话',
direct_wechat: '微信单聊',
direct_wecom: '企微单聊',
group: '群聊',
service: '客服会话',
unknown: '未知',
};
onMounted(() => load(true));
</script>
<template>
<div class="p-5">
<Card title="会话与消息">
<template #extra><Button :loading="loading" @click="load(true)">刷新</Button></template>
<p class="mb-3 text-sm text-gray-500">
使用时间 + 唯一 ID 游标翻页数据增长到百万级时不会因深分页越来越慢
</p>
<Table
:data-source="conversations"
:loading="loading"
row-key="id"
size="small"
:pagination="false"
:columns="[
{ title: '会话', dataIndex: 'name', key: 'name' },
{ title: '类型', dataIndex: 'conversation_type', key: 'conversation_type', width: 120 },
{ title: '归档账号', dataIndex: 'source_account', key: 'source_account', width: 150 },
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
{ title: '最后消息时间', dataIndex: 'last_message_at', key: 'last_message_at', width: 210 },
{ title: '操作', key: 'action', width: 90 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<div class="font-medium">{{ record.name }}</div>
<div class="max-w-[420px] truncate text-xs text-gray-400">{{ record.last_content }}</div>
</template>
<template v-else-if="column.key === 'conversation_type'">
<Tag>{{ TYPE_LABEL[record.conversation_type] || record.conversation_type }}</Tag>
</template>
<template v-else-if="column.key === 'last_message_at'">
{{ formatDateTime(record.last_message_at) }}
</template>
<template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="openConversation(record)">查看</Button>
</template>
</template>
</Table>
<div v-if="hasMore" class="mt-4 text-center">
<Button :loading="loading" @click="load(false)">加载更多会话</Button>
</div>
</Card>
<Drawer
:open="!!selected"
:title="selected?.name"
width="860"
@close="selected = null"
>
<Empty v-if="!messageLoading && !messages.length" description="暂无消息" />
<div v-for="item in messages" :key="item.id" class="mb-3 border-b border-gray-100 pb-3">
<div class="mb-1 flex items-center justify-between text-xs text-gray-400">
<span>
<strong class="mr-2 text-gray-600">{{ item.sender_name || '未知发送者' }}</strong>
{{ item.message_type }}
<Tag v-if="item.attachment_count" class="ml-2">{{ item.attachment_count }} 个素材</Tag>
</span>
<span>{{ formatDateTime(item.sent_at) }}</span>
</div>
<div class="whitespace-pre-wrap break-words text-sm">{{ item.content || '(非文本消息)' }}</div>
<div v-if="item.attachments?.length" class="mt-3 space-y-3">
<div
v-for="attachment in item.attachments"
:key="attachment.id"
class="rounded-md border border-gray-200 bg-gray-50 p-3"
>
<AImage
v-if="attachment.media_type === 'image' && mediaUrls[attachment.id]"
:src="mediaUrls[attachment.id]"
:alt="attachment.original_filename"
:preview="true"
:width="260"
/>
<video
v-else-if="attachment.media_type === 'video' && mediaUrls[attachment.id]"
class="max-h-[420px] max-w-full rounded bg-black"
controls
preload="metadata"
:src="mediaUrls[attachment.id]"
></video>
<audio
v-else-if="attachment.media_type === 'audio' && isBrowserAudio(attachment.mime_type) && mediaUrls[attachment.id]"
class="w-full"
controls
preload="metadata"
:src="mediaUrls[attachment.id]"
></audio>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-500">
<Tag>{{ attachment.media_type }}</Tag>
<span class="max-w-[460px] truncate" :title="attachment.original_filename">
{{ attachment.original_filename || '未命名素材' }}
</span>
<span>{{ formatSize(attachment.size_bytes) }}</span>
<Tag v-if="attachment.status !== 'ready'" color="warning">
{{ attachmentStatusLabel(attachment.status) }}
</Tag>
<a
v-if="mediaUrls[attachment.id]"
:href="mediaUrls[attachment.id]"
target="_blank"
rel="noopener noreferrer"
>
{{ attachment.media_type === 'file' || !isBrowserAudio(attachment.mime_type) ? '下载附件' : '查看原文件' }}
</a>
</div>
</div>
</div>
</div>
<div v-if="messageHasMore" class="mt-3 text-center">
<Button :loading="messageLoading" @click="loadMessages(false)">加载更早消息</Button>
</div>
</Drawer>
</div>
</template>
@@ -0,0 +1,156 @@
<script setup lang="ts">
import type { ArchiveExportJob } from '#/api/archive';
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { downloadFileFromBlob } from '@vben/utils';
import {
Button,
Card,
Checkbox,
DatePicker,
Form,
Progress,
Space,
Table,
Tag,
message,
} from 'ant-design-vue';
import {
createArchiveExport,
downloadArchiveExport,
fetchArchiveExports,
} from '#/api/archive';
const loading = ref(false);
const creating = ref(false);
const downloading = ref('');
const jobs = ref<ArchiveExportJob[]>([]);
const form = reactive({ date_from: '', date_to: '', formats: ['sql', 'xlsx'] });
let timer: ReturnType<typeof setInterval> | undefined;
function setDate(field: 'date_from' | 'date_to', value: unknown) {
form[field] = typeof value === 'string' ? value : '';
}
async function load() {
loading.value = true;
try {
jobs.value = (await fetchArchiveExports()).jobs;
} finally {
loading.value = false;
}
}
async function createJob() {
if (!form.formats.length) {
message.warning('至少选择一种导出格式');
return;
}
creating.value = true;
try {
await createArchiveExport({
formats: form.formats,
filters: {
date_from: form.date_from || undefined,
date_to: form.date_to || undefined,
},
});
message.success('导出任务已创建');
await load();
} finally {
creating.value = false;
}
}
async function download(file: { file_name: string; id: string }) {
downloading.value = file.id;
try {
const blob = await downloadArchiveExport(file.id);
downloadFileFromBlob({ fileName: file.file_name, source: blob });
} finally {
downloading.value = '';
}
}
const STATUS_COLOR: Record<string, string> = {
completed: 'green', failed: 'red', queued: 'default', running: 'blue',
};
const STATUS_LABEL: Record<string, string> = {
completed: '已完成', failed: '失败', queued: '等待中', running: '处理中',
};
onMounted(() => {
load();
timer = setInterval(() => {
if (jobs.value.some((item) => ['queued', 'running'].includes(item.status))) load();
}, 3000);
});
onUnmounted(() => timer && clearInterval(timer));
</script>
<template>
<div class="p-5">
<Card title="新建导出" class="mb-4">
<Form layout="inline">
<Form.Item label="格式">
<Checkbox.Group v-model:value="form.formats" :options="[
{ label: 'SQL', value: 'sql' },
{ label: 'Excel', value: 'xlsx' },
{ label: 'CSV', value: 'csv' },
]" />
</Form.Item>
<Form.Item label="开始日期">
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_from', $event)" />
</Form.Item>
<Form.Item label="结束日期">
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_to', $event)" />
</Form.Item>
<Form.Item>
<Button type="primary" :loading="creating" @click="createJob">开始导出</Button>
</Form.Item>
</Form>
<p class="mb-0 mt-3 text-xs text-gray-500">
导出按创建时的截止水位生成SQL/CSV 流式写出Excel 超过 90 万行自动拆分工作表
</p>
</Card>
<Card title="导出任务">
<template #extra><Button :loading="loading" @click="load">刷新</Button></template>
<Table
:data-source="jobs" row-key="id" size="small" :loading="loading"
:pagination="{ pageSize: 20 }"
:columns="[
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 205 },
{ title: '格式', dataIndex: 'formats', key: 'formats', width: 160 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 110 },
{ title: '进度/行数', key: 'progress', width: 180 },
{ title: '文件', key: 'files' },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'formats'">
<Tag v-for="format in record.formats" :key="format">{{ format.toUpperCase() }}</Tag>
</template>
<template v-else-if="column.key === 'status'">
<Tag :color="STATUS_COLOR[record.status]">{{ STATUS_LABEL[record.status] }}</Tag>
<div v-if="record.error_message" class="mt-1 text-xs text-red-500">{{ record.error_message }}</div>
</template>
<template v-else-if="column.key === 'progress'">
<Progress v-if="record.status === 'running'" :percent="record.progress" size="small" />
<span v-else>{{ record.total_rows || 0 }} </span>
</template>
<template v-else-if="column.key === 'files'">
<Space wrap>
<Button
v-for="file in record.files" :key="file.id" size="small"
:loading="downloading === file.id" @click="download(file)"
>{{ file.file_name }}</Button>
</Space>
</template>
</template>
</Table>
</Card>
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import type { ArchiveStats } from '#/api/archive';
import { onMounted, ref } from 'vue';
import { Alert, Button, Card, Col, Row, Statistic } from 'ant-design-vue';
import { fetchArchiveStats } from '#/api/archive';
const loading = ref(false);
const stats = ref<ArchiveStats | null>(null);
async function load() {
loading.value = true;
try {
stats.value = await fetchArchiveStats();
} finally {
loading.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="p-5">
<div class="mb-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-xl font-semibold">聊天归档</h2>
<p class="mb-0 mt-1 text-sm text-gray-500">
企业微信消息人员会话与 COS 素材的统一数据视图
</p>
</div>
<Button :loading="loading" @click="load">刷新</Button>
</div>
<Alert
class="mb-4"
type="info"
show-icon
message="素材本体不进入数据库"
description="图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。"
/>
<Row :gutter="16" class="mb-4">
<Col :span="6">
<Card size="small" :loading="loading">
<Statistic title="归档消息" :value="stats?.messages ?? 0" />
</Card>
</Col>
<Col :span="6">
<Card size="small" :loading="loading">
<Statistic title="会话" :value="stats?.conversations ?? 0" />
</Card>
</Col>
<Col :span="6">
<Card size="small" :loading="loading">
<Statistic title="唯一人员" :value="stats?.people ?? 0" />
</Card>
</Col>
<Col :span="6">
<Card size="small" :loading="loading">
<Statistic title="COS 素材" :value="stats?.media ?? 0" />
</Card>
</Col>
</Row>
<Row :gutter="16">
<Col :span="8">
<Card title="素材完整性" size="small" :loading="loading">
<div class="flex justify-between py-2">
<span class="text-gray-500">已校验</span>
<strong class="text-green-600">{{ stats?.media_ready ?? 0 }}</strong>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">校验失败</span>
<strong :class="(stats?.media_failed ?? 0) ? 'text-red-600' : ''">
{{ stats?.media_failed ?? 0 }}
</strong>
</div>
</Card>
</Col>
<Col :span="8">
<Card title="处理任务" size="small" :loading="loading">
<div class="flex justify-between py-2">
<span class="text-gray-500">导入批次</span><strong>{{ stats?.imports ?? 0 }}</strong>
</div>
<div class="flex justify-between py-2">
<span class="text-gray-500">导出任务</span><strong>{{ stats?.exports ?? 0 }}</strong>
</div>
</Card>
</Col>
<Col :span="8">
<Card title="最新水位" size="small" :loading="loading">
<p class="mb-1 text-gray-500">最后一条消息时间UTC</p>
<strong>{{ stats?.last_message_at || '尚未导入' }}</strong>
</Card>
</Col>
</Row>
</div>
</template>
@@ -0,0 +1,157 @@
<script setup lang="ts">
import type { ArchivePerson, ArchivePersonDetail } from '#/api/archive';
import { onMounted, reactive, ref } from 'vue';
import {
Button,
Card,
Drawer,
Form,
Input,
Select,
Space,
Switch,
Table,
Tag,
message,
} from 'ant-design-vue';
import {
bindArchiveIdentity,
fetchArchivePeople,
fetchArchivePerson,
} from '#/api/archive';
const loading = ref(false);
const binding = ref(false);
const keyword = ref('');
const people = ref<ArchivePerson[]>([]);
const selected = ref<ArchivePersonDetail | null>(null);
const identity = reactive({
external_id: '',
identity_type: 'wecom_userid',
scope_id: '',
verified: true,
});
async function load() {
loading.value = true;
try {
people.value = (await fetchArchivePeople(200, keyword.value)).items;
} finally {
loading.value = false;
}
}
async function openPerson(record: Record<string, any>) {
selected.value = (await fetchArchivePerson(record.id)).person;
identity.external_id = '';
}
async function bind() {
if (!selected.value || !identity.external_id.trim()) {
message.warning('请输入企业微信人员 ID');
return;
}
binding.value = true;
try {
selected.value = (
await bindArchiveIdentity(selected.value.id, identity)
).person;
identity.external_id = '';
message.success('人员标识已绑定');
await load();
} finally {
binding.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="p-5">
<Card title="人员唯一标识">
<template #extra>
<Space>
<Input.Search
v-model:value="keyword" allow-clear placeholder="姓名"
:loading="loading" @search="load"
/>
<Button :loading="loading" @click="load">刷新</Button>
</Space>
</template>
<p class="mb-3 text-sm text-gray-500">
同一个人可绑定本地 UID企业微信 userid微信 external_userid 等多个身份唯一键由身份类型 + 企业范围 + 外部 ID组成
</p>
<Table
:data-source="people" row-key="id" size="small" :loading="loading"
:pagination="{ pageSize: 20 }"
:columns="[
{ title: '显示名称', dataIndex: 'display_name', key: 'display_name' },
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name' },
{ title: '已绑定身份', dataIndex: 'identity_count', key: 'identity_count', width: 110 },
{ title: '会话数', dataIndex: 'conversation_count', key: 'conversation_count', width: 90 },
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
{ title: '操作', key: 'action', width: 100 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="openPerson(record)">管理标识</Button>
</template>
</template>
</Table>
</Card>
<Drawer
:open="!!selected" :title="`人员标识 · ${selected?.display_name || ''}`"
width="680" @close="selected = null"
>
<Table
class="mb-5" :data-source="selected?.identities || []" row-key="id"
size="small" :pagination="false"
:columns="[
{ title: '类型', dataIndex: 'identity_type', key: 'identity_type', width: 140 },
{ title: '企业/范围', dataIndex: 'scope_id', key: 'scope_id', width: 140 },
{ title: '外部 ID', dataIndex: 'external_id', key: 'external_id' },
{ title: '状态', dataIndex: 'verified', key: 'verified', width: 80 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'verified'">
<Tag :color="record.verified ? 'green' : 'default'">
{{ record.verified ? '已确认' : '未确认' }}
</Tag>
</template>
</template>
</Table>
<Card title="绑定新标识" size="small">
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
<Form.Item label="身份类型">
<Select v-model:value="identity.identity_type" :options="[
{ label: '企业微信 userid', value: 'wecom_userid' },
{ label: '微信 external_userid', value: 'wecom_external_userid' },
{ label: '本地数据库 UID', value: 'wecom_local_uid' },
{ label: '企微 open_userid', value: 'wecom_open_userid' },
]" />
</Form.Item>
<Form.Item label="企业/范围 ID">
<Input v-model:value="identity.scope_id" placeholder="建议填写 corp_id;本地 UID 可填账号 ID" />
</Form.Item>
<Form.Item label="外部人员 ID" required>
<Input v-model:value="identity.external_id" />
</Form.Item>
<Form.Item label="确认身份">
<Switch v-model:checked="identity.verified" />
</Form.Item>
<Form.Item :wrapper-col="{ offset: 6, span: 17 }">
<Button type="primary" :loading="binding" @click="bind">确认绑定</Button>
</Form.Item>
</Form>
</Card>
</Drawer>
</div>
</template>
@@ -0,0 +1,175 @@
<script setup lang="ts">
import type { ArchiveMedia } from '#/api/archive';
import { onMounted, reactive, ref } from 'vue';
import {
Alert,
Button,
Card,
Form,
Input,
Select,
Space,
Switch,
Table,
Tag,
message,
} from 'ant-design-vue';
import {
fetchArchiveMedia,
fetchArchiveStorage,
saveArchiveStorage,
testArchiveStorage,
} from '#/api/archive';
const loading = ref(false);
const saving = ref(false);
const testing = ref(false);
const mediaItems = ref<ArchiveMedia[]>([]);
const form = reactive({
bucket: '',
custom_domain: '',
enabled: false,
encryption_mode: 'AES256',
export_prefix: 'archive/exports',
media_prefix: 'archive/media',
region: '',
secret_id: '',
secret_id_masked: '',
secret_key: '',
secret_key_masked: '',
});
async function load() {
loading.value = true;
try {
const [storage, media] = await Promise.all([
fetchArchiveStorage(),
fetchArchiveMedia(100),
]);
Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
mediaItems.value = media.items;
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
try {
const data = await saveArchiveStorage({
bucket: form.bucket,
custom_domain: form.custom_domain,
enabled: form.enabled,
encryption_mode: form.encryption_mode,
export_prefix: form.export_prefix,
media_prefix: form.media_prefix,
region: form.region,
secret_id: form.secret_id,
secret_key: form.secret_key,
});
Object.assign(form, data.storage, { secret_id: '', secret_key: '' });
message.success('COS 配置已保存');
} finally {
saving.value = false;
}
}
async function testConnection() {
testing.value = true;
try {
const result = (await testArchiveStorage()).result;
message.success(`连接成功:${result.bucket} / ${result.region}`);
} finally {
testing.value = false;
}
}
function sizeText(value: number) {
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toFixed(1)} MB`;
return `${(value / 1024 ** 3).toFixed(1)} GB`;
}
onMounted(load);
</script>
<template>
<div class="p-5">
<Alert
class="mb-4" type="warning" show-icon
message="请使用最小权限的独立 COS 子账号密钥"
description="SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。"
/>
<Card title="腾讯云 COS" class="mb-4" :loading="loading">
<Form :label-col="{ span: 5 }" :wrapper-col="{ span: 15 }">
<Form.Item label="启用存储"><Switch v-model:checked="form.enabled" /></Form.Item>
<Form.Item label="Bucket">
<Input v-model:value="form.bucket" placeholder="bucket-appid" />
</Form.Item>
<Form.Item label="Region">
<Input v-model:value="form.region" placeholder="ap-guangzhou" />
</Form.Item>
<Form.Item label="SecretId">
<Input.Password v-model:value="form.secret_id" :placeholder="form.secret_id_masked || '请输入 SecretId'" />
</Form.Item>
<Form.Item label="SecretKey">
<Input.Password v-model:value="form.secret_key" :placeholder="form.secret_key_masked || '请输入 SecretKey'" />
</Form.Item>
<Form.Item label="素材路径前缀">
<Input v-model:value="form.media_prefix" />
</Form.Item>
<Form.Item label="导出路径前缀">
<Input v-model:value="form.export_prefix" />
</Form.Item>
<Form.Item label="服务端加密">
<Select v-model:value="form.encryption_mode" :options="[
{ label: 'SSE-COSAES256', value: 'AES256' },
{ label: 'SSE-KMS', value: 'cos/kms' },
{ label: '不指定', value: '' },
]" />
</Form.Item>
<Form.Item label="自定义域名">
<Input v-model:value="form.custom_domain" placeholder="可选,仅允许完整 HTTPS 域名" />
</Form.Item>
<Form.Item :wrapper-col="{ offset: 5, span: 15 }">
<Space>
<Button type="primary" :loading="saving" @click="save">保存配置</Button>
<Button :loading="testing" :disabled="!form.enabled" @click="testConnection">测试连接</Button>
</Space>
</Form.Item>
</Form>
</Card>
<Card title="最近素材对象">
<Table
:data-source="mediaItems" row-key="id" size="small" :loading="loading"
:pagination="{ pageSize: 20 }"
:columns="[
{ title: '文件', dataIndex: 'original_filename', key: 'original_filename' },
{ title: '类型', dataIndex: 'media_type', key: 'media_type', width: 90 },
{ title: '大小', dataIndex: 'size_bytes', key: 'size_bytes', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: 'COS ObjectKey', dataIndex: 'object_key', key: 'object_key' },
{ title: '校验时间', dataIndex: 'verified_at', key: 'verified_at', width: 200 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'size_bytes'">{{ sizeText(record.size_bytes) }}</template>
<template v-else-if="column.key === 'status'">
<Tag :color="record.status === 'ready' ? 'green' : record.status === 'failed' ? 'red' : 'orange'">
{{ record.status }}
</Tag>
</template>
<template v-else-if="column.key === 'object_key'">
<span class="break-all font-mono text-xs">{{ record.object_key }}</span>
</template>
</template>
</Table>
</Card>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { Card, Table, Tag } from 'ant-design-vue';
import { fetchAudit } from '#/api/console';
const loading = ref(false);
const entries = ref<any[]>([]);
async function load() {
loading.value = true;
try {
entries.value = (await fetchAudit(200)).entries;
} finally {
loading.value = false;
}
}
const TONE: Record<string, string> = {
'login.failed': 'red',
'role.delete': 'red',
'model.provider.delete': 'red',
'role.save': 'orange',
'model.roles.save': 'orange',
'model.provider.save': 'blue',
};
onMounted(load);
</script>
<template>
<div class="p-5">
<Card title="审计日志">
<p class="mb-3 text-sm text-gray-500">
谁在什么时候改了什么出事之后唯一能回溯的东西只显示最近 200
</p>
<Table
:data-source="entries" :loading="loading" row-key="id"
size="small" :pagination="{ pageSize: 20 }"
:columns="[
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
{ title: '操作人', dataIndex: 'username', key: 'username', width: 120 },
{ title: '动作', dataIndex: 'action', key: 'action', width: 200 },
{ title: '详情', dataIndex: 'detail', key: 'detail' },
{ title: '来源 IP', dataIndex: 'ip_address', key: 'ip_address', width: 140 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'action'">
<Tag :color="TONE[record.action] || 'default'">{{ record.action }}</Tag>
</template>
<template v-else-if="column.key === 'username'">
{{ record.username || '未登录' }}
</template>
</template>
</Table>
</Card>
</div>
</template>
@@ -0,0 +1,343 @@
<script setup lang="ts">
import type { CallStats, ModelCallLogItem } from '#/api/console';
import { computed, onMounted, ref, watch } from 'vue';
import {
Alert,
Card,
Col,
Empty,
Input,
Modal,
Progress,
Row,
Segmented,
Statistic,
Table,
Tag,
} from 'ant-design-vue';
import { fetchCallLog, fetchCallStats } from '#/api/console';
const days = ref(7);
const loading = ref(false);
const stats = ref<CallStats | null>(null);
async function load() {
loading.value = true;
try {
stats.value = await fetchCallStats(days.value);
} finally {
loading.value = false;
}
}
// ── 调用记录:定位某一句具体是怎么被回复的 ──────────────────────────────
const logKeyword = ref('');
const logLoading = ref(false);
const logItems = ref<ModelCallLogItem[]>([]);
const logTotal = ref(0);
const logPage = ref(1);
const logPageSize = ref(20);
const detailItem = ref<ModelCallLogItem | null>(null);
/**
* chat = 回客户的话;guard = 界面识别之类的内部判断;'' = 全都要。
*
* 默认只看 chat:界面守卫每轮轮询都要问一次模型,条数是真实对话的几十倍,
* 混在一起这张表根本没法用。但内部调用同样在花钱,所以留了入口能翻出来看。
*/
const logPurpose = ref('chat');
async function loadLog() {
logLoading.value = true;
try {
const resp = await fetchCallLog({
days: days.value,
limit: logPageSize.value,
offset: (logPage.value - 1) * logPageSize.value,
q: logKeyword.value.trim(),
purpose: logPurpose.value,
});
logItems.value = resp.items;
logTotal.value = resp.total;
} finally {
logLoading.value = false;
}
}
function searchLog() {
logPage.value = 1;
loadLog();
}
/**
* 时间统一显示成「MM-DD HH:mm:ss」。
*
* 库里存过两种格式:网关早期写的是 `2026-08-24 14:40:44`,后台写的是带时区的
* ISO。同一次调用在表格里长得不一样,看着像两件事。新数据已经统一,这里负责
* 让历史数据也能正常显示,解析不了就原样输出,不能显示成 Invalid Date。
*/
function formatTime(raw: string): string {
const value = String(raw || '').trim();
if (!value) return '—';
const parsed = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
if (Number.isNaN(parsed.getTime())) return value;
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(parsed.getMonth() + 1)}-${pad(parsed.getDate())} ${pad(parsed.getHours())}:${pad(parsed.getMinutes())}:${pad(parsed.getSeconds())}`;
}
watch(logPurpose, () => {
logPage.value = 1;
loadLog();
});
function logTableChange(pagination: { current?: number; pageSize?: number }) {
logPage.value = pagination.current ?? 1;
logPageSize.value = pagination.pageSize ?? 20;
loadLog();
}
watch(days, () => {
load();
logPage.value = 1;
loadLog();
});
onMounted(() => {
load();
loadLog();
});
/** 各出口被选中的次数占比——回答"第二个模型值不值那一倍成本"。 */
const chosenRows = computed(() => {
const total = stats.value?.chosen.reduce((sum, row) => sum + row.count, 0) ?? 0;
return (stats.value?.chosen ?? []).map((row) => ({
...row,
percent: total ? Math.round((row.count / total) * 100) : 0,
}));
});
const riskRows = computed(() => {
const risk = stats.value?.risk ?? {};
const total = Object.values(risk).reduce((sum, n) => sum + n, 0);
return Object.entries(risk).map(([level, count]) => ({
level,
count,
percent: total ? Math.round((count / total) * 100) : 0,
}));
});
const maxBucket = computed(() =>
Math.max(1, ...(stats.value?.score_buckets ?? []).map((b) => b.count)),
);
const RISK_COLOR: Record<string, string> = {
low: 'green', medium: 'orange', high: 'red', unknown: 'default',
};
</script>
<template>
<div class="p-5">
<Alert
class="mb-4"
type="info"
show-icon
message="这张表是决定要不要上双模型的唯一依据"
description="分数分布告诉你现有回复的真实水平——如果大部分本来就是高分,并发问两个模型就是纯浪费;如果低分集中在某一类消息上,针对性换个模型比全量双跑划算得多。"
/>
<div class="mb-4">
<Segmented
v-model:value="days"
:options="[{ value: 1, label: '今天' }, { value: 7, label: '近 7 天' }, { value: 30, label: '近 30 天' }]"
/>
</div>
<Row :gutter="16" class="mb-4">
<Col :span="6">
<Card size="small"><Statistic title="总调用" :value="stats?.total ?? 0" /></Card>
</Col>
<Col :span="6">
<Card size="small"><Statistic title="已评审" :value="stats?.judged ?? 0" /></Card>
</Col>
<Col :span="6">
<Card size="small">
<Statistic title="平均分" :value="stats?.avg_score ?? 0" :precision="3" />
</Card>
</Col>
<Col :span="6">
<Card size="small">
<Statistic title="平均耗时" :value="stats?.avg_ms ?? 0" suffix="ms" />
</Card>
</Col>
</Row>
<Row :gutter="16">
<Col :span="12">
<Card title="裁判分数分布" size="small" :loading="loading" class="mb-4">
<Empty v-if="!stats?.score_buckets?.length" description="还没有评审数据" />
<div v-for="bucket in stats?.score_buckets ?? []" :key="bucket.range" class="mb-2">
<div class="mb-1 flex justify-between text-xs">
<span>{{ bucket.range }}</span>
<span>{{ bucket.count }} </span>
</div>
<Progress
:percent="Math.round((bucket.count / maxBucket) * 100)"
:show-info="false" size="small"
/>
</div>
</Card>
</Col>
<Col :span="12">
<Card title="风险等级" size="small" :loading="loading" class="mb-4">
<Empty v-if="!riskRows.length" description="还没有评审数据" />
<div v-for="row in riskRows" :key="row.level" class="mb-2 flex items-center gap-3">
<Tag :color="RISK_COLOR[row.level]" class="w-16 text-center">{{ row.level }}</Tag>
<Progress :percent="row.percent" size="small" class="flex-1" />
<span class="w-16 text-right text-xs">{{ row.count }} </span>
</div>
</Card>
</Col>
</Row>
<Card title="各模型被选中的比例" size="small" :loading="loading">
<Table
:data-source="chosenRows" row-key="provider" size="small" :pagination="false"
:columns="[
{ title: '模型', dataIndex: 'provider', key: 'provider' },
{ title: '被选中', dataIndex: 'count', key: 'count', width: 100 },
{ title: '占比', key: 'percent', width: 240 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'percent'">
<Progress :percent="record.percent" size="small" />
</template>
</template>
</Table>
<p class="mt-3 text-xs text-gray-500">
统计区间自 {{ stats?.since || '—' }} 最慢一次 {{ stats?.max_ms ?? 0 }}ms
</p>
</Card>
<Card title="调用记录" size="small" class="mt-4">
<template #extra>
<Input.Search
v-model:value="logKeyword"
placeholder="搜客户消息或模型回复里的关键词"
style="width: 280px"
allow-clear
@search="searchLog"
/>
</template>
<p class="mb-3 text-xs text-gray-500">
按时间倒序一条一次调用客户说了什么模型候选都答了什么最终选中的是哪个有没有被审核规则拦下出问题时按这张表往回查
</p>
<div class="mb-3">
<Segmented
v-model:value="logPurpose"
:options="[
{ value: 'chat', label: '客服对话' },
{ value: 'guard', label: '界面识别' },
{ value: '', label: '全部' },
]"
/>
<span class="ml-3 text-xs text-gray-500">
界面识别是机器人自己看企业微信窗口用的不是发给客户的话它每轮轮询都要问一次模型条数远多于真实对话所以默认不混在一起
</span>
</div>
<Table
:data-source="logItems"
row-key="id"
size="small"
:loading="logLoading"
:pagination="{
current: logPage,
pageSize: logPageSize,
total: logTotal,
showTotal: (total: number) => `共 ${total} 条`,
}"
@change="logTableChange"
:columns="[
{ title: '时间', key: 'created_at', width: 130 },
{ title: '客户消息', key: 'customer_text', width: 200, ellipsis: true },
{ title: '模型回复', key: 'reply_text', width: 200, ellipsis: true },
{ title: '选中', dataIndex: 'chosen', key: 'chosen', width: 110, ellipsis: true },
{ title: '裁判分', dataIndex: 'judge_score', key: 'judge_score', width: 80 },
{ title: '风险', dataIndex: 'judge_risk', key: 'judge_risk', width: 80 },
{ title: '审核', key: 'review_reason', width: 90 },
{ title: '耗时', dataIndex: 'total_ms', key: 'total_ms', width: 80 },
{ title: '', key: 'action', width: 70 },
]"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
<span :title="record.created_at">{{ formatTime(record.created_at) }}</span>
</template>
<template v-else-if="column.key === 'customer_text'">
<span :title="record.customer_text">{{ record.customer_text || '—' }}</span>
</template>
<template v-else-if="column.key === 'reply_text'">
<span :title="record.reply_text">{{ record.reply_text || '—' }}</span>
</template>
<template v-else-if="column.key === 'judge_risk'">
<Tag v-if="record.judge_risk" :color="RISK_COLOR[record.judge_risk]">{{ record.judge_risk }}</Tag>
<span v-else></span>
</template>
<template v-else-if="column.key === 'review_reason'">
<Tag v-if="record.review_reason" color="orange">已拦</Tag>
<span v-else class="text-gray-400"></span>
</template>
<template v-else-if="column.key === 'total_ms'">
{{ record.total_ms }}ms
</template>
<template v-else-if="column.key === 'action'">
<a @click="detailItem = record as ModelCallLogItem">详情</a>
</template>
</template>
</Table>
</Card>
<Modal
:open="!!detailItem"
title="调用详情"
:footer="null"
width="640px"
@cancel="detailItem = null"
>
<template v-if="detailItem">
<div class="mb-3">
<div class="mb-1 text-xs text-gray-500">客户消息</div>
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.customer_text || '(空)' }}</div>
</div>
<div class="mb-3">
<div class="mb-1 text-xs text-gray-500">模型回复最终选中</div>
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.reply_text || '(空)' }}</div>
</div>
<div v-if="detailItem.review_reason" class="mb-3">
<Alert type="warning" show-icon :message="`已停发送审核:${detailItem.review_reason}`" />
</div>
<div v-if="detailItem.candidates?.length" class="mb-3">
<div class="mb-1 text-xs text-gray-500">全部候选</div>
<div
v-for="(candidate, index) in detailItem.candidates"
:key="index"
class="mb-2 rounded border border-gray-200 p-2 text-sm dark:border-gray-700"
>
<div class="mb-1 flex items-center justify-between">
<Tag :color="candidate.provider === detailItem.chosen ? 'green' : 'default'">
{{ candidate.provider }}{{ candidate.provider === detailItem.chosen ? '(选中)' : '' }}
</Tag>
<span class="text-xs text-gray-400">{{ candidate.latency_ms ?? 0 }}ms</span>
</div>
<div class="whitespace-pre-wrap">{{ candidate.text || candidate.error || '(空)' }}</div>
</div>
</div>
<p class="text-xs text-gray-400">
裁判 {{ detailItem.judge_winner || '—' }}{{ detailItem.judge_score }} {{ detailItem.total_ms }}ms{{ formatTime(detailItem.created_at) }}
</p>
</template>
</Modal>
</div>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { Alert, Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
import { changeMyPassword } from '#/api/console';
const router = useRouter();
const submitting = ref(false);
const form = reactive({ current_password: '', new_password: '', confirm: '' });
async function submit() {
if (form.new_password !== form.confirm) {
message.warning('两次输入的新密码不一致');
return;
}
submitting.value = true;
try {
await changeMyPassword({
current_password: form.current_password,
new_password: form.new_password,
});
message.success('密码已修改');
await router.push('/');
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-xl p-5">
<Alert
class="mb-4"
type="warning"
show-icon
message="首次登录必须修改初始密码"
description="初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。"
/>
<Card title="修改密码">
<Form :model="form" layout="vertical">
<FormItem label="当前密码">
<Input.Password v-model:value="form.current_password" />
</FormItem>
<FormItem label="新密码(至少 10 位,需同时含字母和数字)">
<Input.Password v-model:value="form.new_password" />
</FormItem>
<FormItem label="确认新密码">
<Input.Password v-model:value="form.confirm" />
</FormItem>
<Button type="primary" :loading="submitting" @click="submit">提交</Button>
</Form>
</Card>
</div>
</template>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import type { DesktopConfig } from '#/api/console';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAccess } from '@vben/access';
import {
Alert,
Button,
Card,
Descriptions,
DescriptionsItem,
Form,
FormItem,
Input,
InputNumber,
message,
Select,
Switch,
Textarea,
} from 'ant-design-vue';
import { fetchConfig, saveConfig } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['config:write']));
const canSeeModels = computed(() => hasAccessByCodes(['model:read']));
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const version = ref(0);
const updatedAt = ref('');
const updatedBy = ref('');
const movedTo = ref('');
const gatewayEffective = ref('');
/** MCP 服务器在后端是数组,编辑起来最直白的还是 JSON 文本框。 */
const mcpText = ref('[]');
const mcpError = ref('');
const form = reactive<DesktopConfig>({
AI_AGENT_NAME: '',
AI_GATEWAY_URL: '',
AI_CONTEXT_ENABLED: true,
AI_CONTEXT_MAX_ROUNDS: 8,
AI_COUNTER_INSULT_ENABLED: false,
AI_DEVELOPMENT_MODE: false,
AI_ENABLED: true,
AI_HOSPITAL_NAME: '',
AI_MCP_ENABLED: false,
AI_MCP_MAX_ROUNDS: 5,
AI_MCP_SERVERS: [],
AI_REVIEW_RULES: [],
AI_UI_GUARD_ENABLED: true,
AI_USE_VISION: true,
});
const SWITCHES: { hint: string; key: keyof DesktopConfig; label: string }[] = [
{ key: 'AI_ENABLED', label: '启用 AI 自动回复', hint: '总开关。关掉之后客户端只监听不回复。' },
{ key: 'AI_USE_VISION', label: '启用视觉识别', hint: '图片和表情包走视觉模型识别,OCR 认不出时兜底。用哪个模型看「角色编排」里的媒体消息专用模型。' },
{ key: 'AI_UI_GUARD_ENABLED', label: '人工操作保护', hint: '检测到有人在操作鼠标键盘时暂停自动化,避免抢焦点。' },
{ key: 'AI_CONTEXT_ENABLED', label: '携带上下文', hint: '把最近几轮对话一起发给模型。关掉会省 token,但答复会失忆。' },
{ key: 'AI_COUNTER_INSULT_ENABLED', label: '应对辱骂', hint: '客户情绪激动时用专门的话术回应,而不是照常答业务问题。' },
{ key: 'AI_MCP_ENABLED', label: '启用 MCP 工具', hint: '允许模型调用挂号登记、客户查询等外部工具。工具在客户端本机执行。' },
{ key: 'AI_DEVELOPMENT_MODE', label: '开发模式', hint: '打印详细日志、跳过部分节流。生产环境请关闭。' },
];
async function load() {
loading.value = true;
try {
const data = await fetchConfig();
Object.assign(form, data.config);
mcpText.value = JSON.stringify(data.config.AI_MCP_SERVERS ?? [], null, 2);
version.value = data.version;
updatedAt.value = data.updated_at;
updatedBy.value = data.updated_by;
movedTo.value = data.model_settings_moved_to;
gatewayEffective.value = data.gateway_url_effective;
} finally {
loading.value = false;
}
}
function addReviewRule() {
form.AI_REVIEW_RULES.push({ label: '', keywords: [], enabled: true });
}
function removeReviewRule(index: number) {
form.AI_REVIEW_RULES.splice(index, 1);
}
/**
* 提交前先在前端挡一遍空名称/空关键词——后端 `validate_review_rules` 一样会拦,
* 但那要等一次网络往返才看到;跟 MCP 那份 JSON 预检查一个道理,能在本地发现
* 的错误就不要留给服务器去发现。
*/
function reviewRulesError(): string {
for (const [index, rule] of form.AI_REVIEW_RULES.entries()) {
if (!rule.label.trim()) {
return `${index + 1} 条规则没有填名称`;
}
if (rule.keywords.length === 0) {
return `规则「${rule.label}」至少要有一个关键词,否则永远不会命中`;
}
}
return '';
}
function parseMcp(): null | unknown[] {
try {
const parsed = JSON.parse(mcpText.value || '[]');
if (!Array.isArray(parsed)) {
mcpError.value = 'JSON 根节点必须是数组';
return null;
}
mcpError.value = '';
return parsed;
} catch (error) {
mcpError.value = `不是有效的 JSON${(error as Error).message}`;
return null;
}
}
async function submit() {
const servers = parseMcp();
if (servers === null) {
message.warning('MCP 服务器配置不是有效 JSON,请先修正');
return;
}
const ruleError = reviewRulesError();
if (ruleError) {
message.warning(ruleError);
return;
}
saving.value = true;
try {
const data = await saveConfig({ ...form, AI_MCP_SERVERS: servers });
message.success(`已发布为 v${data.version},桌面端下次同步时生效`);
await load();
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="p-5">
<Alert
class="mb-4"
type="warning"
show-icon
message="这份配置会下发到每一台桌面客户端"
description="保存即产生新版本号,客户端下次同步就会拿到。改错一个字段是全线生效的,不是只影响你自己这台。"
/>
<Alert class="mb-4" type="info" show-icon>
<template #message>模型配置不在这里</template>
<template #description>
<p class="mb-2">
服务类型API 地址API Key模型名称温度最大 tokens请求超时这些以前在本页的设置
现在统一在 <b>{{ movedTo || 'AI 模型 → 模型清单 / 角色编排' }}</b>
</p>
<p class="mb-2 text-xs">
搬走有三个原因同一件事两个地方能配迟早会不一致密钥留在这份配置里就必须明文下发到每一台客户端
而模型清单里是加密存储只返遮罩值一个客户只能配一个模型的话双模型并发和裁判根本无从谈起
</p>
<div v-if="canSeeModels" class="mt-2">
<Button size="small" @click="router.push('/model/catalog')">去模型清单</Button>
<Button size="small" class="ml-2" @click="router.push('/model/plan')">去角色编排</Button>
</div>
</template>
</Alert>
<Card title="当前生效的配置" class="mb-4" :loading="loading">
<Descriptions :column="3" size="small" bordered>
<DescriptionsItem label="版本">v{{ version }}</DescriptionsItem>
<DescriptionsItem label="最后修改">{{ updatedBy || '—' }}</DescriptionsItem>
<DescriptionsItem label="时间">{{ updatedAt || '—' }}</DescriptionsItem>
</Descriptions>
</Card>
<Form :model="form" layout="vertical">
<Card title="能力开关" class="mb-4" :loading="loading">
<div class="grid grid-cols-1 gap-x-8 gap-y-1 md:grid-cols-2">
<div
v-for="item in SWITCHES"
:key="item.key"
class="flex items-start justify-between gap-4 border-b border-dashed py-3 last:border-0"
>
<div class="min-w-0">
<div class="text-sm font-medium">{{ item.label }}</div>
<div class="mt-0.5 text-xs text-gray-500">{{ item.hint }}</div>
</div>
<Switch
v-model:checked="form[item.key] as boolean"
:disabled="!canWrite"
class="mt-1 shrink-0"
/>
</div>
</div>
</Card>
<Card title="客服人格" class="mb-4" :loading="loading">
<p class="mb-3 text-xs text-gray-500">
这几项会拼进系统提示词决定客户看到的是"谁"在说话它们不是模型参数所以留在这里
</p>
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-3">
<FormItem label="客服名称">
<Input v-model:value="form.AI_AGENT_NAME" :disabled="!canWrite" placeholder="贴心管家" />
</FormItem>
<FormItem label="机构名称">
<Input v-model:value="form.AI_HOSPITAL_NAME" :disabled="!canWrite" placeholder="甄养堂" />
</FormItem>
<FormItem label="上下文轮数(150">
<InputNumber
v-model:value="form.AI_CONTEXT_MAX_ROUNDS"
:min="1" :max="50" :disabled="!canWrite" class="w-full"
/>
<p class="mt-1 text-xs text-gray-500">
记几轮对话这是客户端行为"用哪个模型"无关
</p>
</FormItem>
</div>
</Card>
<Card title="模型网关地址" class="mb-4" :loading="loading">
<p class="mb-3 text-xs text-gray-500">
桌面客户端只配一个后台地址模型请求发到哪儿由这里决定随配置一起下发
换网关位置只需要改这一处不用挨个动客户端
</p>
<FormItem label="地址(留空 = 按后台自己的地址自动推算)">
<Input
v-model:value="form.AI_GATEWAY_URL"
:disabled="!canWrite"
placeholder="留空即可,绝大多数情况不用填"
/>
<p class="mt-1 text-xs text-gray-500">
当前实际下发<code>{{ gatewayEffective || '—' }}</code>
</p>
<p class="mt-1 text-xs text-gray-500">
只有网关在别的域名或别的机器上时才需要填写完整地址<code>https://…</code>
或以 <code>/</code> 开头的路径挂在本后台的域名下
</p>
</FormItem>
</Card>
<Card title="选择性审核规则" class="mb-4" :loading="loading">
<p class="mb-3 text-xs text-gray-500">
命中任意一条那一条回复才会停下来贴进企业微信输入框等人工确认不会自动按回车
没命中的照常自动发送关键词不区分大小写客户这句话和模型准备发的回复
沾上任意一边都算命中
</p>
<Alert
class="mb-3"
type="info"
show-icon
message="还有一条内置规则不在这份清单里、关不掉"
description="模型裁判把某条回复判成「高风险」时,无论有没有命中下面的关键词,同样会转人工。裁判只要在角色编排里配了就会一直打分,这个信号比关键词更贴近内容本身。"
/>
<div
v-for="(rule, index) in form.AI_REVIEW_RULES"
:key="index"
class="mb-3 flex items-start gap-3 border-b border-dashed pb-3 last:border-0"
>
<!--
宽度必须写在这两层普通 div 不能直接给 antd 组件加 w-40 / flex-1
antd 的样式是运行时注入的排在 Tailwind 之后`.ant-input{width:100%}`
会盖掉同为单类选择器的 `.w-40`结果就是名称框撑满整行关键词框被挤成
0 开关和删除按钮被顶到卡片外面去
-->
<div class="w-40 shrink-0">
<Input
v-model:value="rule.label"
:disabled="!canWrite"
placeholder="规则名称,例如「诊断」"
/>
</div>
<div class="min-w-0 flex-1">
<Select
v-model:value="rule.keywords"
mode="tags"
:disabled="!canWrite"
placeholder="输入关键词后按回车,可以加多个"
style="width: 100%"
/>
</div>
<Switch v-model:checked="rule.enabled" :disabled="!canWrite" class="mt-1 shrink-0" />
<Button
danger type="link" size="small" :disabled="!canWrite"
class="shrink-0" @click="removeReviewRule(index)"
>
删除
</Button>
</div>
<Button v-if="canWrite" size="small" @click="addReviewRule">+ 新增规则</Button>
<p v-if="form.AI_REVIEW_RULES.length === 0" class="mt-2 text-xs text-gray-500">
还没有配任何规则除了上面那条内置的裁判高风险检查其余全部自动发送
</p>
</Card>
<Card title="MCP 服务器" class="mb-4" :loading="loading">
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-4">
<FormItem label="单次最多调用工具轮数(1–20)">
<InputNumber
v-model:value="form.AI_MCP_MAX_ROUNDS"
:min="1" :max="20" :disabled="!canWrite" class="w-full"
/>
</FormItem>
</div>
<FormItem label="服务器清单(JSON 数组)">
<Textarea
v-model:value="mcpText"
:rows="8"
:disabled="!canWrite"
:status="mcpError ? 'error' : undefined"
spellcheck="false"
class="font-mono text-xs"
@blur="parseMcp"
/>
<p v-if="mcpError" class="mt-1 text-xs text-red-500">{{ mcpError }}</p>
<p v-else class="mt-1 text-xs text-gray-500">
工具跑在客户端本机要连内网所以配置在这里而不是模型清单里
格式错误会当场拦下不会等到桌面端同步之后才发现
</p>
</FormItem>
</Card>
<div
v-if="canWrite"
class="sticky bottom-0 -mx-5 border-t bg-white/90 px-5 py-3 backdrop-blur dark:bg-black/70"
>
<Button type="primary" :loading="saving" @click="submit">保存并发布新版本</Button>
<Button class="ml-2" :disabled="saving" @click="load">放弃修改</Button>
</div>
</Form>
</div>
</template>
@@ -0,0 +1,286 @@
<script setup lang="ts">
import type { ModelProvider } from '#/api/console';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Alert, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Switch, Table, Tag } from 'ant-design-vue';
import { deleteModel, fetchModels, saveModel, testModel } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['model:write']));
const loading = ref(false);
const models = ref<ModelProvider[]>([]);
const kinds = ref<string[]>([]);
const open = ref(false);
const editingId = ref('');
const blank = (): Partial<ModelProvider> => ({
id: '', name: '', kind: 'openai', base_url: '', endpoint_mode: 'auto',
api_key: '', model: '',
capabilities: 'text', max_tokens: 500, temperature: 0.35,
timeout_ms: 30_000, max_inflight: 32, rpm_limit: 0, enabled: true,
});
const form = reactive<Partial<ModelProvider>>(blank());
// ComfyUI 是文生图工作流引擎,当不了对话候选也当不了裁判。后端会把它排除在
// 答题池外,这里同步提示一句,免得配完了发现"怎么不生效"。
const isImageOnly = computed(() => form.kind === 'comfyui');
/**
* 实际会请求的地址。
*
* 后端按接口类型给地址补路径(填 `https://api.openai.com/v1` 会补成
* `.../v1/chat/completions`)。这条规则不显示出来的话,"我填的"和"真正请求的"
* 之间隔着一层看不见的拼接——配错了要等 404 才发现,而且多半会去怀疑密钥。
*
* 这里在前端复刻同一套规则做实时预览。真正的地址仍以后端为准,两边规则若漂移,
* 预览会和 `record.endpoint` 对不上,测连通时立刻暴露。
*/
const resolvedEndpoint = computed(() => {
const base = (form.base_url || '').replace(/\/+$/, '');
if (!base) return '';
if (form.endpoint_mode === 'exact') return base;
let path = '';
try {
path = new URL(base).pathname.replace(/\/+$/, '').toLowerCase();
} catch {
return base;
}
if (form.kind === 'claude') {
if (path.endsWith('/messages')) return base;
return path.endsWith('/v1') ? `${base}/messages` : `${base}/v1/messages`;
}
if (form.kind === 'dify') {
if (path.endsWith('/chat-messages') || path.endsWith('/completion-messages')) return base;
return path.endsWith('/v1') ? `${base}/chat-messages` : `${base}/v1/chat-messages`;
}
if (path.endsWith('/chat/completions')) return base;
if (path.startsWith('/v1/') && path.length > 4) return base;
return `${base}/chat/completions`;
});
async function load() {
loading.value = true;
try {
const data = await fetchModels();
models.value = data.models;
kinds.value = data.kinds;
} finally {
loading.value = false;
}
}
function openCreate() {
editingId.value = '';
Object.assign(form, blank());
open.value = true;
}
function openEdit(row: ModelProvider) {
editingId.value = row.id;
Object.assign(form, { ...row, api_key: '' }); // 密钥永不回显
open.value = true;
}
async function submit() {
if (!form.id || !form.base_url) {
message.warning('模型 ID 和接口地址不能为空');
return;
}
const data = await saveModel({ ...form });
models.value = data.models;
open.value = false;
message.success(editingId.value ? '已更新' : '已新增');
}
/**
* 连通性测试。
*
* 密钥不从这里发过去——只送 provider_id,后端从库里解密取用。所以"测一下现有
* 配置通不通"这个最常见的操作,全程没有明文密钥在网络上走动。
*
* 结果直接展示后端返回的原文(含 HTTP 状态码和上游的错误描述)。"连接失败"
* 四个字对排查毫无帮助,"401 API Key 无效"才是能动手的信息。
*/
const testing = ref('');
const testResult = ref<null | { label: string; ok: boolean; text: string }>(null);
async function runTest(row: ModelProvider) {
testing.value = row.id;
testResult.value = null;
try {
const data = await testModel({ provider_id: row.id, timeout_seconds: 20 });
const r = data.result;
testResult.value = {
label: data.label || row.id,
ok: r.ok,
text: `${r.message} · 端点 ${r.endpoint} · 耗时 ${r.latency_ms}ms${
r.http_status ? ` · HTTP ${r.http_status}` : ''
}`,
};
} finally {
testing.value = '';
}
}
async function remove(row: ModelProvider) {
const data = await deleteModel(row.id);
models.value = data.models;
message.success('已删除');
}
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '接口类型', dataIndex: 'kind', key: 'kind', width: 110 },
{ title: '实际请求地址', dataIndex: 'endpoint', key: 'endpoint', ellipsis: true },
{ title: '模型', dataIndex: 'model', key: 'model' },
{ title: '密钥', dataIndex: 'api_key_masked', key: 'api_key_masked', width: 160 },
{ title: '能力', dataIndex: 'capabilities', key: 'capabilities', width: 120 },
{ title: '并发上限', dataIndex: 'max_inflight', key: 'max_inflight', width: 90 },
{ title: '状态', dataIndex: 'enabled', key: 'enabled', width: 90 },
{ title: '操作', key: 'action', width: 200 },
];
onMounted(load);
</script>
<template>
<div class="p-5">
<Card title="模型清单">
<template #extra>
<Button v-if="canWrite" type="primary" @click="openCreate">新增模型</Button>
</template>
<p class="mb-3 text-sm text-gray-500">
密钥在后端加密存储接口只返回遮罩值这里看到的星号不是显示效果
是后端真的不会把明文发出来留空保存表示不改动原密钥
</p>
<Alert
v-if="testResult"
class="mb-3"
closable
:type="testResult.ok ? 'success' : 'error'"
show-icon
:message="`${testResult.label}${testResult.ok ? '连接成功' : '连接失败'}`"
:description="testResult.text"
@close="testResult = null"
/>
<Table
:columns="columns" :data-source="models" :loading="loading"
row-key="id" size="small" :pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'kind'">
<Tag :color="record.kind === 'comfyui' ? 'default' : 'blue'">{{ record.kind }}</Tag>
</template>
<template v-else-if="column.key === 'endpoint'">
<code class="text-xs">{{ record.endpoint || record.base_url }}</code>
<Tag v-if="record.endpoint_mode === 'exact'" class="ml-1" color="purple">原样</Tag>
</template>
<template v-else-if="column.key === 'api_key_masked'">
<code class="text-xs">{{ record.api_key_masked || '(未设置)' }}</code>
</template>
<template v-else-if="column.key === 'enabled'">
<Tag :color="record.enabled ? 'green' : 'default'">
{{ record.enabled ? '已启用' : '已停用' }}
</Tag>
</template>
<template v-else-if="column.key === 'action'">
<Button
type="link" size="small"
:disabled="!canWrite" :loading="testing === record.id"
@click="runTest(record as ModelProvider)"
>
测连通
</Button>
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as ModelProvider)">
编辑
</Button>
<Popconfirm title="确定删除这个模型?" @confirm="remove(record as ModelProvider)">
<Button type="link" size="small" danger :disabled="!canWrite">删除</Button>
</Popconfirm>
</template>
</template>
</Table>
</Card>
<Modal v-model:open="open" :title="editingId ? '编辑模型' : '新增模型'" @ok="submit">
<Form :model="form" layout="vertical" class="pt-2">
<FormItem label="模型 ID(唯一,编排里按它引用)">
<Input v-model:value="form.id" :disabled="!!editingId" placeholder="dify-kefu" />
</FormItem>
<FormItem label="显示名称">
<Input v-model:value="form.name" placeholder="甄养堂客服" />
</FormItem>
<FormItem label="接口类型">
<Select v-model:value="form.kind" :options="kinds.map((k) => ({ value: k, label: k }))" />
<p v-if="isImageOnly" class="mt-1 text-xs text-orange-500">
ComfyUI 是文生图工作流引擎不能当对话模型后端会把它排除在答题池和裁判之外
</p>
</FormItem>
<FormItem label="接口地址">
<Input
v-model:value="form.base_url"
placeholder="https://api.anthropic.com"
autocomplete="off"
name="model-base-url"
/>
<div class="mt-2 flex items-start gap-2">
<Switch
size="small"
:checked="form.endpoint_mode === 'exact'"
class="mt-0.5 shrink-0"
@change="(v: any) => (form.endpoint_mode = v ? 'exact' : 'auto')"
/>
<div class="text-xs text-gray-500">
<span class="font-medium">这就是完整的请求地址不要再补路径</span>
<p class="mt-0.5">
关着时按接口类型自动补全<code>/v1</code> <code>/v1/chat/completions</code>
大多数服务商都是这个形状路径不按套路的自建服务打开它
</p>
</div>
</div>
<p v-if="resolvedEndpoint" class="mt-2 break-all rounded bg-gray-50 px-2 py-1 text-xs dark:bg-gray-800">
实际请求<code>{{ resolvedEndpoint }}</code>
</p>
</FormItem>
<FormItem :label="editingId ? 'API 密钥(留空表示不改动)' : 'API 密钥'">
<Input.Password v-model:value="form.api_key" placeholder="留空 = 保持原密钥" />
</FormItem>
<FormItem label="模型名称(Dify 由应用侧决定,可留空)">
<Input v-model:value="form.model" placeholder="claude-sonnet-4-6" />
</FormItem>
<FormItem label="能力">
<Select
v-model:value="form.capabilities"
:options="[
{ value: 'text', label: '仅文本' },
{ value: 'text,vision', label: '文本 + 视觉' },
{ value: 'image_gen', label: '文生图' },
]"
/>
</FormItem>
<div class="grid grid-cols-2 gap-3">
<FormItem label="并发上限">
<InputNumber v-model:value="form.max_inflight" :min="1" class="w-full" />
</FormItem>
<FormItem label="超时(毫秒)">
<InputNumber v-model:value="form.timeout_ms" :min="1000" :step="1000" class="w-full" />
</FormItem>
<FormItem label="最大回复 tokens">
<InputNumber v-model:value="form.max_tokens" :min="1" class="w-full" />
</FormItem>
<FormItem label="温度">
<InputNumber v-model:value="form.temperature" :min="0" :max="2" :step="0.05" class="w-full" />
</FormItem>
</div>
<FormItem label="启用">
<Switch v-model:checked="form.enabled" />
</FormItem>
</Form>
</Modal>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { ModelPlan, ModelProvider } from '#/api/console';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Alert, Button, Card, Descriptions, DescriptionsItem, Form, FormItem, message, Select, Tag } from 'ant-design-vue';
import { fetchModels, saveModelPlan } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['model:write']));
const loading = ref(false);
const models = ref<ModelProvider[]>([]);
const current = ref<ModelPlan | null>(null);
const judgeModes = ref<string[]>([]);
const form = reactive({
answer_ids: [] as string[],
judge_id: '',
vision_id: '',
fallback_ids: [] as string[],
judge_mode: 'shadow',
});
/** ComfyUI 当不了对话候选,选项里直接不给——省得配完了发现不生效。 */
const chatOptions = computed(() =>
models.value
.filter((item) => item.kind !== 'comfyui' && item.enabled)
.map((item) => ({ value: item.id, label: `${item.name}${item.kind}` })),
);
const visionOptions = computed(() =>
chatOptions.value.filter((option) =>
models.value
.find((item) => item.id === option.value)
?.capabilities.includes('vision'),
),
);
const MODE_HELP: Record<string, string> = {
shadow:
'照常发主模型的回复,裁判只在后台打分、不改变任何行为。先量出现有回复的真实水平,再决定要不要花第二份钱。',
score_only:
'用裁判的绝对分和风险做路由:低分或高风险转人工审核,不做二选一。',
arbitrate:
'完整的 best-of-N:并发问多个模型,按裁判选出的赢家发送。成本约 2.3 倍。',
};
async function load() {
loading.value = true;
try {
const data = await fetchModels();
models.value = data.models;
judgeModes.value = data.judge_modes;
current.value = data.roles;
const split = (value: string) =>
(value || '').split(',').map((s) => s.trim()).filter(Boolean);
Object.assign(form, {
answer_ids: split(data.roles.answer_ids),
fallback_ids: split(data.roles.fallback_ids),
judge_id: data.roles.judge_id || '',
vision_id: data.roles.vision_id || '',
judge_mode: data.roles.judge_mode || 'shadow',
});
} finally {
loading.value = false;
}
}
async function submit() {
if (form.answer_ids.length === 0) {
message.warning('至少要指定一个答题模型');
return;
}
const data = await saveModelPlan({
answer_ids: form.answer_ids.join(','),
judge_id: form.judge_id,
vision_id: form.vision_id,
fallback_ids: form.fallback_ids.join(','),
judge_mode: form.judge_mode,
});
current.value = data.roles;
message.success(`已保存为 v${data.version},网关 20 秒内自动生效`);
}
onMounted(load);
</script>
<template>
<div class="p-5">
<Alert
class="mb-4"
type="warning"
show-icon
message="编排是追加版本,不是原地修改"
description="每次保存都产生一个新版本号,回滚就是指回旧版本。网关每 20 秒重读一次,不用重启。"
/>
<Card title="当前生效的编排" class="mb-4" :loading="loading">
<Descriptions v-if="current" :column="2" size="small" bordered>
<DescriptionsItem label="版本">v{{ current.version }}</DescriptionsItem>
<DescriptionsItem label="裁判模式">
<Tag :color="current.judge_mode === 'arbitrate' ? 'red' : current.judge_mode === 'score_only' ? 'orange' : 'blue'">
{{ current.judge_mode }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="答题模型">{{ current.answer_ids || '—' }}</DescriptionsItem>
<DescriptionsItem label="裁判模型">{{ current.judge_id || '—' }}</DescriptionsItem>
</Descriptions>
</Card>
<Card title="修改编排">
<Form :model="form" layout="vertical">
<FormItem label="答题模型(多选 = 并发提问,按顺序第一个是主模型)">
<Select
v-model:value="form.answer_ids" mode="multiple"
:options="chatOptions" :disabled="!canWrite"
placeholder="至少选一个"
/>
<p v-if="form.answer_ids.length > 1" class="mt-1 text-xs text-orange-500">
选了 {{ form.answer_ids.length }} = 每条回复都要付 {{ form.answer_ids.length }} 份生成费用
建议先跑一段影子模式用调用统计里的分数分布判断值不值
</p>
</FormItem>
<FormItem label="裁判模型(给回复打绝对分和风险等级)">
<Select
v-model:value="form.judge_id" :options="chatOptions"
:disabled="!canWrite" allow-clear placeholder="不选则不做评审"
/>
</FormItem>
<FormItem label="裁判模式">
<Select
v-model:value="form.judge_mode" :disabled="!canWrite"
:options="judgeModes.map((m) => ({ value: m, label: m }))"
/>
<p class="mt-1 text-xs text-gray-500">{{ MODE_HELP[form.judge_mode] }}</p>
</FormItem>
<FormItem label="媒体消息专用模型(需具备视觉能力,留空则从答题池里挑)">
<Select
v-model:value="form.vision_id" :options="visionOptions"
:disabled="!canWrite" allow-clear
/>
</FormItem>
<FormItem label="降级链(主链全挂时按顺序尝试)">
<Select
v-model:value="form.fallback_ids" mode="multiple"
:options="chatOptions" :disabled="!canWrite"
/>
</FormItem>
<Button v-if="canWrite" type="primary" @click="submit">保存为新版本</Button>
</Form>
</Card>
</div>
</template>
@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import {
Alert,
Button,
Card,
Descriptions,
DescriptionsItem,
Form,
FormItem,
Input,
message,
Switch,
Tag,
Textarea,
} from 'ant-design-vue';
import { fetchRelease, saveRelease } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['release:write']));
const loading = ref(false);
const saving = ref(false);
const current = reactive({
latest_version: '',
download_url: '',
release_notes: '',
force_upgrade: false,
updated_at: '',
updated_by: '',
});
const form = reactive({
latest_version: '',
download_url: '',
release_notes: '',
force_upgrade: false,
});
/** 后端要求版本号形如 1.0.0,可带 v 前缀。这里先在前端提示一遍,少一次往返。 */
const versionValid = computed(() =>
/^v?\d+\.\d+\.\d+$/.test(form.latest_version.trim()),
);
const urlValid = computed(() => {
const value = form.download_url.trim();
return !value || /^https?:\/\/.+/.test(value);
});
/** 强制升级会挡住客户端继续使用,没有下载地址就是把人堵死在原地。 */
const forceWithoutUrl = computed(
() => form.force_upgrade && !form.download_url.trim(),
);
async function load() {
loading.value = true;
try {
const data = await fetchRelease();
Object.assign(current, data);
Object.assign(form, {
latest_version: data.latest_version,
download_url: data.download_url,
release_notes: data.release_notes,
force_upgrade: data.force_upgrade,
});
} finally {
loading.value = false;
}
}
async function submit() {
if (!versionValid.value) {
message.warning('版本号格式应为 1.0.0');
return;
}
if (!urlValid.value) {
message.warning('下载地址必须是完整的 http 或 https 地址');
return;
}
if (forceWithoutUrl.value) {
message.warning('开启强制升级前必须填写下载地址');
return;
}
saving.value = true;
try {
const data = await saveRelease({ ...form });
message.success(`已发布 v${data.latest_version}`);
await load();
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="p-5">
<Alert
class="mb-4"
type="info"
show-icon
message="这里只是告诉客户端「有新版本」,不负责分发安装包"
description="客户端启动时对比本地版本号,发现更新就按这里填的地址去下载。安装包放在哪由你决定,后端只存地址。"
/>
<Card title="当前的版本策略" class="mb-4" :loading="loading">
<Descriptions :column="2" size="small" bordered>
<DescriptionsItem label="最新版本">
v{{ current.latest_version || '—' }}
</DescriptionsItem>
<DescriptionsItem label="强制升级">
<Tag :color="current.force_upgrade ? 'red' : 'default'">
{{ current.force_upgrade ? '开启' : '关闭' }}
</Tag>
</DescriptionsItem>
<DescriptionsItem label="下载地址" :span="2">
<a v-if="current.download_url" :href="current.download_url" target="_blank" rel="noreferrer">
{{ current.download_url }}
</a>
<span v-else class="text-gray-400">未设置</span>
</DescriptionsItem>
<DescriptionsItem label="最后修改">{{ current.updated_by || '—' }}</DescriptionsItem>
<DescriptionsItem label="时间">{{ current.updated_at || '—' }}</DescriptionsItem>
</Descriptions>
<p v-if="current.release_notes" class="mt-3 whitespace-pre-wrap rounded bg-gray-50 p-3 text-xs dark:bg-gray-800">
{{ current.release_notes }}
</p>
</Card>
<Card title="发布新版本">
<Form :model="form" layout="vertical">
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-2">
<FormItem
label="版本号"
:validate-status="form.latest_version && !versionValid ? 'error' : undefined"
:help="form.latest_version && !versionValid ? '格式应为 1.0.0,可带 v 前缀' : ''"
>
<Input v-model:value="form.latest_version" :disabled="!canWrite" placeholder="2.1.0" />
</FormItem>
<FormItem
label="下载地址"
:validate-status="!urlValid ? 'error' : undefined"
:help="!urlValid ? '必须是完整的 http 或 https 地址' : ''"
>
<Input
v-model:value="form.download_url"
:disabled="!canWrite"
placeholder="https://dl.example.com/setup-2.1.0.exe"
/>
</FormItem>
</div>
<FormItem label="更新说明(不超过 4000 字)">
<Textarea
v-model:value="form.release_notes"
:rows="6"
:disabled="!canWrite"
:maxlength="4000"
show-count
placeholder="这一版改了什么。客户端升级提示里会原样显示给使用的人看。"
/>
</FormItem>
<FormItem>
<div class="flex items-start gap-3">
<Switch v-model:checked="form.force_upgrade" :disabled="!canWrite" class="mt-1" />
<div>
<div class="text-sm font-medium">强制升级</div>
<div class="mt-0.5 text-xs text-gray-500">
开启后旧版客户端会被挡住必须升级才能继续用只在旧版有严重问题时才开
否则正在接待客户的人会被中途打断
</div>
<div v-if="forceWithoutUrl" class="mt-1 text-xs text-red-500">
还没填下载地址这样开强制升级客户端会被挡住又无处可下载
</div>
</div>
</div>
</FormItem>
<Button v-if="canWrite" type="primary" :loading="saving" @click="submit">
发布
</Button>
</Form>
</Card>
</div>
</template>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { PermissionItem, RoleItem } from '#/api/console';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Alert, Button, Card, Checkbox, CheckboxGroup, Form, FormItem, Input, message, Modal, Popconfirm, Table, Tag } from 'ant-design-vue';
import { deleteRole, fetchPermissions, fetchRoles, saveRole } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['role:write']));
const loading = ref(false);
const roles = ref<RoleItem[]>([]);
const permissions = ref<PermissionItem[]>([]);
const open = ref(false);
const isNew = ref(false);
const form = reactive({ code: '', name: '', permissions: [] as string[] });
/** 权限按分组展示。一屏十几个码平铺出来没人看得懂哪个是哪个。 */
const grouped = computed(() => {
const map = new Map<string, PermissionItem[]>();
for (const item of permissions.value) {
const list = map.get(item.group_name) ?? [];
list.push(item);
map.set(item.group_name, list);
}
return [...map.entries()].map(([group, items]) => ({ group, items }));
});
async function load() {
loading.value = true;
try {
roles.value = (await fetchRoles()).roles;
if (canWrite.value) {
permissions.value = (await fetchPermissions()).permissions;
}
} finally {
loading.value = false;
}
}
function openCreate() {
isNew.value = true;
Object.assign(form, { code: '', name: '', permissions: [] });
open.value = true;
}
function openEdit(row: RoleItem) {
isNew.value = false;
Object.assign(form, {
code: row.code,
name: row.name,
permissions: [...row.permissions],
});
open.value = true;
}
async function submit() {
if (!form.code) {
message.warning('角色编码不能为空');
return;
}
roles.value = (await saveRole({ ...form })).roles;
open.value = false;
message.success('已保存,权限立即生效(无需重启,也无需重新登录)');
}
async function remove(row: RoleItem) {
roles.value = (await deleteRole(row.code)).roles;
message.success('已删除');
}
const columns = [
{ title: '编码', dataIndex: 'code', key: 'code', width: 140 },
{ title: '名称', dataIndex: 'name', key: 'name', width: 140 },
{ title: '权限', key: 'permissions' },
{ title: '在用人数', dataIndex: 'user_count', key: 'user_count', width: 90 },
{ title: '操作', key: 'action', width: 140 },
];
onMounted(load);
</script>
<template>
<div class="p-5">
<Alert
class="mb-4"
type="info"
show-icon
message="权限存在数据库里,不在代码里"
description="改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。"
/>
<Card title="角色与权限">
<template #extra>
<Button v-if="canWrite" type="primary" @click="openCreate">新增角色</Button>
</template>
<Table
:columns="columns" :data-source="roles" :loading="loading"
row-key="code" size="small" :pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'code'">
<code>{{ record.code }}</code>
<Tag v-if="record.builtin" class="ml-2" color="blue">内置</Tag>
</template>
<template v-else-if="column.key === 'permissions'">
<Tag v-for="code in record.permissions" :key="code" class="mb-1">
{{ code }}
</Tag>
</template>
<template v-else-if="column.key === 'action'">
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as RoleItem)">
编辑
</Button>
<Popconfirm title="确定删除这个角色?" @confirm="remove(record as RoleItem)">
<Button
type="link" size="small" danger
:disabled="!canWrite || record.builtin || record.user_count > 0"
>
删除
</Button>
</Popconfirm>
</template>
</template>
</Table>
</Card>
<Modal v-model:open="open" :title="isNew ? '新增角色' : '编辑角色'" width="640px" @ok="submit">
<Form :model="form" layout="vertical" class="pt-2">
<FormItem label="角色编码(字母数字,创建后不可改)">
<Input v-model:value="form.code" :disabled="!isNew" placeholder="reviewer" />
</FormItem>
<FormItem label="角色名称">
<Input v-model:value="form.name" placeholder="审核员" />
</FormItem>
<FormItem label="权限">
<CheckboxGroup v-model:value="form.permissions" class="w-full">
<div v-for="block in grouped" :key="block.group" class="mb-3">
<div class="mb-1 text-sm font-medium text-gray-600">{{ block.group }}</div>
<div class="flex flex-wrap gap-x-6 gap-y-1">
<Checkbox v-for="item in block.items" :key="item.code" :value="item.code">
{{ item.name }}
<code class="ml-1 text-xs text-gray-400">{{ item.code }}</code>
</Checkbox>
</div>
</div>
</CheckboxGroup>
<p class="mt-1 text-xs text-gray-500">
提交的是最终状态取消勾选就是收回该权限
</p>
</FormItem>
</Form>
</Modal>
</div>
</template>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { RoleItem, UserItem } from '#/api/console';
import { computed, onMounted, reactive, ref } from 'vue';
import { useAccess } from '@vben/access';
import { Button, Card, Form, FormItem, Input, message, Modal, Select, Switch, Table, Tag } from 'ant-design-vue';
import { createUser, fetchRoles, fetchUsers, updateUser } from '#/api/console';
const { hasAccessByCodes } = useAccess();
const canWrite = computed(() => hasAccessByCodes(['user:write']));
const loading = ref(false);
const users = ref<UserItem[]>([]);
const roles = ref<RoleItem[]>([]);
const open = ref(false);
const form = reactive({ username: '', password: '', role: 'viewer' });
const roleOptions = computed(() =>
roles.value.map((item) => ({ value: item.code, label: `${item.name}${item.code}` })),
);
async function load() {
loading.value = true;
try {
users.value = (await fetchUsers()).users;
roles.value = (await fetchRoles()).roles;
} finally {
loading.value = false;
}
}
function openCreate() {
Object.assign(form, { username: '', password: '', role: 'viewer' });
open.value = true;
}
async function submit() {
if (!form.username || !form.password) {
message.warning('用户名和密码不能为空');
return;
}
users.value = (await createUser({ ...form })).users;
open.value = false;
message.success('已创建,该用户首次登录必须先改密码');
}
async function changeRole(row: UserItem, role: string) {
users.value = (await updateUser(row.id, { role, active: row.active })).users;
message.success('角色已更新,立即生效');
}
async function toggleActive(row: UserItem, active: boolean) {
users.value = (await updateUser(row.id, { role: row.role, active })).users;
message.success(active ? '已启用' : '已停用');
}
const columns = [
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '角色', dataIndex: 'role', key: 'role', width: 220 },
{ title: '状态', dataIndex: 'active', key: 'active', width: 120 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
];
onMounted(load);
</script>
<template>
<div class="p-5">
<Card title="用户管理">
<template #extra>
<Button v-if="canWrite" type="primary" @click="openCreate">新增用户</Button>
</template>
<Table
:columns="columns" :data-source="users" :loading="loading"
row-key="id" size="small" :pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'username'">
{{ record.username }}
<Tag v-if="record.must_change_password" color="orange" class="ml-2">
待改密
</Tag>
</template>
<template v-else-if="column.key === 'role'">
<Select
:value="record.role" :options="roleOptions" :disabled="!canWrite"
size="small" class="w-full"
@change="(value) => changeRole(record as UserItem, String(value))"
/>
</template>
<template v-else-if="column.key === 'active'">
<Switch
:checked="record.active" :disabled="!canWrite" size="small"
@change="(value) => toggleActive(record as UserItem, Boolean(value))"
/>
<span class="ml-2 text-xs">{{ record.active ? '已启用' : '已停用' }}</span>
</template>
</template>
</Table>
</Card>
<Modal v-model:open="open" title="新增用户" @ok="submit">
<Form :model="form" layout="vertical" class="pt-2">
<FormItem label="用户名">
<Input v-model:value="form.username" />
</FormItem>
<FormItem label="初始密码(至少 10 位,需同时含字母和数字)">
<Input.Password v-model:value="form.password" />
</FormItem>
<FormItem label="角色">
<Select v-model:value="form.role" :options="roleOptions" />
</FormItem>
</Form>
</Modal>
</div>
</template>