gengx
This commit is contained in:
@@ -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`,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './core';
|
||||
@@ -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 });
|
||||
Reference in New Issue
Block a user