diff --git a/CLAUDE.md b/CLAUDE.md index d45b8b5..2e47522 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,43 @@ -# 回答语言 +# 基本交代 + +- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明; +- 有需要请自行调用MCP服务; + +# 业务设计思路 + +- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录; +- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS; +- 处理业务时,需要同时考虑兜底机制和业务自定义错误捕获; +- 所有设计思路、代码实现均需要考虑以上三个条件。 + +# 架构约定 + +## 任务状态轮询 + +- **两层轮询 + Context 桥接**:`useTaskPolling`(批量轮询 `batchTaskStatus`)负责广度覆盖,`OutputPreview` 通过 `selectedTask.status` 变化检测终态转换后触发 `refetch()` 获取详情——二者通过 Context 同步,避免重复请求。 +- **非终态统一管理**:`POLLING_STATUSES = ['pending', 'submitted', 'processing']` 定义在 `task.ts`,所有轮询逻辑引用此常量。 +- **自适应间隔**:递归 `setTimeout`(非 `setInterval`)+ 服务端 `next_poll_ms`,本地 clamp 2s~30s,出错 10s 重试。 +- **useRef 打破闭包陷阱**:`mergedRef.current` 确保递归回调读到最新任务列表。 + +## Token 刷新 + +- **双轨刷新**:主动刷新(`scheduleProactiveRefresh`)在 JWT `exp` 的 75% 时间点通过 `setTimeout` 提前续期;被动刷新(`handle401` → `refreshHandler`)兜底。 +- **失败分级处理**:`refreshHandler` 永久失败(`RefreshTokenExpiredError`)→ throw → `handle401` 清 token + 弹登录;临时失败(网络超时)→ return null → 仅拒绝当前请求,不登出。 +- **JWT 解码驱动定时器**:从 `atob(token.split('.')[1])` 取 `exp` 声明,无需额外持久化 `expires_in`。 +- **刷新接口隔离**:`/api/v1/auth/refresh` 在 `request.ts` 中特殊处理(`isRefreshRequest`),防止 401→刷新→401 死循环。 + +## 文件上传 + +- **customRequest 集成模式**:`useFileUpload` hook 封装 `request.upload()`,返回 antd Upload 兼容的 `customRequest`——避免直接设 `action` 导致 Token 缺失,复用全局拦截器的认证和错误处理。 +- **beforeUpload 语义修正**:校验通过返回 `true`(放行至 `customRequest`),失败返回 `Upload.LIST_IGNORE`。 + +## 表单提交 + +- **两级防护**:防抖(2s 节流,"你点击的太快了")+ 二次确认(`Modal.confirm`,"您已提交,确认要再次提交吗?")。 +- **useRef 存储时序状态**:`lastClickTimeRef` / `submitCountRef` 避免不必要的重渲染。 +- **useAsyncMutation 返回值判空**:`execute()` 内部 catch 所有错误返回 `undefined`,调用方应检查返回值而非 try/catch。 + +## GET 数组参数 + +- **逗号拼接优于 bracket 序列化**:`task_ids=a,b,c` 比 `task_ids[]=a&task_ids[]=b` 更具跨框架兼容性,避免 axios 默认 `paramsSerializer` 的 PHP/Rack 风格。 -- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 -- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 -- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS。 -- 有需要请自己调用MCP服务 diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index d30450a..ebd8db7 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -23,6 +23,8 @@ import { setStoredRefreshToken, clearStoredRefreshToken, initRefreshTokenStore, + scheduleProactiveRefresh, + clearProactiveRefresh, } from '@/services/auth-token'; import {initModelCache} from '@/services/cache/model-cache'; @@ -89,6 +91,8 @@ export function AppProvider({ children }: AppProviderProps) { const login = useCallback(async (auth: LoginResponseBody) => { await setToken(auth.access_token); await setStoredRefreshToken(auth.refresh_token); + // 启动主动刷新定时器(在 access_token 过期前自动续期) + scheduleProactiveRefresh(auth.access_token); setUser(auth); setIsLoggedIn(true); logger.info('auth', 'User logged in', { @@ -102,6 +106,7 @@ export function AppProvider({ children }: AppProviderProps) { logger.info('auth', 'User logged out'); clearToken(); clearStoredRefreshToken(); + clearProactiveRefresh(); setUser(null); setIsLoggedIn(false); emit(EVENTS.LOGOUT); @@ -133,6 +138,7 @@ export function AppProvider({ children }: AppProviderProps) { if (aborted) return; await setToken(res.access_token); await setStoredRefreshToken(res.refresh_token); + scheduleProactiveRefresh(res.access_token); setIsLoggedIn(true); logger.info('auth', 'Auto-login succeeded'); // 补全用户信息 diff --git a/src/components/PageDispatcher.tsx b/src/components/PageDispatcher.tsx index c75dbb2..c3486fc 100644 --- a/src/components/PageDispatcher.tsx +++ b/src/components/PageDispatcher.tsx @@ -90,7 +90,12 @@ const PAGE_MAP: Record = { label: '订单明细', }, '/projects': {label: '项目管理'}, - '/quota': {label: '查配额'}, + '/quota': { + component: AccountInfo, + label: '配额', + width: () => Math.min(window.outerHeight * 0.65, 720), + height: () => Math.min(window.outerHeight * 0.65, 680), + }, }; // 预计算所有占位组件(模块加载时执行一次,引用永久稳定) diff --git a/src/components/navbar/AnnouncementDrawer.tsx b/src/components/navbar/AnnouncementDrawer.tsx index 9d37eb3..7d868f0 100644 --- a/src/components/navbar/AnnouncementDrawer.tsx +++ b/src/components/navbar/AnnouncementDrawer.tsx @@ -7,28 +7,13 @@ import {useEffect, useState, useCallback} from 'react'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; import {NotificationOutlined} from '@ant-design/icons'; -import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement'; +import { fetchAnnouncements, type Announcement, type AnnouncementListResponse, type AnnouncementTypeValue } from '@/services/modules/announcement'; +import { EnumResolver } from '@/utils/enum-resolver'; const {Text, Paragraph} = Typography; // ---------- 辅助 ---------- -/** 公告类型 → Tag 颜色映射 */ -const typeColorMap: Record = { - system: 'blue', - feature: 'purple', - activity: 'orange', - maintenance: 'red', -}; - -/** 公告类型 → 中文标签 */ -const typeLabelMap: Record = { - system: '系统', - feature: '功能', - activity: '活动', - maintenance: '维护', -}; - /** 格式化日期(YYYY-MM-DD HH:mm) */ function formatDate(iso: string): string { try { @@ -132,10 +117,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) { } extra={ - {typeLabelMap[item.type] || item.type} + {EnumResolver.announcement.type.label(item.type as AnnouncementTypeValue)} } onClick={() => setDetail(item)} @@ -177,10 +162,10 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
- {typeLabelMap[detail.type] || detail.type} + {EnumResolver.announcement.type.label(detail.type as AnnouncementTypeValue)} {formatDate(detail.created_at)} diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index 8c1a5fe..e43fd5d 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -12,9 +12,9 @@ // - 用户可读错误信息(errorMessage) // ============================================================ -import {useMemo, useState} from 'react'; +import {useMemo, useState, useRef, useEffect, useCallback} from 'react'; import {useAppContext} from '@/components/AppProvider'; -import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task'; +import { TaskAPI, POLLING_STATUSES, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody, type TaskStatusResponseBody, type TaskStatusString } from '@/services/modules/task'; import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models'; import { fetchBanners, type Banner } from '@/services/modules/banner'; import { AuthAPI, type UserInfoBody } from '@/services/modules/auth'; @@ -22,6 +22,7 @@ import { VipAPI, type VipLevelsItem } from '@/services/modules/vip-api'; import { BillingAPI, type BalanceResponseBody, type LedgerItemResponseBody, type LedgerReqeustParam } from '@/services/modules/billing'; import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; import {getCachedModels, setCachedModels} from '@/services/cache/model-cache'; +import { upload } from '@/services/request'; // ============================================================ // Banner @@ -203,6 +204,160 @@ export function useSubmitTask(options?: { ); } +// ============================================================ +// 任务状态轮询 +// ============================================================ + +/** + * 批量轮询非终态任务状态,返回合并后的任务列表。 + * + * - 自动识别 tasks 中的非终态任务(pending / submitted / processing) + * - 调用 batchTaskStatus 批量查询状态 + * - 使用服务端返回的 next_poll_ms(本地限制 2s ~ 30s)自适应间隔 + * - 递归 setTimeout 而非 setInterval(避免请求堆积) + * - onTerminal 回调:当某个任务变为终态时触发(用于触发完整列表刷新) + */ +export function useTaskPolling( + tasks: TaskInfoItemResponseBody[], + onTerminal?: () => void, +): TaskInfoItemResponseBody[] { + const [merged, setMerged] = useState(tasks); + const mergedRef = useRef(merged); + const timerRef = useRef | null>(null); + const mountedRef = useRef(true); + + // 保持 ref 与 state 同步,避免轮询闭包读到过期数据 + mergedRef.current = merged; + + // 源数据变化时同步(服务端拉取的新数据覆盖本地合并结果) + useEffect(() => { + setMerged(tasks); + }, [tasks]); + + // 用 ref 稳定化 onTerminal 回调,避免 useEffect 因回调引用变化而重启 + const onTerminalRef = useRef(onTerminal); + onTerminalRef.current = onTerminal; + + // 轮询循环:当 tasks(源数据)变化时重启 + useEffect(() => { + mountedRef.current = true; + + // 检查源数据中是否有非终态任务 + const hasNonTerminal = tasks.some( + t => POLLING_STATUSES.includes(t.status as TaskStatusString), + ); + if (!hasNonTerminal) return; + + const poll = async () => { + if (!mountedRef.current) return; + + // 从最新 merged 中取非终态 ID(而非闭包捕获的旧值) + const currentMerged = mergedRef.current; + const ids = currentMerged + .filter(t => POLLING_STATUSES.includes(t.status as TaskStatusString)) + .map(t => t.id); + + if (ids.length === 0) return; + + try { + const result = await TaskAPI.batchTaskStatus({ task_ids: ids }); + if (!mountedRef.current) return; + + const statusMap = new Map( + result.items + .filter((s): s is TaskStatusResponseBody => s !== null && s !== undefined) + .map(s => [s.task_id, s]), + ); + + let hasTerminal = false; + + setMerged(prev => prev.map(task => { + const st = statusMap.get(task.id); + if (!st || st.status === task.status) return task; + + const isNowTerminal = !POLLING_STATUSES.includes(st.status as TaskStatusString); + if (isNowTerminal) hasTerminal = true; + + return { + ...task, + status: st.status, + cost_cent: st.cost_cent ?? task.cost_cent, + error_code: st.error_code ?? task.error_code, + error_message: st.error_message ?? task.error_message, + finished_at: st.finished_at ?? task.finished_at, + }; + })); + + if (hasTerminal) onTerminalRef.current?.(); + + // 检查合并后是否还有非终态任务(从 ref 读取最新值) + const stillPending = mergedRef.current.some( + t => POLLING_STATUSES.includes(t.status as TaskStatusString), + ); + + if (stillPending && mountedRef.current) { + const delay = Math.max(2000, Math.min(30000, result.next_poll_ms || 5000)); + timerRef.current = setTimeout(poll, delay); + } + } catch { + if (mountedRef.current) { + // 出错后 10s 重试 + timerRef.current = setTimeout(poll, 10000); + } + } + }; + + // 首次轮询延迟 2s(给服务端缓冲时间) + timerRef.current = setTimeout(poll, 2000); + + return () => { + mountedRef.current = false; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [tasks]); + + return merged; +} + +// ============================================================ +// 文件上传 +// ============================================================ + +/** 上传接口路径(与后端 /api/v1/upload 对齐) */ +const UPLOAD_ACTION = '/api/v1/upload'; + +/** + * 文件上传 Hook — 返回 antd Upload 组件兼容的 customRequest。 + * + * 设计要点: + * - 复用 request.ts 的 upload() 函数(自动携带 Token、超时、错误处理) + * - 通过 customRequest 集成到 antd Upload,而非直接设 action(避免 Token 缺失) + * - 上传成功后 antd 自动将 response 写入 file.response,供表单提交时提取 URL + */ +export function useFileUpload() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const customRequest = useCallback((options: any) => { + const file = options.file as File; + const formData = new FormData(); + formData.append('file', file, options.filename || file.name); + + upload<{ url: string }>(UPLOAD_ACTION, formData, (percent) => { + options.onProgress?.({ percent }); + }) + .then((result) => { + options.onSuccess?.(result); + }) + .catch((err) => { + options.onError?.(err instanceof Error ? err : new Error(String(err))); + }); + }, []); + + return { customRequest, uploadAction: UPLOAD_ACTION } as const; +} + // ---------- 重新导出类型和工具 ---------- export {MODEL_CATEGORY_LABELS}; diff --git a/src/pages/headers/AccountInfo.tsx b/src/pages/headers/AccountInfo.tsx index 37eb17b..5276bf8 100644 --- a/src/pages/headers/AccountInfo.tsx +++ b/src/pages/headers/AccountInfo.tsx @@ -17,7 +17,7 @@ import { BarChartOutlined, RightOutlined, } from '@ant-design/icons'; -import { UserAccountTypeLabelMap } from '@/services/modules/auth'; +import {EnumResolver} from '@/utils/enum-resolver'; import type { VipLevelsItem } from '@/services/modules/vip-api'; import { useAccountInfo } from '@/hooks/use-api'; import { centToYuan, formatDate } from '@/utils/display'; @@ -75,7 +75,7 @@ export function AccountInfo() { {userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id} {userInfo.account_type && ( - {UserAccountTypeLabelMap[userInfo.account_type]} + {EnumResolver.auth.accountType.label(userInfo.account_type)} )} diff --git a/src/pages/headers/BillingInfo.tsx b/src/pages/headers/BillingInfo.tsx index 0f92ebd..7eb8e14 100644 --- a/src/pages/headers/BillingInfo.tsx +++ b/src/pages/headers/BillingInfo.tsx @@ -16,7 +16,8 @@ import { ApiOutlined, } from '@ant-design/icons'; import {useBillingBalance, useBillingLedger} from '@/hooks/use-api'; -import type {LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing'; +import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing'; +import {EnumResolver} from '@/utils/enum-resolver'; import {centToYuan, formatDate} from '@/utils/display'; const {Text, Title} = Typography; @@ -107,30 +108,6 @@ function BalanceCard() { ); } -// ============================================================ -// 账单表格 -// ============================================================ - -/** entry_type → 中文标签映射(按需扩展) */ -const ENTRY_TYPE_LABELS: Record = { - task_cost: '任务消耗', - recharge: '充值', - gift: '赠送', - refund: '退款', - vip_purchase: '套餐购买', -}; - -function resolveEntryLabel(type: string): string { - return ENTRY_TYPE_LABELS[type] || type; -} - -/** entry_type → Tag 颜色映射 */ -function entryTypeColor(type: string): string { - if (type === 'task_cost' || type === 'vip_purchase') return 'orange'; - if (type === 'recharge' || type === 'gift') return 'green'; - if (type === 'refund') return 'blue'; - return 'default'; -} function LedgerTable() { const {token} = antTheme.useToken(); @@ -163,9 +140,9 @@ function LedgerTable() { key: 'entry_type', width: 100, align: 'center' as const, - render: (type: string) => ( - - {resolveEntryLabel(type)} + render: (type: EntryTypeValue) => ( + + {EnumResolver.billing.entryType.label(type)} ), }, diff --git a/src/pages/home/components/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx index 1afc2bd..abc5339 100644 --- a/src/pages/home/components/ModelInputForm.tsx +++ b/src/pages/home/components/ModelInputForm.tsx @@ -10,7 +10,7 @@ // - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用 // ============================================================ -import { useState, useLayoutEffect, useMemo, useCallback } from 'react'; +import { useState, useLayoutEffect, useMemo, useCallback, useRef } from 'react'; import { Form, Input, @@ -21,6 +21,7 @@ import { Space, theme as antTheme, message, + Modal, } from 'antd'; import type { FormInstance } from 'antd'; import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons'; @@ -147,6 +148,16 @@ export function ModelInputForm() { }, []), }); + // -------- 提交防抖 & 二次确认 Ref -------- + + /** 上次点击提交按钮的时间戳(毫秒),用于 2s 防抖节流 */ + const lastClickTimeRef = useRef(0); + /** 已成功提交次数,用于判断是否需要"再次提交确认"弹窗 */ + const submitCountRef = useRef(0); + /** 最新错误信息的 ref(避免 doSubmit useCallback 闭包过期) */ + const errorMessageRef = useRef(errorMessage); + errorMessageRef.current = errorMessage; + // ======== useModelUI:param_schema → UIFieldConfig[] ======== const fields: UIFieldConfig[] = useModelUI( @@ -190,54 +201,96 @@ export function ModelInputForm() { // ======== 提交 ======== - const handleSubmit = async () => { + /** + * 实际提交逻辑(在防抖检查和二次确认之后执行)。 + * 用 useCallback 稳定化,避免 handleSubmit 因闭包过期而读到旧 fields/values。 + */ + const doSubmit = useCallback(async () => { + if (!selectedModel) return; + + let values: Record; + try { + values = await form.validateFields(); + } catch { + // 表单校验失败 → antd 自动展示字段错误,静默返回 + return; + } + + const params: Record = {}; + + for (const field of fields) { + const value = values[field.name]; + if (value === undefined || value === null) continue; + + if (field.isFileWidget) { + const fileList = value as UploadFile[]; + const urls = fileList + .filter((f) => f.status === 'done') + .map((f) => f.response?.url || f.url || f.name) + .filter(Boolean) as string[]; + if (urls.length > 0) { + params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls); + } + continue; + } + + if (typeof value === 'boolean') { + params[field.name] = String(value); + continue; + } + + params[field.name] = String(value); + } + + // useAsyncMutation.execute 内部 catch 所有错误,返回 undefined 表示失败 + const result = await submitTask({ + model_id: selectedModel.id, + params, + ...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}), + }); + + if (result !== undefined) { + submitCountRef.current += 1; + } else { + // 使用 ref 读取最新错误信息(避免闭包过期) + message.error(errorMessageRef.current || '任务提交失败,请重试'); + } + }, [selectedModel, form, fields, taskTag, submitTask]); + + /** 提交按钮点击入口:登录校验 → 防抖节流 → 二次确认 → 执行提交 */ + const handleSubmit = useCallback(() => { if (!isLoggedIn) { message.warning('请先登录后再提交任务'); return; } if (!selectedModel) return; - try { - const values = await form.validateFields(); - - const params: Record = {}; - - for (const field of fields) { - const value = values[field.name]; - if (value === undefined || value === null) continue; - - if (field.isFileWidget) { - const fileList = value as UploadFile[]; - const urls = fileList - .filter((f) => f.status === 'done') - .map((f) => f.response?.url || f.url || f.name) - .filter(Boolean) as string[]; - if (urls.length > 0) { - params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls); - } - continue; - } - - if (typeof value === 'boolean') { - params[field.name] = String(value); - continue; - } - - params[field.name] = String(value); - } - - await submitTask({ - model_id: selectedModel.id, - params, - ...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}), - }); - } catch (err) { - if (err && typeof err === 'object' && 'errorFields' in err) { - return; - } - message.error(errorMessage || '任务提交失败,请重试'); + // ---- 防抖节流:2s 内重复点击 ---- + const now = Date.now(); + if (now - lastClickTimeRef.current < 2000) { + message.warning('你点击的太快了'); + return; } - }; + lastClickTimeRef.current = now; + + // ---- 已提交过 → 二次确认弹窗 ---- + if (submitCountRef.current > 0) { + Modal.confirm({ + title: '确认再次提交', + content: '您已提交,确认要再次提交吗?', + okText: '确认提交', + cancelText: '取消', + onOk: () => { + // 弹窗确认后重置节流时间戳,确保确认提交不被防抖拦截 + lastClickTimeRef.current = 0; + void doSubmit(); + }, + }); + return; + } + + void doSubmit(); + }, [isLoggedIn, selectedModel, doSubmit]); // ======== 动态预估消费金额(必须在所有提前返回之前) ======== diff --git a/src/pages/home/components/ModelSelector.tsx b/src/pages/home/components/ModelSelector.tsx index 93deab3..396028b 100644 --- a/src/pages/home/components/ModelSelector.tsx +++ b/src/pages/home/components/ModelSelector.tsx @@ -8,7 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a import { AppstoreOutlined } from '@ant-design/icons'; import { useHomeContext } from '@/pages/home/HomeContent'; -import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models'; +import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models'; +import { EnumResolver } from '@/utils/enum-resolver'; const { Text } = Typography; @@ -132,7 +133,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch } seenCategories.add(cat); items.push({ key: cat, - label: `${resolveCategoryLabel(cat)} (${groupModels.length})`, + label: `${EnumResolver.model.category.label(cat)} (${groupModels.length})`, }); } }); @@ -142,7 +143,7 @@ export function ModelSelector({ models, loading, error, groupedModels, refetch } if (!seenCategories.has(category) && groupModels.length > 0) { items.push({ key: category, - label: `${resolveCategoryLabel(category)} (${groupModels.length})`, + label: `${EnumResolver.model.category.label(category as ModelCategoryStringEnum)} (${groupModels.length})`, }); } }); diff --git a/src/pages/home/components/OutputPreview.tsx b/src/pages/home/components/OutputPreview.tsx index e8654f0..9ddb19c 100644 --- a/src/pages/home/components/OutputPreview.tsx +++ b/src/pages/home/components/OutputPreview.tsx @@ -3,7 +3,7 @@ // 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染 // ============================================================ -import { useMemo } from 'react'; +import { useMemo, useRef, useEffect } from 'react'; import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd'; import { EyeOutlined, @@ -13,7 +13,7 @@ import { import { useHomeContext } from '@/pages/home/HomeContent'; import { useTaskDetail } from '@/hooks/use-api'; -import type { TaskStatusString } from '@/services/modules/task'; +import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task'; const { Text } = Typography; @@ -50,7 +50,30 @@ export function OutputPreview() { const { selectedTask } = useHomeContext(); // 数据获取(Hook 自动处理:taskId 为 null 时不请求、竞态、loading/error) - const { data: detail, loading } = useTaskDetail(selectedTask?.id); + const { data: detail, loading, refetch } = useTaskDetail(selectedTask?.id); + + // ======== 状态转换监听:非终态→终态时触发详情刷新 ======== + // 不再独立轮询 getTaskStatus(避免与 TaskHistory 的批量轮询重复请求), + // 而是依赖批量轮询通过 Context 同步过来的 selectedTask.status 变化。 + + const prevStatusRef = useRef(undefined); + + useEffect(() => { + const status = selectedTask?.status as TaskStatusString | undefined; + const prev = prevStatusRef.current; + + // 从非终态变为终态 → 刷新详情获取完整数据(output_assets 等) + if ( + prev && + POLLING_STATUSES.includes(prev) && + status && + !POLLING_STATUSES.includes(status) + ) { + refetch(); + } + + prevStatusRef.current = status; + }, [selectedTask?.status, refetch]); // 从 output_assets 提取图片/视频 const { images, videos } = useMemo(() => { @@ -75,7 +98,7 @@ export function OutputPreview() { } // ---- 处理中 ---- - if (status === 'pending' || status === 'submitted' || status === 'processing') { + if (status && POLLING_STATUSES.includes(status)) { return (
diff --git a/src/pages/home/components/TaskHistory.tsx b/src/pages/home/components/TaskHistory.tsx index 488f8e2..d1dabe4 100644 --- a/src/pages/home/components/TaskHistory.tsx +++ b/src/pages/home/components/TaskHistory.tsx @@ -17,7 +17,7 @@ import type { ColumnsType, TableProps } from 'antd/es/table'; import type { FilterValue, SorterResult } from 'antd/es/table/interface'; import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'; -import { useTaskList } from '@/hooks/use-api'; +import { useTaskList, useTaskPolling } from '@/hooks/use-api'; import type { ModelsListResponseBody } from '@/services/modules/models'; import { useAppContext } from '@/components/AppProvider'; import { useHomeContext } from '@/pages/home/HomeContent'; @@ -286,6 +286,22 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { }); const tasks = taskData?.items ?? []; + + // 批量轮询非终态任务状态 + const handleTaskTerminal = useCallback(() => { + setRefreshTick((t) => t + 1); + }, []); + const polledTasks = useTaskPolling(tasks, handleTaskTerminal); + + // 轮询更新后同步选中的任务到 Context + useEffect(() => { + if (!selectedTask || polledTasks.length === 0) return; + const updated = polledTasks.find(t => t.id === selectedTask.id); + if (updated && updated.status !== selectedTask.status) { + setSelectedTask(updated); + } + }, [polledTasks, selectedTask, setSelectedTask]); + const total = taskData?.total ?? 0; // 从任务数据中提取用户列表(企业版筛选用) @@ -719,7 +735,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) { className="task-table" columns={columns} - dataSource={tasks} + dataSource={polledTasks} rowKey="id" size="small" loading={loading} diff --git a/src/pages/home/components/useUI/AudioListInput.tsx b/src/pages/home/components/useUI/AudioListInput.tsx index 05519fd..5d5fb04 100644 --- a/src/pages/home/components/useUI/AudioListInput.tsx +++ b/src/pages/home/components/useUI/AudioListInput.tsx @@ -7,6 +7,7 @@ import { Upload } from 'antd'; import { InboxOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import type { WidgetProps } from './types'; +import { useFileUpload } from '@/hooks/use-api'; const { Dragger } = Upload; @@ -16,6 +17,8 @@ export function AudioListInput({ value, onChange, disabled, config }: WidgetProp const maxCount = (config.maxFiles as number) || 10; const filters = config.fileFilter as string[] | undefined; + const { customRequest } = useFileUpload(); + return ( onChange?.(newList)} disabled={disabled} - beforeUpload={() => false} + beforeUpload={() => true} maxCount={maxCount} >

