This commit is contained in:
Your Name
2026-08-27 14:04:28 +08:00
parent f7720831be
commit 334890171e
3016 changed files with 263403 additions and 27971 deletions
+261
View File
@@ -0,0 +1,261 @@
import { requestClient } from '#/api/request';
export interface ArchiveStats {
conversations: number;
exports: number;
imports: number;
last_message_at: string;
media: number;
media_failed: number;
media_ready: number;
messages: number;
people: number;
}
export interface ArchiveConversation {
conversation_type: string;
external_id: string;
id: string;
last_content: string;
last_message_at: string;
message_count: number;
name: string;
source_account: string;
status: string;
}
export interface ArchiveMessage {
attachment_count: number;
attachments: ArchiveAttachment[];
content: string;
direction: string;
id: string;
message_type: string;
sender_name: string;
sender_person_id: null | string;
sent_at: string;
sequence_no: null | number;
source_message_id: string;
status: string;
}
export interface ArchiveAttachment {
attachment_index: number;
attachment_role: string;
checksum?: string;
id: string;
match_confidence: number;
match_method: string;
media_type: 'audio' | 'file' | 'image' | 'video';
mime_type: string;
original_filename: string;
sha256: string;
size_bytes: number;
status: string;
}
export interface ArchiveMediaAccess {
expires_in: number;
id: string;
url: string;
}
export interface ArchiveStorage {
bucket: string;
custom_domain: string;
enabled: boolean;
encryption_mode: string;
export_prefix: string;
media_prefix: string;
region: string;
secret_id_masked: string;
secret_id_present: boolean;
secret_key_masked: string;
secret_key_present: boolean;
updated_at: string;
}
export interface ArchiveMedia {
bucket: string;
created_at: string;
encryption_mode: string;
id: string;
last_error: string;
media_type: string;
mime_type: string;
object_key: string;
original_filename: string;
region: string;
sha256: string;
size_bytes: number;
status: string;
verified_at: string;
version_id: string;
}
export interface ArchivePerson {
conversation_count: number;
created_at: string;
display_name: string;
id: string;
identity_count: number;
message_count: number;
real_name: string;
updated_at: string;
}
export interface ArchiveIdentity {
created_at: string;
external_id: string;
id: string;
identity_type: string;
scope_id: string;
source: string;
updated_at: string;
verified: boolean;
}
export interface ArchivePersonDetail {
display_name: string;
id: string;
identities: ArchiveIdentity[];
real_name: string;
}
export interface ArchiveExportFile {
file_name: string;
format: string;
id: string;
sha256: string;
size_bytes: number;
storage_status: string;
}
export interface ArchiveExportJob {
completed_at: string;
created_at: string;
cutoff_at: string;
error_message: string;
files: ArchiveExportFile[];
filters: Record<string, unknown>;
formats: string[];
id: string;
progress: number;
started_at: string;
status: 'completed' | 'failed' | 'queued' | 'running';
total_rows: number;
}
function query(values: Record<string, number | string>) {
const params = new URLSearchParams();
Object.entries(values).forEach(([key, value]) => {
if (value !== '') params.set(key, String(value));
});
const text = params.toString();
return text ? `?${text}` : '';
}
export function fetchArchiveStats() {
return requestClient.get<ArchiveStats>('/archive/stats');
}
export function fetchArchiveConversations(limit = 50, cursor = '') {
return requestClient.get<{
has_more: boolean;
items: ArchiveConversation[];
next_cursor: string;
}>(`/archive/conversations${query({ cursor, limit })}`);
}
export function fetchArchiveMessages(
conversationId: string,
limit = 100,
cursor = '',
) {
return requestClient.get<{
has_more: boolean;
items: ArchiveMessage[];
next_cursor: string;
}>(
`/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ cursor, limit })}`,
);
}
export function fetchArchiveMediaAccessUrls(mediaIds: string[], expires = 900) {
return requestClient.post<{ items: ArchiveMediaAccess[] }>(
'/archive/media/access-urls',
{ expires, media_ids: mediaIds },
);
}
export function fetchArchiveStorage() {
return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage');
}
export function saveArchiveStorage(data: Partial<ArchiveStorage> & {
secret_id?: string;
secret_key?: string;
}) {
return requestClient.put<{ storage: ArchiveStorage }>(
'/archive/storage',
data,
);
}
export function testArchiveStorage() {
return requestClient.post<{
result: { bucket: string; ok: boolean; region: string; request_id: string };
}>('/archive/storage/test');
}
export function fetchArchiveMedia(limit = 100) {
return requestClient.get<{ items: ArchiveMedia[] }>(
`/archive/media${query({ limit })}`,
);
}
export function fetchArchivePeople(limit = 100, keyword = '') {
return requestClient.get<{ items: ArchivePerson[] }>(
`/archive/people${query({ keyword, limit })}`,
);
}
export function fetchArchivePerson(personId: string) {
return requestClient.get<{ person: ArchivePersonDetail }>(
`/archive/people/${encodeURIComponent(personId)}`,
);
}
export function bindArchiveIdentity(
personId: string,
data: {
external_id: string;
identity_type: string;
scope_id: string;
verified: boolean;
},
) {
return requestClient.post<{ person: ArchivePersonDetail }>(
`/archive/people/${encodeURIComponent(personId)}/identities`,
data,
);
}
export function createArchiveExport(data: {
filters: Record<string, unknown>;
formats: string[];
}) {
return requestClient.post<{ job: ArchiveExportJob }>('/archive/exports', data);
}
export function fetchArchiveExports(limit = 100) {
return requestClient.get<{ jobs: ArchiveExportJob[] }>(
`/archive/exports${query({ limit })}`,
);
}
export function downloadArchiveExport(fileId: string) {
return requestClient.download<Blob>(
`/archive/export-files/${encodeURIComponent(fileId)}/download`,
);
}
+330
View File
@@ -0,0 +1,330 @@
/**
* 业务接口:模型清单、角色权限、用户、调用统计。
*
* 全部对 `admin_api.py` 的 /api/v2/*。这一层只负责搬数据,权限判定在后端——
* 前端隐藏按钮只是体验,后端不拦就等于没有权限系统。
*/
import { requestClient } from '#/api/request';
export interface ModelProvider {
api_key?: string;
api_key_masked?: string;
base_url: string;
capabilities: string;
enabled: boolean;
/** 后端算好的实际请求地址(只读) */
endpoint?: string;
/** auto = 按接口类型补全路径;exact = 地址原样使用 */
endpoint_mode: string;
health: string;
id: string;
kind: string;
max_inflight: number;
max_tokens: number;
model: string;
name: string;
rpm_limit: number;
temperature: number;
timeout_ms: number;
}
export interface ModelPlan {
answer_ids: string;
fallback_ids: string;
judge_id: string;
judge_mode: string;
version: number;
vision_id: string;
}
export interface RoleItem {
builtin: boolean;
code: string;
name: string;
permissions: string[];
user_count: number;
}
export interface PermissionItem {
code: string;
group_name: string;
name: string;
}
export interface UserItem {
active: boolean;
created_at: string;
id: number;
must_change_password: boolean;
role: string;
username: string;
}
export interface CallStats {
avg_ms: number;
avg_score: number;
chosen: { count: number; provider: string }[];
judged: number;
max_ms: number;
risk: Record<string, number>;
score_buckets: { count: number; range: string }[];
since: string;
total: number;
}
/** 单次调用的候选出口——`model_calls.candidates_json` 解出来的一项。 */
export interface ModelCallCandidate {
error?: string;
latency_ms?: number;
provider: string;
text: string;
}
/** 单条调用留痕:客户说了什么、模型答了什么、有没有被拦下来。 */
export interface ModelCallLogItem {
candidates: ModelCallCandidate[];
chosen: string;
created_at: string;
customer_text: string;
device_id: string;
id: number;
judge_mode: string;
judge_risk: string;
judge_score: number;
judge_winner: string;
/** chat = 回客户的话;guard = 界面识别之类的内部判断 */
purpose: string;
reply_text: string;
review_reason: string;
roles_version: number;
task_id: string;
total_ms: number;
}
// ── 模型 ─────────────────────────────────────────────────────────────────────
export function fetchModels() {
return requestClient.get<{
judge_modes: string[];
kinds: string[];
models: ModelProvider[];
roles: ModelPlan;
}>('/models');
}
export function saveModel(data: Partial<ModelProvider>) {
return requestClient.post<{ models: ModelProvider[] }>('/models', data);
}
export function deleteModel(id: string) {
return requestClient.delete<{ models: ModelProvider[] }>(
`/models/${encodeURIComponent(id)}`,
);
}
export function saveModelPlan(data: Partial<ModelPlan>) {
return requestClient.post<{ roles: ModelPlan; version: number }>(
'/models/plan',
data,
);
}
// ── 角色与权限 ───────────────────────────────────────────────────────────────
export function fetchRoles() {
return requestClient.get<{ roles: RoleItem[] }>('/roles');
}
export function fetchPermissions() {
return requestClient.get<{ permissions: PermissionItem[] }>('/permissions');
}
export function saveRole(data: {
code: string;
name: string;
permissions: string[];
}) {
return requestClient.post<{ roles: RoleItem[] }>('/roles', data);
}
export function deleteRole(code: string) {
return requestClient.delete<{ roles: RoleItem[] }>(
`/roles/${encodeURIComponent(code)}`,
);
}
// ── 用户 ─────────────────────────────────────────────────────────────────────
export function fetchUsers() {
return requestClient.get<{ users: UserItem[] }>('/users');
}
export function createUser(data: {
password: string;
role: string;
username: string;
}) {
return requestClient.post<{ users: UserItem[] }>('/users', data);
}
export function updateUser(
id: number,
data: { active: boolean; role: string },
) {
return requestClient.put<{ users: UserItem[] }>(`/users/${id}`, data);
}
// ── 运营 ─────────────────────────────────────────────────────────────────────
export function fetchCallStats(days = 7) {
return requestClient.get<CallStats>(`/stats/model-calls?days=${days}`);
}
export function fetchCallLog(params: {
days?: number;
limit?: number;
offset?: number;
/** chat = 回客户的话(默认);guard = 界面识别;'' = 全都要 */
purpose?: string;
q?: string;
}) {
const query = new URLSearchParams();
query.set('days', String(params.days ?? 7));
query.set('limit', String(params.limit ?? 50));
query.set('offset', String(params.offset ?? 0));
query.set('purpose', params.purpose ?? 'chat');
if (params.q) query.set('q', params.q);
return requestClient.get<{ items: ModelCallLogItem[]; total: number }>(
`/stats/model-calls/log?${query.toString()}`,
);
}
export function fetchAudit(limit = 200) {
return requestClient.get<{
entries: {
action: string;
created_at: string;
detail: string;
id: number;
ip_address: string;
username: null | string;
}[];
}>(`/audit?limit=${limit}`);
}
export function changeMyPassword(data: {
current_password: string;
new_password: string;
}) {
return requestClient.post('/me/password', data);
}
// ── 桌面端配置 ───────────────────────────────────────────────────────────────
/**
* 下发给桌面客户端的配置。
*
* 字段名是大写下划线,和老网页后台的表单完全一致——两边共用后端同一个校验函数,
* 不会出现"这边填得进、那边填不进"。
*
* 这里**没有模型连接参数**:服务类型、API 地址、密钥、模型名、温度、tokens、
* 超时全部搬到了「AI 模型 → 模型清单 / 角色编排」。桌面端的模型调用走网关,
* 密钥不出后端。
*/
export interface DesktopConfig {
AI_AGENT_NAME: string;
AI_CONTEXT_ENABLED: boolean;
AI_CONTEXT_MAX_ROUNDS: number;
AI_COUNTER_INSULT_ENABLED: boolean;
AI_DEVELOPMENT_MODE: boolean;
AI_ENABLED: boolean;
AI_HOSPITAL_NAME: string;
AI_MCP_ENABLED: boolean;
AI_MCP_MAX_ROUNDS: number;
AI_MCP_SERVERS: unknown[];
AI_UI_GUARD_ENABLED: boolean;
AI_USE_VISION: boolean;
/** 留空 = 按后台自己的地址自动推算 */
AI_GATEWAY_URL: string;
AI_REVIEW_RULES: ReviewRule[];
}
/**
* 选择性审核规则:命中任意一条,这一条回复才会停下来等人工确认,其余照常
* 自动发送。`id` 可以留空——留空由后端根据 label 自动生成并去重。
*/
export interface ReviewRule {
id?: string;
label: string;
keywords: string[];
enabled: boolean;
}
export interface ConfigEnvelope {
bool_keys: string[];
config: DesktopConfig;
/** 留空时实际会下发给客户端的网关地址 */
gateway_url_effective: string;
/** 模型设置搬去哪儿了,用来在页面上给人指路 */
model_settings_moved_to: string;
retired_keys: string[];
updated_at: string;
updated_by: string;
version: number;
}
export function fetchConfig() {
return requestClient.get<ConfigEnvelope>('/config');
}
export function saveConfig(data: DesktopConfig) {
return requestClient.post<{ version: number }>('/config', data);
}
// ── 桌面端版本升级 ───────────────────────────────────────────────────────────
export interface ReleasePolicy {
download_url: string;
force_upgrade: boolean;
latest_version: string;
release_notes: string;
updated_at: string;
updated_by: string;
}
export function fetchRelease() {
return requestClient.get<ReleasePolicy>('/release');
}
export function saveRelease(data: {
download_url: string;
force_upgrade: boolean;
latest_version: string;
release_notes: string;
}) {
return requestClient.post<ReleasePolicy>('/release', data);
}
// ── 模型连通性测试 ───────────────────────────────────────────────────────────
export interface ModelTestResult {
endpoint: string;
http_status: null | number;
latency_ms: number;
message: string;
model: string;
ok: boolean;
provider: string;
}
/**
* 给了 provider_id 就测清单里那一条,密钥由后端从库里解密取用——
* 前端从头到尾拿不到明文,也就不可能在网络上多走一趟。
*/
export function testModel(data: {
api_key?: string;
base_url?: string;
endpoint_mode?: string;
kind?: string;
model?: string;
provider_id?: string;
timeout_seconds?: number;
}) {
return requestClient.post<{ label: string; result: ModelTestResult }>(
'/models/test',
data,
);
}
@@ -0,0 +1,81 @@
import { requestClient } from '#/api/request';
/**
* 认证接口。
*
* vben 默认对接的是它自带的 mock 后端(`/auth/login` 返回 `{accessToken}`、
* `/auth/codes` 单独取权限码)。我们的后端是 `admin_api.py`,形状不一样,而且
* **登录时就把权限码一起返回了**——少一次往返,也避免"已登录但权限还没到"
* 这个中间态导致路由守卫误判。
*
* 适配放在这一层,不去改后端:接口契约不该被某一个客户端的习惯带偏。
*/
export namespace AuthApi {
export interface LoginParams {
password?: string;
username?: string;
}
export interface LoginResult {
accessToken: string;
}
/** 后端 /api/v2/auth/login 的真实返回 */
export interface BackendLoginResult {
access_token: string;
expires_in: number;
user: {
id: number;
must_change_password: boolean;
permissions: string[];
role: string;
username: string;
};
}
}
/** 登录后暂存的权限码。getAccessCodesApi 直接用它,省掉一次请求。 */
let cachedCodes: string[] = [];
export function takeCachedAccessCodes(): string[] {
return cachedCodes;
}
export async function loginApi(data: AuthApi.LoginParams) {
const resp = await requestClient.post<AuthApi.BackendLoginResult>(
'/auth/login',
{ username: data.username, password: data.password, device_name: 'web' },
);
cachedCodes = resp.user?.permissions ?? [];
return { accessToken: resp.access_token } as AuthApi.LoginResult;
}
/**
* 刷新 accessToken。
*
* 后端目前发的是 30 天有效的长令牌,没有 refresh token 机制,所以这里必须明确
* 失败——返回一个假 token 会让请求拦截器以为续期成功,接着用一个无效令牌无限
* 重试,表现是页面卡死而不是跳登录页。
*/
export async function refreshTokenApi(): Promise<never> {
throw new Error('后端未启用令牌续期,请重新登录');
}
export async function logoutApi() {
cachedCodes = [];
return requestClient.post('/auth/logout', {});
}
/**
* 取当前用户的权限码。
*
* 优先用登录时带回来的那份;刷新页面后缓存是空的,再去 /me 拿一次。
*/
export async function getAccessCodesApi(): Promise<string[]> {
if (cachedCodes.length > 0) {
return cachedCodes;
}
const me = await requestClient.get<{ permissions: string[] }>('/me');
cachedCodes = me.permissions ?? [];
return cachedCodes;
}
@@ -0,0 +1,3 @@
export * from './auth';
export * from './menu';
export * from './user';
@@ -0,0 +1,10 @@
import type { RouteRecordStringComponent } from '@vben/types';
import { requestClient } from '#/api/request';
/**
* 获取用户所有菜单
*/
export async function getAllMenusApi() {
return requestClient.get<RouteRecordStringComponent[]>('/menu/all');
}
@@ -0,0 +1,37 @@
import type { UserInfo } from '@vben/types';
import { requestClient } from '#/api/request';
interface BackendMe {
id: number;
must_change_password: boolean;
permissions: string[];
role: string;
username: string;
}
/**
* 取当前登录用户。
*
* 后端的 `/api/v2/me` 是权限的唯一数据源——路由守卫和按钮显隐都以它为准。
* 这里把它映射成 vben 的 UserInfo 形状。
*
* `roles` 塞的是**权限码**而不是角色名,这是有意的:vben 的 `v-access:role`
* 指令按这个数组匹配,而我们全线只判权限码,不判角色名。判角色名等于把运营
* 策略焊死在前端,加个角色就要重新发版。
*/
export async function getUserInfoApi(): Promise<UserInfo> {
const me = await requestClient.get<BackendMe>('/me');
return {
avatar: '',
realName: me.username,
roles: me.permissions ?? [],
userId: String(me.id),
username: me.username,
// 首登必须改密:把落地页锁死在改密页,用户改完才放行。后端不会拦改密接口
// 本身(拦了就只能去老网页后台改),锁在前端这一层。
homePath: me.must_change_password ? '/profile/password' : undefined,
desc: me.role,
token: '',
} as UserInfo;
}
+1
View File
@@ -0,0 +1 @@
export * from './core';
+145
View File
@@ -0,0 +1,145 @@
/**
* 该文件可自行根据业务逻辑进行调整
*/
import type { RequestClientOptions } from '@vben/request';
import { useAppConfig } from '@vben/hooks';
import { preferences } from '@vben/preferences';
import {
authenticateResponseInterceptor,
defaultResponseInterceptor,
errorMessageResponseInterceptor,
RequestClient,
} from '@vben/request';
import { useAccessStore } from '@vben/stores';
import { message } from 'ant-design-vue';
import { useAuthStore } from '#/store';
import { refreshTokenApi } from './core';
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
function createRequestClient(baseURL: string, options?: RequestClientOptions) {
const client = new RequestClient({
...options,
baseURL,
});
/**
* 重新认证逻辑。
*
* 这里必须防重入:登出本身也是一个带鉴权头的请求,令牌已经无效时它同样会
* 拿到 401,于是又走进这个函数、又去登出……递归下去,表现是浏览器疯狂
* 发 POST /auth/logout 直到页面卡死。authStore.logout() 里的 try/catch 挡不住,
* 它catch 的是自己那一层,下一层照样新起一轮。
*/
let reAuthenticating = false;
async function doReAuthenticate() {
if (reAuthenticating) {
return;
}
reAuthenticating = true;
console.warn('Access token or refresh token is invalid or expired. ');
const accessStore = useAccessStore();
const authStore = useAuthStore();
accessStore.setAccessToken(null);
if (
preferences.app.loginExpiredMode === 'modal' &&
accessStore.isAccessChecked
) {
accessStore.setLoginExpired(true);
} else {
try {
await authStore.logout();
} finally {
reAuthenticating = false;
}
return;
}
reAuthenticating = false;
}
/**
* 刷新 token 逻辑。
*
* 后端发的是 30 天有效的长令牌,没有 refresh token。配套地
* `enableRefreshToken` 关掉了,拦截器不会走到这里;万一哪天有人打开开关,
* 这里立刻抛错,比默默续期一个假令牌然后无限重试要好得多。
*/
async function doRefreshToken(): Promise<string> {
await refreshTokenApi();
throw new Error('后端未启用令牌续期');
}
function formatToken(token: null | string) {
return token ? `Bearer ${token}` : null;
}
// 请求头处理
client.addRequestInterceptor({
fulfilled: async (config) => {
const accessStore = useAccessStore();
config.headers.Authorization = formatToken(accessStore.accessToken);
config.headers['Accept-Language'] = preferences.app.locale;
return config;
},
});
// 处理返回的响应数据格式。
//
// 模板默认配的是 `{codeField:'code', dataField:'data', successCode:0}`,也就是
// 假定后端返回 `{code:0, data:{...}}` 这种信封。admin_api.py 返回的是扁平
// JSON`{"access_token":...}`),没有 code 字段,按默认配置每一个 200 都会被
// 判成失败并抛出——症状是点登录毫无反应,控制台还一句错都不报。
//
// 所以改成透传:成败由 HTTP 状态码表达(这本来就是它该干的事),
// 整个响应体就是数据。这个拦截器不能直接删掉——删了之后没人拆包,
// 拿到的是整个 axios response`resp.access_token` 一样是 undefined。
client.addResponseInterceptor(
defaultResponseInterceptor({
codeField: '',
dataField: (response) => response,
successCode: () => true,
}),
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
errorMessageResponseInterceptor((msg: string, error) => {
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
// 当前mock接口返回的错误字段是 error 或者 message
const responseData = error?.response?.data ?? {};
// FastAPI 的错误信息在 detail 里(HTTPException(detail=...)),
// 模板只认 error / message,不加这个后端说的话一句都显示不出来。
const errorMessage =
responseData?.detail ??
responseData?.error ??
responseData?.message ??
'';
// 如果没有错误信息,则会根据状态码进行提示
message.error(errorMessage || msg);
}),
);
return client;
}
export const requestClient = createRequestClient(apiURL, {
responseReturn: 'data',
});
export const baseRequestClient = new RequestClient({ baseURL: apiURL });