gengx
This commit is contained in:
@@ -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-COS(AES256)', 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="上下文轮数(1–50)">
|
||||
<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>
|
||||
Reference in New Issue
Block a user