i更新
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
* 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。
|
||||
*/
|
||||
import { requestClient } from '#/api/request';
|
||||
import { t } from '#/locales/t';
|
||||
import { sha256File } from '#/utils/file-sha256';
|
||||
|
||||
/**
|
||||
@@ -54,10 +55,54 @@ export interface RoleItem {
|
||||
user_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限菜单里的一条:一级菜单(group_name)→ 页面(page_name)→ 权限码。
|
||||
*
|
||||
* - source = builtin:随代码发布,只读
|
||||
* - source = custom:管理员在「权限菜单」页自己加的,可改可删
|
||||
* - source = discovered:代码里用到却没登记,后端启动时自动登记到「未归类」
|
||||
*/
|
||||
export interface PermissionItem {
|
||||
code: string;
|
||||
enabled: boolean;
|
||||
group_name: string;
|
||||
icon: string;
|
||||
/** page = 能打开页面;action = 页面里的操作;link = 左侧菜单链接 */
|
||||
kind: 'action' | 'link' | 'page';
|
||||
link_mode: '' | 'iframe' | 'tab';
|
||||
link_url: string;
|
||||
name: string;
|
||||
page_name: string;
|
||||
/** 持有这个权限码的角色 */
|
||||
roles: string[];
|
||||
sort_order: number;
|
||||
source: 'builtin' | 'custom' | 'discovered';
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 新增/修改自定义条目时提交的字段 */
|
||||
export interface PermissionForm {
|
||||
code: string;
|
||||
enabled: boolean;
|
||||
group_name: string;
|
||||
icon: string;
|
||||
kind: 'action' | 'link';
|
||||
link_mode: 'iframe' | 'tab';
|
||||
link_url: string;
|
||||
name: string;
|
||||
page_name: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
/** 左侧菜单里管理员自己加的链接(后端已按当前用户的权限过滤) */
|
||||
export interface CustomMenuItem {
|
||||
code: string;
|
||||
group_name: string;
|
||||
icon: string;
|
||||
link_mode: '' | 'iframe' | 'tab';
|
||||
link_url: string;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
@@ -241,6 +286,30 @@ export function fetchPermissions() {
|
||||
return requestClient.get<{ permissions: PermissionItem[] }>('/permissions');
|
||||
}
|
||||
|
||||
export function createPermission(data: PermissionForm) {
|
||||
return requestClient.post<{ permissions: PermissionItem[] }>(
|
||||
'/permissions',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function updatePermission(code: string, data: PermissionForm) {
|
||||
return requestClient.put<{ permissions: PermissionItem[] }>(
|
||||
`/permissions/${encodeURIComponent(code)}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function deletePermission(code: string) {
|
||||
return requestClient.delete<{ permissions: PermissionItem[] }>(
|
||||
`/permissions/${encodeURIComponent(code)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchCustomMenus() {
|
||||
return requestClient.get<{ menus: CustomMenuItem[] }>('/custom-menus');
|
||||
}
|
||||
|
||||
export function saveRole(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -531,14 +600,14 @@ function putCosBlob(
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.upload.onprogress = (event) => onProgress?.(event.loaded);
|
||||
xhr.onerror = () => reject(new Error('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置'));
|
||||
xhr.ontimeout = () => reject(new Error('上传腾讯 COS 超时,请重试'));
|
||||
xhr.onerror = () => reject(new Error(t('无法连接腾讯 COS,请检查网络和 Bucket CORS 配置')));
|
||||
xhr.ontimeout = () => reject(new Error(t('上传腾讯 COS 超时,请重试')));
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve((xhr.getResponseHeader('ETag') || '').replaceAll('"', ''));
|
||||
return;
|
||||
}
|
||||
reject(new Error(`腾讯 COS 上传失败(HTTP ${xhr.status})`));
|
||||
reject(new Error(t('腾讯 COS 上传失败(HTTP {status})', { status: xhr.status })));
|
||||
};
|
||||
xhr.send(body);
|
||||
});
|
||||
@@ -551,7 +620,7 @@ async function uploadMultipartPackage(
|
||||
) {
|
||||
const multipart = prepared.multipart;
|
||||
if (!multipart?.upload_id || !multipart.parts.length) {
|
||||
throw new Error('COS 分块上传参数不完整');
|
||||
throw new Error(t('COS 分块上传参数不完整'));
|
||||
}
|
||||
const partSize = multipart.part_size;
|
||||
const uploaded = new Map<number, number>();
|
||||
@@ -594,7 +663,7 @@ async function uploadMultipartPackage(
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
if (!etag) {
|
||||
throw new Error('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头');
|
||||
throw new Error(t('COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头'));
|
||||
}
|
||||
uploaded.set(part.part_number, part.size_bytes);
|
||||
completed.set(part.part_number, etag);
|
||||
@@ -622,10 +691,10 @@ export async function uploadReleasePackage(
|
||||
onProgress?: (percent: number) => void,
|
||||
) {
|
||||
if (!file.name.toLowerCase().endsWith('.exe')) {
|
||||
throw new Error('升级包必须是 Windows EXE 安装包');
|
||||
throw new Error(t('升级包必须是 Windows EXE 安装包'));
|
||||
}
|
||||
if (file.size <= 0 || file.size > 1024 ** 3) {
|
||||
throw new Error('安装包大小必须大于 0 且不能超过 1GB');
|
||||
throw new Error(t('安装包大小必须大于 0 且不能超过 1GB'));
|
||||
}
|
||||
onProgress?.(1);
|
||||
const packageSha256 = await sha256File(file, (fraction) => {
|
||||
@@ -649,7 +718,7 @@ export async function uploadReleasePackage(
|
||||
onProgress?.(100);
|
||||
return result;
|
||||
}
|
||||
if (!prepared.upload_url) throw new Error('COS 预上传接口没有返回上传地址');
|
||||
if (!prepared.upload_url) throw new Error(t('COS 预上传接口没有返回上传地址'));
|
||||
await putCosBlob(
|
||||
prepared.upload_url,
|
||||
file,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
/**
|
||||
* 认证接口。
|
||||
@@ -58,7 +59,7 @@ export async function loginApi(data: AuthApi.LoginParams) {
|
||||
* 重试,表现是页面卡死而不是跳登录页。
|
||||
*/
|
||||
export async function refreshTokenApi(): Promise<never> {
|
||||
throw new Error('后端未启用令牌续期,请重新登录');
|
||||
throw new Error(t('后端未启用令牌续期,请重新登录'));
|
||||
}
|
||||
|
||||
export async function logoutApi() {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
/** Convert API errors to text without rendering objects or echoing request inputs. */
|
||||
function errorText(value: unknown, depth = 0): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
// Backend messages are shown as-is; t() only swaps in an English text when the dictionary has one.
|
||||
if (typeof value === 'string') return t(value.trim());
|
||||
if (!value || typeof value !== 'object' || depth > 3) return '';
|
||||
if (Array.isArray(value)) {
|
||||
return [...new Set(value.slice(0, 3).map((entry) => errorText(entry, depth + 1)).filter(Boolean))].join(';');
|
||||
return [...new Set(value.slice(0, 3).map((entry) => errorText(entry, depth + 1)).filter(Boolean))].join(t(';'));
|
||||
}
|
||||
const detail = value as Record<string, unknown>;
|
||||
if (Array.isArray(detail.loc) && typeof detail.type === 'string') {
|
||||
const field = detail.loc.at(-1) === 'query' ? '检索问题' : '请求参数';
|
||||
const field = detail.loc.at(-1) === 'query' ? t('检索问题') : t('请求参数');
|
||||
const ctx = detail.ctx as Record<string, unknown> | undefined;
|
||||
if (detail.type === 'string_too_short' && typeof ctx?.min_length === 'number') return `${field}至少需要 ${ctx.min_length} 个字符`;
|
||||
if (detail.type === 'string_too_long' && typeof ctx?.max_length === 'number') return `${field}不能超过 ${ctx.max_length} 个字符`;
|
||||
if (detail.type === 'missing') return `请填写${field}`;
|
||||
if (detail.type === 'string_type') return `${field}必须是文本`;
|
||||
return typeof detail.msg === 'string' ? `${field}:${detail.msg}` : `${field}格式不正确`;
|
||||
if (detail.type === 'string_too_short' && typeof ctx?.min_length === 'number') return t('{field}至少需要 {min} 个字符', { field, min: ctx.min_length });
|
||||
if (detail.type === 'string_too_long' && typeof ctx?.max_length === 'number') return t('{field}不能超过 {max} 个字符', { field, max: ctx.max_length });
|
||||
if (detail.type === 'missing') return t('请填写{field}', { field });
|
||||
if (detail.type === 'string_type') return t('{field}必须是文本', { field });
|
||||
return typeof detail.msg === 'string' ? t('{field}:{msg}', { field, msg: detail.msg }) : t('{field}格式不正确', { field });
|
||||
}
|
||||
for (const key of ['detail', 'error', 'message', 'msg']) {
|
||||
const text = errorText(detail[key], depth + 1);
|
||||
@@ -22,11 +25,11 @@ function errorText(value: unknown, depth = 0): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function responseErrorMessage(data: unknown, fallback = '请求失败,请稍后重试'): string {
|
||||
export function responseErrorMessage(data: unknown, fallback = t('请求失败,请稍后重试')): string {
|
||||
return errorText(data) || fallback;
|
||||
}
|
||||
|
||||
export function requestErrorMessage(error: unknown, fallback = '请求失败,请稍后重试'): string {
|
||||
export function requestErrorMessage(error: unknown, fallback = t('请求失败,请稍后重试')): string {
|
||||
const data = (error as { response?: { data?: unknown } } | null)?.response?.data;
|
||||
// RequestClient unwraps Axios response.data before rejecting to page handlers.
|
||||
return responseErrorMessage(data ?? (error instanceof Error ? undefined : error), fallback);
|
||||
|
||||
@@ -3,6 +3,9 @@ import { requestClient } from '#/api/request';
|
||||
export interface KnowledgeItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
/** 所属客户端账号(「全部账号」下列表混着多个账号时用来区分) */
|
||||
account_id?: number;
|
||||
account_label?: string;
|
||||
title: string;
|
||||
question: string;
|
||||
answer: string;
|
||||
@@ -21,35 +24,6 @@ export interface KnowledgeItem {
|
||||
|
||||
export interface KnowledgeModel { id: string; name: string; model: string; kind: string; default: boolean }
|
||||
|
||||
export interface KnowledgeJob {
|
||||
id: string;
|
||||
status: string;
|
||||
processed: number;
|
||||
created_items: number;
|
||||
duplicates: number;
|
||||
skipped: number;
|
||||
model_calls: number;
|
||||
input_chars: number;
|
||||
output_chars: number;
|
||||
error: string;
|
||||
created_at: string;
|
||||
total_messages: null | number;
|
||||
progress_percent: null | number;
|
||||
completion_reason: string;
|
||||
pending_items: number;
|
||||
options: { engine: string; max_model_calls: number; model_provider_id?: string; model_name?: string; model_timeout_seconds?: number; model_format_fallbacks?: number; model_last_warning?: string; max_messages: number; source_name?: string; incremental?: boolean };
|
||||
}
|
||||
|
||||
export interface KnowledgePreview {
|
||||
available_messages: number;
|
||||
eligible_text_messages: number;
|
||||
conversations: number;
|
||||
total_messages: number;
|
||||
limited: boolean;
|
||||
cutoff_at: string;
|
||||
source_name: string;
|
||||
}
|
||||
|
||||
export interface KnowledgeOverview {
|
||||
counts: Record<string, number>;
|
||||
settings: null | { enabled: boolean; updated_at: string };
|
||||
@@ -81,8 +55,6 @@ function path(resource: string, accountId: number, extra: Record<string, number
|
||||
|
||||
export const fetchKnowledgeOverview = (id: number, signal?: AbortSignal) =>
|
||||
requestClient.get<KnowledgeOverview>(path('overview', id), { signal });
|
||||
export const fetchKnowledgeJobs = (id: number, signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeJob[] }>(path('jobs', id), { signal });
|
||||
export const fetchKnowledgeItems = (id: number, status: string, query: string, page: number, signal?: AbortSignal) =>
|
||||
requestClient.get<{ items: KnowledgeItem[]; total: number }>(path('items', id, { page, query, status }), { signal });
|
||||
export const fetchKnowledgeItem = (id: number, item: string) =>
|
||||
@@ -91,14 +63,8 @@ export const saveKnowledgeItem = (id: number, item: KnowledgeItem) =>
|
||||
requestClient.put<KnowledgeItem>(path(`items/${item.id}`, id), item);
|
||||
export const knowledgeItemAction = (id: number, item: KnowledgeItem, action: string, confirmed = false, restoreRevision = 0) =>
|
||||
requestClient.post<KnowledgeItem>(path(`items/${item.id}/${action}`, id), { confirmed, restore_revision: restoreRevision, revision: item.revision });
|
||||
export const fetchKnowledgeModels = (id: number) =>
|
||||
export const fetchKnowledgeModels = (id = -1) =>
|
||||
requestClient.get<{ items: KnowledgeModel[] }>(path('models', id));
|
||||
export const configureKnowledgeJob = (id: number, job: string, body: Record<string, unknown>) =>
|
||||
requestClient.put<KnowledgeJob>(path(`jobs/${job}/config`, id), body);
|
||||
export const createKnowledgeJob = (id: number, body: Record<string, unknown>) =>
|
||||
requestClient.post<KnowledgeJob>(path('jobs', id), body);
|
||||
export const knowledgeJobAction = (id: number, job: string, action: string) =>
|
||||
requestClient.post<KnowledgeJob>(path(`jobs/${job}/${action}`, id));
|
||||
export const searchKnowledge = (id: number, query: string) =>
|
||||
requestClient.post<KnowledgeSearch>(path('search', id), { query });
|
||||
export const setKnowledgeEnabled = (id: number, enabled: boolean) =>
|
||||
@@ -111,9 +77,117 @@ export const exportKnowledge = (id: number) =>
|
||||
export const setKnowledgeWatch = (id: number, sourceId: string, enabled: boolean) =>
|
||||
requestClient.put(path(`watch/${sourceId}`, id), { enabled });
|
||||
|
||||
export const previewKnowledgeJob = (id: number, body: Record<string, unknown>) =>
|
||||
requestClient.post<KnowledgePreview>(path('jobs/preview', id), body, { timeout: 60000 });
|
||||
|
||||
// ── 整理批次:一次整理全部(或选中的)账号的全部聊天 ─────────────────────────
|
||||
/** 整理方式与模型参数。max_model_calls = 0 表示不限。 */
|
||||
export interface KnowledgeWashOptions {
|
||||
engine: 'model' | 'rules';
|
||||
max_model_calls: number;
|
||||
/** 一次请求合并几段问答(1 = 一段一请求) */
|
||||
model_batch: number;
|
||||
model_concurrency: number;
|
||||
model_filter: boolean;
|
||||
model_provider_id: string;
|
||||
model_timeout_seconds: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeRunAccount {
|
||||
account_id: number;
|
||||
completion_reason: string;
|
||||
created_items: number;
|
||||
duplicates: number;
|
||||
error: string;
|
||||
job_id: string;
|
||||
label: string;
|
||||
model_calls: number;
|
||||
model_filtered: number;
|
||||
pending_items: number;
|
||||
processed: number;
|
||||
progress_percent: null | number;
|
||||
skipped: number;
|
||||
status: string;
|
||||
tenant_id: string;
|
||||
total_messages: null | number;
|
||||
updated_items: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeRun {
|
||||
accounts: KnowledgeRunAccount[];
|
||||
created_at: string;
|
||||
created_items: number;
|
||||
duplicates: number;
|
||||
errors: string[];
|
||||
id: string;
|
||||
input_chars: number;
|
||||
label: string;
|
||||
/** 上一版按单个账号、单个来源建的任务 */
|
||||
legacy: boolean;
|
||||
model_calls: number;
|
||||
model_filtered: number;
|
||||
options: Partial<KnowledgeWashOptions> & { date_from?: string; date_to?: string; model_name?: string };
|
||||
output_chars: number;
|
||||
pending_items: number;
|
||||
processed: number;
|
||||
progress_percent: null | number;
|
||||
skip_reasons: Record<string, number>;
|
||||
skipped: number;
|
||||
status: string;
|
||||
total_messages: null | number;
|
||||
updated_at: string;
|
||||
updated_items: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeAutoSummary {
|
||||
created_24h: number;
|
||||
failed_24h: number;
|
||||
jobs_24h: number;
|
||||
waiting_conversations: number;
|
||||
watching_sources: number;
|
||||
}
|
||||
|
||||
export interface KnowledgeRunPreview {
|
||||
accounts: { account_id: number; conversations: number; label: string; messages: number; tenant_id: string; text_messages: number }[];
|
||||
empty_accounts: number;
|
||||
estimated_model_calls: number;
|
||||
scope_label: string;
|
||||
totals: { conversations: number; messages: number; text_messages: number };
|
||||
}
|
||||
|
||||
export interface KnowledgeConfig {
|
||||
default_engine: 'model' | 'rules';
|
||||
default_model_provider_id: string;
|
||||
incremental_engine: 'model' | 'rules';
|
||||
model_batch: number;
|
||||
model_concurrency: number;
|
||||
model_filter: boolean;
|
||||
}
|
||||
|
||||
export interface KnowledgeModelTest {
|
||||
draft?: { answer: string; category: string; conditions: string; kind: string; question: string; title: string };
|
||||
error?: string;
|
||||
latency_ms: number;
|
||||
model_name: string;
|
||||
ok: boolean;
|
||||
sample?: { answer: string; question: string };
|
||||
}
|
||||
|
||||
export interface KnowledgeRunScope { account_ids: number[]; date_from: string; date_to: string }
|
||||
|
||||
export const previewKnowledgeRun = (scope: KnowledgeRunScope) =>
|
||||
requestClient.post<KnowledgeRunPreview>('/knowledge/runs/preview', scope, { timeout: 120000 });
|
||||
export const createKnowledgeRun = (body: KnowledgeRunScope & KnowledgeWashOptions & { auto_watch: boolean; staff_confirmed: boolean }) =>
|
||||
requestClient.post<KnowledgeRun>('/knowledge/runs', body, { timeout: 120000 });
|
||||
export const fetchKnowledgeRuns = (signal?: AbortSignal) =>
|
||||
requestClient.get<{ auto: KnowledgeAutoSummary; items: KnowledgeRun[] }>('/knowledge/runs', { signal });
|
||||
export const knowledgeRunAction = (runId: string, action: 'cancel' | 'pause' | 'resume') =>
|
||||
requestClient.post<KnowledgeRun>(`/knowledge/runs/${encodeURIComponent(runId)}/${action}`);
|
||||
export const configureKnowledgeRun = (runId: string, body: KnowledgeWashOptions) =>
|
||||
requestClient.put<KnowledgeRun>(`/knowledge/runs/${encodeURIComponent(runId)}/config`, body);
|
||||
export const fetchKnowledgeConfig = () => requestClient.get<KnowledgeConfig>('/knowledge/config');
|
||||
export const saveKnowledgeConfig = (body: KnowledgeConfig) => requestClient.put<KnowledgeConfig>('/knowledge/config', body);
|
||||
export const testKnowledgeModel = (modelProviderId: string, timeout = 90) =>
|
||||
requestClient.post<KnowledgeModelTest>('/knowledge/models/test',
|
||||
{ model_provider_id: modelProviderId, model_timeout_seconds: timeout }, { timeout: 200000 });
|
||||
|
||||
export interface KnowledgeReviewJob {
|
||||
id: string; account_id: number; scope_label: string; status: string; total: number; processed: number;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { refreshTokenApi } from './core';
|
||||
@@ -71,7 +72,7 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
*/
|
||||
async function doRefreshToken(): Promise<string> {
|
||||
await refreshTokenApi();
|
||||
throw new Error('后端未启用令牌续期');
|
||||
throw new Error(t('后端未启用令牌续期'));
|
||||
}
|
||||
|
||||
function formatToken(token: null | string) {
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { createApp, watchEffect } from 'vue';
|
||||
import { createApp, watch, watchEffect } from 'vue';
|
||||
|
||||
import { registerAccessDirective } from '@vben/access';
|
||||
import { registerLoadingDirective } from '@vben/common-ui/es/loading';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { preferences, updatePreferences } from '@vben/preferences';
|
||||
import { initStores } from '@vben/stores';
|
||||
import '@vben/styles';
|
||||
import '@vben/styles/antd';
|
||||
import './styles/layout-fixes.css';
|
||||
|
||||
import { useTitle } from '@vueuse/core';
|
||||
|
||||
import { $t, setupI18n } from '#/locales';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { initComponentAdapter } from './adapter/component';
|
||||
import { initSetupVbenForm } from './adapter/form';
|
||||
@@ -60,6 +62,22 @@ async function bootstrap(namespace: string) {
|
||||
const { MotionPlugin } = await import('@vben/plugins/motion');
|
||||
app.use(MotionPlugin);
|
||||
|
||||
// 顶栏 Logo、登录页、浏览器标题里的产品名和页脚公司名都直接读偏好设置,框架不翻译它们:
|
||||
// 语言一切换就把这两个值换成对应语言的写法(.env 里的 VITE_APP_TITLE 是中文原文)。
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE;
|
||||
const companyName = '真羊'; // i18n-ignore 中文原文,英文见 locales/en/menu.ts
|
||||
watch(
|
||||
() => preferences.app.locale,
|
||||
() => {
|
||||
const name = t(appTitle);
|
||||
const company = t(companyName);
|
||||
if (preferences.app.name !== name || preferences.copyright.companyName !== company) {
|
||||
updatePreferences({ app: { name }, copyright: { companyName: company } });
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 动态更新标题
|
||||
watchEffect(() => {
|
||||
if (preferences.app.dynamicTitle) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { onMounted, ref } from 'vue';
|
||||
import { Select } from 'ant-design-vue';
|
||||
|
||||
import { ALL_ACCOUNTS, fetchDesktopAccounts } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const props = defineProps<{ modelValue: number; disabled?: boolean }>();
|
||||
const emit = defineEmits<{
|
||||
@@ -62,10 +63,10 @@ onMounted(async () => {
|
||||
class="min-w-[240px]"
|
||||
@change="(value) => choose(Number(value))"
|
||||
>
|
||||
<Select.Option :value="ALL_ACCOUNTS">全部账号</Select.Option>
|
||||
<Select.Option v-if="canViewLegacy" :value="0">旧版未归属数据</Select.Option>
|
||||
<Select.Option :value="ALL_ACCOUNTS">{{ t('全部账号') }}</Select.Option>
|
||||
<Select.Option v-if="canViewLegacy" :value="0">{{ t('旧版未归属数据') }}</Select.Option>
|
||||
<Select.Option v-for="item in accounts" :key="item.id" :value="item.id">
|
||||
{{ item.nickname || item.zyt_sn }}({{ item.zyt_sn }})
|
||||
{{ t('{name}({code})', { code: item.zyt_sn, name: item.nickname || item.zyt_sn }) }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
@@ -1,89 +1,26 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
|
||||
import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
|
||||
import { useWatermark } from '@vben/hooks';
|
||||
import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
LockScreen,
|
||||
Notification,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { BasicLayout, LockScreen, UserDropdown } from '@vben/layouts';
|
||||
import { preferences, usePreferences } from '@vben/preferences';
|
||||
import { useAccessStore, useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import { t } from '#/locales/t';
|
||||
import { useAuthStore } from '#/store';
|
||||
import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
|
||||
const notifications = ref<NotificationItem[]>([
|
||||
{
|
||||
id: 1,
|
||||
avatar: 'https://avatar.vercel.sh/vercel.svg?text=VB',
|
||||
date: '3小时前',
|
||||
isRead: true,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '收到了 14 份新周报',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '刚刚',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '朱偏右 回复了你',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
avatar: 'https://avatar.vercel.sh/1',
|
||||
date: '2024-01-01',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '曲丽丽 评论了你',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '代办提醒',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转Workspace示例',
|
||||
link: '/workspace',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
avatar: 'https://avatar.vercel.sh/satori',
|
||||
date: '1天前',
|
||||
isRead: false,
|
||||
message: '描述信息描述信息描述信息',
|
||||
title: '跳转外部链接示例',
|
||||
link: 'https://doc.vben.pro',
|
||||
},
|
||||
]);
|
||||
|
||||
// 模板自带的「文档 / GitHub / 问题 & 帮助」(指向 vben 官网)、假邮箱、Pro 标签和一铃铛假通知都去掉了:
|
||||
// 这套后台没有通知功能,下拉里只留个人中心和退出登录。
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { isDark } = usePreferences();
|
||||
const showDot = computed(() =>
|
||||
notifications.value.some((item) => !item.isRead),
|
||||
);
|
||||
|
||||
const menus = computed(() => [
|
||||
{
|
||||
@@ -93,89 +30,21 @@ const menus = computed(() => [
|
||||
icon: 'lucide:user',
|
||||
text: $t('page.auth.profile'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_DOC_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: BookOpenText,
|
||||
text: $t('ui.widgets.document'),
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(VBEN_GITHUB_URL, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: SvgGithubIcon,
|
||||
text: 'GitHub',
|
||||
},
|
||||
{
|
||||
handler: () => {
|
||||
openWindow(`${VBEN_GITHUB_URL}/issues`, {
|
||||
target: '_blank',
|
||||
});
|
||||
},
|
||||
icon: CircleHelp,
|
||||
text: $t('ui.widgets.qa'),
|
||||
},
|
||||
]);
|
||||
|
||||
const avatar = computed(() => {
|
||||
return userStore.userInfo?.avatar ?? preferences.app.defaultAvatar;
|
||||
});
|
||||
|
||||
/** 下拉里用户名下面那行:当前角色(userInfo.desc 是 /me 返回的角色编码)。 */
|
||||
const roleText = computed(() =>
|
||||
userStore.userInfo?.desc ? t('角色:{role}', { role: userStore.userInfo.desc }) : '',
|
||||
);
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout(false);
|
||||
}
|
||||
|
||||
function handleNoticeClear() {
|
||||
notifications.value = [];
|
||||
}
|
||||
|
||||
function markRead(id: number | string) {
|
||||
const item = notifications.value.find((item) => item.id === id);
|
||||
if (item) {
|
||||
item.isRead = true;
|
||||
}
|
||||
}
|
||||
|
||||
function remove(id: number | string) {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
function handleMakeAll() {
|
||||
notifications.value.forEach((item) => (item.isRead = true));
|
||||
}
|
||||
|
||||
const viewAll = () => {};
|
||||
|
||||
const handleClick = (item: NotificationItem) => {
|
||||
// 如果通知项有链接,点击时跳转
|
||||
if (item.link) {
|
||||
navigateTo(item.link, item.query, item.state);
|
||||
}
|
||||
};
|
||||
|
||||
function navigateTo(
|
||||
link: string,
|
||||
query?: Record<string, any>,
|
||||
state?: Record<string, any>,
|
||||
) {
|
||||
if (link.startsWith('http://') || link.startsWith('https://')) {
|
||||
// 外部链接,在新标签页打开
|
||||
window.open(link, '_blank');
|
||||
} else {
|
||||
// 内部路由链接,支持 query 参数和 state
|
||||
router.push({
|
||||
path: link,
|
||||
query: query || {},
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
enable: preferences.app.watermark,
|
||||
@@ -228,24 +97,11 @@ watch(
|
||||
:avatar
|
||||
:menus
|
||||
:text="userStore.userInfo?.realName"
|
||||
description="ann.vben@gmail.com"
|
||||
tag-text="Pro"
|
||||
:description="roleText"
|
||||
@clear-preferences-and-logout="handleLogout"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
</template>
|
||||
<template #notification>
|
||||
<Notification
|
||||
:dot="showDot"
|
||||
:notifications="notifications"
|
||||
@clear="handleNoticeClear"
|
||||
@read="(item) => item.id && markRead(item.id)"
|
||||
@remove="(item) => item.id && remove(item.id)"
|
||||
@make-all="handleMakeAll"
|
||||
@on-click="handleClick"
|
||||
@view-all="viewAll"
|
||||
/>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
v-model:open="accessStore.loginExpired"
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* views/archive/*.vue(overview、conversations、exports、people、storage)、
|
||||
* components/archive-account-selector.vue、api/console.ts、api/error-message.ts、
|
||||
* api/request.ts、api/core/auth.ts、router/access.ts
|
||||
*/
|
||||
export default {
|
||||
// components/archive-account-selector.vue
|
||||
'全部账号': 'All accounts',
|
||||
'旧版未归属数据': 'Legacy unassigned data',
|
||||
'{name}({code})': '{name} ({code})',
|
||||
|
||||
// views/archive/overview.vue
|
||||
'聊天归档': 'Chat Archive',
|
||||
'企业微信消息、人员、会话与 COS 素材的统一数据视图': 'A unified view of WeCom messages, people, conversations and COS media',
|
||||
'素材本体不进入数据库': 'Media files are not stored in the database',
|
||||
'图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。':
|
||||
'Images, voice, videos and files are stored in Tencent Cloud COS; the database keeps only the object key, version, SHA-256, CRC64 and verification status.',
|
||||
'归档消息': 'Archived messages',
|
||||
'会话': 'Conversations',
|
||||
'唯一人员': 'Unique people',
|
||||
'COS 素材': 'COS media',
|
||||
'素材完整性': 'Media integrity',
|
||||
'已校验': 'Verified',
|
||||
'校验失败': 'Verification failed',
|
||||
'处理任务': 'Processing jobs',
|
||||
'导入批次': 'Import batches',
|
||||
'导出任务': 'Export jobs',
|
||||
'最新水位': 'Latest watermark',
|
||||
'最后一条消息时间(UTC)': 'Last message time (UTC)',
|
||||
'尚未导入': 'Not imported yet',
|
||||
|
||||
// views/archive/conversations.vue
|
||||
'会话与消息': 'Conversations',
|
||||
'每页显示 50 个会话;打开会话后按时间顺序分页查看消息。':
|
||||
'Shows 50 conversations per page; open a conversation to page through its messages in time order.',
|
||||
'类型': 'Type',
|
||||
'归档账号': 'Archive account',
|
||||
'消息数': 'Messages',
|
||||
'最后消息时间': 'Last message time',
|
||||
'上一页': 'Previous',
|
||||
'下一页': 'Next',
|
||||
'第 {page} 页': 'Page {page}',
|
||||
'第 {page} 页 · 每页 100 条': 'Page {page} · 100 per page',
|
||||
'暂无消息': 'No messages',
|
||||
'未知大小': 'Unknown size',
|
||||
'未知发送者': 'Unknown sender',
|
||||
'{count} 个素材': '{count} media files',
|
||||
'(非文本消息)': '(Non-text message)',
|
||||
'未命名素材': 'Unnamed media',
|
||||
'下载附件': 'Download attachment',
|
||||
'查看原文件': 'View original file',
|
||||
'源文件未缓存,请先在企业微信中下载': 'Source file not cached. Download it in WeCom first',
|
||||
'上传失败,下次启动将自动重试': 'Upload failed; it will retry automatically on next launch',
|
||||
// 消息来源
|
||||
'AI 发送': 'Sent by AI',
|
||||
'人工发送': 'Sent by staff',
|
||||
'系统发送': 'Sent by system',
|
||||
'客户发送': 'Sent by customer',
|
||||
'来源未知': 'Unknown source',
|
||||
// 会话类型
|
||||
'应用会话': 'App conversation',
|
||||
'微信单聊': 'WeChat direct chat',
|
||||
'企微单聊': 'WeCom direct chat',
|
||||
'群聊': 'Group chat',
|
||||
'客服会话': 'Customer service conversation',
|
||||
'未知': 'Unknown',
|
||||
// 消息类型:后端 wxwork_db.MSG_TYPE_MAP / archive_store 写入的固定名称
|
||||
'文本': 'Text',
|
||||
'图片': 'Image',
|
||||
'语音': 'Voice',
|
||||
'表情': 'Sticker',
|
||||
'链接': 'Link',
|
||||
'文件': 'File',
|
||||
'视频': 'Video',
|
||||
'位置': 'Location',
|
||||
'名片': 'Contact card',
|
||||
'系统': 'System',
|
||||
'引用': 'Quote',
|
||||
'红包': 'Red packet',
|
||||
'转账': 'Transfer',
|
||||
'视频号': 'Channels',
|
||||
'合并转发': 'Chat history',
|
||||
'日程': 'Schedule',
|
||||
'接龙': 'Sign-up list',
|
||||
'文件回复': 'File reply',
|
||||
'位置共享': 'Location sharing',
|
||||
'视频通话': 'Video call',
|
||||
'图文链接': 'Rich link',
|
||||
'系统通知': 'System notice',
|
||||
'待办': 'To-do',
|
||||
'文件预览': 'File preview',
|
||||
'话题': 'Topic',
|
||||
'图文': 'Rich text',
|
||||
'收藏': 'Favorite',
|
||||
'订阅通知': 'Subscription notice',
|
||||
'文件分享': 'File share',
|
||||
'群公告': 'Group announcement',
|
||||
'投票': 'Poll',
|
||||
'回执': 'Receipt',
|
||||
'文件编辑': 'File edit',
|
||||
'文件评论': 'File comment',
|
||||
'收藏合并': 'Merged favorites',
|
||||
'卡片': 'Card',
|
||||
'群文件': 'Group file',
|
||||
'工作台': 'Workbench',
|
||||
'音频通话': 'Voice call',
|
||||
'企业微信应用': 'WeCom app',
|
||||
'微信好友': 'WeChat friend',
|
||||
'回复': 'Reply',
|
||||
'邀请': 'Invite',
|
||||
'移除': 'Remove',
|
||||
'修改群名': 'Group renamed',
|
||||
'修改群公告': 'Group announcement changed',
|
||||
'加入群聊': 'Joined group',
|
||||
'退出群聊': 'Left group',
|
||||
'解散群聊': 'Group disbanded',
|
||||
'群主转让': 'Group owner transferred',
|
||||
'截图': 'Screenshot',
|
||||
'应用消息': 'App message',
|
||||
'朋友圈': 'Moments',
|
||||
'安全通知': 'Security notice',
|
||||
'系统消息': 'System message',
|
||||
'小程序': 'Mini program',
|
||||
'未知消息': 'Unknown message',
|
||||
|
||||
// views/archive/exports.vue
|
||||
'新建导出': 'New export',
|
||||
'格式': 'Format',
|
||||
'开始日期': 'Start date',
|
||||
'结束日期': 'End date',
|
||||
'开始导出': 'Start export',
|
||||
'导出按创建时的截止水位生成;SQL/CSV 流式写出,Excel 超过 90 万行自动拆分工作表。':
|
||||
'Exports cover data up to the watermark at creation time; SQL/CSV are streamed, and Excel is split into extra sheets beyond 900,000 rows.',
|
||||
'进度/行数': 'Progress / rows',
|
||||
'{count} 行': '{count} rows',
|
||||
'没有下载导出文件的权限': 'You do not have permission to download export files',
|
||||
'导出要先在右上角选择具体的客户端账号': 'Select a specific client account at the top right before exporting',
|
||||
'至少选择一种导出格式': 'Select at least one export format',
|
||||
'导出任务已创建': 'Export job created',
|
||||
'已完成': 'Completed',
|
||||
'失败': 'Failed',
|
||||
'等待中': 'Queued',
|
||||
'处理中': 'Processing',
|
||||
|
||||
// views/archive/people.vue
|
||||
'人员唯一标识': 'Person Identities',
|
||||
'姓名': 'Name',
|
||||
'同一个人可绑定本地 UID、企业微信 userid、微信 external_userid 等多个身份;唯一键由“身份类型 + 企业范围 + 外部 ID”组成。':
|
||||
'One person can bind several identities, such as a local UID, WeCom userid and WeChat external_userid; the unique key is "identity type + corp scope + external ID".',
|
||||
'显示名称': 'Display name',
|
||||
'真实姓名': 'Real name',
|
||||
'已绑定身份': 'Bound identities',
|
||||
'关联患者': 'Linked patient',
|
||||
'会话数': 'Conversations',
|
||||
'管理关联': 'Manage links',
|
||||
'患者 {id}': 'Patient {id}',
|
||||
'未关联': 'Not linked',
|
||||
'人员关联 · {name}': 'Person links · {name}',
|
||||
'关联 ZYT 患者': 'Link ZYT patient',
|
||||
'患者': 'Patient',
|
||||
'手机号': 'Phone',
|
||||
'关系': 'Relation',
|
||||
'患者ID': 'Patient ID',
|
||||
'患者ID:{id}': 'Patient ID: {id}',
|
||||
'默认': 'Default',
|
||||
'关联': 'Link',
|
||||
'确认解除这个患者关联?': 'Unlink this patient?',
|
||||
'解绑': 'Unlink',
|
||||
'输入患者姓名、手机号或患者编号': 'Enter a patient name, phone number or patient number',
|
||||
'从 ZYT 只读查询患者,关联关系仅保存在客服后台,不会修改 ZYT。':
|
||||
'Patients are looked up read-only in ZYT; links are saved only in the customer service backend and ZYT is not modified.',
|
||||
'企业/范围': 'Corp / scope',
|
||||
'外部 ID': 'External ID',
|
||||
'已确认': 'Confirmed',
|
||||
'未确认': 'Unconfirmed',
|
||||
'绑定新标识': 'Bind new identity',
|
||||
'身份类型': 'Identity type',
|
||||
'企业微信 userid': 'WeCom userid',
|
||||
'微信 external_userid': 'WeChat external_userid',
|
||||
'本地数据库 UID': 'Local database UID',
|
||||
'企微 open_userid': 'WeCom open_userid',
|
||||
'企业/范围 ID': 'Corp / scope ID',
|
||||
'建议填写 corp_id;本地 UID 可填账号 ID': 'corp_id recommended; for a local UID you can use the account ID',
|
||||
'外部人员 ID': 'External person ID',
|
||||
'确认身份': 'Verified identity',
|
||||
'确认绑定': 'Confirm binding',
|
||||
// 关系
|
||||
'本人': 'Self',
|
||||
'家属': 'Family',
|
||||
'监护人': 'Guardian',
|
||||
'代咨询': 'Proxy',
|
||||
'其他': 'Other',
|
||||
'这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属':
|
||||
'Select a specific client account at the top right first; under "All" the owner cannot be determined',
|
||||
'请输入企业微信人员 ID': 'Enter the WeCom person ID',
|
||||
'人员标识已绑定': 'Identity bound',
|
||||
'请先选择已经登录过桌面软件的客户端账号': 'Select a client account that has signed in to the desktop app first',
|
||||
'请输入患者姓名、手机号或患者编号': 'Enter a patient name, phone number or patient number',
|
||||
'ZYT 中未找到匹配患者': 'No matching patient found in ZYT',
|
||||
'患者关联已保存到客服后台': 'Patient link saved to the customer service backend',
|
||||
'患者关联已解除': 'Patient unlinked',
|
||||
|
||||
// views/archive/storage.vue
|
||||
'请使用最小权限的独立 COS 子账号密钥': 'Use a dedicated least-privilege COS sub-account key',
|
||||
'SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。':
|
||||
'SecretId / SecretKey are only written to the backend and stored encrypted; the page never shows them in plain text. Leave blank to keep the current key. Replace any key that has appeared in a screenshot before enabling.',
|
||||
'腾讯云 COS': 'Tencent Cloud COS',
|
||||
'启用存储': 'Enable storage',
|
||||
'请输入 SecretId': 'Enter SecretId',
|
||||
'请输入 SecretKey': 'Enter SecretKey',
|
||||
'素材路径前缀': 'Media path prefix',
|
||||
'导出路径前缀': 'Export path prefix',
|
||||
'安装包路径前缀': 'Installer path prefix',
|
||||
'服务端加密': 'Server-side encryption',
|
||||
'SSE-COS(AES256)': 'SSE-COS (AES256)',
|
||||
'不指定': 'Not specified',
|
||||
'自定义域名': 'Custom domain',
|
||||
'可选,仅允许完整 HTTPS 域名': 'Optional; full HTTPS domain only',
|
||||
'保存配置': 'Save settings',
|
||||
'测试连接': 'Test connection',
|
||||
'最近素材对象': 'Recent media objects',
|
||||
'大小': 'Size',
|
||||
'校验时间': 'Verified at',
|
||||
'COS 配置已保存': 'COS settings saved',
|
||||
'连接成功:{bucket} / {region}': 'Connected: {bucket} / {region}',
|
||||
|
||||
// api/console.ts(安装包上传)
|
||||
'无法连接腾讯 COS,请检查网络和 Bucket CORS 配置': 'Cannot reach Tencent COS. Check the network and the bucket CORS settings',
|
||||
'上传腾讯 COS 超时,请重试': 'Upload to Tencent COS timed out. Please try again',
|
||||
'腾讯 COS 上传失败(HTTP {status})': 'Tencent COS upload failed (HTTP {status})',
|
||||
'COS 分块上传参数不完整': 'COS multipart upload parameters are incomplete',
|
||||
'COS 未返回 ETag,请在 Bucket CORS 中暴露 ETag 响应头': 'COS returned no ETag. Expose the ETag response header in the bucket CORS settings',
|
||||
'升级包必须是 Windows EXE 安装包': 'The update package must be a Windows EXE installer',
|
||||
'安装包大小必须大于 0 且不能超过 1GB': 'The installer must be larger than 0 and no more than 1 GB',
|
||||
'COS 预上传接口没有返回上传地址': 'The COS pre-upload API returned no upload URL',
|
||||
|
||||
// api/request.ts、api/core/auth.ts
|
||||
'后端未启用令牌续期': 'Token renewal is not enabled on the backend',
|
||||
'后端未启用令牌续期,请重新登录': 'Token renewal is not enabled on the backend. Please sign in again',
|
||||
|
||||
// api/error-message.ts(FastAPI 校验错误)
|
||||
';': '; ',
|
||||
'检索问题': 'Search query',
|
||||
'请求参数': 'Request parameter',
|
||||
'{field}至少需要 {min} 个字符': '{field} must be at least {min} characters',
|
||||
'{field}不能超过 {max} 个字符': '{field} cannot exceed {max} characters',
|
||||
'请填写{field}': '{field} is required',
|
||||
'{field}必须是文本': '{field} must be text',
|
||||
'{field}:{msg}': '{field}: {msg}',
|
||||
'{field}格式不正确': '{field} has an invalid format',
|
||||
'请求失败,请稍后重试': 'Request failed. Please try again later',
|
||||
|
||||
// router/access.ts
|
||||
'自定义菜单加载失败,只显示内置菜单': 'Failed to load custom menus; showing built-in menus only',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,27 @@
|
||||
/** 多个页面共用的词:内置角色名、状态、通用按钮和提示。 */
|
||||
export default {
|
||||
// 内置角色(名称来自后端,页面上用 t(role.name) 翻译)
|
||||
'管理员': 'Administrator',
|
||||
'配置员': 'Operator',
|
||||
'只读用户': 'Read-only',
|
||||
'{name}({code})': '{name} ({code})',
|
||||
'已启用': 'Enabled',
|
||||
'已停用': 'Disabled',
|
||||
'状态': 'Status',
|
||||
'操作': 'Actions',
|
||||
'角色': 'Role',
|
||||
'用户名': 'Username',
|
||||
'创建时间': 'Created',
|
||||
'更新时间': 'Updated',
|
||||
'刷新': 'Refresh',
|
||||
'保存': 'Save',
|
||||
'取消': 'Cancel',
|
||||
'编辑': 'Edit',
|
||||
'删除': 'Delete',
|
||||
'查看': 'View',
|
||||
'关闭': 'Close',
|
||||
'确定': 'OK',
|
||||
'已保存': 'Saved',
|
||||
'已删除': 'Deleted',
|
||||
'操作成功': 'Done',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* views/console/desktop-config.vue、release.vue、desktop-accounts.vue、audit.vue、change-password.vue
|
||||
* (客户端配置、版本升级、客户端账号、审计日志、修改密码)。
|
||||
* 「客户端账号」「审计日志」「修改密码」等卡片标题与菜单同名,译文在 menu.ts;
|
||||
* 「状态」「操作」「刷新」「删除」「更新时间」在 common.ts。
|
||||
*/
|
||||
export default {
|
||||
// ---- desktop-config.vue:能力开关
|
||||
'启用 AI 自动回复': 'Enable AI auto-reply',
|
||||
'总开关。关掉之后客户端只监听不回复。': 'Master switch. When off, desktop clients only listen and never reply.',
|
||||
'启用视觉识别': 'Enable vision recognition',
|
||||
'图片和表情包走视觉模型识别,OCR 认不出时兜底。用哪个模型看「角色编排」里的媒体消息专用模型。':
|
||||
'Images and stickers are recognized by a vision model, as a fallback when OCR fails. The model used is the media-message model in "Role Orchestration".',
|
||||
'人工操作保护': 'Manual operation guard',
|
||||
'检测到有人在操作鼠标键盘时暂停自动化,避免抢焦点。': 'Pauses automation while someone is using the mouse or keyboard, so it does not steal focus.',
|
||||
'携带上下文': 'Include context',
|
||||
'把最近几轮对话一起发给模型。关掉会省 token,但答复会失忆。':
|
||||
'Sends the last few rounds of conversation to the model. Turning it off saves tokens, but replies lose their memory.',
|
||||
'应对辱骂': 'Handle abuse',
|
||||
'客户情绪激动时用专门的话术回应,而不是照常答业务问题。':
|
||||
'When a customer gets agitated, respond with dedicated scripts instead of answering business questions as usual.',
|
||||
'启用 MCP 工具': 'Enable MCP tools',
|
||||
'允许模型调用挂号登记、客户查询等外部工具。工具在客户端本机执行。':
|
||||
'Lets the model call external tools such as appointment registration and customer lookup. Tools run locally on the desktop client.',
|
||||
'开发模式': 'Development mode',
|
||||
'打印详细日志、跳过部分节流。生产环境请关闭。': 'Prints verbose logs and skips some throttling. Turn it off in production.',
|
||||
|
||||
// ---- desktop-config.vue:校验与提示
|
||||
'第 {index} 条规则没有填名称': 'Rule {index} has no name',
|
||||
'规则「{label}」至少要有一个关键词,否则永远不会命中': 'Rule "{label}" needs at least one keyword, otherwise it will never match',
|
||||
'JSON 根节点必须是数组': 'The JSON root must be an array',
|
||||
'不是有效的 JSON:{error}': 'Invalid JSON: {error}',
|
||||
'MCP 服务器配置不是有效 JSON,请先修正': 'The MCP server configuration is not valid JSON. Fix it first',
|
||||
'已发布为 v{version},桌面端下次同步时生效': 'Published as v{version}; desktop clients pick it up at their next sync',
|
||||
|
||||
// ---- desktop-config.vue:页面
|
||||
'这份配置会下发到每一台桌面客户端': 'This configuration is pushed to every desktop client',
|
||||
'保存即产生新版本号,客户端下次同步就会拿到。改错一个字段是全线生效的,不是只影响你自己这台。':
|
||||
'Saving creates a new version, which desktop clients receive at their next sync. A wrong field affects every client, not just your own machine.',
|
||||
'模型配置不在这里': 'Model settings are not here',
|
||||
'服务类型、API 地址、API Key、模型名称、温度、最大 tokens、请求超时——这些以前在本页的设置,现在统一在':
|
||||
'Service type, API URL, API key, model name, temperature, max tokens and request timeout used to be set on this page. They now all live in',
|
||||
'。': '.',
|
||||
// 后端 model_settings_moved_to 返回的固定文案,前端兜底也用它
|
||||
'AI 模型 → 模型清单 / 角色编排': 'AI Models → Model List / Role Orchestration',
|
||||
'搬走有三个原因:同一件事两个地方能配迟早会不一致;密钥留在这份配置里就必须明文下发到每一台客户端,而模型清单里是加密存储、只返遮罩值;一个客户只能配一个模型的话,双模型并发和裁判根本无从谈起。':
|
||||
'They moved for three reasons: configuring the same thing in two places drifts apart sooner or later; keys kept in this configuration would have to be pushed in plain text to every desktop client, whereas the model list stores them encrypted and only returns masked values; and with only one model per client, running two models in parallel or using a judge would be impossible.',
|
||||
'去模型清单': 'Go to Model List',
|
||||
'去角色编排': 'Go to Role Orchestration',
|
||||
'当前生效的配置': 'Active configuration',
|
||||
'版本': 'Version',
|
||||
'最后修改': 'Last modified by',
|
||||
'时间': 'Time',
|
||||
'桌面账号登录': 'Desktop account sign-in',
|
||||
'软件打开登录窗口后,会先请求本管理端的公开客户端配置,再按这里返回的地址请求 ZYT 登录。换登录域名只需修改这一处,无需重新修改或打包桌面软件。':
|
||||
'When the desktop client opens its sign-in window, it first requests the public client configuration from this admin site, then signs in to ZYT at the address returned here. To change the sign-in domain, edit it here only; there is no need to modify or rebuild the desktop client.',
|
||||
'ZYT 登录 API 域名(留空 = 使用管理 API 启动配置)': 'ZYT sign-in API domain (empty = use the admin API startup setting)',
|
||||
'当前客户端实际获取:': 'Clients currently receive: ',
|
||||
// 「填写完整的 http:// 或 https:// 基础地址,不要填写 /api/login/account;客户端会自动拼接登录接口路径。」按 <code> 拆成几段
|
||||
'填写完整的': 'Enter the full',
|
||||
'或': 'or',
|
||||
'基础地址,不要填写': 'base URL; do not include',
|
||||
';客户端会自动拼接登录接口路径。': '; the client appends the sign-in path automatically.',
|
||||
'能力开关': 'Feature switches',
|
||||
'企业微信数据库自动备份': 'WeCom database automatic backup',
|
||||
'软件启动后等待设定时间执行第一次增量备份,之后按相同间隔持续运行。只上传服务端检查点之后的新消息和新媒体,修改配置后客户端会自动同步,无需重启。':
|
||||
'After the desktop client starts, it waits the set time before the first incremental backup, then keeps running at the same interval. Only messages and media newer than the server checkpoint are uploaded. Configuration changes sync to clients automatically, no restart needed.',
|
||||
'自动增量备份': 'Automatic incremental backup',
|
||||
'启动等待及备份间隔(分钟)': 'Startup delay and backup interval (minutes)',
|
||||
'范围 1 分钟至 7 天。': 'Range: 1 minute to 7 days.',
|
||||
'客服人格': 'Agent persona',
|
||||
'这几项会拼进系统提示词,决定客户看到的是"谁"在说话。它们不是模型参数,所以留在这里。':
|
||||
'These go into the system prompt and decide "who" the customer is talking to. They are not model parameters, so they stay here.',
|
||||
'客服名称': 'Agent name',
|
||||
'机构名称': 'Organization name',
|
||||
'上下文轮数(1–50)': 'Context rounds (1–50)',
|
||||
'记几轮对话。这是客户端行为,和"用哪个模型"无关。':
|
||||
'How many rounds of conversation to remember. This is desktop client behavior and has nothing to do with "which model".',
|
||||
'模型网关地址': 'Model gateway URL',
|
||||
'桌面客户端只配一个后台地址,模型请求发到哪儿由这里决定、随配置一起下发。换网关位置只需要改这一处,不用挨个动客户端。':
|
||||
'Desktop clients are configured with a single backend address; where model requests go is decided here and pushed with the configuration. To move the gateway, change it here only, without touching each client.',
|
||||
'地址(留空 = 按后台自己的地址自动推算)': 'URL (empty = derived from the backend address)',
|
||||
'留空即可,绝大多数情况不用填': 'Leave empty; almost never needed',
|
||||
'当前实际下发:': 'Currently pushed: ',
|
||||
// 「只有网关在别的域名或别的机器上时才需要填。写完整地址(https://…)或以 / 开头的路径(挂在本后台的域名下)。」按 <code> 拆成几段
|
||||
'只有网关在别的域名或别的机器上时才需要填。写完整地址(': 'Only needed when the gateway is on another domain or machine. Enter a full URL (',
|
||||
')或以': ') or a path starting with',
|
||||
'开头的路径(挂在本后台的域名下)。': "(served under this admin site's domain).",
|
||||
'审核后发送的审核范围': 'Review scope for "Send after review"',
|
||||
'只对切到「审核后发送」的客户端起作用;自动发送模式下只按下面的选择性审核规则拦截。':
|
||||
'Applies only to desktop clients switched to "Send after review"; in auto-send mode, only the selective review rules below hold replies back.',
|
||||
'每条都审核': 'Review every reply',
|
||||
'按原规则': 'Original rules',
|
||||
'按原规则(默认)': 'Original rules (default)',
|
||||
'每条 AI 回复都要人工确认后才发送;医疗助理人工回复过的会话也不会恢复自动回复。':
|
||||
'Every AI reply must be confirmed by a person before it is sent; conversations a medical assistant has replied to manually do not resume auto-reply.',
|
||||
'医疗助理用同一企微账号人工回复并核验后,该会话恢复自动回复,之后仍逐条过审核规则和模型裁判。':
|
||||
'After a medical assistant replies manually from the same WeCom account and it is verified, the conversation resumes auto-reply; each reply still goes through the review rules and the model judge.',
|
||||
'客户端默认跟随这里的设置,也可以在「自动化设置 → 审核后发送 → 审核范围」单独设置本机;1.5.3 起的桌面端生效,更早的版本保持按原规则。':
|
||||
'Desktop clients follow this setting by default; each machine can also override it under "Automation settings → Send after review → Review scope". Takes effect on desktop clients 1.5.3 and later; earlier versions keep the original rules.',
|
||||
'选择性审核规则': 'Selective review rules',
|
||||
'命中任意一条,那一条回复才会停下来贴进企业微信输入框等人工确认,不会自动按回车;没命中的照常自动发送。客户这句话和模型准备发的回复,沾上任意一边都算命中。':
|
||||
'Only a reply that matches a rule stops and is pasted into the WeCom input box for manual confirmation, without pressing Enter; everything else is sent automatically as usual. A match in either the customer message or the reply the model is about to send counts.',
|
||||
'还有一条内置规则不在这份清单里、关不掉': 'There is also a built-in rule that is not in this list and cannot be turned off',
|
||||
'模型裁判把某条回复判成「高风险」时,无论有没有命中下面的关键词,同样会转人工——前提是角色编排里配了裁判模型。1.5.2 之后的桌面端不论裁判模式都会拦;1.5.2 及更早的只在裁判模式为 score_only / arbitrate 时才拦,shadow 下照常自动发送。':
|
||||
'When the model judge rates a reply as "high risk", it is also handed to a person, whether or not it matches the keywords below, provided a judge model is configured in Role Orchestration. Desktop clients after 1.5.2 hold it back in every judge mode; 1.5.2 and earlier only do so when the judge mode is score_only / arbitrate, and send automatically as usual under shadow.',
|
||||
'关键词按包含匹配,忽略大小写、空格和标点(1.5.2 及更早的桌面端只忽略大小写);建议把客户的日常说法都填上,如 服用、怎么吃、用法、用量、饭前、饭后。':
|
||||
'Keywords match as substrings, ignoring case, spaces and punctuation (desktop clients 1.5.2 and earlier only ignore case). Add the everyday phrases customers use, e.g. take, how to take, usage, dosage, before meals, after meals.',
|
||||
'规则名称,例如「诊断」': 'Rule name, e.g. "Diagnosis"',
|
||||
'输入关键词后按回车;用逗号、顿号或空格隔开可一次粘贴多个': 'Type a keyword and press Enter; separate with commas or spaces to paste several at once',
|
||||
'新增规则': 'Add rule',
|
||||
'还没有配任何规则——除了上面那条内置的裁判高风险检查,其余全部自动发送。':
|
||||
'No rules yet. Apart from the built-in judge high-risk check above, everything is sent automatically.',
|
||||
'MCP 服务器': 'MCP servers',
|
||||
'单次最多调用工具轮数(1–20)': 'Max tool-call rounds per reply (1–20)',
|
||||
'服务器清单(JSON 数组)': 'Server list (JSON array)',
|
||||
'工具跑在客户端本机(要连内网),所以配置在这里而不是模型清单里。格式错误会当场拦下,不会等到桌面端同步之后才发现。':
|
||||
'Tools run locally on the desktop client (they need the intranet), so they are configured here rather than in the model list. Format errors are caught right away instead of after desktop clients sync.',
|
||||
'保存并发布新版本': 'Save and publish new version',
|
||||
'放弃修改': 'Discard changes',
|
||||
|
||||
// ---- release.vue:客户端上报的升级状态(前端固定映射)
|
||||
'发现更新': 'Update found',
|
||||
'下载中': 'Downloading',
|
||||
'待安装': 'Ready to install',
|
||||
'安装中': 'Installing',
|
||||
'已完成': 'Completed',
|
||||
'失败': 'Failed',
|
||||
'已推迟': 'Deferred',
|
||||
|
||||
// ---- release.vue
|
||||
'安装包已上传 COS,已生成下载直链并开启自动安装': 'Installer uploaded to COS; direct download link generated and auto-install turned on',
|
||||
'安装包上传失败': 'Installer upload failed',
|
||||
'版本号格式应为 1.0.0': 'The version must look like 1.0.0',
|
||||
'下载地址必须是完整的 HTTP/HTTPS 地址': 'The download URL must be a full HTTP/HTTPS address',
|
||||
'开启强制升级前必须上传安装包': 'Upload an installer before turning on forced upgrade',
|
||||
'自动安装必须先上传安装包并生成 SHA256': 'Auto-install requires an uploaded installer with a SHA256',
|
||||
'已发布 v{version}': 'Published v{version}',
|
||||
'账号/设备': 'Account / device',
|
||||
'进度': 'Progress',
|
||||
'客户端': 'Desktop client',
|
||||
'安装包直传腾讯 COS': 'Installers upload directly to Tencent COS',
|
||||
'浏览器先在本机计算 SHA256,再分块直传 COS;安装包不经过管理端服务器。上传完成后显示 COS 下载直链;客户端发现已发布的新版本后直接从 COS 下载,校验通过后自动安装。':
|
||||
'The browser computes the SHA256 locally, then uploads to COS in chunks; the installer never passes through the admin server. After upload, the COS direct download link is shown; desktop clients that find a newly published version download it straight from COS and install it automatically once verified.',
|
||||
'当前版本策略': 'Current release policy',
|
||||
'最新版本': 'Latest version',
|
||||
'发布状态': 'Release status',
|
||||
'发布中': 'Live',
|
||||
'已暂停': 'Paused',
|
||||
'升级方式': 'Upgrade mode',
|
||||
'强制升级': 'Forced upgrade',
|
||||
'自动升级': 'Automatic upgrade',
|
||||
'提示升级': 'Prompted upgrade',
|
||||
'发布渠道': 'Release channel',
|
||||
'灰度比例': 'Rollout percentage',
|
||||
'安装包': 'Installer',
|
||||
'未配置': 'Not set',
|
||||
'发布新版本': 'Publish a new version',
|
||||
'Windows 安装包': 'Windows installer',
|
||||
'正在计算 SHA256': 'Computing SHA256',
|
||||
'正在直传 COS': 'Uploading to COS',
|
||||
'选择并直传 COS': 'Choose and upload to COS',
|
||||
'已就绪 · {size}': 'Ready · {size}',
|
||||
'上传后自动填写下载地址、大小和 SHA256': 'Download URL, size and SHA256 are filled in after upload',
|
||||
'版本号': 'Version number',
|
||||
'格式应为 1.0.0': 'Format: 1.0.0',
|
||||
'正式版 stable': 'Stable',
|
||||
'测试版 beta': 'Beta',
|
||||
'下载地址(COS 直链)': 'Download URL (COS direct link)',
|
||||
'上传安装包后自动填写 COS HTTPS 地址,也可填写其他 HTTPS 地址': 'Filled with the COS HTTPS URL after upload; any other HTTPS URL also works',
|
||||
'当前直链有效期为 15 分钟。发布后客户端每次检查更新都会获取新地址,无需重新上传。':
|
||||
'The current direct link is valid for 15 minutes. Once published, desktop clients get a fresh link on every update check, so there is no need to upload again.',
|
||||
'上传安装包后自动计算': 'Computed automatically after upload',
|
||||
'立即发布': 'Publish now',
|
||||
'暂停分发': 'Pause distribution',
|
||||
'安全与安装策略': 'Security and install policy',
|
||||
'自动': 'Auto',
|
||||
'提示': 'Prompt',
|
||||
'强制': 'Forced',
|
||||
'非强制': 'Optional',
|
||||
'验签': 'Signature',
|
||||
'仅哈希': 'Hash only',
|
||||
'更新说明(不超过 4000 字)': 'Release notes (up to 4000 characters)',
|
||||
'发布版本': 'Publish version',
|
||||
'客户端升级进度': 'Desktop client upgrade progress',
|
||||
'最近发布记录': 'Recent releases',
|
||||
'暂无发布记录': 'No releases yet',
|
||||
'没有更新说明': 'No release notes',
|
||||
|
||||
// ---- desktop-accounts.vue
|
||||
'客户端账号策略已更新': 'Desktop client account policy updated',
|
||||
'该账号的桌面设备已全部下线': 'All desktop devices of this account have been signed out',
|
||||
'ZYT 账号': 'ZYT account',
|
||||
'数据': 'Data',
|
||||
'最后在线': 'Last online',
|
||||
'状态按客户端最近 20 秒的有效会话判断。同一 ZYT 账号只保留一个有效桌面会话,新设备登录会使旧设备下线。':
|
||||
'Status is based on valid desktop client sessions in the last 20 seconds. Each ZYT account keeps only one valid desktop session; signing in on a new device signs out the old one.',
|
||||
'未绑定手机': 'No phone linked',
|
||||
'暂无设备': 'No device',
|
||||
'未知版本': 'Unknown version',
|
||||
'{count} 台': '{count} devices',
|
||||
'{count} 个企微账号': '{count} WeCom accounts',
|
||||
'{count} 条消息': '{count} messages',
|
||||
'已禁用': 'Disabled',
|
||||
'在线': 'Online',
|
||||
'离线': 'Offline',
|
||||
'策略': 'Policy',
|
||||
'确定让该账号所有桌面设备立即下线?': 'Sign out all desktop devices of this account now?',
|
||||
'强制下线': 'Force sign-out',
|
||||
'客户端账号策略': 'Desktop client account policy',
|
||||
'账号状态': 'Account status',
|
||||
'正常': 'Active',
|
||||
'禁用并下线': 'Disable and sign out',
|
||||
'覆盖全局自动备份开关': 'Override the global automatic backup switch',
|
||||
'启用': 'Enable',
|
||||
'停用': 'Disable',
|
||||
'可查看该账号数据的管理端用户': "Admin users who can view this account's data",
|
||||
'超级管理员无需分配;其他用户按此范围查看': 'Super admins need no assignment; other users only see data within this scope',
|
||||
'覆盖全局备份间隔': 'Override the global backup interval',
|
||||
'分钟': 'min',
|
||||
|
||||
// ---- audit.vue
|
||||
'谁在什么时候改了什么——出事之后唯一能回溯的东西。只显示最近 200 条。':
|
||||
'Who changed what and when: the only way to trace back after an incident. Shows the latest 200 entries.',
|
||||
'操作人': 'User',
|
||||
'动作': 'Action',
|
||||
'详情': 'Details',
|
||||
'来源 IP': 'Source IP',
|
||||
'(未登录)': '(not signed in)',
|
||||
|
||||
// ---- change-password.vue
|
||||
'两次输入的新密码不一致': 'The two new passwords do not match',
|
||||
'密码已修改': 'Password changed',
|
||||
'首次登录必须修改初始密码': 'You must change the initial password at first sign-in',
|
||||
'初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。':
|
||||
'The initial password is a public default; keeping it is the same as having no password. Change it to access the other pages.',
|
||||
'当前密码': 'Current password',
|
||||
'新密码(至少 10 位,需同时含字母和数字)': 'New password (at least 10 characters, letters and digits)',
|
||||
'确认新密码': 'Confirm new password',
|
||||
'提交': 'Submit',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* views/knowledge/:center.vue、runs-panel.vue、review-all.vue、wash-options.vue、run-dialog.vue、
|
||||
* run-format.ts、batch-dialog.vue、batch.ts、settings-dialog.vue。
|
||||
* 「整理」(聊天记录 → 知识)统一译作 processing。
|
||||
*/
|
||||
export default {
|
||||
// ── 知识中心(center.vue) ──────────────────────────────────────────
|
||||
'知识中心': 'Knowledge',
|
||||
'把历史聊天整理成可追溯的知识,审核发布后供客服回答时参考。': 'Turn chat history into traceable knowledge. Once reviewed and published, it is used as a reference when answering customers.',
|
||||
'点「整理聊天记录」可一次整理全部账号的聊天;点知识标题可直接查看、审核和发布。': 'Click "Process chat history" to process the chats of all accounts at once; click a knowledge title to view, review and publish it.',
|
||||
'当前账号尚无聊天归档,请先上传归档或更换账号。': 'This account has no chat archive yet. Upload an archive first or switch accounts.',
|
||||
'点「整理聊天记录」生成草稿,再逐条核对来源和适用条件。': 'Click "Process chat history" to generate drafts, then check the source and conditions of each one.',
|
||||
'草稿补全适用条件后审核,通过后发布;也可以勾选列表中的多条知识集中处理。': 'Fill in the conditions of a draft, review it, and publish it once approved. You can also select several items in the list and handle them together.',
|
||||
'已发布知识可在检索测试中验证;确认结果后开启客服使用知识。': 'Verify published knowledge in Search test; once satisfied, turn on "Use knowledge in replies".',
|
||||
'整理聊天记录': 'Process chat history',
|
||||
'生成草稿': 'Generate drafts',
|
||||
'审核知识': 'Review knowledge',
|
||||
'发布并测试使用': 'Publish and test',
|
||||
'支持语义与关键词检索': 'Semantic and keyword search available',
|
||||
'关键词检索已就绪': 'Keyword search ready',
|
||||
'客服使用知识': 'Use knowledge in replies',
|
||||
'整理设置': 'Processing settings',
|
||||
'导出已发布知识': 'Export published knowledge',
|
||||
'当前汇总全部授权账号:整理聊天、查看和审核知识都可以直接进行;检索测试、导出和「客服使用知识」开关需选择具体账号。': 'Showing all authorized accounts: you can process chats and view and review knowledge directly. Search test, export and the "Use knowledge in replies" switch need a specific account.',
|
||||
'知识与审核': 'Knowledge & review',
|
||||
'搜索标题或问题': 'Search title or question',
|
||||
'一键审核此账号全部草稿': 'Review all drafts of this account',
|
||||
'一键审核全部授权账号': 'Review all authorized accounts',
|
||||
'审核任务与结果': 'Review jobs and results',
|
||||
'后台审核进行中': 'Background review in progress',
|
||||
'已选 {count} 条(当前页)': '{count} selected (this page)',
|
||||
'批量审核': 'Batch review',
|
||||
'发布选中': 'Publish selected',
|
||||
'驳回选中': 'Reject selected',
|
||||
'停用选中': 'Disable selected',
|
||||
'删除已驳回': 'Delete rejected',
|
||||
'清空选择': 'Clear selection',
|
||||
'问题 / 标题': 'Question / title',
|
||||
'账号': 'Account',
|
||||
'分类': 'Category',
|
||||
'版本': 'Version',
|
||||
'没有符合筛选条件的知识,可调整筛选后重试。': 'No knowledge matches the filters. Adjust them and try again.',
|
||||
'还没有知识,先从历史聊天整理出待审草稿。': 'No knowledge yet. Start by processing chat history into drafts for review.',
|
||||
'整理任务': 'Processing jobs',
|
||||
'新聊天自动整理': 'Auto-process new chats',
|
||||
'检索测试': 'Search test',
|
||||
'测试只检索已发布知识,不发送客服消息。相关度是排序参考,不代表事实正确率。': 'The test only searches published knowledge and sends no messages to customers. Relevance is a ranking hint, not a measure of factual accuracy.',
|
||||
'当前账号暂无已发布知识,请先审核并发布知识后再测试检索。': 'This account has no published knowledge yet. Review and publish knowledge before testing search.',
|
||||
'输入一个真实业务问题(2–2000 个字符)': 'Enter a real business question (2–2000 characters)',
|
||||
'检索': 'Search',
|
||||
'{count} 条命中': '{count} hits',
|
||||
'当前账号没有已发布知识,审核通过后还需发布才能检索。': 'This account has no published knowledge. Approved knowledge must also be published before it can be searched.',
|
||||
'没有可靠命中。请补充问题信息,或发布相应知识。': 'No reliable hits. Add more detail to the question, or publish the relevant knowledge.',
|
||||
'查看来源': 'View source',
|
||||
'适用条件:{conditions}': 'Conditions: {conditions}',
|
||||
'版本 {revision}': 'Version {revision}',
|
||||
'相关度 {score}': 'Relevance {score}',
|
||||
'引用记录': 'Citation log',
|
||||
'时间': 'Time',
|
||||
'问题': 'Question',
|
||||
'知识来源': 'Knowledge sources',
|
||||
'检索方式': 'Search mode',
|
||||
'耗时 ms': 'Time (ms)',
|
||||
'无命中': 'No hits',
|
||||
'知识审核与来源': 'Knowledge review and sources',
|
||||
'账号:{account}': 'Account: {account}',
|
||||
'来源对话(已自动脱敏)': 'Source conversation (auto-redacted)',
|
||||
'暂无可查看的来源,请检查正文查看权限': 'No viewable sources. Check your permission to view message content',
|
||||
'客服': 'Agent',
|
||||
'客户': 'Customer',
|
||||
'标题': 'Title',
|
||||
'类型': 'Type',
|
||||
'完整问题': 'Full question',
|
||||
'标准答案 / 步骤': 'Standard answer / steps',
|
||||
'适用条件(必填)': 'Conditions (required)',
|
||||
'有效期截止日期(可选)': 'Valid until (optional)',
|
||||
'修改尚未保存。请先保存草稿,再审核或发布。': 'Changes are not saved. Save the draft before reviewing or publishing.',
|
||||
'已核对客服身份、事实、适用条件和脱敏;内容可作为通用经验。涉及医疗内容已完成专业审核。': 'I have checked the agent identity, facts, conditions and redaction, and the content is usable as general guidance. Any medical content has passed professional review.',
|
||||
'保存草稿': 'Save draft',
|
||||
'审核通过': 'Approve',
|
||||
'驳回': 'Reject',
|
||||
'发布到知识库': 'Publish to knowledge base',
|
||||
'删除已驳回知识': 'Delete rejected knowledge',
|
||||
'停用': 'Disable',
|
||||
'版本与操作记录': 'Versions and activity',
|
||||
'恢复为草稿': 'Restore as draft',
|
||||
// 知识状态、检索方式、类型
|
||||
'待审核': 'Pending review',
|
||||
'待发布': 'Ready to publish',
|
||||
'已发布': 'Published',
|
||||
'已驳回': 'Rejected',
|
||||
'来源已变化': 'Source changed',
|
||||
'等待处理': 'Waiting',
|
||||
'处理中': 'Processing',
|
||||
'已暂停': 'Paused',
|
||||
'已完成': 'Completed',
|
||||
'失败': 'Failed',
|
||||
'已取消': 'Cancelled',
|
||||
'关键词检索': 'Keyword search',
|
||||
'关键词与语义检索': 'Keyword and semantic search',
|
||||
'语义服务暂不可用,已使用关键词': 'Semantic service unavailable; keyword search used',
|
||||
'检索暂不可用': 'Search temporarily unavailable',
|
||||
'全部状态': 'All statuses',
|
||||
'标准问答': 'Standard Q&A',
|
||||
'业务流程': 'Business procedure',
|
||||
'对话案例': 'Conversation example',
|
||||
// 提示与确认
|
||||
'已保存为草稿,需要重新审核后发布': 'Saved as a draft; it must be reviewed again before publishing',
|
||||
'已恢复为草稿,请审核后重新发布': 'Restored as a draft; review it and publish it again',
|
||||
'删除这条已驳回知识?': 'Delete this rejected knowledge item?',
|
||||
'将删除知识及其来源快照和版本记录,原始聊天归档保留。删除后无法恢复。': 'The knowledge item, its source snapshots and version history will be deleted; the original chat archive is kept. This cannot be undone.',
|
||||
'确认删除': 'Delete',
|
||||
'请先选择具体账号': 'Select a specific account first',
|
||||
'请输入至少 2 个字符的检索问题': 'Enter a search question of at least 2 characters',
|
||||
'检索问题不能超过 2000 个字符': 'The search question cannot exceed 2000 characters',
|
||||
'检索失败,请稍后重试': 'Search failed. Please try again later',
|
||||
|
||||
// ── 后端原样返回、页面上用 t(值) 翻译的固定文案 ─────────────────────
|
||||
'旧版未归属数据': 'Legacy unassigned data',
|
||||
'待分类': 'Uncategorized',
|
||||
'全部账号': 'All accounts',
|
||||
'全部授权账号': 'All authorized accounts',
|
||||
'{count} 个账号': '{count} accounts',
|
||||
'全部客服来源({count} 个)': 'All agent sources ({count})',
|
||||
'模型清洗': 'Model cleaning',
|
||||
'规则提取': 'Rule extraction',
|
||||
'历史任务': 'Earlier job',
|
||||
'模型整理': 'Model processing',
|
||||
'规则整理': 'Rule processing',
|
||||
// 草稿上的提醒(knowledge_processing.py、knowledge_model_output.py)
|
||||
'待核实客服身份、适用条件和事实;自动脱敏后仍须人工检查': 'Agent identity, conditions and facts need verifying; check by hand even after auto-redaction',
|
||||
'对话含图片、语音等非文字内容(已用占位符标出),请核对答案是否完整': 'The conversation contains images, voice or other non-text content (marked with placeholders); check that the answer is complete',
|
||||
'包含健康或个体医疗信息,仅经专业审核通用化后可发布': 'Contains health or individual medical information; publish only after professional review and generalization',
|
||||
'模型未提供有效标题,已从问题生成标题,请核对': 'The model gave no valid title; a title was generated from the question. Please check it',
|
||||
'模型标题过长,已缩短标题;问题与答案未截断': 'The model title was too long and has been shortened; the question and answer are not truncated',
|
||||
'模型分类缺失或无效,请人工分类': 'The model category is missing or invalid; categorize it by hand',
|
||||
'模型未提供有效适用条件,请核对来源并补充后再审核': 'The model gave no valid conditions; check the source and add them before review',
|
||||
'模型知识类型无效,暂按问答保留,请核对': 'The model knowledge type is invalid; kept as Q&A for now. Please check it',
|
||||
'模型整理草稿,须逐项核对来源,不能直接作为正确答案': 'Model-processed draft: check every point against the source; do not treat it as a correct answer as is',
|
||||
// 一键审核的原因(knowledge_review.py REASONS)
|
||||
'符合校验条件': 'Passes the checks',
|
||||
'缺少适用条件,需编辑补充': 'Missing conditions; edit to add them',
|
||||
'知识字段不完整、过长或类型无效': 'Fields are incomplete, too long or of an invalid type',
|
||||
'已超过有效期': 'Expired',
|
||||
'缺少来源记录': 'Missing source records',
|
||||
'来源已变化,请重新整理': 'Source changed; process it again',
|
||||
'预览后知识版本或状态已变化': 'Version or status changed after the preview',
|
||||
'知识已删除': 'Knowledge deleted',
|
||||
'该账号已不在当前授权范围': 'This account is no longer within your authorized scope',
|
||||
|
||||
// ── 整理任务(runs-panel.vue、run-format.ts) ───────────────────────
|
||||
'已取消,已生成的草稿保留': 'Cancelled; drafts already generated are kept',
|
||||
'已暂停,进度已保存': 'Paused; progress saved',
|
||||
'已继续': 'Resumed',
|
||||
'取消这个整理批次?': 'Cancel this processing run?',
|
||||
'已经生成的草稿保留,剩下的聊天不再整理;正在进行的模型请求可能仍会计费。': 'Drafts already generated are kept and the remaining chats will not be processed. Model requests in progress may still be billed.',
|
||||
'取消整理': 'Cancel run',
|
||||
'不取消': 'Keep running',
|
||||
'已按新配置继续;已生成的草稿保留,新配置只用于剩下的部分': 'Resumed with the new settings; drafts already generated are kept and the new settings apply only to the rest',
|
||||
'整理批次': 'Processing run',
|
||||
'进度': 'Progress',
|
||||
'模型用量': 'Model usage',
|
||||
'已读消息': 'Messages read',
|
||||
'结果': 'Result',
|
||||
'新聊天自动整理已对 {sources} 个客服来源开启:最近 24 小时整理了 {jobs} 个会话,新增草稿 {created} 条': 'Auto-processing of new chats is on for {sources} agent sources: {jobs} conversations processed in the last 24 hours, {created} new drafts',
|
||||
',{count} 个会话等对话静默后整理': ', {count} conversations waiting for the chat to go quiet',
|
||||
',{count} 个失败': ', {count} failed',
|
||||
'。': '.',
|
||||
'新聊天自动整理还没开启:新建整理时勾选「整理完以后,新聊天也自动整理」即可开启。': 'Auto-processing of new chats is off. To turn it on, tick "also auto-process new chats" when starting a processing run.',
|
||||
'还没有整理过聊天记录': 'No chat history processed yet',
|
||||
'{count} 路并发': '{count} concurrent',
|
||||
'每次 {count} 段': '{count} per request',
|
||||
'{from} 至 {to}': '{from} to {to}',
|
||||
'最早': 'earliest',
|
||||
'现在': 'now',
|
||||
'旧版单账号任务': 'Legacy single-account job',
|
||||
'已读 {processed} / {total} 条消息': 'Read {processed} / {total} messages',
|
||||
'已读 {processed} 条消息': 'Read {processed} messages',
|
||||
'待整理问答 {count}': '{count} Q&A pairs waiting',
|
||||
'每分钟约 {perMinute} 条消息,按目前平均速度还要 {remaining}': 'About {perMinute} messages per minute; {remaining} left at the current average speed',
|
||||
'旧版任务没有分原因统计': 'Legacy jobs have no breakdown by reason',
|
||||
'跳过 {count} 条(系统提示、撤回等)': 'Skipped {count} (system notices, recalls, etc.)',
|
||||
'{count} 次': '{count} calls',
|
||||
'上限 {limit}': 'limit {limit}',
|
||||
'输入 {input} / 输出 {output} 字': 'Input {input} / output {output} chars',
|
||||
'不调用模型': 'No model calls',
|
||||
'暂停': 'Pause',
|
||||
'继续': 'Resume',
|
||||
'继续出错的账号': 'Resume failed accounts',
|
||||
'换模型 / 调整': 'Change model / adjust',
|
||||
'调整后继续整理': 'Adjust and resume',
|
||||
'保存并继续': 'Save and resume',
|
||||
'已整理出 {drafts} 条草稿、调用模型 {calls} 次;这些都保留,新配置只用于剩下的部分。': 'So far {drafts} drafts with {calls} model calls; these are kept, and the new settings apply only to the rest.',
|
||||
'空消息': 'Empty message',
|
||||
'已撤回或删除': 'Recalled or deleted',
|
||||
'系统提示': 'System notice',
|
||||
'收发方向不明': 'Unknown direction',
|
||||
'出错暂停': 'Paused on error',
|
||||
'排队中': 'Queued',
|
||||
'整理中': 'Processing',
|
||||
'新增草稿 {count}': '{count} new drafts',
|
||||
'更新草稿 {count}': '{count} updated drafts',
|
||||
'重复 {count}': '{count} duplicates',
|
||||
'模型筛掉 {count}': '{count} filtered out by model',
|
||||
'还没有产出': 'No output yet',
|
||||
'模型清洗 · {model}': 'Model cleaning · {model}',
|
||||
'默认模型': 'default model',
|
||||
'不到 1 分钟': 'less than 1 minute',
|
||||
'约 {minutes} 分钟': 'about {minutes} min',
|
||||
'约 {days} 天': 'about {days} days',
|
||||
'约 {hours} 小时 {minutes} 分': 'about {hours} h {minutes} min',
|
||||
'约 {hours} 小时': 'about {hours} h',
|
||||
|
||||
// ── 全量审核(review-all.vue) ─────────────────────────────────────
|
||||
'等待审核': 'Waiting for review',
|
||||
'审核中': 'Reviewing',
|
||||
'需处理': 'Needs attention',
|
||||
'全量审核任务已提交,关闭页面后仍会继续': 'Full review job submitted; it keeps running after you close the page',
|
||||
'停止剩余审核?': 'Stop the remaining review?',
|
||||
'已通过的知识保留在待发布,尚未处理的草稿保持原状态。': 'Approved knowledge stays ready to publish; drafts not yet processed keep their status.',
|
||||
'停止剩余审核': 'Stop remaining review',
|
||||
'返回': 'Back',
|
||||
'全量审核': 'Full review',
|
||||
'指定范围审核': 'Review a scope',
|
||||
'审核范围是已整理出的待审核知识草稿,覆盖全部页面,不受列表搜索与勾选影响。通过后进入待发布,原始聊天归档不会被修改。': 'The scope is all processed drafts pending review, across every page, regardless of list search and selection. Approved items become ready to publish; the original chat archive is not modified.',
|
||||
'审核账号': 'Account to review',
|
||||
'预览全部待审草稿': 'Preview all pending drafts',
|
||||
'范围:': 'Scope: ',
|
||||
'快照时间 {time}': 'Snapshot at {time}',
|
||||
'可通过': 'Eligible',
|
||||
'条': 'items',
|
||||
'{reason}:{count} 条': '{reason}: {count}',
|
||||
'预览固定当前草稿与版本,30 分钟内可提交。执行时会再次校验来源、版本和权限;不合格条目保持原状态并列出原因。': 'The preview pins the current drafts and versions and can be submitted within 30 minutes. Sources, versions and permissions are checked again when it runs; items that fail keep their status and are listed with the reason.',
|
||||
'确认所选范围已完成必要的事实、脱敏和适用性审核;涉及医疗内容已完成专业审核。批量通过符合校验条件的草稿,之后另行发布。': 'I confirm the selected scope has had the necessary fact, redaction and applicability review, and any medical content has passed professional review. Drafts that pass the checks are approved in bulk and published separately later.',
|
||||
'一键审核 {count} 条并进入待发布': 'Approve {count} items as ready to publish',
|
||||
'我的审核任务': 'My review jobs',
|
||||
'后台分批执行,关闭页面不影响进度。': 'Runs in batches in the background; closing the page does not affect progress.',
|
||||
'刷新任务': 'Refresh jobs',
|
||||
'账号范围': 'Account scope',
|
||||
'状态 / 进度': 'Status / progress',
|
||||
'审核结果': 'Review result',
|
||||
'{processed} / {total} 条': '{processed} / {total}',
|
||||
'通过 {approved} · 未通过 {skipped}': 'Approved {approved} · Not approved {skipped}',
|
||||
'查看结果': 'View results',
|
||||
'停止': 'Stop',
|
||||
'结果清单 · {scope}': 'Results · {scope}',
|
||||
'未通过': 'Not approved',
|
||||
'已通过': 'Approved',
|
||||
'未处理': 'Not processed',
|
||||
'结果清单仅显示当前仍有权限的账号。': 'Results only include accounts you still have access to.',
|
||||
'知识标题': 'Knowledge title',
|
||||
'原因': 'Reason',
|
||||
'查看知识': 'View knowledge',
|
||||
|
||||
// ── 整理方式与模型(wash-options.vue) ─────────────────────────────
|
||||
'(默认)': ' (default)',
|
||||
'模型清单加载失败,请刷新后重试': 'Failed to load the model list. Refresh and try again',
|
||||
'整理方式': 'Processing method',
|
||||
'模型清洗(推荐)': 'Model cleaning (recommended)',
|
||||
'规则提取(不调用模型)': 'Rule extraction (no model calls)',
|
||||
'先按规则切出问答,再交给模型改写成标准知识,并筛掉寒暄、查物流等没有通用价值的对话。按调用次数计费。': 'Rules first split out Q&A pairs, then the model rewrites them as standard knowledge and filters out conversations with no general value, such as small talk or shipping queries. Billed per call.',
|
||||
'只按规则切出问答、保留原话,不花模型费用;需要人工逐条整理措辞和适用条件。': 'Rules only split out Q&A pairs and keep the original wording, at no model cost; wording and conditions must be edited by hand, one by one.',
|
||||
'整理模型': 'Processing model',
|
||||
'选择一个已启用的文本模型': 'Select an enabled text model',
|
||||
'测试': 'Test',
|
||||
'还没有可用的文本模型,请先在「AI 模型 → 模型清单」启用一个,或改用规则提取。': 'No text model is available. Enable one under "AI Models → Model List" first, or use rule extraction.',
|
||||
'原来选的模型已停用,请重新选择。': 'The previously selected model has been disabled. Select another one.',
|
||||
'「{model}」可以正常整理(用时 {seconds} 秒)': '"{model}" works for processing ({seconds} s)',
|
||||
'用一段虚构对话试整理的结果:': 'Result of a trial run on a made-up conversation:',
|
||||
'问题:{question}': 'Question: {question}',
|
||||
'答案:{answer}': 'Answer: {answer}',
|
||||
'(模型未给出)': '(not given by the model)',
|
||||
'高级设置': 'Advanced settings',
|
||||
'同时调用': 'Concurrent calls',
|
||||
'路': 'at once',
|
||||
'每次请求合并': 'Merge per request',
|
||||
'段问答': 'Q&A pairs',
|
||||
'单次等待': 'Timeout per call',
|
||||
'秒': 'seconds',
|
||||
'调用总上限(0 = 不限)': 'Total call limit (0 = unlimited)',
|
||||
'次': 'calls',
|
||||
'让模型筛掉没有通用价值的问答(寒暄、催单查物流、只针对某位患者的处置)': 'Let the model filter out Q&A with no general value (small talk, order chasing and shipping queries, advice for one specific patient)',
|
||||
'大部分聊天会被判为没有价值,合并请求能省下大部分调用(1 = 一段一请求)。并发越高越快,但会和客服实时回复共用同一个模型额度;': 'Most chats are judged to have no value, so merging requests saves most calls (1 = one pair per request). Higher concurrency is faster but shares the same model quota as live customer replies;',
|
||||
'超时或限流会自动退避重试,频繁出现时调低并发或合并段数。整理时会自动关闭模型的深度思考(服务商支持时)。': 'timeouts and rate limits are retried automatically with backoff; if they happen often, lower the concurrency or the pairs per request. Deep thinking is turned off automatically during processing (where the provider supports it).',
|
||||
|
||||
// ── 新建整理(run-dialog.vue) ─────────────────────────────────────
|
||||
'请选择要整理的账号': 'Select the accounts to process',
|
||||
'结束日期不能早于开始日期': 'The end date cannot be earlier than the start date',
|
||||
'正在统计范围…': 'Counting the scope…',
|
||||
'所选范围内没有单聊消息': 'No one-to-one messages in the selected scope',
|
||||
'请选择整理模型': 'Select a processing model',
|
||||
'已开始整理:{count} 个账号,在「整理任务」里看进度': 'Processing started for {count} accounts; follow the progress under "Processing jobs"',
|
||||
'整理哪些账号': 'Accounts to process',
|
||||
'指定账号': 'Selected accounts',
|
||||
'选择一个或多个客户端账号': 'Select one or more client accounts',
|
||||
'时间范围(留空 = 全部聊天)': 'Date range (empty = all chats)',
|
||||
'至': 'to',
|
||||
'清空': 'Clear',
|
||||
'整理完以后,这些账号的新聊天也自动整理(对话静默 30 分钟后)': 'After processing, also auto-process new chats of these accounts (after 30 minutes of silence)',
|
||||
'{scope}:': '{scope}: ',
|
||||
'个账号有聊天,': 'accounts with chats.',
|
||||
'单聊消息': 'One-to-one messages',
|
||||
'条(文字 {text} 条),会话 {conversations} 个': '({text} text), {conversations} conversations',
|
||||
';另有 {count} 个账号在这个范围内没有聊天,会跳过': '; {count} more accounts have no chats in this range and will be skipped',
|
||||
'模型只处理切出来的问答段(通常只占消息的百分之几),每次请求合并 {batch} 段;开始后在「整理任务」里看实时速度和预计剩余时间。': 'The model only handles the Q&A pairs split out (usually a few percent of the messages), {batch} per request. After starting, see the live speed and time left under "Processing jobs".',
|
||||
'第一次整理全部聊天,建议在高级设置里先设一个调用上限。': 'When processing all chats for the first time, set a call limit in Advanced settings first.',
|
||||
'文字消息': 'Text messages',
|
||||
'会话': 'Conversations',
|
||||
'开始即确认:这些聊天由客服账号产生,允许用于知识整理。整理结果都是待审草稿,不会自动发布;同一段对话已有的草稿不会重复生成。': 'Starting confirms that these chats come from agent accounts and may be used for knowledge processing. Results are drafts pending review and are never published automatically; a conversation that already has a draft will not get a duplicate.',
|
||||
'开始整理': 'Start processing',
|
||||
|
||||
// ── 批量操作(batch-dialog.vue、batch.ts) ─────────────────────────
|
||||
'批量删除 · {count} 条': 'Batch delete · {count} items',
|
||||
'批量审核通过 · {count} 条': 'Batch approve · {count} items',
|
||||
'批量发布 · {count} 条': 'Batch publish · {count} items',
|
||||
'批量驳回 · {count} 条': 'Batch reject · {count} items',
|
||||
'批量停用 · {count} 条': 'Batch disable · {count} items',
|
||||
'确认删除 {count} 条': 'Delete {count} items',
|
||||
'确认审核通过 {count} 条': 'Approve {count} items',
|
||||
'确认发布 {count} 条': 'Publish {count} items',
|
||||
'确认驳回 {count} 条': 'Reject {count} items',
|
||||
'确认停用 {count} 条': 'Disable {count} items',
|
||||
'版本已更新,请关闭后重新选择,避免审核旧内容': 'The version has changed. Close and select again to avoid reviewing outdated content',
|
||||
'请逐条展开,核对来源、事实、脱敏和适用条件,再勾选确认。通过审核后仍需发布才可检索。': 'Expand each item, check its source, facts, redaction and conditions, then tick to confirm. Approved items must still be published before they can be searched.',
|
||||
'每条知识分别校验版本和当前状态。操作结果逐条显示,失败条目不会自动重试。': 'Each item is checked for its version and current status. Results are shown per item; failed items are not retried automatically.',
|
||||
'仅删除已驳回知识及其来源快照和版本记录;原始聊天归档保留。删除后无法恢复,请确认所选条目。': 'Only rejected knowledge and its source snapshots and version history are deleted; the original chat archive is kept. This cannot be undone, so check the selected items.',
|
||||
'可操作 {ready} 条 · 需先处理 {blocked} 条': '{ready} ready · {blocked} need attention first',
|
||||
'成功 {success} · 失败 {failed} · 未执行 {pending}': 'Succeeded {success} · Failed {failed} · Not run {pending}',
|
||||
'成功': 'Succeeded',
|
||||
'未执行': 'Not run',
|
||||
'尚未填写': 'Not filled in',
|
||||
'有效期:{date}': 'Valid until: {date}',
|
||||
'已核对本条来源、事实、脱敏和适用条件;涉及医疗内容已完成专业审核。': 'I have checked the source, facts, redaction and conditions of this item; any medical content has passed professional review.',
|
||||
'打开单条审核与编辑': 'Open to review and edit',
|
||||
'当前操作完成后停止': 'Stopping after the current item',
|
||||
'停止后续操作': 'Stop the rest',
|
||||
'完成': 'Done',
|
||||
'当前状态不支持此操作,请刷新或重新选择': 'This action is not available in the current status. Refresh or select again',
|
||||
'请先编辑并补充适用条件': 'Edit the item and add conditions first',
|
||||
'请求未完成,请刷新确认状态后再操作': 'The request did not complete. Refresh to check the status before trying again',
|
||||
|
||||
// ── 整理设置(settings-dialog.vue) ────────────────────────────────
|
||||
'整理设置已保存': 'Processing settings saved',
|
||||
'新建整理时默认用这里的方式和模型(每次仍可临时改)。': 'New processing runs use this method and model by default (you can still change them each time).',
|
||||
'新聊天自动整理用': 'Auto-processing of new chats uses',
|
||||
'规则提取(不花模型费用)': 'Rule extraction (no model cost)',
|
||||
'模型清洗(用上面的默认模型,每段新对话调用一次)': 'Model cleaning (the default model above, one call per new conversation)',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 左侧菜单、页签、面包屑、浏览器标题(路由 meta.title)和顶栏、下拉里的少量文案。
|
||||
* 路由标题部分还会作为顶层词条合并进 vue-i18n(见 locales/index.ts),框架的 $t(标题) 才能翻译。
|
||||
*/
|
||||
export default {
|
||||
'知识中心': 'Knowledge',
|
||||
'知识加工与检索': 'Knowledge Workbench',
|
||||
'AI 模型': 'AI Models',
|
||||
'模型清单': 'Model List',
|
||||
'角色编排': 'Role Orchestration',
|
||||
'智能体': 'Agents',
|
||||
'调用统计': 'Call Statistics',
|
||||
'桌面端': 'Desktop',
|
||||
'客户端账号': 'Client Accounts',
|
||||
'客户端配置': 'Client Settings',
|
||||
'版本升级': 'Version Upgrades',
|
||||
'聊天归档': 'Chat Archive',
|
||||
'归档概览': 'Archive Overview',
|
||||
'会话与消息': 'Conversations',
|
||||
'导出中心': 'Exports',
|
||||
'人员唯一标识': 'Person Identities',
|
||||
'COS 存储': 'COS Storage',
|
||||
'系统管理': 'System',
|
||||
'角色权限': 'Roles',
|
||||
'权限菜单': 'Permission Menu',
|
||||
'用户管理': 'Users',
|
||||
'审计日志': 'Audit Log',
|
||||
'个人设置': 'Personal Settings',
|
||||
'修改密码': 'Change Password',
|
||||
'角色:{role}': 'Role: {role}',
|
||||
// 产品名(.env 的 VITE_APP_TITLE)和页脚公司名
|
||||
'真羊 AI 客服 · 管理后台': 'Zhenyang AI Customer Service · Admin',
|
||||
'真羊': 'Zhenyang',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* views/console/agents.vue, model-catalog.vue, model-plan.vue, call-stats.vue,
|
||||
* ModelUsageStats.vue, model-usage-format.ts, use-usage-request.ts
|
||||
*/
|
||||
export default {
|
||||
// ── 通用 ──────────────────────────────────────────────
|
||||
'名称': 'Name',
|
||||
'模型': 'Model',
|
||||
'启用': 'Enable',
|
||||
'已更新': 'Updated',
|
||||
'已新增': 'Added',
|
||||
'保存为新版本': 'Save as new version',
|
||||
'{count} 条': '{count}',
|
||||
'耗时': 'Latency',
|
||||
'裁判': 'Judge',
|
||||
|
||||
// ── agents.vue 智能体 ─────────────────────────────────
|
||||
'补充指令(告诉模型该怎么答)': 'Extra instruction (tells the model how to answer)',
|
||||
'禁止措辞(回复里出现就换掉)': 'Forbidden wording (replaced if it appears in a reply)',
|
||||
'固定口径(命中就直接这么回)': 'Fixed reply (sent as-is on a match)',
|
||||
'写一句给模型的补充要求。关键词留空 = 每一轮都注入;填了关键词 = 客户这句话命中才注入(规则多了以后靠这个控制提示词长度)。':
|
||||
'One extra requirement for the model. No keywords = injected every turn; with keywords = injected only when the customer\'s message matches (use this to keep the prompt short once there are many rules).',
|
||||
'关键词是**回复里**不许出现的词。命中就换成下面的兜底话术;兜底留空时客户端会用内置的那句正常人话顶上。':
|
||||
'Keywords are words that must not appear **in the reply**. On a match the reply is replaced with the fallback text below; if the fallback is empty, the client uses its built-in natural-sounding line.',
|
||||
'关键词命中**客户这句话**时,直接发下面这句,不再问模型。口径必须一字不差的场景用它。':
|
||||
'When a keyword matches **the customer\'s message**, the text below is sent directly without asking the model. Use it when the wording must be exact.',
|
||||
'{name}({role})': '{name} ({role})',
|
||||
'智能体名称不能为空': 'Agent name is required',
|
||||
'第 {index} 条规则没有填名称': 'Rule {index} has no name',
|
||||
'第 {index} 条规则是补充指令,必须填要告诉模型的话': 'Rule {index} is an extra instruction and needs the text to tell the model',
|
||||
'第 {index} 条规则至少要有一个关键词,否则永远不会命中': 'Rule {index} needs at least one keyword, otherwise it will never match',
|
||||
'第 {index} 条规则是固定口径,必须填要发给客户的话术': 'Rule {index} is a fixed reply and needs the text to send to the customer',
|
||||
'单角色模式要选一个当前生效的智能体': 'Single-role mode needs one active agent selected',
|
||||
'协作模式至少要有两个在岗智能体,否则就是单角色': 'Collaborative mode needs at least two on-duty agents; otherwise it is single-role',
|
||||
'已保存为 v{version},客户端下次同步配置时生效': 'Saved as v{version}; takes effect when clients next sync their config',
|
||||
'职责': 'Responsibility',
|
||||
'负责话题': 'Topics',
|
||||
'规则': 'Rules',
|
||||
'在岗': 'On duty',
|
||||
'主答': 'Primary responder',
|
||||
'未上岗': 'Off duty',
|
||||
'智能体决定「用什么身份、按什么规矩说话」,模型清单决定「用哪个模型」':
|
||||
'Agents decide "who speaks and by which rules"; the model list decides "which model is used"',
|
||||
'这两件事的变更频率差一个数量级:模型半年动一次,话术一周动三次。改这里不用动模型,也不用给客户端发版——保存后随配置下发。':
|
||||
'These change at very different rates: models change about twice a year, scripts three times a week. Changes here need no model change and no client release; once saved they ship with the config.',
|
||||
'启用与协作': 'Activation and collaboration',
|
||||
'单角色 = 只上一个,切换就是换这里选的那个;协作 = 多个同时在场,客户这句话命中谁的「负责话题」就由谁主答,其余角色的禁止措辞仍然全体生效。':
|
||||
'Single role = only one agent at a time; switching means picking a different one here. Collaborative = several agents at once; the agent whose "Topics" match the customer\'s message is the primary responder, and every agent\'s forbidden wording still applies.',
|
||||
'不论哪种模式,客户看到的永远是同一个人——不会自报角色名,也不会出现「转给同事」。':
|
||||
'In either mode the customer always sees one and the same person: no role names are announced and there is never a "let me pass you to a colleague".',
|
||||
'模式': 'Mode',
|
||||
'单角色(切换到某一个)': 'Single role (switch to one agent)',
|
||||
'协作(多个同时在场)': 'Collaborative (several agents at once)',
|
||||
'主答智能体(没命中任何话题时由它回)': 'Primary agent (answers when no topic matches)',
|
||||
'选一个': 'Pick one',
|
||||
'在岗智能体(至少两个;主答会自动排在最前)': 'On-duty agents (at least two; the primary agent is placed first automatically)',
|
||||
'当前 v{version}': 'Current v{version}',
|
||||
'智能体': 'Agents',
|
||||
'新增智能体': 'New agent',
|
||||
'不认领话题': 'No topics claimed',
|
||||
'确定删除这个智能体?': 'Delete this agent?',
|
||||
'还没有智能体——客户端会沿用内置人设,行为和没有这套东西时一样。':
|
||||
'No agents yet. Clients keep the built-in persona and behave as if this feature did not exist.',
|
||||
'编辑智能体:{name}': 'Edit agent: {name}',
|
||||
'客户经理': 'Account manager',
|
||||
'角色标签(留空同名称)': 'Role label (defaults to the name)',
|
||||
'客户经理 / 糖尿病专科医生': 'Account manager / Diabetes specialist',
|
||||
'ID(留空自动生成,之后不可改)': 'ID (generated if empty; cannot be changed later)',
|
||||
'职责(一句话,写给人看的)': 'Responsibility (one sentence, for humans)',
|
||||
'挂号预约、面诊链接、订单物流、投诉退款': 'Appointments, consultation links, order shipping, complaints and refunds',
|
||||
'负责话题(协作时按它认领本轮;留空 = 不主动认领)': 'Topics (used to claim a turn in collaborative mode; empty = never claims)',
|
||||
'输入后回车,可以加多个': 'Press Enter after each one; you can add several',
|
||||
'人设(这个角色怎么说话、管哪一摊事)': 'Persona (how this role talks and what it handles)',
|
||||
'你负责流程类事情:挂号预约、面诊安排、订单物流、投诉退款……':
|
||||
'You handle process matters: appointments, consultation scheduling, order shipping, complaints and refunds...',
|
||||
'追加在内置人设后面,压过默认的表达习惯。不用重复写「像真人一样说话」这种通用要求,内置人设里已经有了——这里只写这个角色特有的部分。':
|
||||
'Appended after the built-in persona and overrides its default style. No need to repeat general requirements such as "talk like a real person"; the built-in persona already has them. Only write what is specific to this role.',
|
||||
'规则名称': 'Rule name',
|
||||
'回复里不许出现的词,回车分隔': 'Words not allowed in replies, press Enter after each',
|
||||
'触发关键词,回车分隔': 'Trigger keywords, press Enter after each',
|
||||
'要告诉模型的话,例如:客户只发数字时按血糖值理解,严禁说这是系统编码。':
|
||||
'What to tell the model, e.g.: when the customer sends only a number, treat it as a blood glucose reading; never say it is a system code.',
|
||||
'命中后直接发给客户的话术': 'Text sent to the customer on a match',
|
||||
'命中禁止措辞后的兜底话术(留空则用客户端内置的那句)': 'Fallback text when forbidden wording is hit (empty = the client\'s built-in line)',
|
||||
'新增规则': 'Add rule',
|
||||
'优先级(数字越小越靠前)': 'Priority (lower numbers come first)',
|
||||
|
||||
// ── model-catalog.vue 模型清单 ────────────────────────
|
||||
'模型 ID 和接口地址不能为空': 'Model ID and endpoint URL are required',
|
||||
'{message} · 端点 {endpoint} · 耗时 {latency}ms': '{message} · Endpoint {endpoint} · Latency {latency} ms',
|
||||
'{message} · 端点 {endpoint} · 耗时 {latency}ms · HTTP {status}': '{message} · Endpoint {endpoint} · Latency {latency} ms · HTTP {status}',
|
||||
'接口类型': 'API type',
|
||||
'实际请求地址': 'Actual request URL',
|
||||
'密钥': 'API key',
|
||||
'能力': 'Capabilities',
|
||||
'并发上限': 'Concurrency limit',
|
||||
'模型清单': 'Model List',
|
||||
'新增模型': 'New model',
|
||||
'密钥在后端加密存储,接口只返回遮罩值——这里看到的星号不是显示效果,是后端真的不会把明文发出来。留空保存表示不改动原密钥。':
|
||||
'API keys are stored encrypted on the backend and the API returns only masked values. The asterisks here are not cosmetic: the backend really never sends the plaintext. Saving with the key left empty keeps the existing key.',
|
||||
'{label}:连接成功': '{label}: connected',
|
||||
'{label}:连接失败': '{label}: connection failed',
|
||||
'原样': 'As-is',
|
||||
'(未设置)': '(not set)',
|
||||
'测连通': 'Test connection',
|
||||
'确定删除这个模型?': 'Delete this model?',
|
||||
'编辑模型': 'Edit model',
|
||||
'模型 ID(唯一,编排里按它引用)': 'Model ID (unique; role orchestration refers to it)',
|
||||
'显示名称': 'Display name',
|
||||
'甄养堂客服': 'Zhenyangtang support',
|
||||
'ComfyUI 是文生图工作流引擎,不能当对话模型——后端会把它排除在答题池和裁判之外。':
|
||||
'ComfyUI is a text-to-image workflow engine and cannot be a chat model; the backend excludes it from the answer pool and the judge.',
|
||||
'接口地址': 'Endpoint URL',
|
||||
'这就是完整的请求地址,不要再补路径': 'This is the full request URL; do not append a path',
|
||||
'关着时按接口类型自动补全(': 'When off, the path is completed by API type (',
|
||||
'),大多数服务商都是这个形状。路径不按套路的自建服务打开它。':
|
||||
'), which fits most providers. Turn it on for self-hosted services with non-standard paths.',
|
||||
'实际请求:': 'Actual request: ',
|
||||
'API 密钥(留空表示不改动)': 'API key (leave empty to keep unchanged)',
|
||||
'API 密钥': 'API key',
|
||||
'留空 = 保持原密钥': 'Empty = keep the current key',
|
||||
'模型名称(Dify 由应用侧决定,可留空)': 'Model name (for Dify the app decides; may be empty)',
|
||||
'仅文本': 'Text only',
|
||||
'文本 + 视觉': 'Text + vision',
|
||||
'文生图': 'Text-to-image',
|
||||
'超时(毫秒)': 'Timeout (ms)',
|
||||
'最大回复 tokens': 'Max reply tokens',
|
||||
'温度': 'Temperature',
|
||||
|
||||
// ── model-plan.vue 角色编排 ───────────────────────────
|
||||
'{name}({kind})': '{name} ({kind})',
|
||||
'照常发主模型的回复,裁判只打分、不参与挑选答案;被判高风险的回复仍转人工审核(1.5.2 及更早的桌面端不会)。先量出现有回复的真实水平,再决定要不要花第二份钱。':
|
||||
'The primary model\'s reply is sent as usual; the judge only scores and does not pick answers. Replies judged high risk still go to manual review (desktop 1.5.2 and earlier do not). Measure the real quality of current replies first, then decide whether to pay for a second model.',
|
||||
'裁判打绝对分并标风险,高风险转人工审核;不做二选一,不改变答案选择。':
|
||||
'The judge gives an absolute score and flags risk; high-risk replies go to manual review. There is no head-to-head choice and answer selection is unchanged.',
|
||||
'完整的 best-of-N:并发问多个模型,按裁判选出的赢家发送,高风险同样转人工审核。成本约 2.3 倍。':
|
||||
'Full best-of-N: several models are asked concurrently and the winner picked by the judge is sent; high-risk replies also go to manual review. Costs about 2.3x.',
|
||||
'至少要指定一个答题模型': 'Specify at least one answer model',
|
||||
'已保存为 v{version},网关 20 秒内自动生效': 'Saved as v{version}; the gateway applies it within 20 seconds',
|
||||
'编排是追加版本,不是原地修改': 'Orchestration adds new versions; it is never edited in place',
|
||||
'每次保存都产生一个新版本号,回滚就是指回旧版本。网关每 20 秒重读一次,不用重启。':
|
||||
'Every save creates a new version number; rolling back means pointing to an older version. The gateway rereads it every 20 seconds, no restart needed.',
|
||||
'当前生效的编排': 'Active orchestration',
|
||||
'版本': 'Version',
|
||||
'裁判模式': 'Judge mode',
|
||||
'答题模型': 'Answer models',
|
||||
'裁判模型': 'Judge model',
|
||||
'修改编排': 'Edit orchestration',
|
||||
'答题模型(多选 = 并发提问,按顺序第一个是主模型)': 'Answer models (several = asked concurrently; the first one is the primary model)',
|
||||
'至少选一个': 'Pick at least one',
|
||||
'选了 {count} 个 = 每条回复都要付 {count} 份生成费用。': '{count} selected = every reply pays for {count} generations.',
|
||||
'建议先跑一段影子模式,用调用统计里的分数分布判断值不值。':
|
||||
'Run shadow mode for a while first and use the score distribution in call statistics to judge whether it is worth it.',
|
||||
'裁判模型(给回复打绝对分和风险等级)': 'Judge model (gives replies an absolute score and a risk level)',
|
||||
'不选则不做评审': 'Leave empty to skip judging',
|
||||
'媒体消息专用模型(需具备视觉能力,留空则从答题池里挑)': 'Model for media messages (needs vision; if empty, one is picked from the answer pool)',
|
||||
'降级链(主链全挂时按顺序尝试)': 'Fallback chain (tried in order when the whole primary chain is down)',
|
||||
|
||||
// ── call-stats.vue 调用统计 ───────────────────────────
|
||||
'客服质量与模型用量': 'Service quality and model usage',
|
||||
'按账号和时间查看实际模型请求、已记录 Token,以及客服回复的裁判评分、风险分布和最终选用情况。':
|
||||
'View actual model requests and recorded tokens by account and period, plus judge scores, risk distribution and final picks for customer service replies.',
|
||||
'近 1 天': 'Last 1 day',
|
||||
'近 7 天': 'Last 7 days',
|
||||
'近 30 天': 'Last 30 days',
|
||||
'客服回复质量': 'Customer service reply quality',
|
||||
'业务调用记录': 'Business call records',
|
||||
'已评审': 'Judged',
|
||||
'平均分': 'Average score',
|
||||
'平均耗时': 'Average latency',
|
||||
'裁判分数分布': 'Judge score distribution',
|
||||
'还没有评审数据': 'No judging data yet',
|
||||
'风险等级': 'Risk level',
|
||||
'各模型被选中的比例': 'Share of picks per model',
|
||||
'被选中': 'Picked',
|
||||
'占比': 'Share',
|
||||
'统计区间自 {since} 起。最慢一次 {max}ms。': 'Period starts at {since}. Slowest call {max} ms.',
|
||||
'调用记录': 'Call log',
|
||||
'搜客户消息或模型回复里的关键词': 'Search customer messages or model replies',
|
||||
'按时间倒序,一条一次调用:客户说了什么、模型候选都答了什么、最终选中的是哪个、有没有被审核规则拦下——出问题时按这张表往回查。':
|
||||
'Newest first, one row per call: what the customer said, what each candidate model answered, which one was picked and whether a review rule blocked it. Use this table to trace problems back.',
|
||||
'客服对话': 'Customer chats',
|
||||
'界面识别': 'UI recognition',
|
||||
'全部': 'All',
|
||||
'「界面识别」是机器人自己看企业微信窗口用的,不是发给客户的话;它每轮轮询都要问一次模型,条数远多于真实对话,所以默认不混在一起。':
|
||||
'"UI recognition" is the bot reading the WeCom window for itself, not text sent to customers. It asks the model on every polling round, so it far outnumbers real conversations and is kept separate by default.',
|
||||
'共 {total} 条': '{total} in total',
|
||||
'时间': 'Time',
|
||||
'客户消息': 'Customer message',
|
||||
'模型回复': 'Model reply',
|
||||
'选中': 'Picked',
|
||||
'裁判分': 'Judge score',
|
||||
'风险': 'Risk',
|
||||
'审核': 'Review',
|
||||
'已拦': 'Blocked',
|
||||
'详情': 'Details',
|
||||
'调用详情': 'Call details',
|
||||
'(空)': '(empty)',
|
||||
'模型回复(最终选中)': 'Model reply (final pick)',
|
||||
'已停发送审核:{reason}': 'Held for review instead of sending: {reason}',
|
||||
'全部候选': 'All candidates',
|
||||
'(选中)': ' (picked)',
|
||||
'裁判 {winner}/{score}分,共 {ms}ms,{time}': 'Judge: {winner} / score {score}, {ms} ms in total, {time}',
|
||||
|
||||
// ── ModelUsageStats.vue / model-usage-format.ts 模型用量 ──
|
||||
'请求数': 'Requests',
|
||||
'已记录输入': 'Recorded input',
|
||||
'已记录输出': 'Recorded output',
|
||||
'已记录总 Token': 'Recorded total tokens',
|
||||
'用量覆盖': 'Usage coverage',
|
||||
'模型 Token 用量': 'Model token usage',
|
||||
'刷新用量': 'Refresh usage',
|
||||
'每次实际上游请求单独计数,包含所有回答候选、裁判、重试、界面检测及知识整理。Token 仅汇总上游已返回的用量;未返回和历史未记录的部分不作估算。':
|
||||
'Every actual upstream request is counted separately, including all answer candidates, judging, retries, UI detection and knowledge processing. Tokens only add up the usage returned by upstream; usage that was not returned or never recorded historically is not estimated.',
|
||||
'全部用途': 'All purposes',
|
||||
'客服回答与裁判': 'Customer replies and judging',
|
||||
'界面检测': 'UI detection',
|
||||
'知识整理': 'Knowledge processing',
|
||||
'用量统计{error}': 'Usage statistics: {error}',
|
||||
'实际上游请求': 'Actual upstream requests',
|
||||
'已记录输入 Token': 'Recorded input tokens',
|
||||
'已记录输出 Token': 'Recorded output tokens',
|
||||
'总 Token 用量覆盖:{known} / {total} 次请求': 'Total token coverage: {known} / {total} requests',
|
||||
'{count} 次未返回总用量': '{count} without total usage',
|
||||
'输入用量已记录 {input} / {total} 次;输出用量已记录 {output} / {total} 次。部分请求缺少用量时,合计仅包含已知部分。':
|
||||
'Input usage recorded for {input} / {total} requests; output usage for {output} / {total}. When some requests lack usage, totals include only the known part.',
|
||||
'已记录缓存输入 {cached};已记录推理 {reasoning}。': 'Recorded cached input {cached}; recorded reasoning {reasoning}.',
|
||||
'缓存与推理属于明细项,不额外叠加至总 Token。单项未返回时显示“未返回”。':
|
||||
'Cache and reasoning are breakdown items and are not added to the total tokens again. Items that were not returned show "Not returned".',
|
||||
'统计起点 {since} UTC;日期趋势按 UTC 自然日汇总。': 'Statistics start at {since} UTC; the daily trend is grouped by UTC calendar day.',
|
||||
'另有 {count} 次历史业务调用未记录 Token': '{count} more historical business calls have no recorded tokens',
|
||||
'这些历史调用没有逐次上游用量记录,不计入以上请求数和已记录 Token 合计。':
|
||||
'These historical calls have no per-request upstream usage records and are not included in the request count or recorded token totals above.',
|
||||
'当前账号与时间范围内暂无上游请求记录': 'No upstream requests for this account and period',
|
||||
'按模型统计': 'By model',
|
||||
'模型 / 服务商': 'Model / provider',
|
||||
'未标记模型': 'Untagged model',
|
||||
'{count} 次未返回': '{count} not returned',
|
||||
'按用途统计': 'By purpose',
|
||||
'用途': 'Purpose',
|
||||
'每日趋势(UTC)': 'Daily trend (UTC)',
|
||||
'日期(UTC)': 'Date (UTC)',
|
||||
'已记录总量趋势': 'Recorded total trend',
|
||||
'未返回': 'Not returned',
|
||||
'未标记': 'Untagged',
|
||||
'实际请求明细': 'Actual request details',
|
||||
'请求明细{error}': 'Request details: {error}',
|
||||
'重试': 'Retry',
|
||||
'共 {total} 次上游请求': '{total} upstream requests in total',
|
||||
'时间(UTC)': 'Time (UTC)',
|
||||
'用途 / 角色': 'Purpose / role',
|
||||
'输入 Token': 'Input tokens',
|
||||
'输出 Token': 'Output tokens',
|
||||
'总 Token': 'Total tokens',
|
||||
'缓存输入': 'Cached input',
|
||||
'推理 Token': 'Reasoning tokens',
|
||||
'尝试次数': 'Attempts',
|
||||
'请求编号': 'Request ID',
|
||||
'回答 / 生成': 'Answer / generation',
|
||||
'成功': 'Succeeded',
|
||||
'失败': 'Failed',
|
||||
'请求明细加载失败,请重试': 'Failed to load request details; please retry',
|
||||
'暂无实际上游请求': 'No actual upstream requests yet',
|
||||
'加载失败,请稍后重试': 'Failed to load; please try again later',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* views/console/permissions.vue、roles.vue、permission-menu.ts,以及 views/_core(登录、个人中心)。
|
||||
*
|
||||
* 后半部分是后端 rbac.PERMISSIONS 里内置权限的名称、一级菜单和页面名——它们由后端以中文返回,
|
||||
* 页面上用 t(item.name) / t(group) / t(page) 显示。rbac.py 增删内置权限时这里一起改;
|
||||
* 管理员自己加的条目(custom)没有译文,原样显示。
|
||||
*/
|
||||
export default {
|
||||
// ── 权限菜单页 permissions.vue ──────────────────────────────────────────────
|
||||
'已新增 {code}。管理员自动拥有它;其他角色去「角色权限」里勾选':
|
||||
'Added {code}. Administrators get it automatically; grant it to other roles under "Roles"',
|
||||
'已删除,各角色上的这项授权也一并收回': 'Deleted. The grant has also been revoked from every role',
|
||||
'名称': 'Name',
|
||||
'权限码': 'Permission code',
|
||||
'类型': 'Type',
|
||||
'来源': 'Source',
|
||||
'已授权角色': 'Granted roles',
|
||||
'菜单链接(左侧菜单里多一个链接)': 'Menu link (adds a link to the left menu)',
|
||||
'权限点(只给角色勾选,不出现在菜单里)': 'Permission point (only granted to roles; not shown in the menu)',
|
||||
'新窗口打开': 'Open in a new window',
|
||||
'在后台内嵌打开(对方网站需允许被内嵌)': 'Open embedded in the admin (the site must allow embedding)',
|
||||
'权限菜单:左侧每个菜单、页面和页面里的操作都在这里,角色权限照这棵树勾选':
|
||||
'Permission menu: every left-menu item, page and on-page action is listed here; role permissions are granted from this tree',
|
||||
'内置项随系统发布,新功能上线会自动出现在这里。你也可以自己加:':
|
||||
'Built-in items ship with the system, and new features appear here automatically. You can also add your own:',
|
||||
':在左侧菜单加一个链接(外部网址或本站路径,新窗口或内嵌打开),只有被授权的角色看得到;':
|
||||
': adds a link to the left menu (an external URL or a path on this site, opened in a new window or embedded); only granted roles can see it;',
|
||||
':只是一个可以给角色勾选的权限码,供以后的功能或对接系统使用。':
|
||||
': just a permission code that can be granted to roles, for future features or integrated systems.',
|
||||
'新增的条目管理员自动拥有,其他角色到「角色权限」里勾选。':
|
||||
'Administrators get new items automatically; grant them to other roles under "Roles".',
|
||||
'这些页面用到的权限码还没在后端登记,角色界面里勾不到':
|
||||
'These permission codes are used by pages but not registered in the backend, so they cannot be granted on the Roles page',
|
||||
':{pages}': ': {pages}',
|
||||
'、': ', ',
|
||||
'(菜单分组)': '(menu group)',
|
||||
'需要在后端 rbac.PERMISSIONS 登记后重新发布。': 'Register them in the backend rbac.PERMISSIONS and release again.',
|
||||
'有 {count} 个权限码是代码里用到、但没登记名称的,已自动放进「未归类」':
|
||||
'{count} permission codes are used in code but have no registered name; they were put under "Uncategorized" automatically',
|
||||
'管理员已自动拥有,可以照常给角色勾选;也可以点「编辑」给它起个名字、挂到对应的菜单下。':
|
||||
'Administrators already have them, and you can grant them to roles as usual. You can also click "Edit" to name one and place it under the right menu.',
|
||||
'内置 {builtin} 项 · 自定义 {custom} 项(其中菜单链接 {links} 个)':
|
||||
'{builtin} built-in · {custom} custom ({links} menu links)',
|
||||
'搜索名称或权限码': 'Search name or permission code',
|
||||
'新增权限点': 'New permission point',
|
||||
'新增菜单链接': 'New menu link',
|
||||
'内嵌': 'Embedded',
|
||||
'新窗口': 'New window',
|
||||
'删除后,各角色上的这项授权也一并收回。确定删除?': 'Deleting also revokes this grant from every role. Delete?',
|
||||
'随系统发布': 'Ships with the system',
|
||||
'ZYT 后台': 'ZYT admin',
|
||||
'查看经营报表': 'View business reports',
|
||||
'(不可改)': '(cannot be changed)',
|
||||
'(可留空,自动生成)': '(optional; generated automatically)',
|
||||
'(资源:动作)': '(resource:action)',
|
||||
'所属菜单(选已有的,或输入新名字新建一级菜单)': 'Menu (pick an existing one, or type a new name to create a top-level menu)',
|
||||
'所属页面(可留空)': 'Page (optional)',
|
||||
'经营报表': 'Business reports',
|
||||
'链接地址': 'Link URL',
|
||||
'https://… 或本站路径,如 /m/': 'https://… or a path on this site, e.g. /m/',
|
||||
'打开方式': 'Open in',
|
||||
'图标(可留空)': 'Icon (optional)',
|
||||
'排序(小的在前)': 'Order (smaller first)',
|
||||
'启用': 'Enable',
|
||||
'停用的菜单链接不出现在左侧菜单': 'Disabled menu links do not appear in the left menu',
|
||||
'这是代码里用到的权限码,保存后改为自定义条目(名称和所属菜单以这里为准)。':
|
||||
'This permission code is used in code. Saving turns it into a custom item (with the name and menu set here).',
|
||||
|
||||
// ── 条目类型与来源 permission-menu.ts ───────────────────────────────────────
|
||||
'按钮': 'Button',
|
||||
'菜单链接': 'Menu link',
|
||||
'页面': 'Page',
|
||||
'权限点': 'Permission point',
|
||||
'内置': 'Built-in',
|
||||
'自定义': 'Custom',
|
||||
'自动登记': 'Auto-registered',
|
||||
|
||||
// ── 角色权限页 roles.vue ────────────────────────────────────────────────────
|
||||
'角色编码不能为空': 'Role code is required',
|
||||
'已保存,权限立即生效(无需重启,也无需重新登录)':
|
||||
'Saved. Permissions take effect immediately (no restart or re-login needed)',
|
||||
'编码': 'Code',
|
||||
'权限(按菜单)': 'Permissions (by menu)',
|
||||
'在用人数': 'Users',
|
||||
'权限按左侧菜单组织:菜单 → 页面 → 页面里的每个按钮':
|
||||
'Permissions follow the left menu: menu → page → each button on the page',
|
||||
'改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。要新增权限点或左侧菜单链接,去「系统管理 → 权限菜单」。':
|
||||
'Changes take effect immediately: no service restart, and users do not need to sign in again. The administrator role cannot lose permissions (nobody could add them back), and built-in roles or roles still in use cannot be deleted. To add permission points or left-menu links, go to "System → Permission Menu".',
|
||||
'角色与权限': 'Roles and permissions',
|
||||
'全部权限': 'All permissions',
|
||||
'无权限': 'No permissions',
|
||||
'确定删除这个角色?': 'Delete this role?',
|
||||
'编辑角色': 'Edit role',
|
||||
'角色编码(字母数字,创建后不可改)': 'Role code (letters and digits; cannot be changed after creation)',
|
||||
'角色名称': 'Role name',
|
||||
'审核员': 'Reviewer',
|
||||
'权限': 'Permissions',
|
||||
'管理员始终拥有全部权限(包括以后新增的),不能修改。':
|
||||
'The administrator always has all permissions (including ones added later) and cannot be changed.',
|
||||
'搜索按钮名称或权限码': 'Search button name or permission code',
|
||||
'全部展开': 'Expand all',
|
||||
'收起到页面': 'Collapse to pages',
|
||||
'全部收起': 'Collapse all',
|
||||
'已选 {held} / {total} 项': '{held} / {total} selected',
|
||||
'勾了按钮但没勾「页面」,左侧菜单里看不到这个页面':
|
||||
'Buttons are checked but "Page" is not, so this page will not show in the left menu',
|
||||
'没有匹配的按钮': 'No matching buttons',
|
||||
'勾菜单或页面 = 勾它下面全部按钮;提交的是最终状态,取消勾选就是收回该权限。勾选按钮时会自动带上它所在的页面和必需的权限。':
|
||||
'Checking a menu or page checks every button under it. What you submit is the final state: unchecking revokes that permission. Checking a button also checks its page and any required permissions.',
|
||||
'这个角色还持有已不存在的权限码,保存时会一并清掉。':
|
||||
'This role still holds permission codes that no longer exist; they will be removed on save.',
|
||||
|
||||
// ── 登录页、个人中心 views/_core ────────────────────────────────────────────
|
||||
'用户名': 'Username',
|
||||
'请输入用户名': 'Enter your username',
|
||||
'密码': 'Password',
|
||||
'请输入密码': 'Enter your password',
|
||||
'请使用管理员分配的账号登录': 'Sign in with the account your administrator assigned to you',
|
||||
'真羊 AI 客服 · 管理后台': 'Zhenyang AI Customer Service · Admin',
|
||||
'个人中心': 'Profile',
|
||||
'基本设置': 'Basic settings',
|
||||
'安全设置': 'Security settings',
|
||||
'修改密码': 'Change Password',
|
||||
'新消息提醒': 'New message alerts',
|
||||
'账户密码': 'Account password',
|
||||
'其他用户的消息将以站内信的形式通知': 'Messages from other users are delivered as in-site notifications',
|
||||
'系统消息': 'System message',
|
||||
'系统消息将以站内信的形式通知': 'System messages are delivered as in-site notifications',
|
||||
'待办任务': 'To-do tasks',
|
||||
'待办任务将以站内信的形式通知': 'To-do tasks are delivered as in-site notifications',
|
||||
'当前密码强度:强': 'Current password strength: strong',
|
||||
'密保手机': 'Security phone',
|
||||
'已绑定手机:138****8293': 'Bound phone: 138****8293',
|
||||
'密保问题': 'Security question',
|
||||
'未设置密保问题,密保问题可有效保护账户安全': 'No security question set; a security question helps keep your account safe',
|
||||
'备用邮箱': 'Backup email',
|
||||
'已绑定邮箱:ant***sign.com': 'Bound email: ant***sign.com',
|
||||
'MFA 设备': 'MFA device',
|
||||
'未绑定 MFA 设备,绑定后,可以进行二次确认': 'No MFA device bound; once bound, it can be used for two-step verification',
|
||||
'旧密码': 'Old password',
|
||||
'请输入旧密码': 'Enter your old password',
|
||||
'新密码': 'New password',
|
||||
'请输入新密码': 'Enter a new password',
|
||||
'确认密码': 'Confirm password',
|
||||
'请再次输入新密码': 'Enter the new password again',
|
||||
'两次输入的密码不一致': 'The passwords do not match',
|
||||
'密码修改成功': 'Password changed',
|
||||
'用户': 'User',
|
||||
'测试': 'Test',
|
||||
'姓名': 'Name',
|
||||
'个人简介': 'Bio',
|
||||
|
||||
// ── 后端 rbac.PERMISSIONS:一级菜单(group_name)────────────────────────────
|
||||
'AI 模型': 'AI Models',
|
||||
'桌面端': 'Desktop',
|
||||
'聊天归档': 'Chat Archive',
|
||||
'知识中心': 'Knowledge',
|
||||
'系统管理': 'System',
|
||||
'未归类': 'Uncategorized',
|
||||
|
||||
// ── 后端 rbac.PERMISSIONS:页面(page_name),与左侧菜单同名的和 menu.ts 译法一致 ──
|
||||
'模型清单': 'Model List',
|
||||
'角色编排': 'Role Orchestration',
|
||||
'智能体': 'Agents',
|
||||
'调用统计': 'Call Statistics',
|
||||
'客户端账号': 'Client Accounts',
|
||||
'客户端配置': 'Client Settings',
|
||||
'版本升级': 'Version Upgrades',
|
||||
'归档概览': 'Archive Overview',
|
||||
'会话与消息': 'Conversations',
|
||||
'导出中心': 'Exports',
|
||||
'人员唯一标识': 'Person Identities',
|
||||
'COS 存储': 'COS Storage',
|
||||
'导入接口': 'Import API',
|
||||
'知识加工与检索': 'Knowledge Workbench',
|
||||
'角色权限': 'Roles',
|
||||
'权限菜单': 'Permission Menu',
|
||||
'用户管理': 'Users',
|
||||
'审计日志': 'Audit Log',
|
||||
// 没填页面的权限点归到这一页(permission-menu.ts 的 pageOf)
|
||||
'其他': 'Other',
|
||||
|
||||
// ── 后端 rbac.PERMISSIONS:权限名称(name),共 65 项,按登记顺序 ────────────
|
||||
'查看模型清单': 'View model list',
|
||||
'新增模型': 'New model',
|
||||
'编辑模型(含启用 / 停用、更换密钥)': 'Edit model (incl. enable / disable, change key)',
|
||||
'测连通': 'Test connection',
|
||||
'删除模型': 'Delete model',
|
||||
'查看角色编排': 'View role orchestration',
|
||||
'保存角色编排(生成新版本)': 'Save role orchestration (creates a new version)',
|
||||
'查看智能体与协作方案': 'View agents and collaboration plans',
|
||||
'新增智能体': 'New agent',
|
||||
'编辑智能体': 'Edit agent',
|
||||
'删除智能体': 'Delete agent',
|
||||
'保存协作方案(生成新版本)': 'Save collaboration plan (creates a new version)',
|
||||
'查看调用统计、Token 用量与知识引用记录': 'View call statistics, token usage and knowledge citations',
|
||||
'查看客户端账号': 'View client accounts',
|
||||
'修改账号策略(启用 / 禁用、自动备份)': 'Change account policy (enable / disable, auto backup)',
|
||||
'分配可查看该账号数据的管理端用户': "Assign admin users who can view the account's data",
|
||||
'强制下线': 'Force sign-out',
|
||||
'查看全部客户端数据(不受账号分配限制)': 'View all client data (not limited by account assignment)',
|
||||
'查看客户端配置': 'View client settings',
|
||||
'保存并发布客户端配置(能力开关、审核规则、MCP 等)':
|
||||
'Save and publish client settings (feature switches, review rules, MCP, etc.)',
|
||||
'查看版本升级与升级进度': 'View version upgrades and upgrade progress',
|
||||
'上传安装包': 'Upload installer',
|
||||
'发布客户端版本': 'Publish client version',
|
||||
'查看归档概览': 'View archive overview',
|
||||
'查看聊天正文与素材': 'View chat content and media',
|
||||
'查看导出记录': 'View export records',
|
||||
'新建导出': 'New export',
|
||||
'下载导出文件': 'Download export files',
|
||||
'查看人员唯一标识与患者关联': 'View person identities and patient links',
|
||||
'确认绑定人员标识': 'Confirm person identity binding',
|
||||
'搜索并关联患者': 'Search and link patients',
|
||||
'解除患者关联': 'Unlink patient',
|
||||
'查看 COS 存储配置与用量': 'View COS storage settings and usage',
|
||||
'保存存储配置': 'Save storage settings',
|
||||
'测试连接': 'Test connection',
|
||||
'导入聊天记录': 'Import chat records',
|
||||
'查看知识中心(知识列表、详情、整理任务)': 'View knowledge (list, details, curation jobs)',
|
||||
'整理聊天记录(新建整理)': 'Curate chat records (new curation)',
|
||||
'暂停 / 继续 / 取消整理、换模型': 'Pause / resume / cancel curation, switch model',
|
||||
'修改整理设置': 'Change curation settings',
|
||||
'开关新聊天自动整理': 'Toggle auto-curation of new chats',
|
||||
'编辑知识(保存草稿)': 'Edit knowledge (save draft)',
|
||||
'恢复历史版本为草稿': 'Restore a past version as draft',
|
||||
'删除已驳回知识': 'Delete rejected knowledge',
|
||||
'审核通过(含批量审核)': 'Approve (incl. batch review)',
|
||||
'驳回': 'Reject',
|
||||
'一键审核(后台审核任务)': 'One-click review (background review job)',
|
||||
'发布知识(含发布选中)': 'Publish knowledge (incl. publish selected)',
|
||||
'停用已发布知识': 'Disable published knowledge',
|
||||
'开关「客服使用知识」': 'Toggle "Use knowledge in customer service"',
|
||||
'检索测试': 'Search test',
|
||||
'导出已发布知识': 'Export published knowledge',
|
||||
'查看角色': 'View role',
|
||||
'新增角色': 'New role',
|
||||
'修改角色名称与权限': 'Edit role name and permissions',
|
||||
'删除角色': 'Delete role',
|
||||
'查看权限菜单': 'View permission menu',
|
||||
'新增权限点、菜单链接': 'New permission point / menu link',
|
||||
'编辑权限点、菜单链接': 'Edit permission point / menu link',
|
||||
'删除自定义权限点、菜单链接': 'Delete custom permission point / menu link',
|
||||
'查看用户': 'View users',
|
||||
'新增用户': 'New user',
|
||||
'修改用户角色': 'Change user role',
|
||||
'启用 / 停用用户': 'Enable / disable user',
|
||||
'查看审计日志': 'View audit log',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,10 @@
|
||||
/** views/console/users.vue */
|
||||
export default {
|
||||
'用户管理': 'Users',
|
||||
'新增用户': 'New user',
|
||||
'待改密': 'Must change password',
|
||||
'用户名和密码不能为空': 'Username and password are required',
|
||||
'已创建,该用户首次登录必须先改密码': 'Created. The user must change the password at first login',
|
||||
'角色已更新,立即生效': 'Role updated; it takes effect immediately',
|
||||
'初始密码(至少 10 位,需同时含字母和数字)': 'Initial password (at least 10 characters, letters and digits)',
|
||||
} as Record<string, string>;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 英文版的完整性检查。
|
||||
*
|
||||
* 1. 源码里(去掉注释和 t('…') 之后)不能再有中文——界面上的每一句话都要经过 t()。
|
||||
* 确实不该翻译的(比如要和后端返回的中文原样比较的值),在同一行写 i18n-ignore 注释。
|
||||
* 2. 每个 t('中文') 在英文词典里都有译文,译文里没有中文,占位符 {xxx} 与原文一致。
|
||||
* 3. 路由标题(左侧菜单)都有译文。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import routes from '../router/routes/modules/console';
|
||||
import menuTitles from './en/menu';
|
||||
import { EN_DICTIONARY } from './t';
|
||||
|
||||
const HAN = /[一-鿿]/;
|
||||
const sources = import.meta.glob('../**/*.{ts,vue}', { eager: true, import: 'default', query: '?raw' }) as Record<string, string>;
|
||||
const files = Object.entries(sources).filter(([path]) =>
|
||||
// 词典本身(locales/ 下的文件,glob 给的路径是 ./en/xxx.ts)不查
|
||||
!path.endsWith('.test.ts') && !path.startsWith('./') && !path.includes('/locales/'),
|
||||
);
|
||||
|
||||
const CALL = /\bt\(\s*(?:'((?:[^'\\\n]|\\.)*)'|"((?:[^"\\\n]|\\.)*)"|`((?:[^`\\$]|\\.)*)`)/g;
|
||||
|
||||
function stripComments(text: string): string {
|
||||
return text
|
||||
.replaceAll(/<!--[\s\S]*?-->/g, (m) => m.replaceAll(/[^\n]/g, ' '))
|
||||
.replaceAll(/\/\*[\s\S]*?\*\//g, (m) => m.replaceAll(/[^\n]/g, ' '))
|
||||
.replaceAll(/(^|\s)\/\/.*$/gm, '$1');
|
||||
}
|
||||
|
||||
function keysIn(text: string): string[] {
|
||||
return [...text.matchAll(CALL)].map((m) => m[1] ?? m[2] ?? m[3] ?? '');
|
||||
}
|
||||
|
||||
const placeholders = (text: string) => [...text.matchAll(/\{(\w+)\}/g)].map((m) => m[1]).sort();
|
||||
|
||||
describe('English version coverage', () => {
|
||||
it('has no Chinese outside t() and comments', () => {
|
||||
const leftovers: string[] = [];
|
||||
for (const [path, raw] of files) {
|
||||
let code = stripComments(raw).replaceAll(CALL, 't(__)');
|
||||
// 路由的 meta.title 由框架用 $t() 翻译,第三条用例单独检查
|
||||
if (path.startsWith('../router/routes/')) code = code.replaceAll(/title: '[^']*'/g, "title: ''");
|
||||
const lines = code.split('\n');
|
||||
lines.forEach((line, index) => {
|
||||
if (HAN.test(line) && !raw.split('\n')[index]?.includes('i18n-ignore')) {
|
||||
leftovers.push(`${path}:${index + 1}: ${line.trim().slice(0, 80)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
expect(leftovers).toEqual([]);
|
||||
});
|
||||
|
||||
it('translates every t() text with matching placeholders', () => {
|
||||
const problems: string[] = [];
|
||||
for (const [path, raw] of files) {
|
||||
for (const key of keysIn(stripComments(raw))) {
|
||||
if (!HAN.test(key)) continue;
|
||||
const english = EN_DICTIONARY[key];
|
||||
if (!english) problems.push(`${path}: missing「${key}」`);
|
||||
else if (HAN.test(english)) problems.push(`${path}: Chinese left in「${english}」`);
|
||||
else if (placeholders(key).join() !== placeholders(english).join()) problems.push(`${path}: placeholders differ「${key}」`);
|
||||
}
|
||||
}
|
||||
expect(problems).toEqual([]);
|
||||
});
|
||||
|
||||
it('translates every menu title', () => {
|
||||
const titles: string[] = [];
|
||||
const walk = (list: typeof routes) => {
|
||||
for (const route of list) {
|
||||
const title = route.meta?.title;
|
||||
if (typeof title === 'string' && HAN.test(title)) titles.push(title);
|
||||
if (route.children) walk(route.children);
|
||||
}
|
||||
};
|
||||
walk(routes);
|
||||
expect(titles.filter((title) => !(menuTitles as Record<string, string>)[title])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,12 @@ async function loadMessages(lang: SupportedLanguagesType) {
|
||||
localesMap[lang]?.(),
|
||||
loadThirdPartyMessage(lang),
|
||||
]);
|
||||
// 左侧菜单、页签、面包屑、浏览器标题由框架用 $t(路由标题) 翻译。路由标题写的是中文,
|
||||
// 英文下把「中文标题 → 英文」作为顶层词条合并进去;中文下查不到词条,原样显示中文。
|
||||
if (lang === 'en-US') {
|
||||
const { default: menuTitles } = await import('./en/menu');
|
||||
return { ...appLocaleMessages?.default, ...menuTitles };
|
||||
}
|
||||
return appLocaleMessages?.default;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 业务页面的中英文切换。
|
||||
*
|
||||
* 界面文案直接拿中文原文当键:`t('客服使用知识')`。简体中文原样返回;English 查
|
||||
* `./en/*.ts` 里的词典(中文 → 英文),查不到就退回中文——`i18n-coverage.test.ts`
|
||||
* 会拦住漏翻的。用中文原文当键,代码读起来还是原来的样子,也不用给一千多句话起名字。
|
||||
*
|
||||
* - 占位符写成 `{name}`:`t('已选 {count} 条', { count: 3 })`,英文里用同样的占位符。
|
||||
* - 依赖 `preferences.app.locale`(响应式):在模板、computed、函数里调用会随语言切换刷新;
|
||||
* 写在 setup 顶层的常量只算一次,列定义、下拉选项这类要包成 computed 或函数。
|
||||
* - 后端返回的中文(错误提示、内置权限名称)也可以 `t(text)`:词典里有就翻,没有就原样显示。
|
||||
* - 左侧菜单、页签、面包屑由框架用 `$t(路由标题)` 翻译,见 `locales/index.ts` 里合并的 `menuTitles`。
|
||||
*/
|
||||
import { preferences } from '@vben/preferences';
|
||||
|
||||
const fragments = import.meta.glob<Record<string, string>>('./en/*.ts', { eager: true, import: 'default' });
|
||||
|
||||
/** 中文原文 → 英文。各页面的词典分文件放在 ./en/ 下,这里合并。 */
|
||||
export const EN_DICTIONARY: Record<string, string> = Object.assign({}, ...Object.values(fragments));
|
||||
|
||||
export function isEnglish(): boolean {
|
||||
return preferences.app.locale === 'en-US';
|
||||
}
|
||||
|
||||
export function t(text: string, params?: Record<string, null | number | string | undefined>): string {
|
||||
const template = isEnglish() ? (EN_DICTIONARY[text] ?? text) : text;
|
||||
if (!params) return template;
|
||||
return template.replaceAll(/\{(\w+)\}/g, (whole, key: string) =>
|
||||
key in params ? String(params[key] ?? '') : whole,
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initPreferences } from '@vben/preferences';
|
||||
import { initPreferences, updatePreferences } from '@vben/preferences';
|
||||
import { unmountGlobalLoading } from '@vben/utils';
|
||||
|
||||
import { overridesPreferences, preferencesExtension } from './preferences';
|
||||
@@ -19,6 +19,9 @@ async function initApplication() {
|
||||
namespace,
|
||||
overrides: overridesPreferences,
|
||||
});
|
||||
// 浏览器里缓存的偏好设置优先于 overridesPreferences:以前打开过后台的人缓存里还是「显示铃铛」。
|
||||
// 这套后台没有通知功能,启动时强制关掉(语言、明暗主题这些用户自己选的仍按缓存)。
|
||||
updatePreferences({ widget: { notification: false } });
|
||||
|
||||
// 启动应用并挂载
|
||||
// vue应用主要逻辑及视图
|
||||
|
||||
@@ -28,8 +28,12 @@ export const overridesPreferences = defineOverridesPreferences({
|
||||
// 拦截器会先去尝试续期、失败再登出,白白多一轮请求和一次报错。
|
||||
enableRefreshToken: false,
|
||||
},
|
||||
widget: {
|
||||
// 这套后台没有站内通知功能,顶栏不放铃铛(模板自带的是一铃铛假数据)
|
||||
notification: false,
|
||||
},
|
||||
copyright: {
|
||||
companyName: '真羊',
|
||||
companyName: '真羊', // i18n-ignore 中文原文;切到英文时 bootstrap.ts 换成英文
|
||||
companySiteLink: '',
|
||||
date: '2026',
|
||||
enable: true,
|
||||
|
||||
@@ -9,8 +9,13 @@ import { preferences } from '@vben/preferences';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getAllMenusApi } from '#/api';
|
||||
import { fetchCustomMenus } from '#/api/console';
|
||||
import { BasicLayout, IFrameView } from '#/layouts';
|
||||
import { $t } from '#/locales';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { mergeCustomMenus } from './custom-menus';
|
||||
import { parentPaths, pruneEmptyMenus } from './menu-access';
|
||||
|
||||
const forbiddenComponent = () => import('#/views/_core/fallback/forbidden.vue');
|
||||
|
||||
@@ -22,8 +27,19 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
IFrameView,
|
||||
};
|
||||
|
||||
return await generateAccessible(preferences.app.accessMode, {
|
||||
// 管理员在「权限菜单」里自己加的链接。拿不到(接口异常、旧版后端)就只显示内置
|
||||
// 菜单——不能因为一个附加菜单让整个后台进不去。
|
||||
let routes = options.routes;
|
||||
try {
|
||||
const { menus } = await fetchCustomMenus();
|
||||
routes = mergeCustomMenus(routes, menus, IFrameView, window.location.origin);
|
||||
} catch (error) {
|
||||
console.warn(t('自定义菜单加载失败,只显示内置菜单'), error);
|
||||
}
|
||||
|
||||
const access = await generateAccessible(preferences.app.accessMode, {
|
||||
...options,
|
||||
routes,
|
||||
fetchMenuListAsync: async () => {
|
||||
message.loading({
|
||||
content: `${$t('common.loadingMenu')}...`,
|
||||
@@ -37,6 +53,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
|
||||
layoutMap,
|
||||
pageMap,
|
||||
});
|
||||
return { ...access, accessibleMenus: pruneEmptyMenus(access.accessibleMenus, parentPaths(routes)) };
|
||||
}
|
||||
|
||||
export { generateAccess };
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { CustomMenuItem } from '../api/console';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { absoluteLink, customGroupPath, customMenuPath, mergeCustomMenus } from './custom-menus';
|
||||
|
||||
const view = {};
|
||||
const builtin: RouteRecordRaw[] = [{
|
||||
path: '/desktop', name: 'DesktopCenter', meta: { title: '桌面端', order: 1 },
|
||||
children: [{ path: 'config', name: 'DesktopConfig', component: view, meta: { title: '客户端配置', authority: ['config:read'] } }],
|
||||
} as RouteRecordRaw];
|
||||
|
||||
function menu(code: string, group: string, extra: Partial<CustomMenuItem> = {}): CustomMenuItem {
|
||||
return { code, group_name: group, icon: '', link_mode: 'tab', link_url: 'https://zyt.example/admin', name: code, sort_order: 0, ...extra };
|
||||
}
|
||||
|
||||
describe('custom menu routes', () => {
|
||||
it('returns the built-in routes untouched when there is nothing to add', () => {
|
||||
expect(mergeCustomMenus(builtin, [], view, 'http://h')).toBe(builtin);
|
||||
});
|
||||
|
||||
it('hangs links under a built-in menu with the same title, after its pages', () => {
|
||||
const merged = mergeCustomMenus(builtin, [menu('link:zyt', '桌面端', { sort_order: 3 })], view, 'http://h');
|
||||
expect(merged).toHaveLength(1);
|
||||
const children = merged[0]!.children!;
|
||||
expect(children.map((c) => c.name)).toEqual(['DesktopConfig', 'CustomMenu:link:zyt']);
|
||||
expect(children[1]!.path).toBe('custom-link.zyt');
|
||||
expect(children[1]!.meta).toMatchObject({
|
||||
authority: ['link:zyt'], icon: 'lucide:link', link: 'https://zyt.example/admin', order: 1003,
|
||||
});
|
||||
// 内置路由表本身不被改动(重新登录时还要用)
|
||||
expect(builtin[0]!.children).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates new top-level menus for unknown groups and embeds iframe links', () => {
|
||||
const merged = mergeCustomMenus(builtin, [
|
||||
menu('link:a', '常用链接', { link_mode: 'iframe', link_url: '/m/' }),
|
||||
menu('link:b', '常用链接'),
|
||||
menu('link:c', '报表'),
|
||||
], view, 'http://192.168.110.111:8369');
|
||||
expect(merged.map((r) => r.meta?.title)).toEqual(['桌面端', '常用链接', '报表']);
|
||||
const links = merged[1]!;
|
||||
expect(links.path).toBe(customGroupPath('常用链接'));
|
||||
expect(links.meta?.order).toBe(50);
|
||||
expect(links.children!.map((c) => c.name)).toEqual(['CustomMenu:link:a', 'CustomMenu:link:b']);
|
||||
expect(links.children![0]!.meta).toMatchObject({ iframeSrc: 'http://192.168.110.111:8369/m/' });
|
||||
expect(links.children![0]!.meta).not.toHaveProperty('link');
|
||||
expect(merged[2]!.meta?.order).toBe(51);
|
||||
});
|
||||
|
||||
it('builds stable, unique paths', () => {
|
||||
expect(customMenuPath('a:b-c')).not.toBe(customMenuPath('a-b:c'));
|
||||
expect(customGroupPath('常用链接')).toBe(customGroupPath('常用链接'));
|
||||
expect(customGroupPath('常用链接')).toMatch(/^\/custom-menu-[0-9a-z]+$/);
|
||||
});
|
||||
|
||||
it('turns site paths into absolute links but keeps full URLs', () => {
|
||||
expect(absoluteLink('/m/', 'http://h:8369')).toBe('http://h:8369/m/');
|
||||
expect(absoluteLink('https://x.example/a?b=1', 'http://h')).toBe('https://x.example/a?b=1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 管理员在「权限菜单」页自己加的菜单链接 → 左侧菜单的路由。
|
||||
*
|
||||
* 后端 `/custom-menus` 只返回当前用户有权看到的链接;这里再挂上 `authority`,
|
||||
* 走和内置页面同一套前端过滤。所属菜单和某个内置一级菜单同名,就挂到它下面;
|
||||
* 否则新建一个一级菜单,排在内置菜单之后。
|
||||
*/
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { CustomMenuItem } from '../api/console';
|
||||
|
||||
/** 自定义一级菜单排在内置菜单(order 0–3)之后 */
|
||||
const CUSTOM_GROUP_ORDER = 50;
|
||||
/** 挂进内置菜单的链接排在内置页面之后 */
|
||||
const CUSTOM_ITEM_ORDER = 1000;
|
||||
|
||||
/** 权限码里只有小写字母、数字、连字符和冒号;冒号换成点,路径唯一且合法。 */
|
||||
export function customMenuPath(code: string): string {
|
||||
return `custom-${code.replaceAll(':', '.')}`;
|
||||
}
|
||||
|
||||
/** 同一个菜单名每次都得到同一个路径(刷新、收藏都还能打开)。 */
|
||||
export function customGroupPath(group: string): string {
|
||||
let hash = 0;
|
||||
for (const char of group) {
|
||||
hash = (Math.imul(hash, 31) + (char.codePointAt(0) ?? 0)) >>> 0;
|
||||
}
|
||||
return `/custom-menu-${hash.toString(36)}`;
|
||||
}
|
||||
|
||||
/** 本站路径(/m/)补成绝对地址:菜单按链接打开新窗口时只认 http(s)。 */
|
||||
export function absoluteLink(url: string, origin: string): string {
|
||||
return /^https?:\/\//i.test(url) ? url : new URL(url, origin).href;
|
||||
}
|
||||
|
||||
export function mergeCustomMenus(
|
||||
routes: RouteRecordRaw[],
|
||||
menus: CustomMenuItem[],
|
||||
component: RouteRecordRaw['component'],
|
||||
origin: string,
|
||||
): RouteRecordRaw[] {
|
||||
if (menus.length === 0) return routes;
|
||||
const result = [...routes];
|
||||
const extra = new Map<string, RouteRecordRaw>();
|
||||
for (const menu of menus) {
|
||||
const target = absoluteLink(menu.link_url, origin);
|
||||
const leaf = {
|
||||
component,
|
||||
meta: {
|
||||
authority: [menu.code],
|
||||
icon: menu.icon || 'lucide:link',
|
||||
order: CUSTOM_ITEM_ORDER + menu.sort_order,
|
||||
title: menu.name,
|
||||
...(menu.link_mode === 'iframe' ? { iframeSrc: target } : { link: target }),
|
||||
},
|
||||
name: `CustomMenu:${menu.code}`,
|
||||
path: customMenuPath(menu.code),
|
||||
} as RouteRecordRaw;
|
||||
const index = result.findIndex(
|
||||
(route) => route.meta?.title === menu.group_name && !!route.children?.length,
|
||||
);
|
||||
if (index !== -1) {
|
||||
const parent = result[index]!;
|
||||
result[index] = { ...parent, children: [...(parent.children ?? []), leaf] } as RouteRecordRaw;
|
||||
continue;
|
||||
}
|
||||
const group =
|
||||
extra.get(menu.group_name) ??
|
||||
({
|
||||
children: [],
|
||||
meta: {
|
||||
icon: 'lucide:link',
|
||||
order: CUSTOM_GROUP_ORDER + extra.size,
|
||||
title: menu.group_name,
|
||||
},
|
||||
name: `CustomMenuGroup:${menu.group_name}`,
|
||||
path: customGroupPath(menu.group_name),
|
||||
} as RouteRecordRaw);
|
||||
group.children!.push(leaf);
|
||||
extra.set(menu.group_name, group);
|
||||
}
|
||||
return [...result, ...extra.values()];
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import { generateAccess } from './access';
|
||||
import { firstMenuPath } from './menu-access';
|
||||
|
||||
/**
|
||||
* 通用守卫配置
|
||||
@@ -87,6 +88,11 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 是否已经生成过动态路由
|
||||
if (accessStore.isAccessChecked) {
|
||||
// 点 Logo / 回首页时同理:首页没权限就去第一个能打开的页面
|
||||
if (to.name === 'FallbackNotFound' && to.path === preferences.app.defaultHomePath) {
|
||||
const fallback = firstMenuPath(accessStore.accessMenus);
|
||||
if (fallback) return { path: fallback, replace: true };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -111,9 +117,13 @@ function setupAccessGuard(router: Router) {
|
||||
(to.path === preferences.app.defaultHomePath
|
||||
? userInfo.homePath || preferences.app.defaultHomePath
|
||||
: to.fullPath)) as string;
|
||||
const target = router.resolve(decodeURIComponent(redirectPath));
|
||||
// 默认首页没权限(比如只管知识审核的角色看不了调用统计):去左侧第一个能打开的页面,别落在 404
|
||||
const home = [preferences.app.defaultHomePath, userInfo.homePath].includes(target.path);
|
||||
const fallback = home && target.name === 'FallbackNotFound' ? firstMenuPath(accessibleMenus) : undefined;
|
||||
|
||||
return {
|
||||
...router.resolve(decodeURIComponent(redirectPath)),
|
||||
...(fallback ? router.resolve(fallback) : target),
|
||||
replace: true,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { firstMenuPath, parentPaths, pruneEmptyMenus } from './menu-access';
|
||||
|
||||
const routes = [
|
||||
{ children: [{ path: 'catalog' }], path: '/model' },
|
||||
{ children: [{ path: 'accounts' }], path: '/desktop' },
|
||||
{ children: [{ path: 'center' }], path: '/knowledge' },
|
||||
{ path: '/profile' },
|
||||
] as unknown as RouteRecordRaw[];
|
||||
|
||||
describe('menus after button-level filtering', () => {
|
||||
it('drops top-level menus whose pages were all filtered out', () => {
|
||||
const menus = [
|
||||
{ children: [{ path: '/model/catalog' }], path: '/model' },
|
||||
{ children: [], path: '/desktop' },
|
||||
{ children: [{ path: '/knowledge/center' }], path: '/knowledge' },
|
||||
{ path: '/profile' },
|
||||
];
|
||||
expect(pruneEmptyMenus(menus, parentPaths(routes)).map((menu) => menu.path)).toEqual(['/model', '/knowledge', '/profile']);
|
||||
});
|
||||
|
||||
it('lands on the first page the user can open', () => {
|
||||
expect(firstMenuPath([
|
||||
{ path: '/profile', show: false },
|
||||
{ children: [{ path: 'https://example.com/report' }, { path: '/knowledge/center' }], path: '/knowledge' },
|
||||
])).toBe('/knowledge/center');
|
||||
expect(firstMenuPath([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 权限精确到按钮以后,一个角色可能某个一级菜单下一个页面都打不开。vben 按页面过滤路由时
|
||||
* 会把这样的一级菜单留下来——左侧出现点进去什么都没有的「桌面端」「系统管理」,这里去掉。
|
||||
* 另外默认首页(调用统计)不是人人都有权限:没有的话登录后改去左侧菜单里第一个能打开的页面,
|
||||
* 而不是落在 404。
|
||||
*/
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
interface MenuLike {
|
||||
children?: MenuLike[];
|
||||
path: string;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
/** 路由表里带子页面的一级菜单的路径(含管理员自己加的菜单分组)。 */
|
||||
export function parentPaths(routes: RouteRecordRaw[]): Set<string> {
|
||||
return new Set(routes.filter((route) => !!route.children?.length).map((route) => route.path));
|
||||
}
|
||||
|
||||
/** 去掉子页面全被过滤掉的一级菜单。 */
|
||||
export function pruneEmptyMenus<T extends MenuLike>(menus: T[], parents: Set<string>): T[] {
|
||||
return menus.flatMap((menu) => {
|
||||
const children = menu.children ? pruneEmptyMenus(menu.children as T[], parents) : menu.children;
|
||||
if (parents.has(menu.path) && !children?.length) return [];
|
||||
return [{ ...menu, children }];
|
||||
});
|
||||
}
|
||||
|
||||
/** 左侧菜单里第一个能打开的站内页面(外链菜单的 path 是网址,跳过)。 */
|
||||
export function firstMenuPath(menus: MenuLike[]): string | undefined {
|
||||
for (const menu of menus) {
|
||||
if (menu.show === false) continue;
|
||||
if (menu.children?.length) {
|
||||
const found = firstMenuPath(menu.children);
|
||||
if (found) return found;
|
||||
} else if (menu.path.startsWith('/')) {
|
||||
return menu.path;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -8,6 +8,10 @@ import type { RouteRecordRaw } from 'vue-router';
|
||||
*
|
||||
* 前端只负责"看不见",后端负责"进不去"。两者缺一不可:只做前端等于没做,
|
||||
* 任何人拿 curl 都能绕过去;只做后端则是用户点进去才报错,体验很差。
|
||||
*
|
||||
* 加页面时,`authority` 里的码要在后端 `rbac.PERMISSIONS` 登记到对应的菜单和页面,
|
||||
* 角色界面才能按菜单勾选;漏了的话「系统管理 → 权限菜单」页会标出来。
|
||||
* 多个码是"任一即可"。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -47,7 +51,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
icon: 'lucide:git-branch',
|
||||
title: '角色编排',
|
||||
authority: ['model:read'],
|
||||
authority: ['model-plan:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -112,7 +116,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
icon: 'lucide:package-check',
|
||||
title: '版本升级',
|
||||
authority: ['config:read'],
|
||||
authority: ['release:read', 'release:write'],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -163,7 +167,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
icon: 'lucide:contact-round',
|
||||
title: '人员唯一标识',
|
||||
authority: ['im:identity:write'],
|
||||
authority: ['im:identity:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -173,7 +177,7 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
icon: 'lucide:cloud-cog',
|
||||
title: 'COS 存储',
|
||||
authority: ['im:storage:write'],
|
||||
authority: ['im:storage:read'],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -194,7 +198,17 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: {
|
||||
icon: 'lucide:key-round',
|
||||
title: '角色权限',
|
||||
authority: ['user:read'],
|
||||
authority: ['role:read', 'role:write'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'PermissionMenu',
|
||||
path: 'permissions',
|
||||
component: () => import('#/views/console/permissions.vue'),
|
||||
meta: {
|
||||
icon: 'lucide:list-tree',
|
||||
title: '权限菜单',
|
||||
authority: ['permission:read'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 后台内容区是 overflow-x: hidden(vben 布局为了切页动画),页面里任何比内容区宽的东西都会被直接裁掉,
|
||||
* 而且拖不出来。最常见的是表格:列一多、窗口一窄,右边几列(更新时间、操作)就看不到了。
|
||||
* 这里让表格在自己的容器里横向滚动——放得下时没有变化,放不下时表格底部出现滚动条,页面其它部分不受影响。
|
||||
* 设置了 scroll.x 的表格 ant-design-vue 会自己写内联的 overflow,这条规则不影响它们。
|
||||
*/
|
||||
.ant-table-wrapper .ant-table-content {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
* 窄窗口里,自动排版的表格会把列压到「一个字一行」(表头竖着排)。表头不换行,列至少和表头一样宽,
|
||||
* 放不下就交给上面的横向滚动。
|
||||
*/
|
||||
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { computed } from 'vue';
|
||||
|
||||
import { AuthenticationLogin, z } from '@vben/common-ui';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
defineOptions({ name: 'Login' });
|
||||
@@ -17,17 +18,17 @@ const authStore = useAuthStore();
|
||||
const formSchema = computed((): VbenFormSchema[] => [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: { placeholder: '用户名' },
|
||||
componentProps: { placeholder: t('用户名') },
|
||||
fieldName: 'username',
|
||||
label: '用户名',
|
||||
rules: z.string().min(1, { message: '请输入用户名' }),
|
||||
label: t('用户名'),
|
||||
rules: z.string().min(1, { message: t('请输入用户名') }),
|
||||
},
|
||||
{
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: { placeholder: '密码' },
|
||||
componentProps: { placeholder: t('密码') },
|
||||
fieldName: 'password',
|
||||
label: '密码',
|
||||
rules: z.string().min(1, { message: '请输入密码' }),
|
||||
label: t('密码'),
|
||||
rules: z.string().min(1, { message: t('请输入密码') }),
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
@@ -41,8 +42,8 @@ const formSchema = computed((): VbenFormSchema[] => [
|
||||
:show-qrcode-login="false"
|
||||
:show-register="false"
|
||||
:show-third-party-login="false"
|
||||
sub-title="请使用管理员分配的账号登录"
|
||||
title="真羊 AI 客服 · 管理后台"
|
||||
:sub-title="t('请使用管理员分配的账号登录')"
|
||||
:title="t('真羊 AI 客服 · 管理后台')"
|
||||
@submit="authStore.authLogin"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,20 +8,22 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
// 选项文案要随语言切换:写成函数,在 computed 里调用
|
||||
const mockRolesOptions = (): BasicOption[] => [
|
||||
{
|
||||
label: '管理员',
|
||||
label: t('管理员'),
|
||||
value: 'super',
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
label: t('用户'),
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
label: t('测试'),
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
@@ -31,26 +33,26 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
label: t('姓名'),
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
label: t('用户名'),
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
options: mockRolesOptions(),
|
||||
},
|
||||
label: '角色',
|
||||
label: t('角色'),
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
label: t('个人简介'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
@@ -13,21 +15,22 @@ const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
// 页签文案要随语言切换:包成 computed
|
||||
const tabs = computed(() => [
|
||||
{
|
||||
label: '基本设置',
|
||||
label: t('基本设置'),
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
label: t('安全设置'),
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
label: t('修改密码'),
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
label: t('新消息提醒'),
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
@@ -35,7 +38,7 @@ const tabs = ref([
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
title="个人中心"
|
||||
:title="t('个人中心')"
|
||||
:user-info="userStore.userInfo"
|
||||
:tabs="tabs"
|
||||
>
|
||||
|
||||
@@ -3,25 +3,27 @@ import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
label: t('账户密码'),
|
||||
description: t('其他用户的消息将以站内信的形式通知'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
label: t('系统消息'),
|
||||
description: t('系统消息将以站内信的形式通知'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
label: t('待办任务'),
|
||||
description: t('待办任务将以站内信的形式通知'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -7,41 +7,43 @@ import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
label: t('旧密码'),
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
placeholder: t('请输入旧密码'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
label: t('新密码'),
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
placeholder: t('请输入新密码'),
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
label: t('确认密码'),
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
placeholder: t('请再次输入新密码'),
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.string({ error: t('请再次输入新密码') })
|
||||
.min(1, { message: t('请再次输入新密码') })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
message: t('两次输入的密码不一致'),
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
@@ -51,7 +53,7 @@ const formSchema = computed((): VbenFormSchema[] => {
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
message.success('密码修改成功');
|
||||
message.success(t('密码修改成功'));
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -3,37 +3,39 @@ import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
label: t('账户密码'),
|
||||
description: t('当前密码强度:强'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
label: t('密保手机'),
|
||||
description: t('已绑定手机:138****8293'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
label: t('密保问题'),
|
||||
description: t('未设置密保问题,密保问题可有效保护账户安全'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
label: t('备用邮箱'),
|
||||
description: t('已绑定邮箱:ant***sign.com'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
label: t('MFA 设备'),
|
||||
description: t('未绑定 MFA 设备,绑定后,可以进行二次确认'),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveConversation, ArchiveMessage } from '#/api/archive';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '#/api/archive';
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { useArchiveLoad } from './use-archive-load';
|
||||
|
||||
@@ -40,7 +41,7 @@ const mediaUrls = ref<Record<string, string>>({});
|
||||
const accountId = ref(ALL_ACCOUNTS);
|
||||
|
||||
function formatSize(value: number) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '未知大小';
|
||||
if (!Number.isFinite(value) || value <= 0) return t('未知大小');
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let size = value;
|
||||
let index = 0;
|
||||
@@ -74,8 +75,8 @@ function isBrowserAudio(mimeType: string) {
|
||||
|
||||
function attachmentStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
source_not_cached: '源文件未缓存,请先在企业微信中下载',
|
||||
upload_failed: '上传失败,下次启动将自动重试',
|
||||
source_not_cached: t('源文件未缓存,请先在企业微信中下载'),
|
||||
upload_failed: t('上传失败,下次启动将自动重试'),
|
||||
};
|
||||
return labels[status] || status;
|
||||
}
|
||||
@@ -150,36 +151,37 @@ async function loadMessages(targetPage = 0) {
|
||||
);
|
||||
}
|
||||
|
||||
const ORIGIN_LABEL: Record<string, string> = {
|
||||
ai: 'AI 发送', human: '人工发送', system: '系统发送', unknown: '来源未知', customer: '客户发送',
|
||||
};
|
||||
// 文案随语言切换:包成 computed
|
||||
const ORIGIN_LABEL = computed<Record<string, string>>(() => ({
|
||||
ai: t('AI 发送'), human: t('人工发送'), system: t('系统发送'), unknown: t('来源未知'), customer: t('客户发送'),
|
||||
}));
|
||||
const ORIGIN_COLOR: Record<string, string> = { ai: 'purple', human: 'blue', system: 'cyan', customer: 'green' };
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
application: '应用会话',
|
||||
direct_wechat: '微信单聊',
|
||||
direct_wecom: '企微单聊',
|
||||
group: '群聊',
|
||||
service: '客服会话',
|
||||
unknown: '未知',
|
||||
};
|
||||
const TYPE_LABEL = computed<Record<string, string>>(() => ({
|
||||
application: t('应用会话'),
|
||||
direct_wechat: t('微信单聊'),
|
||||
direct_wecom: t('企微单聊'),
|
||||
group: t('群聊'),
|
||||
service: t('客服会话'),
|
||||
unknown: t('未知'),
|
||||
}));
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="会话与消息">
|
||||
<Card :title="t('会话与消息')">
|
||||
<template #extra>
|
||||
<div class="flex items-center gap-2">
|
||||
<ArchiveAccountSelector
|
||||
v-model="accountId"
|
||||
@change="() => { closeConversation(); load(true); }"
|
||||
/>
|
||||
<Button :loading="loading" @click="load(true)">刷新</Button>
|
||||
<Button :loading="loading" @click="load(true)">{{ t('刷新') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
每页显示 50 个会话;打开会话后按时间顺序分页查看消息。
|
||||
{{ t('每页显示 50 个会话;打开会话后按时间顺序分页查看消息。') }}
|
||||
</p>
|
||||
<Table
|
||||
:data-source="conversations"
|
||||
@@ -188,12 +190,12 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
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 },
|
||||
{ title: t('会话'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('类型'), dataIndex: 'conversation_type', key: 'conversation_type', width: 120 },
|
||||
{ title: t('归档账号'), dataIndex: 'source_account', key: 'source_account', width: 150 },
|
||||
{ title: t('消息数'), dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: t('最后消息时间'), dataIndex: 'last_message_at', key: 'last_message_at', width: 210 },
|
||||
{ title: t('操作'), key: 'action', width: 90 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -208,14 +210,14 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
{{ formatDateTime(record.last_message_at) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openConversation(record)">查看</Button>
|
||||
<Button type="link" size="small" @click="openConversation(record)">{{ t('查看') }}</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<div class="mt-4 flex items-center justify-center gap-3">
|
||||
<Button :disabled="page === 0 || loading" @click="load(false, page - 1)">上一页</Button>
|
||||
<span>第 {{ page + 1 }} 页</span>
|
||||
<Button :disabled="!hasMore || loading" @click="load(false, page + 1)">下一页</Button>
|
||||
<Button :disabled="page === 0 || loading" @click="load(false, page - 1)">{{ t('上一页') }}</Button>
|
||||
<span>{{ t('第 {page} 页', { page: page + 1 }) }}</span>
|
||||
<Button :disabled="!hasMore || loading" @click="load(false, page + 1)">{{ t('下一页') }}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -225,18 +227,18 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
width="860"
|
||||
@close="closeConversation"
|
||||
>
|
||||
<Empty v-if="!messageLoading && !messages.length" description="暂无消息" />
|
||||
<Empty v-if="!messageLoading && !messages.length" :description="t('暂无消息')" />
|
||||
<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>
|
||||
<Tag :color="ORIGIN_COLOR[item.message_origin || 'unknown']">{{ ORIGIN_LABEL[item.message_origin || (item.direction === 'inbound' ? 'customer' : 'unknown')] || '来源未知' }}</Tag>
|
||||
{{ item.message_type }}
|
||||
<Tag v-if="item.attachment_count" class="ml-2">{{ item.attachment_count }} 个素材</Tag>
|
||||
<strong class="mr-2 text-gray-600">{{ item.sender_name || t('未知发送者') }}</strong>
|
||||
<Tag :color="ORIGIN_COLOR[item.message_origin || 'unknown']">{{ ORIGIN_LABEL[item.message_origin || (item.direction === 'inbound' ? 'customer' : 'unknown')] || t('来源未知') }}</Tag>
|
||||
{{ t(item.message_type) }}
|
||||
<Tag v-if="item.attachment_count" class="ml-2">{{ t('{count} 个素材', { count: 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 class="whitespace-pre-wrap break-words text-sm">{{ item.content || t('(非文本消息)') }}</div>
|
||||
<div v-if="item.attachments?.length" class="mt-3 space-y-3">
|
||||
<div
|
||||
v-for="attachment in item.attachments"
|
||||
@@ -267,7 +269,7 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
<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 || '未命名素材' }}
|
||||
{{ attachment.original_filename || t('未命名素材') }}
|
||||
</span>
|
||||
<span>{{ formatSize(attachment.size_bytes) }}</span>
|
||||
<Tag v-if="attachment.status !== 'ready'" color="warning">
|
||||
@@ -279,16 +281,16 @@ const TYPE_LABEL: Record<string, string> = {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ attachment.media_type === 'file' || !isBrowserAudio(attachment.mime_type) ? '下载附件' : '查看原文件' }}
|
||||
{{ attachment.media_type === 'file' || !isBrowserAudio(attachment.mime_type) ? t('下载附件') : t('查看原文件') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-center gap-3">
|
||||
<Button :disabled="messagePage === 0 || messageLoading" @click="loadMessages(messagePage - 1)">上一页</Button>
|
||||
<span>第 {{ messagePage + 1 }} 页 · 每页 100 条</span>
|
||||
<Button :disabled="!messageHasMore || messageLoading" @click="loadMessages(messagePage + 1)">下一页</Button>
|
||||
<Button :disabled="messagePage === 0 || messageLoading" @click="loadMessages(messagePage - 1)">{{ t('上一页') }}</Button>
|
||||
<span>{{ t('第 {page} 页 · 每页 100 条', { page: messagePage + 1 }) }}</span>
|
||||
<Button :disabled="!messageHasMore || messageLoading" @click="loadMessages(messagePage + 1)">{{ t('下一页') }}</Button>
|
||||
</div>
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveExportJob } from '#/api/archive';
|
||||
|
||||
import { onActivated, onDeactivated, onUnmounted, reactive, ref } from 'vue';
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { downloadFileFromBlob } from '@vben/utils';
|
||||
import {
|
||||
Button,
|
||||
@@ -24,9 +25,13 @@ import {
|
||||
} from '#/api/archive';
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { useArchiveLoad } from './use-archive-load';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canCreate = computed(() => hasAccessByCodes(['im:export:create']));
|
||||
const canDownload = computed(() => hasAccessByCodes(['im:export:download']));
|
||||
const pageRequest = useArchiveLoad();
|
||||
const loading = pageRequest.loading;
|
||||
const creating = ref(false);
|
||||
@@ -53,11 +58,11 @@ async function load() {
|
||||
async function createJob() {
|
||||
// 导出是按租户切数据的,「全部」下不知道该导谁的,也没法把任务记在某个账号下。
|
||||
if (accountId.value === ALL_ACCOUNTS) {
|
||||
message.warning('导出要先在右上角选择具体的客户端账号');
|
||||
message.warning(t('导出要先在右上角选择具体的客户端账号'));
|
||||
return;
|
||||
}
|
||||
if (!form.formats.length) {
|
||||
message.warning('至少选择一种导出格式');
|
||||
message.warning(t('至少选择一种导出格式'));
|
||||
return;
|
||||
}
|
||||
creating.value = true;
|
||||
@@ -69,7 +74,7 @@ async function createJob() {
|
||||
date_to: form.date_to || undefined,
|
||||
},
|
||||
}, accountId.value);
|
||||
message.success('导出任务已创建');
|
||||
message.success(t('导出任务已创建'));
|
||||
await load();
|
||||
} finally {
|
||||
creating.value = false;
|
||||
@@ -89,9 +94,10 @@ async function download(file: { file_name: string; id: string }) {
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
completed: 'green', failed: 'red', queued: 'default', running: 'blue',
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
completed: '已完成', failed: '失败', queued: '等待中', running: '处理中',
|
||||
};
|
||||
// 文案随语言切换:包成 computed
|
||||
const STATUS_LABEL = computed<Record<string, string>>(() => ({
|
||||
completed: t('已完成'), failed: t('失败'), queued: t('等待中'), running: t('处理中'),
|
||||
}));
|
||||
|
||||
function schedulePolling() {
|
||||
if (timer) clearTimeout(timer);
|
||||
@@ -113,44 +119,44 @@ onUnmounted(stopPolling);
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="新建导出" class="mb-4">
|
||||
<Card v-if="canCreate" :title="t('新建导出')" class="mb-4">
|
||||
<template #extra>
|
||||
<ArchiveAccountSelector v-model="accountId" @change="() => { jobs = []; load(); }" />
|
||||
</template>
|
||||
<Form layout="inline">
|
||||
<Form.Item label="格式">
|
||||
<Form.Item :label="t('格式')">
|
||||
<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="开始日期">
|
||||
<Form.Item :label="t('开始日期')">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_from', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item label="结束日期">
|
||||
<Form.Item :label="t('结束日期')">
|
||||
<DatePicker value-format="YYYY-MM-DD" @update:value="setDate('date_to', $event)" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" :loading="creating" @click="createJob">开始导出</Button>
|
||||
<Button type="primary" :loading="creating" @click="createJob">{{ t('开始导出') }}</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<p class="mb-0 mt-3 text-xs text-gray-500">
|
||||
导出按创建时的截止水位生成;SQL/CSV 流式写出,Excel 超过 90 万行自动拆分工作表。
|
||||
{{ t('导出按创建时的截止水位生成;SQL/CSV 流式写出,Excel 超过 90 万行自动拆分工作表。') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="导出任务">
|
||||
<template #extra><Button :loading="loading" @click="load">刷新</Button></template>
|
||||
<Card :title="t('导出任务')">
|
||||
<template #extra><Button :loading="loading" @click="load">{{ t('刷新') }}</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' },
|
||||
{ title: t('创建时间'), dataIndex: 'created_at', key: 'created_at', width: 205 },
|
||||
{ title: t('格式'), dataIndex: 'formats', key: 'formats', width: 160 },
|
||||
{ title: t('状态'), dataIndex: 'status', key: 'status', width: 110 },
|
||||
{ title: t('进度/行数'), key: 'progress', width: 180 },
|
||||
{ title: t('文件'), key: 'files' },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -163,12 +169,13 @@ onUnmounted(stopPolling);
|
||||
</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>
|
||||
<span v-else>{{ t('{count} 行', { count: 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"
|
||||
v-for="file in record.files" :key="file.id" size="small" :disabled="!canDownload"
|
||||
:title="canDownload ? '' : t('没有下载导出文件的权限')"
|
||||
:loading="downloading === file.id" @click="download(file)"
|
||||
>{{ file.file_name }}</Button>
|
||||
</Space>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Alert, Button, Card, Col, Row, Statistic } from 'ant-design-vue';
|
||||
import { fetchArchiveStats } from '#/api/archive';
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { useArchiveLoad } from './use-archive-load';
|
||||
|
||||
@@ -30,14 +31,14 @@ async function load() {
|
||||
<div class="p-5">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="m-0 text-xl font-semibold">聊天归档</h2>
|
||||
<h2 class="m-0 text-xl font-semibold">{{ t('聊天归档') }}</h2>
|
||||
<p class="mb-0 mt-1 text-sm text-gray-500">
|
||||
企业微信消息、人员、会话与 COS 素材的统一数据视图
|
||||
{{ t('企业微信消息、人员、会话与 COS 素材的统一数据视图') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ArchiveAccountSelector v-model="accountId" @change="load" />
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
<Button :loading="loading" @click="load">{{ t('刷新') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,42 +46,42 @@ async function load() {
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="素材本体不进入数据库"
|
||||
description="图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。"
|
||||
:message="t('素材本体不进入数据库')"
|
||||
:description="t('图片、语音、视频和文件保存在腾讯云 COS;数据库只保存对象地址、版本、SHA-256、CRC64 与校验状态。')"
|
||||
/>
|
||||
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="归档消息" :value="stats?.messages ?? 0" />
|
||||
<Statistic :title="t('归档消息')" :value="stats?.messages ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="会话" :value="stats?.conversations ?? 0" />
|
||||
<Statistic :title="t('会话')" :value="stats?.conversations ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="唯一人员" :value="stats?.people ?? 0" />
|
||||
<Statistic :title="t('唯一人员')" :value="stats?.people ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small" :loading="loading">
|
||||
<Statistic title="COS 素材" :value="stats?.media ?? 0" />
|
||||
<Statistic :title="t('COS 素材')" :value="stats?.media ?? 0" />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row :gutter="16">
|
||||
<Col :span="8">
|
||||
<Card title="素材完整性" size="small" :loading="loading">
|
||||
<Card :title="t('素材完整性')" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">已校验</span>
|
||||
<span class="text-gray-500">{{ t('已校验') }}</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>
|
||||
<span class="text-gray-500">{{ t('校验失败') }}</span>
|
||||
<strong :class="(stats?.media_failed ?? 0) ? 'text-red-600' : ''">
|
||||
{{ stats?.media_failed ?? 0 }}
|
||||
</strong>
|
||||
@@ -88,19 +89,19 @@ async function load() {
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="8">
|
||||
<Card title="处理任务" size="small" :loading="loading">
|
||||
<Card :title="t('处理任务')" size="small" :loading="loading">
|
||||
<div class="flex justify-between py-2">
|
||||
<span class="text-gray-500">导入批次</span><strong>{{ stats?.imports ?? 0 }}</strong>
|
||||
<span class="text-gray-500">{{ t('导入批次') }}</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>
|
||||
<span class="text-gray-500">{{ t('导出任务') }}</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 :title="t('最新水位')" size="small" :loading="loading">
|
||||
<p class="mb-1 text-gray-500">{{ t('最后一条消息时间(UTC)') }}</p>
|
||||
<strong>{{ stats?.last_message_at || t('尚未导入') }}</strong>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -6,7 +6,9 @@ import type {
|
||||
ZytPatientCandidate,
|
||||
} from '#/api/archive';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import {
|
||||
Button,
|
||||
@@ -33,9 +35,14 @@ import {
|
||||
} from '#/api/archive';
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { useArchiveLoad } from './use-archive-load';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canBindIdentity = computed(() => hasAccessByCodes(['im:identity:write']));
|
||||
const canBindPatient = computed(() => hasAccessByCodes(['im:patient:bind']));
|
||||
const canUnbindPatient = computed(() => hasAccessByCodes(['im:patient:unbind']));
|
||||
const pageRequest = useArchiveLoad();
|
||||
const loading = pageRequest.loading;
|
||||
const binding = ref(false);
|
||||
@@ -48,16 +55,17 @@ const selected = ref<ArchivePersonDetail | null>(null);
|
||||
const patientCandidates = ref<ZytPatientCandidate[]>([]);
|
||||
const accountId = ref(ALL_ACCOUNTS);
|
||||
const relationType = ref<PatientBinding['relation_type']>('self');
|
||||
const relationOptions: Array<{
|
||||
// 文案随语言切换:包成 computed
|
||||
const relationOptions = computed<Array<{
|
||||
label: string;
|
||||
value: PatientBinding['relation_type'];
|
||||
}> = [
|
||||
{ label: '本人', value: 'self' },
|
||||
{ label: '家属', value: 'family' },
|
||||
{ label: '监护人', value: 'guardian' },
|
||||
{ label: '代咨询', value: 'proxy' },
|
||||
{ label: '其他', value: 'other' },
|
||||
];
|
||||
}>>(() => [
|
||||
{ label: t('本人'), value: 'self' },
|
||||
{ label: t('家属'), value: 'family' },
|
||||
{ label: t('监护人'), value: 'guardian' },
|
||||
{ label: t('代咨询'), value: 'proxy' },
|
||||
{ label: t('其他'), value: 'other' },
|
||||
]);
|
||||
const identity = reactive({
|
||||
external_id: '',
|
||||
identity_type: 'wecom_userid',
|
||||
@@ -86,7 +94,7 @@ async function openPerson(record: Record<string, any>) {
|
||||
*/
|
||||
function needOneAccount(): boolean {
|
||||
if (accountId.value === ALL_ACCOUNTS) {
|
||||
message.warning('这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属');
|
||||
message.warning(t('这一步要先在右上角选择具体的客户端账号,「全部」下无法确定归属'));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -95,7 +103,7 @@ function needOneAccount(): boolean {
|
||||
async function bind() {
|
||||
if (needOneAccount()) return;
|
||||
if (!selected.value || !identity.external_id.trim()) {
|
||||
message.warning('请输入企业微信人员 ID');
|
||||
message.warning(t('请输入企业微信人员 ID'));
|
||||
return;
|
||||
}
|
||||
binding.value = true;
|
||||
@@ -104,7 +112,7 @@ async function bind() {
|
||||
await bindArchiveIdentity(selected.value.id, identity, accountId.value)
|
||||
).person;
|
||||
identity.external_id = '';
|
||||
message.success('人员标识已绑定');
|
||||
message.success(t('人员标识已绑定'));
|
||||
await load();
|
||||
} finally {
|
||||
binding.value = false;
|
||||
@@ -114,11 +122,11 @@ async function bind() {
|
||||
async function searchPatients() {
|
||||
if (needOneAccount()) return;
|
||||
if (accountId.value <= 0) {
|
||||
message.warning('请先选择已经登录过桌面软件的客户端账号');
|
||||
message.warning(t('请先选择已经登录过桌面软件的客户端账号'));
|
||||
return;
|
||||
}
|
||||
if (!patientKeyword.value.trim()) {
|
||||
message.warning('请输入患者姓名、手机号或患者编号');
|
||||
message.warning(t('请输入患者姓名、手机号或患者编号'));
|
||||
return;
|
||||
}
|
||||
patientSearching.value = true;
|
||||
@@ -126,7 +134,7 @@ async function searchPatients() {
|
||||
patientCandidates.value = (
|
||||
await searchZytPatients(patientKeyword.value.trim(), accountId.value)
|
||||
).items;
|
||||
if (!patientCandidates.value.length) message.info('ZYT 中未找到匹配患者');
|
||||
if (!patientCandidates.value.length) message.info(t('ZYT 中未找到匹配患者'));
|
||||
} finally {
|
||||
patientSearching.value = false;
|
||||
}
|
||||
@@ -148,7 +156,7 @@ async function bindPatient(candidate: Record<string, any>) {
|
||||
accountId.value,
|
||||
)
|
||||
).person;
|
||||
message.success('患者关联已保存到客服后台');
|
||||
message.success(t('患者关联已保存到客服后台'));
|
||||
await load();
|
||||
} finally {
|
||||
patientBinding.value = false;
|
||||
@@ -163,7 +171,7 @@ async function unbindPatient(bindingId: string) {
|
||||
selected.value = (
|
||||
await unbindArchivePatient(selected.value.id, bindingId, accountId.value)
|
||||
).person;
|
||||
message.success('患者关联已解除');
|
||||
message.success(t('患者关联已解除'));
|
||||
await load();
|
||||
} finally {
|
||||
patientBinding.value = false;
|
||||
@@ -171,14 +179,14 @@ async function unbindPatient(bindingId: string) {
|
||||
}
|
||||
|
||||
function relationLabel(value: PatientBinding['relation_type']) {
|
||||
return relationOptions.find((item) => item.value === value)?.label || value;
|
||||
return relationOptions.value.find((item) => item.value === value)?.label || value;
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="人员唯一标识">
|
||||
<Card :title="t('人员唯一标识')">
|
||||
<template #extra>
|
||||
<Space>
|
||||
<ArchiveAccountSelector
|
||||
@@ -186,107 +194,107 @@ function relationLabel(value: PatientBinding['relation_type']) {
|
||||
@change="() => { selected = null; load(); }"
|
||||
/>
|
||||
<Input.Search
|
||||
v-model:value="keyword" allow-clear placeholder="姓名"
|
||||
v-model:value="keyword" allow-clear :placeholder="t('姓名')"
|
||||
:loading="loading" @search="load"
|
||||
/>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
<Button :loading="loading" @click="load">{{ t('刷新') }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
同一个人可绑定本地 UID、企业微信 userid、微信 external_userid 等多个身份;唯一键由“身份类型 + 企业范围 + 外部 ID”组成。
|
||||
{{ t('同一个人可绑定本地 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: '关联患者', key: 'patient', width: 180 },
|
||||
{ title: '会话数', dataIndex: 'conversation_count', key: 'conversation_count', width: 90 },
|
||||
{ title: '消息数', dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 100 },
|
||||
{ title: t('显示名称'), dataIndex: 'display_name', key: 'display_name' },
|
||||
{ title: t('真实姓名'), dataIndex: 'real_name', key: 'real_name' },
|
||||
{ title: t('已绑定身份'), dataIndex: 'identity_count', key: 'identity_count', width: 110 },
|
||||
{ title: t('关联患者'), key: 'patient', width: 180 },
|
||||
{ title: t('会话数'), dataIndex: 'conversation_count', key: 'conversation_count', width: 90 },
|
||||
{ title: t('消息数'), dataIndex: 'message_count', key: 'message_count', width: 90 },
|
||||
{ title: t('操作'), key: 'action', width: 100 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<Button type="link" size="small" @click="openPerson(record)">管理关联</Button>
|
||||
<Button type="link" size="small" @click="openPerson(record)">{{ t('管理关联') }}</Button>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'patient'">
|
||||
<Tag v-if="record.primary_patient_id" color="blue">
|
||||
{{ record.primary_patient_name || `患者 ${record.primary_patient_id}` }}
|
||||
{{ record.primary_patient_name || t('患者 {id}', { id: record.primary_patient_id }) }}
|
||||
</Tag>
|
||||
<span v-else class="text-gray-400">未关联</span>
|
||||
<span v-else class="text-gray-400">{{ t('未关联') }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
:open="!!selected" :title="`人员关联 · ${selected?.display_name || ''}`"
|
||||
:open="!!selected" :title="t('人员关联 · {name}', { name: selected?.display_name || '' })"
|
||||
width="680" @close="selected = null"
|
||||
>
|
||||
<Card title="关联 ZYT 患者" size="small" class="mb-5">
|
||||
<Card :title="t('关联 ZYT 患者')" size="small" class="mb-5">
|
||||
<Table
|
||||
v-if="selected?.patient_bindings?.length"
|
||||
class="mb-4" :data-source="selected.patient_bindings" row-key="id"
|
||||
size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '患者', dataIndex: 'patient_name', key: 'patient_name' },
|
||||
{ title: '手机号', dataIndex: 'phone_masked', key: 'phone_masked', width: 125 },
|
||||
{ title: '关系', dataIndex: 'relation_type', key: 'relation_type', width: 85 },
|
||||
{ title: '状态', key: 'binding_status', width: 75 },
|
||||
{ title: '操作', key: 'unbind', width: 75 },
|
||||
{ title: t('患者'), dataIndex: 'patient_name', key: 'patient_name' },
|
||||
{ title: t('手机号'), dataIndex: 'phone_masked', key: 'phone_masked', width: 125 },
|
||||
{ title: t('关系'), dataIndex: 'relation_type', key: 'relation_type', width: 85 },
|
||||
{ title: t('状态'), key: 'binding_status', width: 75 },
|
||||
{ title: t('操作'), key: 'unbind', width: 75 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'patient_name'">
|
||||
<div>{{ record.patient_name || `患者 ${record.patient_id}` }}</div>
|
||||
<div class="text-xs text-gray-400">患者ID:{{ record.patient_id }}</div>
|
||||
<div>{{ record.patient_name || t('患者 {id}', { id: record.patient_id }) }}</div>
|
||||
<div class="text-xs text-gray-400">{{ t('患者ID:{id}', { id: record.patient_id }) }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'relation_type'">
|
||||
{{ relationLabel(record.relation_type) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'binding_status'">
|
||||
<Tag :color="record.is_primary ? 'green' : 'default'">
|
||||
{{ record.is_primary ? '默认' : '关联' }}
|
||||
{{ record.is_primary ? t('默认') : t('关联') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'unbind'">
|
||||
<Popconfirm title="确认解除这个患者关联?" @confirm="unbindPatient(record.id)">
|
||||
<Button type="link" danger size="small" :loading="patientBinding">解绑</Button>
|
||||
<template v-else-if="column.key === 'unbind' && canUnbindPatient">
|
||||
<Popconfirm :title="t('确认解除这个患者关联?')" @confirm="unbindPatient(record.id)">
|
||||
<Button type="link" danger size="small" :loading="patientBinding">{{ t('解绑') }}</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<Space class="mb-3" wrap>
|
||||
<Space v-if="canBindPatient" class="mb-3" wrap>
|
||||
<Select v-model:value="relationType" style="width: 110px" :options="relationOptions" />
|
||||
<Input.Search
|
||||
v-model:value="patientKeyword" style="width: 360px" allow-clear
|
||||
placeholder="输入患者姓名、手机号或患者编号"
|
||||
:placeholder="t('输入患者姓名、手机号或患者编号')"
|
||||
:loading="patientSearching" @search="searchPatients"
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
v-if="patientCandidates.length" :data-source="patientCandidates"
|
||||
v-if="canBindPatient && patientCandidates.length" :data-source="patientCandidates"
|
||||
row-key="patient_id" size="small" :pagination="false"
|
||||
:columns="[
|
||||
{ title: '患者', dataIndex: 'patient_name', key: 'patient_name' },
|
||||
{ title: '手机号', dataIndex: 'phone_masked', key: 'phone_masked', width: 125 },
|
||||
{ title: '患者ID', dataIndex: 'patient_id', key: 'patient_id', width: 95 },
|
||||
{ title: '操作', key: 'bind_patient', width: 75 },
|
||||
{ title: t('患者'), dataIndex: 'patient_name', key: 'patient_name' },
|
||||
{ title: t('手机号'), dataIndex: 'phone_masked', key: 'phone_masked', width: 125 },
|
||||
{ title: t('患者ID'), dataIndex: 'patient_id', key: 'patient_id', width: 95 },
|
||||
{ title: t('操作'), key: 'bind_patient', width: 75 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'bind_patient'">
|
||||
<Button type="link" size="small" :loading="patientBinding" @click="bindPatient(record)">
|
||||
关联
|
||||
{{ t('关联') }}
|
||||
</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<p v-else class="mb-0 text-sm text-gray-400">
|
||||
从 ZYT 只读查询患者,关联关系仅保存在客服后台,不会修改 ZYT。
|
||||
{{ t('从 ZYT 只读查询患者,关联关系仅保存在客服后台,不会修改 ZYT。') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
@@ -294,42 +302,42 @@ function relationLabel(value: PatientBinding['relation_type']) {
|
||||
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 },
|
||||
{ title: t('类型'), dataIndex: 'identity_type', key: 'identity_type', width: 140 },
|
||||
{ title: t('企业/范围'), dataIndex: 'scope_id', key: 'scope_id', width: 140 },
|
||||
{ title: t('外部 ID'), dataIndex: 'external_id', key: 'external_id' },
|
||||
{ title: t('状态'), dataIndex: 'verified', key: 'verified', width: 80 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'verified'">
|
||||
<Tag :color="record.verified ? 'green' : 'default'">
|
||||
{{ record.verified ? '已确认' : '未确认' }}
|
||||
{{ record.verified ? t('已确认') : t('未确认') }}
|
||||
</Tag>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Card title="绑定新标识" size="small">
|
||||
<Card v-if="canBindIdentity" :title="t('绑定新标识')" size="small">
|
||||
<Form :label-col="{ span: 6 }" :wrapper-col="{ span: 17 }">
|
||||
<Form.Item label="身份类型">
|
||||
<Form.Item :label="t('身份类型')">
|
||||
<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' },
|
||||
{ label: t('企业微信 userid'), value: 'wecom_userid' },
|
||||
{ label: t('微信 external_userid'), value: 'wecom_external_userid' },
|
||||
{ label: t('本地数据库 UID'), value: 'wecom_local_uid' },
|
||||
{ label: t('企微 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 :label="t('企业/范围 ID')">
|
||||
<Input v-model:value="identity.scope_id" :placeholder="t('建议填写 corp_id;本地 UID 可填账号 ID')" />
|
||||
</Form.Item>
|
||||
<Form.Item label="外部人员 ID" required>
|
||||
<Form.Item :label="t('外部人员 ID')" required>
|
||||
<Input v-model:value="identity.external_id" />
|
||||
</Form.Item>
|
||||
<Form.Item label="确认身份">
|
||||
<Form.Item :label="t('确认身份')">
|
||||
<Switch v-model:checked="identity.verified" />
|
||||
</Form.Item>
|
||||
<Form.Item :wrapper-col="{ offset: 6, span: 17 }">
|
||||
<Button type="primary" :loading="binding" @click="bind">确认绑定</Button>
|
||||
<Button type="primary" :loading="binding" @click="bind">{{ t('确认绑定') }}</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArchiveMedia } from '#/api/archive';
|
||||
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
@@ -25,9 +27,14 @@ import {
|
||||
} from '#/api/archive';
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { useArchiveLoad } from './use-archive-load';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canSave = computed(() => hasAccessByCodes(['im:storage:write']));
|
||||
const canTest = computed(() => hasAccessByCodes(['im:storage:test']));
|
||||
const canSeeMedia = computed(() => hasAccessByCodes(['im:content:read']));
|
||||
const pageRequest = useArchiveLoad();
|
||||
const loading = pageRequest.loading;
|
||||
const saving = ref(false);
|
||||
@@ -54,7 +61,7 @@ async function load() {
|
||||
await pageRequest.run(
|
||||
(signal) => Promise.all([
|
||||
fetchArchiveStorage(signal),
|
||||
fetchArchiveMedia(100, accountId.value, signal),
|
||||
canSeeMedia.value ? fetchArchiveMedia(100, accountId.value, signal) : Promise.resolve({ items: [] as ArchiveMedia[] }),
|
||||
]),
|
||||
([storage, media]) => {
|
||||
Object.assign(form, storage.storage, { secret_id: '', secret_key: '' });
|
||||
@@ -79,7 +86,7 @@ async function save() {
|
||||
secret_key: form.secret_key,
|
||||
});
|
||||
Object.assign(form, data.storage, { secret_id: '', secret_key: '' });
|
||||
message.success('COS 配置已保存');
|
||||
message.success(t('COS 配置已保存'));
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
@@ -89,7 +96,7 @@ async function testConnection() {
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = (await testArchiveStorage()).result;
|
||||
message.success(`连接成功:${result.bucket} / ${result.region}`);
|
||||
message.success(t('连接成功:{bucket} / {region}', { bucket: result.bucket, region: result.region }));
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
@@ -108,54 +115,54 @@ function sizeText(value: number) {
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4" type="warning" show-icon
|
||||
message="请使用最小权限的独立 COS 子账号密钥"
|
||||
description="SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。"
|
||||
:message="t('请使用最小权限的独立 COS 子账号密钥')"
|
||||
:description="t('SecretId / SecretKey 只写入后台并加密保存,页面不会回显明文。留空表示保持原密钥;更换截图中曾暴露过的密钥后再启用。')"
|
||||
/>
|
||||
|
||||
<Card title="腾讯云 COS" class="mb-4" :loading="loading">
|
||||
<Card :title="t('腾讯云 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="t('启用存储')"><Switch v-model:checked="form.enabled" :disabled="!canSave" /></Form.Item>
|
||||
<Form.Item label="Bucket">
|
||||
<Input v-model:value="form.bucket" placeholder="bucket-appid" />
|
||||
<Input v-model:value="form.bucket" :disabled="!canSave" placeholder="bucket-appid" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Region">
|
||||
<Input v-model:value="form.region" placeholder="ap-guangzhou" />
|
||||
<Input v-model:value="form.region" :disabled="!canSave" placeholder="ap-guangzhou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretId">
|
||||
<Input.Password v-model:value="form.secret_id" :placeholder="form.secret_id_masked || '请输入 SecretId'" />
|
||||
<Input.Password v-model:value="form.secret_id" :disabled="!canSave" :placeholder="form.secret_id_masked || t('请输入 SecretId')" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SecretKey">
|
||||
<Input.Password v-model:value="form.secret_key" :placeholder="form.secret_key_masked || '请输入 SecretKey'" />
|
||||
<Input.Password v-model:value="form.secret_key" :disabled="!canSave" :placeholder="form.secret_key_masked || t('请输入 SecretKey')" />
|
||||
</Form.Item>
|
||||
<Form.Item label="素材路径前缀">
|
||||
<Input v-model:value="form.media_prefix" />
|
||||
<Form.Item :label="t('素材路径前缀')">
|
||||
<Input v-model:value="form.media_prefix" :disabled="!canSave" />
|
||||
</Form.Item>
|
||||
<Form.Item label="导出路径前缀">
|
||||
<Input v-model:value="form.export_prefix" />
|
||||
<Form.Item :label="t('导出路径前缀')">
|
||||
<Input v-model:value="form.export_prefix" :disabled="!canSave" />
|
||||
</Form.Item>
|
||||
<Form.Item label="安装包路径前缀">
|
||||
<Input v-model:value="form.release_prefix" />
|
||||
<Form.Item :label="t('安装包路径前缀')">
|
||||
<Input v-model:value="form.release_prefix" :disabled="!canSave" />
|
||||
</Form.Item>
|
||||
<Form.Item label="服务端加密">
|
||||
<Select v-model:value="form.encryption_mode" :options="[
|
||||
{ label: 'SSE-COS(AES256)', value: 'AES256' },
|
||||
<Form.Item :label="t('服务端加密')">
|
||||
<Select v-model:value="form.encryption_mode" :disabled="!canSave" :options="[
|
||||
{ label: t('SSE-COS(AES256)'), value: 'AES256' },
|
||||
{ label: 'SSE-KMS', value: 'cos/kms' },
|
||||
{ label: '不指定', value: '' },
|
||||
{ label: t('不指定'), value: '' },
|
||||
]" />
|
||||
</Form.Item>
|
||||
<Form.Item label="自定义域名">
|
||||
<Input v-model:value="form.custom_domain" placeholder="可选,仅允许完整 HTTPS 域名" />
|
||||
<Form.Item :label="t('自定义域名')">
|
||||
<Input v-model:value="form.custom_domain" :disabled="!canSave" :placeholder="t('可选,仅允许完整 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>
|
||||
<Button v-if="canSave" type="primary" :loading="saving" @click="save">{{ t('保存配置') }}</Button>
|
||||
<Button v-if="canTest" :loading="testing" :disabled="!form.enabled" @click="testConnection">{{ t('测试连接') }}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="最近素材对象">
|
||||
<Card :title="t('最近素材对象')">
|
||||
<template #extra>
|
||||
<ArchiveAccountSelector v-model="accountId" @change="load" />
|
||||
</template>
|
||||
@@ -163,12 +170,12 @@ function sizeText(value: number) {
|
||||
: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: t('文件'), dataIndex: 'original_filename', key: 'original_filename' },
|
||||
{ title: t('类型'), dataIndex: 'media_type', key: 'media_type', width: 90 },
|
||||
{ title: t('大小'), dataIndex: 'size_bytes', key: 'size_bytes', width: 100 },
|
||||
{ title: t('状态'), dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: 'COS ObjectKey', dataIndex: 'object_key', key: 'object_key' },
|
||||
{ title: '校验时间', dataIndex: 'verified_at', key: 'verified_at', width: 200 },
|
||||
{ title: t('校验时间'), dataIndex: 'verified_at', key: 'verified_at', width: 200 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue';
|
||||
import { Alert, Button, Card, Col, Empty, Progress, Row, Segmented, Statistic, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { fetchModelUsage, fetchModelUsageLog } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { usageNumber, usagePurpose, usageTokens, usageUtcTime } from './model-usage-format';
|
||||
import { useUsageRequest } from './use-usage-request';
|
||||
@@ -52,103 +53,104 @@ function changePage(pagination: { current?: number; pageSize?: number }) {
|
||||
void loadLog();
|
||||
}
|
||||
|
||||
const tokenColumns = [
|
||||
{ title: '请求数', dataIndex: 'request_count', key: 'request_count', width: 85 },
|
||||
{ title: '已记录输入', key: 'input_tokens', width: 115 },
|
||||
{ title: '已记录输出', key: 'output_tokens', width: 115 },
|
||||
{ title: '已记录总 Token', key: 'total_tokens', width: 135 },
|
||||
{ title: '用量覆盖', key: 'coverage', width: 120 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed(模板里会自动解包)
|
||||
const tokenColumns = computed(() => [
|
||||
{ title: t('请求数'), dataIndex: 'request_count', key: 'request_count', width: 85 },
|
||||
{ title: t('已记录输入'), key: 'input_tokens', width: 115 },
|
||||
{ title: t('已记录输出'), key: 'output_tokens', width: 115 },
|
||||
{ title: t('已记录总 Token'), key: 'total_tokens', width: 135 },
|
||||
{ title: t('用量覆盖'), key: 'coverage', width: 120 },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card title="模型 Token 用量" class="mb-5" size="small">
|
||||
<template #extra><Button :loading="loading || logLoading" @click="reload">刷新用量</Button></template>
|
||||
<Card :title="t('模型 Token 用量')" class="mb-5" size="small">
|
||||
<template #extra><Button :loading="loading || logLoading" @click="reload">{{ t('刷新用量') }}</Button></template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
每次实际上游请求单独计数,包含所有回答候选、裁判、重试、界面检测及知识整理。Token 仅汇总上游已返回的用量;未返回和历史未记录的部分不作估算。
|
||||
{{ t('每次实际上游请求单独计数,包含所有回答候选、裁判、重试、界面检测及知识整理。Token 仅汇总上游已返回的用量;未返回和历史未记录的部分不作估算。') }}
|
||||
</p>
|
||||
<Segmented v-model:value="purpose" class="mb-4" :options="[
|
||||
{ value: '', label: '全部用途' }, { value: 'chat', label: '客服回答与裁判' },
|
||||
{ value: 'guard', label: '界面检测' }, { value: 'knowledge', label: '知识整理' },
|
||||
{ value: '', label: t('全部用途') }, { value: 'chat', label: t('客服回答与裁判') },
|
||||
{ value: 'guard', label: t('界面检测') }, { value: 'knowledge', label: t('知识整理') },
|
||||
]" />
|
||||
<Alert v-if="error" class="mb-4" type="error" show-icon :message="`用量统计${error}`" />
|
||||
<Alert v-if="error" class="mb-4" type="error" show-icon :message="t('用量统计{error}', { error })" />
|
||||
<Row :gutter="[12, 12]" class="mb-4">
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic title="实际上游请求" :value="summary ? usageNumber(summary.request_count) : '—'" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic title="已记录输入 Token" :value="usageTokens(summary, 'input_tokens')" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic title="已记录输出 Token" :value="usageTokens(summary, 'output_tokens')" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic title="已记录总 Token" :value="usageTokens(summary, 'total_tokens')" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic :title="t('实际上游请求')" :value="summary ? usageNumber(summary.request_count) : '—'" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic :title="t('已记录输入 Token')" :value="usageTokens(summary, 'input_tokens')" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic :title="t('已记录输出 Token')" :value="usageTokens(summary, 'output_tokens')" /></Card></Col>
|
||||
<Col :xs="24" :sm="12" :xl="6"><Card size="small" :loading="loading"><Statistic :title="t('已记录总 Token')" :value="usageTokens(summary, 'total_tokens')" /></Card></Col>
|
||||
</Row>
|
||||
<template v-if="stats && summary">
|
||||
<div class="mb-4 rounded border border-gray-200 p-3 dark:border-gray-700">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<span>总 Token 用量覆盖:{{ usageNumber(summary.known_count) }} / {{ usageNumber(summary.request_count) }} 次请求</span>
|
||||
<Tag :color="summary.missing_count ? 'orange' : 'green'">{{ usageNumber(summary.missing_count) }} 次未返回总用量</Tag>
|
||||
<span>{{ t('总 Token 用量覆盖:{known} / {total} 次请求', { known: usageNumber(summary.known_count), total: usageNumber(summary.request_count) }) }}</span>
|
||||
<Tag :color="summary.missing_count ? 'orange' : 'green'">{{ t('{count} 次未返回总用量', { count: usageNumber(summary.missing_count) }) }}</Tag>
|
||||
</div>
|
||||
<Progress :percent="coverage" size="small" :status="summary.missing_count ? 'normal' : undefined" />
|
||||
<p v-if="summary.input_known_count != null && summary.output_known_count != null" class="mb-1 text-xs text-gray-500">
|
||||
输入用量已记录 {{ summary.input_known_count }} / {{ summary.request_count }} 次;输出用量已记录 {{ summary.output_known_count }} / {{ summary.request_count }} 次。部分请求缺少用量时,合计仅包含已知部分。
|
||||
{{ t('输入用量已记录 {input} / {total} 次;输出用量已记录 {output} / {total} 次。部分请求缺少用量时,合计仅包含已知部分。', { input: summary.input_known_count, output: summary.output_known_count, total: summary.request_count }) }}
|
||||
</p>
|
||||
<p class="mb-1 text-xs text-gray-500">
|
||||
已记录缓存输入 {{ usageTokens(summary, 'cached_input_tokens') }};已记录推理 {{ usageTokens(summary, 'reasoning_tokens') }}。
|
||||
缓存与推理属于明细项,不额外叠加至总 Token。单项未返回时显示“未返回”。
|
||||
{{ t('已记录缓存输入 {cached};已记录推理 {reasoning}。', { cached: usageTokens(summary, 'cached_input_tokens'), reasoning: usageTokens(summary, 'reasoning_tokens') }) }}
|
||||
{{ t('缓存与推理属于明细项,不额外叠加至总 Token。单项未返回时显示“未返回”。') }}
|
||||
</p>
|
||||
<p class="m-0 text-xs text-gray-500">统计起点 {{ usageUtcTime(stats.since) }} UTC;日期趋势按 UTC 自然日汇总。</p>
|
||||
<p class="m-0 text-xs text-gray-500">{{ t('统计起点 {since} UTC;日期趋势按 UTC 自然日汇总。', { since: usageUtcTime(stats.since) }) }}</p>
|
||||
</div>
|
||||
<Alert v-if="stats.legacy_unmetered_calls" class="mb-4" type="warning" show-icon
|
||||
:message="`另有 ${usageNumber(stats.legacy_unmetered_calls)} 次历史业务调用未记录 Token`"
|
||||
description="这些历史调用没有逐次上游用量记录,不计入以上请求数和已记录 Token 合计。" />
|
||||
<Empty v-if="!summary.request_count" class="my-5" description="当前账号与时间范围内暂无上游请求记录" />
|
||||
:message="t('另有 {count} 次历史业务调用未记录 Token', { count: usageNumber(stats.legacy_unmetered_calls) })"
|
||||
:description="t('这些历史调用没有逐次上游用量记录,不计入以上请求数和已记录 Token 合计。')" />
|
||||
<Empty v-if="!summary.request_count" class="my-5" :description="t('当前账号与时间范围内暂无上游请求记录')" />
|
||||
<template v-else>
|
||||
<h3 class="mb-2 font-medium">按模型统计</h3>
|
||||
<h3 class="mb-2 font-medium">{{ t('按模型统计') }}</h3>
|
||||
<Table :data-source="models" row-key="key" size="small" :pagination="false" :scroll="{ x: 850 }" class="mb-4"
|
||||
:columns="[{ title: '模型 / 服务商', key: 'model', width: 230 }, ...tokenColumns]">
|
||||
:columns="[{ title: t('模型 / 服务商'), key: 'model', width: 230 }, ...tokenColumns]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'model'"><div>{{ record.model || '未标记模型' }}</div><div class="text-xs text-gray-500">{{ record.provider_name || record.provider_id }}</div></template>
|
||||
<template v-else-if="column.key === 'coverage'">{{ record.known_count }} / {{ record.request_count }}<div v-if="record.missing_count" class="text-xs text-orange-500">{{ record.missing_count }} 次未返回</div></template>
|
||||
<template v-if="column.key === 'model'"><div>{{ record.model || t('未标记模型') }}</div><div class="text-xs text-gray-500">{{ record.provider_name || record.provider_id }}</div></template>
|
||||
<template v-else-if="column.key === 'coverage'">{{ record.known_count }} / {{ record.request_count }}<div v-if="record.missing_count" class="text-xs text-orange-500">{{ t('{count} 次未返回', { count: record.missing_count }) }}</div></template>
|
||||
<template v-else-if="String(column.key).endsWith('_tokens')">{{ usageTokens(record, column.key) }}</template>
|
||||
</template>
|
||||
</Table>
|
||||
<h3 class="mb-2 font-medium">按用途统计</h3>
|
||||
<h3 class="mb-2 font-medium">{{ t('按用途统计') }}</h3>
|
||||
<Table :data-source="stats.by_purpose" row-key="purpose" size="small" :pagination="false" :scroll="{ x: 800 }" class="mb-4"
|
||||
:columns="[{ title: '用途', key: 'purpose', width: 180 }, ...tokenColumns]">
|
||||
:columns="[{ title: t('用途'), key: 'purpose', width: 180 }, ...tokenColumns]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'purpose'">{{ usagePurpose(record.purpose) }}</template>
|
||||
<template v-else-if="column.key === 'coverage'">{{ record.known_count }} / {{ record.request_count }}</template>
|
||||
<template v-else-if="String(column.key).endsWith('_tokens')">{{ usageTokens(record, column.key) }}</template>
|
||||
</template>
|
||||
</Table>
|
||||
<h3 class="mb-2 font-medium">每日趋势(UTC)</h3>
|
||||
<h3 class="mb-2 font-medium">{{ t('每日趋势(UTC)') }}</h3>
|
||||
<Table :data-source="stats.daily" row-key="date" size="small" :pagination="false" :scroll="{ x: 950 }" class="mb-4"
|
||||
:columns="[{ title: '日期(UTC)', dataIndex: 'date', key: 'date', width: 125 }, ...tokenColumns, { title: '已记录总量趋势', key: 'trend', width: 200 }]">
|
||||
:columns="[{ title: t('日期(UTC)'), dataIndex: 'date', key: 'date', width: 125 }, ...tokenColumns, { title: t('已记录总量趋势'), key: 'trend', width: 200 }]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'trend'"><Progress v-if="record.total_tokens != null" :percent="Math.round((record.total_tokens / maxDailyTokens) * 100)" :show-info="false" size="small" /><span v-else>未返回</span></template>
|
||||
<template v-if="column.key === 'trend'"><Progress v-if="record.total_tokens != null" :percent="Math.round((record.total_tokens / maxDailyTokens) * 100)" :show-info="false" size="small" /><span v-else>{{ t('未返回') }}</span></template>
|
||||
<template v-else-if="column.key === 'coverage'">{{ record.known_count }} / {{ record.request_count }}</template>
|
||||
<template v-else-if="String(column.key).endsWith('_tokens')">{{ usageTokens(record, column.key) }}</template>
|
||||
</template>
|
||||
</Table>
|
||||
</template>
|
||||
</template>
|
||||
<h3 class="mb-2 font-medium">实际请求明细</h3>
|
||||
<Alert v-if="logError" class="mb-3" type="error" show-icon :message="`请求明细${logError}`"><template #action><Button size="small" @click="loadLog">重试</Button></template></Alert>
|
||||
<h3 class="mb-2 font-medium">{{ t('实际请求明细') }}</h3>
|
||||
<Alert v-if="logError" class="mb-3" type="error" show-icon :message="t('请求明细{error}', { error: logError })"><template #action><Button size="small" @click="loadLog">{{ t('重试') }}</Button></template></Alert>
|
||||
<Table :data-source="log?.items ?? []" row-key="event_id" size="small" :loading="logLoading" :scroll="{ x: 1600 }"
|
||||
:pagination="{ current: page, pageSize, total: log?.total ?? 0, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'], showTotal: (total: number) => `共 ${total} 次上游请求` }"
|
||||
:pagination="{ current: page, pageSize, total: log?.total ?? 0, showSizeChanger: true, pageSizeOptions: ['20', '50', '100'], showTotal: (total: number) => t('共 {total} 次上游请求', { total }) }"
|
||||
:columns="[
|
||||
{ title: '时间(UTC)', key: 'created_at', width: 170 }, { title: '模型 / 服务商', key: 'model', width: 230 },
|
||||
{ title: '用途 / 角色', key: 'purpose', width: 150 }, { title: '状态', key: 'status', width: 80 },
|
||||
{ title: '输入 Token', key: 'input_tokens', width: 110 }, { title: '输出 Token', key: 'output_tokens', width: 110 },
|
||||
{ title: '总 Token', key: 'total_tokens', width: 110 }, { title: '缓存输入', key: 'cached_input_tokens', width: 110 },
|
||||
{ title: '推理 Token', key: 'reasoning_tokens', width: 110 }, { title: '耗时', key: 'latency_ms', width: 90 },
|
||||
{ title: '尝试次数', dataIndex: 'attempt', key: 'attempt', width: 90 }, { title: '请求编号', dataIndex: 'request_id', key: 'request_id', width: 210, ellipsis: true },
|
||||
{ title: t('时间(UTC)'), key: 'created_at', width: 170 }, { title: t('模型 / 服务商'), key: 'model', width: 230 },
|
||||
{ title: t('用途 / 角色'), key: 'purpose', width: 150 }, { title: t('状态'), key: 'status', width: 80 },
|
||||
{ title: t('输入 Token'), key: 'input_tokens', width: 110 }, { title: t('输出 Token'), key: 'output_tokens', width: 110 },
|
||||
{ title: t('总 Token'), key: 'total_tokens', width: 110 }, { title: t('缓存输入'), key: 'cached_input_tokens', width: 110 },
|
||||
{ title: t('推理 Token'), key: 'reasoning_tokens', width: 110 }, { title: t('耗时'), key: 'latency_ms', width: 90 },
|
||||
{ title: t('尝试次数'), dataIndex: 'attempt', key: 'attempt', width: 90 }, { title: t('请求编号'), dataIndex: 'request_id', key: 'request_id', width: 210, ellipsis: true },
|
||||
]" @change="changePage">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'created_at'">{{ usageUtcTime(record.created_at) }}</template>
|
||||
<template v-else-if="column.key === 'model'"><div>{{ record.model || '未标记模型' }}</div><div class="text-xs text-gray-500">{{ record.provider_name || record.provider_id }}</div></template>
|
||||
<template v-else-if="column.key === 'purpose'"><div>{{ usagePurpose(record.purpose) }}</div><Tag>{{ record.role === 'judge' ? '裁判' : '回答 / 生成' }}</Tag></template>
|
||||
<template v-else-if="column.key === 'status'"><Tag :color="record.status === 'success' ? 'green' : 'red'">{{ record.status === 'success' ? '成功' : '失败' }}</Tag></template>
|
||||
<template v-else-if="column.key === 'model'"><div>{{ record.model || t('未标记模型') }}</div><div class="text-xs text-gray-500">{{ record.provider_name || record.provider_id }}</div></template>
|
||||
<template v-else-if="column.key === 'purpose'"><div>{{ usagePurpose(record.purpose) }}</div><Tag>{{ record.role === 'judge' ? t('裁判') : t('回答 / 生成') }}</Tag></template>
|
||||
<template v-else-if="column.key === 'status'"><Tag :color="record.status === 'success' ? 'green' : 'red'">{{ record.status === 'success' ? t('成功') : t('失败') }}</Tag></template>
|
||||
<template v-else-if="String(column.key).endsWith('_tokens')">{{ usageNumber(record[String(column.key)]) }}</template>
|
||||
<template v-else-if="column.key === 'latency_ms'">{{ usageNumber(record.latency_ms, '—') }} ms</template>
|
||||
</template>
|
||||
<template #emptyText>{{ logError ? '请求明细加载失败,请重试' : '暂无实际上游请求' }}</template>
|
||||
<template #emptyText>{{ logError ? t('请求明细加载失败,请重试') : t('暂无实际上游请求') }}</template>
|
||||
</Table>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
@@ -29,9 +29,13 @@ import {
|
||||
saveAgent,
|
||||
saveAgentPlan,
|
||||
} from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['agent:write']));
|
||||
const canCreate = computed(() => hasAccessByCodes(['agent:create']));
|
||||
const canEdit = computed(() => hasAccessByCodes(['agent:write']));
|
||||
const canDelete = computed(() => hasAccessByCodes(['agent:delete']));
|
||||
const canPlan = computed(() => hasAccessByCodes(['agent-plan:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const agents = ref<AgentItem[]>([]);
|
||||
@@ -58,26 +62,27 @@ const blank = (): Partial<AgentItem> => ({
|
||||
});
|
||||
const form = reactive<Partial<AgentItem>>(blank());
|
||||
|
||||
const RULE_TYPES = [
|
||||
{ value: 'guide', label: '补充指令(告诉模型该怎么答)' },
|
||||
{ value: 'forbid', label: '禁止措辞(回复里出现就换掉)' },
|
||||
{ value: 'reply', label: '固定口径(命中就直接这么回)' },
|
||||
];
|
||||
// 下拉选项和说明文字要随语言切换:包成 computed
|
||||
const ruleTypes = computed(() => [
|
||||
{ value: 'guide', label: t('补充指令(告诉模型该怎么答)') },
|
||||
{ value: 'forbid', label: t('禁止措辞(回复里出现就换掉)') },
|
||||
{ value: 'reply', label: t('固定口径(命中就直接这么回)') },
|
||||
]);
|
||||
|
||||
const RULE_HINT: Record<string, string> = {
|
||||
const ruleHints = computed<Record<string, string>>(() => ({
|
||||
guide:
|
||||
'写一句给模型的补充要求。关键词留空 = 每一轮都注入;填了关键词 = 客户这句话命中才注入(规则多了以后靠这个控制提示词长度)。',
|
||||
t('写一句给模型的补充要求。关键词留空 = 每一轮都注入;填了关键词 = 客户这句话命中才注入(规则多了以后靠这个控制提示词长度)。'),
|
||||
forbid:
|
||||
'关键词是**回复里**不许出现的词。命中就换成下面的兜底话术;兜底留空时客户端会用内置的那句正常人话顶上。',
|
||||
t('关键词是**回复里**不许出现的词。命中就换成下面的兜底话术;兜底留空时客户端会用内置的那句正常人话顶上。'),
|
||||
reply:
|
||||
'关键词命中**客户这句话**时,直接发下面这句,不再问模型。口径必须一字不差的场景用它。',
|
||||
};
|
||||
t('关键词命中**客户这句话**时,直接发下面这句,不再问模型。口径必须一字不差的场景用它。'),
|
||||
}));
|
||||
|
||||
const enabledAgents = computed(() => agents.value.filter((item) => item.enabled));
|
||||
const agentOptions = computed(() =>
|
||||
enabledAgents.value.map((item) => ({
|
||||
value: item.id,
|
||||
label: `${item.name}(${item.role})`,
|
||||
label: t('{name}({role})', { name: item.name, role: item.role }),
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -133,18 +138,18 @@ function removeRule(index: number) {
|
||||
* 而规则编辑器里一条一条填的时候,错在第几条得自己数——不值得让服务器来告诉你。
|
||||
*/
|
||||
function formError(): string {
|
||||
if (!form.name?.trim()) return '智能体名称不能为空';
|
||||
if (!form.name?.trim()) return t('智能体名称不能为空');
|
||||
for (const [index, rule] of (form.rules || []).entries()) {
|
||||
const at = `第 ${index + 1} 条规则`;
|
||||
if (!rule.label.trim()) return `${at}没有填名称`;
|
||||
const at = { index: index + 1 };
|
||||
if (!rule.label.trim()) return t('第 {index} 条规则没有填名称', at);
|
||||
if (rule.type === 'guide' && !rule.instruction.trim()) {
|
||||
return `${at}是补充指令,必须填要告诉模型的话`;
|
||||
return t('第 {index} 条规则是补充指令,必须填要告诉模型的话', at);
|
||||
}
|
||||
if (rule.type !== 'guide' && rule.keywords.length === 0) {
|
||||
return `${at}至少要有一个关键词,否则永远不会命中`;
|
||||
return t('第 {index} 条规则至少要有一个关键词,否则永远不会命中', at);
|
||||
}
|
||||
if (rule.type === 'reply' && !rule.reply.trim()) {
|
||||
return `${at}是固定口径,必须填要发给客户的话术`;
|
||||
return t('第 {index} 条规则是固定口径,必须填要发给客户的话术', at);
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -160,45 +165,47 @@ async function submit() {
|
||||
agents.value = data.agents;
|
||||
plan.value = data.plan;
|
||||
open.value = false;
|
||||
message.success(editingId.value ? '已更新' : '已新增');
|
||||
message.success(editingId.value ? t('已更新') : t('已新增'));
|
||||
}
|
||||
|
||||
async function remove(row: AgentItem) {
|
||||
const data = await deleteAgent(row.id);
|
||||
agents.value = data.agents;
|
||||
plan.value = data.plan;
|
||||
message.success('已删除');
|
||||
message.success(t('已删除'));
|
||||
}
|
||||
|
||||
async function submitPlan() {
|
||||
if (planForm.mode === 'single' && !planForm.primary_id) {
|
||||
message.warning('单角色模式要选一个当前生效的智能体');
|
||||
message.warning(t('单角色模式要选一个当前生效的智能体'));
|
||||
return;
|
||||
}
|
||||
if (planForm.mode === 'collaborate' && planForm.active_ids.length < 2) {
|
||||
message.warning('协作模式至少要有两个在岗智能体,否则就是单角色');
|
||||
message.warning(t('协作模式至少要有两个在岗智能体,否则就是单角色'));
|
||||
return;
|
||||
}
|
||||
const data = await saveAgentPlan({ ...planForm });
|
||||
plan.value = data.plan;
|
||||
message.success(`已保存为 v${data.version},客户端下次同步配置时生效`);
|
||||
message.success(t('已保存为 v{version},客户端下次同步配置时生效', { version: data.version }));
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: '名称', dataIndex: 'name', key: 'name', width: 160 },
|
||||
{ title: '角色', dataIndex: 'role', key: 'role', width: 130 },
|
||||
{ title: '职责', dataIndex: 'description', key: 'description' },
|
||||
{ title: '负责话题', dataIndex: 'keywords', key: 'keywords' },
|
||||
{ title: '规则', dataIndex: 'rules', key: 'rules', width: 100 },
|
||||
{ title: '在岗', key: 'onduty', width: 110 },
|
||||
{ title: '状态', dataIndex: 'enabled', key: 'enabled', width: 90 },
|
||||
{ title: '操作', key: 'action', width: 130 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('名称'), dataIndex: 'name', key: 'name', width: 160 },
|
||||
{ title: t('角色'), dataIndex: 'role', key: 'role', width: 130 },
|
||||
{ title: t('职责'), dataIndex: 'description', key: 'description' },
|
||||
{ title: t('负责话题'), dataIndex: 'keywords', key: 'keywords' },
|
||||
{ title: t('规则'), dataIndex: 'rules', key: 'rules', width: 100 },
|
||||
{ title: t('在岗'), key: 'onduty', width: 110 },
|
||||
{ title: t('状态'), dataIndex: 'enabled', key: 'enabled', width: 90 },
|
||||
{ title: t('操作'), key: 'action', width: 130 },
|
||||
]);
|
||||
|
||||
function onDuty(row: AgentItem): string {
|
||||
/** 在岗状态:primary = 主答,active = 在岗,'' = 未上岗(显示文字在模板里翻译)。 */
|
||||
function onDuty(row: AgentItem): '' | 'active' | 'primary' {
|
||||
if (!plan.value) return '';
|
||||
if (plan.value.primary_id === row.id) return '主答';
|
||||
return plan.value.active_ids.includes(row.id) ? '在岗' : '';
|
||||
if (plan.value.primary_id === row.id) return 'primary';
|
||||
return plan.value.active_ids.includes(row.id) ? 'active' : '';
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -210,64 +217,63 @@ onMounted(load);
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="智能体决定「用什么身份、按什么规矩说话」,模型清单决定「用哪个模型」"
|
||||
description="这两件事的变更频率差一个数量级:模型半年动一次,话术一周动三次。改这里不用动模型,也不用给客户端发版——保存后随配置下发。"
|
||||
:message="t('智能体决定「用什么身份、按什么规矩说话」,模型清单决定「用哪个模型」')"
|
||||
:description="t('这两件事的变更频率差一个数量级:模型半年动一次,话术一周动三次。改这里不用动模型,也不用给客户端发版——保存后随配置下发。')"
|
||||
/>
|
||||
|
||||
<Card title="启用与协作" class="mb-4" :loading="loading">
|
||||
<Card :title="t('启用与协作')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
单角色 = 只上一个,切换就是换这里选的那个;协作 = 多个同时在场,客户这句话
|
||||
命中谁的「负责话题」就由谁主答,其余角色的禁止措辞仍然全体生效。
|
||||
不论哪种模式,客户看到的永远是同一个人——不会自报角色名,也不会出现「转给同事」。
|
||||
{{ t('单角色 = 只上一个,切换就是换这里选的那个;协作 = 多个同时在场,客户这句话命中谁的「负责话题」就由谁主答,其余角色的禁止措辞仍然全体生效。') }}
|
||||
{{ t('不论哪种模式,客户看到的永远是同一个人——不会自报角色名,也不会出现「转给同事」。') }}
|
||||
</p>
|
||||
<Form :model="planForm" layout="vertical">
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
<FormItem label="模式">
|
||||
<FormItem :label="t('模式')">
|
||||
<Select
|
||||
v-model:value="planForm.mode"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canPlan"
|
||||
:options="[
|
||||
{ value: 'single', label: '单角色(切换到某一个)' },
|
||||
{ value: 'collaborate', label: '协作(多个同时在场)' },
|
||||
{ value: 'single', label: t('单角色(切换到某一个)') },
|
||||
{ value: 'collaborate', label: t('协作(多个同时在场)') },
|
||||
]"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="主答智能体(没命中任何话题时由它回)">
|
||||
<FormItem :label="t('主答智能体(没命中任何话题时由它回)')">
|
||||
<Select
|
||||
v-model:value="planForm.primary_id"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canPlan"
|
||||
:options="agentOptions"
|
||||
placeholder="选一个"
|
||||
:placeholder="t('选一个')"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem
|
||||
v-if="planForm.mode === 'collaborate'"
|
||||
label="在岗智能体(至少两个;主答会自动排在最前)"
|
||||
:label="t('在岗智能体(至少两个;主答会自动排在最前)')"
|
||||
>
|
||||
<Select
|
||||
v-model:value="planForm.active_ids"
|
||||
mode="multiple"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canPlan"
|
||||
:options="agentOptions"
|
||||
/>
|
||||
</FormItem>
|
||||
<div class="flex items-center gap-3">
|
||||
<Button v-if="canWrite" type="primary" @click="submitPlan">
|
||||
保存为新版本
|
||||
<Button v-if="canPlan" type="primary" @click="submitPlan">
|
||||
{{ t('保存为新版本') }}
|
||||
</Button>
|
||||
<span v-if="plan" class="text-xs text-gray-500">
|
||||
当前 v{{ plan.version }}
|
||||
{{ t('当前 v{version}', { version: plan.version }) }}
|
||||
<template v-if="plan.updated_at">· {{ plan.updated_at }}</template>
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="智能体">
|
||||
<Card :title="t('智能体')">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">
|
||||
新增智能体
|
||||
<Button v-if="canCreate" type="primary" @click="openCreate">
|
||||
{{ t('新增智能体') }}
|
||||
</Button>
|
||||
</template>
|
||||
<Table
|
||||
@@ -284,96 +290,95 @@ onMounted(load);
|
||||
{{ word }}
|
||||
</Tag>
|
||||
<span v-if="record.keywords.length === 0" class="text-xs text-gray-400">
|
||||
不认领话题
|
||||
{{ t('不认领话题') }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'rules'">
|
||||
{{ record.rules.length }} 条
|
||||
{{ t('{count} 条', { count: record.rules.length }) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'onduty'">
|
||||
<Tag v-if="onDuty(record as AgentItem) === '主答'" color="green">主答</Tag>
|
||||
<Tag v-else-if="onDuty(record as AgentItem) === '在岗'" color="blue">在岗</Tag>
|
||||
<span v-else class="text-xs text-gray-400">未上岗</span>
|
||||
<Tag v-if="onDuty(record as AgentItem) === 'primary'" color="green">{{ t('主答') }}</Tag>
|
||||
<Tag v-else-if="onDuty(record as AgentItem) === 'active'" color="blue">{{ t('在岗') }}</Tag>
|
||||
<span v-else class="text-xs text-gray-400">{{ t('未上岗') }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<Tag :color="record.enabled ? 'green' : 'default'">
|
||||
{{ record.enabled ? '已启用' : '已停用' }}
|
||||
{{ record.enabled ? t('已启用') : t('已停用') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="!canWrite"
|
||||
:disabled="!canEdit"
|
||||
@click="openEdit(record as AgentItem)"
|
||||
>
|
||||
编辑
|
||||
{{ t('编辑') }}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除这个智能体?"
|
||||
:title="t('确定删除这个智能体?')"
|
||||
@confirm="remove(record as AgentItem)"
|
||||
>
|
||||
<Button type="link" size="small" danger :disabled="!canWrite">
|
||||
删除
|
||||
<Button type="link" size="small" danger :disabled="!canDelete">
|
||||
{{ t('删除') }}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<p v-if="agents.length === 0 && !loading" class="mt-3 text-xs text-gray-500">
|
||||
还没有智能体——客户端会沿用内置人设,行为和没有这套东西时一样。
|
||||
{{ t('还没有智能体——客户端会沿用内置人设,行为和没有这套东西时一样。') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
v-model:open="open"
|
||||
:title="editingId ? `编辑智能体:${form.name}` : '新增智能体'"
|
||||
:title="editingId ? t('编辑智能体:{name}', { name: form.name }) : t('新增智能体')"
|
||||
width="760px"
|
||||
@ok="submit"
|
||||
>
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
<FormItem label="名称">
|
||||
<Input v-model:value="form.name" placeholder="客户经理" />
|
||||
<FormItem :label="t('名称')">
|
||||
<Input v-model:value="form.name" :placeholder="t('客户经理')" />
|
||||
</FormItem>
|
||||
<FormItem label="角色标签(留空同名称)">
|
||||
<Input v-model:value="form.role" placeholder="客户经理 / 糖尿病专科医生" />
|
||||
<FormItem :label="t('角色标签(留空同名称)')">
|
||||
<Input v-model:value="form.role" :placeholder="t('客户经理 / 糖尿病专科医生')" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="ID(留空自动生成,之后不可改)">
|
||||
<FormItem :label="t('ID(留空自动生成,之后不可改)')">
|
||||
<Input
|
||||
v-model:value="form.id"
|
||||
:disabled="!!editingId"
|
||||
placeholder="customer-manager"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="职责(一句话,写给人看的)">
|
||||
<FormItem :label="t('职责(一句话,写给人看的)')">
|
||||
<Input
|
||||
v-model:value="form.description"
|
||||
placeholder="挂号预约、面诊链接、订单物流、投诉退款"
|
||||
:placeholder="t('挂号预约、面诊链接、订单物流、投诉退款')"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="负责话题(协作时按它认领本轮;留空 = 不主动认领)">
|
||||
<FormItem :label="t('负责话题(协作时按它认领本轮;留空 = 不主动认领)')">
|
||||
<Select
|
||||
v-model:value="form.keywords"
|
||||
mode="tags"
|
||||
placeholder="输入后回车,可以加多个"
|
||||
:placeholder="t('输入后回车,可以加多个')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="人设(这个角色怎么说话、管哪一摊事)">
|
||||
<FormItem :label="t('人设(这个角色怎么说话、管哪一摊事)')">
|
||||
<Textarea
|
||||
v-model:value="form.persona"
|
||||
:rows="5"
|
||||
placeholder="你负责流程类事情:挂号预约、面诊安排、订单物流、投诉退款……"
|
||||
:placeholder="t('你负责流程类事情:挂号预约、面诊安排、订单物流、投诉退款……')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
追加在内置人设后面,压过默认的表达习惯。不用重复写「像真人一样说话」
|
||||
这种通用要求,内置人设里已经有了——这里只写这个角色特有的部分。
|
||||
{{ t('追加在内置人设后面,压过默认的表达习惯。不用重复写「像真人一样说话」这种通用要求,内置人设里已经有了——这里只写这个角色特有的部分。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="规则">
|
||||
<FormItem :label="t('规则')">
|
||||
<div
|
||||
v-for="(rule, index) in form.rules"
|
||||
:key="index"
|
||||
@@ -381,12 +386,12 @@ onMounted(load);
|
||||
>
|
||||
<div class="mb-2 flex items-start gap-3">
|
||||
<div class="w-44 shrink-0">
|
||||
<Input v-model:value="rule.label" placeholder="规则名称" />
|
||||
<Input v-model:value="rule.label" :placeholder="t('规则名称')" />
|
||||
</div>
|
||||
<div class="w-64 shrink-0">
|
||||
<Select
|
||||
v-model:value="rule.type"
|
||||
:options="RULE_TYPES"
|
||||
:options="ruleTypes"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
@@ -398,18 +403,18 @@ onMounted(load);
|
||||
class="shrink-0"
|
||||
@click="removeRule(index)"
|
||||
>
|
||||
删除
|
||||
{{ t('删除') }}
|
||||
</Button>
|
||||
</div>
|
||||
<p class="mb-2 text-xs text-gray-500">{{ RULE_HINT[rule.type] }}</p>
|
||||
<p class="mb-2 text-xs text-gray-500">{{ ruleHints[rule.type] }}</p>
|
||||
<Select
|
||||
v-model:value="rule.keywords"
|
||||
mode="tags"
|
||||
class="mb-2"
|
||||
:placeholder="
|
||||
rule.type === 'forbid'
|
||||
? '回复里不许出现的词,回车分隔'
|
||||
: '触发关键词,回车分隔'
|
||||
? t('回复里不许出现的词,回车分隔')
|
||||
: t('触发关键词,回车分隔')
|
||||
"
|
||||
style="width: 100%"
|
||||
/>
|
||||
@@ -417,7 +422,7 @@ onMounted(load);
|
||||
v-if="rule.type === 'guide'"
|
||||
v-model:value="rule.instruction"
|
||||
:rows="2"
|
||||
placeholder="要告诉模型的话,例如:客户只发数字时按血糖值理解,严禁说这是系统编码。"
|
||||
:placeholder="t('要告诉模型的话,例如:客户只发数字时按血糖值理解,严禁说这是系统编码。')"
|
||||
/>
|
||||
<Textarea
|
||||
v-else
|
||||
@@ -425,19 +430,19 @@ onMounted(load);
|
||||
:rows="2"
|
||||
:placeholder="
|
||||
rule.type === 'reply'
|
||||
? '命中后直接发给客户的话术'
|
||||
: '命中禁止措辞后的兜底话术(留空则用客户端内置的那句)'
|
||||
? t('命中后直接发给客户的话术')
|
||||
: t('命中禁止措辞后的兜底话术(留空则用客户端内置的那句)')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<Button size="small" @click="addRule">+ 新增规则</Button>
|
||||
<Button size="small" @click="addRule">+ {{ t('新增规则') }}</Button>
|
||||
</FormItem>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||
<FormItem label="优先级(数字越小越靠前)">
|
||||
<FormItem :label="t('优先级(数字越小越靠前)')">
|
||||
<InputNumber v-model:value="form.priority" :min="1" :max="999" />
|
||||
</FormItem>
|
||||
<FormItem label="启用">
|
||||
<FormItem :label="t('启用')">
|
||||
<Switch v-model:checked="form.enabled" />
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { onMounted, ref } from 'vue';
|
||||
import { Card, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { fetchAudit } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const loading = ref(false);
|
||||
const entries = ref<any[]>([]);
|
||||
@@ -22,6 +23,9 @@ const TONE: Record<string, string> = {
|
||||
'role.delete': 'red',
|
||||
'model.provider.delete': 'red',
|
||||
'role.save': 'orange',
|
||||
'permission.delete': 'red',
|
||||
'permission.update': 'orange',
|
||||
'permission.create': 'blue',
|
||||
'model.roles.save': 'orange',
|
||||
'model.provider.save': 'blue',
|
||||
};
|
||||
@@ -31,19 +35,19 @@ onMounted(load);
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="审计日志">
|
||||
<Card :title="t('审计日志')">
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
谁在什么时候改了什么——出事之后唯一能回溯的东西。只显示最近 200 条。
|
||||
{{ t('谁在什么时候改了什么——出事之后唯一能回溯的东西。只显示最近 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 },
|
||||
{ title: t('时间'), dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: t('操作人'), dataIndex: 'username', key: 'username', width: 120 },
|
||||
{ title: t('动作'), dataIndex: 'action', key: 'action', width: 200 },
|
||||
{ title: t('详情'), dataIndex: 'detail', key: 'detail' },
|
||||
{ title: t('来源 IP'), dataIndex: 'ip_address', key: 'ip_address', width: 140 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -51,7 +55,7 @@ onMounted(load);
|
||||
<Tag :color="TONE[record.action] || 'default'">{{ record.action }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'username'">
|
||||
{{ record.username || '(未登录)' }}
|
||||
{{ record.username || t('(未登录)') }}
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
|
||||
import { ALL_ACCOUNTS, fetchCallLog, fetchCallStats } from '#/api/console';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import ModelUsageStats from './ModelUsageStats.vue';
|
||||
import { useUsageRequest } from './use-usage-request';
|
||||
@@ -139,8 +140,8 @@ const RISK_COLOR: Record<string, string> = {
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="客服质量与模型用量"
|
||||
description="按账号和时间查看实际模型请求、已记录 Token,以及客服回复的裁判评分、风险分布和最终选用情况。"
|
||||
:message="t('客服质量与模型用量')"
|
||||
:description="t('按账号和时间查看实际模型请求、已记录 Token,以及客服回复的裁判评分、风险分布和最终选用情况。')"
|
||||
/>
|
||||
|
||||
<div class="mb-4 flex flex-wrap items-center gap-3">
|
||||
@@ -150,41 +151,41 @@ const RISK_COLOR: Record<string, string> = {
|
||||
/>
|
||||
<Segmented
|
||||
v-model:value="days"
|
||||
:options="[{ value: 1, label: '近 1 天' }, { value: 7, label: '近 7 天' }, { value: 30, label: '近 30 天' }]"
|
||||
:options="[{ value: 1, label: t('近 1 天') }, { value: 7, label: t('近 7 天') }, { value: 30, label: t('近 30 天') }]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelUsageStats :account-id="accountId" :days="days" />
|
||||
|
||||
<h2 class="mb-3 text-lg font-medium">客服回复质量</h2>
|
||||
<h2 class="mb-3 text-lg font-medium">{{ t('客服回复质量') }}</h2>
|
||||
<Alert v-if="statsError" class="mb-3" type="error" show-icon :message="statsError" />
|
||||
<Row :gutter="16" class="mb-4">
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="业务调用记录" :value="stats?.total ?? 0" /></Card>
|
||||
<Card size="small"><Statistic :title="t('业务调用记录')" :value="stats?.total ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small"><Statistic title="已评审" :value="stats?.judged ?? 0" /></Card>
|
||||
<Card size="small"><Statistic :title="t('已评审')" :value="stats?.judged ?? 0" /></Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均分" :value="stats?.avg_score ?? 0" :precision="3" />
|
||||
<Statistic :title="t('平均分')" :value="stats?.avg_score ?? 0" :precision="3" />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col :span="6">
|
||||
<Card size="small">
|
||||
<Statistic title="平均耗时" :value="stats?.avg_ms ?? 0" suffix="ms" />
|
||||
<Statistic :title="t('平均耗时')" :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="还没有评审数据" />
|
||||
<Card :title="t('裁判分数分布')" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!stats?.score_buckets?.length" :description="t('还没有评审数据')" />
|
||||
<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>
|
||||
<span>{{ t('{count} 条', { count: bucket.count }) }}</span>
|
||||
</div>
|
||||
<Progress
|
||||
:percent="Math.round((bucket.count / maxBucket) * 100)"
|
||||
@@ -195,24 +196,24 @@ const RISK_COLOR: Record<string, string> = {
|
||||
</Col>
|
||||
|
||||
<Col :span="12">
|
||||
<Card title="风险等级" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!riskRows.length" description="还没有评审数据" />
|
||||
<Card :title="t('风险等级')" size="small" :loading="loading" class="mb-4">
|
||||
<Empty v-if="!riskRows.length" :description="t('还没有评审数据')" />
|
||||
<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>
|
||||
<span class="w-16 text-right text-xs">{{ t('{count} 条', { count: row.count }) }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card title="各模型被选中的比例" size="small" :loading="loading">
|
||||
<Card :title="t('各模型被选中的比例')" 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 },
|
||||
{ title: t('模型'), dataIndex: 'provider', key: 'provider' },
|
||||
{ title: t('被选中'), dataIndex: 'count', key: 'count', width: 100 },
|
||||
{ title: t('占比'), key: 'percent', width: 240 },
|
||||
]"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -222,34 +223,34 @@ const RISK_COLOR: Record<string, string> = {
|
||||
</template>
|
||||
</Table>
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
统计区间自 {{ stats?.since || '—' }} 起。最慢一次 {{ stats?.max_ms ?? 0 }}ms。
|
||||
{{ t('统计区间自 {since} 起。最慢一次 {max}ms。', { max: stats?.max_ms ?? 0, since: stats?.since || '—' }) }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="调用记录" size="small" class="mt-4">
|
||||
<Card :title="t('调用记录')" size="small" class="mt-4">
|
||||
<template #extra>
|
||||
<Input.Search
|
||||
v-model:value="logKeyword"
|
||||
placeholder="搜客户消息或模型回复里的关键词"
|
||||
:placeholder="t('搜客户消息或模型回复里的关键词')"
|
||||
style="width: 280px"
|
||||
allow-clear
|
||||
@search="searchLog"
|
||||
/>
|
||||
</template>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
按时间倒序,一条一次调用:客户说了什么、模型候选都答了什么、最终选中的是哪个、有没有被审核规则拦下——出问题时按这张表往回查。
|
||||
{{ t('按时间倒序,一条一次调用:客户说了什么、模型候选都答了什么、最终选中的是哪个、有没有被审核规则拦下——出问题时按这张表往回查。') }}
|
||||
</p>
|
||||
<div class="mb-3">
|
||||
<Segmented
|
||||
v-model:value="logPurpose"
|
||||
:options="[
|
||||
{ value: 'chat', label: '客服对话' },
|
||||
{ value: 'guard', label: '界面识别' },
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'chat', label: t('客服对话') },
|
||||
{ value: 'guard', label: t('界面识别') },
|
||||
{ value: '', label: t('全部') },
|
||||
]"
|
||||
/>
|
||||
<span class="ml-3 text-xs text-gray-500">
|
||||
「界面识别」是机器人自己看企业微信窗口用的,不是发给客户的话;它每轮轮询都要问一次模型,条数远多于真实对话,所以默认不混在一起。
|
||||
{{ t('「界面识别」是机器人自己看企业微信窗口用的,不是发给客户的话;它每轮轮询都要问一次模型,条数远多于真实对话,所以默认不混在一起。') }}
|
||||
</span>
|
||||
</div>
|
||||
<Alert v-if="logError" class="mb-3" type="error" show-icon :message="logError" />
|
||||
@@ -262,18 +263,18 @@ const RISK_COLOR: Record<string, string> = {
|
||||
current: logPage,
|
||||
pageSize: logPageSize,
|
||||
total: logTotal,
|
||||
showTotal: (total: number) => `共 ${total} 条`,
|
||||
showTotal: (total: number) => t('共 {total} 条', { 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: t('时间'), key: 'created_at', width: 130 },
|
||||
{ title: t('客户消息'), key: 'customer_text', width: 200, ellipsis: true },
|
||||
{ title: t('模型回复'), key: 'reply_text', width: 200, ellipsis: true },
|
||||
{ title: t('选中'), dataIndex: 'chosen', key: 'chosen', width: 110, ellipsis: true },
|
||||
{ title: t('裁判分'), dataIndex: 'judge_score', key: 'judge_score', width: 80 },
|
||||
{ title: t('风险'), dataIndex: 'judge_risk', key: 'judge_risk', width: 80 },
|
||||
{ title: t('审核'), key: 'review_reason', width: 90 },
|
||||
{ title: t('耗时'), dataIndex: 'total_ms', key: 'total_ms', width: 80 },
|
||||
{ title: '', key: 'action', width: 70 },
|
||||
]"
|
||||
>
|
||||
@@ -292,14 +293,14 @@ const RISK_COLOR: Record<string, string> = {
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'review_reason'">
|
||||
<Tag v-if="record.review_reason" color="orange">已拦</Tag>
|
||||
<Tag v-if="record.review_reason" color="orange">{{ t('已拦') }}</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>
|
||||
<a @click="detailItem = record as ModelCallLogItem">{{ t('详情') }}</a>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -307,25 +308,25 @@ const RISK_COLOR: Record<string, string> = {
|
||||
|
||||
<Modal
|
||||
:open="!!detailItem"
|
||||
title="调用详情"
|
||||
:title="t('调用详情')"
|
||||
: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 class="mb-1 text-xs text-gray-500">{{ t('客户消息') }}</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.customer_text || t('(空)') }}</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 class="mb-1 text-xs text-gray-500">{{ t('模型回复(最终选中)') }}</div>
|
||||
<div class="whitespace-pre-wrap rounded bg-gray-50 p-2 text-sm dark:bg-gray-800">{{ detailItem.reply_text || t('(空)') }}</div>
|
||||
</div>
|
||||
<div v-if="detailItem.review_reason" class="mb-3">
|
||||
<Alert type="warning" show-icon :message="`已停发送审核:${detailItem.review_reason}`" />
|
||||
<Alert type="warning" show-icon :message="t('已停发送审核:{reason}', { reason: detailItem.review_reason })" />
|
||||
</div>
|
||||
<div v-if="detailItem.candidates?.length" class="mb-3">
|
||||
<div class="mb-1 text-xs text-gray-500">全部候选</div>
|
||||
<div class="mb-1 text-xs text-gray-500">{{ t('全部候选') }}</div>
|
||||
<div
|
||||
v-for="(candidate, index) in detailItem.candidates"
|
||||
:key="index"
|
||||
@@ -333,15 +334,20 @@ const RISK_COLOR: Record<string, string> = {
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<Tag :color="candidate.provider === detailItem.chosen ? 'green' : 'default'">
|
||||
{{ candidate.provider }}{{ candidate.provider === detailItem.chosen ? '(选中)' : '' }}
|
||||
{{ candidate.provider }}{{ candidate.provider === detailItem.chosen ? t('(选中)') : '' }}
|
||||
</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 class="whitespace-pre-wrap">{{ candidate.text || candidate.error || t('(空)') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">
|
||||
裁判 {{ detailItem.judge_winner || '—' }}/{{ detailItem.judge_score }}分,共 {{ detailItem.total_ms }}ms,{{ formatTime(detailItem.created_at) }}
|
||||
{{ t('裁判 {winner}/{score}分,共 {ms}ms,{time}', {
|
||||
ms: detailItem.total_ms,
|
||||
score: detailItem.judge_score,
|
||||
time: formatTime(detailItem.created_at),
|
||||
winner: detailItem.judge_winner || '—',
|
||||
}) }}
|
||||
</p>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Alert, Button, Card, Form, FormItem, Input, message } from 'ant-design-vue';
|
||||
|
||||
import { changeMyPassword } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
// 只有后端标记「首登必须改密」时(getUserInfoApi 把落地页锁在本页)才挂警告;从下拉「个人中心」进来改密码的不挂
|
||||
const mustChange = computed(() => userStore.userInfo?.homePath === '/profile/password');
|
||||
const submitting = ref(false);
|
||||
const form = reactive({ current_password: '', new_password: '', confirm: '' });
|
||||
|
||||
async function submit() {
|
||||
if (form.new_password !== form.confirm) {
|
||||
message.warning('两次输入的新密码不一致');
|
||||
message.warning(t('两次输入的新密码不一致'));
|
||||
return;
|
||||
}
|
||||
submitting.value = true;
|
||||
@@ -21,7 +27,7 @@ async function submit() {
|
||||
current_password: form.current_password,
|
||||
new_password: form.new_password,
|
||||
});
|
||||
message.success('密码已修改');
|
||||
message.success(t('密码已修改'));
|
||||
await router.push('/');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
@@ -32,24 +38,25 @@ async function submit() {
|
||||
<template>
|
||||
<div class="mx-auto max-w-xl p-5">
|
||||
<Alert
|
||||
v-if="mustChange"
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="首次登录必须修改初始密码"
|
||||
description="初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。"
|
||||
:message="t('首次登录必须修改初始密码')"
|
||||
:description="t('初始密码是公开的默认值,不改等于没有密码。改完才能进入其他页面。')"
|
||||
/>
|
||||
<Card title="修改密码">
|
||||
<Card :title="t('修改密码')">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="当前密码">
|
||||
<FormItem :label="t('当前密码')">
|
||||
<Input.Password v-model:value="form.current_password" />
|
||||
</FormItem>
|
||||
<FormItem label="新密码(至少 10 位,需同时含字母和数字)">
|
||||
<FormItem :label="t('新密码(至少 10 位,需同时含字母和数字)')">
|
||||
<Input.Password v-model:value="form.new_password" />
|
||||
</FormItem>
|
||||
<FormItem label="确认新密码">
|
||||
<FormItem :label="t('确认新密码')">
|
||||
<Input.Password v-model:value="form.confirm" />
|
||||
</FormItem>
|
||||
<Button type="primary" :loading="submitting" @click="submit">提交</Button>
|
||||
<Button type="primary" :loading="submitting" @click="submit">{{ t('提交') }}</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
updateDesktopAccount,
|
||||
updateDesktopAccountAdmins,
|
||||
} from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['desktop-account:write']));
|
||||
const canPolicy = computed(() => hasAccessByCodes(['desktop-account:write']));
|
||||
const canAssign = computed(() => hasAccessByCodes(['desktop-account:assign']));
|
||||
const canRevoke = computed(() => hasAccessByCodes(['desktop-account:revoke']));
|
||||
const loading = ref(false);
|
||||
const accounts = ref<DesktopAccountItem[]>([]);
|
||||
const adminUsers = ref<UserItem[]>([]);
|
||||
@@ -51,7 +54,7 @@ async function load() {
|
||||
try {
|
||||
const [accountData, userData] = await Promise.all([
|
||||
fetchDesktopAccounts(),
|
||||
canWrite.value ? fetchUsers() : Promise.resolve({ users: [] }),
|
||||
canAssign.value ? fetchUsers() : Promise.resolve({ users: [] }),
|
||||
]);
|
||||
accounts.value = accountData.accounts;
|
||||
adminUsers.value = userData.users.filter((item) => item.active);
|
||||
@@ -62,7 +65,7 @@ async function load() {
|
||||
|
||||
async function edit(row: DesktopAccountItem) {
|
||||
editing.value = row;
|
||||
const scope = canWrite.value
|
||||
const scope = canAssign.value
|
||||
? await fetchDesktopAccountAdmins(row.id)
|
||||
: { admin_user_ids: [] };
|
||||
Object.assign(form, {
|
||||
@@ -78,45 +81,51 @@ async function edit(row: DesktopAccountItem) {
|
||||
|
||||
async function save() {
|
||||
if (!editing.value) return;
|
||||
await updateDesktopAccount(editing.value.id, {
|
||||
status: form.status,
|
||||
backup_enabled: form.overrideBackup ? form.backup_enabled : null,
|
||||
backup_interval_minutes: form.overrideInterval
|
||||
? form.backup_interval_minutes
|
||||
: null,
|
||||
});
|
||||
await updateDesktopAccountAdmins(editing.value.id, form.admin_user_ids);
|
||||
// 弹窗里两块各有各的权限:只保存自己能改的那一块
|
||||
if (canPolicy.value) {
|
||||
await updateDesktopAccount(editing.value.id, {
|
||||
status: form.status,
|
||||
backup_enabled: form.overrideBackup ? form.backup_enabled : null,
|
||||
backup_interval_minutes: form.overrideInterval
|
||||
? form.backup_interval_minutes
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (canAssign.value) {
|
||||
await updateDesktopAccountAdmins(editing.value.id, form.admin_user_ids);
|
||||
}
|
||||
open.value = false;
|
||||
message.success('客户端账号策略已更新');
|
||||
message.success(t('客户端账号策略已更新'));
|
||||
await load();
|
||||
}
|
||||
|
||||
async function revoke(row: DesktopAccountItem) {
|
||||
await revokeDesktopDevices(row.id);
|
||||
message.success('该账号的桌面设备已全部下线');
|
||||
message.success(t('该账号的桌面设备已全部下线'));
|
||||
await load();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ title: 'ZYT 账号', key: 'account', width: 220 },
|
||||
{ title: '客户端', key: 'device', width: 200 },
|
||||
{ title: '数据', key: 'data', width: 160 },
|
||||
{ title: '最后在线', dataIndex: 'last_online_at', key: 'last_online_at', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: '操作', key: 'actions', width: 180 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('ZYT 账号'), key: 'account', width: 220 },
|
||||
{ title: t('客户端'), key: 'device', width: 200 },
|
||||
{ title: t('数据'), key: 'data', width: 160 },
|
||||
{ title: t('最后在线'), dataIndex: 'last_online_at', key: 'last_online_at', width: 180 },
|
||||
{ title: t('状态'), dataIndex: 'status', key: 'status', width: 100 },
|
||||
{ title: t('操作'), key: 'actions', width: 180 },
|
||||
]);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="客户端账号">
|
||||
<Card :title="t('客户端账号')">
|
||||
<template #extra>
|
||||
<Button @click="load">刷新</Button>
|
||||
<Button @click="load">{{ t('刷新') }}</Button>
|
||||
</template>
|
||||
<p class="mb-4 text-xs text-gray-500">
|
||||
状态按客户端最近 20 秒的有效会话判断。同一 ZYT 账号只保留一个有效桌面会话,新设备登录会使旧设备下线。
|
||||
{{ t('状态按客户端最近 20 秒的有效会话判断。同一 ZYT 账号只保留一个有效桌面会话,新设备登录会使旧设备下线。') }}
|
||||
</p>
|
||||
<Table
|
||||
:columns="columns"
|
||||
@@ -129,79 +138,83 @@ onMounted(load);
|
||||
<template v-if="column.key === 'account'">
|
||||
<div class="font-medium">{{ record.nickname || record.zyt_sn }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ record.zyt_sn }} · {{ record.mobile_masked || '未绑定手机' }}
|
||||
{{ record.zyt_sn }} · {{ record.mobile_masked || t('未绑定手机') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'device'">
|
||||
<div>{{ record.current_device_name || '暂无设备' }}</div>
|
||||
<div>{{ record.current_device_name || t('暂无设备') }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ record.app_version || '未知版本' }} · {{ record.device_count }} 台
|
||||
{{ record.app_version || t('未知版本') }} · {{ t('{count} 台', { count: record.device_count }) }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'data'">
|
||||
<div>{{ record.source_account_count }} 个企微账号</div>
|
||||
<div class="text-xs text-gray-500">{{ record.message_count }} 条消息</div>
|
||||
<div>{{ t('{count} 个企微账号', { count: record.source_account_count }) }}</div>
|
||||
<div class="text-xs text-gray-500">{{ t('{count} 条消息', { count: record.message_count }) }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag
|
||||
:color="record.status === 'disabled' ? 'red' : record.is_online ? 'green' : 'default'"
|
||||
>
|
||||
{{ record.status === 'disabled' ? '已禁用' : record.is_online ? '在线' : '离线' }}
|
||||
{{ record.status === 'disabled' ? t('已禁用') : record.is_online ? t('在线') : t('离线') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions'">
|
||||
<Button type="link" size="small" :disabled="!canWrite" @click="edit(record as DesktopAccountItem)">
|
||||
策略
|
||||
<Button type="link" size="small" :disabled="!canPolicy && !canAssign" @click="edit(record as DesktopAccountItem)">
|
||||
{{ t('策略') }}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
v-if="record.status === 'active' && record.is_online"
|
||||
title="确定让该账号所有桌面设备立即下线?"
|
||||
:title="t('确定让该账号所有桌面设备立即下线?')"
|
||||
@confirm="revoke(record as DesktopAccountItem)"
|
||||
>
|
||||
<Button type="link" danger size="small" :disabled="!canWrite">强制下线</Button>
|
||||
<Button type="link" danger size="small" :disabled="!canRevoke">{{ t('强制下线') }}</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" title="客户端账号策略" @ok="save">
|
||||
<Modal v-model:open="open" :title="t('客户端账号策略')" @ok="save">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="账号状态">
|
||||
<FormItem :label="t('账号状态')">
|
||||
<Select
|
||||
v-model:value="form.status"
|
||||
:disabled="!canPolicy"
|
||||
:options="[
|
||||
{ label: '正常', value: 'active' },
|
||||
{ label: '禁用并下线', value: 'disabled' },
|
||||
{ label: t('正常'), value: 'active' },
|
||||
{ label: t('禁用并下线'), value: 'disabled' },
|
||||
]"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Checkbox v-model:checked="form.overrideBackup">覆盖全局自动备份开关</Checkbox>
|
||||
<Checkbox v-model:checked="form.overrideBackup" :disabled="!canPolicy">{{ t('覆盖全局自动备份开关') }}</Checkbox>
|
||||
<Switch
|
||||
v-if="form.overrideBackup"
|
||||
v-model:checked="form.backup_enabled"
|
||||
:disabled="!canPolicy"
|
||||
class="ml-3"
|
||||
checked-children="启用"
|
||||
un-checked-children="停用"
|
||||
:checked-children="t('启用')"
|
||||
:un-checked-children="t('停用')"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="可查看该账号数据的管理端用户">
|
||||
<FormItem :label="t('可查看该账号数据的管理端用户')">
|
||||
<Select
|
||||
v-model:value="form.admin_user_ids"
|
||||
mode="multiple"
|
||||
:disabled="!canAssign"
|
||||
:options="adminUsers.map((item) => ({ label: item.username, value: item.id }))"
|
||||
placeholder="超级管理员无需分配;其他用户按此范围查看"
|
||||
:placeholder="t('超级管理员无需分配;其他用户按此范围查看')"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem>
|
||||
<Checkbox v-model:checked="form.overrideInterval">覆盖全局备份间隔</Checkbox>
|
||||
<Checkbox v-model:checked="form.overrideInterval" :disabled="!canPolicy">{{ t('覆盖全局备份间隔') }}</Checkbox>
|
||||
<InputNumber
|
||||
v-if="form.overrideInterval"
|
||||
v-model:value="form.backup_interval_minutes"
|
||||
:disabled="!canPolicy"
|
||||
:min="1"
|
||||
:max="10080"
|
||||
addon-after="分钟"
|
||||
:addon-after="t('分钟')"
|
||||
class="mt-2 w-full"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { fetchConfig, saveConfig } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['config:write']));
|
||||
@@ -63,15 +64,16 @@ const form = reactive<DesktopConfig>({
|
||||
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: '打印详细日志、跳过部分节流。生产环境请关闭。' },
|
||||
];
|
||||
// 文案要随语言切换:包成 computed
|
||||
const SWITCHES = computed<{ hint: string; key: keyof DesktopConfig; label: string }[]>(() => [
|
||||
{ key: 'AI_ENABLED', label: t('启用 AI 自动回复'), hint: t('总开关。关掉之后客户端只监听不回复。') },
|
||||
{ key: 'AI_USE_VISION', label: t('启用视觉识别'), hint: t('图片和表情包走视觉模型识别,OCR 认不出时兜底。用哪个模型看「角色编排」里的媒体消息专用模型。') },
|
||||
{ key: 'AI_UI_GUARD_ENABLED', label: t('人工操作保护'), hint: t('检测到有人在操作鼠标键盘时暂停自动化,避免抢焦点。') },
|
||||
{ key: 'AI_CONTEXT_ENABLED', label: t('携带上下文'), hint: t('把最近几轮对话一起发给模型。关掉会省 token,但答复会失忆。') },
|
||||
{ key: 'AI_COUNTER_INSULT_ENABLED', label: t('应对辱骂'), hint: t('客户情绪激动时用专门的话术回应,而不是照常答业务问题。') },
|
||||
{ key: 'AI_MCP_ENABLED', label: t('启用 MCP 工具'), hint: t('允许模型调用挂号登记、客户查询等外部工具。工具在客户端本机执行。') },
|
||||
{ key: 'AI_DEVELOPMENT_MODE', label: t('开发模式'), hint: t('打印详细日志、跳过部分节流。生产环境请关闭。') },
|
||||
]);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
@@ -106,10 +108,10 @@ function removeReviewRule(index: number) {
|
||||
function reviewRulesError(): string {
|
||||
for (const [index, rule] of form.AI_REVIEW_RULES.entries()) {
|
||||
if (!rule.label.trim()) {
|
||||
return `第 ${index + 1} 条规则没有填名称`;
|
||||
return t('第 {index} 条规则没有填名称', { index: index + 1 });
|
||||
}
|
||||
if (rule.keywords.length === 0) {
|
||||
return `规则「${rule.label}」至少要有一个关键词,否则永远不会命中`;
|
||||
return t('规则「{label}」至少要有一个关键词,否则永远不会命中', { label: rule.label });
|
||||
}
|
||||
}
|
||||
return '';
|
||||
@@ -119,13 +121,13 @@ function parseMcp(): null | unknown[] {
|
||||
try {
|
||||
const parsed = JSON.parse(mcpText.value || '[]');
|
||||
if (!Array.isArray(parsed)) {
|
||||
mcpError.value = 'JSON 根节点必须是数组';
|
||||
mcpError.value = t('JSON 根节点必须是数组');
|
||||
return null;
|
||||
}
|
||||
mcpError.value = '';
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
mcpError.value = `不是有效的 JSON:${(error as Error).message}`;
|
||||
mcpError.value = t('不是有效的 JSON:{error}', { error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -133,7 +135,7 @@ function parseMcp(): null | unknown[] {
|
||||
async function submit() {
|
||||
const servers = parseMcp();
|
||||
if (servers === null) {
|
||||
message.warning('MCP 服务器配置不是有效 JSON,请先修正');
|
||||
message.warning(t('MCP 服务器配置不是有效 JSON,请先修正'));
|
||||
return;
|
||||
}
|
||||
const ruleError = reviewRulesError();
|
||||
@@ -144,7 +146,7 @@ async function submit() {
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveConfig({ ...form, AI_MCP_SERVERS: servers });
|
||||
message.success(`已发布为 v${data.version},桌面端下次同步时生效`);
|
||||
message.success(t('已发布为 v{version},桌面端下次同步时生效', { version: data.version }));
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -160,59 +162,57 @@ onMounted(load);
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="这份配置会下发到每一台桌面客户端"
|
||||
description="保存即产生新版本号,客户端下次同步就会拿到。改错一个字段是全线生效的,不是只影响你自己这台。"
|
||||
:message="t('这份配置会下发到每一台桌面客户端')"
|
||||
:description="t('保存即产生新版本号,客户端下次同步就会拿到。改错一个字段是全线生效的,不是只影响你自己这台。')"
|
||||
/>
|
||||
|
||||
<Alert class="mb-4" type="info" show-icon>
|
||||
<template #message>模型配置不在这里</template>
|
||||
<template #message>{{ t('模型配置不在这里') }}</template>
|
||||
<template #description>
|
||||
<p class="mb-2">
|
||||
服务类型、API 地址、API Key、模型名称、温度、最大 tokens、请求超时——这些以前在本页的设置,
|
||||
现在统一在 <b>{{ movedTo || 'AI 模型 → 模型清单 / 角色编排' }}</b>。
|
||||
{{ t('服务类型、API 地址、API Key、模型名称、温度、最大 tokens、请求超时——这些以前在本页的设置,现在统一在') }}
|
||||
<b>{{ movedTo ? t(movedTo) : t('AI 模型 → 模型清单 / 角色编排') }}</b>{{ t('。') }}
|
||||
</p>
|
||||
<p class="mb-2 text-xs">
|
||||
搬走有三个原因:同一件事两个地方能配迟早会不一致;密钥留在这份配置里就必须明文下发到每一台客户端,
|
||||
而模型清单里是加密存储、只返遮罩值;一个客户只能配一个模型的话,双模型并发和裁判根本无从谈起。
|
||||
{{ t('搬走有三个原因:同一件事两个地方能配迟早会不一致;密钥留在这份配置里就必须明文下发到每一台客户端,而模型清单里是加密存储、只返遮罩值;一个客户只能配一个模型的话,双模型并发和裁判根本无从谈起。') }}
|
||||
</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>
|
||||
<Button size="small" @click="router.push('/model/catalog')">{{ t('去模型清单') }}</Button>
|
||||
<Button size="small" class="ml-2" @click="router.push('/model/plan')">{{ t('去角色编排') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<Card title="当前生效的配置" class="mb-4" :loading="loading">
|
||||
<Card :title="t('当前生效的配置')" class="mb-4" :loading="loading">
|
||||
<Descriptions :column="3" size="small" bordered>
|
||||
<DescriptionsItem label="版本">v{{ version }}</DescriptionsItem>
|
||||
<DescriptionsItem label="最后修改">{{ updatedBy || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="时间">{{ updatedAt || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('版本')">v{{ version }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('最后修改')">{{ updatedBy || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('时间')">{{ updatedAt || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Form :model="form" layout="vertical">
|
||||
<Card title="桌面账号登录" class="mb-4" :loading="loading">
|
||||
<Card :title="t('桌面账号登录')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
软件打开登录窗口后,会先请求本管理端的公开客户端配置,再按这里返回的地址请求 ZYT 登录。
|
||||
换登录域名只需修改这一处,无需重新修改或打包桌面软件。
|
||||
{{ t('软件打开登录窗口后,会先请求本管理端的公开客户端配置,再按这里返回的地址请求 ZYT 登录。换登录域名只需修改这一处,无需重新修改或打包桌面软件。') }}
|
||||
</p>
|
||||
<FormItem label="ZYT 登录 API 域名(留空 = 使用管理 API 启动配置)">
|
||||
<FormItem :label="t('ZYT 登录 API 域名(留空 = 使用管理 API 启动配置)')">
|
||||
<Input
|
||||
v-model:value="form.ZYT_LOGIN_API_URL"
|
||||
:disabled="!canWrite"
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
当前客户端实际获取:<code>{{ loginApiEffective || '—' }}</code>
|
||||
{{ t('当前客户端实际获取:') }}<code>{{ loginApiEffective || '—' }}</code>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
填写完整的 <code>http://</code> 或 <code>https://</code> 基础地址,不要填写
|
||||
<code>/api/login/account</code>;客户端会自动拼接登录接口路径。
|
||||
{{ t('填写完整的') }} <code>http://</code> {{ t('或') }} <code>https://</code> {{ t('基础地址,不要填写') }}
|
||||
<code>/api/login/account</code>{{ t(';客户端会自动拼接登录接口路径。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Card>
|
||||
|
||||
<Card title="能力开关" class="mb-4" :loading="loading">
|
||||
<Card :title="t('能力开关')" 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"
|
||||
@@ -232,18 +232,18 @@ onMounted(load);
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="企业微信数据库自动备份" class="mb-4" :loading="loading">
|
||||
<Card :title="t('企业微信数据库自动备份')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
软件启动后等待设定时间执行第一次增量备份,之后按相同间隔持续运行。只上传服务端检查点之后的新消息和新媒体,修改配置后客户端会自动同步,无需重启。
|
||||
{{ t('软件启动后等待设定时间执行第一次增量备份,之后按相同间隔持续运行。只上传服务端检查点之后的新消息和新媒体,修改配置后客户端会自动同步,无需重启。') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-1 gap-x-8 md:grid-cols-2">
|
||||
<FormItem label="自动增量备份">
|
||||
<FormItem :label="t('自动增量备份')">
|
||||
<Switch
|
||||
v-model:checked="form.ARCHIVE_AUTO_BACKUP_ENABLED"
|
||||
:disabled="!canWrite"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="启动等待及备份间隔(分钟)">
|
||||
<FormItem :label="t('启动等待及备份间隔(分钟)')">
|
||||
<InputNumber
|
||||
v-model:value="form.ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES"
|
||||
:min="1"
|
||||
@@ -251,96 +251,93 @@ onMounted(load);
|
||||
:disabled="!canWrite || !form.ARCHIVE_AUTO_BACKUP_ENABLED"
|
||||
class="w-full"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">范围 1 分钟至 7 天。</p>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ t('范围 1 分钟至 7 天。') }}</p>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="客服人格" class="mb-4" :loading="loading">
|
||||
<Card :title="t('客服人格')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
这几项会拼进系统提示词,决定客户看到的是"谁"在说话。它们不是模型参数,所以留在这里。
|
||||
{{ t('这几项会拼进系统提示词,决定客户看到的是"谁"在说话。它们不是模型参数,所以留在这里。') }}
|
||||
</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 :label="t('客服名称')">
|
||||
<Input v-model:value="form.AI_AGENT_NAME" :disabled="!canWrite" placeholder="贴心管家" /><!-- i18n-ignore: 客户端的默认客服名,原样提示 -->
|
||||
</FormItem>
|
||||
<FormItem label="机构名称">
|
||||
<Input v-model:value="form.AI_HOSPITAL_NAME" :disabled="!canWrite" placeholder="甄养堂" />
|
||||
<FormItem :label="t('机构名称')">
|
||||
<Input v-model:value="form.AI_HOSPITAL_NAME" :disabled="!canWrite" placeholder="甄养堂" /><!-- i18n-ignore: 客户端的默认机构名,原样提示 -->
|
||||
</FormItem>
|
||||
<FormItem label="上下文轮数(1–50)">
|
||||
<FormItem :label="t('上下文轮数(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">
|
||||
记几轮对话。这是客户端行为,和"用哪个模型"无关。
|
||||
{{ t('记几轮对话。这是客户端行为,和"用哪个模型"无关。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="模型网关地址" class="mb-4" :loading="loading">
|
||||
<Card :title="t('模型网关地址')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
桌面客户端只配一个后台地址,模型请求发到哪儿由这里决定、随配置一起下发。
|
||||
换网关位置只需要改这一处,不用挨个动客户端。
|
||||
{{ t('桌面客户端只配一个后台地址,模型请求发到哪儿由这里决定、随配置一起下发。换网关位置只需要改这一处,不用挨个动客户端。') }}
|
||||
</p>
|
||||
<FormItem label="地址(留空 = 按后台自己的地址自动推算)">
|
||||
<FormItem :label="t('地址(留空 = 按后台自己的地址自动推算)')">
|
||||
<Input
|
||||
v-model:value="form.AI_GATEWAY_URL"
|
||||
:disabled="!canWrite"
|
||||
placeholder="留空即可,绝大多数情况不用填"
|
||||
:placeholder="t('留空即可,绝大多数情况不用填')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
当前实际下发:<code>{{ gatewayEffective || '—' }}</code>
|
||||
{{ t('当前实际下发:') }}<code>{{ gatewayEffective || '—' }}</code>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
只有网关在别的域名或别的机器上时才需要填。写完整地址(<code>https://…</code>)
|
||||
或以 <code>/</code> 开头的路径(挂在本后台的域名下)。
|
||||
{{ t('只有网关在别的域名或别的机器上时才需要填。写完整地址(') }}<code>https://…</code>{{ t(')或以') }}
|
||||
<code>/</code> {{ t('开头的路径(挂在本后台的域名下)。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Card>
|
||||
|
||||
<Card title="审核后发送的审核范围" class="mb-4" :loading="loading">
|
||||
<Card :title="t('审核后发送的审核范围')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
只对切到「审核后发送」的客户端起作用;自动发送模式下只按下面的选择性审核规则拦截。
|
||||
{{ t('只对切到「审核后发送」的客户端起作用;自动发送模式下只按下面的选择性审核规则拦截。') }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<Switch
|
||||
v-model:checked="form.AI_REVIEW_EVERY_REPLY"
|
||||
:disabled="!canWrite"
|
||||
checked-children="每条都审核"
|
||||
un-checked-children="按原规则"
|
||||
:checked-children="t('每条都审核')"
|
||||
:un-checked-children="t('按原规则')"
|
||||
/>
|
||||
<span class="text-sm">{{ form.AI_REVIEW_EVERY_REPLY ? '每条都审核' : '按原规则(默认)' }}</span>
|
||||
<span class="text-sm">{{ form.AI_REVIEW_EVERY_REPLY ? t('每条都审核') : t('按原规则(默认)') }}</span>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-500">
|
||||
<template v-if="form.AI_REVIEW_EVERY_REPLY">
|
||||
每条 AI 回复都要人工确认后才发送;医疗助理人工回复过的会话也不会恢复自动回复。
|
||||
{{ t('每条 AI 回复都要人工确认后才发送;医疗助理人工回复过的会话也不会恢复自动回复。') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
医疗助理用同一企微账号人工回复并核验后,该会话恢复自动回复,之后仍逐条过审核规则和模型裁判。
|
||||
{{ t('医疗助理用同一企微账号人工回复并核验后,该会话恢复自动回复,之后仍逐条过审核规则和模型裁判。') }}
|
||||
</template>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
客户端默认跟随这里的设置,也可以在「自动化设置 → 审核后发送 → 审核范围」单独设置本机;1.5.3 起的桌面端生效,更早的版本保持按原规则。
|
||||
{{ t('客户端默认跟随这里的设置,也可以在「自动化设置 → 审核后发送 → 审核范围」单独设置本机;1.5.3 起的桌面端生效,更早的版本保持按原规则。') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="选择性审核规则" class="mb-4" :loading="loading">
|
||||
<Card :title="t('选择性审核规则')" class="mb-4" :loading="loading">
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
命中任意一条,那一条回复才会停下来贴进企业微信输入框等人工确认,不会自动按回车;
|
||||
没命中的照常自动发送。客户这句话和模型准备发的回复,
|
||||
沾上任意一边都算命中。
|
||||
{{ t('命中任意一条,那一条回复才会停下来贴进企业微信输入框等人工确认,不会自动按回车;没命中的照常自动发送。客户这句话和模型准备发的回复,沾上任意一边都算命中。') }}
|
||||
</p>
|
||||
<Alert
|
||||
class="mb-3"
|
||||
type="info"
|
||||
show-icon
|
||||
message="还有一条内置规则不在这份清单里、关不掉"
|
||||
description="模型裁判把某条回复判成「高风险」时,无论有没有命中下面的关键词,同样会转人工——前提是角色编排里配了裁判模型。1.5.2 之后的桌面端不论裁判模式都会拦;1.5.2 及更早的只在裁判模式为 score_only / arbitrate 时才拦,shadow 下照常自动发送。"
|
||||
:message="t('还有一条内置规则不在这份清单里、关不掉')"
|
||||
:description="t('模型裁判把某条回复判成「高风险」时,无论有没有命中下面的关键词,同样会转人工——前提是角色编排里配了裁判模型。1.5.2 之后的桌面端不论裁判模式都会拦;1.5.2 及更早的只在裁判模式为 score_only / arbitrate 时才拦,shadow 下照常自动发送。')"
|
||||
/>
|
||||
<p class="mb-3 text-xs text-gray-500">
|
||||
关键词按包含匹配,忽略大小写、空格和标点(1.5.2 及更早的桌面端只忽略大小写);建议把客户的日常说法都填上,如 服用、怎么吃、用法、用量、饭前、饭后。
|
||||
{{ t('关键词按包含匹配,忽略大小写、空格和标点(1.5.2 及更早的桌面端只忽略大小写);建议把客户的日常说法都填上,如 服用、怎么吃、用法、用量、饭前、饭后。') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="(rule, index) in form.AI_REVIEW_RULES"
|
||||
@@ -357,7 +354,7 @@ onMounted(load);
|
||||
<Input
|
||||
v-model:value="rule.label"
|
||||
:disabled="!canWrite"
|
||||
placeholder="规则名称,例如「诊断」"
|
||||
:placeholder="t('规则名称,例如「诊断」')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -366,7 +363,7 @@ onMounted(load);
|
||||
mode="tags"
|
||||
:token-separators="[',', ',', '、', ';', ';', ' ', ' ', '\t']"
|
||||
:disabled="!canWrite"
|
||||
placeholder="输入关键词后按回车;用逗号、顿号或空格隔开可一次粘贴多个"
|
||||
:placeholder="t('输入关键词后按回车;用逗号、顿号或空格隔开可一次粘贴多个')"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</div>
|
||||
@@ -375,25 +372,25 @@ onMounted(load);
|
||||
danger type="link" size="small" :disabled="!canWrite"
|
||||
class="shrink-0" @click="removeReviewRule(index)"
|
||||
>
|
||||
删除
|
||||
{{ t('删除') }}
|
||||
</Button>
|
||||
</div>
|
||||
<Button v-if="canWrite" size="small" @click="addReviewRule">+ 新增规则</Button>
|
||||
<Button v-if="canWrite" size="small" @click="addReviewRule">+ {{ t('新增规则') }}</Button>
|
||||
<p v-if="form.AI_REVIEW_RULES.length === 0" class="mt-2 text-xs text-gray-500">
|
||||
还没有配任何规则——除了上面那条内置的裁判高风险检查,其余全部自动发送。
|
||||
{{ t('还没有配任何规则——除了上面那条内置的裁判高风险检查,其余全部自动发送。') }}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card title="MCP 服务器" class="mb-4" :loading="loading">
|
||||
<Card :title="t('MCP 服务器')" class="mb-4" :loading="loading">
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-4">
|
||||
<FormItem label="单次最多调用工具轮数(1–20)">
|
||||
<FormItem :label="t('单次最多调用工具轮数(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 数组)">
|
||||
<FormItem :label="t('服务器清单(JSON 数组)')">
|
||||
<Textarea
|
||||
v-model:value="mcpText"
|
||||
:rows="8"
|
||||
@@ -405,8 +402,7 @@ onMounted(load);
|
||||
/>
|
||||
<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">
|
||||
工具跑在客户端本机(要连内网),所以配置在这里而不是模型清单里。
|
||||
格式错误会当场拦下,不会等到桌面端同步之后才发现。
|
||||
{{ t('工具跑在客户端本机(要连内网),所以配置在这里而不是模型清单里。格式错误会当场拦下,不会等到桌面端同步之后才发现。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Card>
|
||||
@@ -415,8 +411,8 @@ onMounted(load);
|
||||
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>
|
||||
<Button type="primary" :loading="saving" @click="submit">{{ t('保存并发布新版本') }}</Button>
|
||||
<Button class="ml-2" :disabled="saving" @click="load">{{ t('放弃修改') }}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,13 @@ 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';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['model:write']));
|
||||
const canCreate = computed(() => hasAccessByCodes(['model:create']));
|
||||
const canEdit = computed(() => hasAccessByCodes(['model:write']));
|
||||
const canTest = computed(() => hasAccessByCodes(['model:test']));
|
||||
const canDelete = computed(() => hasAccessByCodes(['model:delete']));
|
||||
|
||||
const loading = ref(false);
|
||||
const models = ref<ModelProvider[]>([]);
|
||||
@@ -88,13 +92,13 @@ function openEdit(row: ModelProvider) {
|
||||
|
||||
async function submit() {
|
||||
if (!form.id || !form.base_url) {
|
||||
message.warning('模型 ID 和接口地址不能为空');
|
||||
message.warning(t('模型 ID 和接口地址不能为空'));
|
||||
return;
|
||||
}
|
||||
const data = await saveModel({ ...form });
|
||||
models.value = data.models;
|
||||
open.value = false;
|
||||
message.success(editingId.value ? '已更新' : '已新增');
|
||||
message.success(editingId.value ? t('已更新') : t('已新增'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,12 +119,13 @@ async function runTest(row: ModelProvider) {
|
||||
try {
|
||||
const data = await testModel({ provider_id: row.id, timeout_seconds: 20 });
|
||||
const r = data.result;
|
||||
const parts = { endpoint: r.endpoint, latency: r.latency_ms, message: r.message, status: r.http_status };
|
||||
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}` : ''
|
||||
}`,
|
||||
text: r.http_status
|
||||
? t('{message} · 端点 {endpoint} · 耗时 {latency}ms · HTTP {status}', parts)
|
||||
: t('{message} · 端点 {endpoint} · 耗时 {latency}ms', parts),
|
||||
};
|
||||
} finally {
|
||||
testing.value = '';
|
||||
@@ -130,33 +135,35 @@ async function runTest(row: ModelProvider) {
|
||||
async function remove(row: ModelProvider) {
|
||||
const data = await deleteModel(row.id);
|
||||
models.value = data.models;
|
||||
message.success('已删除');
|
||||
message.success(t('已删除'));
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
// 「实际请求地址」省略显示会让表格按固定列宽排版:没写宽度的列在窄窗口里会被挤成 0,
|
||||
// 所以每列都给宽度,放不下时表格横向滚动
|
||||
{ title: t('名称'), dataIndex: 'name', key: 'name', width: 160 },
|
||||
{ title: t('接口类型'), dataIndex: 'kind', key: 'kind', width: 110 },
|
||||
{ title: t('实际请求地址'), dataIndex: 'endpoint', key: 'endpoint', ellipsis: true, width: 260 },
|
||||
{ title: t('模型'), dataIndex: 'model', key: 'model', width: 160 },
|
||||
{ title: t('密钥'), dataIndex: 'api_key_masked', key: 'api_key_masked', width: 160 },
|
||||
{ title: t('能力'), dataIndex: 'capabilities', key: 'capabilities', width: 120 },
|
||||
{ title: t('并发上限'), dataIndex: 'max_inflight', key: 'max_inflight', width: 90 },
|
||||
{ title: t('状态'), dataIndex: 'enabled', key: 'enabled', width: 90 },
|
||||
{ title: t('操作'), key: 'action', width: 200 },
|
||||
]);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="模型清单">
|
||||
<Card :title="t('模型清单')">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增模型</Button>
|
||||
<Button v-if="canCreate" type="primary" @click="openCreate">{{ t('新增模型') }}</Button>
|
||||
</template>
|
||||
<p class="mb-3 text-sm text-gray-500">
|
||||
密钥在后端加密存储,接口只返回遮罩值——这里看到的星号不是显示效果,
|
||||
是后端真的不会把明文发出来。留空保存表示不改动原密钥。
|
||||
{{ t('密钥在后端加密存储,接口只返回遮罩值——这里看到的星号不是显示效果,是后端真的不会把明文发出来。留空保存表示不改动原密钥。') }}
|
||||
</p>
|
||||
<Alert
|
||||
v-if="testResult"
|
||||
@@ -164,13 +171,13 @@ onMounted(load);
|
||||
closable
|
||||
:type="testResult.ok ? 'success' : 'error'"
|
||||
show-icon
|
||||
:message="`${testResult.label}:${testResult.ok ? '连接成功' : '连接失败'}`"
|
||||
:message="testResult.ok ? t('{label}:连接成功', { label: testResult.label }) : t('{label}:连接失败', { label: testResult.label })"
|
||||
:description="testResult.text"
|
||||
@close="testResult = null"
|
||||
/>
|
||||
<Table
|
||||
:columns="columns" :data-source="models" :loading="loading"
|
||||
row-key="id" size="small" :pagination="false"
|
||||
row-key="id" size="small" :pagination="false" :scroll="{ x: 1350 }"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'kind'">
|
||||
@@ -178,50 +185,50 @@ onMounted(load);
|
||||
</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>
|
||||
<Tag v-if="record.endpoint_mode === 'exact'" class="ml-1" color="purple">{{ t('原样') }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'api_key_masked'">
|
||||
<code class="text-xs">{{ record.api_key_masked || '(未设置)' }}</code>
|
||||
<code class="text-xs">{{ record.api_key_masked || t('(未设置)') }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'enabled'">
|
||||
<Tag :color="record.enabled ? 'green' : 'default'">
|
||||
{{ record.enabled ? '已启用' : '已停用' }}
|
||||
{{ record.enabled ? t('已启用') : t('已停用') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button
|
||||
type="link" size="small"
|
||||
:disabled="!canWrite" :loading="testing === record.id"
|
||||
:disabled="!canTest" :loading="testing === record.id"
|
||||
@click="runTest(record as ModelProvider)"
|
||||
>
|
||||
测连通
|
||||
{{ t('测连通') }}
|
||||
</Button>
|
||||
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as ModelProvider)">
|
||||
编辑
|
||||
<Button type="link" size="small" :disabled="!canEdit" @click="openEdit(record as ModelProvider)">
|
||||
{{ t('编辑') }}
|
||||
</Button>
|
||||
<Popconfirm title="确定删除这个模型?" @confirm="remove(record as ModelProvider)">
|
||||
<Button type="link" size="small" danger :disabled="!canWrite">删除</Button>
|
||||
<Popconfirm :title="t('确定删除这个模型?')" @confirm="remove(record as ModelProvider)">
|
||||
<Button type="link" size="small" danger :disabled="!canDelete">{{ t('删除') }}</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" :title="editingId ? '编辑模型' : '新增模型'" @ok="submit">
|
||||
<Modal v-model:open="open" :title="editingId ? t('编辑模型') : t('新增模型')" @ok="submit">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="模型 ID(唯一,编排里按它引用)">
|
||||
<FormItem :label="t('模型 ID(唯一,编排里按它引用)')">
|
||||
<Input v-model:value="form.id" :disabled="!!editingId" placeholder="dify-kefu" />
|
||||
</FormItem>
|
||||
<FormItem label="显示名称">
|
||||
<Input v-model:value="form.name" placeholder="甄养堂客服" />
|
||||
<FormItem :label="t('显示名称')">
|
||||
<Input v-model:value="form.name" :placeholder="t('甄养堂客服')" />
|
||||
</FormItem>
|
||||
<FormItem label="接口类型">
|
||||
<FormItem :label="t('接口类型')">
|
||||
<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 是文生图工作流引擎,不能当对话模型——后端会把它排除在答题池和裁判之外。
|
||||
{{ t('ComfyUI 是文生图工作流引擎,不能当对话模型——后端会把它排除在答题池和裁判之外。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem label="接口地址">
|
||||
<FormItem :label="t('接口地址')">
|
||||
<Input
|
||||
v-model:value="form.base_url"
|
||||
placeholder="https://api.anthropic.com"
|
||||
@@ -236,48 +243,47 @@ onMounted(load);
|
||||
@change="(v: any) => (form.endpoint_mode = v ? 'exact' : 'auto')"
|
||||
/>
|
||||
<div class="text-xs text-gray-500">
|
||||
<span class="font-medium">这就是完整的请求地址,不要再补路径</span>
|
||||
<span class="font-medium">{{ t('这就是完整的请求地址,不要再补路径') }}</span>
|
||||
<p class="mt-0.5">
|
||||
关着时按接口类型自动补全(<code>/v1</code> → <code>/v1/chat/completions</code>),
|
||||
大多数服务商都是这个形状。路径不按套路的自建服务打开它。
|
||||
{{ t('关着时按接口类型自动补全(') }}<code>/v1</code> → <code>/v1/chat/completions</code>{{ t('),大多数服务商都是这个形状。路径不按套路的自建服务打开它。') }}
|
||||
</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>
|
||||
{{ t('实际请求:') }}<code>{{ resolvedEndpoint }}</code>
|
||||
</p>
|
||||
</FormItem>
|
||||
<FormItem :label="editingId ? 'API 密钥(留空表示不改动)' : 'API 密钥'">
|
||||
<Input.Password v-model:value="form.api_key" placeholder="留空 = 保持原密钥" />
|
||||
<FormItem :label="editingId ? t('API 密钥(留空表示不改动)') : t('API 密钥')">
|
||||
<Input.Password v-model:value="form.api_key" :placeholder="t('留空 = 保持原密钥')" />
|
||||
</FormItem>
|
||||
<FormItem label="模型名称(Dify 由应用侧决定,可留空)">
|
||||
<FormItem :label="t('模型名称(Dify 由应用侧决定,可留空)')">
|
||||
<Input v-model:value="form.model" placeholder="claude-sonnet-4-6" />
|
||||
</FormItem>
|
||||
<FormItem label="能力">
|
||||
<FormItem :label="t('能力')">
|
||||
<Select
|
||||
v-model:value="form.capabilities"
|
||||
:options="[
|
||||
{ value: 'text', label: '仅文本' },
|
||||
{ value: 'text,vision', label: '文本 + 视觉' },
|
||||
{ value: 'image_gen', label: '文生图' },
|
||||
{ value: 'text', label: t('仅文本') },
|
||||
{ value: 'text,vision', label: t('文本 + 视觉') },
|
||||
{ value: 'image_gen', label: t('文生图') },
|
||||
]"
|
||||
/>
|
||||
</FormItem>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<FormItem label="并发上限">
|
||||
<FormItem :label="t('并发上限')">
|
||||
<InputNumber v-model:value="form.max_inflight" :min="1" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="超时(毫秒)">
|
||||
<FormItem :label="t('超时(毫秒)')">
|
||||
<InputNumber v-model:value="form.timeout_ms" :min="1000" :step="1000" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="最大回复 tokens">
|
||||
<FormItem :label="t('最大回复 tokens')">
|
||||
<InputNumber v-model:value="form.max_tokens" :min="1" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem label="温度">
|
||||
<FormItem :label="t('温度')">
|
||||
<InputNumber v-model:value="form.temperature" :min="0" :max="2" :step="0.05" class="w-full" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem label="启用">
|
||||
<FormItem :label="t('启用')">
|
||||
<Switch v-model:checked="form.enabled" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
@@ -8,9 +8,10 @@ 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';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['model:write']));
|
||||
const canWrite = computed(() => hasAccessByCodes(['model-plan:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const models = ref<ModelProvider[]>([]);
|
||||
@@ -29,7 +30,7 @@ const form = reactive({
|
||||
const chatOptions = computed(() =>
|
||||
models.value
|
||||
.filter((item) => item.kind !== 'comfyui' && item.enabled)
|
||||
.map((item) => ({ value: item.id, label: `${item.name}(${item.kind})` })),
|
||||
.map((item) => ({ value: item.id, label: t('{name}({kind})', { kind: item.kind, name: item.name }) })),
|
||||
);
|
||||
|
||||
const visionOptions = computed(() =>
|
||||
@@ -40,14 +41,15 @@ const visionOptions = computed(() =>
|
||||
),
|
||||
);
|
||||
|
||||
const MODE_HELP: Record<string, string> = {
|
||||
// 说明文字要随语言切换:包成 computed
|
||||
const modeHelp = computed<Record<string, string>>(() => ({
|
||||
shadow:
|
||||
'照常发主模型的回复,裁判只打分、不参与挑选答案;被判高风险的回复仍转人工审核(1.5.2 及更早的桌面端不会)。先量出现有回复的真实水平,再决定要不要花第二份钱。',
|
||||
t('照常发主模型的回复,裁判只打分、不参与挑选答案;被判高风险的回复仍转人工审核(1.5.2 及更早的桌面端不会)。先量出现有回复的真实水平,再决定要不要花第二份钱。'),
|
||||
score_only:
|
||||
'裁判打绝对分并标风险,高风险转人工审核;不做二选一,不改变答案选择。',
|
||||
t('裁判打绝对分并标风险,高风险转人工审核;不做二选一,不改变答案选择。'),
|
||||
arbitrate:
|
||||
'完整的 best-of-N:并发问多个模型,按裁判选出的赢家发送,高风险同样转人工审核。成本约 2.3 倍。',
|
||||
};
|
||||
t('完整的 best-of-N:并发问多个模型,按裁判选出的赢家发送,高风险同样转人工审核。成本约 2.3 倍。'),
|
||||
}));
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
@@ -72,7 +74,7 @@ async function load() {
|
||||
|
||||
async function submit() {
|
||||
if (form.answer_ids.length === 0) {
|
||||
message.warning('至少要指定一个答题模型');
|
||||
message.warning(t('至少要指定一个答题模型'));
|
||||
return;
|
||||
}
|
||||
const data = await saveModelPlan({
|
||||
@@ -83,7 +85,7 @@ async function submit() {
|
||||
judge_mode: form.judge_mode,
|
||||
});
|
||||
current.value = data.roles;
|
||||
message.success(`已保存为 v${data.version},网关 20 秒内自动生效`);
|
||||
message.success(t('已保存为 v{version},网关 20 秒内自动生效', { version: data.version }));
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
@@ -95,67 +97,67 @@ onMounted(load);
|
||||
class="mb-4"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="编排是追加版本,不是原地修改"
|
||||
description="每次保存都产生一个新版本号,回滚就是指回旧版本。网关每 20 秒重读一次,不用重启。"
|
||||
:message="t('编排是追加版本,不是原地修改')"
|
||||
:description="t('每次保存都产生一个新版本号,回滚就是指回旧版本。网关每 20 秒重读一次,不用重启。')"
|
||||
/>
|
||||
|
||||
<Card title="当前生效的编排" class="mb-4" :loading="loading">
|
||||
<Card :title="t('当前生效的编排')" class="mb-4" :loading="loading">
|
||||
<Descriptions v-if="current" :column="2" size="small" bordered>
|
||||
<DescriptionsItem label="版本">v{{ current.version }}</DescriptionsItem>
|
||||
<DescriptionsItem label="裁判模式">
|
||||
<DescriptionsItem :label="t('版本')">v{{ current.version }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('裁判模式')">
|
||||
<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>
|
||||
<DescriptionsItem :label="t('答题模型')">{{ current.answer_ids || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('裁判模型')">{{ current.judge_id || '—' }}</DescriptionsItem>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="修改编排">
|
||||
<Card :title="t('修改编排')">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="答题模型(多选 = 并发提问,按顺序第一个是主模型)">
|
||||
<FormItem :label="t('答题模型(多选 = 并发提问,按顺序第一个是主模型)')">
|
||||
<Select
|
||||
v-model:value="form.answer_ids" mode="multiple"
|
||||
:options="chatOptions" :disabled="!canWrite"
|
||||
placeholder="至少选一个"
|
||||
:placeholder="t('至少选一个')"
|
||||
/>
|
||||
<p v-if="form.answer_ids.length > 1" class="mt-1 text-xs text-orange-500">
|
||||
选了 {{ form.answer_ids.length }} 个 = 每条回复都要付 {{ form.answer_ids.length }} 份生成费用。
|
||||
建议先跑一段影子模式,用调用统计里的分数分布判断值不值。
|
||||
{{ t('选了 {count} 个 = 每条回复都要付 {count} 份生成费用。', { count: form.answer_ids.length }) }}
|
||||
{{ t('建议先跑一段影子模式,用调用统计里的分数分布判断值不值。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="裁判模型(给回复打绝对分和风险等级)">
|
||||
<FormItem :label="t('裁判模型(给回复打绝对分和风险等级)')">
|
||||
<Select
|
||||
v-model:value="form.judge_id" :options="chatOptions"
|
||||
:disabled="!canWrite" allow-clear placeholder="不选则不做评审"
|
||||
:disabled="!canWrite" allow-clear :placeholder="t('不选则不做评审')"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="裁判模式">
|
||||
<FormItem :label="t('裁判模式')">
|
||||
<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>
|
||||
<p class="mt-1 text-xs text-gray-500">{{ modeHelp[form.judge_mode] }}</p>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="媒体消息专用模型(需具备视觉能力,留空则从答题池里挑)">
|
||||
<FormItem :label="t('媒体消息专用模型(需具备视觉能力,留空则从答题池里挑)')">
|
||||
<Select
|
||||
v-model:value="form.vision_id" :options="visionOptions"
|
||||
:disabled="!canWrite" allow-clear
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem label="降级链(主链全挂时按顺序尝试)">
|
||||
<FormItem :label="t('降级链(主链全挂时按顺序尝试)')">
|
||||
<Select
|
||||
v-model:value="form.fallback_ids" mode="multiple"
|
||||
:options="chatOptions" :disabled="!canWrite"
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<Button v-if="canWrite" type="primary" @click="submit">保存为新版本</Button>
|
||||
<Button v-if="canWrite" type="primary" @click="submit">{{ t('保存为新版本') }}</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ModelUsageSummary } from '../../api/console';
|
||||
|
||||
export function usageNumber(value: unknown, missing = '未返回'): string {
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
export function usageNumber(value: unknown, missing = t('未返回')): string {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? value.toLocaleString('zh-CN')
|
||||
: missing;
|
||||
@@ -16,7 +18,7 @@ export function usageTokens(
|
||||
}
|
||||
|
||||
export function usagePurpose(value: string): string {
|
||||
return ({ chat: '客服回答与裁判', guard: '界面检测', knowledge: '知识整理' } as Record<string, string>)[value] || value || '未标记';
|
||||
return ({ chat: t('客服回答与裁判'), guard: t('界面检测'), knowledge: t('知识整理') } as Record<string, string>)[value] || value || t('未标记');
|
||||
}
|
||||
|
||||
export function usageUtcTime(value: string): string {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { PermissionItem } from '../../api/console';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import routes from '../../router/routes/modules/console';
|
||||
import {
|
||||
applyTreeCheck,
|
||||
branchKeys,
|
||||
checkCode,
|
||||
groupPermissions,
|
||||
pageMissing,
|
||||
permissionTree,
|
||||
REQUIRES,
|
||||
routePermissions,
|
||||
summarizeRole,
|
||||
toggleCodes,
|
||||
unregisteredCodes,
|
||||
} from './permission-menu';
|
||||
|
||||
function item(code: string, group: string, page: string, kind: PermissionItem['kind'] = 'action'): PermissionItem {
|
||||
return {
|
||||
code, enabled: true, group_name: group, icon: '', kind, link_mode: '', link_url: '',
|
||||
name: code, page_name: page, roles: [], sort_order: 0, source: 'builtin', updated_at: '',
|
||||
};
|
||||
}
|
||||
|
||||
const catalog = [
|
||||
item('release:read', '桌面端', '版本升级', 'page'),
|
||||
item('release:write', '桌面端', '版本升级'),
|
||||
item('config:read', '桌面端', '客户端配置', 'page'),
|
||||
item('im:content:read', '聊天归档', '会话与消息', 'page'),
|
||||
item('knowledge:read', '知识中心', '知识加工与检索', 'page'),
|
||||
item('knowledge:write', '知识中心', '知识加工与检索'),
|
||||
{ ...item('link:zyt', '常用链接', '', 'link'), name: 'ZYT 后台', source: 'custom' as const },
|
||||
{ ...item('report:read', '常用链接', ''), source: 'custom' as const },
|
||||
];
|
||||
|
||||
describe('permission menu grouping', () => {
|
||||
it('keeps the backend order and groups by menu then page', () => {
|
||||
const groups = groupPermissions(catalog);
|
||||
expect(groups.map((g) => g.group)).toEqual(['桌面端', '聊天归档', '知识中心', '常用链接']);
|
||||
expect(groups[0]!.pages.map((p) => p.page)).toEqual(['版本升级', '客户端配置']);
|
||||
expect(groups[0]!.codes).toEqual(['release:read', 'release:write', 'config:read']);
|
||||
// 没填页面:菜单链接自成一页,权限点归「其他」
|
||||
expect(groups[3]!.pages.map((p) => p.page)).toEqual(['ZYT 后台', '其他']);
|
||||
});
|
||||
|
||||
it('summarizes a role per menu and skips menus it has nothing in', () => {
|
||||
const groups = groupPermissions(catalog);
|
||||
expect(summarizeRole(groups, ['release:read', 'release:write', 'knowledge:read'])).toEqual([
|
||||
{ group: '桌面端', held: 2, total: 3 },
|
||||
{ group: '知识中心', held: 1, total: 2 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('checking an action also checks its page and required codes, unchecking does not cascade', () => {
|
||||
expect(checkCode([], 'release:write', true, catalog).sort()).toEqual(['release:read', 'release:write']);
|
||||
expect(checkCode([], 'knowledge:write', true, catalog).sort()).toEqual(
|
||||
['im:content:read', 'knowledge:read', 'knowledge:write'],
|
||||
);
|
||||
expect(checkCode(['release:read', 'release:write'], 'release:read', false, catalog)).toEqual(['release:write']);
|
||||
});
|
||||
|
||||
it('flags a page whose actions are granted without the page itself', () => {
|
||||
const page = groupPermissions(catalog)[0]!.pages[0]!;
|
||||
expect(pageMissing(page, ['release:write'])).toBe(true);
|
||||
expect(pageMissing(page, ['release:write', 'release:read'])).toBe(false);
|
||||
expect(pageMissing(page, [])).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a menu → page → button tree and filters it by keyword', () => {
|
||||
const tree = permissionTree(groupPermissions(catalog));
|
||||
expect(tree.map((node) => node.title)).toEqual(['桌面端', '聊天归档', '知识中心', '常用链接']);
|
||||
expect(tree[0]!.children!.map((node) => [node.title, node.codes])).toEqual([
|
||||
['版本升级', ['release:read', 'release:write']],
|
||||
['客户端配置', ['config:read']],
|
||||
]);
|
||||
expect(tree[0]!.children![0]!.children!.map((node) => node.key)).toEqual(['release:read', 'release:write']);
|
||||
expect(branchKeys(tree, 1)).toEqual(['#group/桌面端', '#group/聊天归档', '#group/知识中心', '#group/常用链接']);
|
||||
expect(branchKeys(tree)).toContain('#page/知识中心/知识加工与检索');
|
||||
const found = permissionTree(groupPermissions(catalog), 'WRITE');
|
||||
expect(found.map((node) => node.title)).toEqual(['桌面端', '知识中心']);
|
||||
expect(found.flatMap((node) => node.codes)).toEqual(['release:write', 'knowledge:write']);
|
||||
expect(permissionTree(groupPermissions(catalog), '后台').flatMap((node) => node.codes)).toEqual(['link:zyt']);
|
||||
});
|
||||
|
||||
it('turns tree checks into grants without dropping codes the tree does not show', () => {
|
||||
const all = catalog.map((entry) => entry.code);
|
||||
// 勾整个「知识中心」:两个码都勾上,还带上正文查看
|
||||
expect(applyTreeCheck([], ['#group/知识中心', 'knowledge:read', 'knowledge:write'], all, catalog).sort())
|
||||
.toEqual(['im:content:read', 'knowledge:read', 'knowledge:write']);
|
||||
// 取消一个按钮:只收回它
|
||||
expect(applyTreeCheck(['release:read', 'release:write'], ['release:read'], all, catalog)).toEqual(['release:read']);
|
||||
// 搜索时树里只有 release:write:其余已有的授权(含后端已不认识的码)原样保留
|
||||
expect(applyTreeCheck(['config:read', 'gone:code', 'release:write'], [], ['release:write'], catalog))
|
||||
.toEqual(['config:read', 'gone:code']);
|
||||
});
|
||||
|
||||
it('toggles a whole menu without touching other menus', () => {
|
||||
expect(toggleCodes(['x:y'], ['a:b', 'c:d'], true)).toEqual(['x:y', 'a:b', 'c:d']);
|
||||
expect(toggleCodes(['x:y', 'a:b'], ['a:b', 'c:d'], false)).toEqual(['x:y']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('route ↔ permission menu sync check', () => {
|
||||
const sample: RouteRecordRaw[] = [{
|
||||
path: '/desktop', name: 'D', meta: { title: '桌面端' },
|
||||
children: [
|
||||
{ path: 'release', name: 'R', component: {}, meta: { title: '版本升级', authority: ['release:read', 'release:write'] } },
|
||||
{ path: 'new', name: 'N', component: {}, meta: { title: '新页面', authority: ['brand-new:read'] } },
|
||||
],
|
||||
} as RouteRecordRaw];
|
||||
|
||||
it('lists the codes each page uses', () => {
|
||||
expect(routePermissions(sample)).toEqual([
|
||||
{ code: 'release:read', pages: ['桌面端 / 版本升级'] },
|
||||
{ code: 'release:write', pages: ['桌面端 / 版本升级'] },
|
||||
{ code: 'brand-new:read', pages: ['桌面端 / 新页面'] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports page codes the backend has not registered', () => {
|
||||
expect(unregisteredCodes(sample, catalog)).toEqual([{ code: 'brand-new:read', pages: ['桌面端 / 新页面'] }]);
|
||||
});
|
||||
|
||||
// 与后端 rbac.PERMISSIONS 保持一致;新增页面或按钮时两边一起改。
|
||||
const BACKEND_CODES = [
|
||||
'model:read', 'model:create', 'model:write', 'model:test', 'model:delete', 'model-plan:read', 'model-plan:write',
|
||||
'agent:read', 'agent:create', 'agent:write', 'agent:delete', 'agent-plan:write', 'stats:read',
|
||||
'desktop-account:read', 'desktop-account:write', 'desktop-account:assign', 'desktop-account:revoke',
|
||||
'desktop-account:all', 'config:read', 'config:write', 'release:read', 'release:upload', 'release:write',
|
||||
'im:read', 'im:content:read', 'im:export', 'im:export:create', 'im:export:download', 'im:identity:read',
|
||||
'im:identity:write', 'im:patient:bind', 'im:patient:unbind', 'im:storage:read', 'im:storage:write',
|
||||
'im:storage:test', 'im:import',
|
||||
'knowledge:read', 'knowledge:run', 'knowledge:run:control', 'knowledge:settings', 'knowledge:watch',
|
||||
'knowledge:write', 'knowledge:restore', 'knowledge:delete', 'knowledge:review', 'knowledge:reject',
|
||||
'knowledge:review:auto', 'knowledge:publish', 'knowledge:disable', 'knowledge:enable', 'knowledge:search',
|
||||
'knowledge:export',
|
||||
'role:read', 'role:create', 'role:write', 'role:delete', 'permission:read', 'permission:create',
|
||||
'permission:write', 'permission:delete', 'user:read', 'user:create', 'user:write', 'user:status', 'audit:read',
|
||||
];
|
||||
|
||||
it('every page in the real menu uses only codes the backend registers', () => {
|
||||
const backend = BACKEND_CODES.map((code) => item(code, 'g', 'p'));
|
||||
expect(unregisteredCodes(routes, backend)).toEqual([]);
|
||||
});
|
||||
|
||||
it('every button check in the pages uses a code the backend registers', () => {
|
||||
// 页面里按钮用的码(hasAccessByCodes([...]) / withContent('...'))后端没登记,角色界面里就勾不到
|
||||
const sources = import.meta.glob('../**/*.vue', { eager: true, import: 'default', query: '?raw' }) as Record<string, string>;
|
||||
const used = new Set<string>();
|
||||
for (const text of Object.values(sources)) {
|
||||
for (const match of text.matchAll(/hasAccessByCodes\(\[([^\]]*)\]\)|withContent\(([^)]*)\)/g)) {
|
||||
for (const code of (match[1] ?? match[2] ?? '').matchAll(/'([^']+)'/g)) used.add(code[1]!);
|
||||
}
|
||||
}
|
||||
expect(used.size).toBeGreaterThan(30);
|
||||
expect([...used].filter((code) => !BACKEND_CODES.includes(code))).toEqual([]);
|
||||
for (const code of Object.keys(REQUIRES)) expect(BACKEND_CODES).toContain(code);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 权限菜单的整理:后端给的是一张平表(已按左侧菜单顺序排好),这里按
|
||||
* 「一级菜单 → 页面 → 权限码」分组,供角色勾选和权限菜单页共用。
|
||||
*
|
||||
* 另外拿前端路由表和后端权限菜单对一遍:页面上用到的码后端没登记,角色界面里
|
||||
* 就勾不到——这种漏同步以前只能等人发现,现在权限菜单页直接标出来。
|
||||
*/
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
import type { PermissionItem } from '../../api/console';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
export interface PermissionPage {
|
||||
items: PermissionItem[];
|
||||
page: string;
|
||||
}
|
||||
|
||||
export interface PermissionGroup {
|
||||
codes: string[];
|
||||
group: string;
|
||||
pages: PermissionPage[];
|
||||
}
|
||||
|
||||
// 写成函数而不是常量:t() 每次调用时读当前语言,切换语言后标签跟着变
|
||||
export function kindLabel(kind: PermissionItem['kind']): string {
|
||||
const labels: Record<PermissionItem['kind'], string> = {
|
||||
action: t('按钮'),
|
||||
link: t('菜单链接'),
|
||||
page: t('页面'),
|
||||
};
|
||||
return labels[kind];
|
||||
}
|
||||
|
||||
export function sourceLabel(source: PermissionItem['source']): string {
|
||||
const labels: Record<PermissionItem['source'], string> = {
|
||||
builtin: t('内置'),
|
||||
custom: t('自定义'),
|
||||
discovered: t('自动登记'),
|
||||
};
|
||||
return labels[source];
|
||||
}
|
||||
|
||||
/**
|
||||
* 没填页面的自定义条目:菜单链接自成一页,权限点归到「其他」。
|
||||
* 返回的是中文原文,当分组键用(树节点的 key 也由它拼成);显示时再 t(page)。
|
||||
*/
|
||||
export function pageOf(item: PermissionItem): string {
|
||||
return item.page_name || (item.kind === 'link' ? item.name : '其他'); // i18n-ignore 分组键,显示时 t()
|
||||
}
|
||||
|
||||
/** 按出现顺序分组,不重新排序——顺序由后端决定(和左侧菜单一致)。 */
|
||||
export function groupPermissions(items: PermissionItem[]): PermissionGroup[] {
|
||||
const groups = new Map<string, Map<string, PermissionItem[]>>();
|
||||
for (const item of items) {
|
||||
const pages = groups.get(item.group_name) ?? new Map<string, PermissionItem[]>();
|
||||
const page = pageOf(item);
|
||||
pages.set(page, [...(pages.get(page) ?? []), item]);
|
||||
groups.set(item.group_name, pages);
|
||||
}
|
||||
return [...groups.entries()].map(([group, pages]) => ({
|
||||
codes: [...pages.values()].flat().map((item) => item.code),
|
||||
group,
|
||||
pages: [...pages.entries()].map(([page, list]) => ({ items: list, page })),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 一个角色在每个一级菜单下持有几项,用于角色列表的概览。 */
|
||||
export function summarizeRole(
|
||||
groups: PermissionGroup[],
|
||||
granted: string[],
|
||||
): { group: string; held: number; total: number }[] {
|
||||
const have = new Set(granted);
|
||||
return groups
|
||||
.map(({ codes, group }) => ({
|
||||
group,
|
||||
held: codes.filter((code) => have.has(code)).length,
|
||||
total: codes.length,
|
||||
}))
|
||||
.filter((row) => row.held > 0);
|
||||
}
|
||||
|
||||
/** 知识中心里碰到聊天原文(来源证据)的按钮:后端同时要求能看正文。 */
|
||||
const KNOWLEDGE_WITH_SOURCES = [
|
||||
'knowledge:run', 'knowledge:run:control', 'knowledge:watch', 'knowledge:write', 'knowledge:restore',
|
||||
'knowledge:delete', 'knowledge:review', 'knowledge:reject', 'knowledge:review:auto', 'knowledge:publish',
|
||||
'knowledge:disable',
|
||||
];
|
||||
|
||||
/**
|
||||
* 后端同时要求的码(缺一个就 403)。勾按钮时顺手勾上,免得配出"看得见、点不动"的角色。
|
||||
* 同一页面的「页面」码由 checkCode 自动带上,这里只写跨页面的。
|
||||
*/
|
||||
export const REQUIRES: Record<string, string[]> = Object.fromEntries(
|
||||
KNOWLEDGE_WITH_SOURCES.map((code) => [code, ['knowledge:read', 'im:content:read']]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 勾上一个码:同一页面的「页面」码(能打开页面才谈得上操作)和 REQUIRES 里的码一起勾上。
|
||||
* 取消勾选只取消它自己——收权限不做连带,免得一下子收掉别的。
|
||||
*/
|
||||
export function checkCode(
|
||||
selected: string[],
|
||||
code: string,
|
||||
checked: boolean,
|
||||
items: PermissionItem[],
|
||||
): string[] {
|
||||
if (!checked) return selected.filter((item) => item !== code);
|
||||
const set = new Set([...selected, code]);
|
||||
const known = new Set(items.map((item) => item.code));
|
||||
const target = items.find((item) => item.code === code);
|
||||
if (target && target.kind === 'action') {
|
||||
for (const item of items) {
|
||||
if (
|
||||
item.kind === 'page' &&
|
||||
item.group_name === target.group_name &&
|
||||
pageOf(item) === pageOf(target)
|
||||
) {
|
||||
set.add(item.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const extra of REQUIRES[code] ?? []) {
|
||||
if (known.has(extra)) set.add(extra);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 勾了页面里的操作、却没勾能打开这个页面的码——这种角色在左侧菜单里看不到该页。 */
|
||||
export function pageMissing(page: PermissionPage, selected: string[]): boolean {
|
||||
const have = new Set(selected);
|
||||
const gate = page.items.filter((item) => item.kind === 'page');
|
||||
return (
|
||||
gate.length > 0 &&
|
||||
!gate.some((item) => have.has(item.code)) &&
|
||||
page.items.some((item) => item.kind === 'action' && have.has(item.code))
|
||||
);
|
||||
}
|
||||
|
||||
export interface PermissionTreeNode {
|
||||
children?: PermissionTreeNode[];
|
||||
/** 节点下的全部权限码(按钮节点就是它自己) */
|
||||
codes: string[];
|
||||
/** 按钮节点才有 */
|
||||
item?: PermissionItem;
|
||||
key: string;
|
||||
/** 页面节点才有:用来提示「勾了按钮却没勾页面」 */
|
||||
page?: PermissionPage;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色勾选用的树:一级菜单 → 页面 → 按钮。菜单、页面节点的 key 以 # 开头,
|
||||
* 不会和权限码(只有小写字母、数字、连字符和冒号)撞上;按钮节点的 key 就是权限码。
|
||||
* 传了 keyword 时只留下名称(原文或译文)或权限码里含它的按钮(以及它们所在的菜单、页面)。
|
||||
* 节点标题按当前语言显示(后端给的内置名称是中文,t() 查得到就翻);key 始终用原文。
|
||||
*/
|
||||
export function permissionTree(groups: PermissionGroup[], keyword = ''): PermissionTreeNode[] {
|
||||
const word = keyword.trim().toLowerCase();
|
||||
const hit = (item: PermissionItem) =>
|
||||
!word || item.name.toLowerCase().includes(word) || t(item.name).toLowerCase().includes(word) ||
|
||||
item.code.includes(word);
|
||||
return groups.flatMap((group) => {
|
||||
const pages = group.pages.flatMap((page) => {
|
||||
const items = page.items.filter(hit);
|
||||
if (items.length === 0) return [];
|
||||
return [{
|
||||
children: items.map((item) => ({ codes: [item.code], item, key: item.code, title: t(item.name) })),
|
||||
codes: items.map((item) => item.code),
|
||||
key: `#page/${group.group}/${page.page}`,
|
||||
page,
|
||||
title: t(page.page),
|
||||
}];
|
||||
});
|
||||
if (pages.length === 0) return [];
|
||||
return [{
|
||||
children: pages,
|
||||
codes: pages.flatMap((page) => page.codes),
|
||||
key: `#group/${group.group}`,
|
||||
title: t(group.group),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
/** 树里全部可展开节点(菜单、页面)的 key;只要菜单的,传 depth = 1。 */
|
||||
export function branchKeys(nodes: PermissionTreeNode[], depth = Infinity): string[] {
|
||||
if (depth <= 0) return [];
|
||||
return nodes.flatMap((node) =>
|
||||
node.children?.length ? [node.key, ...branchKeys(node.children, depth - 1)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 树上勾选之后角色的新授权。树给的是当前勾上的全部节点(含菜单、页面),这里只取权限码:
|
||||
* - 树里看得到、却不在 checkedKeys 里的码:收回;
|
||||
* - 树里看不到的码(被搜索过滤掉的、后端已删除的):原样保留,不会因为没显示就被收回;
|
||||
* - 新勾上的码:照 checkCode 带上所在页面和后端必需的码。
|
||||
*/
|
||||
export function applyTreeCheck(
|
||||
selected: string[],
|
||||
checkedKeys: (number | string)[],
|
||||
visibleCodes: string[],
|
||||
items: PermissionItem[],
|
||||
): string[] {
|
||||
const visible = new Set(visibleCodes);
|
||||
const next = new Set(checkedKeys.map(String).filter((key) => visible.has(key)));
|
||||
let result = selected.filter((code) => !visible.has(code) || next.has(code));
|
||||
for (const code of next) {
|
||||
if (!selected.includes(code)) result = checkCode(result, code, true, items);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 勾选/取消一整组,保留组外的勾选。 */
|
||||
export function toggleCodes(
|
||||
selected: string[],
|
||||
codes: string[],
|
||||
checked: boolean,
|
||||
): string[] {
|
||||
const set = new Set(selected);
|
||||
for (const code of codes) {
|
||||
if (checked) set.add(code);
|
||||
else set.delete(code);
|
||||
}
|
||||
return [...set];
|
||||
}
|
||||
|
||||
export interface RoutePermission {
|
||||
code: string;
|
||||
/** 用到这个码的页面,形如「桌面端 / 版本升级」(按当前语言,只用来显示) */
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
/** 路由表里每个页面 `meta.authority` 用到的码。 */
|
||||
export function routePermissions(routes: RouteRecordRaw[]): RoutePermission[] {
|
||||
const found = new Map<string, string[]>();
|
||||
const walk = (list: readonly RouteRecordRaw[], trail: string[]) => {
|
||||
for (const route of list) {
|
||||
const title = t(String(route.meta?.title ?? route.name ?? route.path));
|
||||
const path = [...trail, title];
|
||||
for (const code of (route.meta?.authority as string[] | undefined) ?? []) {
|
||||
const pages = found.get(code) ?? [];
|
||||
if (!route.children?.length) pages.push(path.join(' / '));
|
||||
found.set(code, pages);
|
||||
}
|
||||
if (route.children?.length) walk(route.children, path);
|
||||
}
|
||||
};
|
||||
walk(routes, []);
|
||||
return [...found.entries()].map(([code, pages]) => ({ code, pages }));
|
||||
}
|
||||
|
||||
/** 页面上用到、后端权限菜单里却没有的码。 */
|
||||
export function unregisteredCodes(
|
||||
routes: RouteRecordRaw[],
|
||||
items: PermissionItem[],
|
||||
): RoutePermission[] {
|
||||
const known = new Set(items.map((item) => item.code));
|
||||
return routePermissions(routes).filter((row) => !known.has(row.code));
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
<script setup lang="ts">
|
||||
import type { PermissionForm, PermissionItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
|
||||
import { Alert, AutoComplete, Button, Card, Form, FormItem, Input, InputNumber, message, Modal, Popconfirm, Select, Switch, Table, Tag } from 'ant-design-vue';
|
||||
|
||||
import { createPermission, deletePermission, fetchPermissions, updatePermission } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
import { accessRoutes } from '#/router/routes';
|
||||
|
||||
import { groupPermissions, kindLabel, sourceLabel, unregisteredCodes } from './permission-menu';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const accessStore = useAccessStore();
|
||||
const router = useRouter();
|
||||
const canCreate = computed(() => hasAccessByCodes(['permission:create']));
|
||||
const canEdit = computed(() => hasAccessByCodes(['permission:write']));
|
||||
const canDelete = computed(() => hasAccessByCodes(['permission:delete']));
|
||||
const canSeeRoles = computed(() => hasAccessByCodes(['role:read', 'role:write']));
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const items = ref<PermissionItem[]>([]);
|
||||
const keyword = ref('');
|
||||
const open = ref(false);
|
||||
const editing = ref<PermissionItem | null>(null);
|
||||
|
||||
const blank = (): PermissionForm => ({
|
||||
code: '', enabled: true, group_name: '', icon: '', kind: 'link',
|
||||
link_mode: 'tab', link_url: '', name: '', page_name: '', sort_order: 0,
|
||||
});
|
||||
const form = reactive<PermissionForm>(blank());
|
||||
|
||||
interface Row {
|
||||
children?: Row[];
|
||||
item?: PermissionItem;
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 树:一级菜单 → 页面 → 权限码。搜索时只留命中的权限码和它们的上级。
|
||||
* 名称按当前语言显示(内置名称后端给中文,t() 查得到就翻),搜索原文和译文都算命中;key 用原文。
|
||||
*/
|
||||
const tree = computed<Row[]>(() => {
|
||||
const word = keyword.value.trim().toLowerCase();
|
||||
const hit = (item: PermissionItem) =>
|
||||
!word || [item.code, item.name, item.group_name, item.page_name, t(item.name), t(item.group_name), t(item.page_name)]
|
||||
.some((text) => text.toLowerCase().includes(word));
|
||||
const leaf = (item: PermissionItem): Row => ({ item, key: item.code, label: t(item.name) });
|
||||
return groupPermissions(items.value.filter(hit)).map((group) => ({
|
||||
key: `g:${group.group}`,
|
||||
label: t(group.group),
|
||||
// 没填页面的菜单链接自成一页,不必再套一层同名的页面
|
||||
children: group.pages.map((page) => {
|
||||
const only = page.items.length === 1 ? page.items[0]! : null;
|
||||
return only && only.kind === 'link' && !only.page_name
|
||||
? leaf(only)
|
||||
: { key: `p:${group.group}/${page.page}`, label: t(page.page), children: page.items.map(leaf) };
|
||||
}),
|
||||
}));
|
||||
});
|
||||
const expanded = ref<string[]>([]);
|
||||
function expandAll() {
|
||||
expanded.value = tree.value.flatMap((group) => [group.key, ...(group.children ?? []).map((page) => page.key)]);
|
||||
}
|
||||
|
||||
/** 页面上用到、后端权限菜单里却没有的码:角色界面里勾不到,需要补登记。 */
|
||||
const missing = computed(() => (items.value.length ? unregisteredCodes(accessRoutes, items.value) : []));
|
||||
const discovered = computed(() => items.value.filter((item) => item.source === 'discovered'));
|
||||
const counts = computed(() => ({
|
||||
builtin: items.value.filter((item) => item.source === 'builtin').length,
|
||||
custom: items.value.filter((item) => item.source === 'custom').length,
|
||||
links: items.value.filter((item) => item.kind === 'link').length,
|
||||
}));
|
||||
|
||||
// 选中后填进输入框、提交给后端的仍是原文(value),下拉里按当前语言显示(label)
|
||||
const groupOptions = computed(() =>
|
||||
[...new Set(items.value.map((item) => item.group_name))].map((value) => ({ label: t(value), value })),
|
||||
);
|
||||
const pageOptions = computed(() =>
|
||||
[...new Set(items.value.filter((item) => item.group_name === form.group_name && item.page_name).map((item) => item.page_name))]
|
||||
.map((value) => ({ label: t(value), value })),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
items.value = (await fetchPermissions()).permissions;
|
||||
expandAll();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 菜单链接改了:让路由守卫重新生成左侧菜单,不用重新登录。 */
|
||||
async function refreshMenus() {
|
||||
const { path, query } = router.currentRoute.value;
|
||||
accessStore.setIsAccessChecked(false);
|
||||
// force:同一地址也要走一遍守卫,否则 vue-router 当作重复导航直接跳过
|
||||
await router.replace({ force: true, path, query });
|
||||
}
|
||||
|
||||
function openCreate(kind: PermissionForm['kind']) {
|
||||
editing.value = null;
|
||||
Object.assign(form, blank(), { kind });
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
function openEdit(item: PermissionItem) {
|
||||
editing.value = item;
|
||||
Object.assign(form, blank(), {
|
||||
code: item.code,
|
||||
enabled: item.enabled,
|
||||
group_name: item.group_name,
|
||||
icon: item.icon,
|
||||
kind: item.kind === 'link' ? 'link' : 'action',
|
||||
link_mode: item.link_mode || 'tab',
|
||||
link_url: item.link_url,
|
||||
name: item.name,
|
||||
page_name: item.page_name,
|
||||
sort_order: item.sort_order,
|
||||
});
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const payload: PermissionForm = { ...form };
|
||||
if (!editing.value && !payload.code.trim() && payload.kind === 'link') {
|
||||
// 菜单链接的权限码没人关心,留空就自动起一个
|
||||
payload.code = `link:${Date.now().toString(36)}`;
|
||||
}
|
||||
const linkChanged = payload.kind === 'link' || editing.value?.kind === 'link';
|
||||
saving.value = true;
|
||||
try {
|
||||
items.value = editing.value
|
||||
? (await updatePermission(editing.value.code, payload)).permissions
|
||||
: (await createPermission(payload)).permissions;
|
||||
open.value = false;
|
||||
expandAll();
|
||||
message.success(
|
||||
editing.value
|
||||
? t('已保存')
|
||||
: t('已新增 {code}。管理员自动拥有它;其他角色去「角色权限」里勾选', { code: payload.code }),
|
||||
);
|
||||
if (linkChanged) await refreshMenus();
|
||||
} catch {
|
||||
// 后端的校验说明(链接不合法、权限码已存在…)已由请求拦截器弹出,弹窗保持打开
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: PermissionItem) {
|
||||
items.value = (await deletePermission(item.code)).permissions;
|
||||
message.success(t('已删除,各角色上的这项授权也一并收回'));
|
||||
if (item.kind === 'link') await refreshMenus();
|
||||
}
|
||||
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('名称'), key: 'label', width: 300 },
|
||||
{ title: t('权限码'), key: 'code', width: 200 },
|
||||
{ title: t('类型'), key: 'kind', width: 90 },
|
||||
{ title: t('来源'), key: 'source', width: 90 },
|
||||
{ title: t('已授权角色'), key: 'roles' },
|
||||
{ title: t('操作'), key: 'action', width: 130 },
|
||||
]);
|
||||
|
||||
// 下拉选项里的文案同样要随语言切换
|
||||
const kindOptions = computed(() => [
|
||||
{ label: t('菜单链接(左侧菜单里多一个链接)'), value: 'link' },
|
||||
{ label: t('权限点(只给角色勾选,不出现在菜单里)'), value: 'action' },
|
||||
]);
|
||||
const linkModeOptions = computed(() => [
|
||||
{ label: t('新窗口打开'), value: 'tab' },
|
||||
{ label: t('在后台内嵌打开(对方网站需允许被内嵌)'), value: 'iframe' },
|
||||
]);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Alert
|
||||
class="mb-4" type="info" show-icon
|
||||
:message="t('权限菜单:左侧每个菜单、页面和页面里的操作都在这里,角色权限照这棵树勾选')"
|
||||
>
|
||||
<template #description>
|
||||
<div>{{ t('内置项随系统发布,新功能上线会自动出现在这里。你也可以自己加:') }}</div>
|
||||
<div>· <b>{{ t('菜单链接') }}</b>{{ t(':在左侧菜单加一个链接(外部网址或本站路径,新窗口或内嵌打开),只有被授权的角色看得到;') }}</div>
|
||||
<div>· <b>{{ t('权限点') }}</b>{{ t(':只是一个可以给角色勾选的权限码,供以后的功能或对接系统使用。') }}</div>
|
||||
<div>{{ t('新增的条目管理员自动拥有,其他角色到「角色权限」里勾选。') }}</div>
|
||||
</template>
|
||||
</Alert>
|
||||
|
||||
<Alert v-if="missing.length" class="mb-4" type="warning" show-icon :message="t('这些页面用到的权限码还没在后端登记,角色界面里勾不到')">
|
||||
<template #description>
|
||||
<div v-for="row in missing" :key="row.code">
|
||||
<code>{{ row.code }}</code>{{ t(':{pages}', { pages: row.pages.join(t('、')) || t('(菜单分组)') }) }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs">{{ t('需要在后端 rbac.PERMISSIONS 登记后重新发布。') }}</div>
|
||||
</template>
|
||||
</Alert>
|
||||
<Alert
|
||||
v-if="discovered.length" class="mb-4" type="warning" show-icon
|
||||
:message="t('有 {count} 个权限码是代码里用到、但没登记名称的,已自动放进「未归类」', { count: discovered.length })"
|
||||
:description="t('管理员已自动拥有,可以照常给角色勾选;也可以点「编辑」给它起个名字、挂到对应的菜单下。')"
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<template #title>
|
||||
{{ t('权限菜单') }}
|
||||
<span class="ml-2 text-xs font-normal text-gray-500">
|
||||
{{ t('内置 {builtin} 项 · 自定义 {custom} 项(其中菜单链接 {links} 个)', { builtin: counts.builtin, custom: counts.custom, links: counts.links }) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #extra>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input v-model:value="keyword" allow-clear :placeholder="t('搜索名称或权限码')" class="w-52" @change="expandAll" />
|
||||
<Button v-if="canSeeRoles" @click="router.push('/system/roles')">{{ t('角色权限') }}</Button>
|
||||
<template v-if="canCreate">
|
||||
<Button @click="openCreate('action')">{{ t('新增权限点') }}</Button>
|
||||
<Button type="primary" @click="openCreate('link')">{{ t('新增菜单链接') }}</Button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<Table
|
||||
v-model:expanded-row-keys="expanded"
|
||||
:columns="columns" :data-source="tree" :loading="loading"
|
||||
row-key="key" size="small" :pagination="false"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'label'">
|
||||
<span :class="record.item ? '' : 'font-medium'">{{ record.label }}</span>
|
||||
<Tag v-if="record.item && !record.item.enabled" class="ml-2">{{ t('已停用') }}</Tag>
|
||||
<div v-if="record.item?.kind === 'link'" class="truncate text-xs text-gray-400" :title="record.item.link_url">
|
||||
{{ record.item.link_mode === 'iframe' ? t('内嵌') : t('新窗口') }} · {{ record.item.link_url }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'code' && record.item">
|
||||
<code class="text-xs">{{ record.item.code }}</code>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'kind' && record.item">
|
||||
<Tag :color="record.item.kind === 'page' ? 'blue' : record.item.kind === 'link' ? 'purple' : undefined">
|
||||
{{ kindLabel(record.item.kind as PermissionItem['kind']) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'source' && record.item">
|
||||
<Tag :color="record.item.source === 'builtin' ? undefined : record.item.source === 'custom' ? 'green' : 'orange'">
|
||||
{{ sourceLabel(record.item.source as PermissionItem['source']) }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'roles' && record.item">
|
||||
<Tag v-for="role in record.item.roles" :key="role" class="mb-1">{{ role }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action' && record.item">
|
||||
<template v-if="record.item.source !== 'builtin'">
|
||||
<Button type="link" size="small" :disabled="!canEdit" @click="openEdit(record.item)">{{ t('编辑') }}</Button>
|
||||
<Popconfirm
|
||||
v-if="record.item.source === 'custom'"
|
||||
:title="t('删除后,各角色上的这项授权也一并收回。确定删除?')"
|
||||
@confirm="remove(record.item)"
|
||||
>
|
||||
<Button type="link" size="small" danger :disabled="!canDelete">{{ t('删除') }}</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
<span v-else class="text-xs text-gray-400">{{ t('随系统发布') }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
v-model:open="open" :confirm-loading="saving" width="640px"
|
||||
:title="editing ? t('编辑') : form.kind === 'link' ? t('新增菜单链接') : t('新增权限点')"
|
||||
@ok="submit"
|
||||
>
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem :label="t('类型')">
|
||||
<Select v-model:value="form.kind" :options="kindOptions" />
|
||||
</FormItem>
|
||||
<div class="flex gap-4">
|
||||
<FormItem :label="t('名称')" class="flex-1" required>
|
||||
<Input v-model:value="form.name" :maxlength="40" :placeholder="form.kind === 'link' ? t('ZYT 后台') : t('查看经营报表')" />
|
||||
</FormItem>
|
||||
<FormItem class="flex-1">
|
||||
<template #label>
|
||||
{{ t('权限码') }}
|
||||
<span class="ml-1 text-xs text-gray-400">{{ editing ? t('(不可改)') : form.kind === 'link' ? t('(可留空,自动生成)') : t('(资源:动作)') }}</span>
|
||||
</template>
|
||||
<Input v-model:value="form.code" :disabled="!!editing" :maxlength="64" :placeholder="form.kind === 'link' ? 'link:zyt' : 'report:read'" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<div class="flex gap-4">
|
||||
<FormItem :label="t('所属菜单(选已有的,或输入新名字新建一级菜单)')" class="flex-1" required>
|
||||
<AutoComplete v-model:value="form.group_name" :options="groupOptions" :maxlength="20" :placeholder="t('桌面端')" />
|
||||
</FormItem>
|
||||
<FormItem v-if="form.kind === 'action'" :label="t('所属页面(可留空)')" class="flex-1">
|
||||
<AutoComplete v-model:value="form.page_name" :options="pageOptions" :maxlength="40" :placeholder="t('经营报表')" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<template v-if="form.kind === 'link'">
|
||||
<FormItem :label="t('链接地址')" required>
|
||||
<Input v-model:value="form.link_url" :maxlength="1000" :placeholder="t('https://… 或本站路径,如 /m/')" />
|
||||
</FormItem>
|
||||
<div class="flex gap-4">
|
||||
<FormItem :label="t('打开方式')" class="flex-1">
|
||||
<Select v-model:value="form.link_mode" :options="linkModeOptions" />
|
||||
</FormItem>
|
||||
<FormItem :label="t('图标(可留空)')" class="flex-1">
|
||||
<Input v-model:value="form.icon" :maxlength="60" placeholder="lucide:link" />
|
||||
</FormItem>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex gap-4">
|
||||
<FormItem :label="t('排序(小的在前)')" class="flex-1">
|
||||
<InputNumber v-model:value="form.sort_order" :min="0" :max="999" class="w-full" />
|
||||
</FormItem>
|
||||
<FormItem :label="t('启用')" class="flex-1">
|
||||
<Switch v-model:checked="form.enabled" />
|
||||
<span class="ml-2 text-xs text-gray-500">{{ t('停用的菜单链接不出现在左侧菜单') }}</span>
|
||||
</FormItem>
|
||||
</div>
|
||||
<p v-if="editing?.source === 'discovered'" class="text-xs text-orange-500">
|
||||
{{ t('这是代码里用到的权限码,保存后改为自定义条目(名称和所属菜单以这里为准)。') }}
|
||||
</p>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
saveRelease,
|
||||
uploadReleasePackage,
|
||||
} from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['release:write']));
|
||||
const canUpload = computed(() => hasAccessByCodes(['release:upload']));
|
||||
|
||||
type Channel = 'beta' | 'stable';
|
||||
type ReleaseState = 'paused' | 'published';
|
||||
@@ -104,13 +106,13 @@ function formatBytes(value: number) {
|
||||
|
||||
function statusLabel(value: string) {
|
||||
return {
|
||||
available: '发现更新',
|
||||
downloading: '下载中',
|
||||
downloaded: '待安装',
|
||||
installing: '安装中',
|
||||
installed: '已完成',
|
||||
failed: '失败',
|
||||
deferred: '已推迟',
|
||||
available: t('发现更新'),
|
||||
downloading: t('下载中'),
|
||||
downloaded: t('待安装'),
|
||||
installing: t('安装中'),
|
||||
installed: t('已完成'),
|
||||
failed: t('失败'),
|
||||
deferred: t('已推迟'),
|
||||
}[value] || value;
|
||||
}
|
||||
|
||||
@@ -156,9 +158,9 @@ async function selectPackage(file: File) {
|
||||
form.package_sha256 = result.package_sha256;
|
||||
form.package_size = result.package_size;
|
||||
uploadProgress.value = 100;
|
||||
message.success('安装包已上传 COS,已生成下载直链并开启自动安装');
|
||||
message.success(t('安装包已上传 COS,已生成下载直链并开启自动安装'));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '安装包上传失败');
|
||||
message.error(error instanceof Error ? error.message : t('安装包上传失败'));
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
@@ -167,44 +169,45 @@ async function selectPackage(file: File) {
|
||||
|
||||
async function submit() {
|
||||
if (!versionValid.value) {
|
||||
message.warning('版本号格式应为 1.0.0');
|
||||
message.warning(t('版本号格式应为 1.0.0'));
|
||||
return;
|
||||
}
|
||||
if (!urlValid.value) {
|
||||
message.warning('下载地址必须是完整的 HTTP/HTTPS 地址');
|
||||
message.warning(t('下载地址必须是完整的 HTTP/HTTPS 地址'));
|
||||
return;
|
||||
}
|
||||
if (form.force_upgrade && !form.download_url) {
|
||||
message.warning('开启强制升级前必须上传安装包');
|
||||
message.warning(t('开启强制升级前必须上传安装包'));
|
||||
return;
|
||||
}
|
||||
if (form.auto_install && !packageReady.value) {
|
||||
message.warning('自动安装必须先上传安装包并生成 SHA256');
|
||||
message.warning(t('自动安装必须先上传安装包并生成 SHA256'));
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const data = await saveRelease({ ...form });
|
||||
message.success(`已发布 v${data.latest_version}`);
|
||||
message.success(t('已发布 v{version}', { version: data.latest_version }));
|
||||
await load();
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const reportColumns = [
|
||||
{ title: '账号/设备', dataIndex: 'device', key: 'device' },
|
||||
{ title: '版本', dataIndex: 'version', key: 'version' },
|
||||
{ title: '状态', dataIndex: 'status', key: 'status' },
|
||||
{ title: '进度', dataIndex: 'progress', key: 'progress' },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at' },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const reportColumns = computed(() => [
|
||||
{ title: t('账号/设备'), dataIndex: 'device', key: 'device' },
|
||||
{ title: t('版本'), dataIndex: 'version', key: 'version' },
|
||||
{ title: t('状态'), dataIndex: 'status', key: 'status' },
|
||||
{ title: t('进度'), dataIndex: 'progress', key: 'progress' },
|
||||
{ title: t('更新时间'), dataIndex: 'updated_at', key: 'updated_at' },
|
||||
]);
|
||||
|
||||
const reportRows = computed(() =>
|
||||
reports.items.map((item, index) => ({
|
||||
...item,
|
||||
key: `${item.device_id}-${item.target_version}-${index}`,
|
||||
device: `${item.nickname || item.zyt_sn || '客户端'} / ${item.device_id}`,
|
||||
device: `${item.nickname || item.zyt_sn || t('客户端')} / ${item.device_id}`,
|
||||
version: `${item.current_version || '—'} → ${item.target_version}`,
|
||||
})),
|
||||
);
|
||||
@@ -218,121 +221,121 @@ onMounted(load);
|
||||
class="mb-4"
|
||||
type="success"
|
||||
show-icon
|
||||
message="安装包直传腾讯 COS"
|
||||
description="浏览器先在本机计算 SHA256,再分块直传 COS;安装包不经过管理端服务器。上传完成后显示 COS 下载直链;客户端发现已发布的新版本后直接从 COS 下载,校验通过后自动安装。"
|
||||
:message="t('安装包直传腾讯 COS')"
|
||||
:description="t('浏览器先在本机计算 SHA256,再分块直传 COS;安装包不经过管理端服务器。上传完成后显示 COS 下载直链;客户端发现已发布的新版本后直接从 COS 下载,校验通过后自动安装。')"
|
||||
/>
|
||||
|
||||
<Card title="当前版本策略" class="mb-4" :loading="loading">
|
||||
<Card :title="t('当前版本策略')" class="mb-4" :loading="loading">
|
||||
<Descriptions :column="3" size="small" bordered>
|
||||
<DescriptionsItem label="最新版本">
|
||||
<DescriptionsItem :label="t('最新版本')">
|
||||
<Tag color="blue">v{{ current.latest_version || '—' }}</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="发布状态">
|
||||
<DescriptionsItem :label="t('发布状态')">
|
||||
<Tag :color="current.status === 'published' ? 'green' : 'orange'">
|
||||
{{ current.status === 'published' ? '发布中' : '已暂停' }}
|
||||
{{ current.status === 'published' ? t('发布中') : t('已暂停') }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="升级方式">
|
||||
<DescriptionsItem :label="t('升级方式')">
|
||||
<Tag :color="current.force_upgrade ? 'red' : current.auto_install ? 'purple' : 'default'">
|
||||
{{ current.force_upgrade ? '强制升级' : current.auto_install ? '自动升级' : '提示升级' }}
|
||||
{{ current.force_upgrade ? t('强制升级') : current.auto_install ? t('自动升级') : t('提示升级') }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="发布渠道">{{ current.release_channel }}</DescriptionsItem>
|
||||
<DescriptionsItem label="灰度比例">{{ current.rollout_percent }}%</DescriptionsItem>
|
||||
<DescriptionsItem label="安装包">{{ formatBytes(current.package_size) }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('发布渠道')">{{ current.release_channel }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('灰度比例')">{{ current.rollout_percent }}%</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('安装包')">{{ formatBytes(current.package_size) }}</DescriptionsItem>
|
||||
<DescriptionsItem label="SHA256" :span="3">
|
||||
<code class="break-all text-xs">{{ current.package_sha256 || '未配置' }}</code>
|
||||
<code class="break-all text-xs">{{ current.package_sha256 || t('未配置') }}</code>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem label="最后修改">{{ current.updated_by || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem label="时间" :span="2">{{ current.updated_at || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('最后修改')">{{ current.updated_by || '—' }}</DescriptionsItem>
|
||||
<DescriptionsItem :label="t('时间')" :span="2">{{ 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="发布新版本" class="mb-4">
|
||||
<Card :title="t('发布新版本')" class="mb-4">
|
||||
<Form :model="form" layout="vertical">
|
||||
<FormItem label="Windows 安装包">
|
||||
<FormItem :label="t('Windows 安装包')">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Upload
|
||||
accept=".exe"
|
||||
:before-upload="selectPackage"
|
||||
:disabled="!canWrite || uploading"
|
||||
:disabled="!canUpload || uploading"
|
||||
:show-upload-list="false"
|
||||
>
|
||||
<Button :loading="uploading" :disabled="!canWrite">
|
||||
{{ uploading ? (uploadProgress < 5 ? '正在计算 SHA256' : '正在直传 COS') : '选择并直传 COS' }}
|
||||
<Button :loading="uploading" :disabled="!canUpload">
|
||||
{{ uploading ? (uploadProgress < 5 ? t('正在计算 SHA256') : t('正在直传 COS')) : t('选择并直传 COS') }}
|
||||
</Button>
|
||||
</Upload>
|
||||
<Tag v-if="packageReady" color="green">
|
||||
已就绪 · {{ formatBytes(form.package_size) }}
|
||||
{{ t('已就绪 · {size}', { size: formatBytes(form.package_size) }) }}
|
||||
</Tag>
|
||||
<span v-else class="text-xs text-gray-400">上传后自动填写下载地址、大小和 SHA256</span>
|
||||
<span v-else class="text-xs text-gray-400">{{ t('上传后自动填写下载地址、大小和 SHA256') }}</span>
|
||||
</div>
|
||||
<Progress v-if="uploading" class="mt-2" :percent="uploadProgress" />
|
||||
</FormItem>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-6 md:grid-cols-3">
|
||||
<FormItem
|
||||
label="版本号"
|
||||
:label="t('版本号')"
|
||||
:validate-status="form.latest_version && !versionValid ? 'error' : undefined"
|
||||
:help="form.latest_version && !versionValid ? '格式应为 1.0.0' : ''"
|
||||
:help="form.latest_version && !versionValid ? t('格式应为 1.0.0') : ''"
|
||||
>
|
||||
<Input v-model:value="form.latest_version" :disabled="!canWrite" placeholder="1.4.0" />
|
||||
</FormItem>
|
||||
<FormItem label="发布渠道">
|
||||
<FormItem :label="t('发布渠道')">
|
||||
<Select v-model:value="form.release_channel" :disabled="!canWrite">
|
||||
<SelectOption value="stable">正式版 stable</SelectOption>
|
||||
<SelectOption value="beta">测试版 beta</SelectOption>
|
||||
<SelectOption value="stable">{{ t('正式版 stable') }}</SelectOption>
|
||||
<SelectOption value="beta">{{ t('测试版 beta') }}</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="灰度比例">
|
||||
<FormItem :label="t('灰度比例')">
|
||||
<InputNumber v-model:value="form.rollout_percent" :min="1" :max="100" :disabled="!canWrite" class="w-full" addon-after="%" />
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<FormItem label="下载地址(COS 直链)">
|
||||
<Input v-model:value="form.download_url" :disabled="!canWrite" placeholder="上传安装包后自动填写 COS HTTPS 地址,也可填写其他 HTTPS 地址" @change="form.package_media_id = ''" />
|
||||
<p v-if="form.package_media_id" class="mt-2 text-xs text-gray-500">当前直链有效期为 15 分钟。发布后客户端每次检查更新都会获取新地址,无需重新上传。</p>
|
||||
<FormItem :label="t('下载地址(COS 直链)')">
|
||||
<Input v-model:value="form.download_url" :disabled="!canWrite" :placeholder="t('上传安装包后自动填写 COS HTTPS 地址,也可填写其他 HTTPS 地址')" @change="form.package_media_id = ''" />
|
||||
<p v-if="form.package_media_id" class="mt-2 text-xs text-gray-500">{{ t('当前直链有效期为 15 分钟。发布后客户端每次检查更新都会获取新地址,无需重新上传。') }}</p>
|
||||
</FormItem>
|
||||
<FormItem label="SHA256">
|
||||
<Input v-model:value="form.package_sha256" :disabled="!canWrite" placeholder="上传安装包后自动计算" />
|
||||
<Input v-model:value="form.package_sha256" :disabled="!canWrite" :placeholder="t('上传安装包后自动计算')" />
|
||||
</FormItem>
|
||||
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<FormItem label="发布状态">
|
||||
<FormItem :label="t('发布状态')">
|
||||
<Select v-model:value="form.status" :disabled="!canWrite">
|
||||
<SelectOption value="published">立即发布</SelectOption>
|
||||
<SelectOption value="paused">暂停分发</SelectOption>
|
||||
<SelectOption value="published">{{ t('立即发布') }}</SelectOption>
|
||||
<SelectOption value="paused">{{ t('暂停分发') }}</SelectOption>
|
||||
</Select>
|
||||
</FormItem>
|
||||
<FormItem label="安全与安装策略">
|
||||
<FormItem :label="t('安全与安装策略')">
|
||||
<div class="flex flex-wrap gap-5 pt-1">
|
||||
<Switch v-model:checked="form.auto_install" :disabled="!canWrite" checked-children="自动" un-checked-children="提示" />
|
||||
<Switch v-model:checked="form.force_upgrade" :disabled="!canWrite" checked-children="强制" un-checked-children="非强制" />
|
||||
<Switch v-model:checked="form.signature_required" :disabled="!canWrite" checked-children="验签" un-checked-children="仅哈希" />
|
||||
<Switch v-model:checked="form.auto_install" :disabled="!canWrite" :checked-children="t('自动')" :un-checked-children="t('提示')" />
|
||||
<Switch v-model:checked="form.force_upgrade" :disabled="!canWrite" :checked-children="t('强制')" :un-checked-children="t('非强制')" />
|
||||
<Switch v-model:checked="form.signature_required" :disabled="!canWrite" :checked-children="t('验签')" :un-checked-children="t('仅哈希')" />
|
||||
</div>
|
||||
</FormItem>
|
||||
</div>
|
||||
|
||||
<FormItem label="更新说明(不超过 4000 字)">
|
||||
<FormItem :label="t('更新说明(不超过 4000 字)')">
|
||||
<Textarea v-model:value="form.release_notes" :rows="5" :disabled="!canWrite" :maxlength="4000" show-count />
|
||||
</FormItem>
|
||||
|
||||
<Button v-if="canWrite" type="primary" size="large" :loading="saving" :disabled="uploading" @click="submit">
|
||||
发布版本
|
||||
{{ t('发布版本') }}
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title="客户端升级进度" class="mb-4">
|
||||
<Card :title="t('客户端升级进度')" class="mb-4">
|
||||
<div class="mb-3 flex flex-wrap gap-2">
|
||||
<Tag color="blue">下载中 {{ reports.counts.downloading || 0 }}</Tag>
|
||||
<Tag color="orange">待安装 {{ reports.counts.downloaded || 0 }}</Tag>
|
||||
<Tag color="purple">安装中 {{ reports.counts.installing || 0 }}</Tag>
|
||||
<Tag color="green">已完成 {{ reports.counts.installed || 0 }}</Tag>
|
||||
<Tag color="red">失败 {{ reports.counts.failed || 0 }}</Tag>
|
||||
<Tag color="blue">{{ t('下载中') }} {{ reports.counts.downloading || 0 }}</Tag>
|
||||
<Tag color="orange">{{ t('待安装') }} {{ reports.counts.downloaded || 0 }}</Tag>
|
||||
<Tag color="purple">{{ t('安装中') }} {{ reports.counts.installing || 0 }}</Tag>
|
||||
<Tag color="green">{{ t('已完成') }} {{ reports.counts.installed || 0 }}</Tag>
|
||||
<Tag color="red">{{ t('失败') }} {{ reports.counts.failed || 0 }}</Tag>
|
||||
</div>
|
||||
<Table :columns="reportColumns" :data-source="reportRows" size="small" :pagination="{ pageSize: 8 }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
@@ -349,11 +352,11 @@ onMounted(load);
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Card title="最近发布记录">
|
||||
<div v-if="!history.length" class="text-sm text-gray-400">暂无发布记录</div>
|
||||
<Card :title="t('最近发布记录')">
|
||||
<div v-if="!history.length" class="text-sm text-gray-400">{{ t('暂无发布记录') }}</div>
|
||||
<div v-for="(item, index) in history" :key="`${item.latest_version}-${index}`" class="mb-2 flex flex-wrap items-center gap-3 rounded border border-gray-200 p-3 last:mb-0 dark:border-gray-700">
|
||||
<Tag color="blue">v{{ item.latest_version }}</Tag>
|
||||
<span class="text-sm">{{ item.release_notes || '没有更新说明' }}</span>
|
||||
<span class="text-sm">{{ item.release_notes || t('没有更新说明') }}</span>
|
||||
<span class="ml-auto text-xs text-gray-400">{{ item.updated_by }} · {{ item.updated_at }}</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -2,15 +2,23 @@
|
||||
import type { PermissionItem, RoleItem } from '#/api/console';
|
||||
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
|
||||
import { Alert, Button, Card, Checkbox, CheckboxGroup, Form, FormItem, Input, message, Modal, Popconfirm, Table, Tag } from 'ant-design-vue';
|
||||
import { Alert, Button, Card, Form, FormItem, Input, message, Modal, Popconfirm, Table, Tag, Tree } from 'ant-design-vue';
|
||||
|
||||
import { deleteRole, fetchPermissions, fetchRoles, saveRole } from '#/api/console';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { applyTreeCheck, branchKeys, groupPermissions, pageMissing, permissionTree, summarizeRole } from './permission-menu';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const router = useRouter();
|
||||
const canCreate = computed(() => hasAccessByCodes(['role:create']));
|
||||
const canWrite = computed(() => hasAccessByCodes(['role:write']));
|
||||
const canDelete = computed(() => hasAccessByCodes(['role:delete']));
|
||||
const canSeeMenu = computed(() => hasAccessByCodes(['permission:read']));
|
||||
|
||||
const loading = ref(false);
|
||||
const roles = ref<RoleItem[]>([]);
|
||||
@@ -19,25 +27,39 @@ const open = ref(false);
|
||||
const isNew = ref(false);
|
||||
|
||||
const form = reactive({ code: '', name: '', permissions: [] as string[] });
|
||||
// 新增和修改是两个按钮:只能新增的人打开已有角色只能看
|
||||
const readonly = computed(() => !(isNew.value ? canCreate.value : canWrite.value) || form.code === 'admin');
|
||||
|
||||
/** 权限按分组展示。一屏十几个码平铺出来没人看得懂哪个是哪个。 */
|
||||
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 }));
|
||||
});
|
||||
/** 一级菜单 → 页面 → 权限码,和左侧菜单一一对应。 */
|
||||
const grouped = computed(() => groupPermissions(permissions.value));
|
||||
const names = computed(() => new Map(permissions.value.map((item) => [item.code, item.name])));
|
||||
|
||||
// 勾选权限的树:一级菜单 → 页面 → 按钮。勾菜单或页面 = 勾它下面全部按钮,部分勾选显示半选。
|
||||
const keyword = ref('');
|
||||
const tree = computed(() => permissionTree(grouped.value, keyword.value));
|
||||
const visibleCodes = computed(() => tree.value.flatMap((node) => node.codes));
|
||||
const expanded = ref<(number | string)[]>([]);
|
||||
const total = computed(() => permissions.value.length);
|
||||
const held = (codes: string[]) => codes.filter((code) => form.permissions.includes(code)).length;
|
||||
const checkedKeys = computed(() => form.permissions.filter((code) => names.value.has(code)));
|
||||
const heldTotal = computed(() => checkedKeys.value.length);
|
||||
|
||||
function expandAll() {
|
||||
expanded.value = branchKeys(tree.value);
|
||||
}
|
||||
|
||||
function onCheck(keys: unknown) {
|
||||
// 不是严格模式时 antd 给的是数组;兜底处理 { checked } 形状
|
||||
const list = Array.isArray(keys) ? keys : ((keys as { checked?: (number | string)[] }).checked ?? []);
|
||||
form.permissions = applyTreeCheck(form.permissions, list, visibleCodes.value, permissions.value);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
roles.value = (await fetchRoles()).roles;
|
||||
if (canWrite.value) {
|
||||
permissions.value = (await fetchPermissions()).permissions;
|
||||
}
|
||||
const [roleData, permissionData] = await Promise.all([fetchRoles(), fetchPermissions()]);
|
||||
roles.value = roleData.roles;
|
||||
permissions.value = permissionData.permissions;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -46,6 +68,8 @@ async function load() {
|
||||
function openCreate() {
|
||||
isNew.value = true;
|
||||
Object.assign(form, { code: '', name: '', permissions: [] });
|
||||
keyword.value = '';
|
||||
expandAll();
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
@@ -56,31 +80,40 @@ function openEdit(row: RoleItem) {
|
||||
name: row.name,
|
||||
permissions: [...row.permissions],
|
||||
});
|
||||
keyword.value = '';
|
||||
expandAll();
|
||||
open.value = true;
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.code) {
|
||||
message.warning('角色编码不能为空');
|
||||
if (readonly.value) {
|
||||
open.value = false;
|
||||
return;
|
||||
}
|
||||
roles.value = (await saveRole({ ...form })).roles;
|
||||
if (!form.code) {
|
||||
message.warning(t('角色编码不能为空'));
|
||||
return;
|
||||
}
|
||||
// 后端已经不认识的码(比如刚被删掉的自定义权限)不再提交
|
||||
const known = new Set(permissions.value.map((item) => item.code));
|
||||
roles.value = (await saveRole({ ...form, permissions: form.permissions.filter((code) => known.has(code)) })).roles;
|
||||
open.value = false;
|
||||
message.success('已保存,权限立即生效(无需重启,也无需重新登录)');
|
||||
message.success(t('已保存,权限立即生效(无需重启,也无需重新登录)'));
|
||||
}
|
||||
|
||||
async function remove(row: RoleItem) {
|
||||
roles.value = (await deleteRole(row.code)).roles;
|
||||
message.success('已删除');
|
||||
message.success(t('已删除'));
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('编码'), dataIndex: 'code', key: 'code', width: 160 },
|
||||
{ title: t('名称'), dataIndex: 'name', key: 'name', width: 140 },
|
||||
{ title: t('权限(按菜单)'), key: 'permissions' },
|
||||
{ title: t('在用人数'), dataIndex: 'user_count', key: 'user_count', width: 90 },
|
||||
{ title: t('操作'), key: 'action', width: 140 },
|
||||
]);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -91,12 +124,13 @@ onMounted(load);
|
||||
class="mb-4"
|
||||
type="info"
|
||||
show-icon
|
||||
message="权限存在数据库里,不在代码里"
|
||||
description="改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。"
|
||||
:message="t('权限按左侧菜单组织:菜单 → 页面 → 页面里的每个按钮')"
|
||||
:description="t('改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。要新增权限点或左侧菜单链接,去「系统管理 → 权限菜单」。')"
|
||||
/>
|
||||
<Card title="角色与权限">
|
||||
<Card :title="t('角色与权限')">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增角色</Button>
|
||||
<Button v-if="canSeeMenu" class="mr-2" @click="router.push('/system/permissions')">{{ t('权限菜单') }}</Button>
|
||||
<Button v-if="canCreate" type="primary" @click="openCreate">{{ t('新增角色') }}</Button>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns" :data-source="roles" :loading="loading"
|
||||
@@ -105,23 +139,33 @@ onMounted(load);
|
||||
<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>
|
||||
<Tag v-if="record.builtin" class="ml-2" color="blue">{{ t('内置') }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'name'">
|
||||
{{ t(record.name) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'permissions'">
|
||||
<Tag v-for="code in record.permissions" :key="code" class="mb-1">
|
||||
{{ code }}
|
||||
</Tag>
|
||||
<Tag v-if="record.code === 'admin'" color="gold">{{ t('全部权限') }}</Tag>
|
||||
<template v-else>
|
||||
<Tag
|
||||
v-for="row in summarizeRole(grouped, record.permissions)" :key="row.group"
|
||||
class="mb-1" :color="row.held === row.total ? 'green' : undefined"
|
||||
>
|
||||
{{ t(row.group) }} {{ row.held }}/{{ row.total }}
|
||||
</Tag>
|
||||
<span v-if="!record.permissions.length" class="text-xs text-gray-400">{{ t('无权限') }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<Button type="link" size="small" :disabled="!canWrite" @click="openEdit(record as RoleItem)">
|
||||
编辑
|
||||
<Button type="link" size="small" @click="openEdit(record as RoleItem)">
|
||||
{{ canWrite && record.code !== 'admin' ? t('编辑') : t('查看') }}
|
||||
</Button>
|
||||
<Popconfirm title="确定删除这个角色?" @confirm="remove(record as RoleItem)">
|
||||
<Popconfirm :title="t('确定删除这个角色?')" @confirm="remove(record as RoleItem)">
|
||||
<Button
|
||||
type="link" size="small" danger
|
||||
:disabled="!canWrite || record.builtin || record.user_count > 0"
|
||||
:disabled="!canDelete || record.builtin || record.user_count > 0"
|
||||
>
|
||||
删除
|
||||
{{ t('删除') }}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</template>
|
||||
@@ -129,28 +173,63 @@ onMounted(load);
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" :title="isNew ? '新增角色' : '编辑角色'" width="640px" @ok="submit">
|
||||
<Modal
|
||||
v-model:open="open" :title="isNew ? t('新增角色') : readonly ? t('查看角色') : t('编辑角色')"
|
||||
width="820px" :ok-text="readonly ? t('关闭') : t('保存')" @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>
|
||||
<div class="flex gap-4">
|
||||
<FormItem :label="t('角色编码(字母数字,创建后不可改)')" class="flex-1">
|
||||
<Input v-model:value="form.code" :disabled="!isNew" placeholder="reviewer" />
|
||||
</FormItem>
|
||||
<FormItem :label="t('角色名称')" class="flex-1">
|
||||
<Input v-model:value="form.name" :disabled="readonly" :placeholder="t('审核员')" />
|
||||
</FormItem>
|
||||
</div>
|
||||
<FormItem :label="t('权限')">
|
||||
<p v-if="form.code === 'admin'" class="mb-2 text-xs text-gray-500">
|
||||
{{ t('管理员始终拥有全部权限(包括以后新增的),不能修改。') }}
|
||||
</p>
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<Input v-model:value="keyword" allow-clear size="small" class="w-60" :placeholder="t('搜索按钮名称或权限码')"
|
||||
@change="expandAll" />
|
||||
<Button size="small" @click="expandAll">{{ t('全部展开') }}</Button>
|
||||
<Button size="small" @click="expanded = branchKeys(tree, 1)">{{ t('收起到页面') }}</Button>
|
||||
<Button size="small" @click="expanded = []">{{ t('全部收起') }}</Button>
|
||||
<span class="ml-auto text-xs text-gray-500">{{ t('已选 {held} / {total} 项', { held: heldTotal, total }) }}</span>
|
||||
</div>
|
||||
<div class="max-h-[56vh] overflow-y-auto rounded border border-solid border-gray-200 px-2 py-1">
|
||||
<Tree
|
||||
v-model:expanded-keys="expanded" :tree-data="tree as any" :checked-keys="checkedKeys"
|
||||
checkable :show-line="{ showLeafIcon: false }" block-node :selectable="false" :disabled="readonly"
|
||||
@check="onCheck"
|
||||
>
|
||||
<template #title="{ dataRef }">
|
||||
<span v-if="dataRef.item" class="inline-flex flex-wrap items-center gap-x-2">
|
||||
<span>{{ dataRef.title }}</span>
|
||||
<Tag v-if="dataRef.item.kind === 'page'" class="m-0" color="blue">{{ t('页面') }}</Tag>
|
||||
<Tag v-else-if="dataRef.item.kind === 'link'" class="m-0" color="purple">{{ t('菜单链接') }}</Tag>
|
||||
<code class="text-xs text-gray-400">{{ dataRef.item.code }}</code>
|
||||
</span>
|
||||
<span v-else class="inline-flex flex-wrap items-center gap-x-2">
|
||||
<span :class="dataRef.page ? '' : 'font-medium'">{{ dataRef.title }}</span>
|
||||
<span class="text-xs text-gray-400">{{ held(dataRef.codes) }}/{{ dataRef.codes.length }}</span>
|
||||
<span v-if="dataRef.page && pageMissing(dataRef.page, form.permissions)" class="text-xs text-orange-500">
|
||||
{{ t('勾了按钮但没勾「页面」,左侧菜单里看不到这个页面') }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
<p v-if="!tree.length" class="my-3 text-center text-xs text-gray-400">{{ t('没有匹配的按钮') }}</p>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
提交的是最终状态:取消勾选就是收回该权限。
|
||||
{{ t('勾菜单或页面 = 勾它下面全部按钮;提交的是最终状态,取消勾选就是收回该权限。勾选按钮时会自动带上它所在的页面和必需的权限。') }}
|
||||
</p>
|
||||
<p
|
||||
v-if="form.permissions.some((code) => !names.has(code))"
|
||||
class="mt-1 text-xs text-orange-500"
|
||||
>
|
||||
{{ t('这个角色还持有已不存在的权限码,保存时会一并清掉。') }}
|
||||
</p>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { onScopeDispose, ref, shallowRef } from 'vue';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { requestErrorMessage } from '../../api/error-message';
|
||||
|
||||
/** Clear old-scope totals immediately and prevent stale responses/errors from winning. */
|
||||
@@ -27,7 +29,7 @@ export function useUsageRequest<T>() {
|
||||
if (current === request && !request.signal.aborted) data.value = result;
|
||||
} catch (cause) {
|
||||
if (current === request && !request.signal.aborted) {
|
||||
error.value = requestErrorMessage(cause, '加载失败,请稍后重试');
|
||||
error.value = requestErrorMessage(cause, t('加载失败,请稍后重试'));
|
||||
}
|
||||
} finally {
|
||||
if (current === request) loading.value = false;
|
||||
|
||||
@@ -8,9 +8,12 @@ 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';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const canWrite = computed(() => hasAccessByCodes(['user:write']));
|
||||
const canCreate = computed(() => hasAccessByCodes(['user:create']));
|
||||
const canChangeRole = computed(() => hasAccessByCodes(['user:write']));
|
||||
const canToggle = computed(() => hasAccessByCodes(['user:status']));
|
||||
|
||||
const loading = ref(false);
|
||||
const users = ref<UserItem[]>([]);
|
||||
@@ -20,7 +23,7 @@ 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})` })),
|
||||
roles.value.map((item) => ({ value: item.code, label: t('{name}({code})', { code: item.code, name: t(item.name) }) })),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
@@ -40,39 +43,40 @@ function openCreate() {
|
||||
|
||||
async function submit() {
|
||||
if (!form.username || !form.password) {
|
||||
message.warning('用户名和密码不能为空');
|
||||
message.warning(t('用户名和密码不能为空'));
|
||||
return;
|
||||
}
|
||||
users.value = (await createUser({ ...form })).users;
|
||||
open.value = false;
|
||||
message.success('已创建,该用户首次登录必须先改密码');
|
||||
message.success(t('已创建,该用户首次登录必须先改密码'));
|
||||
}
|
||||
|
||||
async function changeRole(row: UserItem, role: string) {
|
||||
users.value = (await updateUser(row.id, { role, active: row.active })).users;
|
||||
message.success('角色已更新,立即生效');
|
||||
message.success(t('角色已更新,立即生效'));
|
||||
}
|
||||
|
||||
async function toggleActive(row: UserItem, active: boolean) {
|
||||
users.value = (await updateUser(row.id, { role: row.role, active })).users;
|
||||
message.success(active ? '已启用' : '已停用');
|
||||
message.success(active ? t('已启用') : t('已停用'));
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('用户名'), dataIndex: 'username', key: 'username' },
|
||||
{ title: t('角色'), dataIndex: 'role', key: 'role', width: 220 },
|
||||
{ title: t('状态'), dataIndex: 'active', key: 'active', width: 120 },
|
||||
{ title: t('创建时间'), dataIndex: 'created_at', key: 'created_at', width: 180 },
|
||||
]);
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<Card title="用户管理">
|
||||
<Card :title="t('用户管理')">
|
||||
<template #extra>
|
||||
<Button v-if="canWrite" type="primary" @click="openCreate">新增用户</Button>
|
||||
<Button v-if="canCreate" type="primary" @click="openCreate">{{ t('新增用户') }}</Button>
|
||||
</template>
|
||||
<Table
|
||||
:columns="columns" :data-source="users" :loading="loading"
|
||||
@@ -82,36 +86,36 @@ onMounted(load);
|
||||
<template v-if="column.key === 'username'">
|
||||
{{ record.username }}
|
||||
<Tag v-if="record.must_change_password" color="orange" class="ml-2">
|
||||
待改密
|
||||
{{ t('待改密') }}
|
||||
</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'role'">
|
||||
<Select
|
||||
:value="record.role" :options="roleOptions" :disabled="!canWrite"
|
||||
:value="record.role" :options="roleOptions" :disabled="!canChangeRole"
|
||||
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"
|
||||
:checked="record.active" :disabled="!canToggle" size="small"
|
||||
@change="(value) => toggleActive(record as UserItem, Boolean(value))"
|
||||
/>
|
||||
<span class="ml-2 text-xs">{{ record.active ? '已启用' : '已停用' }}</span>
|
||||
<span class="ml-2 text-xs">{{ record.active ? t('已启用') : t('已停用') }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Modal v-model:open="open" title="新增用户" @ok="submit">
|
||||
<Modal v-model:open="open" :title="t('新增用户')" @ok="submit">
|
||||
<Form :model="form" layout="vertical" class="pt-2">
|
||||
<FormItem label="用户名">
|
||||
<FormItem :label="t('用户名')">
|
||||
<Input v-model:value="form.username" />
|
||||
</FormItem>
|
||||
<FormItem label="初始密码(至少 10 位,需同时含字母和数字)">
|
||||
<FormItem :label="t('初始密码(至少 10 位,需同时含字母和数字)')">
|
||||
<Input.Password v-model:value="form.password" />
|
||||
</FormItem>
|
||||
<FormItem label="角色">
|
||||
<FormItem :label="t('角色')">
|
||||
<Select v-model:value="form.role" :options="roleOptions" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { BatchAction, BatchResult } from './batch';
|
||||
import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue';
|
||||
import { Alert, Button, Checkbox, Collapse, Modal, Progress, Space, Spin, Tag } from 'ant-design-vue';
|
||||
import { fetchKnowledgeItem, knowledgeItemAction } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
import { actionProblem, failureText, runKnowledgeBatch } from './batch';
|
||||
|
||||
const props = defineProps<{ open: boolean; accountId: number; entries: KnowledgeItem[]; action: BatchAction }>();
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; finished: []; edit: [id: string] }>();
|
||||
const names = { delete: '删除', approve: '审核通过', publish: '发布', reject: '驳回', disable: '停用' };
|
||||
const details = ref<KnowledgeItem[]>([]);
|
||||
const problems = ref<Record<string, string>>({});
|
||||
const checked = ref<Record<string, boolean>>({});
|
||||
@@ -22,6 +22,23 @@ onBeforeUnmount(stop); onDeactivated(stop);
|
||||
const ready = computed(() => details.value.filter((item) => !problems.value[item.id]));
|
||||
const confirmed = computed(() => props.action !== 'approve' || ready.value.every((item) => checked.value[item.id]));
|
||||
const completed = computed(() => results.value?.filter((row) => row.status !== 'pending').length || 0);
|
||||
// 标题和确认按钮按操作分别写整句,英文语序才自然
|
||||
const title = computed(() => {
|
||||
const count = props.entries.length;
|
||||
return {
|
||||
delete: t('批量删除 · {count} 条', { count }), approve: t('批量审核通过 · {count} 条', { count }),
|
||||
publish: t('批量发布 · {count} 条', { count }), reject: t('批量驳回 · {count} 条', { count }),
|
||||
disable: t('批量停用 · {count} 条', { count }),
|
||||
}[props.action];
|
||||
});
|
||||
const confirmText = computed(() => {
|
||||
const count = ready.value.length;
|
||||
return {
|
||||
delete: t('确认删除 {count} 条', { count }), approve: t('确认审核通过 {count} 条', { count }),
|
||||
publish: t('确认发布 {count} 条', { count }), reject: t('确认驳回 {count} 条', { count }),
|
||||
disable: t('确认停用 {count} 条', { count }),
|
||||
}[props.action];
|
||||
});
|
||||
|
||||
watch(() => [props.open, props.accountId, props.action], async () => {
|
||||
const current = ++generation;
|
||||
@@ -34,7 +51,7 @@ watch(() => [props.open, props.accountId, props.action], async () => {
|
||||
const item = await fetchKnowledgeItem(props.accountId, entry.id);
|
||||
if (current !== generation) return;
|
||||
details.value.push(item);
|
||||
const problem = item.revision !== entry.revision ? '版本已更新,请关闭后重新选择,避免审核旧内容' : actionProblem(item, props.action);
|
||||
const problem = item.revision !== entry.revision ? t('版本已更新,请关闭后重新选择,避免审核旧内容') : actionProblem(item, props.action);
|
||||
if (problem) problems.value[item.id] = problem;
|
||||
} catch (error) {
|
||||
if (current !== generation) return;
|
||||
@@ -59,32 +76,32 @@ async function start() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" :title="`批量${names[action]} · ${entries.length} 条`" width="960px" :closable="!running" :keyboard="!running" :mask-closable="false" @cancel="emit('update:open', false)">
|
||||
<Modal :open="open" :title="title" width="960px" :closable="!running" :keyboard="!running" :mask-closable="false" @cancel="emit('update:open', false)">
|
||||
<Spin :spinning="loading">
|
||||
<Alert class="mb-4" show-icon type="info" :message="action === 'approve' ? '请逐条展开,核对来源、事实、脱敏和适用条件,再勾选确认。通过审核后仍需发布才可检索。' : '每条知识分别校验版本和当前状态。操作结果逐条显示,失败条目不会自动重试。'" />
|
||||
<Alert v-if="action === 'delete'" class="mb-4" type="warning" show-icon message="仅删除已驳回知识及其来源快照和版本记录;原始聊天归档保留。删除后无法恢复,请确认所选条目。" />
|
||||
<p v-if="!loading">可操作 {{ ready.length }} 条 · 需先处理 {{ Object.keys(problems).length }} 条</p>
|
||||
<Alert class="mb-4" show-icon type="info" :message="action === 'approve' ? t('请逐条展开,核对来源、事实、脱敏和适用条件,再勾选确认。通过审核后仍需发布才可检索。') : t('每条知识分别校验版本和当前状态。操作结果逐条显示,失败条目不会自动重试。')" />
|
||||
<Alert v-if="action === 'delete'" class="mb-4" type="warning" show-icon :message="t('仅删除已驳回知识及其来源快照和版本记录;原始聊天归档保留。删除后无法恢复,请确认所选条目。')" />
|
||||
<p v-if="!loading">{{ t('可操作 {ready} 条 · 需先处理 {blocked} 条', { ready: ready.length, blocked: Object.keys(problems).length }) }}</p>
|
||||
<div v-if="results" class="mb-4 rounded border p-3">
|
||||
<Progress :percent="results.length ? Math.round(completed * 100 / results.length) : 0" :status="running ? 'active' : undefined" />
|
||||
<p>成功 {{ results.filter((row) => row.status === 'success').length }} · 失败 {{ results.filter((row) => row.status === 'failed').length }} · 未执行 {{ results.filter((row) => row.status === 'pending').length }}</p>
|
||||
<div v-for="row in results" :key="row.id" class="mb-2"><Tag :color="row.status === 'success' ? 'green' : row.status === 'failed' ? 'red' : 'default'">{{ row.status === 'success' ? '成功' : row.status === 'failed' ? '失败' : '未执行' }}</Tag>{{ row.title }}<p v-if="row.error" class="text-red-500">{{ row.error }}</p></div>
|
||||
<p>{{ t('成功 {success} · 失败 {failed} · 未执行 {pending}', { success: results.filter((row) => row.status === 'success').length, failed: results.filter((row) => row.status === 'failed').length, pending: results.filter((row) => row.status === 'pending').length }) }}</p>
|
||||
<div v-for="row in results" :key="row.id" class="mb-2"><Tag :color="row.status === 'success' ? 'green' : row.status === 'failed' ? 'red' : 'default'">{{ row.status === 'success' ? t('成功') : row.status === 'failed' ? t('失败') : t('未执行') }}</Tag>{{ row.title }}<p v-if="row.error" class="text-red-500">{{ row.error }}</p></div>
|
||||
</div>
|
||||
<Collapse v-if="!results">
|
||||
<Collapse.Panel v-for="item in details" :key="item.id" :header="`${item.title} · v${item.revision}`">
|
||||
<Alert v-if="problems[item.id]" :message="problems[item.id]" type="warning" class="mb-3" />
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div><h4 class="mb-2 font-semibold">来源对话(已自动脱敏)</h4><div v-for="source in item.sources" :key="source.message_id" class="mb-2 rounded border p-2"><span class="text-xs text-gray-500">{{ source.role === 'staff' ? '客服' : '客户' }} · {{ source.sent_at }}</span><p class="whitespace-pre-wrap break-words">{{ source.content }}</p></div></div>
|
||||
<div><p class="whitespace-pre-wrap">问题:{{ item.question }}</p><p class="whitespace-pre-wrap">答案:{{ item.answer }}</p><p class="whitespace-pre-wrap">适用条件:{{ item.conditions || '尚未填写' }}</p><Alert v-for="flag in item.flags" :key="flag" :message="flag" type="warning" class="mb-2" /><p v-if="item.valid_until">有效期:{{ item.valid_until }}</p></div>
|
||||
<div><h4 class="mb-2 font-semibold">{{ t('来源对话(已自动脱敏)') }}</h4><div v-for="source in item.sources" :key="source.message_id" class="mb-2 rounded border p-2"><span class="text-xs text-gray-500">{{ source.role === 'staff' ? t('客服') : t('客户') }} · {{ source.sent_at }}</span><p class="whitespace-pre-wrap break-words">{{ source.content }}</p></div></div>
|
||||
<div><p class="whitespace-pre-wrap">{{ t('问题:{question}', { question: item.question }) }}</p><p class="whitespace-pre-wrap">{{ t('答案:{answer}', { answer: item.answer }) }}</p><p class="whitespace-pre-wrap">{{ t('适用条件:{conditions}', { conditions: item.conditions || t('尚未填写') }) }}</p><Alert v-for="flag in item.flags" :key="flag" :message="t(flag)" type="warning" class="mb-2" /><p v-if="item.valid_until">{{ t('有效期:{date}', { date: item.valid_until }) }}</p></div>
|
||||
</div>
|
||||
<Checkbox v-if="action === 'approve' && !problems[item.id]" v-model:checked="checked[item.id]">已核对本条来源、事实、脱敏和适用条件;涉及医疗内容已完成专业审核。</Checkbox>
|
||||
<Button type="link" @click="emit('edit', item.id)">打开单条审核与编辑</Button>
|
||||
<Checkbox v-if="action === 'approve' && !problems[item.id]" v-model:checked="checked[item.id]">{{ t('已核对本条来源、事实、脱敏和适用条件;涉及医疗内容已完成专业审核。') }}</Checkbox>
|
||||
<Button type="link" @click="emit('edit', item.id)">{{ t('打开单条审核与编辑') }}</Button>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Spin>
|
||||
<template #footer><Space>
|
||||
<Button v-if="running" :disabled="stopRequested" @click="stopRequested = true">{{ stopRequested ? '当前操作完成后停止' : '停止后续操作' }}</Button>
|
||||
<Button v-else @click="emit('update:open', false)">{{ results ? '完成' : '取消' }}</Button>
|
||||
<Button v-if="!results" :danger="action === 'delete'" type="primary" :disabled="loading || !ready.length || !confirmed" @click="start">确认{{ names[action] }} {{ ready.length }} 条</Button>
|
||||
<Button v-if="running" :disabled="stopRequested" @click="stopRequested = true">{{ stopRequested ? t('当前操作完成后停止') : t('停止后续操作') }}</Button>
|
||||
<Button v-else @click="emit('update:open', false)">{{ results ? t('完成') : t('取消') }}</Button>
|
||||
<Button v-if="!results" :danger="action === 'delete'" type="primary" :disabled="loading || !ready.length || !confirmed" @click="start">{{ confirmText }}</Button>
|
||||
</Space></template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import type { KnowledgeItem } from '#/api/knowledge';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
export type BatchAction = 'approve' | 'delete' | 'disable' | 'publish' | 'reject';
|
||||
export interface BatchResult { id: string; title: string; status: 'failed' | 'pending' | 'success'; error: string }
|
||||
|
||||
export function actionProblem(item: KnowledgeItem, action: BatchAction): string {
|
||||
const allowed = { delete: ['rejected'], approve: ['draft'], publish: ['approved'], reject: ['draft', 'approved'], disable: ['published'] };
|
||||
if (!allowed[action].includes(item.status)) return '当前状态不支持此操作,请刷新或重新选择';
|
||||
if (action === 'approve' && !item.conditions.trim()) return '请先编辑并补充适用条件';
|
||||
if (!allowed[action].includes(item.status)) return t('当前状态不支持此操作,请刷新或重新选择');
|
||||
if (action === 'approve' && !item.conditions.trim()) return t('请先编辑并补充适用条件');
|
||||
return '';
|
||||
}
|
||||
|
||||
export function failureText(error: unknown): string {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail;
|
||||
return typeof detail === 'string' ? detail : '请求未完成,请刷新确认状态后再操作';
|
||||
return typeof detail === 'string' ? detail : t('请求未完成,请刷新确认状态后再操作');
|
||||
}
|
||||
|
||||
/** Each request retains the selected revision; never retry ambiguous mutations automatically. */
|
||||
|
||||
@@ -1,36 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeItem, KnowledgeJob, KnowledgeReviewJob, KnowledgeOverview, KnowledgeSearch, KnowledgeTrace } from '#/api/knowledge';
|
||||
import type { KnowledgeAutoSummary, KnowledgeItem, KnowledgeReviewJob, KnowledgeOverview, KnowledgeRun, KnowledgeSearch, KnowledgeTrace } from '#/api/knowledge';
|
||||
|
||||
import { computed, onActivated, onDeactivated, onUnmounted, ref } from 'vue';
|
||||
import { useAccess } from '@vben/access';
|
||||
import { downloadFileFromBlob } from '@vben/utils';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, message, Modal, Progress, Select, Space, Steps, Switch, Table, Tabs, Tag } from 'ant-design-vue';
|
||||
import { Alert, Button, Card, Checkbox, Empty, Form, Input, message, Modal, Select, Space, Steps, Switch, Table, Tabs, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ALL_ACCOUNTS } from '#/api/console';
|
||||
import { requestErrorMessage } from '#/api/error-message';
|
||||
import { exportKnowledge, fetchKnowledgeReviewJobs, fetchKnowledgeItem, fetchKnowledgeItems, fetchKnowledgeJobs, fetchKnowledgeOverview, fetchKnowledgeTraces, knowledgeItemAction, knowledgeJobAction, saveKnowledgeItem, searchKnowledge, setKnowledgeEnabled, setKnowledgeWatch } from '#/api/knowledge';
|
||||
import { exportKnowledge, fetchKnowledgeReviewJobs, fetchKnowledgeItem, fetchKnowledgeItems, fetchKnowledgeOverview, fetchKnowledgeRuns, fetchKnowledgeTraces, knowledgeItemAction, saveKnowledgeItem, searchKnowledge, setKnowledgeEnabled, setKnowledgeWatch } from '#/api/knowledge';
|
||||
import ArchiveAccountSelector from '#/components/archive-account-selector.vue';
|
||||
import KnowledgeJobWizard from './job-wizard.vue';
|
||||
import KnowledgeJobConfig from './job-config.vue';
|
||||
import { t } from '#/locales/t';
|
||||
import KnowledgeRunDialog from './run-dialog.vue';
|
||||
import KnowledgeRunsPanel from './runs-panel.vue';
|
||||
import KnowledgeSettingsDialog from './settings-dialog.vue';
|
||||
import KnowledgeBatchDialog from './batch-dialog.vue';
|
||||
import KnowledgeReviewAll from './review-all.vue';
|
||||
import type { BatchAction } from './batch';
|
||||
import { actionProblem } from './batch';
|
||||
import { shortTime } from './run-format';
|
||||
import { useArchiveLoad } from '#/views/archive/use-archive-load';
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const hasAll = (codes: string[]) => codes.every((code) => hasAccessByCodes([code]));
|
||||
const canWrite = computed(() => hasAll(['knowledge:write', 'im:content:read']));
|
||||
const canReview = computed(() => hasAll(['knowledge:review', 'im:content:read']));
|
||||
const canPublish = computed(() => hasAll(['knowledge:publish', 'im:content:read']));
|
||||
// 每个按钮一个权限码;碰到聊天原文(来源证据)的操作还要能看正文,和后端判法一致
|
||||
const withContent = (code: string) => computed(() => hasAll([code, 'im:content:read']));
|
||||
const canRun = withContent('knowledge:run');
|
||||
const canControl = withContent('knowledge:run:control');
|
||||
const canSettings = computed(() => hasAccessByCodes(['knowledge:settings']));
|
||||
const canWatch = withContent('knowledge:watch');
|
||||
const canEdit = withContent('knowledge:write');
|
||||
const canRestore = withContent('knowledge:restore');
|
||||
const canDelete = withContent('knowledge:delete');
|
||||
const canReview = withContent('knowledge:review');
|
||||
const canReject = withContent('knowledge:reject');
|
||||
const canAutoReview = withContent('knowledge:review:auto');
|
||||
const canPublish = withContent('knowledge:publish');
|
||||
const canDisable = withContent('knowledge:disable');
|
||||
const canEnable = computed(() => hasAccessByCodes(['knowledge:enable']));
|
||||
const canSearch = computed(() => hasAccessByCodes(['knowledge:search']));
|
||||
const canExport = computed(() => hasAccessByCodes(['knowledge:export']));
|
||||
const canTrace = computed(() => hasAccessByCodes(['stats:read']));
|
||||
const canBatch = computed(() => canReview.value || canReject.value || canPublish.value || canDisable.value || canDelete.value);
|
||||
const accountId = ref(ALL_ACCOUNTS);
|
||||
const single = computed(() => accountId.value !== ALL_ACCOUNTS);
|
||||
const tab = ref('items');
|
||||
const overview = ref<KnowledgeOverview | null>(null);
|
||||
const items = ref<KnowledgeItem[]>([]);
|
||||
const jobs = ref<KnowledgeJob[]>([]);
|
||||
const runs = ref<KnowledgeRun[]>([]);
|
||||
const autoSummary = ref<KnowledgeAutoSummary | null>(null);
|
||||
const reviewJobs = ref<KnowledgeReviewJob[]>([]);
|
||||
const reviewOpen = ref(false);
|
||||
const reviewTab = ref('new');
|
||||
@@ -54,27 +72,23 @@ const contentSignature = (item: KnowledgeItem) => JSON.stringify([item.title, it
|
||||
const dirty = computed(() => !!selected.value && contentSignature(selected.value) !== savedContent.value);
|
||||
const editorOpen = ref(false);
|
||||
const createOpen = ref(false);
|
||||
const configOpen = ref(false);
|
||||
const configJob = ref<KnowledgeJob | null>(null);
|
||||
function configureJob(jobId: string) { configJob.value = jobs.value.find((job) => job.id === jobId) || null; configOpen.value = !!configJob.value; }
|
||||
const accountSelector = ref<InstanceType<typeof ArchiveAccountSelector>>();
|
||||
let pendingCreate = false;
|
||||
const settingsOpen = ref(false);
|
||||
const selectedKeys = ref<string[]>([]);
|
||||
const batchOpen = ref(false);
|
||||
const batchAction = ref<BatchAction>('approve');
|
||||
const batchEntries = ref<KnowledgeItem[]>([]);
|
||||
const selectedRows = computed(() => items.value.filter((item) => selectedKeys.value.includes(item.id)));
|
||||
const rowSelection = computed(() => single.value && (canReview.value || canPublish.value || canWrite.value) ? {
|
||||
const rowSelection = computed(() => canBatch.value ? {
|
||||
selectedRowKeys: selectedKeys.value,
|
||||
onChange: (keys: (number | string)[]) => { selectedKeys.value = keys.map(String); },
|
||||
} : undefined);
|
||||
const guideStep = computed(() => !single.value ? 0 : (overview.value?.counts.published || 0) > 0 ? 3
|
||||
: (overview.value?.counts.draft || 0) + (overview.value?.counts.approved || 0) > 0 ? 2 : 1);
|
||||
const guideText = computed(() => !single.value ? '先选择一个客服账号,系统会预览该账号可整理的聊天范围。'
|
||||
: !overview.value?.source_accounts.length ? '当前账号尚无聊天归档,请先上传归档或更换账号。'
|
||||
: guideStep.value === 1 ? '先预览范围并生成草稿,再逐条核对来源和适用条件。'
|
||||
: guideStep.value === 2 ? '草稿补全适用条件后审核,通过后发布;也可以勾选列表中的多条知识集中处理。'
|
||||
: '已发布知识可在检索测试中验证;确认结果后开启客服使用知识。');
|
||||
const guideText = computed(() => !single.value ? t('点「整理聊天记录」可一次整理全部账号的聊天;点知识标题可直接查看、审核和发布。')
|
||||
: !overview.value?.source_accounts.length ? t('当前账号尚无聊天归档,请先上传归档或更换账号。')
|
||||
: guideStep.value === 1 ? t('点「整理聊天记录」生成草稿,再逐条核对来源和适用条件。')
|
||||
: guideStep.value === 2 ? t('草稿补全适用条件后审核,通过后发布;也可以勾选列表中的多条知识集中处理。')
|
||||
: t('已发布知识可在检索测试中验证;确认结果后开启客服使用知识。'));
|
||||
function batchAllowed(action: BatchAction) {
|
||||
return selectedRows.value.length > 0 && selectedRows.value.every((item) => !actionProblem(item, action));
|
||||
}
|
||||
@@ -84,17 +98,9 @@ function openBatch(action: BatchAction) {
|
||||
batchOpen.value = true;
|
||||
}
|
||||
function batchFinished() { selectedKeys.value = []; void load(); }
|
||||
async function startCreate() {
|
||||
if (!single.value) { pendingCreate = true; accountSelector.value?.open(); return; }
|
||||
if (!overview.value) await load();
|
||||
createOpen.value = true;
|
||||
}
|
||||
function startCreate() { createOpen.value = true; }
|
||||
function created() { tab.value = 'jobs'; void load(); }
|
||||
function listChanged() { selectedKeys.value = []; void load(); }
|
||||
function cancelJob(jobId: string) {
|
||||
Modal.confirm({ title: '取消此整理任务?', content: '已生成的草稿会保留。取消后不再继续处理;已经开始的模型请求可能仍会计费。持续整理开关需要单独关闭。',
|
||||
okText: '取消任务', cancelText: '保留任务', onOk: () => jobAction(jobId, 'cancel') });
|
||||
}
|
||||
const confirmed = ref(false);
|
||||
const busy = ref(false);
|
||||
const request = useArchiveLoad();
|
||||
@@ -103,11 +109,12 @@ let active = true;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let detailGeneration = 0;
|
||||
|
||||
const labels: Record<string, string> = { draft: '待审核', approved: '待发布', published: '已发布', rejected: '已驳回', disabled: '已停用', stale: '来源已变化', queued: '等待处理', running: '处理中', paused: '已暂停', completed: '已完成', failed: '失败', cancelled: '已取消' };
|
||||
// 文案随语言切换:包成 computed(模板里自动解包)
|
||||
const labels = computed<Record<string, string>>(() => ({ draft: t('待审核'), approved: t('待发布'), published: t('已发布'), rejected: t('已驳回'), disabled: t('已停用'), stale: t('来源已变化'), queued: t('等待处理'), running: t('处理中'), paused: t('已暂停'), completed: t('已完成'), failed: t('失败'), cancelled: t('已取消') }));
|
||||
const colors: Record<string, string> = { draft: 'orange', approved: 'blue', published: 'green', rejected: 'red', stale: 'red', completed: 'green', failed: 'red', running: 'blue' };
|
||||
const modes: Record<string, string> = { lexical: '关键词检索', hybrid: '关键词与语义检索', lexical_fallback: '语义服务暂不可用,已使用关键词', unavailable: '检索暂不可用' };
|
||||
const statusOptions = [{ value: '', label: '全部状态' }, ...['draft', 'approved', 'published', 'rejected', 'disabled', 'stale'].map((value) => ({ value, label: labels[value] }))];
|
||||
const kindOptions = [{ value: 'qa', label: '标准问答' }, { value: 'procedure', label: '业务流程' }, { value: 'case', label: '对话案例' }];
|
||||
const modes = computed<Record<string, string>>(() => ({ lexical: t('关键词检索'), hybrid: t('关键词与语义检索'), lexical_fallback: t('语义服务暂不可用,已使用关键词'), unavailable: t('检索暂不可用') }));
|
||||
const statusOptions = computed(() => [{ value: '', label: t('全部状态') }, ...['draft', 'approved', 'published', 'rejected', 'disabled', 'stale'].map((value) => ({ value, label: labels.value[value] }))]);
|
||||
const kindOptions = computed(() => [{ value: 'qa', label: t('标准问答') }, { value: 'procedure', label: t('业务流程') }, { value: 'case', label: t('对话案例') }]);
|
||||
const sourceOptions = computed(() => (overview.value?.source_accounts || []).map((a) => ({ value: a.id, label: a.display_name || a.external_account_id })));
|
||||
|
||||
async function load() {
|
||||
@@ -115,33 +122,31 @@ async function load() {
|
||||
const id = accountId.value;
|
||||
const [summary, entries, tasks, logs, reviews] = await Promise.all([
|
||||
fetchKnowledgeOverview(id, signal), fetchKnowledgeItems(id, filter.value, query.value, page.value, signal),
|
||||
fetchKnowledgeJobs(id, signal), canTrace.value ? fetchKnowledgeTraces(id, signal) : Promise.resolve({ items: [] }),
|
||||
canReview.value ? fetchKnowledgeReviewJobs(signal) : Promise.resolve({ items: [] }),
|
||||
fetchKnowledgeRuns(signal), canTrace.value ? fetchKnowledgeTraces(id, signal) : Promise.resolve({ items: [] }),
|
||||
canAutoReview.value ? fetchKnowledgeReviewJobs(signal) : Promise.resolve({ items: [] }),
|
||||
]);
|
||||
return { summary, entries, tasks, logs, reviews };
|
||||
}, (data) => {
|
||||
overview.value = data.summary; items.value = data.entries.items; total.value = data.entries.total;
|
||||
jobs.value = data.tasks.items; reviewJobs.value = data.reviews.items; traces.value = data.logs.items;
|
||||
runs.value = data.tasks.items; autoSummary.value = data.tasks.auto; reviewJobs.value = data.reviews.items; traces.value = data.logs.items;
|
||||
});
|
||||
schedule();
|
||||
}
|
||||
function schedule() {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (!active || ![...jobs.value, ...reviewJobs.value].some((j) => ['queued', 'running'].includes(j.status))) return;
|
||||
if (!active || ![...runs.value, ...reviewJobs.value].some((j) => ['queued', 'running'].includes(j.status))) return;
|
||||
timer = setTimeout(() => { if (document.visibilityState === 'visible' && !busy.value) void load(); else schedule(); }, 5000);
|
||||
}
|
||||
async function accountChanged() {
|
||||
const currentAccount = accountId.value;
|
||||
detailGeneration++; selected.value = null; editorOpen.value = false; createOpen.value = false; configOpen.value = false; configJob.value = null;
|
||||
items.value = []; jobs.value = []; traces.value = []; result.value = null; searchError.value = ''; overview.value = null;
|
||||
detailGeneration++; selected.value = null; editorOpen.value = false;
|
||||
items.value = []; traces.value = []; result.value = null; searchError.value = ''; overview.value = null;
|
||||
selectedKeys.value = []; batchOpen.value = false; batchEntries.value = []; page.value = 1;
|
||||
await load();
|
||||
if (currentAccount !== accountId.value) return;
|
||||
if (pendingCreate && single.value) { pendingCreate = false; createOpen.value = true; }
|
||||
else pendingCreate = false;
|
||||
}
|
||||
// 「全部账号」下也能直接打开:后端按知识自己的账号处理,并检查在授权范围内
|
||||
async function inspect(id: string) {
|
||||
if (!single.value) { message.info('请先选择具体账号,再查看和审核知识'); return; }
|
||||
const generation = ++detailGeneration;
|
||||
const entry = await fetchKnowledgeItem(accountId.value, id);
|
||||
if (generation !== detailGeneration) return;
|
||||
@@ -159,8 +164,8 @@ async function save() {
|
||||
const generation = detailGeneration;
|
||||
await mutate(async () => {
|
||||
const saved = await saveKnowledgeItem(id, item);
|
||||
if (generation === detailGeneration) { selected.value = saved; savedContent.value = contentSignature(saved); confirmed.value = false; }
|
||||
message.success('已保存为草稿,需要重新审核后发布');
|
||||
if (generation === detailGeneration) { selected.value = { ...saved, account_id: item.account_id, account_label: item.account_label }; savedContent.value = contentSignature(saved); confirmed.value = false; }
|
||||
message.success(t('已保存为草稿,需要重新审核后发布'));
|
||||
});
|
||||
}
|
||||
async function act(action: string, restoreRevision = 0) {
|
||||
@@ -170,25 +175,21 @@ async function act(action: string, restoreRevision = 0) {
|
||||
const generation = detailGeneration;
|
||||
await mutate(async () => {
|
||||
const changed = await knowledgeItemAction(id, item, action, confirmed.value, restoreRevision);
|
||||
if (generation === detailGeneration) { selected.value = changed; savedContent.value = contentSignature(changed); confirmed.value = false; }
|
||||
message.success(action === 'restore' ? '已恢复为草稿,请审核后重新发布' : '操作成功');
|
||||
if (generation === detailGeneration) { selected.value = { ...changed, account_id: item.account_id, account_label: item.account_label }; savedContent.value = contentSignature(changed); confirmed.value = false; }
|
||||
message.success(action === 'restore' ? t('已恢复为草稿,请审核后重新发布') : t('操作成功'));
|
||||
});
|
||||
}
|
||||
function deleteSelected() {
|
||||
if (!selected.value) return;
|
||||
const item = { ...selected.value }; const id = accountId.value; const generation = detailGeneration;
|
||||
Modal.confirm({ title: '删除这条已驳回知识?', content: '将删除知识及其来源快照和版本记录,原始聊天归档保留。删除后无法恢复。',
|
||||
okText: '确认删除', okType: 'danger', cancelText: '取消', onOk: () => mutate(async () => {
|
||||
Modal.confirm({ title: t('删除这条已驳回知识?'), content: t('将删除知识及其来源快照和版本记录,原始聊天归档保留。删除后无法恢复。'),
|
||||
okText: t('确认删除'), okType: 'danger', cancelText: t('取消'), onOk: () => mutate(async () => {
|
||||
await knowledgeItemAction(id, item, 'delete', true);
|
||||
if (generation === detailGeneration) { editorOpen.value = false; selected.value = null; }
|
||||
selectedKeys.value = selectedKeys.value.filter((key) => key !== item.id);
|
||||
message.success('已删除');
|
||||
message.success(t('已删除'));
|
||||
}) });
|
||||
}
|
||||
async function jobAction(jobId: string, action: string) {
|
||||
const id = accountId.value;
|
||||
await mutate(() => knowledgeJobAction(id, jobId, action));
|
||||
}
|
||||
async function toggleEnabled(enabled: boolean) {
|
||||
const id = accountId.value;
|
||||
await mutate(() => setKnowledgeEnabled(id, enabled));
|
||||
@@ -196,11 +197,11 @@ async function toggleEnabled(enabled: boolean) {
|
||||
async function test() {
|
||||
if (busy.value) return;
|
||||
result.value = null; searchError.value = '';
|
||||
if (!single.value) { searchError.value = '请先选择具体账号'; return; }
|
||||
if (!single.value) { searchError.value = t('请先选择具体账号'); return; }
|
||||
const text = testQuery.value.trim();
|
||||
const length = Array.from(text).length;
|
||||
if (length < 2) { searchError.value = '请输入至少 2 个字符的检索问题'; return; }
|
||||
if (length > 2000) { searchError.value = '检索问题不能超过 2000 个字符'; return; }
|
||||
if (length < 2) { searchError.value = t('请输入至少 2 个字符的检索问题'); return; }
|
||||
if (length > 2000) { searchError.value = t('检索问题不能超过 2000 个字符'); return; }
|
||||
testQuery.value = text;
|
||||
const id = accountId.value;
|
||||
const generation = detailGeneration;
|
||||
@@ -209,7 +210,7 @@ async function test() {
|
||||
const found = await searchKnowledge(id, text);
|
||||
if (generation === detailGeneration) result.value = found;
|
||||
} catch (error) {
|
||||
if (generation === detailGeneration) searchError.value = requestErrorMessage(error, '检索失败,请稍后重试');
|
||||
if (generation === detailGeneration) searchError.value = requestErrorMessage(error, t('检索失败,请稍后重试'));
|
||||
} finally { busy.value = false; }
|
||||
}
|
||||
async function download() {
|
||||
@@ -223,11 +224,11 @@ onDeactivated(stop); onUnmounted(stop);
|
||||
|
||||
<template>
|
||||
<div class="space-y-4 p-5">
|
||||
<Card title="知识中心">
|
||||
<template #extra><ArchiveAccountSelector ref="accountSelector" v-model="accountId" :disabled="busy || createOpen || batchOpen || configOpen || reviewOpen" @change="accountChanged" /></template>
|
||||
<p class="mb-4 text-gray-500">把历史聊天整理成可追溯的知识,审核发布后供客服回答时参考。</p>
|
||||
<Card :title="t('知识中心')">
|
||||
<template #extra><ArchiveAccountSelector v-model="accountId" :disabled="busy || createOpen || batchOpen || settingsOpen || reviewOpen" @change="accountChanged" /></template>
|
||||
<p class="mb-4 text-gray-500">{{ t('把历史聊天整理成可追溯的知识,审核发布后供客服回答时参考。') }}</p>
|
||||
<div class="mb-5 rounded-lg border p-4">
|
||||
<Steps size="small" :current="guideStep" :items="[{ title: '选择账号' }, { title: '预览并整理聊天' }, { title: '审核知识' }, { title: '发布并测试使用' }]" />
|
||||
<Steps size="small" :current="guideStep" :items="[{ title: t('整理聊天记录') }, { title: t('生成草稿') }, { title: t('审核知识') }, { title: t('发布并测试使用') }]" />
|
||||
<p class="mb-0 mt-3 text-gray-500">{{ guideText }}</p>
|
||||
</div>
|
||||
<div class="mb-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
@@ -236,96 +237,82 @@ onDeactivated(stop); onUnmounted(stop);
|
||||
</div>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag>{{ overview?.semantic_configured ? '支持语义与关键词检索' : '关键词检索已就绪' }}</Tag>
|
||||
<span>客服使用知识</span>
|
||||
<Switch :checked="!!overview?.settings?.enabled" :disabled="!single || !canPublish || busy || (!overview?.settings?.enabled && !overview?.counts.published)" @change="(v) => toggleEnabled(Boolean(v))" />
|
||||
<Button v-if="canWrite" type="primary" :disabled="busy || loading" @click="startCreate">{{ single ? '从聊天整理知识' : '选择账号开始整理' }}</Button>
|
||||
<Button v-if="canExport" :disabled="!single || busy" @click="download">导出已发布知识</Button>
|
||||
<Button :loading="loading" @click="load">刷新</Button>
|
||||
<Tag>{{ overview?.semantic_configured ? t('支持语义与关键词检索') : t('关键词检索已就绪') }}</Tag>
|
||||
<span>{{ t('客服使用知识') }}</span>
|
||||
<Switch :checked="!!overview?.settings?.enabled" :disabled="!single || !canEnable || busy || (!overview?.settings?.enabled && !overview?.counts.published)" @change="(v) => toggleEnabled(Boolean(v))" />
|
||||
<Button v-if="canRun" type="primary" :disabled="busy" @click="startCreate">{{ t('整理聊天记录') }}</Button>
|
||||
<Button v-if="canSettings" @click="settingsOpen = true">{{ t('整理设置') }}</Button>
|
||||
<Button v-if="canExport" :disabled="!single || busy" @click="download">{{ t('导出已发布知识') }}</Button>
|
||||
<Button :loading="loading" @click="load">{{ t('刷新') }}</Button>
|
||||
</Space>
|
||||
<Alert v-if="!single" class="mt-4" type="info" show-icon message="当前汇总全部授权账号,可一键审核全部授权账号的草稿。整理聊天、单条编辑、发布和检索测试需选择具体账号。" />
|
||||
<Alert v-if="!single" class="mt-4" type="info" show-icon :message="t('当前汇总全部授权账号:整理聊天、查看和审核知识都可以直接进行;检索测试、导出和「客服使用知识」开关需选择具体账号。')" />
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Tabs v-model:active-key="tab">
|
||||
<Tabs.TabPane key="items" tab="知识与审核">
|
||||
<Tabs.TabPane key="items" :tab="t('知识与审核')">
|
||||
<Space class="mb-4" wrap>
|
||||
<Select v-model:value="filter" :options="statusOptions" style="width: 150px" @change="() => { page = 1; listChanged(); }" />
|
||||
<Input.Search v-model:value="query" placeholder="搜索标题或问题" style="width: 300px" @search="() => { page = 1; listChanged(); }" />
|
||||
<Input.Search v-model:value="query" :placeholder="t('搜索标题或问题')" style="width: 300px" @search="() => { page = 1; listChanged(); }" />
|
||||
</Space>
|
||||
<Space v-if="canReview" class="mb-4 flex" wrap>
|
||||
<Button type="primary" :disabled="busy" @click="openFullReview('new')">{{ single ? '一键审核此账号全部草稿' : '一键审核全部授权账号' }}</Button>
|
||||
<Button @click="openFullReview('jobs')">审核任务与结果</Button>
|
||||
<Tag v-if="reviewJobs.some((job) => ['queued', 'running'].includes(job.status))" color="blue">后台审核进行中</Tag>
|
||||
<Space v-if="canAutoReview" class="mb-4 flex" wrap>
|
||||
<Button type="primary" :disabled="busy" @click="openFullReview('new')">{{ single ? t('一键审核此账号全部草稿') : t('一键审核全部授权账号') }}</Button>
|
||||
<Button @click="openFullReview('jobs')">{{ t('审核任务与结果') }}</Button>
|
||||
<Tag v-if="reviewJobs.some((job) => ['queued', 'running'].includes(job.status))" color="blue">{{ t('后台审核进行中') }}</Tag>
|
||||
</Space>
|
||||
<Space v-if="single && (canReview || canPublish || canWrite)" class="mb-4 flex" wrap>
|
||||
<span>已选 {{ selectedKeys.length }} 条(当前页)</span>
|
||||
<Button v-if="canReview" :disabled="!selectedKeys.length || busy" @click="openBatch('approve')">批量审核</Button>
|
||||
<Button v-if="canPublish" type="primary" :disabled="!batchAllowed('publish') || busy" @click="openBatch('publish')">发布选中</Button>
|
||||
<Button v-if="canReview" :disabled="!batchAllowed('reject') || busy" @click="openBatch('reject')">驳回选中</Button>
|
||||
<Button v-if="canPublish" danger :disabled="!batchAllowed('disable') || busy" @click="openBatch('disable')">停用选中</Button>
|
||||
<Button v-if="canWrite" danger :disabled="!batchAllowed('delete') || busy" @click="openBatch('delete')">删除已驳回</Button>
|
||||
<Button v-if="selectedKeys.length" type="link" @click="selectedKeys = []">清空选择</Button>
|
||||
<Space v-if="canBatch" class="mb-4 flex" wrap>
|
||||
<span>{{ t('已选 {count} 条(当前页)', { count: selectedKeys.length }) }}</span>
|
||||
<Button v-if="canReview" :disabled="!selectedKeys.length || busy" @click="openBatch('approve')">{{ t('批量审核') }}</Button>
|
||||
<Button v-if="canPublish" type="primary" :disabled="!batchAllowed('publish') || busy" @click="openBatch('publish')">{{ t('发布选中') }}</Button>
|
||||
<Button v-if="canReject" :disabled="!batchAllowed('reject') || busy" @click="openBatch('reject')">{{ t('驳回选中') }}</Button>
|
||||
<Button v-if="canDisable" danger :disabled="!batchAllowed('disable') || busy" @click="openBatch('disable')">{{ t('停用选中') }}</Button>
|
||||
<Button v-if="canDelete" danger :disabled="!batchAllowed('delete') || busy" @click="openBatch('delete')">{{ t('删除已驳回') }}</Button>
|
||||
<Button v-if="selectedKeys.length" type="link" @click="selectedKeys = []">{{ t('清空选择') }}</Button>
|
||||
</Space>
|
||||
<!-- 固定列宽 + 最小 1000px:窗口窄时整表横向滚动,标题列不会把账号、状态挤成一个字一行 -->
|
||||
<Table :row-selection="rowSelection" :data-source="items" row-key="id" :loading="loading" :pagination="{ current: page, pageSize: 30, total, showSizeChanger: false }" @change="(p) => { page = p.current || 1; listChanged(); }"
|
||||
:columns="[{ title: '问题 / 标题', dataIndex: 'title', key: 'title' }, { title: '分类', dataIndex: 'category' }, { title: '状态', key: 'status', width: 130 }, { title: '版本', dataIndex: 'revision', width: 75 }, { title: '更新时间', dataIndex: 'updated_at', width: 230 }]">
|
||||
<template #emptyText><Empty :description="query || filter ? '没有符合筛选条件的知识,可调整筛选后重试。' : single ? '还没有知识,先从该账号的历史聊天生成待审草稿。' : '选择具体账号,开始整理历史聊天。'"><Button v-if="canWrite && !query && !filter" type="primary" @click="startCreate">{{ single ? '预览并整理聊天' : '选择账号' }}</Button></Empty></template>
|
||||
:scroll="{ x: 1000 }"
|
||||
:columns="[{ title: t('问题 / 标题'), dataIndex: 'title', key: 'title' }, ...(single ? [] : [{ title: t('账号'), dataIndex: 'account_label', key: 'account', width: 160 }]), { title: t('分类'), dataIndex: 'category', key: 'category', width: 120 }, { title: t('状态'), key: 'status', width: 130 }, { title: t('版本'), dataIndex: 'revision', width: 75 }, { title: t('更新时间'), key: 'updated_at', width: 150 }]">
|
||||
<template #emptyText><Empty :description="query || filter ? t('没有符合筛选条件的知识,可调整筛选后重试。') : t('还没有知识,先从历史聊天整理出待审草稿。')"><Button v-if="canRun && !query && !filter" type="primary" @click="startCreate">{{ t('整理聊天记录') }}</Button></Empty></template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<Button v-if="column.key === 'title'" type="link" class="!h-auto whitespace-normal text-left" @click="inspect(record.id)">{{ record.title }}</Button>
|
||||
<Tag v-else-if="column.key === 'status'" :color="colors[record.status]">{{ labels[record.status] }}</Tag>
|
||||
<span v-else-if="column.key === 'updated_at'" class="whitespace-nowrap">{{ shortTime(record.updated_at) }}</span>
|
||||
<template v-else-if="column.key === 'account'">{{ t(record.account_label) }}</template>
|
||||
<template v-else-if="column.key === 'category'">{{ t(record.category) }}</template>
|
||||
</template>
|
||||
</Table>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="jobs" tab="加工任务">
|
||||
<Alert class="mb-4" type="info" show-icon message="任务分批处理,可暂停和继续。只有角色明确的单聊文本会整理为草稿;采样末尾不完整的问答会丢弃。" />
|
||||
<Tabs.TabPane key="jobs" :tab="t('整理任务')">
|
||||
<div v-if="single && overview?.watches.length" class="mb-4 space-y-2">
|
||||
<div v-for="watch in overview.watches" :key="watch.source_account_id" class="flex items-center gap-3">
|
||||
<span>{{ sourceOptions.find((s) => s.value === watch.source_account_id)?.label }} · 自动整理新增聊天</span>
|
||||
<Switch :checked="!!watch.enabled" :disabled="!canWrite || busy" @change="(value) => mutate(() => setKnowledgeWatch(accountId, watch.source_account_id, Boolean(value)))" />
|
||||
<span>{{ sourceOptions.find((s) => s.value === watch.source_account_id)?.label }} · {{ t('新聊天自动整理') }}</span>
|
||||
<Switch :checked="!!watch.enabled" :disabled="!canWatch || busy" @change="(value) => mutate(() => setKnowledgeWatch(accountId, watch.source_account_id, Boolean(value)))" />
|
||||
</div>
|
||||
</div>
|
||||
<Table :data-source="jobs" row-key="id" :pagination="{ pageSize: 15 }" :columns="[{ title: '客服来源', key: 'source' }, { title: '创建时间', dataIndex: 'created_at' }, { title: '状态', key: 'status' }, { title: '处理结果', key: 'progress' }, { title: '模型用量', key: 'usage' }, { title: '操作', key: 'actions' }]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'source'">{{ record.options.source_name || '历史任务' }}<div class="text-xs text-gray-500">{{ record.options.engine === 'model' ? '模型整理' : '规则整理' }}{{ record.options.incremental ? ' · 增量任务' : '' }}<span v-if="record.options.engine === 'model'"> · {{ record.options.model_name || record.options.model_provider_id || '系统默认模型' }}</span></div></template>
|
||||
<template v-else-if="column.key === 'status'"><Tag :color="colors[record.status]">{{ labels[record.status] }}</Tag><div class="max-w-xs text-red-500">{{ record.error }}</div></template>
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<div class="min-w-[240px]">已读 {{ record.processed }}<span v-if="record.total_messages !== null && record.total_messages !== undefined"> / {{ record.total_messages }}</span> 条
|
||||
<Progress v-if="record.progress_percent !== null && record.progress_percent !== undefined" :percent="record.progress_percent" size="small" :status="record.status === 'failed' ? 'exception' : record.status === 'running' ? 'active' : undefined" />
|
||||
<div v-if="record.pending_items" class="text-xs text-gray-500">待整理问答 {{ record.pending_items }} 条(已保存断点)</div>
|
||||
<div>草稿 {{ record.created_items }} · 重复 {{ record.duplicates }} · 无效消息 {{ record.skipped }}</div>
|
||||
<div v-if="record.options.model_format_fallbacks" class="max-w-xs text-amber-500">模型格式异常 {{ record.options.model_format_fallbacks }} 条,已保留原文待审。<span v-if="record.options.model_last_warning">最近原因:{{ record.options.model_last_warning }}</span></div>
|
||||
<div v-if="record.completion_reason && record.status === 'completed'" class="text-xs text-gray-500">{{ record.completion_reason === 'message_limit' ? '达到消息上限;末尾不完整问答已丢弃' : '所选范围已读取完成' }}</div>
|
||||
<div v-if="record.status === 'completed' && !record.created_items" class="text-xs text-gray-500">未生成新知识:请检查有效文本、完整问答或是否已被其他任务整理。</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">{{ record.model_calls }} 次调用<br />输入 {{ record.input_chars }} / 输出 {{ record.output_chars }} 字符</template>
|
||||
<template v-else-if="column.key === 'actions' && single && canWrite">
|
||||
<Button v-if="['queued', 'running'].includes(record.status)" :disabled="busy" @click="jobAction(record.id, 'pause')">暂停</Button>
|
||||
<Button v-if="['paused', 'failed'].includes(record.status)" :disabled="busy" @click="configureJob(record.id)">配置 / 继续</Button>
|
||||
<Button v-if="['queued', 'running', 'paused', 'failed'].includes(record.status)" danger type="link" :disabled="busy" @click="cancelJob(record.id)">取消任务</Button>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
<KnowledgeRunsPanel :runs="runs" :auto="autoSummary" :can-create="canRun" :can-control="canControl"
|
||||
:can-settings="canSettings" :loading="loading"
|
||||
@changed="load" @create="startCreate" @settings="settingsOpen = true" />
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="test" tab="检索测试">
|
||||
<p class="text-gray-500">测试只检索已发布知识,不发送客服消息。相关度是排序参考,不代表事实正确率。</p>
|
||||
<Alert v-if="single && overview && !overview.counts.published" class="mb-3" type="info" show-icon message="当前账号暂无已发布知识,请先审核并发布知识后再测试检索。" />
|
||||
<Input.Search v-model:value="testQuery" placeholder="输入一个真实业务问题(2–2000 个字符)" enter-button="检索" :disabled="!single" :loading="busy" @search="test" @change="searchError = ''" />
|
||||
<Tabs.TabPane v-if="canSearch" key="test" :tab="t('检索测试')">
|
||||
<p class="text-gray-500">{{ t('测试只检索已发布知识,不发送客服消息。相关度是排序参考,不代表事实正确率。') }}</p>
|
||||
<Alert v-if="single && overview && !overview.counts.published" class="mb-3" type="info" show-icon :message="t('当前账号暂无已发布知识,请先审核并发布知识后再测试检索。')" />
|
||||
<Input.Search v-model:value="testQuery" :placeholder="t('输入一个真实业务问题(2–2000 个字符)')" :enter-button="t('检索')" :disabled="!single" :loading="busy" @search="test" @change="searchError = ''" />
|
||||
<Alert v-if="searchError" class="mt-3" type="error" show-icon :message="searchError" />
|
||||
<template v-if="result">
|
||||
<p class="my-4">{{ modes[result.mode] || result.mode }} · {{ result.elapsed_ms }} ms · {{ result.hits.length }} 条命中</p>
|
||||
<Empty v-if="!result.hits.length" :description="overview && !overview.counts.published ? '当前账号没有已发布知识,审核通过后还需发布才能检索。' : '没有可靠命中。请补充问题信息,或发布相应知识。'" />
|
||||
<p class="my-4">{{ modes[result.mode] || result.mode }} · {{ result.elapsed_ms }} ms · {{ t('{count} 条命中', { count: result.hits.length }) }}</p>
|
||||
<Empty v-if="!result.hits.length" :description="overview && !overview.counts.published ? t('当前账号没有已发布知识,审核通过后还需发布才能检索。') : t('没有可靠命中。请补充问题信息,或发布相应知识。')" />
|
||||
<Card v-for="hit in result.hits" :key="hit.id" class="mb-3" :title="hit.title">
|
||||
<template #extra><Button type="link" @click="inspect(hit.id)">查看来源</Button></template>
|
||||
<p class="whitespace-pre-wrap">{{ hit.answer }}</p><p class="text-gray-500">适用条件:{{ hit.conditions }}</p>
|
||||
<Tag>版本 {{ hit.revision }}</Tag><Tag>相关度 {{ hit.score }}</Tag>
|
||||
<template #extra><Button type="link" @click="inspect(hit.id)">{{ t('查看来源') }}</Button></template>
|
||||
<p class="whitespace-pre-wrap">{{ hit.answer }}</p><p class="text-gray-500">{{ t('适用条件:{conditions}', { conditions: hit.conditions }) }}</p>
|
||||
<Tag>{{ t('版本 {revision}', { revision: hit.revision }) }}</Tag><Tag>{{ t('相关度 {score}', { score: hit.score }) }}</Tag>
|
||||
</Card>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane v-if="canTrace" key="traces" tab="引用记录">
|
||||
<Table :data-source="traces" row-key="id" :pagination="{ pageSize: 15 }" :columns="[{ title: '时间', dataIndex: 'created_at' }, { title: '问题', dataIndex: 'query' }, { title: '知识来源', key: 'hits' }, { title: '检索方式', key: 'mode' }, { title: '耗时 ms', dataIndex: 'elapsed_ms' }]">
|
||||
<Tabs.TabPane v-if="canTrace" key="traces" :tab="t('引用记录')">
|
||||
<Table :data-source="traces" row-key="id" :pagination="{ pageSize: 15 }" :columns="[{ title: t('时间'), dataIndex: 'created_at' }, { title: t('问题'), dataIndex: 'query' }, { title: t('知识来源'), key: 'hits' }, { title: t('检索方式'), key: 'mode' }, { title: t('耗时 ms'), dataIndex: 'elapsed_ms' }]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'hits'"><span v-if="!record.hits.length">无命中</span><Button v-for="hit in record.hits" :key="hit.id" type="link" @click="inspect(hit.id)">{{ hit.id.slice(0, 8) }} · v{{ hit.revision }}</Button></template>
|
||||
<template v-if="column.key === 'hits'"><span v-if="!record.hits.length">{{ t('无命中') }}</span><Button v-for="hit in record.hits" :key="hit.id" type="link" @click="inspect(hit.id)">{{ hit.id.slice(0, 8) }} · v{{ hit.revision }}</Button></template>
|
||||
<span v-else-if="column.key === 'mode'">{{ modes[record.mode] || record.mode }}</span>
|
||||
</template>
|
||||
</Table>
|
||||
@@ -333,46 +320,46 @@ onDeactivated(stop); onUnmounted(stop);
|
||||
</Tabs>
|
||||
</Card>
|
||||
|
||||
<KnowledgeJobWizard v-model:open="createOpen" :account-id="accountId" :sources="overview?.source_accounts || []" @created="created" />
|
||||
<KnowledgeJobConfig v-model:open="configOpen" :account-id="accountId" :job="configJob" @finished="load" />
|
||||
<KnowledgeRunDialog v-model:open="createOpen" :account-id="accountId" :can-watch="canWatch" @created="created" />
|
||||
<KnowledgeSettingsDialog v-model:open="settingsOpen" />
|
||||
<KnowledgeReviewAll v-model:open="reviewOpen" :account-id="accountId" :initial-tab="reviewTab" @changed="load" @inspect="inspectReviewed" />
|
||||
<KnowledgeBatchDialog v-model:open="batchOpen" :account-id="accountId" :entries="batchEntries" :action="batchAction" @finished="batchFinished" @edit="(id) => { batchOpen = false; inspect(id); }" />
|
||||
|
||||
<Modal v-model:open="editorOpen" title="知识审核与来源" width="1100px" :footer="null" :destroy-on-close="true">
|
||||
<Modal v-model:open="editorOpen" :title="t('知识审核与来源')" width="1100px" :footer="null" :destroy-on-close="true">
|
||||
<template v-if="selected">
|
||||
<Space class="mb-4"><Tag :color="colors[selected.status]">{{ labels[selected.status] }}</Tag><Tag>版本 {{ selected.revision }}</Tag></Space>
|
||||
<Alert v-for="flag in selected.flags" :key="flag" :message="flag" type="warning" class="mb-2" />
|
||||
<Space class="mb-4"><Tag :color="colors[selected.status]">{{ labels[selected.status] }}</Tag><Tag>{{ t('版本 {revision}', { revision: selected.revision }) }}</Tag><Tag v-if="selected.account_label" color="blue">{{ t('账号:{account}', { account: t(selected.account_label) }) }}</Tag></Space>
|
||||
<Alert v-for="flag in selected.flags" :key="flag" :message="t(flag)" type="warning" class="mb-2" />
|
||||
<div class="mt-4 grid gap-6 md:grid-cols-2">
|
||||
<div><h3 class="mb-3 font-semibold">来源对话(已自动脱敏)</h3>
|
||||
<Empty v-if="!selected.sources?.length" description="暂无可查看的来源,请检查正文查看权限" />
|
||||
<div><h3 class="mb-3 font-semibold">{{ t('来源对话(已自动脱敏)') }}</h3>
|
||||
<Empty v-if="!selected.sources?.length" :description="t('暂无可查看的来源,请检查正文查看权限')" />
|
||||
<div v-for="source in selected.sources" :key="source.message_id" class="mb-3 rounded border p-3">
|
||||
<div class="mb-1 text-xs text-gray-500">{{ source.role === 'staff' ? '客服' : '客户' }} · {{ source.sent_at }} · v{{ source.version_no }}</div>
|
||||
<div class="mb-1 text-xs text-gray-500">{{ source.role === 'staff' ? t('客服') : t('客户') }} · {{ source.sent_at }} · v{{ source.version_no }}</div>
|
||||
<p class="m-0 whitespace-pre-wrap break-words">{{ source.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="标题"><Input v-model:value="selected.title" :maxlength="160" :disabled="!canWrite" /></Form.Item>
|
||||
<Space><Form.Item label="类型"><Select v-model:value="selected.kind" :options="kindOptions" :disabled="!canWrite" style="width: 140px" /></Form.Item><Form.Item label="分类"><Input v-model:value="selected.category" :disabled="!canWrite" /></Form.Item></Space>
|
||||
<Form.Item label="完整问题"><Input.TextArea v-model:value="selected.question" :rows="3" :maxlength="4000" :disabled="!canWrite" /></Form.Item>
|
||||
<Form.Item label="标准答案 / 步骤"><Input.TextArea v-model:value="selected.answer" :rows="7" :maxlength="8000" :disabled="!canWrite" /></Form.Item>
|
||||
<Form.Item label="适用条件(必填)"><Input.TextArea v-model:value="selected.conditions" :rows="3" :maxlength="2000" :disabled="!canWrite" /></Form.Item>
|
||||
<Form.Item label="有效期截止日期(可选)"><Input v-model:value="selected.valid_until" type="date" :disabled="!canWrite" /></Form.Item>
|
||||
<Form.Item :label="t('标题')"><Input v-model:value="selected.title" :maxlength="160" :disabled="!canEdit" /></Form.Item>
|
||||
<Space><Form.Item :label="t('类型')"><Select v-model:value="selected.kind" :options="kindOptions" :disabled="!canEdit" style="width: 140px" /></Form.Item><Form.Item :label="t('分类')"><Input v-model:value="selected.category" :disabled="!canEdit" /></Form.Item></Space>
|
||||
<Form.Item :label="t('完整问题')"><Input.TextArea v-model:value="selected.question" :rows="3" :maxlength="4000" :disabled="!canEdit" /></Form.Item>
|
||||
<Form.Item :label="t('标准答案 / 步骤')"><Input.TextArea v-model:value="selected.answer" :rows="7" :maxlength="8000" :disabled="!canEdit" /></Form.Item>
|
||||
<Form.Item :label="t('适用条件(必填)')"><Input.TextArea v-model:value="selected.conditions" :rows="3" :maxlength="2000" :disabled="!canEdit" /></Form.Item>
|
||||
<Form.Item :label="t('有效期截止日期(可选)')"><Input v-model:value="selected.valid_until" type="date" :disabled="!canEdit" /></Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<Alert v-if="dirty" class="my-3" type="info" message="修改尚未保存。请先保存草稿,再审核或发布。" />
|
||||
<Checkbox v-if="selected.status === 'draft' && canReview" v-model:checked="confirmed" class="my-4">已核对客服身份、事实、适用条件和脱敏;内容可作为通用经验。涉及医疗内容已完成专业审核。</Checkbox>
|
||||
<Alert v-if="dirty" class="my-3" type="info" :message="t('修改尚未保存。请先保存草稿,再审核或发布。')" />
|
||||
<Checkbox v-if="selected.status === 'draft' && canReview" v-model:checked="confirmed" class="my-4">{{ t('已核对客服身份、事实、适用条件和脱敏;内容可作为通用经验。涉及医疗内容已完成专业审核。') }}</Checkbox>
|
||||
<Space wrap>
|
||||
<Button v-if="canWrite" :loading="busy" :disabled="selected.status === 'stale'" @click="save">保存草稿</Button>
|
||||
<Button v-if="canReview && selected.status === 'draft'" type="primary" :disabled="!confirmed || busy || dirty || !selected.conditions.trim()" @click="act('approve')">审核通过</Button>
|
||||
<Button v-if="canReview && ['draft', 'approved'].includes(selected.status)" danger :disabled="busy" @click="act('reject')">驳回</Button>
|
||||
<Button v-if="canPublish && selected.status === 'approved'" type="primary" :loading="busy" :disabled="dirty" @click="act('publish')">发布到知识库</Button>
|
||||
<Button v-if="canWrite && selected.status === 'rejected'" danger :disabled="busy" @click="deleteSelected">删除已驳回知识</Button>
|
||||
<Button v-if="canPublish && selected.status === 'published'" danger :disabled="busy" @click="act('disable')">停用</Button>
|
||||
<Button v-if="canEdit" :loading="busy" :disabled="selected.status === 'stale'" @click="save">{{ t('保存草稿') }}</Button>
|
||||
<Button v-if="canReview && selected.status === 'draft'" type="primary" :disabled="!confirmed || busy || dirty || !selected.conditions.trim()" @click="act('approve')">{{ t('审核通过') }}</Button>
|
||||
<Button v-if="canReject && ['draft', 'approved'].includes(selected.status)" danger :disabled="busy" @click="act('reject')">{{ t('驳回') }}</Button>
|
||||
<Button v-if="canPublish && selected.status === 'approved'" type="primary" :loading="busy" :disabled="dirty" @click="act('publish')">{{ t('发布到知识库') }}</Button>
|
||||
<Button v-if="canDelete && selected.status === 'rejected'" danger :disabled="busy" @click="deleteSelected">{{ t('删除已驳回知识') }}</Button>
|
||||
<Button v-if="canDisable && selected.status === 'published'" danger :disabled="busy" @click="act('disable')">{{ t('停用') }}</Button>
|
||||
</Space>
|
||||
<div class="mt-5 border-t pt-3"><h3 class="font-semibold">版本与操作记录</h3>
|
||||
<div class="mt-5 border-t pt-3"><h3 class="font-semibold">{{ t('版本与操作记录') }}</h3>
|
||||
<div v-for="(history, index) in selected.history" :key="index" class="flex items-center justify-between py-1 text-sm">
|
||||
<span>v{{ history.revision }} · {{ history.action }} · {{ history.created_at }}</span>
|
||||
<Button v-if="canWrite && history.revision < selected.revision" type="link" :disabled="busy" @click="act('restore', history.revision)">恢复为草稿</Button>
|
||||
<Button v-if="canRestore && history.revision < selected.revision" type="link" :disabled="busy" @click="act('restore', history.revision)">{{ t('恢复为草稿') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeJob } from '#/api/knowledge';
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { Alert, Form, message, Modal } from 'ant-design-vue';
|
||||
import { configureKnowledgeJob, knowledgeJobAction } from '#/api/knowledge';
|
||||
import KnowledgeModelFields from './model-fields.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; accountId: number; job: KnowledgeJob | null }>();
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; finished: [] }>();
|
||||
const saving = ref(false);
|
||||
const form = reactive({ engine: 'model', model_provider_id: '', model_timeout_seconds: 90, max_model_calls: 100 });
|
||||
watch(() => props.open, (open) => {
|
||||
if (!open || !props.job) return;
|
||||
Object.assign(form, { engine: props.job.options.engine, model_provider_id: props.job.options.model_provider_id || '',
|
||||
model_timeout_seconds: props.job.options.model_timeout_seconds || 90, max_model_calls: props.job.options.max_model_calls || 100 });
|
||||
});
|
||||
async function submit() {
|
||||
if (saving.value || !props.job) return;
|
||||
const account = props.accountId; const job = props.job.id;
|
||||
saving.value = true;
|
||||
try {
|
||||
await configureKnowledgeJob(account, job, { ...form });
|
||||
await knowledgeJobAction(account, job, 'resume');
|
||||
message.success('配置已保存,任务将从断点继续'); emit('update:open', false);
|
||||
} finally { saving.value = false; emit('finished'); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :destroy-on-close="true" :open="open" title="调整配置并继续任务" ok-text="保存并继续" :confirm-loading="saving" :closable="!saving" :keyboard="!saving" :mask-closable="false" :cancel-button-props="{ disabled: saving }" :ok-button-props="{ disabled: form.engine === 'model' && (!form.model_provider_id || form.max_model_calls <= (job?.model_calls || 0)) }" @ok="submit" @cancel="emit('update:open', false)">
|
||||
<Alert class="mb-4" type="info" show-icon :message="`已生成 ${job?.created_items || 0} 条草稿,已调用模型 ${job?.model_calls || 0} 次。继续任务会保留已完成结果;新配置用于剩余内容。`" />
|
||||
<Alert v-if="job?.error" class="mb-4" type="warning" :message="job.error" />
|
||||
<Form layout="vertical">
|
||||
<KnowledgeModelFields v-model:engine="form.engine" v-model:provider="form.model_provider_id" v-model:timeout="form.model_timeout_seconds" v-model:calls="form.max_model_calls" :account-id="accountId" :disabled="saving" />
|
||||
</Form>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeOverview, KnowledgePreview } from '#/api/knowledge';
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { Alert, Button, Checkbox, Form, Input, InputNumber, message, Modal, Select, Space, Steps } from 'ant-design-vue';
|
||||
import { createKnowledgeJob, previewKnowledgeJob } from '#/api/knowledge';
|
||||
import KnowledgeModelFields from './model-fields.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean; accountId: number; sources: KnowledgeOverview['source_accounts'] }>();
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; created: [] }>();
|
||||
const form = reactive({ source_account_id: '', conversation_id: '', date_from: '', date_to: '', engine: 'rules', model_provider_id: '', model_timeout_seconds: 90, max_messages: 10000, max_model_calls: 100, staff_confirmed: false, auto_watch: false });
|
||||
const preview = ref<KnowledgePreview | null>(null);
|
||||
const checking = ref(false);
|
||||
const saving = ref(false);
|
||||
let generation = 0;
|
||||
const signature = computed(() => JSON.stringify([props.accountId, form.source_account_id, form.conversation_id, form.date_from, form.date_to, form.engine, form.model_provider_id, form.model_timeout_seconds, form.max_messages, form.max_model_calls]));
|
||||
const options = computed(() => props.sources.map((source) => ({ value: source.id, label: source.display_name || source.external_account_id })));
|
||||
watch(signature, () => { generation++; checking.value = false; preview.value = null; form.staff_confirmed = false; });
|
||||
watch(() => props.open, (open) => {
|
||||
generation++;
|
||||
preview.value = null;
|
||||
checking.value = false;
|
||||
if (open) Object.assign(form, { source_account_id: props.sources.length === 1 ? props.sources[0]!.id : '', conversation_id: '', date_from: '', date_to: '', engine: 'rules', model_provider_id: '', model_timeout_seconds: 90, max_messages: 10000, max_model_calls: 100, staff_confirmed: false, auto_watch: false });
|
||||
});
|
||||
async function inspect() {
|
||||
const current = ++generation;
|
||||
checking.value = true;
|
||||
try {
|
||||
const data = await previewKnowledgeJob(props.accountId, { ...form });
|
||||
if (current === generation && props.open) preview.value = data;
|
||||
} finally { if (current === generation) checking.value = false; }
|
||||
}
|
||||
async function submit() {
|
||||
if (saving.value || !preview.value || !form.staff_confirmed) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await createKnowledgeJob(props.accountId, { ...form });
|
||||
emit('update:open', false); emit('created'); message.success('整理任务已创建,可在加工任务中查看进度');
|
||||
} finally { saving.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :destroy-on-close="true" :open="open" title="从聊天整理知识" width="720px" :closable="!saving" :keyboard="!saving" :mask-closable="false" @cancel="emit('update:open', false)">
|
||||
<Steps class="mb-6" size="small" :current="preview ? 1 : 0" :items="[{ title: '选择整理范围' }, { title: '预览并确认' }, { title: '后台分批处理' }]" />
|
||||
<Alert v-if="!sources.length" class="mb-4" type="warning" show-icon message="此账号还没有归档来源。请先上传聊天归档,或返回选择有数据的账号。" />
|
||||
<Form layout="vertical" :disabled="saving">
|
||||
<Form.Item label="客服来源账号"><Select v-model:value="form.source_account_id" :options="options" placeholder="请选择实际使用的客服账号" /></Form.Item>
|
||||
<Space wrap><Form.Item label="开始日期(北京时间)"><Input v-model:value="form.date_from" type="date" /></Form.Item><Form.Item label="结束日期(包含当天)"><Input v-model:value="form.date_to" type="date" /></Form.Item><Form.Item label="最多读取消息"><InputNumber v-model:value="form.max_messages" :min="1" :max="1000000" /></Form.Item></Space>
|
||||
<KnowledgeModelFields v-model:engine="form.engine" v-model:provider="form.model_provider_id" v-model:timeout="form.model_timeout_seconds" v-model:calls="form.max_model_calls" :account-id="accountId" :disabled="saving" />
|
||||
</Form>
|
||||
<template v-if="preview">
|
||||
<div class="mb-4 grid grid-cols-2 gap-3 rounded-lg border p-4">
|
||||
<div>范围内单聊消息 <strong>{{ preview.available_messages.toLocaleString() }}</strong> 条</div>
|
||||
<div>涉及会话 <strong>{{ preview.conversations.toLocaleString() }}</strong> 个</div>
|
||||
<div>身份明确的有效文本 <strong>{{ preview.eligible_text_messages.toLocaleString() }}</strong> 条</div>
|
||||
<div>本次最多读取 <strong>{{ preview.total_messages.toLocaleString() }}</strong> 条</div>
|
||||
</div>
|
||||
<Alert class="mb-3" :type="preview.total_messages ? 'info' : 'warning'" show-icon :message="preview.total_messages ? '消息条数不等于知识条数。系统会过滤闲聊、媒体和不完整问答,结果均为待审草稿。' : '该范围没有可读取的单聊消息,请更换来源或日期。'" />
|
||||
<Alert v-if="preview.limited" class="mb-3" type="warning" message="本次设置了消息上限;达到上限会停止,末尾不完整问答不会作为知识。任务中可查看停止原因。" />
|
||||
<Checkbox v-model:checked="form.auto_watch" :disabled="saving" class="mb-3">持续整理此来源新增或修改的会话(静默 30 分钟后生成规则草稿,仍需审核)</Checkbox>
|
||||
<Checkbox v-model:checked="form.staff_confirmed" :disabled="saving">确认来源账号由客服使用,所选聊天允许用于知识整理。</Checkbox>
|
||||
</template>
|
||||
<template #footer>
|
||||
<Space><Button :disabled="saving" @click="emit('update:open', false)">取消</Button>
|
||||
<Button :loading="checking" :disabled="saving || !form.source_account_id" @click="inspect">{{ preview ? '重新预览范围' : '预览整理范围' }}</Button>
|
||||
<Button type="primary" :loading="saving" :disabled="!preview?.total_messages || !form.staff_confirmed || checking || (form.engine === 'model' && !form.model_provider_id)" @click="submit">确认创建任务</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeModel } from '#/api/knowledge';
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import { Alert, Form, InputNumber, Select, Space } from 'ant-design-vue';
|
||||
import { fetchKnowledgeModels } from '#/api/knowledge';
|
||||
|
||||
const props = defineProps<{ accountId: number; disabled?: boolean }>();
|
||||
const engine = defineModel<string>('engine', { required: true });
|
||||
const provider = defineModel<string>('provider', { required: true });
|
||||
const timeout = defineModel<number>('timeout', { required: true });
|
||||
const calls = defineModel<number>('calls', { required: true });
|
||||
const models = ref<KnowledgeModel[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
let generation = 0;
|
||||
onBeforeUnmount(() => { generation++; });
|
||||
const options = computed(() => models.value.map((model) => ({ value: model.id, label: `${model.name} · ${model.kind}${model.default ? '(默认)' : ''}` })));
|
||||
watch(() => [props.accountId, engine.value], async () => {
|
||||
const current = ++generation;
|
||||
if (engine.value !== 'model') return;
|
||||
loading.value = true; error.value = ''; models.value = [];
|
||||
try {
|
||||
const result = await fetchKnowledgeModels(props.accountId);
|
||||
if (current !== generation) return;
|
||||
models.value = result.items;
|
||||
if (!provider.value) provider.value = result.items.find((item) => item.default)?.id || result.items[0]?.id || '';
|
||||
} catch { if (current === generation) error.value = '模型清单加载失败,请重新打开后重试'; }
|
||||
finally { if (current === generation) loading.value = false; }
|
||||
}, { immediate: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form.Item label="整理方式"><Select v-model:value="engine" :disabled="disabled" :options="[{ value: 'rules', label: '规则整理:保留原话生成待审草稿' }, { value: 'model', label: '模型整理:脱敏后调用指定模型' }]" /></Form.Item>
|
||||
<template v-if="engine === 'model'">
|
||||
<Form.Item label="整理模型"><Select v-model:value="provider" :disabled="disabled" :loading="loading" :options="options" placeholder="选择已启用的文本模型" /></Form.Item>
|
||||
<Alert v-if="!loading && (error || !models.length || !models.some((item) => item.id === provider))" class="mb-3" type="warning" show-icon :message="error || (!models.length ? '暂无可用文本模型,请先在模型管理中配置并启用。' : '原模型已不可用,请重新选择。')" />
|
||||
<Space wrap>
|
||||
<Form.Item label="单次等待时间(秒)"><InputNumber v-model:value="timeout" :disabled="disabled" :min="30" :max="180" /></Form.Item>
|
||||
<Form.Item label="模型调用总上限"><InputNumber v-model:value="calls" :disabled="disabled" :min="1" :max="10000" /></Form.Item>
|
||||
</Space>
|
||||
<p class="mb-3 text-gray-500">每条知识保存进度后再处理下一条。失败后可调整模型继续;调用上限包含失败及重试次数。</p>
|
||||
</template>
|
||||
</template>
|
||||
@@ -4,13 +4,18 @@ import { computed, onBeforeUnmount, onDeactivated, ref, watch } from 'vue';
|
||||
import { Alert, Button, Checkbox, Modal, Progress, Select, Space, Table, Tabs, Tag, message } from 'ant-design-vue';
|
||||
import { fetchDesktopAccounts } from '#/api/console';
|
||||
import { fetchKnowledgeReviewJobs, fetchKnowledgeReviewResults, knowledgeReviewAction, previewKnowledgeReview } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
import { failureText } from './batch';
|
||||
|
||||
const props = defineProps<{ open: boolean; accountId: number; initialTab: string }>();
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean]; changed: []; inspect: [itemId: string, accountId: number] }>();
|
||||
const tab = ref('new');
|
||||
const scopeId = ref(-1);
|
||||
const accounts = ref<{ value: number; label: string }[]>([]);
|
||||
const accountList = ref<Awaited<ReturnType<typeof fetchDesktopAccounts>> | null>(null);
|
||||
// 下拉选项随语言切换:存原始数据,选项用 computed 生成
|
||||
const accounts = computed(() => !accountList.value ? [] : [
|
||||
{ value: -1, label: t('全部授权账号') }, ...(accountList.value.can_view_legacy ? [{ value: 0, label: t('旧版未归属数据') }] : []),
|
||||
...accountList.value.accounts.map((item) => ({ value: item.id, label: t('{name}({code})', { name: item.nickname || item.zyt_sn, code: item.zyt_sn }) }))]);
|
||||
const preview = ref<KnowledgeReviewJob | null>(null);
|
||||
const confirmed = ref(false);
|
||||
const busy = ref(false);
|
||||
@@ -22,7 +27,7 @@ const resultPage = ref(1);
|
||||
const resultTotal = ref(0);
|
||||
const resultType = ref('skipped');
|
||||
const selectedJob = computed(() => jobs.value.find((job) => job.id === selectedId.value));
|
||||
const labels: Record<string, string> = { queued: '等待审核', running: '审核中', paused: '已暂停', failed: '需处理', completed: '已完成', cancelled: '已取消' };
|
||||
const labels = computed<Record<string, string>>(() => ({ queued: t('等待审核'), running: t('审核中'), paused: t('已暂停'), failed: t('需处理'), completed: t('已完成'), cancelled: t('已取消') }));
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let generation = 0;
|
||||
let resultGeneration = 0;
|
||||
@@ -38,8 +43,7 @@ watch(() => props.open, async (open) => {
|
||||
try {
|
||||
const result = await fetchDesktopAccounts(false);
|
||||
if (current !== generation) return;
|
||||
accounts.value = [{ value: -1, label: '全部授权账号' }, ...(result.can_view_legacy ? [{ value: 0, label: '旧版未归属数据' }] : []),
|
||||
...result.accounts.map((item) => ({ value: item.id, label: `${item.nickname || item.zyt_sn}(${item.zyt_sn})` }))];
|
||||
accountList.value = result;
|
||||
await refresh();
|
||||
} catch (cause) { if (current === generation) error.value = failureText(cause); }
|
||||
finally { if (current === generation) busy.value = false; }
|
||||
@@ -75,7 +79,7 @@ async function start() {
|
||||
try {
|
||||
const job = await knowledgeReviewAction(preview.value.id, 'start', true);
|
||||
selectedId.value = job.id; resultPage.value = 1; tab.value = 'jobs'; preview.value = null; confirmed.value = false;
|
||||
message.success('全量审核任务已提交,关闭页面后仍会继续'); await refresh();
|
||||
message.success(t('全量审核任务已提交,关闭页面后仍会继续')); await refresh();
|
||||
} catch (cause) { error.value = failureText(cause); }
|
||||
finally { busy.value = false; }
|
||||
}
|
||||
@@ -87,8 +91,8 @@ async function act(jobId: string, action: string) {
|
||||
finally { busy.value = false; }
|
||||
}
|
||||
function cancel(jobId: string) {
|
||||
Modal.confirm({ title: '停止剩余审核?', content: '已通过的知识保留在待发布,尚未处理的草稿保持原状态。',
|
||||
okText: '停止剩余审核', cancelText: '返回', onOk: () => act(jobId, 'cancel') });
|
||||
Modal.confirm({ title: t('停止剩余审核?'), content: t('已通过的知识保留在待发布,尚未处理的草稿保持原状态。'),
|
||||
okText: t('停止剩余审核'), cancelText: t('返回'), onOk: () => act(jobId, 'cancel') });
|
||||
}
|
||||
async function loadResults() {
|
||||
if (!selectedId.value) return;
|
||||
@@ -104,49 +108,57 @@ function close() { emit('update:open', false); emit('changed'); }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" title="全量审核" width="1050px" :footer="null" :closable="!busy" :keyboard="!busy" :mask-closable="false" @cancel="close">
|
||||
<Modal :open="open" :title="t('全量审核')" width="1050px" :footer="null" :closable="!busy" :keyboard="!busy" :mask-closable="false" @cancel="close">
|
||||
<Alert v-if="error" class="mb-3" type="error" show-icon :message="error" />
|
||||
<Tabs v-model:active-key="tab">
|
||||
<Tabs.TabPane key="new" tab="指定范围审核">
|
||||
<Alert class="mb-4" type="info" show-icon message="审核范围是已整理出的待审核知识草稿,覆盖全部页面,不受列表搜索与勾选影响。通过后进入待发布,原始聊天归档不会被修改。" />
|
||||
<Space class="mb-4" wrap><span>审核账号</span><Select v-model:value="scopeId" :disabled="busy" :options="accounts" style="min-width: 320px" /><Button :loading="busy" @click="inspectScope">预览全部待审草稿</Button></Space>
|
||||
<Tabs.TabPane key="new" :tab="t('指定范围审核')">
|
||||
<Alert class="mb-4" type="info" show-icon :message="t('审核范围是已整理出的待审核知识草稿,覆盖全部页面,不受列表搜索与勾选影响。通过后进入待发布,原始聊天归档不会被修改。')" />
|
||||
<Space class="mb-4" wrap><span>{{ t('审核账号') }}</span><Select v-model:value="scopeId" :disabled="busy" :options="accounts" style="min-width: 320px" /><Button :loading="busy" @click="inspectScope">{{ t('预览全部待审草稿') }}</Button></Space>
|
||||
<template v-if="preview">
|
||||
<p>范围:<strong>{{ preview.scope_label }}</strong> · 快照时间 {{ preview.preview.snapshot_at }}</p>
|
||||
<p>{{ t('范围:') }}<strong>{{ t(preview.scope_label) }}</strong> · {{ t('快照时间 {time}', { time: preview.preview.snapshot_at }) }}</p>
|
||||
<div class="mb-4 grid grid-cols-3 gap-3 rounded border p-4">
|
||||
<div>待审核 <strong>{{ preview.total.toLocaleString() }}</strong> 条</div>
|
||||
<div>可通过 <strong>{{ preview.preview.eligible.toLocaleString() }}</strong> 条</div>
|
||||
<div>需处理 <strong>{{ preview.preview.blocked.toLocaleString() }}</strong> 条</div>
|
||||
<div>{{ t('待审核') }} <strong>{{ preview.total.toLocaleString() }}</strong> {{ t('条') }}</div>
|
||||
<div>{{ t('可通过') }} <strong>{{ preview.preview.eligible.toLocaleString() }}</strong> {{ t('条') }}</div>
|
||||
<div>{{ t('需处理') }} <strong>{{ preview.preview.blocked.toLocaleString() }}</strong> {{ t('条') }}</div>
|
||||
</div>
|
||||
<Table size="small" :data-source="preview.preview.accounts" row-key="tenant_id" :pagination="{ pageSize: 5, showSizeChanger: false }" :columns="[{ title: '账号', dataIndex: 'name' }, { title: '待审核', dataIndex: 'total' }, { title: '可通过', dataIndex: 'eligible' }]" />
|
||||
<Alert v-for="reason in preview.preview.reasons" :key="reason.code" class="mb-2" type="warning" :message="`${reason.reason}:${reason.count} 条`" />
|
||||
<p class="my-3 text-gray-500">预览固定当前草稿与版本,30 分钟内可提交。执行时会再次校验来源、版本和权限;不合格条目保持原状态并列出原因。</p>
|
||||
<Checkbox v-model:checked="confirmed" :disabled="busy || !preview.preview.eligible">确认所选范围已完成必要的事实、脱敏和适用性审核;涉及医疗内容已完成专业审核。批量通过符合校验条件的草稿,之后另行发布。</Checkbox>
|
||||
<div class="mt-4"><Button type="primary" :loading="busy" :disabled="!confirmed || !preview.preview.eligible" @click="start">一键审核 {{ preview.preview.eligible.toLocaleString() }} 条并进入待发布</Button></div>
|
||||
<Table size="small" :data-source="preview.preview.accounts" row-key="tenant_id" :pagination="{ pageSize: 5, showSizeChanger: false }" :columns="[{ title: t('账号'), dataIndex: 'name', key: 'name' }, { title: t('待审核'), dataIndex: 'total' }, { title: t('可通过'), dataIndex: 'eligible' }]">
|
||||
<template #bodyCell="{ column, record }"><template v-if="column.key === 'name'">{{ t(record.name) }}</template></template>
|
||||
</Table>
|
||||
<Alert v-for="reason in preview.preview.reasons" :key="reason.code" class="mb-2" type="warning" :message="t('{reason}:{count} 条', { reason: t(reason.reason), count: reason.count })" />
|
||||
<p class="my-3 text-gray-500">{{ t('预览固定当前草稿与版本,30 分钟内可提交。执行时会再次校验来源、版本和权限;不合格条目保持原状态并列出原因。') }}</p>
|
||||
<Checkbox v-model:checked="confirmed" :disabled="busy || !preview.preview.eligible">{{ t('确认所选范围已完成必要的事实、脱敏和适用性审核;涉及医疗内容已完成专业审核。批量通过符合校验条件的草稿,之后另行发布。') }}</Checkbox>
|
||||
<div class="mt-4"><Button type="primary" :loading="busy" :disabled="!confirmed || !preview.preview.eligible" @click="start">{{ t('一键审核 {count} 条并进入待发布', { count: preview.preview.eligible.toLocaleString() }) }}</Button></div>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
<Tabs.TabPane key="jobs" tab="我的审核任务">
|
||||
<Space class="mb-3"><span>后台分批执行,关闭页面不影响进度。</span><Button :disabled="busy" @click="refresh">刷新任务</Button></Space>
|
||||
<Table size="small" :data-source="jobs" row-key="id" :pagination="{ pageSize: 5, showSizeChanger: false }" :columns="[{ title: '账号范围', dataIndex: 'scope_label' }, { title: '状态 / 进度', key: 'progress' }, { title: '审核结果', key: 'result' }, { title: '操作', key: 'actions' }]">
|
||||
<Tabs.TabPane key="jobs" :tab="t('我的审核任务')">
|
||||
<Space class="mb-3"><span>{{ t('后台分批执行,关闭页面不影响进度。') }}</span><Button :disabled="busy" @click="refresh">{{ t('刷新任务') }}</Button></Space>
|
||||
<Table size="small" :data-source="jobs" row-key="id" :pagination="{ pageSize: 5, showSizeChanger: false }" :columns="[{ title: t('账号范围'), dataIndex: 'scope_label', key: 'scope' }, { title: t('状态 / 进度'), key: 'progress' }, { title: t('审核结果'), key: 'result' }, { title: t('操作'), key: 'actions' }]">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'progress'"><Tag>{{ labels[record.status] }}</Tag><div>{{ record.processed }} / {{ record.total }} 条</div><Progress :percent="record.progress_percent" size="small" /><div class="text-red-500">{{ record.error }}</div></template>
|
||||
<template v-else-if="column.key === 'result'">通过 {{ record.approved }} · 未通过 {{ record.skipped }}</template>
|
||||
<template v-if="column.key === 'scope'">{{ t(record.scope_label) }}</template>
|
||||
<template v-else-if="column.key === 'progress'"><Tag>{{ labels[record.status] }}</Tag><div>{{ t('{processed} / {total} 条', { processed: record.processed, total: record.total }) }}</div><Progress :percent="record.progress_percent" size="small" /><div class="text-red-500">{{ record.error }}</div></template>
|
||||
<template v-else-if="column.key === 'result'">{{ t('通过 {approved} · 未通过 {skipped}', { approved: record.approved, skipped: record.skipped }) }}</template>
|
||||
<Space v-else-if="column.key === 'actions'" wrap>
|
||||
<Button size="small" @click="showResults(record.id)">查看结果</Button>
|
||||
<Button v-if="['queued', 'running'].includes(record.status)" size="small" :disabled="busy" @click="act(record.id, 'pause')">暂停</Button>
|
||||
<Button v-if="['paused', 'failed'].includes(record.status)" size="small" :disabled="busy" @click="act(record.id, 'resume')">继续</Button>
|
||||
<Button v-if="['queued', 'running', 'paused', 'failed'].includes(record.status)" size="small" danger :disabled="busy" @click="cancel(record.id)">停止</Button>
|
||||
<Button size="small" @click="showResults(record.id)">{{ t('查看结果') }}</Button>
|
||||
<Button v-if="['queued', 'running'].includes(record.status)" size="small" :disabled="busy" @click="act(record.id, 'pause')">{{ t('暂停') }}</Button>
|
||||
<Button v-if="['paused', 'failed'].includes(record.status)" size="small" :disabled="busy" @click="act(record.id, 'resume')">{{ t('继续') }}</Button>
|
||||
<Button v-if="['queued', 'running', 'paused', 'failed'].includes(record.status)" size="small" danger :disabled="busy" @click="cancel(record.id)">{{ t('停止') }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Table>
|
||||
<template v-if="selectedId">
|
||||
<Space class="my-4"><strong>结果清单 · {{ selectedJob?.scope_label }}</strong><Select v-model:value="resultType" style="width: 140px" :options="[{ value: 'skipped', label: '未通过' }, { value: 'approved', label: '已通过' }, { value: 'pending', label: '未处理' }]" @change="() => { resultPage = 1; loadResults(); }" /></Space>
|
||||
<p class="mb-2 text-gray-500">结果清单仅显示当前仍有权限的账号。</p>
|
||||
<Table size="small" :data-source="rows" row-key="item_id" :pagination="{ current: resultPage, pageSize: 30, total: resultTotal, showSizeChanger: false }" :columns="[{ title: '账号', dataIndex: 'account_name' }, { title: '知识标题', dataIndex: 'title' }, { title: '原因', dataIndex: 'reason_text' }, { title: '操作', key: 'open' }]" @change="(p) => { resultPage = p.current || 1; loadResults(); }">
|
||||
<template #bodyCell="{ column, record }"><Button v-if="column.key === 'open' && record.reason !== 'deleted'" type="link" @click="emit('inspect', record.item_id, record.account_id)">查看知识</Button></template>
|
||||
<Space class="my-4"><strong>{{ t('结果清单 · {scope}', { scope: t(selectedJob?.scope_label || '') }) }}</strong><Select v-model:value="resultType" style="width: 140px" :options="[{ value: 'skipped', label: t('未通过') }, { value: 'approved', label: t('已通过') }, { value: 'pending', label: t('未处理') }]" @change="() => { resultPage = 1; loadResults(); }" /></Space>
|
||||
<p class="mb-2 text-gray-500">{{ t('结果清单仅显示当前仍有权限的账号。') }}</p>
|
||||
<Table size="small" :data-source="rows" row-key="item_id" :pagination="{ current: resultPage, pageSize: 30, total: resultTotal, showSizeChanger: false }" :columns="[{ title: t('账号'), dataIndex: 'account_name', key: 'account' }, { title: t('知识标题'), dataIndex: 'title', key: 'title' }, { title: t('原因'), dataIndex: 'reason_text', key: 'reason' }, { title: t('操作'), key: 'open' }]" @change="(p) => { resultPage = p.current || 1; loadResults(); }">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'account'">{{ t(record.account_name) }}</template>
|
||||
<template v-else-if="column.key === 'title'">{{ t(record.title) }}</template>
|
||||
<template v-else-if="column.key === 'reason'">{{ t(record.reason_text) }}</template>
|
||||
<Button v-else-if="column.key === 'open' && record.reason !== 'deleted'" type="link" @click="emit('inspect', record.item_id, record.account_id)">{{ t('查看知识') }}</Button>
|
||||
</template>
|
||||
</Table>
|
||||
</template>
|
||||
</Tabs.TabPane>
|
||||
</Tabs>
|
||||
<div class="mt-4 text-right"><Button :disabled="busy" @click="close">关闭</Button></div>
|
||||
<div class="mt-4 text-right"><Button :disabled="busy" @click="close">{{ t('关闭') }}</Button></div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import type { DesktopAccountItem } from '#/api/console';
|
||||
import type { KnowledgeRunPreview, KnowledgeWashOptions } from '#/api/knowledge';
|
||||
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
|
||||
import { Alert, Button, Checkbox, Form, Input, message, Modal, Radio, Select, Space, Spin, Table } from 'ant-design-vue';
|
||||
|
||||
import { ALL_ACCOUNTS, fetchDesktopAccounts } from '#/api/console';
|
||||
import { createKnowledgeRun, fetchKnowledgeConfig, previewKnowledgeRun } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { scopeLabel, washFromConfig } from './run-format';
|
||||
import WashOptions from './wash-options.vue';
|
||||
|
||||
/**
|
||||
* 整理聊天记录:一张表单。默认整理全部授权账号的全部聊天,选好模型直接开始。
|
||||
* 范围一改就自动重新统计;不用先选账号、选来源、预览、勾确认框。
|
||||
*/
|
||||
const props = defineProps<{ accountId: number; canWatch?: boolean; open: boolean }>();
|
||||
const emit = defineEmits<{ created: []; 'update:open': [value: boolean] }>();
|
||||
|
||||
const scopeMode = ref<'all' | 'pick'>('all');
|
||||
const accountIds = ref<number[]>([]);
|
||||
const dateFrom = ref('');
|
||||
const dateTo = ref('');
|
||||
const autoWatch = ref(false);
|
||||
const wash = ref<KnowledgeWashOptions>(washFromConfig(null));
|
||||
const ready = ref(false);
|
||||
const accounts = ref<DesktopAccountItem[]>([]);
|
||||
const canViewLegacy = ref(false);
|
||||
const preview = ref<KnowledgeRunPreview | null>(null);
|
||||
const previewing = ref(false);
|
||||
const saving = ref(false);
|
||||
let generation = 0;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const accountOptions = computed(() => [
|
||||
...(canViewLegacy.value ? [{ value: 0, label: t('旧版未归属数据') }] : []),
|
||||
...accounts.value.map((item) => ({ value: item.id, label: t('{name}({code})', { name: item.nickname || item.zyt_sn, code: item.zyt_sn }) })),
|
||||
]);
|
||||
const scope = computed(() => ({
|
||||
account_ids: scopeMode.value === 'all' ? [] : accountIds.value,
|
||||
date_from: dateFrom.value,
|
||||
date_to: dateTo.value,
|
||||
}));
|
||||
const blocked = computed(() => {
|
||||
if (scopeMode.value === 'pick' && !accountIds.value.length) return t('请选择要整理的账号');
|
||||
if (dateFrom.value && dateTo.value && dateFrom.value > dateTo.value) return t('结束日期不能早于开始日期');
|
||||
if (!preview.value) return t('正在统计范围…');
|
||||
if (!preview.value.totals.messages) return t('所选范围内没有单聊消息');
|
||||
if (wash.value.engine === 'model' && !wash.value.model_provider_id) return t('请选择整理模型');
|
||||
return '';
|
||||
});
|
||||
|
||||
watch(() => props.open, async (open) => {
|
||||
generation++;
|
||||
if (!open) return;
|
||||
ready.value = false;
|
||||
preview.value = null;
|
||||
dateFrom.value = '';
|
||||
dateTo.value = '';
|
||||
autoWatch.value = false;
|
||||
scopeMode.value = props.accountId === ALL_ACCOUNTS ? 'all' : 'pick';
|
||||
accountIds.value = props.accountId === ALL_ACCOUNTS ? [] : [props.accountId];
|
||||
const [config, list] = await Promise.all([fetchKnowledgeConfig().catch(() => null), fetchDesktopAccounts(false)]);
|
||||
wash.value = washFromConfig(config);
|
||||
accounts.value = list.accounts;
|
||||
canViewLegacy.value = list.can_view_legacy;
|
||||
ready.value = true;
|
||||
refresh();
|
||||
});
|
||||
watch(scope, () => { if (props.open && ready.value) refresh(); }, { deep: true });
|
||||
onBeforeUnmount(() => { generation++; if (timer) clearTimeout(timer); });
|
||||
|
||||
function refresh() {
|
||||
if (timer) clearTimeout(timer);
|
||||
const current = ++generation;
|
||||
preview.value = null;
|
||||
if (scopeMode.value === 'pick' && !accountIds.value.length) return;
|
||||
if (dateFrom.value && dateTo.value && dateFrom.value > dateTo.value) return;
|
||||
timer = setTimeout(async () => {
|
||||
previewing.value = true;
|
||||
try {
|
||||
const data = await previewKnowledgeRun(scope.value);
|
||||
if (current === generation) preview.value = data;
|
||||
} finally {
|
||||
if (current === generation) previewing.value = false;
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (blocked.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const run = await createKnowledgeRun({ ...scope.value, ...wash.value, auto_watch: !!props.canWatch && autoWatch.value, staff_confirmed: true });
|
||||
message.success(t('已开始整理:{count} 个账号,在「整理任务」里看进度', { count: run.accounts.length }));
|
||||
emit('update:open', false);
|
||||
emit('created');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" :title="t('整理聊天记录')" width="780px" :destroy-on-close="true" :mask-closable="false"
|
||||
:closable="!saving" :keyboard="!saving" @cancel="emit('update:open', false)">
|
||||
<Spin :spinning="!ready">
|
||||
<Form layout="vertical" :disabled="saving" class="pt-1">
|
||||
<Form.Item :label="t('整理哪些账号')">
|
||||
<Radio.Group v-model:value="scopeMode">
|
||||
<Radio value="all">{{ t('全部账号') }}</Radio>
|
||||
<Radio value="pick">{{ t('指定账号') }}</Radio>
|
||||
</Radio.Group>
|
||||
<Select v-if="scopeMode === 'pick'" v-model:value="accountIds" class="mt-2 w-full" mode="multiple"
|
||||
:options="accountOptions" :placeholder="t('选择一个或多个客户端账号')" option-filter-prop="label" />
|
||||
</Form.Item>
|
||||
<Form.Item :label="t('时间范围(留空 = 全部聊天)')">
|
||||
<Space wrap>
|
||||
<Input v-model:value="dateFrom" type="date" />
|
||||
<span>{{ t('至') }}</span>
|
||||
<Input v-model:value="dateTo" type="date" />
|
||||
<Button v-if="dateFrom || dateTo" type="link" @click="() => { dateFrom = ''; dateTo = ''; }">{{ t('清空') }}</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<WashOptions v-if="ready" v-model="wash" :disabled="saving" />
|
||||
<Checkbox v-if="canWatch" v-model:checked="autoWatch" class="mb-2">{{ t('整理完以后,这些账号的新聊天也自动整理(对话静默 30 分钟后)') }}</Checkbox>
|
||||
</Form>
|
||||
|
||||
<div class="rounded-lg border border-solid border-gray-200 p-3">
|
||||
<Spin :spinning="previewing">
|
||||
<template v-if="preview">
|
||||
<div class="mb-2">
|
||||
{{ t('{scope}:', { scope: scopeLabel(preview.scope_label) }) }}<strong>{{ preview.accounts.length }}</strong> {{ t('个账号有聊天,') }}
|
||||
{{ t('单聊消息') }} <strong>{{ preview.totals.messages.toLocaleString() }}</strong> {{ t('条(文字 {text} 条),会话 {conversations} 个', {
|
||||
text: preview.totals.text_messages.toLocaleString(), conversations: preview.totals.conversations.toLocaleString() }) }}
|
||||
<span v-if="preview.empty_accounts" class="text-gray-400">{{ t(';另有 {count} 个账号在这个范围内没有聊天,会跳过', { count: preview.empty_accounts }) }}</span>
|
||||
</div>
|
||||
<div v-if="wash.engine === 'model'" class="mb-2 text-gray-500">
|
||||
{{ t('模型只处理切出来的问答段(通常只占消息的百分之几),每次请求合并 {batch} 段;开始后在「整理任务」里看实时速度和预计剩余时间。', { batch: wash.model_batch }) }}
|
||||
{{ t('第一次整理全部聊天,建议在高级设置里先设一个调用上限。') }}
|
||||
</div>
|
||||
<Table v-if="preview.accounts.length > 1" size="small" :pagination="false" :scroll="{ y: 180 }" row-key="tenant_id"
|
||||
:data-source="preview.accounts" :columns="[{ title: t('账号'), dataIndex: 'label', key: 'label' }, { title: t('单聊消息'), dataIndex: 'messages', width: 110 }, { title: t('文字消息'), dataIndex: 'text_messages', width: 110 }, { title: t('会话'), dataIndex: 'conversations', width: 90 }]">
|
||||
<template #bodyCell="{ column, record }"><template v-if="column.key === 'label'">{{ t(record.label) }}</template></template>
|
||||
</Table>
|
||||
</template>
|
||||
<div v-else class="text-gray-500">{{ blocked || t('正在统计范围…') }}</div>
|
||||
</Spin>
|
||||
</div>
|
||||
<Alert class="mt-3" type="info" show-icon
|
||||
:message="t('开始即确认:这些聊天由客服账号产生,允许用于知识整理。整理结果都是待审草稿,不会自动发布;同一段对话已有的草稿不会重复生成。')" />
|
||||
</Spin>
|
||||
<template #footer>
|
||||
<Space>
|
||||
<Button :disabled="saving" @click="emit('update:open', false)">{{ t('取消') }}</Button>
|
||||
<Button type="primary" :loading="saving" :disabled="!!blocked" @click="submit">{{ t('开始整理') }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { KnowledgeRun } from '../../api/knowledge';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { engineLabel, humanDuration, runActions, runEta, runOutcome, shortTime, skipSummary, washFromConfig, washFromRun } from './run-format';
|
||||
|
||||
describe('knowledge run display', () => {
|
||||
it('lists only the outcomes that happened', () => {
|
||||
expect(runOutcome({ created_items: 1200, duplicates: 0, model_filtered: 35, updated_items: 4 }))
|
||||
.toBe('新增草稿 1,200 · 更新草稿 4 · 模型筛掉 35');
|
||||
expect(runOutcome({ created_items: 0, duplicates: 0, model_filtered: 0, updated_items: 0 })).toBe('还没有产出');
|
||||
});
|
||||
|
||||
it('explains skipped messages by reason, largest first', () => {
|
||||
expect(skipSummary({ empty: 2, revoked: 0, system: 90, unknown_direction: 5, weird: 1 }))
|
||||
.toBe('系统提示 90 · 收发方向不明 5 · 空消息 2 · weird 1');
|
||||
expect(skipSummary(undefined)).toBe('');
|
||||
});
|
||||
|
||||
it('offers actions that match the status', () => {
|
||||
expect(runActions('running')).toEqual({ cancel: true, configure: false, pause: true, resume: false });
|
||||
expect(runActions('failed')).toEqual({ cancel: true, configure: true, pause: false, resume: true });
|
||||
expect(runActions('completed')).toEqual({ cancel: false, configure: false, pause: false, resume: false });
|
||||
});
|
||||
|
||||
it('names the engine and model', () => {
|
||||
expect(engineLabel({ engine: 'model', model_name: '豆包' })).toBe('模型清洗 · 豆包');
|
||||
expect(engineLabel({ engine: 'model' })).toBe('模型清洗 · 默认模型');
|
||||
expect(engineLabel({ engine: 'rules' })).toBe('规则提取');
|
||||
});
|
||||
|
||||
it('builds wash options from config defaults and from a run', () => {
|
||||
expect(washFromConfig(null)).toEqual({ engine: 'model', max_model_calls: 0, model_batch: 8, model_concurrency: 4,
|
||||
model_filter: true, model_provider_id: '', model_timeout_seconds: 90 });
|
||||
expect(washFromConfig({ default_engine: 'rules', default_model_provider_id: 'm1', incremental_engine: 'rules',
|
||||
model_batch: 5, model_concurrency: 8, model_filter: false })).toMatchObject({ engine: 'rules', model_batch: 5,
|
||||
model_concurrency: 8, model_filter: false, model_provider_id: 'm1' });
|
||||
const run = { options: { engine: 'model', max_model_calls: 500, model_provider_id: 'm2' } } as unknown as KnowledgeRun;
|
||||
expect(washFromRun(run)).toEqual({ engine: 'model', max_model_calls: 500, model_batch: 8, model_concurrency: 4,
|
||||
model_filter: true, model_provider_id: 'm2', model_timeout_seconds: 90 });
|
||||
});
|
||||
|
||||
it('estimates the remaining time from the average speed so far', () => {
|
||||
const start = Date.parse('2026-10-07T06:43:30Z');
|
||||
const run = { created_at: '2026-10-07T06:43:30Z', processed: 8200, status: 'running', total_messages: 809209 };
|
||||
expect(runEta(run, start + 60_000)).toBeNull();
|
||||
expect(runEta(run, start + 3_600_000)).toEqual({ perMinute: 137, remaining: '约 4 天' });
|
||||
expect(runEta({ ...run, processed: 400_000 }, start + 3_600_000)).toEqual({ perMinute: 6667, remaining: '约 1 小时 1 分' });
|
||||
expect(runEta({ ...run, status: 'paused' }, start + 3_600_000)).toBeNull();
|
||||
expect(humanDuration(30)).toBe('不到 1 分钟');
|
||||
expect(humanDuration(720)).toBe('约 12 分钟');
|
||||
expect(humanDuration(7200)).toBe('约 2 小时');
|
||||
});
|
||||
|
||||
it('shows list times briefly in Beijing time', () => {
|
||||
expect(shortTime('2026-10-07T11:39:49.092+00:00')).toBe('2026-10-07 19:39');
|
||||
expect(shortTime('2026-10-07T23:05:00Z')).toBe('2026-10-08 07:05');
|
||||
expect(shortTime('')).toBe('-');
|
||||
expect(shortTime('不是时间')).toBe('不是时间');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/** 整理批次的展示文案。纯函数,方便单测。 */
|
||||
import type { KnowledgeConfig, KnowledgeRun, KnowledgeWashOptions } from '../../api/knowledge';
|
||||
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
/** 跳过原因的说明。写成函数,每次调用都按当前语言取词。 */
|
||||
export function skipLabel(reason: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
empty: t('空消息'),
|
||||
revoked: t('已撤回或删除'),
|
||||
system: t('系统提示'),
|
||||
unknown_direction: t('收发方向不明'),
|
||||
};
|
||||
return labels[reason] || reason;
|
||||
}
|
||||
|
||||
/** 批次状态的颜色和名称;不认识的状态原样显示。 */
|
||||
export function runStatus(status: string): { color?: string; label: string } {
|
||||
const known: Record<string, { color?: string; label: string }> = {
|
||||
cancelled: { label: t('已取消') },
|
||||
completed: { color: 'green', label: t('已完成') },
|
||||
failed: { color: 'red', label: t('出错暂停') },
|
||||
paused: { color: 'orange', label: t('已暂停') },
|
||||
queued: { color: 'blue', label: t('排队中') },
|
||||
running: { color: 'blue', label: t('整理中') },
|
||||
};
|
||||
return known[status] || { label: status };
|
||||
}
|
||||
|
||||
/** 后端拼好的范围说明:「全部账号」「3 个账号」「全部客服来源(2 个)」……认得的翻译,其余原样。 */
|
||||
export function scopeLabel(label: string): string {
|
||||
const accounts = /^(\d+) 个账号$/.exec(label)?.[1]; // i18n-ignore
|
||||
if (accounts) return t('{count} 个账号', { count: accounts });
|
||||
const sources = /^全部客服来源((\d+) 个)$/.exec(label)?.[1]; // i18n-ignore
|
||||
if (sources) return t('全部客服来源({count} 个)', { count: sources });
|
||||
return t(label);
|
||||
}
|
||||
|
||||
/** 批次名称由后端用「 · 」拼成(范围 · 整理方式 · 模型),逐段翻译。 */
|
||||
export function runLabel(label: string): string {
|
||||
return label.split(' · ').map((part) => scopeLabel(part)).join(' · ');
|
||||
}
|
||||
|
||||
/** 「新增 12 · 更新 3 · 重复 5 · 模型筛掉 8」:数字为 0 的不显示。 */
|
||||
export function runOutcome(run: Pick<KnowledgeRun, 'created_items' | 'duplicates' | 'model_filtered' | 'updated_items'>): string {
|
||||
const shown = [
|
||||
run.created_items > 0 && t('新增草稿 {count}', { count: run.created_items.toLocaleString() }),
|
||||
run.updated_items > 0 && t('更新草稿 {count}', { count: run.updated_items.toLocaleString() }),
|
||||
run.duplicates > 0 && t('重复 {count}', { count: run.duplicates.toLocaleString() }),
|
||||
run.model_filtered > 0 && t('模型筛掉 {count}', { count: run.model_filtered.toLocaleString() }),
|
||||
].filter(Boolean);
|
||||
return shown.length ? shown.join(' · ') : t('还没有产出');
|
||||
}
|
||||
|
||||
/** 跳过的消息按原因列出,最多的在前。 */
|
||||
export function skipSummary(reasons: Record<string, number> | undefined): string {
|
||||
return Object.entries(reasons || {})
|
||||
.filter(([, count]) => count > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([reason, count]) => `${skipLabel(reason)} ${count.toLocaleString()}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function runActions(status: string) {
|
||||
return {
|
||||
cancel: ['failed', 'paused', 'queued', 'running'].includes(status),
|
||||
configure: ['failed', 'paused'].includes(status),
|
||||
pause: ['queued', 'running'].includes(status),
|
||||
resume: ['failed', 'paused'].includes(status),
|
||||
};
|
||||
}
|
||||
|
||||
export function engineLabel(options: KnowledgeRun['options']): string {
|
||||
if (options.engine !== 'model') return t('规则提取');
|
||||
return t('模型清洗 · {model}', { model: options.model_name || options.model_provider_id || t('默认模型') });
|
||||
}
|
||||
|
||||
/** 时长的口语写法:「约 3 小时 20 分」「约 12 分钟」「不到 1 分钟」。 */
|
||||
export function humanDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 60) return t('不到 1 分钟');
|
||||
const minutes = Math.round(seconds / 60);
|
||||
if (minutes < 60) return t('约 {minutes} 分钟', { minutes });
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours >= 48) return t('约 {days} 天', { days: Math.round(hours / 24) });
|
||||
const rest = minutes % 60;
|
||||
return rest ? t('约 {hours} 小时 {minutes} 分', { hours, minutes: rest }) : t('约 {hours} 小时', { hours });
|
||||
}
|
||||
|
||||
/** 列表里的时间:北京时间「2026-10-07 19:39」。后端给的是带时区的 ISO 串,原样显示又长又要换行。 */
|
||||
export function shortTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (!value || Number.isNaN(date.getTime())) return value || '-';
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat('zh-CN', {
|
||||
day: '2-digit', hour: '2-digit', hourCycle: 'h23', minute: '2-digit', month: '2-digit',
|
||||
timeZone: 'Asia/Shanghai', year: 'numeric',
|
||||
}).formatToParts(date).map((part) => [part.type, part.value]));
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按开始以来的平均速度估算还要多久。刚开始(不到 2 分钟)或还没读到消息时不估,免得报一个离谱的数。
|
||||
* 暂停过的批次会把暂停时间也算进去,估得偏慢,所以只说「按目前平均速度」。
|
||||
*/
|
||||
export function runEta(run: Pick<KnowledgeRun, 'created_at' | 'processed' | 'status' | 'total_messages'>, now = Date.now()) {
|
||||
if (!['queued', 'running'].includes(run.status) || !run.total_messages || run.processed <= 0) return null;
|
||||
const elapsed = (now - Date.parse(run.created_at)) / 1000;
|
||||
if (!Number.isFinite(elapsed) || elapsed < 120) return null;
|
||||
const perMinute = (run.processed / elapsed) * 60;
|
||||
const remaining = Math.max(0, run.total_messages - run.processed) / (run.processed / elapsed);
|
||||
return { perMinute: Math.round(perMinute), remaining: humanDuration(remaining) };
|
||||
}
|
||||
|
||||
export function washFromConfig(config: KnowledgeConfig | null): KnowledgeWashOptions {
|
||||
return {
|
||||
engine: config?.default_engine ?? 'model',
|
||||
max_model_calls: 0,
|
||||
model_batch: config?.model_batch ?? 8,
|
||||
model_concurrency: config?.model_concurrency ?? 4,
|
||||
model_filter: config?.model_filter ?? true,
|
||||
model_provider_id: config?.default_model_provider_id ?? '',
|
||||
model_timeout_seconds: 90,
|
||||
};
|
||||
}
|
||||
|
||||
export function washFromRun(run: KnowledgeRun): KnowledgeWashOptions {
|
||||
return {
|
||||
engine: run.options.engine ?? 'model',
|
||||
max_model_calls: run.options.max_model_calls ?? 0,
|
||||
model_batch: run.options.model_batch ?? 8,
|
||||
model_concurrency: run.options.model_concurrency ?? 4,
|
||||
model_filter: run.options.model_filter ?? true,
|
||||
model_provider_id: run.options.model_provider_id ?? '',
|
||||
model_timeout_seconds: run.options.model_timeout_seconds ?? 90,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeAutoSummary, KnowledgeRun, KnowledgeRunAccount, KnowledgeWashOptions } from '#/api/knowledge';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Alert, Button, Empty, Form, message, Modal, Progress, Space, Table, Tag, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import { configureKnowledgeRun, knowledgeRunAction } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { engineLabel, runActions, runEta, runLabel, runOutcome, runStatus, skipSummary, washFromRun } from './run-format';
|
||||
import WashOptions from './wash-options.vue';
|
||||
|
||||
/** 整理任务:一行一个批次,展开看每个账号。持续整理产生的小任务只汇总成一行说明。 */
|
||||
const props = defineProps<{
|
||||
auto: KnowledgeAutoSummary | null; canControl: boolean; canCreate: boolean; canSettings: boolean; loading: boolean; runs: KnowledgeRun[];
|
||||
}>();
|
||||
const emit = defineEmits<{ changed: []; create: []; settings: [] }>();
|
||||
|
||||
const busy = ref(false);
|
||||
const configOpen = ref(false);
|
||||
const configRun = ref<KnowledgeRun | null>(null);
|
||||
const configWash = ref<KnowledgeWashOptions | null>(null);
|
||||
|
||||
async function act(run: KnowledgeRun, action: 'cancel' | 'pause' | 'resume') {
|
||||
if (busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await knowledgeRunAction(run.id, action);
|
||||
message.success({ cancel: t('已取消,已生成的草稿保留'), pause: t('已暂停,进度已保存'), resume: t('已继续') }[action]);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
function cancel(run: KnowledgeRun) {
|
||||
Modal.confirm({
|
||||
title: t('取消这个整理批次?'),
|
||||
content: t('已经生成的草稿保留,剩下的聊天不再整理;正在进行的模型请求可能仍会计费。'),
|
||||
okText: t('取消整理'), cancelText: t('不取消'), okType: 'danger',
|
||||
onOk: () => act(run, 'cancel'),
|
||||
});
|
||||
}
|
||||
|
||||
function configure(run: KnowledgeRun) {
|
||||
configRun.value = run;
|
||||
configWash.value = washFromRun(run);
|
||||
configOpen.value = true;
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const run = configRun.value;
|
||||
if (!run || !configWash.value || busy.value) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await configureKnowledgeRun(run.id, configWash.value);
|
||||
await knowledgeRunAction(run.id, 'resume');
|
||||
configOpen.value = false;
|
||||
message.success(t('已按新配置继续;已生成的草稿保留,新配置只用于剩下的部分'));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
emit('changed');
|
||||
}
|
||||
}
|
||||
|
||||
// 列标题要随语言切换:包成 computed
|
||||
const columns = computed(() => [
|
||||
{ title: t('整理批次'), key: 'run' },
|
||||
{ title: t('状态'), key: 'status', width: 120 },
|
||||
{ title: t('进度'), key: 'progress', width: 300 },
|
||||
{ title: t('模型用量'), key: 'usage', width: 150 },
|
||||
{ title: t('操作'), key: 'actions', width: 190 },
|
||||
]);
|
||||
const accountColumns = computed(() => [
|
||||
{ title: t('账号'), dataIndex: 'label', key: 'label' },
|
||||
{ title: t('状态'), key: 'status', width: 110 },
|
||||
{ title: t('已读消息'), key: 'read', width: 160 },
|
||||
{ title: t('结果'), key: 'outcome' },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Alert class="mb-4" type="info" show-icon>
|
||||
<template #message>
|
||||
<span v-if="auto && auto.watching_sources">
|
||||
{{ t('新聊天自动整理已对 {sources} 个客服来源开启:最近 24 小时整理了 {jobs} 个会话,新增草稿 {created} 条', {
|
||||
sources: auto.watching_sources, jobs: auto.jobs_24h, created: auto.created_24h }) }}<span v-if="auto.waiting_conversations">{{ t(',{count} 个会话等对话静默后整理', { count: auto.waiting_conversations }) }}</span><span v-if="auto.failed_24h" class="text-red-500">{{ t(',{count} 个失败', { count: auto.failed_24h }) }}</span>{{ t('。') }}
|
||||
</span>
|
||||
<span v-else>{{ t('新聊天自动整理还没开启:新建整理时勾选「整理完以后,新聊天也自动整理」即可开启。') }}</span>
|
||||
<Button v-if="canSettings" type="link" size="small" @click="emit('settings')">{{ t('整理设置') }}</Button>
|
||||
</template>
|
||||
</Alert>
|
||||
<Table :data-source="runs" row-key="id" :loading="loading" :columns="columns" :pagination="{ pageSize: 10 }"
|
||||
:expand-row-by-click="false">
|
||||
<template #emptyText>
|
||||
<Empty :description="t('还没有整理过聊天记录')">
|
||||
<Button v-if="canCreate" type="primary" @click="emit('create')">{{ t('整理聊天记录') }}</Button>
|
||||
</Empty>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'run'">
|
||||
<div class="font-medium">{{ runLabel(record.label) }}</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
{{ engineLabel(record.options) }}
|
||||
<template v-if="record.options.engine === 'model'"> · {{ t('{count} 路并发', { count: record.options.model_concurrency || 1 }) }} · {{ t('每次 {count} 段', { count: record.options.model_batch || (record.legacy ? 1 : 8) }) }}</template>
|
||||
<template v-if="record.options.date_from || record.options.date_to"> · {{ t('{from} 至 {to}', { from: record.options.date_from || t('最早'), to: record.options.date_to || t('现在') }) }}</template>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">{{ record.created_at.replace('T', ' ').slice(0, 19) }}<template v-if="record.legacy"> · {{ t('旧版单账号任务') }}</template></div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="runStatus(record.status).color">{{ runStatus(record.status).label }}</Tag>
|
||||
<div v-for="error in record.errors" :key="error" class="mt-1 max-w-xs text-xs text-red-500">{{ error }}</div>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'progress'">
|
||||
<Progress v-if="record.progress_percent !== null" :percent="record.progress_percent" size="small"
|
||||
:status="record.status === 'failed' ? 'exception' : record.status === 'running' ? 'active' : undefined" />
|
||||
<div class="text-xs">
|
||||
{{ record.total_messages !== null
|
||||
? t('已读 {processed} / {total} 条消息', { processed: record.processed.toLocaleString(), total: record.total_messages.toLocaleString() })
|
||||
: t('已读 {processed} 条消息', { processed: record.processed.toLocaleString() }) }}
|
||||
<template v-if="record.pending_items"> · {{ t('待整理问答 {count}', { count: record.pending_items }) }}</template>
|
||||
</div>
|
||||
<div class="text-xs">{{ runOutcome(record as KnowledgeRun) }}</div>
|
||||
<div v-if="runEta(record as KnowledgeRun)" class="text-xs text-gray-500">
|
||||
{{ t('每分钟约 {perMinute} 条消息,按目前平均速度还要 {remaining}', {
|
||||
perMinute: runEta(record as KnowledgeRun)!.perMinute.toLocaleString(), remaining: runEta(record as KnowledgeRun)!.remaining }) }}
|
||||
</div>
|
||||
<Tooltip v-if="record.skipped" :title="skipSummary(record.skip_reasons) || t('旧版任务没有分原因统计')">
|
||||
<div class="cursor-help text-xs text-gray-400 underline decoration-dotted">{{ t('跳过 {count} 条(系统提示、撤回等)', { count: record.skipped.toLocaleString() }) }}</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'usage'">
|
||||
<template v-if="record.options.engine === 'model' || record.model_calls">
|
||||
{{ t('{count} 次', { count: record.model_calls.toLocaleString() }) }}<span v-if="record.options.max_model_calls"> / {{ t('上限 {limit}', { limit: record.options.max_model_calls.toLocaleString() }) }}</span>
|
||||
<div class="text-xs text-gray-500">{{ t('输入 {input} / 输出 {output} 字', { input: record.input_chars.toLocaleString(), output: record.output_chars.toLocaleString() }) }}</div>
|
||||
</template>
|
||||
<span v-else class="text-gray-400">{{ t('不调用模型') }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'actions' && canControl">
|
||||
<Space wrap :size="4">
|
||||
<Button v-if="runActions(record.status).pause" size="small" :disabled="busy" @click="act(record as KnowledgeRun, 'pause')">{{ t('暂停') }}</Button>
|
||||
<Button v-if="runActions(record.status).resume" size="small" type="primary" :disabled="busy" @click="act(record as KnowledgeRun, 'resume')">{{ t('继续') }}</Button>
|
||||
<Button v-else-if="record.accounts.some((account: KnowledgeRunAccount) => account.status === 'failed')" size="small" type="primary"
|
||||
:disabled="busy" @click="act(record as KnowledgeRun, 'resume')">{{ t('继续出错的账号') }}</Button>
|
||||
<Button v-if="runActions(record.status).configure" size="small" :disabled="busy" @click="configure(record as KnowledgeRun)">{{ t('换模型 / 调整') }}</Button>
|
||||
<Button v-if="runActions(record.status).cancel" size="small" type="link" danger :disabled="busy" @click="cancel(record as KnowledgeRun)">{{ t('取消') }}</Button>
|
||||
</Space>
|
||||
</template>
|
||||
</template>
|
||||
<template #expandedRowRender="{ record }">
|
||||
<Table size="small" row-key="job_id" :pagination="false" :data-source="record.accounts" :columns="accountColumns">
|
||||
<template #bodyCell="{ column, record: account }">
|
||||
<template v-if="column.key === 'label'">{{ t(account.label) }}</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<Tag :color="runStatus(account.status).color">{{ runStatus(account.status).label }}</Tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'read'">
|
||||
{{ account.processed.toLocaleString() }}<template v-if="account.total_messages !== null"> / {{ account.total_messages.toLocaleString() }}</template>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'outcome'">
|
||||
{{ runOutcome(account as KnowledgeRunAccount) }}
|
||||
<div v-if="account.error" class="text-xs text-red-500">{{ account.error }}</div>
|
||||
</template>
|
||||
</template>
|
||||
</Table>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<Modal v-model:open="configOpen" :title="t('调整后继续整理')" width="680px" :ok-text="t('保存并继续')" :confirm-loading="busy"
|
||||
:destroy-on-close="true" @ok="saveConfig">
|
||||
<Alert v-if="configRun" class="mb-4" type="info" show-icon
|
||||
:message="t('已整理出 {drafts} 条草稿、调用模型 {calls} 次;这些都保留,新配置只用于剩下的部分。', { drafts: configRun.created_items + configRun.updated_items, calls: configRun.model_calls })" />
|
||||
<Form v-if="configWash" layout="vertical">
|
||||
<WashOptions v-model="configWash" :disabled="busy" />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeConfig, KnowledgeWashOptions } from '#/api/knowledge';
|
||||
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
import { Form, message, Modal, Select, Spin } from 'ant-design-vue';
|
||||
|
||||
import { fetchKnowledgeConfig, saveKnowledgeConfig } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
import { washFromConfig } from './run-format';
|
||||
import WashOptions from './wash-options.vue';
|
||||
|
||||
/** 整理设置:新建整理时的默认方式和模型,以及新聊天自动整理用哪种方式。 */
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ saved: []; 'update:open': [value: boolean] }>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const wash = ref<KnowledgeWashOptions | null>(null);
|
||||
const incremental = ref<KnowledgeConfig['incremental_engine']>('rules');
|
||||
|
||||
watch(() => props.open, async (open) => {
|
||||
if (!open) return;
|
||||
loading.value = true;
|
||||
wash.value = null;
|
||||
try {
|
||||
const config = await fetchKnowledgeConfig();
|
||||
wash.value = washFromConfig(config);
|
||||
incremental.value = config.incremental_engine;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function save() {
|
||||
if (!wash.value || saving.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
await saveKnowledgeConfig({
|
||||
default_engine: wash.value.engine,
|
||||
default_model_provider_id: wash.value.model_provider_id,
|
||||
incremental_engine: incremental.value,
|
||||
model_batch: wash.value.model_batch,
|
||||
model_concurrency: wash.value.model_concurrency,
|
||||
model_filter: wash.value.model_filter,
|
||||
});
|
||||
message.success(t('整理设置已保存'));
|
||||
emit('update:open', false);
|
||||
emit('saved');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :open="open" :title="t('整理设置')" width="680px" :ok-text="t('保存')" :confirm-loading="saving" :destroy-on-close="true"
|
||||
@ok="save" @cancel="emit('update:open', false)">
|
||||
<Spin :spinning="loading">
|
||||
<p class="text-gray-500">{{ t('新建整理时默认用这里的方式和模型(每次仍可临时改)。') }}</p>
|
||||
<Form v-if="wash" layout="vertical">
|
||||
<WashOptions v-model="wash" hide-cap :disabled="saving" />
|
||||
<Form.Item :label="t('新聊天自动整理用')">
|
||||
<Select v-model:value="incremental" :options="[
|
||||
{ value: 'rules', label: t('规则提取(不花模型费用)') },
|
||||
{ value: 'model', label: t('模型清洗(用上面的默认模型,每段新对话调用一次)') },
|
||||
]" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import type { KnowledgeModel, KnowledgeModelTest, KnowledgeWashOptions } from '#/api/knowledge';
|
||||
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
import { Alert, Button, Collapse, Form, InputNumber, Radio, Select, Space, Switch } from 'ant-design-vue';
|
||||
|
||||
import { fetchKnowledgeModels, testKnowledgeModel } from '#/api/knowledge';
|
||||
import { t } from '#/locales/t';
|
||||
|
||||
/**
|
||||
* 整理方式和模型:新建整理、换模型继续、整理设置三处共用。
|
||||
* 模型清单是全局的(不用先选账号),只列能做文字整理的模型。
|
||||
*/
|
||||
const props = defineProps<{ disabled?: boolean; hideCap?: boolean }>();
|
||||
const value = defineModel<KnowledgeWashOptions>({ required: true });
|
||||
const models = ref<KnowledgeModel[]>([]);
|
||||
const loading = ref(false);
|
||||
const loadError = ref('');
|
||||
const testing = ref(false);
|
||||
const tested = ref<KnowledgeModelTest | null>(null);
|
||||
|
||||
const options = computed(() => models.value.map((model) => ({
|
||||
value: model.id,
|
||||
label: `${model.name}${model.model && model.model !== model.name ? ` · ${model.model}` : ''}${model.default ? t('(默认)') : ''}`,
|
||||
})));
|
||||
const missing = computed(() => value.value.engine === 'model' && !loading.value
|
||||
&& !models.value.some((model) => model.id === value.value.model_provider_id));
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
models.value = (await fetchKnowledgeModels()).items;
|
||||
if (!value.value.model_provider_id) {
|
||||
value.value.model_provider_id = models.value.find((item) => item.default)?.id || models.value[0]?.id || '';
|
||||
}
|
||||
} catch {
|
||||
loadError.value = t('模型清单加载失败,请刷新后重试');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
watch(() => value.value.model_provider_id, () => { tested.value = null; });
|
||||
|
||||
async function test() {
|
||||
if (!value.value.model_provider_id) return;
|
||||
testing.value = true;
|
||||
tested.value = null;
|
||||
try {
|
||||
tested.value = await testKnowledgeModel(value.value.model_provider_id, value.value.model_timeout_seconds);
|
||||
} catch {
|
||||
// 请求本身失败(400 等)时拦截器已经弹出原因
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Form.Item :label="t('整理方式')">
|
||||
<Radio.Group v-model:value="value.engine" button-style="solid" :disabled="props.disabled">
|
||||
<Radio.Button value="model">{{ t('模型清洗(推荐)') }}</Radio.Button>
|
||||
<Radio.Button value="rules">{{ t('规则提取(不调用模型)') }}</Radio.Button>
|
||||
</Radio.Group>
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
{{ value.engine === 'model'
|
||||
? t('先按规则切出问答,再交给模型改写成标准知识,并筛掉寒暄、查物流等没有通用价值的对话。按调用次数计费。')
|
||||
: t('只按规则切出问答、保留原话,不花模型费用;需要人工逐条整理措辞和适用条件。') }}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<template v-if="value.engine === 'model'">
|
||||
<Form.Item :label="t('整理模型')">
|
||||
<Space.Compact class="w-full">
|
||||
<Select v-model:value="value.model_provider_id" :options="options" :loading="loading" :disabled="props.disabled"
|
||||
:placeholder="t('选择一个已启用的文本模型')" class="flex-1" show-search option-filter-prop="label" />
|
||||
<Button :loading="testing" :disabled="props.disabled || !value.model_provider_id" @click="test">{{ t('测试') }}</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Alert v-if="loadError || (missing && !models.length)" class="mb-3" type="warning" show-icon
|
||||
:message="loadError || t('还没有可用的文本模型,请先在「AI 模型 → 模型清单」启用一个,或改用规则提取。')" />
|
||||
<Alert v-else-if="missing" class="mb-3" type="warning" show-icon :message="t('原来选的模型已停用,请重新选择。')" />
|
||||
<Alert v-if="tested" class="mb-3" :type="tested.ok ? 'success' : 'error'" show-icon>
|
||||
<template #message>
|
||||
{{ tested.ok ? t('「{model}」可以正常整理(用时 {seconds} 秒)', { model: tested.model_name, seconds: (tested.latency_ms / 1000).toFixed(1) }) : tested.error }}
|
||||
</template>
|
||||
<template v-if="tested.ok && tested.draft" #description>
|
||||
<div class="text-xs">{{ t('用一段虚构对话试整理的结果:') }}</div>
|
||||
<div class="text-xs">{{ t('问题:{question}', { question: tested.draft.question }) }}</div>
|
||||
<div class="text-xs">{{ t('答案:{answer}', { answer: tested.draft.answer }) }}</div>
|
||||
<div class="text-xs">{{ t('适用条件:{conditions}', { conditions: tested.draft.conditions || t('(模型未给出)') }) }}</div>
|
||||
</template>
|
||||
</Alert>
|
||||
<Collapse ghost class="mb-2">
|
||||
<Collapse.Panel key="advanced" :header="t('高级设置')">
|
||||
<Space wrap :size="24">
|
||||
<Form.Item :label="t('同时调用')" class="mb-2">
|
||||
<InputNumber v-model:value="value.model_concurrency" :min="1" :max="16" :disabled="props.disabled" :addon-after="t('路')" />
|
||||
</Form.Item>
|
||||
<Form.Item :label="t('每次请求合并')" class="mb-2">
|
||||
<InputNumber v-model:value="value.model_batch" :min="1" :max="20" :disabled="props.disabled" :addon-after="t('段问答')" />
|
||||
</Form.Item>
|
||||
<Form.Item :label="t('单次等待')" class="mb-2">
|
||||
<InputNumber v-model:value="value.model_timeout_seconds" :min="30" :max="180" :disabled="props.disabled" :addon-after="t('秒')" />
|
||||
</Form.Item>
|
||||
<Form.Item v-if="!props.hideCap" :label="t('调用总上限(0 = 不限)')" class="mb-2">
|
||||
<InputNumber v-model:value="value.max_model_calls" :min="0" :max="10000000" :disabled="props.disabled" :addon-after="t('次')" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model:checked="value.model_filter" :disabled="props.disabled" size="small" />
|
||||
<span>{{ t('让模型筛掉没有通用价值的问答(寒暄、催单查物流、只针对某位患者的处置)') }}</span>
|
||||
</div>
|
||||
<p class="mb-0 mt-2 text-xs text-gray-500">
|
||||
{{ t('大部分聊天会被判为没有价值,合并请求能省下大部分调用(1 = 一段一请求)。并发越高越快,但会和客服实时回复共用同一个模型额度;') }}
|
||||
{{ t('超时或限流会自动退避重试,频繁出现时调低并发或合并段数。整理时会自动关闭模型的深度思考(服务商支持时)。') }}
|
||||
</p>
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</template>
|
||||
</template>
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user