diff --git a/src/pages/home/components/useUI/ImageInput.tsx b/src/pages/home/components/useUI/ImageInput.tsx index 38a73f2..243f94d 100644 --- a/src/pages/home/components/useUI/ImageInput.tsx +++ b/src/pages/home/components/useUI/ImageInput.tsx @@ -19,6 +19,7 @@ import { UploadOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import type { WidgetProps } from './types'; import { createImageBeforeUpload } from './uploadValidation'; +import { useFileUpload } from '@/hooks/use-api'; const { Text } = Typography; @@ -36,6 +37,9 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) { const maxSizeMb = (config.maxFileSizeMb as number) || 10; const hasEnableSwitch = (config.enableSwitch as boolean) || false; + // 文件上传 Hook(提供 customRequest → antd Upload 集成) + const { customRequest } = useFileUpload(); + // 有 enable_switch 时默认禁用,需手动开启 const [switchOn, setSwitchOn] = useState(!hasEnableSwitch); const isUploadDisabled = disabled || !switchOn; @@ -75,6 +79,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) { maxCount={1} accept={buildAccept(config.fileFilter as string[] | undefined)} fileList={fileList} + customRequest={customRequest} onChange={({ fileList: newList }) => { // 仅当开关开启时上报文件变化 if (switchOn || !hasEnableSwitch) { diff --git a/src/pages/home/components/useUI/ImageListInput.tsx b/src/pages/home/components/useUI/ImageListInput.tsx index 1bc2b21..01fd896 100644 --- a/src/pages/home/components/useUI/ImageListInput.tsx +++ b/src/pages/home/components/useUI/ImageListInput.tsx @@ -8,6 +8,7 @@ import { InboxOutlined } from '@ant-design/icons'; import type { UploadFile } from 'antd/es/upload'; import type { WidgetProps } from './types'; import { createImageBeforeUpload } from './uploadValidation'; +import { useFileUpload } from '@/hooks/use-api'; const { Dragger } = Upload; @@ -17,6 +18,8 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp const maxCount = (config.maxFiles as number) || 10; const filters = config.fileFilter as string[] | undefined; + const { customRequest } = useFileUpload(); + return ( onChange?.(newList)} disabled={disabled} beforeUpload={createImageBeforeUpload(maxSizeMb)} diff --git a/src/pages/home/components/useUI/uploadValidation.ts b/src/pages/home/components/useUI/uploadValidation.ts index fa411f3..efb92a4 100644 --- a/src/pages/home/components/useUI/uploadValidation.ts +++ b/src/pages/home/components/useUI/uploadValidation.ts @@ -21,6 +21,6 @@ export function createImageBeforeUpload(maxSizeMb: number = 10) { message.error(`图片大小不能超过 ${maxSizeMb}MB`); return Upload.LIST_IGNORE; } - return false; + return true; // 校验通过,交由 customRequest 执行实际上传 }; } diff --git a/src/services/auth-token.ts b/src/services/auth-token.ts index d98e07d..b58234c 100644 --- a/src/services/auth-token.ts +++ b/src/services/auth-token.ts @@ -1,11 +1,16 @@ // ============================================================ -// auth-token — Token 生命周期管理(存取 refresh_token + 注册刷新回调) +// auth-token — Token 生命周期管理(存取 refresh_token + 刷新回调 + 主动续期) // // 职责: // - 持久化 / 清除 refresh_token(通过 safeStorage 加密) // - 向 request.ts 注册 handle401 触发的 token 刷新回调 +// - 主动刷新定时器:在 access_token 过期前自动续期,避免被动 401 // - request.ts 不依赖此模块(通过 setTokenRefreshHandler 解耦) // +// 刷新失败策略(关键设计): +// - 永久失败(refresh_token 已过期/无效)→ THROW → handle401 清 token + 弹登录 +// - 临时失败(网络超时/无连接) → return null → handle401 仅拒绝当前请求,不登出 +// // 安全: // - refresh_token 由 Electron safeStorage 加密后存入 localStorage // - 内存缓存供同步读取(setTokenRefreshHandler 回调内使用) @@ -23,7 +28,24 @@ import { const REFRESH_KEY = 'ele-heixiu-refresh-token'; -// ---------- 初始化 ---------- +// ============================================================ +// 自定义错误:refresh_token 永久失效 +// ============================================================ + +/** + * 当 refresh_token 已过期或被服务端拒绝时抛出。 + * handle401 据此区分"需要重新登录"和"网络暂时不通"。 + */ +export class RefreshTokenExpiredError extends Error { + constructor(message = '登录已过期,请重新登录') { + super(message); + this.name = 'RefreshTokenExpiredError'; + } +} + +// ============================================================ +// 初始化 +// ============================================================ /** * 启动时初始化 refresh_token:从加密存储解密到内存缓存。 @@ -33,7 +55,9 @@ export async function initRefreshTokenStore(): Promise { await initSafeStore(REFRESH_KEY); } -// ---------- 存取 ---------- +// ============================================================ +// 存取 +// ============================================================ /** 获取当前内存缓存中的 refresh_token(同步,需先调用 initRefreshTokenStore) */ export function getStoredRefreshToken(): string | null { @@ -50,19 +74,92 @@ export function clearStoredRefreshToken(): void { clearSafeStore(REFRESH_KEY); } -// ---------- 初始化(App 启动时调用一次) ---------- +// ============================================================ +// JWT 解码(用于主动刷新定时器) +// ============================================================ + +/** 从 JWT access_token 中提取过期时间(毫秒时间戳),解码失败返回 0 */ +function getTokenExpiryMs(token: string): number { + try { + const payload = JSON.parse(atob(token.split('.')[1])); + return (payload.exp as number) * 1000; + } catch { + return 0; + } +} + +// ============================================================ +// 主动刷新定时器 +// ============================================================ + +/** 主动刷新定时器句柄(模块级单例) */ +let refreshTimer: ReturnType | null = null; + +/** + * 安排在 access_token 过期前自动刷新。 + * 在 75% 过期时间点触发,确保在被动 401 之前完成续期。 + * + * @param accessToken - 当前有效的 access_token(JWT 格式) + */ +export function scheduleProactiveRefresh(accessToken: string): void { + // 先清除旧定时器 + clearProactiveRefresh(); + + const expiryMs = getTokenExpiryMs(accessToken); + if (!expiryMs) return; + + const remainingMs = expiryMs - Date.now(); + if (remainingMs <= 0) return; // 已经过期,等被动刷新 + + // 在 75% 过期时间点触发 + const delayMs = Math.max(60_000, remainingMs * 0.75); // 至少 60s 后再刷新 + + refreshTimer = setTimeout(async () => { + refreshTimer = null; + try { + const refreshToken = getStoredRefreshToken(); + if (!refreshToken) return; + + const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken }); + await setToken(res.access_token); + await setStoredRefreshToken(res.refresh_token); + + // 成功 → 安排下一次刷新 + scheduleProactiveRefresh(res.access_token); + } catch { + // 主动刷新失败 → 不弹登录,等请求 401 时走被动刷新 + } + }, delayMs); +} + +/** 清除主动刷新定时器(退出登录时调用) */ +export function clearProactiveRefresh(): void { + if (refreshTimer !== null) { + clearTimeout(refreshTimer); + refreshTimer = null; + } +} + +// ============================================================ +// 向 request.ts 注册 401 刷新回调(被动刷新) +// ============================================================ /** * 向 request.ts 注册 token 刷新回调。 - * 当任意请求收到 401 时,request.ts 会调用此回调尝试换新 token, - * 成功后自动重试原请求,失败则 emit AUTH_REQUIRED。 * - * 应在 AppProvider 挂载时调用。 + * 返回约定(由 handle401 解释): + * - 成功返回新 access_token → handle401 重试原请求 + * - return null → 临时失败(网络问题),handle401 拒绝当前请求但不登出 + * - throw RefreshTokenExpiredError → 永久失败,handle401 清 token + 弹登录 + * + * 应在 AppProvider 挂载时调用一次。 */ export function initAuthRefresh(): void { setTokenRefreshHandler(async (): Promise => { const refreshToken = getStoredRefreshToken(); - if (!refreshToken) return null; + if (!refreshToken) { + throw new RefreshTokenExpiredError(); + } try { const res: RefreshTokenResponseBody = await AuthAPI.refreshToken({ refresh_token: refreshToken }); @@ -71,26 +168,29 @@ export function initAuthRefresh(): void { await setToken(res.access_token); await setStoredRefreshToken(res.refresh_token); + // 主动刷新:为新 token 安排下次续期 + scheduleProactiveRefresh(res.access_token); + return res.access_token; } catch (err) { - // 区分错误类型: - // - RefreshError → refresh_token 已过期/无效 → 清除 - // - 网络错误(NetworkError/Timeout)→ 保留 refresh_token,等网络恢复后重试 - // - 其他认证错误 → 清除 + // ---- 永久失败:refresh_token 已过期/无效 ---- if (isRefreshError(err)) { clearStoredRefreshToken(); - } else if (err instanceof RequestError) { - if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) { - // 网络暂时不可用,保留 refresh_token,等下次 401 时再试 - return null; - } - // HTTP/Business 错误(如 5xx)→ 也保留,可能是服务端临时故障 - clearStoredRefreshToken(); - } else { - // 未知错误 → 保守清除 - clearStoredRefreshToken(); + clearProactiveRefresh(); + throw new RefreshTokenExpiredError(); } - return null; + + // ---- 临时失败:网络超时/无连接 → 保留 token,下次再试 ---- + if (err instanceof RequestError) { + if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) { + return null; // 临时失败,不登出 + } + } + + // ---- HTTP/Business 错误(如 5xx)→ 保守清除 ---- + clearStoredRefreshToken(); + clearProactiveRefresh(); + throw new RefreshTokenExpiredError(); } }); } diff --git a/src/services/modules/announcement.ts b/src/services/modules/announcement.ts index c75cd6e..3c0f49f 100644 --- a/src/services/modules/announcement.ts +++ b/src/services/modules/announcement.ts @@ -15,8 +15,35 @@ const AnnouncementUrlObj = { // ---------- 类型 ---------- -/** 公告类型枚举 */ -export type AnnouncementType = 'system' | 'feature' | 'activity' | 'maintenance' | string; +// ============================================================ +// 公告类型常量 +// ============================================================ + +export const AnnouncementTypeEnum = { + System: "system", + Feature: "feature", + Activity: "activity", + Maintenance: "maintenance", +} as const; + +export type AnnouncementTypeValue = typeof AnnouncementTypeEnum[keyof typeof AnnouncementTypeEnum]; + +export const AnnouncementTypeLabelMap: Record = { + [AnnouncementTypeEnum.System]: "系统公告", + [AnnouncementTypeEnum.Feature]: "功能更新", + [AnnouncementTypeEnum.Activity]: "活动通知", + [AnnouncementTypeEnum.Maintenance]: "维护公告", +}; + +export const AnnouncementTypeColorMap: Record = { + [AnnouncementTypeEnum.System]: "blue", + [AnnouncementTypeEnum.Feature]: "purple", + [AnnouncementTypeEnum.Activity]: "orange", + [AnnouncementTypeEnum.Maintenance]: "red", +}; + +/** 公告类型(向后兼容:包含已知枚举 + string 宽类型) */ +export type AnnouncementType = AnnouncementTypeValue | string; /** 单条公告 */ export interface Announcement { diff --git a/src/services/modules/banner.ts b/src/services/modules/banner.ts index 9642cfc..669a9a8 100644 --- a/src/services/modules/banner.ts +++ b/src/services/modules/banner.ts @@ -13,6 +13,22 @@ const BannerUrlObj = { // ---------- 类型 ---------- +// ============================================================ +// Banner 分类常量 +// ============================================================ + +export const BannerCategoryEnum = { + Promotion: "promotion", + Banner: "banner", +} as const; + +export type BannerCategoryValue = typeof BannerCategoryEnum[keyof typeof BannerCategoryEnum]; + +export const BannerCategoryLabelMap: Record = { + [BannerCategoryEnum.Promotion]: "推广", + [BannerCategoryEnum.Banner]: "轮播", +}; + /** 单条 Banner */ export interface Banner { id: number; @@ -20,7 +36,7 @@ export interface Banner { content: "真人素材提示"; image_url: string; link_url?: string; - category: "promotion" | "banner"; + category: BannerCategoryValue; vip_level_id?: number; sort_order: number; is_active: boolean; diff --git a/src/services/modules/billing.ts b/src/services/modules/billing.ts index 8c630d1..8dc141d 100644 --- a/src/services/modules/billing.ts +++ b/src/services/modules/billing.ts @@ -54,4 +54,38 @@ export class BillingAPI { } static exportUrl = BillingUrlObj.ExportLedger; -} \ No newline at end of file +} + +// ============================================================ +// 账单条目类型常量 +// ============================================================ + +export const EntryTypeEnum = { + TaskHold: "task_hold", + Recharge: "recharge", + Gift: "gift", + HoldRelease: "hold_release", + HoldSettle: "hold_settle", +} as const; + +export type EntryTypeValue = typeof EntryTypeEnum[keyof typeof EntryTypeEnum]; + +export const EntryTypeLabelMap = { + [EntryTypeEnum.TaskHold]: "任务消耗", + [EntryTypeEnum.Recharge]: "充值", + [EntryTypeEnum.Gift]: "赠送", + [EntryTypeEnum.HoldRelease]: "退款", + [EntryTypeEnum.HoldSettle]: "结算", +} as const; + + +export const EntryTypeColorMap = { + [EntryTypeEnum.TaskHold]: "orange", + [EntryTypeEnum.Recharge]: "green", + [EntryTypeEnum.Gift]: "green", + [EntryTypeEnum.HoldRelease]: "blue", + [EntryTypeEnum.HoldSettle]: "orange", +} as const; + +export type EntryUITypeValue = typeof EntryTypeLabelMap[keyof typeof EntryTypeLabelMap]; +export type ColorValue = typeof EntryTypeColorMap[keyof typeof EntryTypeColorMap]; diff --git a/src/services/modules/task.ts b/src/services/modules/task.ts index e0703d9..49e00cd 100644 --- a/src/services/modules/task.ts +++ b/src/services/modules/task.ts @@ -12,6 +12,7 @@ import {get, post} from '../request'; import {getDeviceId} from '@/utils/device.ts'; +import {Nullable} from "@/utils/type.ts"; // ============================================================ // 基础枚举 / 字面量类型 @@ -37,6 +38,9 @@ export enum TaskStatusMap { export type TaskStatusString = keyof typeof TaskStatusMap; +/** 需要持续轮询的非终态状态 */ +export const POLLING_STATUSES: TaskStatusString[] = ['pending', 'submitted', 'processing']; + // ============================================================ // 请求体 / 参数类型 // ============================================================ @@ -62,6 +66,23 @@ export interface CreatTaskRequestBody { tags?: string[]; } +/** + * 批量任务轮询查询参数 + */ +export interface BatchTaskStatusRequestParam { + task_ids: string[]; +} + +/** + * 批量任务状态轮询响应头 + */ +export interface BatchTaskStatusResponseBody { + items: Nullable[], + balance_cent: number, + next_poll_ms: number; +} + + /** 导出 CSV 参数 */ export interface ExportTaskCSVParams { start_date: Date; @@ -183,11 +204,19 @@ export interface TaskInfoDataResponseBody { export interface TaskStatusResponseBody { task_id: string; status: TaskStatusString; - progress: number; + progress?: number; output_assets?: string[]; error_code?: string; error_message?: string; cost_cent?: number; + queue_position: number, + submitted_at: Date, + finished_at: Date, + external_task_id: string, + refund_cent: number, + refunded_at: Date, + refund_reason: string, + next_poll_ms: number } // ============================================================ @@ -199,6 +228,7 @@ const TaskUrlObj = { taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`, taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`, tasksCSV: '/api/v1/tasks/export', + batchTaskStatus: "/api/v1/tasks/status/batch" } as const; // ============================================================ @@ -208,7 +238,7 @@ const TaskUrlObj = { export class TaskAPI { /** 获取任务列表(分页/游标) */ static getTaskList(params: TaskListRequestParams, signal?: AbortSignal): Promise { - return get(TaskUrlObj.task, params as Record, { signal }); + return get(TaskUrlObj.task, params as Record, {signal}); } /** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */ @@ -221,7 +251,7 @@ export class TaskAPI { /** 获取任务详情 */ static getTaskInfo(task_id: string, signal?: AbortSignal): Promise { - return get(TaskUrlObj.taskInfo(task_id), undefined, { signal }); + return get(TaskUrlObj.taskInfo(task_id), undefined, {signal}); } /** 轻量轮询任务状态 */ @@ -229,6 +259,11 @@ export class TaskAPI { return get(TaskUrlObj.taskStatus(task_id)); } + /** 批量查询任务状态 — GET + 逗号拼接 task_ids(避免 axios 默认 bracket 序列化 task_ids[]=...) */ + static batchTaskStatus(body: BatchTaskStatusRequestParam): Promise { + return post(TaskUrlObj.batchTaskStatus, body); + } + /** 导出任务 CSV */ static exportTaskCSV(params: ExportTaskCSVParams): Promise { return get(TaskUrlObj.tasksCSV, params as unknown as Record); diff --git a/src/services/request.ts b/src/services/request.ts index d7f9283..bbad2fb 100644 --- a/src/services/request.ts +++ b/src/services/request.ts @@ -145,21 +145,23 @@ function handle401(config: InternalAxiosRequestConfig): Promise { if (newToken) { + // 刷新成功 → 用新 token 重试队列和当前请求 processQueue(null, newToken); config.headers.Authorization = `Bearer ${newToken}`; resolve(instance(config)); } else { - const err = new RefreshError(); - processQueue(err, null); - clearToken(); - emit(EVENTS.AUTH_REQUIRED); - reject(err); + // null = 临时失败(网络超时/无连接) + // → 保留 token,仅拒绝当前请求;下次 401 再试(不弹登录) + processQueue(new RefreshError(), null); + reject(new RefreshError()); } isRefreshing = false; refreshPromise = null; }) - .catch((err) => { - processQueue(err, null); + .catch((_err) => { + // throw = 永久失败(refresh_token 已过期/无效) + // → 清 token + 弹登录 + processQueue(_err, null); isRefreshing = false; refreshPromise = null; clearToken(); diff --git a/src/utils/enum-resolver.ts b/src/utils/enum-resolver.ts new file mode 100644 index 0000000..9ed8e63 --- /dev/null +++ b/src/utils/enum-resolver.ts @@ -0,0 +1,153 @@ +// ============================================================ +// EnumResolver — 统一枚举解析器(Facade Pattern) +// +// 聚合所有业务模块的 LabelMap/ColorMap,提供类型安全的 +// 字典查询 + 运行时兜底。 +// +// 设计意图: +// - 单一入口:组件只需 import EnumResolver,无需分别导入各模块 +// - 类型安全:label/color 参数为具体枚举字面量,编译时阻止拼写错误 +// - 运行时兜底:通过 || value || '默认文案' 处理后端新增枚举值 +// +// 设计模式: +// Facade(外观) — 屏蔽 7 个模块的复杂性,暴露统一 API +// Namespace Object — 按业务域二级分组 +// Strategy — 每个 leaf 是可替换的解析策略 +// +// 使用示例: +// EnumResolver.billing.entryType.label('task_hold') // "任务消耗" +// EnumResolver.auth.userRole.label('admin') // "管理账户" +// EnumResolver.announcement.type.color('feature') // "purple" +// ============================================================ + +import { + EntryTypeLabelMap, + EntryTypeColorMap, + type EntryTypeValue, +} from '@/services/modules/billing'; + +import { + UserRoleTypeLabelMap, + UserAccountTypeLabelMap, + type UserRoleTypeValue, + type UserAccountTypeValue, +} from '@/services/modules/auth'; + +import { + VipOrderStatusLabelMap, + TargetAccountTypeLabelMap, + type VipOrderStatusValue, + type TargetAccountTypeValue, +} from '@/services/modules/vip-api'; + +import { + OutputTypeMap, + TaskStatusMap, + type OutputTypeString, + type TaskStatusString, +} from '@/services/modules/task'; + +import { + BannerCategoryLabelMap, + type BannerCategoryValue, +} from '@/services/modules/banner'; + +import { + AnnouncementTypeLabelMap, + AnnouncementTypeColorMap, + type AnnouncementTypeValue, +} from '@/services/modules/announcement'; + +import { + MODEL_CATEGORY_LABELS, + CATEGORY_SLUG_MAP, + type ModelCategoryStringEnum, +} from '@/services/modules/models'; + +// ============================================================ +// 模型分类 slug → 前端标识反向映射 +// ============================================================ + +const SLUG_TO_CATEGORY: Record = Object.fromEntries( + Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug as string, cat]), +); + +// ============================================================ +// EnumResolver +// ============================================================ + +export const EnumResolver = { + /** 账单 */ + billing: { + entryType: { + label: (value: EntryTypeValue) => + EntryTypeLabelMap[value] || value || '未知账单类型', + color: (value: EntryTypeValue) => + EntryTypeColorMap[value] || 'default', + }, + }, + + /** 用户身份 */ + auth: { + userRole: { + label: (value: UserRoleTypeValue) => + UserRoleTypeLabelMap[value] || value || '未知角色', + }, + accountType: { + label: (value: UserAccountTypeValue) => + UserAccountTypeLabelMap[value] || value || '未知账户类型', + }, + }, + + /** 任务 */ + task: { + outputType: { + label: (value: OutputTypeString) => + OutputTypeMap[value] || value || '未知输出类型', + }, + status: { + label: (value: TaskStatusString) => + TaskStatusMap[value] || value || '未知任务状态', + }, + }, + + /** VIP */ + vip: { + orderStatus: { + label: (value: VipOrderStatusValue) => + VipOrderStatusLabelMap[value] || value || '未知订单状态', + }, + targetAccountType: { + label: (value: TargetAccountTypeValue) => + TargetAccountTypeLabelMap[value] || value || '未知账户类型', + }, + }, + + /** Banner */ + banner: { + category: { + label: (value: BannerCategoryValue) => + BannerCategoryLabelMap[value] || value || '未知分类', + }, + }, + + /** 公告 */ + announcement: { + type: { + label: (value: AnnouncementTypeValue) => + AnnouncementTypeLabelMap[value] || value || '未知公告类型', + color: (value: AnnouncementTypeValue) => + AnnouncementTypeColorMap[value] || 'default', + }, + }, + + /** 模型分类(两步解析:前端字面量 > 后端 slug 反向映射 > 兜底) */ + model: { + category: { + label: (value: ModelCategoryStringEnum) => + MODEL_CATEGORY_LABELS[value] || + MODEL_CATEGORY_LABELS[SLUG_TO_CATEGORY[value] as ModelCategoryStringEnum] || + value, + }, + }, +};