Compare commits
3 Commits
1c27283b56
...
95019316b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 95019316b2 | |||
| 848fdeca3d | |||
| d95d155ab5 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 568932,
|
"pid": 844000,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
|
||||||
"startedAt": 1780880691721
|
"startedAt": 1781055291389
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,3 +38,4 @@ dist-ssr
|
|||||||
WORKLOG.md
|
WORKLOG.md
|
||||||
*open*.json
|
*open*.json
|
||||||
response_*.json
|
response_*.json
|
||||||
|
test-server/__pycache__/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 船长 · HeiXiu — 前端架构文档
|
# 船长 · HeiXiu — 前端架构文档
|
||||||
|
|
||||||
> 最后更新:2026-06-08
|
> 最后更新:2026-06-09
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -111,6 +111,10 @@ App 启动 → AppProvider useEffect
|
|||||||
→ 否 → 跳过
|
→ 否 → 跳过
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **StrictMode 清理**:该 useEffect 持有 `aborted` 标志,cleanup 返回 `() => { aborted = true }`。
|
||||||
|
> 所有异步回调(`.then` / `.catch`)在操作 state 或发射事件前检查 `if (aborted) return`。
|
||||||
|
> 这防止 React StrictMode(开发模式)挂载→卸载→重新挂载时发起重复的 refreshToken / getUserInfo 请求。
|
||||||
|
|
||||||
### 3. 401 → 刷新 Token → 自动重试
|
### 3. 401 → 刷新 Token → 自动重试
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -165,18 +169,20 @@ NavBar / 设置页 → AppProvider.logout()
|
|||||||
|
|
||||||
- 纯发布-订阅,不依赖任何框架
|
- 纯发布-订阅,不依赖任何框架
|
||||||
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
||||||
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS`
|
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
|
||||||
|
|
||||||
### 页面叠加层模式(QT 多窗口模型映射)
|
### 页面叠加层模式(QT 多窗口模型映射)
|
||||||
|
|
||||||
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则:
|
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则:
|
||||||
|
|
||||||
| QT 原版 | Web 对应 | 实现方式 |
|
| QT 原版 | Web 对应 | 实现方式 |
|
||||||
|---------|---------|---------|
|
|---------------|----------------|-------------------------------|
|
||||||
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
|
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
|
||||||
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
|
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
|
||||||
|
|
||||||
**当前叠加层**:
|
**当前叠加层**:
|
||||||
|
|
||||||
|
- `PageDispatcher` — 统一页面调度器,监听 `OPEN_PAGE` 事件,根据 `modalType` 渲染 Modal/Drawer/FloatingPanel,全局互斥(同一时刻最多一个面板)
|
||||||
- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
|
- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
|
||||||
- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发
|
- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发
|
||||||
- AnnouncementDrawer — Drawer,NavBar 内部 state 控制
|
- AnnouncementDrawer — Drawer,NavBar 内部 state 控制
|
||||||
|
|||||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -3,6 +3,86 @@
|
|||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 0.0.18(2026-06-10)
|
||||||
|
|
||||||
|
**前后端联调 — 任务创建 + 账户信息 + 预估花费**
|
||||||
|
|
||||||
|
### 任务创建联调
|
||||||
|
|
||||||
|
- `CreatTaskRequestBody.device_id` 改为后端必填参数,API 层 `Omit` 封装,自动注入 `getDeviceId()`(UUID v4 持久化,与登录/注册统一)
|
||||||
|
- `useSubmitTask` 泛型同步改为 `Omit<CreatTaskRequestBody, 'device_id'>`,调用方无感
|
||||||
|
- `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<string, unknown>` → `Record<string, unknown> | 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)
|
||||||
|
|
||||||
|
**修复接口重复调用 — 请求量减半**
|
||||||
|
|
||||||
|
- 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/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0.0.16(2026-06-09)
|
||||||
|
|
||||||
|
**模型输入栏重构 + 统一页面调度 + 依赖联动**
|
||||||
|
|
||||||
|
- 模型输入栏三层架构: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/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 0.0.15(2026-06-09)
|
## 0.0.15(2026-06-09)
|
||||||
|
|
||||||
**主题切换性能优化 — 桌面端流畅度提升**
|
**主题切换性能优化 — 桌面端流畅度提升**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
|
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
|
||||||
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
|
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
|
||||||
|
- 进行系统级别的操作时,需要考虑系统差异,并处理兼容性。如WINDOWS、MAC OS。
|
||||||
- 有需要请自己调用MCP服务
|
- 有需要请自己调用MCP服务
|
||||||
|
|
||||||
# 主题系统架构
|
# 主题系统架构
|
||||||
@@ -23,5 +24,6 @@ ThemeProvider.useLayoutEffect([isDark])
|
|||||||
## 过渡策略
|
## 过渡策略
|
||||||
|
|
||||||
- **Electron/Chromium**:View Transitions API(`document.startViewTransition` + `flushSync`),GPU 合成器单次 cross-fade
|
- **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 仍走标签替换路径,过渡无效
|
- **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效
|
||||||
7
TODO.md
7
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-08
|
> 最后更新:2026-06-09
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
|
|
||||||
### 路由与页面
|
### 路由与页面
|
||||||
|
|
||||||
|
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
||||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
- [ ] 会员/充值/消费记录/订单明细页面
|
||||||
- [ ] 各页面暗色主题适配
|
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
|
||||||
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
- [ ] React 组件 lazy loading(React.lazy + Suspense)
|
||||||
- [ ] 错误边界组件(ErrorBoundary)
|
- [ ] 错误边界组件(ErrorBoundary)
|
||||||
|
- [ ] 审计其余 useEffect 缺少清理函数的位置(StrictMode 双重挂载可能导致重复请求,参考 AppProvider 的 aborted 模式)
|
||||||
|
|
||||||
### 文档
|
### 文档
|
||||||
|
|
||||||
|
|||||||
279
electron/preload/device.ts
Normal file
279
electron/preload/device.ts
Normal file
@@ -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<Component, ComponentQuery> = {
|
||||||
|
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);
|
||||||
|
}
|
||||||
200
electron/preload/device_service.ts
Normal file
200
electron/preload/device_service.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "ele-heixiu",
|
"name": "ele-heixiu",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.14",
|
"version": "0.0.18",
|
||||||
"description": "船长·HeiXiu — 桌面效率工作台",
|
"description": "船长·HeiXiu — 桌面效率工作台",
|
||||||
"author": "HeiXiu 杨烨",
|
"author": "HeiXiu 杨烨",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
clearStoredRefreshToken,
|
clearStoredRefreshToken,
|
||||||
initRefreshTokenStore,
|
initRefreshTokenStore,
|
||||||
} from '@/services/auth-token';
|
} from '@/services/auth-token';
|
||||||
|
import {initModelCache} from '@/services/cache/model-cache';
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
@@ -107,10 +108,17 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
// ---------- 启动时初始化 token 存储 + 自动登录 ----------
|
||||||
|
//
|
||||||
|
// 注意:React StrictMode(开发模式)会挂载→卸载→重新挂载以暴露副作用问题。
|
||||||
|
// aborted 标志确保卸载后不再执行异步回调,避免 refreshToken / getUserInfo 重复请求。
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 从加密存储恢复 token 到内存缓存
|
let aborted = false;
|
||||||
Promise.all([initTokenStore(), initRefreshTokenStore()]).then(() => {
|
|
||||||
|
// 从加密存储恢复 token + 模型缓存 到内存缓存
|
||||||
|
Promise.all([initTokenStore(), initRefreshTokenStore(), initModelCache()]).then(() => {
|
||||||
|
if (aborted) return;
|
||||||
|
|
||||||
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
// 初始化 401 刷新回调(token 缓存就绪后才注册)
|
||||||
initAuthRefresh();
|
initAuthRefresh();
|
||||||
|
|
||||||
@@ -122,12 +130,14 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
|
|
||||||
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
AuthAPI.refreshToken({ refresh_token: refreshToken })
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
|
if (aborted) return;
|
||||||
await setToken(res.access_token);
|
await setToken(res.access_token);
|
||||||
await setStoredRefreshToken(res.refresh_token);
|
await setStoredRefreshToken(res.refresh_token);
|
||||||
setIsLoggedIn(true);
|
setIsLoggedIn(true);
|
||||||
logger.info('auth', 'Auto-login succeeded');
|
logger.info('auth', 'Auto-login succeeded');
|
||||||
// 补全用户信息
|
// 补全用户信息
|
||||||
AuthAPI.getUserInfo().then((info) => {
|
AuthAPI.getUserInfo().then((info) => {
|
||||||
|
if (aborted) return;
|
||||||
const u: LoginResponseBody = {
|
const u: LoginResponseBody = {
|
||||||
access_token: res.access_token,
|
access_token: res.access_token,
|
||||||
refresh_token: res.refresh_token,
|
refresh_token: res.refresh_token,
|
||||||
@@ -152,12 +162,15 @@ export function AppProvider({ children }: AppProviderProps) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (aborted) return;
|
||||||
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
logger.warn('auth', 'Auto-login failed, refresh_token expired');
|
||||||
clearStoredRefreshToken();
|
clearStoredRefreshToken();
|
||||||
// 仅清除自动登录标记,保留用户名供下次表单回填
|
// 仅清除自动登录标记,保留用户名供下次表单回填
|
||||||
clearAutoLoginFlag();
|
clearAutoLoginFlag();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => { aborted = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ---------- 监听主进程推送的平台/版本信息 ----------
|
// ---------- 监听主进程推送的平台/版本信息 ----------
|
||||||
|
|||||||
@@ -16,12 +16,12 @@
|
|||||||
// 新增页面只需在这里加一行,无需创建独立文件
|
// 新增页面只需在这里加一行,无需创建独立文件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import { Drawer, Modal } from 'antd';
|
import {Drawer, Modal} from 'antd';
|
||||||
import { EVENTS, off, on } from '@/utils/event-bus';
|
import {EVENTS, off, on} from '@/utils/event-bus';
|
||||||
import { FloatingPanel } from '@/components/ui/FloatingPanel';
|
import {FloatingPanel} from '@/components/ui/FloatingPanel';
|
||||||
import { WechatWorkPage } from '@/pages/headers/WechatWorkPage';
|
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
|
||||||
import { AccountInfo } from '@/pages/headers/AccountInfo.tsx';
|
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -52,10 +52,10 @@ interface PageConfig {
|
|||||||
|
|
||||||
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
|
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
|
||||||
|
|
||||||
function PlaceholderPage({ label }: { label: string }) {
|
function PlaceholderPage({label}: { label: string }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 8 }}>
|
<div style={{padding: 8}}>
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>{label} — 页面开发中</p>
|
<p style={{color: 'inherit', opacity: 0.6, margin: 0}}>{label} — 页面开发中</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,20 +70,20 @@ function createPlaceholder(label: string): ComponentType {
|
|||||||
// ---------- target → 页面组件映射 ----------
|
// ---------- target → 页面组件映射 ----------
|
||||||
|
|
||||||
const PAGE_MAP: Record<string, PageConfig> = {
|
const PAGE_MAP: Record<string, PageConfig> = {
|
||||||
'/wechat-work': { component: WechatWorkPage, label: '企业微信' },
|
'/wechat-work': {component: WechatWorkPage, label: '企业微信'},
|
||||||
'/compliance-assets': { label: '合规素材库' },
|
'/compliance-assets': {label: '合规素材库'},
|
||||||
'/account': {
|
'/account': {
|
||||||
component: AccountInfo,
|
component: AccountInfo,
|
||||||
label: '账户',
|
label: '账户',
|
||||||
width: () => Math.min(window.innerWidth * 0.6, 720),
|
width: () => Math.min(window.outerHeight * 0.65, 720),
|
||||||
height: () => Math.min(window.innerHeight * 0.6, 680),
|
height: () => Math.min(window.outerHeight * 0.65, 680),
|
||||||
},
|
},
|
||||||
'/vip': { label: '开会员/激活' },
|
'/vip': {label: '开会员/激活'},
|
||||||
'/recharge': { label: '充值积分' },
|
'/recharge': {label: '充值积分'},
|
||||||
'/billing': { label: '消费记录' },
|
'/billing': {label: '消费记录'},
|
||||||
'/orders': { label: '订单明细' },
|
'/orders': {label: '订单明细'},
|
||||||
'/projects': { label: '项目管理' },
|
'/projects': {label: '项目管理'},
|
||||||
'/quota': { label: '查配额' },
|
'/quota': {label: '查配额'},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
||||||
@@ -118,7 +118,7 @@ export function PageDispatcher() {
|
|||||||
// 监听 OPEN_PAGE 事件
|
// 监听 OPEN_PAGE 事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (payload: unknown) => {
|
const handler = (payload: unknown) => {
|
||||||
const { target, modalType, label } = payload as PageEventPayload;
|
const {target, modalType, label} = payload as PageEventPayload;
|
||||||
|
|
||||||
if (!target || !PAGE_MAP[target]) {
|
if (!target || !PAGE_MAP[target]) {
|
||||||
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
||||||
@@ -129,7 +129,7 @@ export function PageDispatcher() {
|
|||||||
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
||||||
setActivePanel((prev) => {
|
setActivePanel((prev) => {
|
||||||
if (prev) return prev;
|
if (prev) return prev;
|
||||||
return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
|
return {id: `pnl-${nextIdRef.current++}`, target, modalType, label};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ export function PageDispatcher() {
|
|||||||
onCancel={closePanel}
|
onCancel={closePanel}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
mask={{ closable: true }}
|
mask={{closable: true}}
|
||||||
width={panelWidth ?? defaultModalWidth}
|
width={panelWidth ?? defaultModalWidth}
|
||||||
>
|
>
|
||||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
|||||||
@@ -12,12 +12,13 @@
|
|||||||
// - 用户可读错误信息(errorMessage)
|
// - 用户可读错误信息(errorMessage)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {useMemo} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {useAppContext} from '@/components/AppProvider';
|
import {useAppContext} from '@/components/AppProvider';
|
||||||
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
|
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 { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||||
|
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Banner
|
// Banner
|
||||||
@@ -38,37 +39,63 @@ export function useBannerList() {
|
|||||||
// 模型
|
// 模型
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 模型列表数据 Hook(单次请求全量模型 + 客户端按 category_name 分组)
|
/** 模块级:单次会话中是否已完成首次 API 拉取 */
|
||||||
|
let modelFirstFetchDone = false;
|
||||||
|
|
||||||
|
/** 模型列表数据 Hook(缓存优先 + 后台刷新)
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading)
|
||||||
|
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
|
||||||
|
* 3. 拉取成功后将最新数据加密写入 localStorage
|
||||||
|
*
|
||||||
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
* 因后端 category 查询参数实际不可用(传参返回空列表),
|
||||||
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
|
||||||
* category_name 字段手动分组。 */
|
* category_name 字段手动分组。 */
|
||||||
export function useModelList() {
|
export function useModelList() {
|
||||||
const {isLoggedIn} = useAppContext();
|
const {isLoggedIn} = useAppContext();
|
||||||
|
|
||||||
|
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
|
||||||
|
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
|
||||||
|
getCachedModels(),
|
||||||
|
);
|
||||||
|
|
||||||
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
|
||||||
() => 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'},
|
{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)
|
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img)
|
||||||
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
|
||||||
const groups = new Map<string, ModelsListResponseBody[]>();
|
const groups = new Map<string, ModelsListResponseBody[]>();
|
||||||
result.data?.forEach((model: ModelsListResponseBody) => {
|
mergedData?.forEach((model: ModelsListResponseBody) => {
|
||||||
const cat: ModelCategoryStringEnum = model.model_type;
|
const cat: ModelCategoryStringEnum = model.model_type;
|
||||||
if (!groups.has(cat)) groups.set(cat, []);
|
if (!groups.has(cat)) groups.set(cat, []);
|
||||||
groups.get(cat)!.push(model);
|
groups.get(cat)!.push(model);
|
||||||
});
|
});
|
||||||
// 每组内按优先级降序排列
|
|
||||||
// groups.forEach((models) => {
|
|
||||||
// models.sort((a, b) => b.priority - a.priority);
|
|
||||||
// });
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [result.data]);
|
}, [mergedData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
/** 合并后的模型数据(缓存优先) */
|
||||||
|
data: mergedData,
|
||||||
|
/** 仅在无缓存且正在加载时为 true */
|
||||||
|
loading,
|
||||||
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
/** 按 model_type 分组后的模型(key 为后端 slug) */
|
||||||
groupedModels,
|
groupedModels,
|
||||||
};
|
};
|
||||||
@@ -121,12 +148,12 @@ export function useTaskDetail(taskId: string | null | undefined) {
|
|||||||
// 提交任务
|
// 提交任务
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/** 提交任务 Mutation Hook */
|
/** 提交任务 Mutation Hook(device_id 由 API 层自动注入,调用方无需传入) */
|
||||||
export function useSubmitTask(options?: {
|
export function useSubmitTask(options?: {
|
||||||
onSuccess?: (data: TaskInfoItemResponseBody) => void;
|
onSuccess?: (data: TaskInfoItemResponseBody) => void;
|
||||||
onError?: (err: Error) => void;
|
onError?: (err: Error) => void;
|
||||||
}) {
|
}) {
|
||||||
return useAsyncMutation<TaskInfoItemResponseBody, [CreatTaskRequestBody]>(
|
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
|
||||||
(body) => TaskAPI.submitTask(body),
|
(body) => TaskAPI.submitTask(body),
|
||||||
{
|
{
|
||||||
label: 'submit-task',
|
label: 'submit-task',
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
// ============================================================
|
import {useEffect, useState, useCallback} from 'react';
|
||||||
// AccountInfo — 账户信息展示
|
|
||||||
// 数据来源:AuthAPI.getUserInfo() → GET /api/v1/auth/me
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
@@ -23,9 +18,10 @@ import {
|
|||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} 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 {
|
function centToYuan(cent: number): string {
|
||||||
@@ -36,12 +32,13 @@ function centToYuan(cent: number): string {
|
|||||||
function formatDate(date: Date | string | undefined): string {
|
function formatDate(date: Date | string | undefined): string {
|
||||||
if (!date) return '—';
|
if (!date) return '—';
|
||||||
const d = typeof date === 'string' ? new Date(date) : date;
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountInfo() {
|
export function AccountInfo() {
|
||||||
const { token } = antTheme.useToken();
|
const {token} = antTheme.useToken();
|
||||||
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
|
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
|
||||||
|
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -49,7 +46,9 @@ export function AccountInfo() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const info = await AuthAPI.getUserInfo();
|
const info: UserInfoBody = await AuthAPI.getUserInfo();
|
||||||
|
const vips: VipLevelsItem[] = await VipAPI.getVipLevels();
|
||||||
|
setVipsInfo(vips);
|
||||||
setUserInfo(info);
|
setUserInfo(info);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message || '加载账户信息失败');
|
setError((err as Error).message || '加载账户信息失败');
|
||||||
@@ -65,8 +64,8 @@ export function AccountInfo() {
|
|||||||
// ======== 加载态 ========
|
// ======== 加载态 ========
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 24 }}>
|
<div style={{padding: 24}}>
|
||||||
<Skeleton active paragraph={{ rows: 6 }} />
|
<Skeleton active paragraph={{rows: 6}} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -88,27 +87,34 @@ export function AccountInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ======== 数据展示 ========
|
// ======== 数据展示 ========
|
||||||
const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent;
|
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 (
|
return (
|
||||||
<div style={{ padding: 24, maxWidth: 640 }}>
|
<div style={{padding: 24, maxWidth: 640}}>
|
||||||
{/* ======== 标题:账户 ======== */}
|
{/* ======== 标题:账户 ======== */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16}}>
|
||||||
<Space align="center" size={12}>
|
<Space align="center" size={12}>
|
||||||
<UserOutlined style={{ fontSize: 22, color: token.colorPrimary }} />
|
<UserOutlined style={{fontSize: 22, color: token.colorPrimary}} />
|
||||||
<div>
|
<div>
|
||||||
<Title level={4} style={{ margin: 0 }}>
|
<Title level={4} style={{margin: 0}}>
|
||||||
{userInfo.display_name || userInfo.username}
|
{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>
|
||||||
|
)}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
ID: {userInfo.id}
|
ID: {userInfo.id}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{userInfo.role !== 'user' && (
|
|
||||||
<Tag color="gold" style={{ lineHeight: '18px', fontSize: 11 }}>
|
|
||||||
{userInfo.role === 'admin' ? '管理员' : '超级管理员'}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
||||||
充值
|
充值
|
||||||
@@ -119,31 +125,31 @@ export function AccountInfo() {
|
|||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: '16px 20px' },
|
body: {padding: '16px 20px'},
|
||||||
}}
|
}}
|
||||||
style={{ marginBottom: 16 }}
|
style={{marginBottom: 16}}
|
||||||
>
|
>
|
||||||
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
|
||||||
账户总积分
|
账户总积分
|
||||||
</Text>
|
</Text>
|
||||||
<Title level={3} style={{ margin: '0 0 12px 0' }}>
|
<Title level={3} style={{margin: '0 0 12px 0'}}>
|
||||||
{centToYuan(totalBalance)}
|
{centToYuan(totalBalance)}
|
||||||
<Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}>积分</Text>
|
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}>积分</Text>
|
||||||
</Title>
|
</Title>
|
||||||
<Space size={32}>
|
<Space size={32}>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>主积分</Text>
|
<Text type="secondary" style={{fontSize: 12}}>主积分</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>赠送积分</Text>
|
<Text type="secondary" style={{fontSize: 12}}>赠送积分</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
||||||
</div>
|
</div>
|
||||||
{userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
{userInfo.discount_rate && userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
||||||
<div>
|
<div>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>折扣率</Text>
|
<Text type="secondary" style={{fontSize: 12}}>折扣率</Text>
|
||||||
<br />
|
<br />
|
||||||
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,13 +161,13 @@ export function AccountInfo() {
|
|||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: '16px 20px' },
|
body: {padding: '16px 20px'},
|
||||||
}}
|
}}
|
||||||
style={{ marginBottom: 16 }}
|
style={{marginBottom: 16}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12}}>
|
||||||
<Space size={8}>
|
<Space size={8}>
|
||||||
<CrownOutlined style={{ color: token.colorWarning }} />
|
<CrownOutlined style={{color: token.colorWarning}} />
|
||||||
<Text strong>当前套餐</Text>
|
<Text strong>当前套餐</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
||||||
@@ -170,15 +176,34 @@ export function AccountInfo() {
|
|||||||
</div>
|
</div>
|
||||||
<Descriptions column={1} size="small" colon={false}>
|
<Descriptions column={1} size="small" colon={false}>
|
||||||
<Descriptions.Item label="套餐名称">
|
<Descriptions.Item label="套餐名称">
|
||||||
<Text strong>{userInfo.subscription_plan || '免费版'}</Text>
|
<Space size={8}>
|
||||||
|
<Text strong>
|
||||||
|
{matchedVip?.name || userInfo.subscription_plan || '免费版'}
|
||||||
|
</Text>
|
||||||
|
{matchedVip?.level != null && (
|
||||||
|
<Tag color="gold" style={{fontSize: 11, lineHeight: '18px'}}>
|
||||||
|
Lv.{matchedVip.level}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Descriptions.Item>
|
||||||
|
{matchedVip?.description && (
|
||||||
|
<Descriptions.Item label="套餐说明">
|
||||||
|
<Text type="secondary" style={{fontSize: 12}}>
|
||||||
|
{matchedVip.description}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
<Descriptions.Item label="套餐周期">
|
||||||
|
{matchedVip?.duration_days ? `${matchedVip.duration_days} 天` : '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="到期时间">
|
<Descriptions.Item label="到期时间">
|
||||||
<Text type={new Date(userInfo.subscription_expires_at) < new Date() ? 'danger' : undefined}>
|
<Text type={isExpired ? 'danger' : undefined}>
|
||||||
{formatDate(userInfo.subscription_expires_at)}
|
{formatDate(userInfo.software_expires_at)}
|
||||||
</Text>
|
</Text>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="席位上限">
|
<Descriptions.Item label="席位上限">
|
||||||
{userInfo.seat_limit || '—'}
|
{matchedVip?.seat_limit ?? userInfo.seat_limit ?? '—'}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -187,22 +212,22 @@ export function AccountInfo() {
|
|||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: '16px 20px' },
|
body: {padding: '16px 20px'},
|
||||||
}}
|
}}
|
||||||
style={{ marginBottom: 16 }}
|
style={{marginBottom: 16}}
|
||||||
>
|
>
|
||||||
<Space size={8} style={{ marginBottom: 12 }}>
|
<Space size={8} style={{marginBottom: 12}}>
|
||||||
<BarChartOutlined style={{ color: token.colorSuccess }} />
|
<BarChartOutlined style={{color: token.colorSuccess}} />
|
||||||
<Text strong>今日使用</Text>
|
<Text strong>今日使用</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13 }}>
|
<Text type="secondary" style={{display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13}}>
|
||||||
统计数据将在次日更新
|
统计数据将在次日更新
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ======== 查看更多权益 ======== */}
|
{/* ======== 查看更多权益 ======== */}
|
||||||
<Divider style={{ margin: '8px 0' }} />
|
<Divider style={{margin: '8px 0'}} />
|
||||||
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{ color: token.colorPrimary }}>
|
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{color: token.colorPrimary}}>
|
||||||
查看更多权益
|
查看更多权益
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
|||||||
import { theme as antTheme } from 'antd';
|
import { theme as antTheme } from 'antd';
|
||||||
import { ModelSelector } from './ModelSelector';
|
import { ModelSelector } from './ModelSelector';
|
||||||
import { TaskHistory } from './TaskHistory';
|
import { TaskHistory } from './TaskHistory';
|
||||||
|
import { useModelList } from '@/hooks/use-api';
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
@@ -29,6 +30,9 @@ export function LeftPanel() {
|
|||||||
// 面板容器引用
|
// 面板容器引用
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// ======== 模型数据(提升到父组件,避免两个子组件重复请求) ========
|
||||||
|
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
||||||
|
|
||||||
// 模型选择区高度(像素)
|
// 模型选择区高度(像素)
|
||||||
const [topHeight, setTopHeight] = useState<number | null>(null);
|
const [topHeight, setTopHeight] = useState<number | null>(null);
|
||||||
// 拖拽状态
|
// 拖拽状态
|
||||||
@@ -132,7 +136,13 @@ export function LeftPanel() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ModelSelector />
|
<ModelSelector
|
||||||
|
models={models}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
groupedModels={groupedModels}
|
||||||
|
refetch={refetch}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 纵向拖拽分隔条 */}
|
{/* 纵向拖拽分隔条 */}
|
||||||
@@ -172,7 +182,7 @@ export function LeftPanel() {
|
|||||||
|
|
||||||
{/* 下方:任务记录(剩余高度) */}
|
{/* 下方:任务记录(剩余高度) */}
|
||||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
|
||||||
<TaskHistory />
|
<TaskHistory allModels={models} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ import { emit, EVENTS } from '@/utils/event-bus';
|
|||||||
import { OutputTypeMap } from '@/services/modules/task';
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
|
||||||
|
import {
|
||||||
|
calculateCost,
|
||||||
|
formatYuan,
|
||||||
|
type ModelDefinition,
|
||||||
|
type ModelPricingConfig,
|
||||||
|
} from '@/utils/pricing';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -75,12 +81,17 @@ function DependentFormItem({
|
|||||||
onChange?: (value: unknown) => void;
|
onChange?: (value: unknown) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const depValues = (field.dependsOn || []).map((depName) =>
|
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
|
||||||
Form.useWatch(depName, form),
|
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
|
||||||
);
|
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
|
||||||
|
const deps = field.dependsOn || [];
|
||||||
|
const d0 = Form.useWatch(deps[0] || '', form);
|
||||||
|
const d1 = Form.useWatch(deps[1] || '', form);
|
||||||
|
const d2 = Form.useWatch(deps[2] || '', form);
|
||||||
|
const depValues = [d0, d1, d2].slice(0, deps.length);
|
||||||
|
|
||||||
const isDepDisabled =
|
const isDepDisabled =
|
||||||
field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false;
|
deps.some((_, i) => isValueEmpty(depValues[i]));
|
||||||
|
|
||||||
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
||||||
const effectiveConfig = effectiveDisabled
|
const effectiveConfig = effectiveDisabled
|
||||||
@@ -103,6 +114,19 @@ const EMPTY_ARRAY: never[] = [];
|
|||||||
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
||||||
const EMPTY_OBJ: Record<string, unknown> = {};
|
const EMPTY_OBJ: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从自由格式 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() {
|
export function ModelInputForm() {
|
||||||
@@ -112,10 +136,13 @@ export function ModelInputForm() {
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [taskTag, setTaskTag] = useState('');
|
const [taskTag, setTaskTag] = useState('');
|
||||||
|
|
||||||
|
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
|
||||||
|
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
|
||||||
|
|
||||||
// 提交突变 Hook
|
// 提交突变 Hook
|
||||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||||
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||||
message.success('任务已提交');
|
void message.success('任务已提交');
|
||||||
emit(EVENTS.TASK_SUBMITTED);
|
emit(EVENTS.TASK_SUBMITTED);
|
||||||
}, []),
|
}, []),
|
||||||
});
|
});
|
||||||
@@ -146,14 +173,19 @@ export function ModelInputForm() {
|
|||||||
if (selectedModel && Object.keys(initialValues).length > 0) {
|
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||||
form.setFieldsValue(initialValues);
|
form.setFieldsValue(initialValues);
|
||||||
setTaskTag('');
|
setTaskTag('');
|
||||||
|
// 同步价格计算:切换模型时立即用默认值更新预估
|
||||||
|
setCurrentValues(initialValues);
|
||||||
|
} else if (!selectedModel) {
|
||||||
|
setCurrentValues({});
|
||||||
}
|
}
|
||||||
}, [selectedModel?.id, initialValues]);
|
}, [selectedModel?.id, initialValues, selectedModel, form]);
|
||||||
|
|
||||||
// 重置参数(回到默认值 + 清空标签)
|
// 重置参数(回到默认值 + 清空标签)
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
form.setFieldsValue(initialValues);
|
form.setFieldsValue(initialValues);
|
||||||
setTaskTag('');
|
setTaskTag('');
|
||||||
|
setCurrentValues(initialValues);
|
||||||
}, [form, initialValues]);
|
}, [form, initialValues]);
|
||||||
|
|
||||||
// ======== 提交 ========
|
// ======== 提交 ========
|
||||||
@@ -207,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<string, unknown> | 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) {
|
if (!selectedModel) {
|
||||||
@@ -228,7 +311,6 @@ export function ModelInputForm() {
|
|||||||
|
|
||||||
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
|
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
|
||||||
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
|
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
|
||||||
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
|
|
||||||
|
|
||||||
const outputType = metaConfig.output_type as OutputTypeString | undefined;
|
const outputType = metaConfig.output_type as OutputTypeString | undefined;
|
||||||
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
|
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
|
||||||
@@ -236,11 +318,6 @@ export function ModelInputForm() {
|
|||||||
const group = uiConfig.group as string | undefined;
|
const group = uiConfig.group as string | undefined;
|
||||||
const helpUrl = metaConfig.help_url as string | undefined;
|
const helpUrl = metaConfig.help_url as string | undefined;
|
||||||
|
|
||||||
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
|
|
||||||
const price = pricingConfig.price as number | undefined;
|
|
||||||
const priceType = pricingConfig.price_type as string | undefined;
|
|
||||||
|
|
||||||
// ======== 正常渲染 ========
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -317,6 +394,7 @@ export function ModelInputForm() {
|
|||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
onValuesChange={(_, allValues) => setCurrentValues(allValues)}
|
||||||
>
|
>
|
||||||
{fields.map((field) => (
|
{fields.map((field) => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -384,7 +462,7 @@ export function ModelInputForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleSubmit}
|
onClick={() => void handleSubmit()}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={!isLoggedIn || !selectedModel}
|
disabled={!isLoggedIn || !selectedModel}
|
||||||
size="large"
|
size="large"
|
||||||
@@ -396,9 +474,7 @@ export function ModelInputForm() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{priceType === 'SIMPLE' && price !== undefined
|
{estimatedCostDisplay}
|
||||||
? `消费 ¥${price}/${priceUnit || '次'}`
|
|
||||||
: '消费'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
// ModelSelector — 模型选择器(Tabs 标签页分组 + 平铺列表)
|
||||||
// 使用 useModelList() Hook 获取数据,组件仅负责渲染
|
// 模型数据由父组件 LeftPanel 通过 props 传入,避免多个兄弟组件各自请求
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
|
||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useModelList } from '@/hooks/use-api';
|
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
|
|
||||||
@@ -97,10 +96,17 @@ function ModelItem({ model, isSelected, onSelect }: ModelItemProps) {
|
|||||||
|
|
||||||
// ---------- 主组件 ----------
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
export function ModelSelector() {
|
interface ModelSelectorProps {
|
||||||
|
models: ModelsListResponseBody[] | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
groupedModels: Map<string, ModelsListResponseBody[]>;
|
||||||
|
refetch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelSelector({ models, loading, error, groupedModels, refetch }: ModelSelectorProps) {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { selectedModel, setSelectedModel } = useHomeContext();
|
const { selectedModel, setSelectedModel } = useHomeContext();
|
||||||
const { data: models, loading, error, groupedModels, refetch } = useModelList();
|
|
||||||
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
|
||||||
|
|
||||||
// 当前 Tab 对应的模型列表
|
// 当前 Tab 对应的模型列表
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
|
|||||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||||
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTaskList, useModelList } from '@/hooks/use-api';
|
import { useTaskList } from '@/hooks/use-api';
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
@@ -135,7 +136,12 @@ function pickMany(filters: TableFilters, key: string): string[] | undefined {
|
|||||||
|
|
||||||
// ---------- 组件 ----------
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
export function TaskHistory() {
|
interface TaskHistoryProps {
|
||||||
|
/** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */
|
||||||
|
allModels: ModelsListResponseBody[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskHistory({ allModels }: TaskHistoryProps) {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { edition } = useAppContext();
|
const { edition } = useAppContext();
|
||||||
const {
|
const {
|
||||||
@@ -210,10 +216,8 @@ export function TaskHistory() {
|
|||||||
}));
|
}));
|
||||||
}, [taskPage, taskPageSize]);
|
}, [taskPage, taskPageSize]);
|
||||||
|
|
||||||
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射)--------
|
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
|
||||||
|
|
||||||
|
|
||||||
const { data: allModels } = useModelList();
|
|
||||||
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
const modelFilters:{text:string, value:string}[] = useMemo(() => {
|
||||||
if (!allModels) return [];
|
if (!allModels) return [];
|
||||||
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
return allModels.map((m) => ({ text: m.name, value: m.id }));
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
import { Upload, Switch, Space, Typography } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
import { createImageBeforeUpload } from './uploadValidation';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -81,18 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isUploadDisabled}
|
disabled={isUploadDisabled}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{fileList.length < 1 && (
|
{fileList.length < 1 && (
|
||||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// 用于图生图模型的多图输入场景
|
// 用于图生图模型的多图输入场景
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { Upload, message } from 'antd';
|
import { Upload } from 'antd';
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
import { createImageBeforeUpload } from './uploadValidation';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@@ -28,18 +29,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
|||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
maxCount={maxCount}
|
maxCount={maxCount}
|
||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
|
|||||||
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ============================================================
|
||||||
|
// uploadValidation — 文件上传前校验工具
|
||||||
|
// ImageInput / ImageListInput 共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload, message } from 'antd';
|
||||||
|
import type { RcFile } from 'antd/es/upload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图片上传前的类型 & 大小校验函数
|
||||||
|
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||||
|
*/
|
||||||
|
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||||
|
return (file: RcFile) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
|||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules/auth';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -50,22 +50,12 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
|
|||||||
|
|
||||||
// ---- 提交 ----
|
// ---- 提交 ----
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const newErrors: Record<string, string> = {};
|
if (runFieldChecks(setErrors,
|
||||||
|
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
|
||||||
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
|
)) return;
|
||||||
|
|
||||||
if (!currentPassword) {
|
|
||||||
newErrors.currentPassword = '请输入当前密码';
|
|
||||||
}
|
|
||||||
const pwdErr = validatePassword(newPassword);
|
|
||||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
|
||||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
|
||||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
|
||||||
setErrors(newErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors({});
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await AuthAPI.changePassword({
|
await AuthAPI.changePassword({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
validateSmsCode,
|
validateSmsCode,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
validateConfirmPassword,
|
validateConfirmPassword,
|
||||||
|
runFieldChecks,
|
||||||
} from '../config/validators';
|
} from '../config/validators';
|
||||||
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||||||
|
|
||||||
@@ -80,23 +81,12 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
|
|||||||
|
|
||||||
// ---- 提交重置 ----
|
// ---- 提交重置 ----
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
// 逐字段校验
|
if (runFieldChecks(setErrors,
|
||||||
const newErrors: Record<string, string> = {};
|
{ field: 'phone', error: validatePhone(phone) },
|
||||||
const phoneErr = validatePhone(phone);
|
{ field: 'smsCode', error: validateSmsCode(smsCode) },
|
||||||
if (phoneErr) newErrors.phone = phoneErr;
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
const smsErr = validateSmsCode(smsCode);
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
if (smsErr) newErrors.smsCode = smsErr;
|
)) return;
|
||||||
const pwdErr = validatePassword(newPassword);
|
|
||||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
|
||||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
|
||||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
|
||||||
setErrors(newErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors({});
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await AuthAPI.resetPassword({
|
await AuthAPI.resetPassword({
|
||||||
|
|||||||
@@ -102,6 +102,32 @@ export function validateField(value: string, rules: import('../types').FieldRule
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐字段运行校验器,有错误则设置并返回 true。
|
||||||
|
*
|
||||||
|
* 用法(在 handleSubmit 中):
|
||||||
|
* if (runFieldChecks(setErrors,
|
||||||
|
* { field: 'phone', error: validatePhone(phone) },
|
||||||
|
* { field: 'pwd', error: validatePassword(pwd) },
|
||||||
|
* { field: 'confirm', error: validateConfirmPassword(confirm, pwd) },
|
||||||
|
* )) return;
|
||||||
|
*/
|
||||||
|
export function runFieldChecks(
|
||||||
|
setErrors: (errors: Record<string, string>) => void,
|
||||||
|
...checks: Array<{ field: string; error: string | null }>
|
||||||
|
): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
for (const { field, error } of checks) {
|
||||||
|
if (error) newErrors[field] = error;
|
||||||
|
}
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验整个表单
|
* 校验整个表单
|
||||||
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
||||||
|
|||||||
51
src/services/cache/model-cache.ts
vendored
Normal file
51
src/services/cache/model-cache.ts
vendored
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
try {
|
||||||
|
await setSafeStore(MODEL_CACHE_KEY, JSON.stringify(models));
|
||||||
|
} catch {
|
||||||
|
// 写入失败静默,内存缓存已在 setSafeStore 内部更新
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,34 @@ const AuthUrlObj = {
|
|||||||
// 认证相关 DTO 传输模型
|
// 认证相关 DTO 传输模型
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
|
export const UserRoleTypeEnum = {
|
||||||
|
User: "user",
|
||||||
|
Admin: "admin",
|
||||||
|
SuperAdmin: "superadmin",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type UserRoleTypeValue = typeof UserRoleTypeEnum[keyof typeof UserRoleTypeEnum];
|
||||||
|
|
||||||
|
export const UserRoleTypeLabelMap: Record<UserRoleTypeValue, string> = {
|
||||||
|
[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<UserAccountTypeValue, string> = {
|
||||||
|
[UserAccountTypeEnum.Individual]: "个人用户",
|
||||||
|
[UserAccountTypeEnum.Company]: "企业用户",
|
||||||
|
[UserAccountTypeEnum.Employee]: "员工账号"
|
||||||
|
};
|
||||||
|
|
||||||
/** 短信验证码请求体 */
|
/** 短信验证码请求体 */
|
||||||
export interface SendSMSRequestBody {
|
export interface SendSMSRequestBody {
|
||||||
phone: string;
|
phone: string;
|
||||||
@@ -53,10 +81,10 @@ export interface RegisterResponseBody {
|
|||||||
refresh_expires_in: 0;
|
refresh_expires_in: 0;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
email: string;
|
email: string;
|
||||||
balance_cent: 0;
|
balance_cent: 0;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
real_name: string;
|
real_name: string;
|
||||||
@@ -81,10 +109,10 @@ export interface LoginResponseBody {
|
|||||||
refresh_expires_in: number;
|
refresh_expires_in: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
username: string;
|
username: string;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
email: string;
|
email: string;
|
||||||
balance_cent: number;
|
balance_cent: number;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
company_id: string;
|
company_id: string;
|
||||||
real_name: string;
|
real_name: string;
|
||||||
@@ -108,27 +136,27 @@ export interface RefreshTokenResponseBody {
|
|||||||
export interface UserInfoBody {
|
export interface UserInfoBody {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string | null;
|
||||||
balance_cent: number;
|
balance_cent: number;
|
||||||
gift_balance_cent: number;
|
gift_balance_cent: number;
|
||||||
role: "user" | "admin" | "superadmin";
|
role: UserRoleTypeValue;
|
||||||
status: string;
|
status: string;
|
||||||
account_type: "individual" | "company" | "employee";
|
account_type: UserAccountTypeValue;
|
||||||
display_name: string;
|
display_name: string | null;
|
||||||
real_name: string;
|
real_name: string | null;
|
||||||
phone: string;
|
phone: string;
|
||||||
company_id: string;
|
company_id: string | null;
|
||||||
discount_rate: number;
|
discount_rate: number | null;
|
||||||
subscription_plan: string;
|
subscription_plan: string;
|
||||||
subscription_expires_at: Date;
|
subscription_expires_at: Date;
|
||||||
seat_limit: number;
|
seat_limit: number | null;
|
||||||
last_login_at: Date;
|
last_login_at: Date;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
model_package_id: number;
|
model_package_id: number;
|
||||||
vip_level_id: number;
|
vip_level_id: number;
|
||||||
software_expires_at: Date;
|
software_expires_at: Date;
|
||||||
referral_code: string;
|
referral_code: string | null;
|
||||||
admin_permissions: string[];
|
admin_permissions: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePasswordRequestBody {
|
export interface ChangePasswordRequestBody {
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export interface ModelsListResponseBody {
|
|||||||
status: string;
|
status: string;
|
||||||
priority: number;
|
priority: number;
|
||||||
response_mode: string;
|
response_mode: string;
|
||||||
pricing_config: Record<string, unknown>;
|
pricing_config: Record<string, unknown> | null;
|
||||||
param_schema: ParamSchemaItem[];
|
param_schema: ParamSchemaItem[];
|
||||||
quota_config: Record<string, unknown> | null;
|
quota_config: Record<string, unknown> | null;
|
||||||
ui_config: Record<string, unknown>;
|
ui_config: Record<string, unknown>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {get, post} from '../request';
|
import {get, post} from '../request';
|
||||||
|
import {getDeviceId} from '../../utils/device';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 基础枚举 / 字面量类型
|
// 基础枚举 / 字面量类型
|
||||||
@@ -57,7 +58,7 @@ export interface TaskListRequestParams {
|
|||||||
export interface CreatTaskRequestBody {
|
export interface CreatTaskRequestBody {
|
||||||
model_id: string;
|
model_id: string;
|
||||||
params: Record<string, string>;
|
params: Record<string, string>;
|
||||||
device_id?: string;
|
device_id: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,9 +211,12 @@ export class TaskAPI {
|
|||||||
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
|
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 提交新任务 */
|
/** 提交新任务(device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
|
||||||
static submitTask(body: CreatTaskRequestBody): Promise<TaskInfoItemResponseBody> {
|
static submitTask(body: Omit<CreatTaskRequestBody, 'device_id'>): Promise<TaskInfoItemResponseBody> {
|
||||||
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, body);
|
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, {
|
||||||
|
...body,
|
||||||
|
device_id: getDeviceId(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取任务详情 */
|
/** 获取任务详情 */
|
||||||
|
|||||||
154
src/services/modules/vip-api.ts
Normal file
154
src/services/modules/vip-api.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import {post, get} from '../request';
|
||||||
|
import {type UserInfoBody} from "@/services/modules/auth";
|
||||||
|
|
||||||
|
type Nullable<T> = 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<TargetAccountTypeValue, string> = {
|
||||||
|
[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<string>;
|
||||||
|
image: Nullable<string>;
|
||||||
|
is_active: number;
|
||||||
|
sort_order: number;
|
||||||
|
target_account_type: TargetAccountTypeValue;
|
||||||
|
seat_limit: Nullable<number>;
|
||||||
|
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<VipOrderStatusValue, string> = {
|
||||||
|
[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<string>;
|
||||||
|
/** 支付链接(扫码支付用) */
|
||||||
|
pay_url: Nullable<string>;
|
||||||
|
/** 订单过期时间 */
|
||||||
|
expires_at: Nullable<Date>;
|
||||||
|
/** 支付完成时间 */
|
||||||
|
paid_at: Nullable<Date>;
|
||||||
|
created_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// VipAPI — VIP 模块 API 静态方法类
|
||||||
|
// 调用方式:VipAPI.activateVipCode() / VipAPI.getVipLevels() 等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// TODO(human): 请确认 VipOrderStatus 的状态值和中文标签是否正确,根据实际后端逻辑补充/修改
|
||||||
|
|
||||||
|
export class VipAPI {
|
||||||
|
/** 使用激活码激活 VIP 权益 */
|
||||||
|
static async activateVipCode(data: ActivateVipCodeRequestParams): Promise<ActivateVipCodeResponseBody> {
|
||||||
|
return await post<ActivateVipCodeResponseBody>(VipApiUrl.ActivateVipCode, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取 VIP 等级列表(已登录时按账户类型过滤) */
|
||||||
|
static async getVipLevels(): Promise<VipLevelsItem[]> {
|
||||||
|
return await get<VipLevelsItem[]>(VipApiUrl.GetVipLevelsList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 VIP 套餐购买订单 */
|
||||||
|
static async createVipOrder(data: CreateVipOrderRequest): Promise<VipOrderRead> {
|
||||||
|
return await post<VipOrderRead>(VipApiUrl.VipOrders, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询当前用户的 VIP 订单列表 */
|
||||||
|
static async getVipOrders(params?: VipOrderListParams): Promise<VipOrderRead[]> {
|
||||||
|
return await get<VipOrderRead[]>(VipApiUrl.VipOrders, params as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟支付成功(仅开发测试用) */
|
||||||
|
static async mockPay(orderId: string): Promise<VipOrderRead> {
|
||||||
|
return await get<VipOrderRead>(`${VipApiUrl.MockPay}/${orderId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
488
src/utils/pricing.ts
Normal file
488
src/utils/pricing.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
|
||||||
|
export interface ModelPricingFloorTableConfig {
|
||||||
|
field: string | null;
|
||||||
|
mapping: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown> | 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<string, unknown> | null | undefined,
|
||||||
|
dimensions: string[],
|
||||||
|
inputs: Record<string, unknown>,
|
||||||
|
): 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<string, unknown> {
|
||||||
|
return { ...(row.raw ?? {}), ...row as unknown as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 金额格式化函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将元数值格式化为显示字符串,保留两位小数,去除尾部无意义的零和小数点。
|
||||||
|
* 对应 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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<string, unknown>,
|
||||||
|
): 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<string> {
|
||||||
|
const pricing = model.pricing_config;
|
||||||
|
if (!pricing) return new Set();
|
||||||
|
|
||||||
|
const fields = new Set<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ python server.py --release-dir ../release --port 8000
|
|||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:
|
启动后访问:
|
||||||
|
|
||||||
- API 文档(Swagger):`http://localhost:8000/docs`
|
- API 文档(Swagger):`http://localhost:8000/docs`
|
||||||
- 健康检查:`http://localhost:8000/health`
|
- 健康检查:`http://localhost:8000/health`
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ python server.py --release-dir ../release --port 8000
|
|||||||
**请求参数(Query String):**
|
**请求参数(Query String):**
|
||||||
|
|
||||||
| 参数 | 类型 | 必填 | 示例 | 说明 |
|
| 参数 | 类型 | 必填 | 示例 | 说明 |
|
||||||
|------|------|:--:|------|------|
|
|------------|--------|:--:|------------|-----------------------------------------|
|
||||||
| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` |
|
| `platform` | string | 是 | `win32` | `win32` / `darwin` / `linux` |
|
||||||
| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver) |
|
| `version` | string | 是 | `0.0.3` | 客户端当前版本号(三段式 semver) |
|
||||||
| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` |
|
| `edition` | string | 否 | `personal` | `personal` / `enterprise`,默认 `personal` |
|
||||||
@@ -69,7 +70,7 @@ python server.py --release-dir ../release --port 8000
|
|||||||
**字段说明:**
|
**字段说明:**
|
||||||
|
|
||||||
| 字段 | 类型 | 必填 | 说明 |
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|------|------|:--:|------|
|
|------------------|---------|:--:|----------------------------------|
|
||||||
| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 |
|
| `downloadUrl` | string | 是 | 安装包 HTTPS 直链 |
|
||||||
| `fileSize` | number | 是 | 安装包字节数 |
|
| `fileSize` | number | 是 | 安装包字节数 |
|
||||||
| `sha512` | string | 是 | 安装包 SHA512 Base64 |
|
| `sha512` | string | 是 | 安装包 SHA512 Base64 |
|
||||||
@@ -80,7 +81,8 @@ python server.py --release-dir ../release --port 8000
|
|||||||
| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) |
|
| `forceUpdate` | boolean | 否 | 是否强制更新(`true` 时用户不可跳过) |
|
||||||
| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) |
|
| `minimumVersion` | string | 否 | 最低兼容版本(低于此版本的客户端必须更新) |
|
||||||
|
|
||||||
> **增量更新说明**:`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省 30%-70% 流量)。不传或为 `null` 时客户端全量下载。
|
> **增量更新说明**:`blockMapUrl` + `blockMapSize` 为可选字段。客户端检测到 `blockMapSize > 0` 时自动启用增量下载(仅下载变更区块,节省
|
||||||
|
> 30%-70% 流量)。不传或为 `null` 时客户端全量下载。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -116,7 +118,8 @@ Content-Type: application/json
|
|||||||
## 数据库表结构
|
## 数据库表结构
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE releases (
|
CREATE TABLE releases
|
||||||
|
(
|
||||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
|
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
|
||||||
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
|
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
|
||||||
|
|||||||
Reference in New Issue
Block a user