From 95019316b258789f4a5294291b125d75e5161db9 Mon Sep 17 00:00:00 2001 From: YoungestSongMo <2130460579@qq.com> Date: Wed, 10 Jun 2026 19:38:26 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=89=8D=E5=90=8E=E7=AB=AF=E8=81=94?= =?UTF-8?q?=E8=B0=83=20=E2=80=94=20=E4=BB=BB=E5=8A=A1=E5=88=9B=E5=BB=BA=20?= =?UTF-8?q?+=20=E8=B4=A6=E6=88=B7=E4=BF=A1=E6=81=AF=20+=20=E9=A2=84?= =?UTF-8?q?=E4=BC=B0=E8=8A=B1=E8=B4=B9=20(0.0.18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - task: device_id 改为必填参数,API 层 Omit 封装 + getDeviceId() 自动注入 - pricing.ts: Python 后端 calculate_cost 完整移植,六种定价模式 + 6 个 Bug 修复 (浮点冗余/维度匹配不一致/null 守卫/matrix 数据提取/unit_scale 等) - ModelInputForm: 三层兜底预估消费展示 + React Hooks 顺序修复 - AccountInfo: VIP 等级列表匹配,套餐详情展示(说明/周期/席位/到期) - 模型列表加密缓存 (safe-storage, stale-while-revalidate) - VIP API 模块 (激活码/等级/订单/模拟支付) - device_id 统一使用 UUID v4 持久化 (与 login/register 对齐) --- .codegraph/daemon.pid | 4 +- .gitignore | 1 + CHANGELOG.md | 55 +++ CLAUDE.md | 4 +- electron/preload/device.ts | 279 +++++++++++ electron/preload/device_service.ts | 200 ++++++++ package.json | 2 +- src/components/AppProvider.tsx | 5 +- src/components/PageDispatcher.tsx | 48 +- src/hooks/use-api.ts | 49 +- src/pages/headers/AccountInfo.tsx | 401 ++++++++------- src/pages/home/components/ModelInputForm.tsx | 89 +++- src/services/cache/model-cache.ts | 51 ++ src/services/modules/auth.ts | 56 ++- src/services/modules/models.ts | 2 +- src/services/modules/task.ts | 12 +- src/services/modules/vip-api.ts | 154 ++++++ src/utils/pricing.ts | 488 +++++++++++++++++++ test-server/README.md | 73 +-- 19 files changed, 1681 insertions(+), 292 deletions(-) create mode 100644 electron/preload/device.ts create mode 100644 electron/preload/device_service.ts create mode 100644 src/services/cache/model-cache.ts create mode 100644 src/services/modules/vip-api.ts create mode 100644 src/utils/pricing.ts diff --git a/.codegraph/daemon.pid b/.codegraph/daemon.pid index a65585c..31a78d0 100644 --- a/.codegraph/daemon.pid +++ b/.codegraph/daemon.pid @@ -1,6 +1,6 @@ { - "pid": 568932, + "pid": 844000, "version": "0.9.9", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", - "startedAt": 1780880691721 + "startedAt": 1781055291389 } diff --git a/.gitignore b/.gitignore index c12c67c..0896f7e 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ dist-ssr WORKLOG.md *open*.json response_*.json +test-server/__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 91b2a97..7e2a3d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 --- +## 0.0.18(2026-06-10) + +**前后端联调 — 任务创建 + 账户信息 + 预估花费** + +### 任务创建联调 + +- `CreatTaskRequestBody.device_id` 改为后端必填参数,API 层 `Omit` 封装,自动注入 `getDeviceId()`(UUID v4 持久化,与登录/注册统一) +- `useSubmitTask` 泛型同步改为 `Omit`,调用方无感 +- `submitTask` 移除 `body.device_id || getDeviceId()` 兜底逻辑,始终以本地生成的设备 ID 为准 + +### 预估花费计算(与 QT/Python 后端对齐) + +- `src/utils/pricing.ts` — 从 Python 后端 `calculate_cost` 完整移植六种定价模式: + - 服务端:`SIMPLE` · `MULTI_DIMENSION` · `BILLING_ADAPTER`(per_k_chars / rate + 条件附加费) + - 客户端:`fixed` · `per_k_chars` · `rate_matrix` · `matrix` +- `ModelInputForm.tsx` — 三层兜底预估消费展示:完整动态计价 → 配置文件兜底提取 → 模式标签 +- **修复 6 个定价 Bug**: + - SIMPLE 浮点冗余 `(x/100)*100` 在 IEEE 754 下不恒等 → `Math.round(分子)/100` + - `rate_matrix` / `matrix` 内联维度匹配缺少 `.trim().toLowerCase()` + null 跳过 → 统一 `matchDimensions()` + - `lookupMappingValue` / `matchDimensions` 缺 null 守卫 → `Cannot use 'in' operator` 崩溃 + - `rate_matrix` 错误应用 `unit_scale`(Python spec 无此行为)→ 移除乘法 + - `matrix` 模式 `dimensions` 空值提前返 null(与 `MULTI_DIMENSION` 不一致)→ 对齐处理 + - 矩阵维度提取 `row.raw` — API 实际在行顶层存储维度(如 `resolution`),`raw` 子对象不存在 → `matrixRowData()` 兼容两种格式 +- `pricing_config` 类型修正:`Record` → `Record | null`(对齐 OpenAPI `anyOf`) +- 修复 React Hooks 顺序违例 — `estimatedCostDisplay` useMemo 移至提前返回之前 + +### 账户信息联调 + +- `AccountInfo.tsx` "当前套餐"从 VIP 等级列表匹配详细数据: + - `vip_level_id` → `VipLevelsItem.id` 映射查找 + - 新增套餐说明、套餐周期字段,席位上限优先取 VIP 等级值 + - 到期时间提取 `isExpired` 变量复用 +- `VipAPI.getVipLevels()` 与 `AuthAPI.getUserInfo()` 并行加载 + +### 模型列表缓存 + +- 加密持久化缓存(safe-storage):启动时 `Promise.all` 初始化,缓存优先渲染消除 loading 闪烁 +- `useModelList` Stale-while-revalidate:即时展示缓存 → 后台拉新 → 覆盖缓存 + +### 其他 + +- VIP API 模块(`src/services/modules/vip-api.ts`)— 激活码/等级列表/订单/模拟支付 +- ESLint/TS 类型修复(未使用符号清理、分号补充) +- CLAUDE.md 更新系统兼容性说明 + +--- + ## 0.0.17(2026-06-09) **修复接口重复调用 — 请求量减半** @@ -10,6 +57,14 @@ - AppProvider 自动登录 useEffect 增加 aborted 清理标志,修复 StrictMode 双重挂载导致 refreshToken/getUserInfo 各调两次 - useModelList 提升到 LeftPanel 单例调用,修复 ModelSelector 和 TaskHistory 各自独立请求全量模型列表 - 开发模式 DevTools 中"已停止"的请求即为 StrictMode 第一轮挂载被丢弃的请求 +- 模型输入栏三层架构:useUI 组件体系(10 种 widget → antd 映射)+ useModelUI Hook(param_schema → UIFieldConfig[]) +- constraints_config.requires 动态联动:enable_switch 开关控制依赖字段启用/禁用 +- 修复模型默认值不加载(antd 外部 form 实例忽略 initialValues)+ DependentFormItem prop 透传断裂 +- PageDispatcher 统一页面调度:Modal/Drawer/FloatingPanel 三容器,全局互斥,动态视口尺寸 +- 账户信息页:AuthAPI.getUserInfo() 驱动,积分/套餐/统计三卡片布局 +- 代码去重:runFieldChecks 校验运行器 + createImageBeforeUpload 上传校验工厂 +- 主题切换性能优化 — View Transitions API + 0.15s 统一过渡 + 设置页增强(0.0.15 延续) +- 目录调整:wechat-work/ → headers/ --- diff --git a/CLAUDE.md b/CLAUDE.md index 2c16fa5..09fd954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,7 @@ - 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 - 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 +- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS。 - 有需要请自己调用MCP服务 # 主题系统架构 @@ -23,5 +24,6 @@ ThemeProvider.useLayoutEffect([isDark]) ## 过渡策略 - **Electron/Chromium**:View Transitions API(`document.startViewTransition` + `flushSync`),GPU 合成器单次 cross-fade -- **其他浏览器**:CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有 transition) +- **其他浏览器**:CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有 + transition) - **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效 \ No newline at end of file diff --git a/electron/preload/device.ts b/electron/preload/device.ts new file mode 100644 index 0000000..20c15b4 --- /dev/null +++ b/electron/preload/device.ts @@ -0,0 +1,279 @@ +/** + * 设备指纹采集 — TypeScript 实现 + * + * 与 device.py 逻辑一致,保留原 Python 方法不变。 + * 通过采集 UUID / CPU / HDD 三项硬件 ID,拼接后做 SHA256 得到 device_id。 + * + * 适用环境: + * - Node.js / Electron(Windows):通过 child_process 执行 wmic / powershell 采集硬件 ID + * - 浏览器:仅提供 SHA256 哈希工具函数,硬件采集需由上层注入 + */ + +import * as crypto from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { platform } from "node:os"; + +// ============================================================ +// 类型定义 +// ============================================================ + +/** 可采集的硬件组件类型 */ +export type Component = "uuid" | "cpu" | "hdd"; + +/** 查询方式 */ +export type QueryMethod = "auto" | "ps" | "powershell" | "wmic"; + +/** 内部解析后的方法 */ +type ResolvedMethod = "auto" | "powershell" | "wmic"; + +// ============================================================ +// 查询配置(对应 Python _COMPONENT_QUERIES) +// ============================================================ + +interface ComponentQuery { + powershell: string; + wmicArgs: string[]; + headerTokens: string[]; +} + +const COMPONENT_QUERIES: Record = { + uuid: { + powershell: + "Get-CimInstance -Class Win32_ComputerSystemProduct | " + + "Select-Object -ExpandProperty UUID", + wmicArgs: ["wmic", "csproduct", "get", "uuid"], + headerTokens: ["uuid"], + }, + cpu: { + powershell: + "Get-CimInstance -Class Win32_Processor | " + + "Select-Object -ExpandProperty ProcessorId", + wmicArgs: ["wmic", "cpu", "get", "processorid"], + headerTokens: ["processorid"], + }, + hdd: { + powershell: + "Get-CimInstance -Class Win32_DiskDrive | " + + "Select-Object -First 1 -ExpandProperty SerialNumber", + wmicArgs: ["wmic", "diskdrive", "get", "serialnumber"], + headerTokens: ["serialnumber"], + }, +}; + +// ============================================================ +// 工具函数 +// ============================================================ + +/** 对应 Python: _normalize_identifier(value) -> str */ +function normalizeIdentifier(value: string): string { + if (!value) return ""; + return value.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); +} + +/** 对应 Python: _first_value_from_output(raw_output, header_tokens) -> str */ +function firstValueFromOutput( + rawOutput: string, + headerTokens: readonly string[], +): string { + if (!rawOutput) return ""; + const headers = new Set(headerTokens.map((t) => t.toLowerCase())); + for (const line of rawOutput.split(/\r?\n/)) { + const candidate = line.trim(); + if (!candidate) continue; + if (headers.has(candidate.toLowerCase())) continue; + return candidate; + } + return ""; +} + +/** 对应 Python: _resolve_method(method) -> _ResolvedMethod */ +function resolveMethod(method: QueryMethod): ResolvedMethod { + if (method === "ps") return "powershell"; + return method; +} + +/** 计算 SHA256 哈希(hex 大写),对应 Python hashlib.sha256(...).hexdigest().upper() */ +function sha256HexUpper(input: string): string { + return crypto.createHash("sha256").update(input, "utf-8").digest("hex").toUpperCase(); +} + +// ============================================================ +// 环境检测 +// ============================================================ + +function isWindows(): boolean { + return platform() === "win32"; +} + +function isWmicAvailable(): boolean { + if (!isWindows()) return false; + const result = spawnSync("where", ["wmic"], { + timeout: 3000, + stdio: "ignore", + windowsHide: true, + }); + return result.status === 0; +} + +function isPowershellAvailable(): boolean { + if (!isWindows()) return false; + const result = spawnSync("where", ["powershell"], { + timeout: 3000, + stdio: "ignore", + windowsHide: true, + }); + return result.status === 0; +} + +// ============================================================ +// 进程执行 +// ============================================================ + +/** + * 执行命令并返回 stdout(去除首尾空白)。 + * 对应 Python: _run_process(args, timeout=5.0) -> str + */ +function runProcess(args: string[], timeoutSec: number = 5.0): string { + const command = args[0]; + const cmdArgs = args.slice(1); + try { + const result = spawnSync(command, cmdArgs, { + timeout: Math.round(timeoutSec * 1000), + encoding: "utf-8", + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, // 对应 Python STARTF_USESHOWWINDOW + }); + if (result.status !== 0) return ""; + return (result.stdout ?? "").trim(); + } catch { + return ""; + } +} + +/** 对应 Python: _run_powershell(command) -> str */ +function runPowershell(command: string): string { + if (!isPowershellAvailable()) return ""; + const args = ["powershell", "-NoProfile", "-NonInteractive", "-Command", command]; + return runProcess(args); +} + +/** 对应 Python: _run_wmic(args) -> str */ +function runWmic(args: readonly string[]): string { + if (!isWmicAvailable()) return ""; + return runProcess([...args]); +} + +// ============================================================ +// DeviceFingerprint 类 +// ============================================================ + +export class DeviceFingerprint { + // private wmicAvailableCache: boolean | null = null; + // private powershellAvailableCache: boolean | null = null; + + // ---------- 组件 ID 查询 ---------- + + /** + * 查询单个硬件组件 ID。 + * 对应 Python: get_component_id(component, method="auto") -> str + */ + getComponentId(component: Component, method: QueryMethod = "auto"): string { + const query = COMPONENT_QUERIES[component]; + if (!query) return ""; + + const resolvedMethod = resolveMethod(method); + + const readViaPowershell = (): string => { + const rawOutput = runPowershell(query.powershell); + return firstValueFromOutput(rawOutput, query.headerTokens); + }; + + const readViaWmic = (): string => { + const rawOutput = runWmic(query.wmicArgs); + return firstValueFromOutput(rawOutput, query.headerTokens); + }; + + let rawValue = ""; + if (resolvedMethod === "powershell") { + rawValue = readViaPowershell(); + } else if (resolvedMethod === "wmic") { + rawValue = readViaWmic(); + if (!rawValue) { + rawValue = readViaPowershell(); + } + } else { + // auto: wmic 优先,回退 powershell + rawValue = readViaWmic() || readViaPowershell(); + } + + return normalizeIdentifier(rawValue); + } + + /** + * 查询单个硬件组件(别名)。 + * 对应 Python: get_component(component_type, method="auto") -> str + */ + getComponent(component: Component, method: QueryMethod = "auto"): string { + return this.getComponentId(component, method); + } + + // ---------- 机器指纹 ---------- + + /** + * 生成设备指纹 SHA256。 + * 采集 UUID + HDD + CPU,拼接后做 SHA256 哈希。 + * 对应 Python: fingerprint_sha256(method="auto") -> str | None + */ + fingerprintSha256(method: QueryMethod = "auto"): string | null { + const resolvedMethod = resolveMethod(method); + const wmicOk = isWmicAvailable(); + const psOk = isPowershellAvailable(); + + if (resolvedMethod === "powershell") { + if (!psOk) return null; + } else { + if (!wmicOk && !psOk) return null; + } + + const uuidId = this.getComponentId("uuid", method); + const diskId = this.getComponentId("hdd", method); + const cpuId = this.getComponentId("cpu", method); + + if (!uuidId && !diskId && !cpuId) { + return null; + } + + const combined = `${uuidId}|${diskId}|${cpuId}`; + const normalized = normalizeIdentifier(combined); + return sha256HexUpper(normalized); + } + + /** + * 获取机器码(指纹的别名)。 + * 对应 Python: get_machine_code(method="auto") -> str | None + */ + getMachineCode(method: QueryMethod = "auto"): string | null { + return this.fingerprintSha256(method); + } +} + +// ============================================================ +// 便捷函数(无需实例化) +// ============================================================ + +const defaultFingerprint = new DeviceFingerprint(); + +/** 获取机器码(单例便捷调用) */ +export function getMachineCode(method: QueryMethod = "auto"): string | null { + return defaultFingerprint.getMachineCode(method); +} + +/** 获取设备指纹 SHA256(单例便捷调用) */ +export function fingerprintSha256(method: QueryMethod = "auto"): string | null { + return defaultFingerprint.fingerprintSha256(method); +} + +/** 查询单个硬件组件 ID(单例便捷调用) */ +export function getComponentId(component: Component, method: QueryMethod = "auto"): string { + return defaultFingerprint.getComponentId(component, method); +} diff --git a/electron/preload/device_service.ts b/electron/preload/device_service.ts new file mode 100644 index 0000000..9122abf --- /dev/null +++ b/electron/preload/device_service.ts @@ -0,0 +1,200 @@ +/** + * 设备信息服务 — TypeScript 实现 + * + * 与 device_service.py 逻辑一致,保留原 Python 方法不变。 + * 提供带缓存的机器码、用户 IP、MAC 地址查询。 + * + * 适用环境:Node.js / Electron + */ + +import * as os from "node:os"; +import * as dgram from "node:dgram"; +import { DeviceFingerprint } from "./device"; +import type { QueryMethod } from "./device"; + +// ============================================================ +// 工具函数 +// ============================================================ + +/** 对应 Python: _is_valid_ipv4(value) -> bool */ +function isValidIPv4(value: string): boolean { + const parts = value.split("."); + if (parts.length !== 4) return false; + for (const part of parts) { + const num = parseInt(part, 10); + if (isNaN(num) || num < 0 || num > 255) return false; + if (part.length > 1 && part.startsWith("0")) return false; + } + // 排除环回地址和 0.0.0.0 + if (value.startsWith("127.") || value === "0.0.0.0") return false; + return true; +} + +/** 对应 Python: _resolve_user_ip() -> Optional[str] */ +function resolveUserIp(): string | null { + // 优先通过 UDP "伪连接" 获取本机出站 IP + try { + const sock = dgram.createSocket("udp4"); + // 同步方式不可行;使用 UDP connect + getsockname 的异步模式 + // 但在 Node.js 中这本质是同步的 (connect 不发送数据) + sock.connect(80, "8.8.8.8"); + const address = sock.address(); + sock.close(); + if (address && typeof address.address === "string") { + const ip = address.address; + if (isValidIPv4(ip)) return ip; + } + } catch { + // 忽略 + } + + // 回退: 遍历网络接口查找第一个有效的非环回 IPv4 + const interfaces = os.networkInterfaces(); + for (const ifaceList of Object.values(interfaces)) { + if (!ifaceList) continue; + for (const iface of ifaceList) { + if (iface.family === "IPv4") { + const ip = iface.address; + if (isValidIPv4(ip)) return ip; + } + } + } + + return null; +} + +/** 对应 Python: _resolve_user_mac_address() -> Optional[str] */ +function resolveUserMacAddress(): string | null { + const interfaces = os.networkInterfaces(); + for (const ifaceList of Object.values(interfaces)) { + if (!ifaceList) continue; + for (const iface of ifaceList) { + // 跳过环回接口 + if (iface.internal) continue; + if (iface.mac && iface.mac !== "00:00:00:00:00:00") { + // 对应 Python: 检查多播位 + const parts = iface.mac.split(":"); + if (parts.length === 6) { + const firstByte = parseInt(parts[0], 16); + // uuid.getnode 的多播位检查: (node >> 40) & 1 + if ((firstByte & 1) === 0) { + return iface.mac.toUpperCase(); + } + } + } + } + } + return null; +} + +// ============================================================ +// 未解析标记 +// ============================================================ + +const UNRESOLVED = Symbol("unresolved"); + +// ============================================================ +// DeviceService 类 +// ============================================================ + +export class DeviceService { + private fingerprint = new DeviceFingerprint(); + + private cachedMachineCode: string | null | typeof UNRESOLVED = UNRESOLVED; + private cachedUserIp: string | null | typeof UNRESOLVED = UNRESOLVED; + private cachedUserMacAddress: string | null | typeof UNRESOLVED = UNRESOLVED; + + private warmupStarted = false; + + /** + * 获取机器码(带缓存)。 + * 对应 Python: get_machine_code() -> Optional[str] + */ + getMachineCode(method: QueryMethod = "auto"): string | null { + if (this.cachedMachineCode !== UNRESOLVED) { + return this.cachedMachineCode; + } + + const machineCode = this.fingerprint.getMachineCode(method); + if (this.cachedMachineCode === UNRESOLVED) { + this.cachedMachineCode = machineCode; + } + return machineCode; + } + + /** + * 获取用户本机 IP(带缓存)。 + * 对应 Python: get_user_ip() -> Optional[str] + */ + getUserIp(): string | null { + if (this.cachedUserIp !== UNRESOLVED) { + return this.cachedUserIp; + } + + const value = resolveUserIp(); + if (this.cachedUserIp === UNRESOLVED && value) { + this.cachedUserIp = value; + } + return value; + } + + /** + * 获取用户 MAC 地址(带缓存)。 + * 对应 Python: get_user_mac_address() -> Optional[str] + */ + getUserMacAddress(): string | null { + if (this.cachedUserMacAddress !== UNRESOLVED) { + return this.cachedUserMacAddress; + } + + const value = resolveUserMacAddress(); + if (this.cachedUserMacAddress === UNRESOLVED) { + this.cachedUserMacAddress = value; + } + return value; + } + + /** + * 异步预热机器码(后台线程)。 + * 对应 Python: warm_up_machine_code() -> None + */ + warmUpMachineCode(): void { + if (this.cachedMachineCode !== UNRESOLVED) return; + if (this.warmupStarted) return; + this.warmupStarted = true; + + // 使用 setImmediate 模拟后台线程,不阻塞当前事件循环 + setImmediate(() => { + const machineCode = this.fingerprint.getMachineCode(); + if (this.cachedMachineCode === UNRESOLVED) { + this.cachedMachineCode = machineCode; + } + }); + } +} + +// ============================================================ +// 便捷函数(单例) +// ============================================================ + +const defaultService = new DeviceService(); + +/** 获取机器码(单例,带缓存) */ +export function getMachineCode(method: QueryMethod = "auto"): string | null { + return defaultService.getMachineCode(method); +} + +/** 获取用户本机 IP(单例,带缓存) */ +export function getUserIp(): string | null { + return defaultService.getUserIp(); +} + +/** 获取用户 MAC 地址(单例,带缓存) */ +export function getUserMacAddress(): string | null { + return defaultService.getUserMacAddress(); +} + +/** 异步预热机器码 */ +export function warmUpMachineCode(): void { + defaultService.warmUpMachineCode(); +} diff --git a/package.json b/package.json index 50f83ed..08cab97 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ele-heixiu", "private": true, - "version": "0.0.14", + "version": "0.0.18", "description": "船长·HeiXiu — 桌面效率工作台", "author": "HeiXiu 杨烨", "type": "module", diff --git a/src/components/AppProvider.tsx b/src/components/AppProvider.tsx index 2d2e5af..6478d8d 100644 --- a/src/components/AppProvider.tsx +++ b/src/components/AppProvider.tsx @@ -24,6 +24,7 @@ import { clearStoredRefreshToken, initRefreshTokenStore, } from '@/services/auth-token'; +import {initModelCache} from '@/services/cache/model-cache'; // ---------- Context 类型 ---------- @@ -114,8 +115,8 @@ export function AppProvider({ children }: AppProviderProps) { useEffect(() => { let aborted = false; - // 从加密存储恢复 token 到内存缓存 - Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => { + // 从加密存储恢复 token + 模型缓存 到内存缓存 + Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => { if (aborted) return; // 初始化 401 刷新回调(token 缓存就绪后才注册) diff --git a/src/components/PageDispatcher.tsx b/src/components/PageDispatcher.tsx index 48780a7..08837ea 100644 --- a/src/components/PageDispatcher.tsx +++ b/src/components/PageDispatcher.tsx @@ -16,12 +16,12 @@ // 新增页面只需在这里加一行,无需创建独立文件 // ============================================================ -import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Drawer, Modal } from 'antd'; -import { EVENTS, off, on } from '@/utils/event-bus'; -import { FloatingPanel } from '@/components/ui/FloatingPanel'; -import { WechatWorkPage } from '@/pages/headers/WechatWorkPage'; -import { AccountInfo } from '@/pages/headers/AccountInfo.tsx'; +import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {Drawer, Modal} from 'antd'; +import {EVENTS, off, on} from '@/utils/event-bus'; +import {FloatingPanel} from '@/components/ui/FloatingPanel'; +import {WechatWorkPage} from '@/pages/headers/WechatWorkPage'; +import {AccountInfo} from '@/pages/headers/AccountInfo.tsx'; // ---------- 类型 ---------- @@ -52,10 +52,10 @@ interface PageConfig { // ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ---------- -function PlaceholderPage({ label }: { label: string }) { +function PlaceholderPage({label}: { label: string }) { return ( -
-

{label} — 页面开发中

+
+

{label} — 页面开发中

); } @@ -70,20 +70,20 @@ function createPlaceholder(label: string): ComponentType { // ---------- target → 页面组件映射 ---------- const PAGE_MAP: Record = { - '/wechat-work': { component: WechatWorkPage, label: '企业微信' }, - '/compliance-assets': { label: '合规素材库' }, + '/wechat-work': {component: WechatWorkPage, label: '企业微信'}, + '/compliance-assets': {label: '合规素材库'}, '/account': { component: AccountInfo, label: '账户', - width: () => Math.min(window.innerWidth * 0.6, 720), - height: () => Math.min(window.innerHeight * 0.6, 680), + width: () => Math.min(window.outerHeight * 0.65, 720), + height: () => Math.min(window.outerHeight * 0.65, 680), }, - '/vip': { label: '开会员/激活' }, - '/recharge': { label: '充值积分' }, - '/billing': { label: '消费记录' }, - '/orders': { label: '订单明细' }, - '/projects': { label: '项目管理' }, - '/quota': { label: '查配额' }, + '/vip': {label: '开会员/激活'}, + '/recharge': {label: '充值积分'}, + '/billing': {label: '消费记录'}, + '/orders': {label: '订单明细'}, + '/projects': {label: '项目管理'}, + '/quota': {label: '查配额'}, }; // 预计算所有占位组件(模块加载时执行一次,引用永久稳定) @@ -118,7 +118,7 @@ export function PageDispatcher() { // 监听 OPEN_PAGE 事件 useEffect(() => { const handler = (payload: unknown) => { - const { target, modalType, label } = payload as PageEventPayload; + const {target, modalType, label} = payload as PageEventPayload; if (!target || !PAGE_MAP[target]) { console.warn(`[PageDispatcher] 未找到页面组件: ${target}`); @@ -129,7 +129,7 @@ export function PageDispatcher() { // 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等) setActivePanel((prev) => { if (prev) return prev; - return { id: `pnl-${nextIdRef.current++}`, target, modalType, label }; + return {id: `pnl-${nextIdRef.current++}`, target, modalType, label}; }); }; @@ -152,8 +152,8 @@ export function PageDispatcher() { const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined; // 容器默认尺寸(动态计算,视口缩放自动适配) - const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720); - const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520); + const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720); + const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520); return ( <> @@ -166,7 +166,7 @@ export function PageDispatcher() { onCancel={closePanel} footer={null} destroyOnHidden={true} - mask={{ closable: true }} + mask={{closable: true}} width={panelWidth ?? defaultModalWidth} > {/* eslint-disable-next-line react-hooks/static-components */} diff --git a/src/hooks/use-api.ts b/src/hooks/use-api.ts index c1814fa..6b2ffeb 100644 --- a/src/hooks/use-api.ts +++ b/src/hooks/use-api.ts @@ -12,12 +12,13 @@ // - 用户可读错误信息(errorMessage) // ============================================================ -import {useMemo} from 'react'; +import {useMemo, useState} from 'react'; import {useAppContext} from '@/components/AppProvider'; import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } 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 {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; +import {getCachedModels, setCachedModels} from '@/services/cache/model-cache'; // ============================================================ // Banner @@ -38,37 +39,63 @@ export function useBannerList() { // 模型 // ============================================================ -/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组) +/** 模块级:单次会话中是否已完成首次 API 拉取 */ +let modelFirstFetchDone = false; +/** 模型列表数据 Hook(缓存优先 + 后台刷新) + * + * 策略: + * 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading) + * 2. 异步拉取最新模型列表 → 覆盖缓存数据 + * 3. 拉取成功后将最新数据加密写入 localStorage + * * 因后端 category 查询参数实际不可用(传参返回空列表), * 改为不带分类参数一次拉取全部,再由客户端根据响应中的 * category_name 字段手动分组。 */ export function useModelList() { const {isLoggedIn} = useAppContext(); + // 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖) + const [instantData, setInstantData] = useState(() => + getCachedModels(), + ); + const result: UseAsyncDataReturn = useAsyncData( - () => ModelAPI.fetchModels({page: 1, page_size: 200}), + async () => { + const models = await ModelAPI.fetchModels({page: 1, page_size: 200}); + // 异步写入加密缓存(best-effort,不影响数据流) + setCachedModels(models).catch(() => {}); + modelFirstFetchDone = true; + setInstantData(models); + return models; + }, [], {enabled: isLoggedIn, label: 'model-list'}, ); + // 合并策略:API 数据 > 缓存即时数据 + const mergedData = result.data ?? instantData; + + // loading 仅在无任何数据时展示(缓存命中时不闪烁 loading) + const loading = result.loading && mergedData === null; + // 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img) const groupedModels: Map = useMemo(() => { const groups = new Map(); - result.data?.forEach((model: ModelsListResponseBody) => { + mergedData?.forEach((model: ModelsListResponseBody) => { const cat: ModelCategoryStringEnum = model.model_type; if (!groups.has(cat)) groups.set(cat, []); groups.get(cat)!.push(model); }); - // 每组内按优先级降序排列 - // groups.forEach((models) => { - // models.sort((a, b) => b.priority - a.priority); - // }); return groups; - }, [result.data]); + }, [mergedData]); return { ...result, + /** 合并后的模型数据(缓存优先) */ + data: mergedData, + /** 仅在无缓存且正在加载时为 true */ + loading, /** 按 model_type 分组后的模型(key 为后端 slug) */ groupedModels, }; @@ -121,12 +148,12 @@ export function useTaskDetail(taskId: string | null | undefined) { // 提交任务 // ============================================================ -/** 提交任务 Mutation Hook */ +/** 提交任务 Mutation Hook(device_id 由 API 层自动注入,调用方无需传入) */ export function useSubmitTask(options?: { onSuccess?: (data: TaskInfoItemResponseBody) => void; onError?: (err: Error) => void; }) { - return useAsyncMutation( + return useAsyncMutation]>( (body) => TaskAPI.submitTask(body), { label: 'submit-task', diff --git a/src/pages/headers/AccountInfo.tsx b/src/pages/headers/AccountInfo.tsx index fc0e58e..053b112 100644 --- a/src/pages/headers/AccountInfo.tsx +++ b/src/pages/headers/AccountInfo.tsx @@ -1,210 +1,235 @@ -// ============================================================ -// AccountInfo — 账户信息展示 -// 数据来源:AuthAPI.getUserInfo() → GET /api/v1/auth/me -// ============================================================ - -import { useEffect, useState, useCallback } from 'react'; +import {useEffect, useState, useCallback} from 'react'; import { - Card, - Descriptions, - Button, - Typography, - Skeleton, - Result, - Space, - Tag, - Divider, - theme as antTheme, + Card, + Descriptions, + Button, + Typography, + Skeleton, + Result, + Space, + Tag, + Divider, + theme as antTheme, } from 'antd'; import { - UserOutlined, - WalletOutlined, - CrownOutlined, - BarChartOutlined, - RightOutlined, + UserOutlined, + WalletOutlined, + CrownOutlined, + BarChartOutlined, + RightOutlined, } from '@ant-design/icons'; -import { AuthAPI, type UserInfoBody } from '@/services/modules/auth'; +import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth'; +import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts"; -const { Text, Title } = Typography; +const {Text, Title} = Typography; /** 将分转为元,保留两位小数 */ function centToYuan(cent: number): string { - return (cent / 100).toFixed(2); + return (cent / 100).toFixed(2); } /** 格式化日期 */ function formatDate(date: Date | string | undefined): string { - if (!date) return '—'; - const d = typeof date === 'string' ? new Date(date) : date; - return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }); + if (!date) return '—'; + const d = typeof date === 'string' ? new Date(date) : date; + return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'}); } export function AccountInfo() { - const { token } = antTheme.useToken(); - const [userInfo, setUserInfo] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const {token} = antTheme.useToken(); + const [userInfo, setUserInfo] = useState(null); + const [vipsInfo, setVipsInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const info = await AuthAPI.getUserInfo(); - setUserInfo(info); - } catch (err) { - setError((err as Error).message || '加载账户信息失败'); - } finally { - setLoading(false); - } - }, []); + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const info: UserInfoBody = await AuthAPI.getUserInfo(); + const vips: VipLevelsItem[] = await VipAPI.getVipLevels(); + setVipsInfo(vips); + setUserInfo(info); + } catch (err) { + setError((err as Error).message || '加载账户信息失败'); + } finally { + setLoading(false); + } + }, []); - useEffect(() => { - void load(); - }, [load]); + useEffect(() => { + void load(); + }, [load]); - // ======== 加载态 ======== - if (loading) { - return ( -
- -
- ); - } - - // ======== 错误态 ======== - if (error || !userInfo) { - return ( - - 重试 - - } - /> - ); - } - - // ======== 数据展示 ======== - const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent; - - return ( -
- {/* ======== 标题:账户 ======== */} -
- - -
- - {userInfo.display_name || userInfo.username} - - - ID: {userInfo.id} - -
- {userInfo.role !== 'user' && ( - - {userInfo.role === 'admin' ? '管理员' : '超级管理员'} - - )} -
- -
- - {/* ======== 积分栏 ======== */} - - - 账户总积分 - - - {centToYuan(totalBalance)} - <Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}>积分</Text> - - -
- 主积分 -
- {centToYuan(userInfo.balance_cent)} -
-
- 赠送积分 -
- {centToYuan(userInfo.gift_balance_cent)} -
- {userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && ( -
- 折扣率 -
- {Math.round(userInfo.discount_rate * 100)}% -
- )} -
-
- - {/* ======== 套餐信息 ======== */} - -
- - - 当前套餐 - - + // ======== 加载态 ======== + if (loading) { + return ( +
+
- - - {userInfo.subscription_plan || '免费版'} - - - - {formatDate(userInfo.subscription_expires_at)} - - - - {userInfo.seat_limit || '—'} - - - + ); + } - {/* ======== 今日统计 ======== */} - - - - 今日使用 - - - 统计数据将在次日更新 - - + // ======== 错误态 ======== + if (error || !userInfo) { + return ( + + 重试 + + } + /> + ); + } - {/* ======== 查看更多权益 ======== */} - - -
- ); + // ======== 数据展示 ======== + const totalBalance: number = userInfo.balance_cent + userInfo.gift_balance_cent; + + // 匹配当前 VIP 等级(vip_level_id → VipLevelsItem) + const matchedVip: VipLevelsItem | null = + userInfo.vip_level_id != null && vipsInfo + ? (vipsInfo.find((v) => v.id === userInfo.vip_level_id) ?? null) + : null; + + const isExpired = new Date(userInfo.software_expires_at) < new Date(); + return ( +
+ {/* ======== 标题:账户 ======== */} +
+ + +
+ + {userInfo.display_name || userInfo.real_name || userInfo.username || userInfo.id} + {userInfo.account_type && ( + <Tag color="gold" style={{lineHeight: '18px', fontSize: 11}}> + {UserAccountTypeLabelMap[userInfo.account_type]} + </Tag> + )} + + + ID: {userInfo.id} + +
+
+ +
+ + {/* ======== 积分栏 ======== */} + + + 账户总积分 + + + {centToYuan(totalBalance)} + <Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>积分</Text> + + +
+ 主积分 +
+ {centToYuan(userInfo.balance_cent)} +
+
+ 赠送积分 +
+ {centToYuan(userInfo.gift_balance_cent)} +
+ {userInfo.discount_rate && userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && ( +
+ 折扣率 +
+ {Math.round(userInfo.discount_rate * 100)}% +
+ )} +
+
+ + {/* ======== 套餐信息 ======== */} + +
+ + + 当前套餐 + + +
+ + + + + {matchedVip?.name || userInfo.subscription_plan || '免费版'} + + {matchedVip?.level != null && ( + + Lv.{matchedVip.level} + + )} + + + {matchedVip?.description && ( + + + {matchedVip.description} + + + )} + + {matchedVip?.duration_days ? `${matchedVip.duration_days} 天` : '—'} + + + + {formatDate(userInfo.software_expires_at)} + + + + {matchedVip?.seat_limit ?? userInfo.seat_limit ?? '—'} + + +
+ + {/* ======== 今日统计 ======== */} + + + + 今日使用 + + + 统计数据将在次日更新 + + + + {/* ======== 查看更多权益 ======== */} + + +
+ ); } diff --git a/src/pages/home/components/ModelInputForm.tsx b/src/pages/home/components/ModelInputForm.tsx index cb4b950..1afc2bd 100644 --- a/src/pages/home/components/ModelInputForm.tsx +++ b/src/pages/home/components/ModelInputForm.tsx @@ -33,6 +33,12 @@ import { emit, EVENTS } from '@/utils/event-bus'; import { OutputTypeMap } from '@/services/modules/task'; import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task'; import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI'; +import { + calculateCost, + formatYuan, + type ModelDefinition, + type ModelPricingConfig, +} from '@/utils/pricing'; const { Text, Title } = Typography; @@ -108,6 +114,19 @@ const EMPTY_ARRAY: never[] = []; /** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */ const EMPTY_OBJ: Record = {}; +/** + * 从自由格式 JSON 中安全读取数字(兼容 int / float / string) + */ +function safeNum(v: unknown): number | null { + if (v === null || v === undefined) return null; + if (typeof v === 'number' && !Number.isNaN(v)) return v; + if (typeof v === 'string') { + const n = Number(v.trim()); + return Number.isNaN(n) ? null : n; + } + return null; +} + // ---------- 主组件 ---------- export function ModelInputForm() { @@ -117,6 +136,9 @@ export function ModelInputForm() { const [form] = Form.useForm(); const [taskTag, setTaskTag] = useState(''); + // 跟踪表单当前值(用于动态计价,每次字段变化时更新) + const [currentValues, setCurrentValues] = useState>({}); + // 提交突变 Hook const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ onSuccess: useCallback((_data: TaskInfoItemResponseBody) => { @@ -151,6 +173,10 @@ export function ModelInputForm() { if (selectedModel && Object.keys(initialValues).length > 0) { form.setFieldsValue(initialValues); setTaskTag(''); + // 同步价格计算:切换模型时立即用默认值更新预估 + setCurrentValues(initialValues); + } else if (!selectedModel) { + setCurrentValues({}); } }, [selectedModel?.id, initialValues, selectedModel, form]); @@ -159,6 +185,7 @@ export function ModelInputForm() { form.resetFields(); form.setFieldsValue(initialValues); setTaskTag(''); + setCurrentValues(initialValues); }, [form, initialValues]); // ======== 提交 ======== @@ -212,6 +239,57 @@ export function ModelInputForm() { } }; + // ======== 动态预估消费金额(必须在所有提前返回之前) ======== + + const estimatedCostDisplay: string = useMemo(() => { + const rawCfg = selectedModel?.pricing_config; + if (!rawCfg) return '消费'; + + const cfg = rawCfg as unknown as ModelPricingConfig; + + // ═══ 第一层:完整动态计价(覆盖全部 6 种模式) ═══ + const modelDef: ModelDefinition = { + id: selectedModel.id, + name: selectedModel.name, + pricing_config: cfg, + }; + + const cost = calculateCost(modelDef, currentValues); + if (cost !== null && cost !== undefined) { + if (cost === 0) return '消费 ¥0(免费)'; + return `消费 ¥${formatYuan(cost)}`; + } + + // ═══ 第二层:兜底提取基础价格(calculateCost 失败时) ═══ + const rawObj = cfg.raw as Record | undefined; + + const fallbackPrice = + safeNum(cfg.price) ?? + safeNum(cfg.price_cent != null ? cfg.price_cent / 100 : null) ?? + safeNum(rawObj?.price) ?? + safeNum(rawObj?.base_price); + + const unitName = + (rawObj?.unit_name as string) || + (rawObj?.unit as string) || + undefined; + + if (fallbackPrice !== null) { + const formatted = formatYuan(fallbackPrice); + if (unitName) return `消费 ¥${formatted}/${unitName}`; + if (fallbackPrice > 0) return `消费约 ¥${formatted}`; + return `消费 ¥${formatted}`; + } + + if (cfg.price_per_k != null) return `消费 ¥${formatYuan(cfg.price_per_k)}/千字符`; + if (cfg.price_per_k_cent != null) return `消费 ¥${formatYuan(cfg.price_per_k_cent / 100)}/千字符`; + + const modeLabel = cfg.mode || cfg.price_type || cfg.billing_mode; + if (modeLabel) return `消费(${modeLabel})`; + + return '消费'; + }, [selectedModel, currentValues]); + // ======== 未选择模型 ======== if (!selectedModel) { @@ -233,7 +311,6 @@ export function ModelInputForm() { const metaConfig = (selectedModel.meta_config || {}) as Record; const uiConfig = (selectedModel.ui_config || {}) as Record; - const pricingConfig = (selectedModel.pricing_config || {}) as Record; const outputType = metaConfig.output_type as OutputTypeString | undefined; const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined; @@ -241,11 +318,6 @@ export function ModelInputForm() { const group = uiConfig.group as string | undefined; const helpUrl = metaConfig.help_url as string | undefined; - const priceUnit = (pricingConfig.unit as Record)?.name as string | undefined; - const price = pricingConfig.price as number | undefined; - const priceType = pricingConfig.price_type as string | undefined; - - // ======== 正常渲染 ======== return (
setCurrentValues(allValues)} > {fields.map((field) => ( - {priceType === 'SIMPLE' && price !== undefined - ? `消费 ¥${price}/${priceUnit || '次'}` - : '消费'} + {estimatedCostDisplay}
diff --git a/src/services/cache/model-cache.ts b/src/services/cache/model-cache.ts new file mode 100644 index 0000000..7b4f7ad --- /dev/null +++ b/src/services/cache/model-cache.ts @@ -0,0 +1,51 @@ +// ============================================================ +// model-cache — 模型列表加密本地缓存 +// +// 策略: +// - 应用启动时 initModelCache() 从加密 localStorage 恢复缓存 +// - getCachedModels() 同步读取(内存缓存,高频安全) +// - setCachedModels() 异步加密写入(API 返回后更新) +// - 降级:非 Electron 环境回退到 localStorage 明文 +// ============================================================ + +import {initSafeStore, getSafeStore, setSafeStore} from '@/utils/safe-storage'; +import type {ModelsListResponseBody} from '@/services/modules/models'; + +/** 缓存键名(与 safe-storage 加密存储一致) */ +const MODEL_CACHE_KEY = 'ele-heixiu-model-cache'; + +/** + * 启动时初始化模型缓存:从 localStorage 解密到内存缓存。 + * 应在 AppProvider 挂载时调用(在 useModelList 首次使用之前)。 + */ +export async function initModelCache(): Promise { + await initSafeStore(MODEL_CACHE_KEY); +} + +/** + * 同步读取缓存的模型列表(内存缓存,不触发 IPC)。 + * 返回 null 表示无缓存(需从 API 拉取)。 + */ +export function getCachedModels(): ModelsListResponseBody[] | null { + const raw = getSafeStore(MODEL_CACHE_KEY); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) return parsed as ModelsListResponseBody[]; + return null; + } catch { + return null; + } +} + +/** + * 异步加密写入模型列表到 localStorage + 更新内存缓存。 + * API 返回后调用,best-effort(失败不抛异常,下次启动仍可用旧缓存)。 + */ +export async function setCachedModels(models: ModelsListResponseBody[]): Promise { + try { + await setSafeStore(MODEL_CACHE_KEY, JSON.stringify(models)); + } catch { + // 写入失败静默,内存缓存已在 setSafeStore 内部更新 + } +} diff --git a/src/services/modules/auth.ts b/src/services/modules/auth.ts index 3e7bd08..7c425a0 100644 --- a/src/services/modules/auth.ts +++ b/src/services/modules/auth.ts @@ -20,6 +20,34 @@ const AuthUrlObj = { // 认证相关 DTO 传输模型 // ============================================================ +export const UserRoleTypeEnum = { + User: "user", + Admin: "admin", + SuperAdmin: "superadmin", +} as const; + +export type UserRoleTypeValue = typeof UserRoleTypeEnum[keyof typeof UserRoleTypeEnum]; + +export const UserRoleTypeLabelMap: Record = { + [UserRoleTypeEnum.User]: "用户账户", + [UserRoleTypeEnum.Admin]: "管理账户", + [UserRoleTypeEnum.SuperAdmin]: "超管账户" +} + +export const UserAccountTypeEnum = { + Individual: 'individual', + Company: 'company', + Employee: 'employee', +} as const; + +export type UserAccountTypeValue = typeof UserAccountTypeEnum[keyof typeof UserAccountTypeEnum]; + +export const UserAccountTypeLabelMap: Record = { + [UserAccountTypeEnum.Individual]: "个人用户", + [UserAccountTypeEnum.Company]: "企业用户", + [UserAccountTypeEnum.Employee]: "员工账号" +}; + /** 短信验证码请求体 */ export interface SendSMSRequestBody { phone: string; @@ -53,10 +81,10 @@ export interface RegisterResponseBody { refresh_expires_in: 0; user_id: string; username: string; - role: "user" | "admin" | "superadmin"; + role: UserRoleTypeValue; email: string; balance_cent: 0; - account_type: "individual" | "company" | "employee"; + account_type: UserAccountTypeValue; display_name: string; company_id: string; real_name: string; @@ -81,10 +109,10 @@ export interface LoginResponseBody { refresh_expires_in: number; user_id: string; username: string; - role: "user" | "admin" | "superadmin"; + role: UserRoleTypeValue; email: string; balance_cent: number; - account_type: "individual" | "company" | "employee"; + account_type: UserAccountTypeValue; display_name: string; company_id: string; real_name: string; @@ -108,27 +136,27 @@ export interface RefreshTokenResponseBody { export interface UserInfoBody { id: string; username: string; - email: string; + email: string | null; balance_cent: number; gift_balance_cent: number; - role: "user" | "admin" | "superadmin"; + role: UserRoleTypeValue; status: string; - account_type: "individual" | "company" | "employee"; - display_name: string; - real_name: string; + account_type: UserAccountTypeValue; + display_name: string | null; + real_name: string | null; phone: string; - company_id: string; - discount_rate: number; + company_id: string | null; + discount_rate: number | null; subscription_plan: string; subscription_expires_at: Date; - seat_limit: number; + seat_limit: number | null; last_login_at: Date; created_at: Date; model_package_id: number; vip_level_id: number; software_expires_at: Date; - referral_code: string; - admin_permissions: string[]; + referral_code: string | null; + admin_permissions: string[] | null; } export interface ChangePasswordRequestBody { diff --git a/src/services/modules/models.ts b/src/services/modules/models.ts index 2044ed1..af1f9a7 100644 --- a/src/services/modules/models.ts +++ b/src/services/modules/models.ts @@ -115,7 +115,7 @@ export interface ModelsListResponseBody { status: string; priority: number; response_mode: string; - pricing_config: Record; + pricing_config: Record | null; param_schema: ParamSchemaItem[]; quota_config: Record | null; ui_config: Record; diff --git a/src/services/modules/task.ts b/src/services/modules/task.ts index 85136f4..e5d390b 100644 --- a/src/services/modules/task.ts +++ b/src/services/modules/task.ts @@ -11,6 +11,7 @@ // ============================================================ import {get, post} from '../request'; +import {getDeviceId} from '../../utils/device'; // ============================================================ // 基础枚举 / 字面量类型 @@ -57,7 +58,7 @@ export interface TaskListRequestParams { export interface CreatTaskRequestBody { model_id: string; params: Record; - device_id?: string; + device_id: string; tags?: string[]; } @@ -210,9 +211,12 @@ export class TaskAPI { return get(TaskUrlObj.task, params as Record); } - /** 提交新任务 */ - static submitTask(body: CreatTaskRequestBody): Promise { - return post(TaskUrlObj.task, body); + /** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */ + static submitTask(body: Omit): Promise { + return post(TaskUrlObj.task, { + ...body, + device_id: getDeviceId(), + }); } /** 获取任务详情 */ diff --git a/src/services/modules/vip-api.ts b/src/services/modules/vip-api.ts new file mode 100644 index 0000000..384788a --- /dev/null +++ b/src/services/modules/vip-api.ts @@ -0,0 +1,154 @@ +import {post, get} from '../request'; +import {type UserInfoBody} from "@/services/modules/auth"; + +type Nullable = T | null; + +// ============================================================ +// VipApiUrl — VIP 模块路由常量 +// ============================================================ + +const VipApiUrl = { + ActivateVipCode: "/api/v1/vip/activate", + GetVipLevelsList: "/api/v1/vip/levels", + VipOrders: "/api/v1/vip/orders", + MockPay: "/api/v1/vip/mock-pay", +} as const; + +// ============================================================ +// 激活码相关 +// ============================================================ + +export interface ActivateVipCodeRequestParams { + code: string; +} + +export type ActivateVipCodeResponseBody = UserInfoBody; + +// ============================================================ +// 账户类型常量 +// ============================================================ + +export const TargetAccountType = { + Individual: "individual", + Company: "company" +} as const; + +export type TargetAccountTypeValue = typeof TargetAccountType[keyof typeof TargetAccountType]; +export const TargetAccountTypeLabelMap: Record = { + [TargetAccountType.Individual]: "个人套餐", + [TargetAccountType.Company]: "企业套餐" +}; + +// ============================================================ +// VIP 等级 +// ============================================================ + +export interface VipLevelsItem { + id: number; + name: string; + level: number; + price_cent: number; + duration_days: number; + gift_balance_cent: number; + model_package_id: number; + description: Nullable; + image: Nullable; + is_active: number; + sort_order: number; + target_account_type: TargetAccountTypeValue; + seat_limit: Nullable; + created_at: Date; + updated_at: Date; +} + +// ============================================================ +// VIP 订单 +// ============================================================ + +/** VIP 订单状态常量 */ +export const VipOrderStatus = { + Pending: "pending", + Paid: "paid", + Expired: "expired", + Cancelled: "cancelled", +} as const; + +export type VipOrderStatusValue = typeof VipOrderStatus[keyof typeof VipOrderStatus]; + +export const VipOrderStatusLabelMap: Record = { + [VipOrderStatus.Pending]: "待支付", + [VipOrderStatus.Paid]: "已支付", + [VipOrderStatus.Expired]: "已过期", + [VipOrderStatus.Cancelled]: "已取消", +}; + +/** POST /api/v1/vip/orders — 创建购买订单 */ +export interface CreateVipOrderRequest { + /** 购买的 VIP 等级 ID */ + vip_level_id: number; + /** 支付渠道:alipay | mock,默认 alipay */ + payment_channel?: string; +} + +/** 订单列表查询参数 */ +export interface VipOrderListParams { + /** 按状态筛选,可选 */ + status?: VipOrderStatusValue; + /** 页码,默认 1 */ + page?: number; + /** 每页条数,默认 20 */ + page_size?: number; +} + +/** VIP 订单读取 Schema */ +export interface VipOrderRead { + id: string; + user_id: string; + vip_level_id: number; + amount_cent: number; + status: VipOrderStatusValue; + out_trade_no: string; + /** 支付宝/微信交易号 */ + transaction_id: Nullable; + /** 支付链接(扫码支付用) */ + pay_url: Nullable; + /** 订单过期时间 */ + expires_at: Nullable; + /** 支付完成时间 */ + paid_at: Nullable; + created_at: Date; +} + +// ============================================================ +// VipAPI — VIP 模块 API 静态方法类 +// 调用方式:VipAPI.activateVipCode() / VipAPI.getVipLevels() 等 +// ============================================================ + +// TODO(human): 请确认 VipOrderStatus 的状态值和中文标签是否正确,根据实际后端逻辑补充/修改 + +export class VipAPI { + /** 使用激活码激活 VIP 权益 */ + static async activateVipCode(data: ActivateVipCodeRequestParams): Promise { + return await post(VipApiUrl.ActivateVipCode, data); + } + + /** 获取 VIP 等级列表(已登录时按账户类型过滤) */ + static async getVipLevels(): Promise { + return await get(VipApiUrl.GetVipLevelsList); + } + + /** 创建 VIP 套餐购买订单 */ + static async createVipOrder(data: CreateVipOrderRequest): Promise { + return await post(VipApiUrl.VipOrders, data); + } + + /** 查询当前用户的 VIP 订单列表 */ + static async getVipOrders(params?: VipOrderListParams): Promise { + return await get(VipApiUrl.VipOrders, params as Record); + } + + /** 模拟支付成功(仅开发测试用) */ + static async mockPay(orderId: string): Promise { + return await get(`${VipApiUrl.MockPay}/${orderId}`); + } +} diff --git a/src/utils/pricing.ts b/src/utils/pricing.ts new file mode 100644 index 0000000..910e0c2 --- /dev/null +++ b/src/utils/pricing.ts @@ -0,0 +1,488 @@ + +export interface ModelPricingFloorTableConfig { + field: string | null; + mapping: Record; +} + +export interface ModelPricingAddonConfig { + field_key: string | null; + operator: string; + condition_value: unknown; + condition_values: unknown[]; + price: number | null; + unit_field: string | null; +} + +export interface ModelPricingMatrixRowConfig { + price: number | null; + price_cent: number | null; + rate: number | null; + price_per_unit: number | null; + unit_price: number | null; + raw: Record; +} + +export interface ModelPricingConfig { + mode: string | null; + price_type: string | null; + billing_mode: string | null; + price: number | null; + price_per_k: number | null; + price_cent: number | null; + price_per_k_cent: number | null; + field: string | null; + unit_field: string | null; + unit_scale: number | null; + round_digits: number | null; + dimensions: string[]; + rate_dimensions: string[]; + matrix: ModelPricingMatrixRowConfig[]; + conditional_addons: ModelPricingAddonConfig[]; + floor_table: ModelPricingFloorTableConfig | null; + submit_warning_threshold: number | null; + raw: Record; +} + +export interface ModelDefinition { + id: string; + name: string; + pricing_config: ModelPricingConfig | null; +} + +// ============================================================ +// 工具函数 +// ============================================================ + +/** 对应 Python: _as_float(value) -> Optional[float] */ +function asFloat(value: unknown): number | null { + if (value === null || value === undefined) return null; + if (typeof value === "number" && !Number.isNaN(value)) return value; + if (typeof value === "string") { + const text = value.trim(); + if (!text) return null; + const parsed = Number(text); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + +/** 对应 Python: _to_int(value, *, default=0) -> int */ +function toInt(value: unknown, defaultValue: number = 0): number { + if (value === null || value === undefined) return defaultValue; + if (typeof value === "boolean") return value ? 1 : 0; + if (typeof value === "number") return Math.trunc(value); + if (typeof value === "string") { + const text = value.trim(); + if (!text) return defaultValue; + const parsed = parseInt(text, 10); + return Number.isNaN(parsed) ? defaultValue : parsed; + } + return defaultValue; +} + +/** 对应 Python: _cent_to_yuan(value) -> float */ +function centToYuan(value: unknown): number { + return toInt(value, 0) / 100.0; +} + +/** 对应 Python: _lookup_mapping_value(mapping, key, default="") -> object */ +function lookupMappingValue( + mapping: Record | null | undefined, + key: unknown, + defaultValue: unknown = "", +): unknown { + const keyStr = String(key); + if (mapping == null) return defaultValue; + if (keyStr in mapping) return mapping[keyStr]; + return defaultValue; +} + +/** 对应 Python: _match_dimensions(entry, dimensions, inputs) -> bool */ +function matchDimensions( + entry: Record | null | undefined, + dimensions: string[], + inputs: Record, +): boolean { + if (entry == null) return false; + for (const dim of dimensions) { + const entryValue = lookupMappingValue(entry, dim, null); + if (entryValue === null || entryValue === undefined) continue; + const inputVal = String(lookupMappingValue(inputs, dim, "")).trim().toLowerCase(); + const entryVal = String(entryValue).trim().toLowerCase(); + if (inputVal !== entryVal) return false; + } + return true; +} + +/** + * 从 matrix 行提取维度数据。 + * + * API 返回的 matrix 行中,维度字段(如 resolution、quality)在顶层, + * 而非嵌套在 raw 子对象内。本函数合并 raw(如果存在)和行自身字段, + * 兼容两种数据格式,确保 matchDimensions 能正确匹配。 + */ +function matrixRowData(row: ModelPricingMatrixRowConfig): Record { + return { ...(row.raw ?? {}), ...row as unknown as Record }; +} + +// ============================================================ +// 金额格式化函数 +// ============================================================ + +/** + * 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。 + * 对应 workbench.py / billing_dialog.py: _format_yuan(value: float) -> str + */ +export function formatYuan(value: number): string { + const rounded = Math.round(value * 100) / 100; + // .toFixed(2) → 去除尾部零 → 去除尾部小数点 + return rounded.toFixed(2).replace(/\.?0+$/, ""); +} + +/** + * 将分(cent)转换为元并格式化。 + * 对应 workbench.py / billing_dialog.py / account_dialog.py: _format_yuan_from_cent(cent: int) -> str + */ +export function formatYuanFromCent(cent: number): string { + return formatYuan(cent / 100.0); +} + +/** + * 格式化金额(分→元),带符号和¥前缀。 + * 对应 task_table.py: _format_money_cent(value: object) -> str + */ +export function formatMoneyCent(value: unknown): string { + if (value === null || value === undefined) return "-"; + if (typeof value === "boolean") return String(value); + + let cent: number; + if (typeof value === "number") { + cent = Math.trunc(value); + } else if (typeof value === "string") { + const parsed = parseInt(value, 10); + if (Number.isNaN(parsed)) return value; + cent = parsed; + } else { + return String(value); + } + + const sign = cent < 0 ? "-" : ""; + const centAbs = Math.abs(cent); + const yuan = centAbs / 100.0; + const text = yuan.toFixed(2).replace(/\.?0+$/, ""); + return `${sign}¥${text}`; +} + +/** + * 格式化分→元,带符号和¥前缀。 + * 对应 admin_console_dialog.py: _format_cent(cent: int) -> str + */ +export function formatCent(cent: number): string { + const sign = cent < 0 ? "-" : ""; + const valueAbs = Math.abs(Math.trunc(cent)); + const yuan = valueAbs / 100.0; + const text = yuan.toFixed(2).replace(/\.?0+$/, ""); + return `${sign}¥${text}`; +} + +// ============================================================ +// 服务端计价逻辑 +// ============================================================ + +/** 对应 Python: _calculate_server_simple(pricing, inputs) -> float */ +function calculateServerSimple( + pricing: ModelPricingConfig, + inputs: Record, +): number { + const priceCent = pricing.price_cent ?? 0; + const unitField = (pricing.unit_field ?? "").trim(); + if (!unitField) { + return priceCent / 100.0; + } + const amount = asFloat(inputs[unitField]); + const amountValue = amount === null ? 1.0 : amount; + // (priceCent * amountValue) / 100 * 100 在 IEEE 754 下可能产生 + // 浮点累积误差(例如 0.03*100→2.999...),直接 round 分子避免 + return Math.round(priceCent * amountValue) / 100; +} + +/** 对应 Python: _calculate_server_multi_dimension(pricing, inputs) -> Optional[float] */ +function calculateServerMultiDimension( + pricing: ModelPricingConfig, + inputs: Record, +): number | null { + const { dimensions, matrix } = pricing; + if (!matrix || matrix.length === 0) return null; + + for (const row of matrix) { + if (matchDimensions(matrixRowData(row), dimensions, inputs)) { + return centToYuan(row.price_cent); + } + } + + const firstRow = matrix[0]; + if (firstRow.price_cent !== null && firstRow.price_cent !== undefined) { + return centToYuan(firstRow.price_cent); + } + return null; +} + +/** 对应 Python: _calculate_server_billing_adapter(pricing, inputs) -> Optional[float] */ +function calculateServerBillingAdapter( + pricing: ModelPricingConfig, + inputs: Record, +): number | null { + const billingMode = (pricing.billing_mode ?? "rate").trim().toLowerCase(); + let baseCostCent = 0; + + if (billingMode === "per_k_chars") { + const field = (pricing.field ?? "prompt").trim() || "prompt"; + const text = String(inputs[field] ?? ""); + const pricePerKCent = pricing.price_per_k_cent ?? 0; + baseCostCent = Math.floor((text.length + 999) / 1000) * pricePerKCent; + } else { + const matrix = pricing.matrix; + if (!matrix || matrix.length === 0) return null; + + const dimensions = pricing.dimensions; + const unitField = (pricing.unit_field ?? "duration").trim() || "duration"; + const unitsRaw = asFloat(inputs[unitField]); + let units = unitsRaw === null ? 1.0 : unitsRaw; + + // 底表修正 + const floorTable = pricing.floor_table; + if (floorTable) { + const floorField = (floorTable.field ?? "").trim(); + if (floorField === unitField) { + const floorValue = asFloat(floorTable.mapping[String(Math.trunc(units))]); + if (floorValue !== null && units < floorValue) { + units = floorValue; + } + } + } + + let rate: number | null = null; + for (const row of matrix) { + if (matchDimensions(matrixRowData(row), dimensions, inputs)) { + rate = row.rate; + break; + } + } + if (rate === null) { + rate = matrix[0].rate; + } + if (rate === null || rate === undefined) return null; + + baseCostCent = Math.round(rate * units * 100); + } + + // 条件附加费 + for (const addon of pricing.conditional_addons ?? []) { + const fieldKey = (addon.field_key ?? "").trim(); + const operator = addon.operator; + let matched = false; + + if (operator === "PRESENT") { + matched = Boolean(inputs[fieldKey]); + } else if (operator === "EQ") { + matched = String(inputs[fieldKey] ?? "") === String(addon.condition_value ?? ""); + } else if (operator === "IN") { + const conditionValues = (addon.condition_values ?? []).map((v) => String(v)); + matched = conditionValues.includes(String(inputs[fieldKey] ?? "")); + } + + if (!matched) continue; + + const addonPrice = addon.price; + if (addonPrice === null || addonPrice === undefined) continue; + + const addonUnitField = String( + addon.unit_field ?? pricing.unit_field ?? "duration", + ).trim() || "duration"; + const addonUnitsRaw = asFloat(inputs[addonUnitField]); + const addonUnits = addonUnitsRaw === null ? 1.0 : addonUnitsRaw; + + baseCostCent += Math.round(addonPrice * addonUnits * 100); + } + + return baseCostCent / 100.0; +} + +// ============================================================ +// 主计价入口 +// ============================================================ + +/** + * 根据模型定义和输入参数,动态计算消耗金额(单位:元)。 + * 对应 Python: calculate_cost(model: ModelDefinition, inputs: Mapping) -> Optional[float] + */ +export function calculateCost( + model: ModelDefinition, + inputs: Record, +): number | null { + const pricing = model.pricing_config; + if (!pricing) return null; + + // ── 服务端定价 (price_type 存在且 mode 不存在) ── + if (pricing.price_type && !pricing.mode) { + const priceType = (pricing.price_type ?? "SIMPLE").trim().toUpperCase(); + switch (priceType) { + case "SIMPLE": + return calculateServerSimple(pricing, inputs); + case "MULTI_DIMENSION": + return calculateServerMultiDimension(pricing, inputs); + case "BILLING_ADAPTER": + return calculateServerBillingAdapter(pricing, inputs); + default: + return null; + } + } + + // ── 客户端定价 ── + const mode = pricing.mode; + + if (mode === "fixed") { + if (pricing.price !== null && pricing.price !== undefined) { + return pricing.price; + } + return Number(pricing.raw?.price ?? 0); + } + + if (mode === "per_k_chars") { + const fieldId = String(pricing.field ?? "text"); + const rawText = inputs[fieldId] ?? ""; + const text = rawText !== null && rawText !== undefined ? String(rawText) : ""; + const pricePerK = + pricing.price_per_k ?? Number(pricing.raw?.price_per_k ?? 0); + return (text.length / 1000) * pricePerK; + } + + if (mode === "rate_matrix") { + const unitField = String(pricing.unit_field ?? "duration"); + + const unitRaw = inputs[unitField]; + const unitValue = asFloat(unitRaw); + if (unitValue === null) return null; + + const { rate_dimensions, matrix } = pricing; + if (!rate_dimensions || rate_dimensions.length === 0 || !matrix || matrix.length === 0) { + return null; + } + + for (const entry of matrix) { + if (matchDimensions(matrixRowData(entry), rate_dimensions, inputs)) { + let rate = entry.rate; + if (rate === null || rate === undefined) rate = entry.price_per_unit; + if (rate === null || rate === undefined) rate = entry.unit_price; + if (rate === null || rate === undefined) rate = 0.0; + + const rawRound = pricing.round_digits ?? pricing.raw?.round ?? 2; + const roundDigits = + typeof rawRound === "number" || (typeof rawRound === "string" && String(rawRound).trim()) + ? Number(rawRound) + : 2; + return ( + Math.round(unitValue * rate * Math.pow(10, roundDigits)) / + Math.pow(10, roundDigits) + ); + } + } + return null; + } + + if (mode === "matrix") { + const { dimensions, matrix } = pricing; + // 与 MULTI_DIMENSION 对齐:dimensions 为空时 matchDimensions 自动全匹配 + if (!matrix || matrix.length === 0) { + return null; + } + + for (const entry of matrix) { + if (matchDimensions(matrixRowData(entry), dimensions, inputs)) { + // price_cent 优先:Python 端矩阵定价统一走 cent 分度值, + // entry.price 可能为衍生/展示值,price_cent 才是计价基准 + if (entry.price_cent !== null && entry.price_cent !== undefined) { + return entry.price_cent / 100; + } + if (entry.price !== null && entry.price !== undefined) { + return entry.price; + } + return Number(entry.raw?.price ?? 0); + } + } + return null; + } + + return null; +} + +// ============================================================ +// 定价依赖字段收集 +// ============================================================ + +/** + * 收集定价计算所依赖的输入字段名集合。 + * 对应 Python: collect_pricing_dependent_fields(model: ModelDefinition) -> set[str] + */ +export function collectPricingDependentFields(model: ModelDefinition): Set { + const pricing = model.pricing_config; + if (!pricing) return new Set(); + + const fields = new Set(); + const priceType = (pricing.price_type ?? "").trim().toUpperCase(); + const mode = (pricing.mode ?? "").trim().toLowerCase(); + const billingMode = (pricing.billing_mode ?? "").trim().toLowerCase(); + + if (priceType === "SIMPLE") { + const unitField = (pricing.unit_field ?? "").trim(); + if (unitField) fields.add(unitField); + } else if (priceType === "MULTI_DIMENSION") { + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } else if (priceType === "BILLING_ADAPTER") { + if (billingMode === "per_k_chars") { + const field = String(pricing.field ?? "prompt").trim() || "prompt"; + fields.add(field); + } else { + const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; + fields.add(unitField); + + const floorTable = pricing.floor_table; + if (floorTable) { + const floorField = String(floorTable.field ?? "").trim(); + if (floorField) fields.add(floorField); + } + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } + for (const addon of pricing.conditional_addons ?? []) { + const fieldKey = String(addon.field_key ?? "").trim(); + if (fieldKey) fields.add(fieldKey); + const addonUnitField = String( + addon.unit_field ?? pricing.unit_field ?? "duration", + ).trim(); + if (addonUnitField) fields.add(addonUnitField); + } + } else if (mode === "per_k_chars") { + const field = String(pricing.field ?? "text").trim() || "text"; + fields.add(field); + } else if (mode === "rate_matrix") { + const unitField = String(pricing.unit_field ?? "duration").trim() || "duration"; + fields.add(unitField); + for (const dim of pricing.rate_dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } else if (mode === "matrix") { + for (const dim of pricing.dimensions) { + const dimText = String(dim).trim(); + if (dimText) fields.add(dimText); + } + } + + return fields; +} diff --git a/test-server/README.md b/test-server/README.md index da5b45d..2da3d5e 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -13,6 +13,7 @@ python server.py --release-dir ../release --port 8000 ``` 启动后访问: + - API 文档(Swagger):`http://localhost:8000/docs` - 健康检查:`http://localhost:8000/health` @@ -28,11 +29,11 @@ python server.py --release-dir ../release --port 8000 **请求参数(Query String):** -| 参数 | 类型 | 必填 | 示例 | 说明 | -|------|------|:--:|------|------| -| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` | -| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver) | -| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` | +| 参数 | 类型 | 必填 | 示例 | 说明 | +|------------|--------|:--:|------------|-----------------------------------------| +| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` | +| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver) | +| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` | **响应 — 有更新时:** @@ -68,19 +69,20 @@ python server.py --release-dir ../release --port 8000 **字段说明:** -| 字段 | 类型 | 必填 | 说明 | -|------|------|:--:|------| -| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 | -| `fileSize` | number | 是 | 安装包字节数 | -| `sha512` | string | 是 | 安装包 SHA512 Base64 | -| `blockMapUrl` | string | 否 | blockmap 文件 URL(缺失时客户端全量下载) | -| `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) | -| `changelog` | string | 否 | Markdown 格式更新日志 | -| `releaseDate` | string | 否 | 发布日期(YYYY-MM-DD) | -| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) | -| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) | +| 字段 | 类型 | 必填 | 说明 | +|------------------|---------|:--:|----------------------------------| +| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 | +| `fileSize` | number | 是 | 安装包字节数 | +| `sha512` | string | 是 | 安装包 SHA512 Base64 | +| `blockMapUrl` | string | 否 | blockmap 文件 URL(缺失时客户端全量下载) | +| `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) | +| `changelog` | string | 否 | Markdown 格式更新日志 | +| `releaseDate` | string | 否 | 发布日期(YYYY-MM-DD) | +| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) | +| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) | -> **增量更新说明**:`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省 30%-70% 流量)。不传或为 `null` 时客户端全量下载。 +> **增量更新说明**:`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省 +> 30%-70% 流量)。不传或为 `null` 时客户端全量下载。 --- @@ -116,26 +118,27 @@ Content-Type: application/json ## 数据库表结构 ```sql -CREATE TABLE releases ( - id BIGINT PRIMARY KEY AUTO_INCREMENT, - platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', - edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', - version VARCHAR(20) NOT NULL COMMENT '三段式 semver', - file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址', - file_size BIGINT NOT NULL COMMENT '安装包字节数', - sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64', - blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)', - blockmap_size BIGINT COMMENT 'blockmap 文件字节数', - changelog TEXT COMMENT 'Markdown 格式更新日志', - force_update TINYINT(1) DEFAULT 0, - min_version VARCHAR(20) DEFAULT '', - release_date DATE NOT NULL, - status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, +CREATE TABLE releases +( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', + edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', + version VARCHAR(20) NOT NULL COMMENT '三段式 semver', + file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址', + file_size BIGINT NOT NULL COMMENT '安装包字节数', + sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64', + blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)', + blockmap_size BIGINT COMMENT 'blockmap 文件字节数', + changelog TEXT COMMENT 'Markdown 格式更新日志', + force_update TINYINT(1) DEFAULT 0, + min_version VARCHAR(20) DEFAULT '', + release_date DATE NOT NULL, + status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_platform_edition_version (platform, edition, version), - INDEX idx_platform_edition_status (platform, edition, status) + INDEX idx_platform_edition_status (platform, edition, status) ); ```