Compare commits

...

24 Commits

Author SHA1 Message Date
95019316b2 feat: 前后端联调 — 任务创建 + 账户信息 + 预估花费 (0.0.18)
- task: device_id 改为必填参数,API 层 Omit 封装 + getDeviceId() 自动注入
- pricing.ts: Python 后端 calculate_cost 完整移植,六种定价模式 + 6 个 Bug 修复
  (浮点冗余/维度匹配不一致/null 守卫/matrix 数据提取/unit_scale 等)
- ModelInputForm: 三层兜底预估消费展示 + React Hooks 顺序修复
- AccountInfo: VIP 等级列表匹配,套餐详情展示(说明/周期/席位/到期)
- 模型列表加密缓存 (safe-storage, stale-while-revalidate)
- VIP API 模块 (激活码/等级/订单/模拟支付)
- device_id 统一使用 UUID v4 持久化 (与 login/register 对齐)
2026-06-10 19:38:26 +08:00
848fdeca3d fix: 修复接口重复调用 — StrictMode 清理 + useModelList 去重
AppProvider useEffect 增加 aborted 标志,防止 React StrictMode
挂载→卸载→重新挂载时 refreshToken/getUserInfo 各调两次。

useModelList 提升到 LeftPanel 单例调用,通过 props 分发给
ModelSelector 和 TaskHistory,消除 /api/v1/models 重复请求。

DevTools 中显示「已停止」的请求即 StrictMode 第一轮挂载被丢弃的请求。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:24:34 +08:00
d95d155ab5 chore: 文档更新 + 代码去重 + ESLint 修复,准备 0.0.16
- CHANGELOG: 新增 0.0.16 版本条目(模型输入栏重构/统一页面调度/依赖联动)
- TODO: 标记账户信息页为已完成,拆分未完成页面项
- ARCHITECTURE: 新增 PageDispatcher 叠加层说明 + OPEN_PAGE 事件
- 代码去重:runFieldChecks(validators) + createImageBeforeUpload(uploadValidation)
- ESLint: DependentFormItem 中 Form.useWatch 移到组件顶层(修复 rules-of-hooks)
- ESLint: message.success / async handleSubmit 加 void(修复 no-floating-promises)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:12:37 +08:00
1c27283b56 docs:重命名文件、变量名称 2026-06-09 18:54:01 +08:00
c18d9c8404 feat: 统一页面调度体系 — 全局互斥 + 动态尺寸 + 账户信息页
PageDispatcher 重构:
- singleton + floatingPanels[] 双状态 → 单一 activePanel 状态
- 全局互斥:已打开面板时禁止打开新面板(保留拖拽/图片拖放等互操作)
- 占位组件预计算为模块级 Map,消除 ESLint static-components 误报
- PAGE_MAP 支持 width/height 配置,动态函数计算(视口比例自适应)
- 容器默认尺寸改为动态:Modal=min(w*0.55,720) Drawer=min(w*0.38,520)
- 修复 FloatingPanel 未接收 width/height 配置的问题

Test2 账户信息页:
- AuthAPI.getUserInfo() → 三卡片布局(积分/套餐/今日统计)
- 加载态 Skeleton / 错误态 Result+重试
- centToYuan 积分转换、formatDate 日期格式化

目录调整:wechat-work/ → headers/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:43:50 +08:00
0c7b33aac1 feat: constraints_config.requires 联动控制 — enable_switch 开关同步启用/禁用依赖字段
- DependentFormItem: 透传 disabled 为顶层 prop(之前藏在 config.disabled 中,widget 组件读取不到)
- ImageInput: 值语义分层 — undefined(开关关) / [](开关开但空) / [{...}](已上传)
- isValueEmpty: 空数组[] 不再视为空,配合 enable_switch 语义
- handleFileWidgetValue: 兼容 Upload DOM事件 和 enable_switch 裸值两种 onChange 形态

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:36:53 +08:00
4261915679 fix: 修复模型默认值不加载 + DependentFormItem prop 透传断裂
- DependentFormItem 透传 Form.Item 注入的 value/onChange/disabled prop
- 稳定化 constraintsConfig 空对象引用(EMPTY_OBJ),避免 useMemo 失效导致无限重渲染
- useLayoutEffect 手动 setFieldsValue(initialValues)替代 antd 被忽略的 initialValues prop
- handleReset 使用 resetFields + setFieldsValue 组合确保默认值归位

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:00:44 +08:00
d5ba7e63cd refactor: 中间栏三层架构 — useUI 组件体系 + useModelUI Hook
- 新建 useUI/ 目录:12 个文件,10 个 Qt 控件 → antd 组件封装 + 类型 + 注册表
- 新建 useModelUI Hook:param_schema → UIFieldConfig[] 自动映射
- ModelInputForm 瘦身 ~160 行:删除 buildParamDescriptors/renderParamControl/文件状态管理
- WIDGET_MAP: Record<string, ComponentType<WidgetProps>> — 零 any,完全类型安全
- 新增控件只需两步:创建组件文件 + 在 WIDGET_MAP 加一行
- 删除旧 widgets/ 目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:32:17 +08:00
76e66a37c8 refactor: 消除占位页面 + 删除 barrel 导出 — 页面集中注册 + 直接导入
- 删除 8 个占位页面文件(Account/Vip/Recharge/Billing/Orders/Projects/Quota/ComplianceAssets)
- PageDispatcher: PAGE_MAP 支持无组件页面自动渲染 PlaceholderPage
- 新增页面只需在 PAGE_MAP 加一行配置,无需创建独立文件
- 删除 services/modules/index.ts barrel,13 个文件改为直接导入
- IDE 跳转到定义一步到位,import 自文档化
- TypeScript 编译零错误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:32:14 +08:00
7982b69219 refactor: CSS 拆分 + auth 持久化提取 + 目录整理
- globals.css 拆分为 4 文件:tokens.css(颜色)/ transitions.css(过渡)/ base.css(重置)/ globals.css(入口)
- 提取 auth-persistence.ts 到 services/,解除 AppProvider→pages/login/hooks 反向依赖
- use-auth-state.ts 精简为纯 React Hook,持久化逻辑委托给 services/auth-persistence.ts
- components/common/ → components/ui/(目录名语义更清晰)
- TypeScript + Vite 构建通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:20:07 +08:00
53df926973 refactor: Context 与 Provider 合并 — 消除 contexts/ 目录,一文件一状态单元
- ThemeContext + useTheme → ThemeProvider.tsx(原 hooks/use-theme.ts 删除)
- AppContext + useAppContext → AppProvider.tsx(原 contexts/app-context.ts 删除)
- SettingsContext + useSettings → SettingsProvider.tsx(原 contexts/settings-context.ts 删除)
- HomeContext + useHomeContext → HomeContent.tsx(原 contexts/home-context.ts 删除)
- 删除空的 contexts/ 目录,20+ 导入路径同步更新
- TypeScript 编译通过(npx tsc --noEmit 零错误)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:02:48 +08:00
eb12248a62 feat: 主题切换性能优化 — View Transitions API + 0.15s 统一过渡 + 设置页增强
【问题】
  桌面端(Electron)切换主题时颜色变化速度不一致、明显卡顿。
  根因分析(4 轮迭代):
    1. 过渡时长不统一(body 0.3s / 组件 0.15s)→ 统一到 0.2s
    2. antd CSS-in-JS 标签替换导致瞬间变色 → 尝试 cssVar(无效,仍走标签替换)
    3. applyHtmlTheme 在 updater 回调中执行,与 antd CSS 变更不同帧 → 移入 useLayoutEffect
    4. html * 全局过渡在 Electron 中为数千 DOM 节点同步插值 → 合成器掉帧
  终极方案:View Transitions API(GPU 合成器 1 次 cross-fade 替代 N 个 CSS 过渡)
   + 精准 CSS 过渡覆盖 ~80 个 antd 容器类(回退方案)

  【修改】

  ThemeProvider.tsx — 核心重构
    - applyHtmlTheme 从 setIsDark updater → useLayoutEffect([isDark])
      确保自定义 CSS 变量与 antd CSS-in-JS 在同一次 React 提交中生效
    - View Transitions API:document.startViewTransition + flushSync
      GPU 合成器截取旧/新两帧做单次 cross-fade,消除多元素插值卡顿
    - 浏览器不支持时自动回退到 CSS transition 方案

  globals.css — 过渡策略
    - body: 0.3s ease → 0.15s linear
    - View Transitions 动画:::view-transition-old/new(root) 0.15s linear
    - ~80 个 antd 容器类精准过渡(Layout/Card/Modal/Table/Input/
      Select/Picker/Menu/Tabs/Tag/Alert/Empty/Result/Skeleton 等)
      按功能分组:布局 → 表格 → 表单 → 导航 → 反馈
      不覆盖交互组件自有 transition(Button/Switch/Slider 等)
      linear 替代 ease — 匀速插值,桌面应用更利落

  Layout.tsx / HomeContent.tsx / LeftPanel.tsx / ModelSelector.tsx
    - 过渡参数对齐:全部 0.2s → 0.15s linear

  SettingsPage.tsx — 用户体验
    - Modal 取消 keyboard={false},Esc 键可关闭
    - macOS:关闭按钮移至标题左侧(遵循 macOS HIG)
    - afterClose 中 blur 当前焦点元素,防止聚焦跳到导航栏

  【文档】
  CHANGELOG.md — 新增 0.0.15 版本记录
  CLAUDE.md — 新增主题系统架构章节(双轨颜色体系 / 时序 / 过渡策略)
2026-06-09 11:02:40 +08:00
59059caae7 feat: 重构模型列表UI + 修复后端数据格式适配
模型列表 UI 重构:
- ModelSelector 从 Menu 折叠分组改为 Tabs 标签页 + 平铺列表
- 新增「全部模型」Tab + 各分类由 MODEL_CATEGORIES 数组控制顺序
- ModelItem 新增 hover 高亮效果 + 维护中模型禁用 + toast 提示
- 新增错误态(Result + 重试按钮)

后端数据格式适配:
- param_schema 类型从 string[] 改为 ParamSchemaItem[]({id, map_to, spec, ui})
- 修复 Form.Item 重复 key 报错(item.id 作 React key,item.map_to 作表单字段名)
- ModelsListResponseBody 字段类型收紧(model_type / category_name / quota_config)

数据层重构:
- useModelList 从 4 次并行请求改为 1 次全量拉取(后端 category 参数不可用)
- 分组键从 category_name 改为 model_type
- page_size 提升至 200

类型体系增强:
- type 别名改为 as const 数组推导(MODEL_CATEGORIES 单一数据源)
- 新增 CATEGORY_SLUG_MAP + resolveCategoryLabel + toCategorySlug 映射层
- 类型重命名 ModelCategory → ModelCategoryStringEnum
2026-06-08 19:02:32 +08:00
a2741edf01 feat: 企业微信改为 Modal 展示二维码,修复 PageDispatcher hooks 错误
将"企业微信"导航项从 external(跳转浏览器)改为 page(Modal 展示),
  二维码图片数据来源于 Banner API(/api/v1/creatives,category=promotion)。

  - 新增 WechatWorkPage:调用 fetchBanners,筛选 category==='promotion',
    展示 image_url 二维码 + 扫码提示 + 刷新按钮,加载/空态/错误三态覆盖
  - nav-config:wechat-work 从 action:'external' 改为 action:'page',
    modalType:'modal',target:'/wechat-work'
  - PageDispatcher:注册 /wechat-work 映射,Modal 新增 centered 居中
  - 修复 PageDispatcher 致命 bug:PAGE_MAP[...]() 函数调用改为
    JSX <SingletonPage />,避免子组件 hooks 误跑在父组件链上导致崩溃
  - PAGE_MAP 类型从 Record<string, () => ReactNode> 修正为
    Record<string, ComponentType>
2026-06-08 17:27:24 +08:00
ada915f298 feat: 去 React Router 化 + 实现三种窗口类型的页面调度体系
移除 react-router-dom 依赖,将导航从 URL 路由切换为事件总线驱动,
  新增 Modal / Drawer / FloatingPanel 三种窗口类型支持。

  架构变更:
  - 删除 src/router/(唯一路由 /* → HomePage,无实际用途)
  - App.tsx 移除 HashRouter,直接渲染 Layout > HomePage
  - Layout 从 useLocation + Outlet 改为 children prop
  - NavBar 移除 useNavigate,所有页面导航改为 emit(OPEN_PAGE)
  - NavAction 精简:'navigate'|'spa' 合并为 'page'

  新增组件:
  - FloatingPanel:纯手写非模态浮动面板(可拖拽、无遮罩、多面板共存、
    暗色主题适配、边界约束),对应 QT 非模态窗口的 Web 映射
    Modal(居中模态)| Drawer(侧滑)| FloatingPanel(浮动可拖拽)
  - 8 个页面占位组件(compliance-assets/account/vip/recharge/
    billing/orders/projects/quota),各显示导航栏名称作为占位

  配置扩展:
  - NavItemConfig 新增 modalType?: 'modal' | 'drawer' | 'floating'
  - nav-config 所有 page 项补齐 modalType(支付类→modal,浏览类→floating)
  - event-bus 新增 OPEN_PAGE 事件,payload 携带 {target, modalType, label}
  - 卸载 react-router-dom@^7.16.0(连带移除 4 个包)

  设计原则:
  - 页面组件不感知窗口类型(内容与容器分离),切换窗口类型只需改配置
  - Modal/Drawer 单实例(模态含义=独占注意力),Floating 多实例共存
  - 主页面始终挂载不卸载,所有子页面为叠加层(对应 QT 多窗口模型)
2026-06-08 16:33:47 +08:00
0f137b21fa feat: 忘记密码/修改密码 + 设置页结构重组 + barrel cleanup + LoginPage 版本统一
新增:
  - ForgotPasswordModal — 手机号+短信验证码重置密码 (AuthAPI.resetPassword)
  - ChangePasswordModal — 当前密码验证后修改密码 (AuthAPI.changePassword)
  - useSmsCooldown Hook — 通用短信发送冷却逻辑,供注册/忘记密码复用
  - PasswordSetting — 设置页账号安全区块,双选项:修改密码 / 短信验证码重置

  修改:
  - LoginForm \"忘记密码?\" 链接接入 ForgotPasswordModal(替换 TODO)
  - LoginPage 移除 edition 业务版本 UI 区分,登录/注册 Tab 全局统一
  - 设置页 Section 组件移入 blocks/ 子目录(git mv 保留历史)
  - auth.ts sendSms URL 修正: /sms/send → /send-sms

  清理:
  - settings/index.ts 移除 4 个未使用 re-export,精简为仅 SettingsPage
  - navbar/index.ts 移除未使用 re-export,精简为仅 NavBar

  文档:
  - WORKLOG.md 新增 2026-06-08 工作日志
  - ARCHITECTURE.md / PROJECT.md 更新设置页目录结构与新组件
  - TODO.md 忘记密码流程标记已完成 + 短信人机验证列入后期优化
2026-06-08 11:58:54 +08:00
7032d1c3f2 feat: 忘记密码/修改密码 + 设置页结构重组 + barrel cleanup + LoginPage 版本统一
新增:
  - ForgotPasswordModal — 手机号+短信验证码重置密码 (AuthAPI.resetPassword)
  - ChangePasswordModal — 当前密码验证后修改密码 (AuthAPI.changePassword)
  - useSmsCooldown Hook — 通用短信发送冷却逻辑,供注册/忘记密码复用
  - PasswordSetting — 设置页账号安全区块,双选项:修改密码 / 短信验证码重置

  修改:
  - LoginForm \"忘记密码?\" 链接接入 ForgotPasswordModal(替换 TODO)
  - LoginPage 移除 edition 业务版本 UI 区分,登录/注册 Tab 全局统一
  - 设置页 Section 组件移入 blocks/ 子目录(git mv 保留历史)
  - auth.ts sendSms URL 修正: /sms/send → /send-sms

  清理:
  - settings/index.ts 移除 4 个未使用 re-export,精简为仅 SettingsPage
  - navbar/index.ts 移除未使用 re-export,精简为仅 NavBar

  文档:
  - WORKLOG.md 新增 2026-06-08 工作日志
  - ARCHITECTURE.md / PROJECT.md 更新设置页目录结构与新组件
  - TODO.md 忘记密码流程标记已完成 + 短信人机验证列入后期优化
2026-06-08 11:57:35 +08:00
952a723448 fix: 修复首页三栏布局拖拽溢出 — 分隔条最大宽度约束 + 视口缩小自动收窄
问题:切换视口大小或拖拽分隔条时,右侧预览面板甚至中右两栏被挤出视口。

  根因:flex 布局左/右列 flexShrink:0,拖拽无上限约束,视口缩小时不自动收窄,
  总宽度超出容器后 overflow:hidden 裁剪。

  修复(HomeContent.tsx):
  - 拖拽时计算 maxLeft / maxRight 动态上限,确保中列≥300px、三栏均在视口内
  - ResizeObserver 监听容器缩小,优先收右侧面板→不够再收左侧,拖拽中自动跳过
  - leftWidthRef / rightWidthRef / draggingRef 避免闭包读到过期值

  变更:CHANGELOG.md / TODO.md / WORKLOG.md 同步更新

  影响文件:3 个(+60 -3)
  - src/pages/home/HomeContent.tsx
  - CHANGELOG.md
  - TODO.md
2026-06-07 05:56:00 +08:00
a01b3f259c Merge remote-tracking branch 'origin/dev-main' into dev-main 2026-06-07 05:22:11 +08:00
72ec1a5459 fix: 修复主题切换后首页状态重置 + 首页 UX 优化 + 文档同步
## 根因
  设置页 `navigate('/settings')` 导致 React Router `/*` 的 index
  路由不匹配,HomePage 被卸载重挂载,所有 useState 回到默认值。

  ## 修复(6 文件)
  - event-bus.ts: 新增 OPEN_SETTINGS 事件
  - nav-config.ts: 设置按钮 action: 'navigate' → 'button',去路由化
  - NavBar.tsx: button case 中 emit(OPEN_SETTINGS)
  - SettingsPage.tsx: 去路由化,改为 Modal 居中叠加 + open/onClose props,
    使用 antd 6.x 正确 API destroyOnHidden(非废弃的 destroyOnClose),
    mask={{ closable: false }} + keyboard={false} 仅允许 X 按钮关闭
  - router/index.tsx: 移除 SettingsPage 渲染
  - App.tsx: 添加 SettingsPage Modal,事件驱动(与 LoginPage 模式一致)

  ## UX 优化(2 文件)
  - HomeContent.tsx: 右侧分隔条移除多余 marginLeft,左右视觉对称
  - BannerCarousel.tsx: 新增关闭按钮,session 级别 useState 隐藏,
    不做 localStorage 持久化,确保每次启动重新展示

  ## 架构原则
  确立 QT 多窗口模型 → Web 映射:叠加层走 Modal/Drawer + 事件总线,
  主页面不卸载;真正的视图切换才用 React Router。

  ## 文档同步
  - TODO.md: 移除已完成项详细记录,精简为未完成待办
  - WORKLOG.md: 追加 2026-06-07 完整工作日志
  - PROJECT.md: 更新路由/叠加层架构说明
  - CHANGELOG.md: 追加 v0.0.13 条目
  - ARCHITECTURE.md: 更新日期 + 新增叠加层模式章节
2026-06-07 05:21:47 +08:00
a8ace232a4 feat: 任务列表UI重构 + 分页器/登录错误/请求拦截修复
【任务列表 UI 重构】(TaskHistory.tsx)
  - 列重新排序:任务ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 → 时长 → 费用 → 供应商(隐藏) → 帧率/文件大小/标签/错误信息(隐藏) → 用户
  - 斑马纹行样式 + hover 增强 + 选中行高亮 + 暗色主题适配
  - 列可见性选择器(齿轮图标 Popover),持久化到 localStorage
  - 视频时长通过 VideoUiParams.duration 计算,非视频显示 "-"
  - 未知状态降级处理(resolveStatus → 显示原始 key)
  - 分页器固定预留 48px,避免 >20 条时分页器溢出可视区
  - 提示词搜索图标颜色用 CSS class 承载,避免 columns 依赖 token.colorPrimary 导致主题切换时 Table 重建

  【分页器修复】(antd-theme.ts)
  - Pagination 组件 token 添加 itemActiveColor: '#fff'
  - 激活页码数字与 #6366F1 主题背景形成对比,亮色/暗色均可见
  - 替代之前的 CSS !important hack

  【登录错误信息修复】(request.ts)
  - HTTP 401 拦截改为双重校验:status === 401 && data?.code === 401
  - 仅业务码也为 401 时才走 token 刷新流程
  - 其他业务码(如密码错误)透传后端 data.message,不再被 RefreshError 覆盖

  【全局中文 locale】(ThemeProvider.tsx)
  - ConfigProvider 添加 locale={zhCN},分页器显示"条/页"等中文
  - 避免逐个 Table 设置不完整 locale 导致 Pagination Select 退化为 Input

  【上下文稳定性】(HomeContent.tsx)
  - contextValue 用 useMemo 包裹,避免每次 render 新建对象导致无关重渲染
  - 配合 columns 依赖清理,减少主题切换时的级联更新

  【其他】
  - index.html CSP 策略增加 COS 域名白名单
  - OutputTypeMap 枚举值修复为 image='图片', video='视频', audio='音频'
  - TaskStatusMap STATUS_CONFIG 类型放宽为 Record<string,...> 兼容未知状态
  - 移除 computeDuration,替换为 getVideoDuration + formatDuration
  - LoginPage 移除未使用的 authState 依赖
2026-06-05 20:49:23 +08:00
0d3d4fcd6a feat: 任务列表UI重构 + 分页器/登录错误/请求拦截修复
【任务列表 UI 重构】(TaskHistory.tsx)
  - 列重新排序、斑马纹/hover/选中样式、暗色主题适配
  - 列可见性选择器(齿轮图标),localStorage 持久化
  - 未知状态降级、视频时长从 ui_params.duration 计算
  - 分页器固定预留 48px

  【分页器修复】(antd-theme.ts)
  - Pagination 添加 itemActiveColor: '#fff',激活页码始终可见

  【登录错误信息修复】(request.ts)
  - HTTP 401 双重校验:status === 401 && data?.code === 401
  - 仅业务码 401 才走 token 刷新;其他业务码透传后端 message

  【全局中文】(ThemeProvider.tsx)
  - ConfigProvider locale={zhCN},分页器"条/页"等全局生效

  【上下文稳定性】(HomeContent.tsx)
  - contextValue useMemo 包裹

  【其他】
  - CSP 策略 COS 域名白名单
  - OutputTypeMap 枚举值修正
  - PROJECT.md 补充 401 判断策略和主题配置说明
2026-06-05 20:48:02 +08:00
af85d31035 feat :修改文件结构 2026-06-05 19:24:06 +08:00
1da95eaef3 feat: 实现业务首页(三栏布局 + 轮播图)
## 首页整体架构
  - 新增 src/pages/home/ — 完整首页模块(10 个组件)
    - HomePage / HomeContent:三栏弹性布局,拖动分隔条调整列宽
    - BannerCarousel:顶部轮播图(antd Carousel,2280×90 长条图片适配)
    - LeftPanel:模型选择器(Menu 分组) + 任务记录(Table 分页),纵向拖拽调整比例
    - CenterPanel / ModelInputForm:动态表单,根据模型 param_schema/ui_config 渲染控件
    - RightPanel / OutputPreview:任务输出预览(Image/Video)
  - 新增 Layout 组件:h-screen flex 布局,首页 Banner + NavBar + Outlet

  ## 数据层(数据与视图分离)
  - 新增 src/services/modules/ — API 模块(banner/models/task/auth)
    - TaskAPI 静态方法,TaskInfoItemResponseBody 判别联合类型
    - ModelAPI / BannerAPI,const 断言路由常量
  - 新增 src/hooks/use-async.ts — 通用异步 Hook
    - useAsyncData:竞态处理、条件请求(enabled)、自动错误日志
    - useAsyncMutation:数据变更,onSuccess/onError
    - getErrorMessage():RequestErrorType → 可读错误信息
  - 新增 src/hooks/use-api.ts — 业务 Hook
    - useBannerList / useModelList / useTaskList / useTaskDetail / useSubmitTask

  ## 主题与交互
  - 重写暗色令牌:科技感蓝紫(#080C24 底 / #0F1340 面板 / #2A3278 边框)
  - 三栏分隔线:token.colorBorderSecondary 常驻 + 手柄竖线 hover 高亮
  - 左面板纵向拖拽:模型区/任务区可调比例,ResizeObserver 自适应
  - 暗色/亮色同步:tokens.ts + antd-theme.ts + globals.css 三处一致

  ## 基础设施
  - EventBus 新增 TASK_SUBMITTED 事件(替代 Context 数字递增 hack)
  - HomeContext 状态管理(选中模型、任务分页、折叠状态)
  - 所有组件边框使用 token.colorBorderSecondary(主题自适应)
  - Table 滚动高度 ResizeObserver 动态计算(修复分页器遮挡)
2026-06-05 16:24:17 +08:00
101 changed files with 8416 additions and 1945 deletions

View File

@@ -1,6 +1,6 @@
{ {
"pid": 91784, "pid": 844000,
"version": "0.9.9", "version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea", "socketPath": "\\\\.\\pipe\\codegraph-aef859f0205bacea",
"startedAt": 1780539931451 "startedAt": 1781055291389
} }

View File

@@ -3,7 +3,8 @@
# ============================================================ # ============================================================
# 渲染进程 API 地址Vite 编译时注入,不可在主进程中使用) # 渲染进程 API 地址Vite 编译时注入,不可在主进程中使用)
VITE_API_BASE_URL=http://8.160.179.64:8000 # VITE_API_BASE_URL=http://8.160.179.64:8000
VITE_API_BASE_URL=https://www.heixiu.net
# 主进程 — 更新检查 API指向本地测试服务器 # 主进程 — 更新检查 API指向本地测试服务器
UPDATE_API_URL=http://127.0.0.1:8000 UPDATE_API_URL=http://127.0.0.1:8000

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ dist-ssr
*.py *.py
*.pyi *.pyi
WORKLOG.md WORKLOG.md
*open*.json
response_*.json
test-server/__pycache__/

View File

@@ -1,6 +1,6 @@
# 船长 · HeiXiu — 前端架构文档 # 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-03 > 最后更新2026-06-09
## 目录结构 ## 目录结构
@@ -17,12 +17,21 @@ src/
│ ├── use-theme.ts # 主题切换 │ ├── use-theme.ts # 主题切换
│ └── use-updater.ts # 版本更新 │ └── use-updater.ts # 版本更新
├── pages/ # 页面 ├── pages/ # 页面
── login/ # 登录/注册 ── login/ # 登录/注册/忘记密码/修改密码
├── index.tsx # LoginPage — Modal 弹层 ├── index.tsx # LoginPage — Modal 弹层
├── components/ # LoginForm / RegisterForm / FormField ├── components/ # LoginForm / RegisterForm / ForgotPasswordModal / ChangePasswordModal / FormField
├── config/ # 字段配置 / 校验规则 ├── config/ # 字段配置 / 校验规则
├── hooks/ # use-auth-state(记住账号偏好) ├── hooks/ # use-auth-state / use-sms-cooldown
└── types.ts # LoginFormValues / RegisterFormValues └── types.ts # LoginFormValues / RegisterFormValues / FieldConfig
│ └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
│ ├── SettingsPage.tsx # 设置 Modal 主页面
│ ├── index.ts # barrel export
│ └── blocks/ # 设置子区块组件
│ ├── ThemeSetting.tsx
│ ├── StoragePathSetting.tsx
│ ├── SystemInfoSetting.tsx
│ ├── UpdateSetting.tsx
│ └── PasswordSetting.tsx
├── services/ # HTTP 层 + API 模块 ├── services/ # HTTP 层 + API 模块
│ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth │ ├── request.ts # Axios 封装(通用 HTTP 层,不知晓 auth
│ ├── auth-token.ts # Token 生命周期refresh_token 存取 + 401 刷新回调) │ ├── auth-token.ts # Token 生命周期refresh_token 存取 + 401 刷新回调)
@@ -102,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 → 自动重试
``` ```
@@ -156,7 +169,25 @@ NavBar / 设置页 → AppProvider.logout()
- 纯发布-订阅,不依赖任何框架 - 纯发布-订阅,不依赖任何框架
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context - 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` - 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
### 页面叠加层模式QT 多窗口模型映射)
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态原窗口不因新窗口打开而销毁。Web 端对应原则:
| QT 原版 | Web 对应 | 实现方式 |
|---------------|----------------|-------------------------------|
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
**当前叠加层**
- `PageDispatcher` — 统一页面调度器,监听 `OPEN_PAGE` 事件,根据 `modalType` 渲染 Modal/Drawer/FloatingPanel全局互斥同一时刻最多一个面板
- LoginPage — Modal事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
- SettingsPage — Modal事件总线 `OPEN_SETTINGS` 触发
- AnnouncementDrawer — DrawerNavBar 内部 state 控制
**反模式警告**:叠加层绝不应通过路由跳转实现。`navigate('/xxx')` + `useLocation` 判断显隐会导致原有页面卸载,丢失所有组件状态。
### API 模块规范 ### API 模块规范

View File

@@ -1,6 +1,118 @@
# 更新日志 # 更新日志
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。 > 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 0.0.182026-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.172026-06-09
**修复接口重复调用 — 请求量减半**
- AppProvider 自动登录 useEffect 增加 aborted 清理标志,修复 StrictMode 双重挂载导致 refreshToken/getUserInfo 各调两次
- useModelList 提升到 LeftPanel 单例调用,修复 ModelSelector 和 TaskHistory 各自独立请求全量模型列表
- 开发模式 DevTools 中"已停止"的请求即为 StrictMode 第一轮挂载被丢弃的请求
- 模型输入栏三层架构useUI 组件体系10 种 widget → antd 映射)+ useModelUI Hookparam_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.162026-06-09
**模型输入栏重构 + 统一页面调度 + 依赖联动**
- 模型输入栏三层架构useUI 组件体系10 种 widget → antd 映射)+ useModelUI Hookparam_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.152026-06-09
**主题切换性能优化 — 桌面端流畅度提升**
- View Transitions API 驱动主题切换GPU 合成器单次 cross-fade 替代 80+ 个独立 CSS 过渡,消除 Electron 卡顿
- 主题更新时序统一:`applyHtmlTheme` 从 updater → `useLayoutEffect`CSS 变量与 React 提交同帧
- 过渡参数对齐:全部 `0.2s ease``0.15s linear`,桌面应用更利落
- CSS 过渡精准覆盖antd 容器组件Layout/Card/Modal/Table/Input/Menu 等 ~80 个类)替代 `html *` 全局选择器
- 设置页 Esc 键关闭 + macOS 关闭按钮适配 + 关闭后焦点防跳转
---
## 0.0.14 -- 🚀更新公告
**模型选择器焕新**
- 模型列表改为标签页分组展示新增「全部模型」Tab
- 列表项新增悬停高亮效果,维护中模型自动置灰并提示
- 修复选择模型后参数表单重复 key 报错
- 类型体系重构,支持后端分类 slug 自动映射
---
## 0.0.132026-06-07
- 修复主题切换后首页状态重置问题:设置页去路由化,改为事件总线驱动的 Modal 叠加
- 首页三栏分隔条视觉对齐:移除右侧多余 margin左右完全对称
- 轮播图新增关闭按钮session 级别隐藏,每次启动重新展示
- 确立 QT 多窗口模型 → Web 架构映射原则:叠加层走 Modal/Drawer + 事件总线,不走路由
- 修复首页三栏布局溢出:拖拽分隔条增加最大宽度约束 + 视口缩小时自动收窄面板,防止右侧面板被挤出视口
--- ---

View File

@@ -2,3 +2,28 @@
- 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明 - 所有交互、解释、代码注释与生成内容均使用简体中文,专有技术名词可保留英文,但需附带中文说明
- 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录 - 进行任务时都需要考虑安全、性能、主题切换、业务自定义错误、事件总线驱动方式以及日志记录
- 进行系统级别的操作时需要考虑系统差异并处理兼容性。如WINDOWS、MAC OS。
- 有需要请自己调用MCP服务
# 主题系统架构
## 双轨颜色体系
1. **自定义 CSS 变量**`globals.css``data-theme="light|dark"``var(--color-bg-base)` 等,用于 body 和自定义组件
2. **antd 令牌**`antd-theme.ts``ConfigProvider theme={algorithm}``useToken()` → inline style用于 antd 组件
## 主题切换时序(关键)
```
ThemeProvider.useLayoutEffect([isDark])
→ applyHtmlTheme(isDark) ← data-theme 属性变更
(与 ConfigProvider 的 useInsertionEffect 同帧执行)
→ 浏览器绘制 ← 所有颜色变更在同一帧生效
```
## 过渡策略
- **Electron/Chromium**View Transitions API`document.startViewTransition` + `flushSync`GPU 合成器单次 cross-fade
- **其他浏览器**CSS `transition: color/bg/border/shadow 0.15s linear`,精准覆盖 ~80 个 antd 容器类(不覆盖交互组件自有
transition
- **不启用 antd cssVar**:经实测 cssVar 模式下 CSS-in-JS 仍走标签替换路径,过渡无效

View File

@@ -36,77 +36,78 @@
遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer不污染全局 Context。 遵循 Provider → Context → Comsumer 单向流动。组件内部状态使用 useState/useReducer不污染全局 Context。
- 事件驱动EDA - 事件驱动EDA
1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。 1. 主进程与渲染进程通信基于 Electron IPCipcMain/ipcRenderer定义明确的事件通道与载荷类型。
2. 渲染进程内跨组件通信优先使用 Context需要解耦的场景使用自定义事件总线src/utils/event-bus.ts 2. 渲染进程内跨组件通信优先使用 Context需要解耦的场景使用自定义事件总线src/utils/event-bus.ts
所有 emit 调用自动记录到日志文件用于审计追踪。 所有 emit 调用自动记录到日志文件用于审计追踪。
3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 → 3. 自定义错误体系RequestError / RefreshError通过事件总线解耦请求拦截器抛出自定义错误 →
全局捕获 → emit 事件 → UI 层响应。 全局捕获 → emit 事件 → UI 层响应。
4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。 4. 窗口间通信统一通过主进程转发(或使用 MessagePort 等 Electron 支持的机制),保持窗口独立性。
- 路由与导航:主窗口为 单页应用,使用 React Router v7+ 管理页面切换,支持嵌套路由。 - 路由与导航:主窗口为单页应用,使用 React Router v7+ 管理页面切换。**遵循 QT 多窗口模型映射原则**
- 叠加层(设置、公告、素材库等)→ Modal/Drawer + 事件总线驱动,主页面不卸载
- 页面跳转(真正的视图切换)→ React Router需跨页保持的状态放 Provider 或 localStorage
- 辅助窗口(设置、预览、导入导出等)为独立 BrowserWindow加载独立的 HTML 入口(可根据路由参数或 windowId 决定内部路由),不与主窗口共享 - 辅助窗口(设置、预览、导入导出等)以 Modal/Drawer 叠加在主窗口之上,不与主窗口共享 DOM但保持在同一个 React 组件树中以确保状态一致性。
DOM。
# 目录结构 # 目录结构
```md ```md
├── electron/ # Electron 主进程 ├── electron/ # Electron 主进程
├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期 │ ├── main.ts # 入口:窗口创建 / IPC 注册 / 生命周期
├── preload.ts # 预加载脚本contextBridge │ ├── preload.ts # 预加载脚本contextBridge
└── main/ │ └── main/
├── updater.ts # 自定义更新API 版本检查 + OSS 下载 + NSIS 安装) │ ├── updater.ts # 自定义更新API 版本检查 + OSS 下载 + NSIS 安装)
├── logger.ts # 主进程日志核心JSON Lines 写入 / 轮转 / 清理) │ ├── logger.ts # 主进程日志核心JSON Lines 写入 / 轮转 / 清理)
├── log-ipc.ts # 渲染进程日志 IPC 通道接收 │ ├── log-ipc.ts # 渲染进程日志 IPC 通道接收
├── net-request.ts # Electron net.request 的 axios 风格封装 │ ├── net-request.ts # Electron net.request 的 axios 风格封装
├── menu/index.ts # 系统菜单 │ ├── menu/index.ts # 系统菜单
└── utils/ # 平台判断 / 图标路径 │ └── utils/ # 平台判断 / 图标路径
├── src/ # 渲染进程React 19 SPA ├── src/ # 渲染进程React 19 SPA
├── main.tsx # React 挂载点 + 全局错误捕获 │ ├── main.tsx # React 挂载点 + 全局错误捕获
├── App.tsx # 根组件(路由 + 登录弹窗) │ ├── App.tsx # 根组件(路由 + 登录弹窗)
├── components/ # 通用 UI 组件 + Provider │ ├── components/ # 通用 UI 组件 + Provider
├── AppProvider.tsx # 全局状态platform / edition / login ├── AppProvider.tsx # 全局状态platform / edition / login
├── ThemeProvider.tsx # 主题状态light / dark ├── ThemeProvider.tsx # 主题状态light / dark
├── SettingsProvider.tsx # 设置状态(存储路径等) ├── SettingsProvider.tsx # 设置状态(存储路径等)
├── Layout.tsx # 页面壳NavBar + Outlet ├── Layout.tsx # 页面壳NavBar + Outlet
└── navbar/ # 导航栏 + 公告抽屉 └── navbar/ # 导航栏 + 公告抽屉
├── contexts/ # Context 定义app / settings / theme │ ├── contexts/ # Context 定义app / settings / theme
├── hooks/ # 自定义 HooksuseUpdater / useTheme │ ├── hooks/ # 自定义 HooksuseUpdater / useTheme
├── pages/ # 页面组件 │ ├── pages/ # 页面组件
├── home/ # 首页仪表盘 ├── home/ # 首页仪表盘
├── login/ # 登录 / 注册页 ├── login/ # 登录 / 注册页
└── settings/ # 设置面板(主题 / 存储 / 更新) └── settings/ # 设置面板(主题 / 存储 / 账号安全 / 系统信息 / 更新)
├── router/index.tsx # HashRouter 路由配置 │ ├── router/index.tsx # HashRouter 路由配置
├── services/ # HTTP 请求封装 │ ├── services/ # HTTP 请求封装
├── request.ts # Axios 封装 + 拦截器 + Token 刷新 ├── request.ts # Axios 封装 + 拦截器 + Token 刷新
├── types.ts # 自定义错误类型RequestError / RefreshError ├── types.ts # 自定义错误类型RequestError / RefreshError
├── auth-token.ts # refresh_token 管理 ├── auth-token.ts # refresh_token 管理
└── modules/ # API 模块auth / announcement └── modules/ # API 模块auth / announcement
├── theme/ # 主题系统 │ ├── theme/ # 主题系统
├── tokens.ts # 设计令牌 ├── tokens.ts # 设计令牌
└── globals.css # Tailwind + 全局样式 └── globals.css # Tailwind + 全局样式
└── utils/ # 纯函数工具 │ └── utils/ # 纯函数工具
├── event-bus.ts # 事件总线on / off / emit + 日志钩子) │ ├── event-bus.ts # 事件总线on / off / emit + 日志钩子)
├── logger.ts # 渲染进程日志器IPC 转发 / DEV 控制台) │ ├── logger.ts # 渲染进程日志器IPC 转发 / DEV 控制台)
├── ipc.ts # 安全 IPC 守卫safeIpcOn / send / invoke │ ├── ipc.ts # 安全 IPC 守卫safeIpcOn / send / invoke
├── platform.ts # 平台判定 │ ├── platform.ts # 平台判定
└── device.ts # 设备 ID 生成 │ └── device.ts # 设备 ID 生成
├── shared/ # 主进程 & 渲染进程共享 ├── shared/ # 主进程 & 渲染进程共享
├── types/ # 类型定义API / IPC / update / logging │ ├── types/ # 类型定义API / IPC / update / logging
├── constants/ # 常量IPC 通道 / version / app │ ├── constants/ # 常量IPC 通道 / version / app
└── utils/ # 共享工具 │ └── utils/ # 共享工具
├── index.ts # debounce / throttle / compareVersions │ ├── index.ts # debounce / throttle / compareVersions
└── sanitize.ts # 日志脱敏工具ID / 邮箱 / 手机 / 名称) │ └── sanitize.ts # 日志脱敏工具ID / 邮箱 / 手机 / 名称)
├── scripts/ # 构建脚本 ├── scripts/ # 构建脚本
├── build.mjs # 构建总管 │ ├── build.mjs # 构建总管
├── clean.mjs # 清理产物 │ ├── clean.mjs # 清理产物
├── upload-oss.mjs # OSS 上传 │ ├── upload-oss.mjs # OSS 上传
├── publish-release.mjs # 发布到 API │ ├── publish-release.mjs # 发布到 API
└── generate-update-info.mjs # 生成更新元信息 │ └── generate-update-info.mjs # 生成更新元信息
├── package.json # 含 electron-builder 打包配置 ├── package.json # 含 electron-builder 打包配置
├── vite.config.ts ├── vite.config.ts
├── tsconfig.json ├── tsconfig.json
└── tailwind.config.ts └── tailwind.config.ts
@@ -169,15 +170,52 @@
- 错误日志request.ts 拦截器根据错误类型自动分级记录AUTH_EXPIRED → debug5xx → error其余 → warn - 错误日志request.ts 拦截器根据错误类型自动分级记录AUTH_EXPIRED → debug5xx → error其余 → warn
- 401 判断策略HTTP 401 拦截处双重校验——同时检查 HTTP 状态码和响应体中的业务码 `data.code`
`status === 401 && data?.code === 401` 才走 token 刷新流程;其他业务码(如密码错误)穿透到正常错误处理,
透传后端的 `data.message`。这确保登录接口返回的"账号或密码错误"不被 RefreshError("登录已过期") 覆盖。
- 脱敏shared/utils/sanitize.ts 在日志写入前自动处理 ID哈希、邮箱/手机/名称/Token 等字段。 - 脱敏shared/utils/sanitize.ts 在日志写入前自动处理 ID哈希、邮箱/手机/名称/Token 等字段。
# 安全与认证
## Token 加密存储
- `access_token``refresh_token` 通过 Electron `safeStorage` API 加密存储,不落明文:
- macOS → Keychain
- Windows → DPAPI
- Linux → libsecret
- 实现文件:
- `electron/main/safe-storage-ipc.ts` — 主进程加密/解密 IPC 处理(`BIDIRECTIONAL.SAFESTORAGE_ENCRYPT / DECRYPT`
- `src/utils/safe-storage.ts` — 渲染进程桥接层(`initSafeStore / getSafeStore / setSafeStore / clearSafeStore`
- `src/services/request.ts` — token 存取通过 `getSafeStore / setSafeStore`,无需感知加密细节
- `src/services/auth-token.ts` — refresh_token 同理通过 safeStorage 存取
## 双 Token 机制
- `access_token`(短效)+ `refresh_token`(长效),登录成功同时下发
- 请求拦截器自动附加 `Authorization: Bearer <access_token>`
- 401 响应 → 双重校验HTTP `status` + 业务 `data.code`)→ 仅 `code === 401` 时触发刷新
- 刷新期间并发请求排队(`failedQueue`),刷新完成后批量重试
- refresh 接口自身 401 直接拒绝防死循环login 接口 401 穿透透传后端 `message`
- 启动时 `refresh_token` 静默续期(自动登录),失败仅清除标记不影响用户名回填
- 登录/退出通过事件总线解耦(`LOGIN_SUCCESS` / `LOGOUT` / `AUTH_REQUIRED`
## 日志脱敏
- `shared/utils/sanitize.ts` 在日志写入前自动脱敏处理
- 覆盖字段:用户 IDSHA256 哈希)、邮箱(`***@domain`、手机号掩码、用户名称、Token截断
- 主进程 `electron/main/logger.ts` 和渲染进程 `src/utils/logger.ts` 均写入前调用 `sanitizeLogEntry()`
# 样式与主题 # 样式与主题
- 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置 - 优先使用 Tailwind 工具类 完成布局与常规样式,自定义设计令牌应映射为 Tailwind 扩展(在 tailwind.config.ts 中配置
theme.extend theme.extend
- Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。在 src/theme/adapters/ 中创建 - Ant Design 组件样式通过 ConfigProvider 的 theme 属性覆盖,确保与 Tailwind 主题令牌同步。
antd-theme.ts导出主题配置对象引用 tokens.ts 中的设计变量 `ConfigProvider` 同时配置 `locale={zhCN}` 实现全局中文(分页器"条/页"等无需逐个组件设置)
在 src/theme/ 中创建 antd-theme.ts导出主题配置对象引用 tokens.ts 中的设计变量。
组件级 token如 Pagination.itemActiveColor / itemActiveBg在 sharedComponents 中统一管理,
亮色/暗色主题共享,避免 CSS !important hack。
- 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。 - 禁止在组件中硬编码颜色、字号等设计令牌,一律使用主题变量或 Tailwind 类名。
@@ -223,15 +261,28 @@
└─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行 └─ 下载完成 → 用户点击"安装更新" → NSIS/DMG/AppImage 执行
``` ```
| 模块 | 位置 | 职责 | | 模块 | 位置 | 职责 |
|------|------|------| |-------------------|-----------------------------------------------|------------------------------------------|
| `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider调用自有 API无更新返回 APP_VERSION | | `HeiXiuProvider` | `electron/main/updater.ts` | 自定义 Provider调用自有 API无更新返回 APP_VERSION |
| `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater | | `checkForUpdates` | `electron/main/updater.ts` | 预检版本 → 无更新直发 IPC / 有更新交 electron-updater |
| `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) | | `downloadUpdate` | `electron/main/updater.ts` | 用户确认后触发下载(从 checkForUpdates 分离) |
| `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 | | `netRequest` | `electron/main/net-request.ts` | Electron net.request 的 axios 风格封装 |
| `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook单例 IPC 监听) | | `useUpdater` | `src/hooks/use-updater.ts` | 渲染进程更新状态 Hook单例 IPC 监听) |
| `UpdateSetting` | `src/pages/settings/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) | | `UpdateSetting` | `src/pages/settings/blocks/UpdateSetting.tsx` | 设置页更新区块(含"查看详情"弹窗) |
| `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 | | `server.py` | `test-server/server.py` | 本地测试服务器,扫描 release/ 目录,语义版本排序 |
## 增量更新(差分下载)
- `package.json` 配置 `"differentialPackage": true`electron-builder 打包时自动生成 `.exe.blockmap` 文件
- 自定义 Provider 模式:`HeiXiuNsisUpdater extends NsisUpdater` / `HeiXiuMacUpdater extends MacUpdater` /
`HeiXiuAppImageUpdater extends AppImageUpdater`
- 三平台 Updater 均覆盖 `getUpdateInfoAndProvider()` 注入 `HeiXiuProvider`(自定义 API 版本检查),差分下载由
electron-updater 父类原生处理
- blockmap 文件流程:
- `scripts/generate-update-info.mjs` — 扫描 blockmap 文件,写 `blockmap.url` + `blockmap.size``update-info.json`
- `scripts/upload-oss.mjs` — 安装包 + blockmap 文件一并上传 OSS支持 Node.js 直传和 ossutil 两种模式)
- `scripts/publish-release.mjs` — 通过 `--blockmap-url` 参数将 blockmap 信息发布到后端 API
- 效果:日常更新仅下载变更区块,预计节省 60%~90% 流量
## 环境变量加载机制(主进程) ## 环境变量加载机制(主进程)
@@ -244,11 +295,11 @@
④ updater.ts 硬编码默认值https://www.heixiu.com ④ updater.ts 硬编码默认值https://www.heixiu.com
``` ```
| 文件 | 何时生效 | 进 git | 用途 | | 文件 | 何时生效 | 进 git | 用途 |
|------|---------|:------:|------| |--------------------|---------------------|:-----:|--------------------|
| `.env.development` | `npm run dev` | ✅ | 本地开发API → 测试服务器) | | `.env.development` | `npm run dev` | ✅ | 本地开发API → 测试服务器) |
| `.env.production` | `npm run build:xxx` | | 构建时参考API → 生产服务器) | | `.env.production` | `npm run build:xxx` | | 构建时参考API → 生产服务器) |
| `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) | | `.env.local` | 始终(后加载) | ❌ | 个人特殊配置(打包后连本地测试) |
> `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。 > `.env.production` 和 `.env.local` 不打包进 ASAR。打包后无这些文件时自动使用代码默认值。

View File

@@ -6,13 +6,13 @@
## 技术栈 ## 技术栈
| 层 | 技术 | | 层 | 技术 |
| ---- | ------------------------------ | |----|--------------------------------|
| 框架 | Electron 42 | | 框架 | Electron 42 |
| 前端 | React 19 + TypeScript + Vite 8 | | 前端 | React 19 + TypeScript + Vite 8 |
| UI | Ant Design 6 + Tailwind CSS 4 | | UI | Ant Design 6 + Tailwind CSS 4 |
| 主题 | 蓝紫渐变(亮色 / 暗色) | | 主题 | 蓝紫渐变(亮色 / 暗色) |
| 更新 | 自定义 API + 阿里云 OSS | | 更新 | 自定义 API + 阿里云 OSS |
| 打包 | electron-builder + NSIS | | 打包 | electron-builder + NSIS |
--- ---
@@ -67,14 +67,14 @@ node build.mjs --win --personal --sign --cert ./cert.pfx --cert-password xxx
node build.mjs --win --personal --clean --build node build.mjs --win --personal --clean --build
``` ```
| 操作 | 说明 | | 操作 | 说明 |
| ----------- | -------------------------------------------------------------- | |-------------|---------------------------------------------------------|
| `--build` | 构建默认tsc + vite + electron-builder + update-info.json | | `--build` | 构建默认tsc + vite + electron-builder + update-info.json |
| `--clean` | 构建前清理 release/ dist/ dist-electron/ | | `--clean` | 构建前清理 release/ dist/ dist-electron/ |
| `--sign` | 代码签名(需配合 `--cert``CSC_LINK` 环境变量) | | `--sign` | 代码签名(需配合 `--cert``CSC_LINK` 环境变量) |
| `--upload` | 上传安装包到 OSS | | `--upload` | 上传安装包到 OSS |
| `--publish` | POST 版本信息到后端 API | | `--publish` | POST 版本信息到后端 API |
| `--all` | 全流程 | | `--all` | 全流程 |
### 工具命令 ### 工具命令
@@ -143,18 +143,18 @@ ele-heixiu/
主进程加载顺序:`.env.{mode}``.env.local` → OS 环境变量 → 代码默认值。 主进程加载顺序:`.env.{mode}``.env.local` → OS 环境变量 → 代码默认值。
| 变量 | 默认值 | 用途 | | 变量 | 默认值 | 用途 |
| ----------------------- | ------------------------------------------------------ | --------------- | |-------------------------|--------------------------------------------------------|---------------|
| `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API | | `UPDATE_API_URL` | `https://www.heixiu.com` | 版本检查 API |
| `UPDATE_API_KEY` | — | 发布 API 鉴权 | | `UPDATE_API_KEY` | — | 发布 API 鉴权 |
| `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | OSS 下载基地址 | | `UPDATE_DOWNLOAD_BASE` | `https://heixiu.oss-cn-hangzhou.aliyuncs.com/releases` | OSS 下载基地址 |
| `OSS_ENDPOINT` | — | OSS endpoint | | `OSS_ENDPOINT` | — | OSS endpoint |
| `OSS_BUCKET` | — | OSS bucket | | `OSS_BUCKET` | — | OSS bucket |
| `OSS_ACCESS_KEY_ID` | — | OSS AccessKey | | `OSS_ACCESS_KEY_ID` | — | OSS AccessKey |
| `OSS_ACCESS_KEY_SECRET` | — | OSS Secret | | `OSS_ACCESS_KEY_SECRET` | — | OSS Secret |
| `CSC_LINK` | — | 签名证书路径 | | `CSC_LINK` | — | 签名证书路径 |
| `CSC_KEY_PASSWORD` | — | 签名证书密码 | | `CSC_KEY_PASSWORD` | — | 签名证书密码 |
| `VITE_API_BASE_URL` | `http://8.160.179.64:8000`dev | 业务 API 基地址 | | `VITE_API_BASE_URL` | `http://8.160.179.64:8000`dev | 业务 API 基地址 |
> **`.env.local`**(不进 git用于个人特殊配置如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。 > **`.env.local`**(不进 git用于个人特殊配置如打包后覆盖 `UPDATE_API_URL` 指向本地测试服务器。
@@ -162,8 +162,8 @@ ele-heixiu/
## 相关文档 ## 相关文档
| 文档 | 面向 | 内容 | | 文档 | 面向 | 内容 |
|------|------|------| |--------------------------------------------------|-------|-----------------------|
| [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 | | [PROJECT.md](./PROJECT.md) | 开发者 | 技术架构、更新系统、环境变量架构、测试流程 |
| [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 | | [UpdateA.md](./UpdateA.md) | 前端发版 | 构建命令、发版流程、代码签名 |
| [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 | | [test-server/README.md](./test-server/README.md) | 后端程序员 | API 规格、数据库表结构、实现清单 |

120
TODO.md
View File

@@ -1,49 +1,6 @@
# TODO — 船长·HeiXiu 待办与优化清单 # TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-03 > 最后更新2026-06-09
---
## 🔴 安全加固(优先级高)
### 1. 敏感数据加密存储
当前所有敏感数据存储在 `localStorage` 明文/Base64
| 数据 | 现状 | 目标方案 |
|------------------|-----------|----------------------------------------|
| `access_token` | 明文 | Electron `safeStorage.encryptString()` |
| `refresh_token` | 明文 | Electron `safeStorage.encryptString()` |
| `passwordBase64` | Base64 编码 | 移除密码存储,仅靠 refresh_token 自动登录 |
**方案 A推荐**:使用 Electron `safeStorage` API
- macOS → Keychain
- Windows → DPAPI
- Linux → libsecret
```ts
// preload.ts 暴露安全方法
contextBridge.exposeInMainWorld('safeStorage', {
encrypt: (plain: string) => ipcRenderer.invoke('safe-storage:encrypt', plain),
decrypt: (encrypted: Buffer) => ipcRenderer.invoke('safe-storage:decrypt', encrypted),
});
// 替代现有 setToken / setStoredRefreshToken
// 渲染进程不再直接操作 localStorage
```
**方案 B**:敏感操作下沉到主进程
- `access_token` / `refresh_token` 由主进程管理
- 渲染进程通过 IPC 发起请求时由主进程追加 Token
- 渲染进程永不持有明文 Token
### 2. 密码存储策略调整
- [ ] "记住密码" → 仅回填用户名,不再存储密码(已完成)
- [ ] 清理 `StoredAuth.passwordBase64` 字段
- [ ] "自动登录" 完全依赖 `refresh_token` 静默续期
--- ---
@@ -57,15 +14,13 @@ contextBridge.exposeInMainWorld('safeStorage', {
### 路由与页面 ### 路由与页面
- [x] 账户信息页面(`/account``AccountInfo.tsx`AuthAPI.getUserInfo() 驱动
- [ ] 合规素材库页面(`/compliance-assets` - [ ] 合规素材库页面(`/compliance-assets`
- [ ] 项目管理页面(`/projects`,企业版) - [ ] 项目管理页面(`/projects`,企业版)
- [ ] 账户/会员/充值/消费记录/订单明细页面 - [ ] 会员/充值/消费记录/订单明细页面
- [ ] 忘记密码流程
- [ ] 各页面暗色主题适配
### 导航栏 ### 导航栏
- [ ] 移除调试用的 `console.log`
- [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验) - [ ] 导航配置中的 `action: 'external'` 统一管理(白名单校验)
- [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页 - [ ] 未登录点击需登录项 → 登录成功后自动跳转到目标页
@@ -87,60 +42,13 @@ contextBridge.exposeInMainWorld('safeStorage', {
- [ ] 单元测试覆盖Vitest - [ ] 单元测试覆盖Vitest
- [ ] Bundle 体积分析优化rollup-plugin-visualizer 已有,定期检查) - [ ] Bundle 体积分析优化rollup-plugin-visualizer 已有,定期检查)
### 增量更新改造
当前状态:
- `electron-builder` 已配置 `"differential": true`,打包时已生成 `.exe.blockmap` 文件 ✅
- `electron/main/updater.ts` 为**手写全量下载**`net.request`),未使用 `electron-updater` npm 包 ❌
- blockmap 文件生成了但从未被消费,每次更新都是完整下载
目标方案 — 自定义 Provider + electron-updater
```ts
// electron/main/updater-v2.ts
import { autoUpdater } from 'electron-updater';
import { Provider } from 'electron-updater/out/providers/Provider';
class HeiXiuProvider extends Provider<UpdateInfo> {
// 版本检查 → 走自定义 API保留灰度/强更控制)
async getLatestVersion(): Promise<UpdateInfo> {
const res = await fetch(`${API_BASE}/api/v1/update/check?...`);
const data = await res.json();
return {
version: data.latestVersion,
files: [{
url: data.downloadUrl,
sha512: data.sha512,
size: data.fileSize,
blockMapSize: data.blockMapSize, // 后端新增字段
}],
path: data.downloadUrl,
sha512: data.sha512,
releaseDate: data.releaseDate,
};
}
}
```
改造内容:
- [ ] 安装 `electron-updater` npm 包
- [ ] 重写 `electron/main/updater.ts` 为自定义 Provider 模式
- [ ] 发布脚本 `upload-oss.mjs` 增加 blockmap 文件上传
- [ ] `generate-update-info.mjs` 增加 blockmap 字段(`blockMapUrl``blockMapSize`
- [ ] 后端 API `/api/v1/update/check` 响应增加 `blockMapUrl``blockMapSize` 字段
- [ ] 后端 `releases` 表增加 `blockmap_url``blockmap_size`
- [ ] OSS 上传每次版本需同时上传 `.exe` + `.exe.blockmap` 两个文件
增量效果预估:日常更新下载量从完整安装包 → 仅变化的块(预计节省 60%~90% 流量)。
### 代码质量 ### 代码质量
- [ ] 移除未使用的 `shared/` 类型和常量 - [ ] 移除未使用的 `shared/` 类型和常量
- [ ] 统一 IPC 通道注册/注销模式(避免重复监听) - [ ] 统一 IPC 通道注册/注销模式(避免重复监听)
- [ ] React 组件 lazy loadingReact.lazy + Suspense - [ ] React 组件 lazy loadingReact.lazy + Suspense
- [ ] 错误边界组件ErrorBoundary - [ ] 错误边界组件ErrorBoundary
- [ ] 审计其余 useEffect 缺少清理函数的位置StrictMode 双重挂载可能导致重复请求,参考 AppProvider 的 aborted 模式)
### 文档 ### 文档
@@ -152,17 +60,19 @@ class HeiXiuProvider extends Provider<UpdateInfo> {
## ⚠️ 已知技术债务 ## ⚠️ 已知技术债务
| 问题 | 说明 | 影响 | | 问题 | 说明 | 影响 |
|-----------------|---------------------------------------|----------------| |-----------------|----------------------------------------------------------------------------------------------------------------------|----------------|
| localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 | | localStorage 依赖 | 所有持久化数据依赖 localStorage | 清理浏览器数据会丢失所有配置 |
| IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 | | IPC 监听器 | useUpdater 已改为单例,其他 IPC hook 可能也有类似问题 | 长期运行可能内存泄漏 |
| `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 | | `test.py` | 仓库根目录残留测试文件 | 无实际作用,应清理 |
| `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 | | `null` 文件 | 仓库根目录名为 null 的文件 | 构建产物或误操作残留 |
--- ---
## 📋 下次开发计划 ## 📋 下次开发计划
1. **safeStorage 加密改造**(安全加固 #1 1. **合规素材库页面**(功能待完善 — 按 QT 原版设计,以 Modal/Drawer 叠加而非路由跳转实现
2. **合规素材库页面**(功能待完善 Route 页面 2. **各页面 nav 导航补齐**(后续页面优先使用叠加模式,遵循 QT 多窗口模型 → 见 WORKLOG 2026-06-07
3. **各页面 nav 导航补齐** 3. **leftWidth / rightWidth / taskPage 加 localStorage 持久化**UX 优化:刷新页面后保持用户偏好;已通过路由去路由化解决组件卸载问题,但 localStorage 持久层仍未实现)
4. **轮播图关闭按钮 localStorage 持久化**关闭按钮已实现待做localStorage 记录关闭时间戳,超过 N 天再显示)
5. **短信发送接入人机验证**(后期优化,需后端配合:`sendSms` 接口增加 `captcha_token` 参数 + 前端滑块/图形验证组件。当前仅靠 60s 冷却按钮 + 后端限流,桌面端 ASAR 可解包 + DevTools 网络面板 + 代理抓包均能直接暴露 API纯前端限制对脚本攻击无效

View File

@@ -1,18 +1,18 @@
import { app, BrowserWindow, ipcMain, dialog } from 'electron'; import {app, BrowserWindow, ipcMain, dialog, session} from 'electron';
import { createRequire } from 'node:module'; import {createRequire} from 'node:module';
import { fileURLToPath } from 'node:url'; import {fileURLToPath} from 'node:url';
import path from 'node:path'; import path from 'node:path';
import { getPlatform, isMacOS } from './main/utils/platform'; import {getPlatform, isMacOS} from './main/utils/platform';
import { getWindowIconPath } from './main/utils/logo'; import {getWindowIconPath} from './main/utils/logo';
import { buildWindowTitle, parseEdition } from '../shared/constants/app'; import {buildWindowTitle, parseEdition} from '../shared/constants/app';
import { loadEnvFile } from './main/load-env'; import {loadEnvFile} from './main/load-env';
import { initUpdater, registerUpdateIpcHandlers } from './main/updater'; import {initUpdater, registerUpdateIpcHandlers} from './main/updater';
import { setupAppMenu } from './main/menu'; import {setupAppMenu} from './main/menu';
import { initLogger, flushLogger, logger } from './main/logger'; import {initLogger, flushLogger, logger} from './main/logger';
import { registerLogIpcHandlers } from './main/log-ipc'; import {registerLogIpcHandlers} from './main/log-ipc';
import { registerSafeStorageIpcHandlers } from './main/safe-storage-ipc'; import {registerSafeStorageIpcHandlers} from './main/safe-storage-ipc';
import { BIDIRECTIONAL } from '../shared/constants/ipc-channels'; import {BIDIRECTIONAL} from '../shared/constants/ipc-channels';
createRequire(import.meta.url); createRequire(import.meta.url);
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -28,8 +28,8 @@ export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron');
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist'); export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist');
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL
? path.join(process.env.APP_ROOT, 'public') ? path.join(process.env.APP_ROOT, 'public')
: RENDERER_DIST; : RENDERER_DIST;
// ---------- 平台 & 版本信息 ---------- // ---------- 平台 & 版本信息 ----------
const currentPlatform = getPlatform(); const currentPlatform = getPlatform();
@@ -41,20 +41,20 @@ const WINDOW_TITLE = buildWindowTitle(currentPlatform, currentEdition);
// ============================================================ // ============================================================
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
try { try {
logger.error('app', 'Uncaught exception (main process)', error); logger.error('app', 'Uncaught exception (main process)', error);
} catch { } catch {
/* logger 自身异常 — 最后防线 */ /* logger 自身异常 — 最后防线 */
} }
}); });
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
try { try {
const error = reason instanceof Error ? reason : new Error(String(reason)); const error = reason instanceof Error ? reason : new Error(String(reason));
logger.error('app', 'Unhandled rejection (main process)', error); logger.error('app', 'Unhandled rejection (main process)', error);
} catch { } catch {
/* logger 自身异常 — 最后防线 */ /* logger 自身异常 — 最后防线 */
} }
}); });
// ============================================================ // ============================================================
@@ -68,60 +68,60 @@ let mainWindow: BrowserWindow | null = null;
// ============================================================ // ============================================================
function createMainWindow(): BrowserWindow { function createMainWindow(): BrowserWindow {
const iconPath = getWindowIconPath(app.getAppPath()); const iconPath = getWindowIconPath(app.getAppPath());
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
title: WINDOW_TITLE, title: WINDOW_TITLE,
icon: iconPath, icon: iconPath,
width: 1280, width: 1280,
height: 800, height: 800,
minWidth: 960, minWidth: 960,
minHeight: 600, minHeight: 600,
show: false, show: false,
...(isMacOS() ? { titleBarStyle: 'hiddenInset' as const } : {}), ...(isMacOS() ? {titleBarStyle: 'hiddenInset' as const} : {}),
webPreferences: { webPreferences: {
preload: path.join(__dirname, 'preload.mjs'), preload: path.join(__dirname, 'preload.mjs'),
contextIsolation: true, contextIsolation: true,
nodeIntegration: false, nodeIntegration: false,
}, },
}); });
mainWindow.setTitle(WINDOW_TITLE); mainWindow.setTitle(WINDOW_TITLE);
if (VITE_DEV_SERVER_URL) { if (VITE_DEV_SERVER_URL) {
mainWindow.webContents.openDevTools(); mainWindow.webContents.openDevTools();
} }
// macOS 全屏事件 // macOS 全屏事件
if (isMacOS()) { if (isMacOS()) {
mainWindow.on('enter-full-screen', () => mainWindow.on('enter-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', true), mainWindow?.webContents.send('window-fullscreen-changed', true),
); );
mainWindow.on('leave-full-screen', () => mainWindow.on('leave-full-screen', () =>
mainWindow?.webContents.send('window-fullscreen-changed', false), mainWindow?.webContents.send('window-fullscreen-changed', false),
); );
} }
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
mainWindow?.webContents.send('main-process-message', new Date().toLocaleString()); mainWindow?.webContents.send('main-process-message', new Date().toLocaleString());
mainWindow?.webContents.send('platform-info', { mainWindow?.webContents.send('platform-info', {
platform: currentPlatform, platform: currentPlatform,
edition: currentEdition, edition: currentEdition,
}); });
}); });
// ready-to-show 后显示,避免白屏闪烁 // ready-to-show 后显示,避免白屏闪烁
mainWindow.once('ready-to-show', () => { mainWindow.once('ready-to-show', () => {
mainWindow?.show(); mainWindow?.show();
}); });
if (VITE_DEV_SERVER_URL) { if (VITE_DEV_SERVER_URL) {
mainWindow.loadURL(VITE_DEV_SERVER_URL); mainWindow.loadURL(VITE_DEV_SERVER_URL);
} else { } else {
mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html')); mainWindow.loadFile(path.join(RENDERER_DIST, 'index.html'));
} }
return mainWindow; return mainWindow;
} }
// ============================================================ // ============================================================
@@ -129,41 +129,51 @@ function createMainWindow(): BrowserWindow {
// ============================================================ // ============================================================
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (!isMacOS()) { if (!isMacOS()) {
app.quit(); app.quit();
} }
}); });
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow(); createMainWindow();
} }
}); });
app.on('before-quit', () => { app.on('before-quit', () => {
flushLogger(); flushLogger();
}); });
app.whenReady().then(() => { app.whenReady().then(() => {
initLogger(); initLogger();
registerLogIpcHandlers(); registerLogIpcHandlers();
registerSafeStorageIpcHandlers(); registerSafeStorageIpcHandlers();
setupAppMenu(); setupAppMenu();
app.setName(WINDOW_TITLE); app.setName(WINDOW_TITLE);
registerUpdateIpcHandlers(); registerUpdateIpcHandlers();
const fakeUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
session.defaultSession.webRequest.onBeforeSendHeaders((details, callback) => {
const url = details.url;
if (url.includes("rh-images-1252422369.cos.ap-beijing.myqcloud.com")) {
details.requestHeaders['User-Agent'] = fakeUserAgent;
}
callback({
requestHeaders: details.requestHeaders
});
});
// 文件对话框 IPC 处理(供渲染进程选择文件夹) // 文件对话框 IPC 处理(供渲染进程选择文件夹)
ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => { ipcMain.handle(BIDIRECTIONAL.FILE_DIALOG, async (_event, options: Electron.OpenDialogOptions) => {
if (!mainWindow) return { canceled: true, filePaths: [] }; if (!mainWindow) return {canceled: true, filePaths: []};
return dialog.showOpenDialog(mainWindow, { return dialog.showOpenDialog(mainWindow, {
title: options?.title || '选择文件夹', title: options?.title || '选择文件夹',
defaultPath: options?.defaultPath || app.getPath('home'), defaultPath: options?.defaultPath || app.getPath('home'),
properties: options?.properties || ['openDirectory'], properties: options?.properties || ['openDirectory'],
}); });
}); });
// 始终打开主窗口 // 始终打开主窗口
// 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口 // 登录/注册由渲染进程内的 Modal 弹层处理,不再新开窗口
const win = createMainWindow(); const win = createMainWindow();
initUpdater(win); initUpdater(win);
}); });

279
electron/preload/device.ts Normal file
View File

@@ -0,0 +1,279 @@
/**
* 设备指纹采集 — TypeScript 实现
*
* 与 device.py 逻辑一致,保留原 Python 方法不变。
* 通过采集 UUID / CPU / HDD 三项硬件 ID拼接后做 SHA256 得到 device_id。
*
* 适用环境:
* - Node.js / ElectronWindows通过 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);
}

View 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();
}

View File

@@ -1,21 +1,21 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- 使用项目 Logo 作为 faviconWindows 可用 .ico --> <!-- 使用项目 Logo 作为 faviconWindows 可用 .ico -->
<link rel="icon" type="image/png" href="/src/assets/logo/HX.png" /> <link rel="icon" type="image/png" href="/src/assets/logo/HX.png" />
<!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />--> <!-- <meta name="viewport" content="width=device-width, initial-scale=1.0" />-->
<meta http-equiv="Content-Security-Policy" content=" <meta http-equiv="Content-Security-Policy" content="
default-src 'self'; default-src 'self';
script-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; img-src 'self' data: https://rh-images-1252422369.cos.ap-beijing.myqcloud.com https://www.heixiu.net ; ;
connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net; connect-src 'self' http://8.160.179.64:8000 http://localhost:5173 ws://localhost:5173 https://www.heixiu.net ;
"> ">
<title>船长·HeiXiu</title> <title>船长·HeiXiu</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

74
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{ {
"name": "ele-heixiu", "name": "ele-heixiu",
"version": "0.0.1", "version": "0.0.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ele-heixiu", "name": "ele-heixiu",
"version": "0.0.1", "version": "0.0.13",
"dependencies": { "dependencies": {
"@react-buddy/ide-toolbox": "^2.5.0",
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7"
"react-router-dom": "^7.16.0"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
@@ -1949,6 +1949,15 @@
"react-dom": ">=18.0.0" "react-dom": ">=18.0.0"
} }
}, },
"node_modules/@react-buddy/ide-toolbox": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@react-buddy/ide-toolbox/-/ide-toolbox-2.5.0.tgz",
"integrity": "sha512-ev2BZUeCymJfKuV3M+rQzdVZYlAacid8GexD3TtP0S+UBCBbH6ZCdkbx2eJoh/X+CEWFctg+2ppxIoaK52A7Rw==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
@@ -3932,19 +3941,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/core-util-is": { "node_modules/core-util-is": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
@@ -7094,44 +7090,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-router": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.16.0.tgz",
"integrity": "sha512-wArC8lVyJb3+jM9OpDyW6hLCizACWkvQR/sSGqSs+o5uEXEtGlqdZ4v8hENR3Jad6i+LRkK93q/+bQAcvl6V1A==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.16.0.tgz",
"integrity": "sha512-kMUAbimWB5FVbF4Bce4bJsiKJWLIUHq/mEG8+CFDnCSgltptBiG5nguducmsJeGKytlCvQud9Qhzpn49iduTlA==",
"license": "MIT",
"dependencies": {
"react-router": "7.16.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/read-binary-file-arch": { "node_modules/read-binary-file-arch": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz", "resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
@@ -7531,12 +7489,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{ {
"name": "ele-heixiu", "name": "ele-heixiu",
"private": true, "private": true,
"version": "0.0.12", "version": "0.0.18",
"description": "船长·HeiXiu — 桌面效率工作台", "description": "船长·HeiXiu — 桌面效率工作台",
"author": "HeiXiu 杨烨", "author": "HeiXiu 杨烨",
"type": "module", "type": "module",
@@ -26,12 +26,12 @@
"test:e2e": "playwright test.json" "test:e2e": "playwright test.json"
}, },
"dependencies": { "dependencies": {
"@react-buddy/ide-toolbox": "^2.5.0",
"antd": "^6.4.3", "antd": "^6.4.3",
"axios": "^1.16.1", "axios": "^1.16.1",
"electron-updater": "^6.8.3", "electron-updater": "^6.8.3",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7"
"react-router-dom": "^7.16.0"
}, },
"build": { "build": {
"appId": "com.heixiu.electron", "appId": "com.heixiu.electron",

View File

@@ -1,18 +1,25 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { HashRouter } from 'react-router-dom';
import { useAppContext } from './contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { LoginPage } from './pages/login'; import { LoginPage } from './pages/login';
import { SettingsPage } from './pages/settings';
import { on, off, EVENTS } from './utils/event-bus'; import { on, off, EVENTS } from './utils/event-bus';
import { AppRoutes } from './router'; import { Layout } from '@/components/Layout';
import { HomePage } from '@/pages/home/HomePage';
import { PageDispatcher } from '@/components/PageDispatcher';
/** /**
* 根组件 — 主窗口 * 根组件 — 主窗口
* *
* 层级: * 层级:
* HashRouter * LayoutBanner + NavBar
* ── LoginPageModal 弹层,事件总线驱动 * ── HomePage主页面始终挂载
* ├── AppRoutes页面路由 + SettingsPage Drawer 叠加 * LoginPageModal 弹层,事件总线驱动
* SettingsPageModal 居中叠加,事件总线驱动 — 对应 QT 原版设置窗口)
*
* 设计原则(对应 QT 原版多窗口模型):
* - 叠加层(设置、公告、页面等)→ Modal/Drawer + 事件总线,主页面不卸载
* - 不再使用 React Router所有导航由事件总线驱动
*/ */
function App() { function App() {
const { isLoggedIn } = useAppContext(); const { isLoggedIn } = useAppContext();
@@ -42,14 +49,31 @@ function App() {
setLoginOpen(false); setLoginOpen(false);
}, []); }, []);
// 设置 Drawer 状态
const [settingsOpen, setSettingsOpen] = useState(false);
// 监听打开设置 Drawer
useEffect(() => {
const handler = () => setSettingsOpen(true);
on(EVENTS.OPEN_SETTINGS, handler);
return () => { off(EVENTS.OPEN_SETTINGS, handler); };
}, []);
return ( return (
<HashRouter> <>
<Layout>
<HomePage />
</Layout>
{/* 登录 Modal — 可关闭,关闭后主页可操作 */} {/* 登录 Modal — 可关闭,关闭后主页可操作 */}
<LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} /> <LoginPage open={loginOpen && !isLoggedIn} onClose={handleLoginClose} />
{/* 页面路由 + SettingsPage Drawer 叠加 */} {/* 设置 Modal — 事件驱动,居中叠加,主页面始终挂载(对应 QT 原版独立设置窗口) */}
<AppRoutes /> <SettingsPage open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</HashRouter>
{/* 页面调度器 — 监听 OPEN_PAGE 事件,根据 modalType 渲染 Modal/Drawer/FloatingPanel */}
<PageDispatcher />
</>
); );
} }

View File

@@ -1,21 +1,22 @@
// ============================================================ // ============================================================
// AppProvider — 应用状态 Provider 组件 // AppProvider — 全局应用状态 Context + Provider + Hook
//
// 导出:
// AppContext, useAppContext() — Context + Consumer Hook
// AppContextValue — 类型
// AppProvider — Provider 组件
// ============================================================ // ============================================================
import { useState, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/platform'; import { getPlatform, isDev } from '@/utils/platform';
import { parseEdition } from '@shared/constants/app';
import { safeIpcOn, safeIpcOff } from '@/utils/ipc'; import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
import { AppContext } from '@/contexts/app-context'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import type { AppContextValue } from '@/contexts/app-context';
import type { LoginResponseBody } from '@/services/modules';
import { setToken, clearToken, initTokenStore } from '@/services/request'; import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus'; import { emit, EVENTS } from '@/utils/event-bus';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { AuthAPI } from '@/services/modules'; import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
import { import {
initAuthRefresh, initAuthRefresh,
getStoredRefreshToken, getStoredRefreshToken,
@@ -23,6 +24,40 @@ import {
clearStoredRefreshToken, clearStoredRefreshToken,
initRefreshTokenStore, initRefreshTokenStore,
} from '@/services/auth-token'; } from '@/services/auth-token';
import {initModelCache} from '@/services/cache/model-cache';
// ---------- Context 类型 ----------
export interface AppContextValue {
/** 当前操作系统平台 */
platform: Platform;
/** 产品版本(个人版 / 企业版) */
edition: Edition;
/** 用户是否已登录 */
isLoggedIn: boolean;
/** 当前登录用户信息(未登录时为 null结构与 LoginResponseBody 一致) */
user: LoginResponseBody | null;
/** 登录:保存 Token 并更新登录态auth 为登录接口返回值,异步加密存储) */
login: (auth: LoginResponseBody) => Promise<void>;
/** 退出登录:清除 Token 和用户状态 */
logout: () => void;
/** 是否为开发环境 */
isDevMode: boolean;
}
export const AppContext = createContext<AppContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface AppProviderProps { interface AppProviderProps {
children: ReactNode; children: ReactNode;
@@ -30,12 +65,25 @@ interface AppProviderProps {
export function AppProvider({ children }: AppProviderProps) { export function AppProvider({ children }: AppProviderProps) {
const [platform] = useState<Platform>(getPlatform); const [platform] = useState<Platform>(getPlatform);
const [edition] = useState<Edition>(() => {
return parseEdition(import.meta.env.VITE_EDITION);
});
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [user, setUser] = useState<LoginResponseBody | null>(null); const [user, setUser] = useState<LoginResponseBody | null>(null);
/**
* edition 从登录用户的 account_type 派生(运行时判断,无需构建变量)
*
* 映射关系:
* "individual" → personal ← 个人用户
* "company" | "employee" → enterprise ← 企业用户
* null未登录 → personal ← 默认最小功能集
*
* 安全性edition 是 useMemo 派生值,不可独立赋值;
* 不写入 localStorage重启/登出后回退默认值,必须重新登录才能确权。
*/
const edition: Edition = useMemo(() => {
if (!user) return 'personal';
return user.account_type === 'individual' ? 'personal' : 'enterprise';
}, [user]);
// ---------- 登录 / 退出 ---------- // ---------- 登录 / 退出 ----------
const login = useCallback(async (auth: LoginResponseBody) => { const login = useCallback(async (auth: LoginResponseBody) => {
@@ -60,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();
@@ -75,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,
@@ -105,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; };
}, []); }, []);
// ---------- 监听主进程推送的平台/版本信息 ---------- // ---------- 监听主进程推送的平台/版本信息 ----------

View File

@@ -1,21 +1,24 @@
// ============================================================ // ============================================================
// Layout — 页面布局壳NavBar + 页面内容 Outlet // Layout — 页面布局壳(Banner轮播 + NavBar + 页面内容)
// BannerCarousel 始终渲染(当前仅有首页,后续多面板切换时 Banner 始终保持)
// ============================================================ // ============================================================
import { Outlet } from 'react-router-dom'; import type { ReactNode } from 'react';
import { theme as antTheme } from 'antd'; import { theme as antTheme } from 'antd';
import { NavBar } from './navbar'; import { NavBar } from './navbar';
import { BannerCarousel } from '@/pages/home/components/BannerCarousel';
export function Layout() { export function Layout({ children }: { children: ReactNode }) {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();
return ( return (
<div <div
className="min-h-screen flex flex-col transition-colors duration-300" className="h-screen flex flex-col transition-colors duration-150"
style={{ background: token.colorBgLayout }} style={{ background: token.colorBgLayout }}
> >
<BannerCarousel />
<NavBar /> <NavBar />
<Outlet /> {children}
</div> </div>
); );
} }

View File

@@ -0,0 +1,207 @@
// ============================================================
// PageDispatcher — 页面事件调度器
//
// 监听 EVENTS.OPEN_PAGE根据 modalType 渲染对应容器:
// - modal → antd Modal居中模态有遮罩阻断交互
// - drawer → antd Drawer侧滑抽屉
// - floating → FloatingPanel非模态浮动面板可拖拽不阻断交互
//
// 全局互斥:同一时刻最多打开一个页面组件。
// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。
//
// PAGE_MAP 集中注册所有可调度页面:
// 有真实组件 → { component: XxxPage, label: '...', width?: number }
// 占位页面 → 只写 label自动渲染 "页面开发中"
// 懒加载页面 → component: React.lazy(() => import('...'))
// 新增页面只需在这里加一行,无需创建独立文件
// ============================================================
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Drawer, Modal} from 'antd';
import {EVENTS, off, on} from '@/utils/event-bus';
import {FloatingPanel} from '@/components/ui/FloatingPanel';
import {WechatWorkPage} from '@/pages/headers/WechatWorkPage';
import {AccountInfo} from '@/pages/headers/AccountInfo.tsx';
// ---------- 类型 ----------
type ModalType = 'modal' | 'drawer' | 'floating';
interface PageEventPayload {
target: string;
modalType: ModalType;
label: string;
}
/** 当前活跃面板id 用于 key / 关闭定位) */
interface ActivePanel extends PageEventPayload {
id: string;
}
interface PageConfig {
/** 页面组件(未提供则使用自动生成的占位组件)。
* 支持 React.lazy(() => import('...')) 懒加载。 */
component?: ComponentType;
/** 页面标签(显示在容器标题栏) */
label: string;
/** 容器宽度px。也支持函数动态计算() => window.innerWidth * 0.6 */
width?: number | (() => number);
/** 容器高度px仅 FloatingPanel 生效。也支持函数动态计算 */
height?: number | (() => number);
}
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
function PlaceholderPage({label}: { label: string }) {
return (
<div style={{padding: 8}}>
<p style={{color: 'inherit', opacity: 0.6, margin: 0}}>{label} </p>
</div>
);
}
/** 为每个占位页预创建稳定组件引用 */
function createPlaceholder(label: string): ComponentType {
const C = () => <PlaceholderPage label={label} />;
C.displayName = `Placeholder_${label}`;
return C;
}
// ---------- target → 页面组件映射 ----------
const PAGE_MAP: Record<string, PageConfig> = {
'/wechat-work': {component: WechatWorkPage, label: '企业微信'},
'/compliance-assets': {label: '合规素材库'},
'/account': {
component: AccountInfo,
label: '账户',
width: () => Math.min(window.outerHeight * 0.65, 720),
height: () => Math.min(window.outerHeight * 0.65, 680),
},
'/vip': {label: '开会员/激活'},
'/recharge': {label: '充值积分'},
'/billing': {label: '消费记录'},
'/orders': {label: '订单明细'},
'/projects': {label: '项目管理'},
'/quota': {label: '查配额'},
};
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
const RESOLVED_PAGE_MAP = new Map<string, ComponentType>();
for (const [key, config] of Object.entries(PAGE_MAP)) {
RESOLVED_PAGE_MAP.set(key, config.component || createPlaceholder(config.label));
}
/** 解析页面组件(纯查表,不创建新组件) */
function resolvePage(target: string): ComponentType | null {
return RESOLVED_PAGE_MAP.get(target) || null;
}
/** 解析容器宽度(支持静态数值或动态函数) */
function resolveWidth(target: string): number | undefined {
const w = PAGE_MAP[target]?.width;
return typeof w === 'function' ? w() : w;
}
/** 解析容器高度(支持静态数值或动态函数) */
function resolveHeight(target: string): number | undefined {
const h = PAGE_MAP[target]?.height;
return typeof h === 'function' ? h() : h;
}
// ---------- 组件 ----------
export function PageDispatcher() {
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
const nextIdRef = useRef(0);
// 监听 OPEN_PAGE 事件
useEffect(() => {
const handler = (payload: unknown) => {
const {target, modalType, label} = payload as PageEventPayload;
if (!target || !PAGE_MAP[target]) {
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
return;
}
// 全局互斥:已有面板打开时,禁止打开新面板
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
setActivePanel((prev) => {
if (prev) return prev;
return {id: `pnl-${nextIdRef.current++}`, target, modalType, label};
});
};
on(EVENTS.OPEN_PAGE, handler);
return () => off(EVENTS.OPEN_PAGE, handler);
}, []);
// 关闭面板
const closePanel = useCallback(() => {
setActivePanel(null);
}, []);
// 页面组件useMemo 稳定引用)
const PanelPage = useMemo<ComponentType | null>(
() => (activePanel ? resolvePage(activePanel.target) : null),
[activePanel],
);
const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined;
// 容器默认尺寸(动态计算,视口缩放自动适配)
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
return (
<>
{/* ======== Modal ======== */}
{activePanel?.modalType === 'modal' && PanelPage && (
<Modal
open
centered
title={activePanel.label}
onCancel={closePanel}
footer={null}
destroyOnHidden={true}
mask={{closable: true}}
width={panelWidth ?? defaultModalWidth}
>
{/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Modal>
)}
{/* ======== Drawer ======== */}
{activePanel?.modalType === 'drawer' && PanelPage && (
<Drawer
open
title={activePanel.label}
onClose={closePanel}
destroyOnHidden={true}
size={panelWidth ?? defaultDrawerWidth}
>
{/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Drawer>
)}
{/* ======== Floating Panel ======== */}
{activePanel?.modalType === 'floating' && PanelPage && (
<FloatingPanel
key={activePanel.id}
open
title={activePanel.label}
onClose={closePanel}
width={panelWidth ?? defaultDrawerWidth}
height={panelHeight}
>
{/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</FloatingPanel>
)}
</>
);
}

View File

@@ -1,23 +1,99 @@
// ============================================================ // ============================================================
// SettingsProvider — 设置状态 Provider 组件 // SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
// //
// 职责: // 职责:
// - 初始化时从 localStorage 读取已存储的设置项 // - 存储路径的集中管理read/write/clear
// - 提供 SettingsContext 给所有子组件 // - 持久化到 localStorage遵循 ele-heixiu-* 键名规范
// - 写操作同步更新 state + localStorage // - 全局组件通过 useSettings() 消费
// ============================================================ // ============================================================
import { useState, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import {
SettingsContext, // ---------- localStorage 键 ----------
readStoredOutputPath,
writeStoredOutputPath, const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
clearStoredOutputPath, const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
readStoredTeamRepoPath,
writeStoredTeamRepoPath, // ---------- Context 类型 ----------
clearStoredTeamRepoPath,
} from '@/contexts/settings-context'; export interface SettingsContextValue {
import type { SettingsContextValue } from '@/contexts/settings-context'; /** 大模型产物存储仓库路径 */
outputPath: string;
/** 团队创作存储仓库路径(企业版) */
teamRepoPath: string;
/** 设置产物输出路径 */
setOutputPath: (path: string) => void;
/** 设置团队创作仓库路径 */
setTeamRepoPath: (path: string) => void;
/** 清除产物输出路径 */
clearOutputPath: () => void;
/** 清除团队创作仓库路径 */
clearTeamRepoPath: () => void;
}
export const SettingsContext = createContext<SettingsContextValue | null>(null);
// ---------- 工具函数localStorage 读写)----------
export function readStoredOutputPath(): string {
try {
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredOutputPath(path: string): void {
try {
localStorage.setItem(OUTPUT_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredOutputPath(): void {
try {
localStorage.removeItem(OUTPUT_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
export function readStoredTeamRepoPath(): string {
try {
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredTeamRepoPath(path: string): void {
try {
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredTeamRepoPath(): void {
try {
localStorage.removeItem(TEAM_REPO_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Consumer Hook ----------
export function useSettings(): SettingsContextValue {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface SettingsProviderProps { interface SettingsProviderProps {
children: ReactNode; children: ReactNode;

View File

@@ -1,51 +1,141 @@
// ============================================================ // ============================================================
// ThemeProvider — 主题 Provider 组件 // ThemeProvider — 主题 Context + Provider + Hook + 工具函数
//
// 桌面端Electron使用 View Transitions API 驱动主题切换:
// GPU 合成器截取旧/新两帧做一次 cross-fade替代 80+ 个
// 独立 CSS 过渡,消除 Electron Chromium 的合成器卡顿。
// 浏览器不支持时回退到 CSS transition 方案。
// ============================================================ // ============================================================
import { useState, useEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
import { flushSync } from 'react-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { getThemeConfig } from '../theme/antd-theme'; import { getThemeConfig } from '../theme/antd-theme';
import {
ThemeContext, // ---------- Context 类型 ----------
readStoredTheme,
writeStoredTheme, export interface ThemeContextValue {
applyHtmlTheme, isDark: boolean;
STORAGE_KEY, toggleTheme: () => void;
} from '../hooks/use-theme'; setLight: () => void;
import type { ThemeContextValue } from '../hooks/use-theme'; setDark: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
// ---------- localStorage 工具函数 ----------
export const STORAGE_KEY = 'ele-heixiu-theme';
export function readStoredTheme(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark') return true;
if (stored === 'light') return false;
} catch {
/* 静默忽略 */
}
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}
export function writeStoredTheme(isDark: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
} catch {
/* 静默忽略 */
}
}
export function applyHtmlTheme(isDark: boolean): void {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// ---------- Consumer Hook ----------
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface ThemeProviderProps { interface ThemeProviderProps {
children: ReactNode; children: ReactNode;
} }
/** 检测 View Transitions API 是否可用Chromium 111+Electron 28+ */
function supportsViewTransition(): boolean {
return typeof document !== 'undefined' && 'startViewTransition' in document;
}
export function ThemeProvider({ children }: ThemeProviderProps) { export function ThemeProvider({ children }: ThemeProviderProps) {
const [isDark, setIsDark] = useState<boolean>(readStoredTheme); const [isDark, setIsDark] = useState<boolean>(readStoredTheme);
// 挂载时同步 useLayoutEffect(() => {
useEffect(() => {
applyHtmlTheme(isDark); applyHtmlTheme(isDark);
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, [isDark]);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
setIsDark((prev) => { if (supportsViewTransition()) {
const next = !prev; // View Transitions APIGPU 合成器 cross-fade1 个动画替代 N 个 CSS 过渡
writeStoredTheme(next); document.startViewTransition(() => {
applyHtmlTheme(next); flushSync(() => {
return next; setIsDark((prev) => {
}); const next = !prev;
writeStoredTheme(next);
return next;
});
});
});
} else {
// 回退:普通 React 状态更新 + CSS transition
setIsDark((prev) => {
const next = !prev;
writeStoredTheme(next);
return next;
});
}
}, []); }, []);
const setLight = useCallback(() => { const setLight = useCallback(() => {
setIsDark(false); if (supportsViewTransition()) {
writeStoredTheme(false); document.startViewTransition(() => {
applyHtmlTheme(false); flushSync(() => {
setIsDark(false);
writeStoredTheme(false);
});
});
} else {
setIsDark(false);
writeStoredTheme(false);
}
}, []); }, []);
const setDark = useCallback(() => { const setDark = useCallback(() => {
setIsDark(true); if (supportsViewTransition()) {
writeStoredTheme(true); document.startViewTransition(() => {
applyHtmlTheme(true); flushSync(() => {
setIsDark(true);
writeStoredTheme(true);
});
});
} else {
setIsDark(true);
writeStoredTheme(true);
}
}, []); }, []);
// 监听系统主题变化(仅用户从未手动切换时跟随) // 监听系统主题变化(仅用户从未手动切换时跟随)
@@ -55,7 +145,6 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored === null) { if (stored === null) {
setIsDark(e.matches); setIsDark(e.matches);
applyHtmlTheme(e.matches);
} }
}; };
mq.addEventListener('change', handleChange); mq.addEventListener('change', handleChange);
@@ -66,7 +155,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) {
return ( return (
<ThemeContext.Provider value={ctx}> <ThemeContext.Provider value={ctx}>
<ConfigProvider theme={getThemeConfig(isDark)}>{children}</ConfigProvider> <ConfigProvider theme={getThemeConfig(isDark)} locale={zhCN}>{children}</ConfigProvider>
</ThemeContext.Provider> </ThemeContext.Provider>
); );
} }

View File

@@ -3,202 +3,201 @@
// 数据来源GET /api/v1/announcements // 数据来源GET /api/v1/announcements
// ============================================================ // ============================================================
import { useEffect, useState, useCallback } from 'react'; import {useEffect, useState, useCallback} from 'react';
import { Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip } from 'antd'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import { NotificationOutlined } from '@ant-design/icons'; import {NotificationOutlined} from '@ant-design/icons';
import { fetchAnnouncements, type Announcement } from '@/services/modules'; import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement';
const { Text, Paragraph } = Typography; const {Text, Paragraph} = Typography;
// ---------- 辅助 ---------- // ---------- 辅助 ----------
/** 公告类型 → Tag 颜色映射 */ /** 公告类型 → Tag 颜色映射 */
const typeColorMap: Record<string, string> = { const typeColorMap: Record<string, string> = {
system: 'blue', system: 'blue',
feature: 'purple', feature: 'purple',
activity: 'orange', activity: 'orange',
maintenance: 'red', maintenance: 'red',
}; };
/** 公告类型 → 中文标签 */ /** 公告类型 → 中文标签 */
const typeLabelMap: Record<string, string> = { const typeLabelMap: Record<string, string> = {
system: '系统', system: '系统',
feature: '功能', feature: '功能',
activity: '活动', activity: '活动',
maintenance: '维护', maintenance: '维护',
}; };
/** 格式化日期YYYY-MM-DD HH:mm */ /** 格式化日期YYYY-MM-DD HH:mm */
function formatDate(iso: string): string { function formatDate(iso: string): string {
try { try {
return new Date(iso).toLocaleDateString('zh-CN', { return new Date(iso).toLocaleDateString('zh-CN', {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
} catch { } catch {
return iso; return iso;
} }
} }
// ---------- 组件 ---------- // ---------- 组件 ----------
interface AnnouncementDrawerProps { interface AnnouncementDrawerProps {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
} }
export function AnnouncementDrawer({ open, onClose }: AnnouncementDrawerProps) { export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
const [list, setList] = useState<Announcement[]>([]); const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 详情 Modal // 详情 Modal
const [detail, setDetail] = useState<Announcement | null>(null); const [detail, setDetail] = useState<Announcement | null>(null);
// 打开抽屉时请求公告列表 // 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => { const loadAnnouncements = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchAnnouncements(); const data: AnnouncementListResponse = await fetchAnnouncements();
// 仅展示有效公告 setList(data.filter((item: Announcement) => item.is_active));
setList(data.filter((item) => item.is_active)); } catch (err) {
} catch (err) { setError((err as Error).message || '加载公告失败');
setError((err as Error).message || '加载公告失败'); setList([]);
setList([]); } finally {
} finally { setLoading(false);
setLoading(false); }
} }, []);
}, []);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
loadAnnouncements(); loadAnnouncements();
} }
}, [open, loadAnnouncements]); }, [open, loadAnnouncements]);
// ---- 渲染 ---- // ---- 渲染 ----
return ( return (
<> <>
<Drawer <Drawer
title={ title={
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<NotificationOutlined /> <NotificationOutlined />
</span> </span>
} }
placement="right" placement="right"
size={380} size={380}
open={open} open={open}
onClose={onClose} onClose={onClose}
styles={{ body: { padding: 0 } }} styles={{body: {padding: 0}}}
> >
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<Spin description="加载中..." /> <Spin description="加载中..." />
</div> </div>
) : error ? ( ) : error ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<Empty description={error} /> <Empty description={error} />
</div> </div>
) : list.length === 0 ? ( ) : list.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<Empty description="暂无公告" /> <Empty description="暂无公告" />
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
{list.map((item) => ( {list.map((item:Announcement) => (
<Tooltip <Tooltip
key={item.id} key={item.id}
title={ title={
item.content.length > 100 item.content.length > 100
? item.content.slice(0, 200) + '…' ? item.content.slice(0, 200) + '…'
: undefined : undefined
} }
placement="left" placement="left"
> >
<Card <Card
size="small" size="small"
className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow" className="rounded-md! border-0! shadow-sm! cursor-pointer hover:shadow-md! transition-shadow"
title={ title={
<Text strong className="text-sm" ellipsis> <Text strong className="text-sm" ellipsis>
{item.pinned && '📌 '} {item.pinned && '📌 '}
{item.title} {item.title}
</Text> </Text>
} }
extra={ extra={
<Tag <Tag
color={typeColorMap[item.type] || 'default'} color={typeColorMap[item.type] || 'default'}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[item.type] || item.type} {typeLabelMap[item.type] || item.type}
</Tag> </Tag>
} }
onClick={() => setDetail(item)} onClick={() => setDetail(item)}
> >
<Paragraph <Paragraph
className="mb-2! text-xs" className="mb-2! text-xs"
type="secondary" type="secondary"
ellipsis={{ rows: 2 }} ellipsis={{rows: 2}}
> >
{item.content} {item.content}
</Paragraph> </Paragraph>
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
{formatDate(item.created_at)} {formatDate(item.created_at)}
</Text> </Text>
</Card> </Card>
</Tooltip> </Tooltip>
))} ))}
</div> </div>
)} )}
</Drawer> </Drawer>
{/* ======== 公告详情 Modal ======== */} {/* ======== 公告详情 Modal ======== */}
<Modal <Modal
open={!!detail} open={!!detail}
onCancel={() => setDetail(null)} onCancel={() => setDetail(null)}
footer={null} footer={null}
width={480} width={480}
centered centered
title={ title={
detail && ( detail && (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{detail.pinned && '📌 '} {detail.pinned && '📌 '}
{detail.title} {detail.title}
</span> </span>
) )
} }
> >
{detail && ( {detail && (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag <Tag
color={typeColorMap[detail.type] || 'default'} color={typeColorMap[detail.type] || 'default'}
className="m-0! text-xs" className="m-0! text-xs"
> >
{typeLabelMap[detail.type] || detail.type} {typeLabelMap[detail.type] || detail.type}
</Tag> </Tag>
<Text type="secondary" className="text-xs"> <Text type="secondary" className="text-xs">
{formatDate(detail.created_at)} {formatDate(detail.created_at)}
</Text> </Text>
</div> </div>
<div className="px-1 py-2 rounded-md" <div className="px-1 py-2 rounded-md"
style={{ style={{
background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))', background: 'var(--color-fill-tertiary, rgba(0,0,0,0.04))',
}} }}
> >
<Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap"> <Paragraph className="mb-0! text-sm leading-relaxed whitespace-pre-wrap">
{detail.content} {detail.content}
</Paragraph> </Paragraph>
</div> </div>
</div> </div>
)} )}
</Modal> </Modal>
</> </>
); );
} }

View File

@@ -7,131 +7,134 @@
// - 登录/注册 与 退出登录 仍按登录状态互斥显示 // - 登录/注册 与 退出登录 仍按登录状态互斥显示
// ============================================================ // ============================================================
import { useState, useCallback, useMemo } from 'react'; import {useState, useCallback, useMemo} from 'react';
import { useNavigate } from 'react-router-dom'; import {App} from 'antd';
import { App } from 'antd';
import { useAppContext } from '@/contexts/app-context'; import {useAppContext} from '@/components/AppProvider';
import { useTheme } from '@/hooks/use-theme'; import {useTheme} from '@/components/ThemeProvider';
import { emit, EVENTS } from '@/utils/event-bus.ts'; import {emit, EVENTS} from '@/utils/event-bus.ts';
import { NavItem } from './NavItem'; import {NavItem} from './NavItem';
import { AnnouncementDrawer } from './AnnouncementDrawer'; import {AnnouncementDrawer} from './AnnouncementDrawer';
import { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './nav-config'; import {personalLeft, personalRight, enterpriseLeft, enterpriseRight} from './nav-config';
import type { NavItemConfig } from './types'; import type {NavItemConfig} from './types';
const NAVBAR_HEIGHT = 56; const NAVBAR_HEIGHT = 56;
export function NavBar() { export function NavBar() {
const { platform, edition, isLoggedIn, logout } = useAppContext(); const {platform, edition, isLoggedIn, logout} = useAppContext();
const { isDark } = useTheme(); const {isDark} = useTheme();
const { message } = App.useApp(); const {message} = App.useApp();
const navigate = useNavigate();
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
// 根据版本选择导航项 // 根据版本选择导航项
const { leftItems, rightItems } = useMemo(() => { const {leftItems, rightItems} = useMemo(() => {
const isEnterprise = edition === 'enterprise'; const isEnterprise = edition === 'enterprise';
return { return {
leftItems: isEnterprise ? enterpriseLeft : personalLeft, leftItems: isEnterprise ? enterpriseLeft : personalLeft,
rightItems: isEnterprise ? enterpriseRight : personalRight, rightItems: isEnterprise ? enterpriseRight : personalRight,
}; };
}, [edition]); }, [edition]);
// 过滤规则: // 过滤规则:
// requireGuest → 仅未登录时可见(登录/注册) // requireGuest → 仅未登录时可见(登录/注册)
// "退出登录" → 仅已登录时可见 // "退出登录" → 仅已登录时可见
// 其他所有项 → 始终可见,未登录点击时弹"请先登录" // 其他所有项 → 始终可见,未登录点击时弹"请先登录"
const visibleLeft = useMemo( const visibleLeft = useMemo(
() => () =>
leftItems.filter((item) => { leftItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false; if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn); return !(item.key === 'logout' && !isLoggedIn);
}), }),
[leftItems, isLoggedIn], [leftItems, isLoggedIn],
); );
const visibleRight = useMemo( const visibleRight: NavItemConfig[] = useMemo(
() => () =>
rightItems.filter((item) => { rightItems.filter((item) => {
if (item.requireGuest && isLoggedIn) return false; if (item.requireGuest && isLoggedIn) return false;
return !(item.key === 'logout' && !isLoggedIn); return !(item.key === 'logout' && !isLoggedIn);
}), }),
[rightItems, isLoggedIn], [rightItems, isLoggedIn],
); );
// 点击导航项 // 点击导航项
const handleNavClick = useCallback( const handleNavClick = useCallback(
(item: NavItemConfig) => { (item: NavItemConfig) => {
// 需要登录但未登录 → 提示并弹出登录 Modal // 需要登录但未登录 → 提示并弹出登录 Modal
if (item.requireLogin && !isLoggedIn) { if (item.requireLogin && !isLoggedIn) {
message.warning('请先登录'); message.warning('请先登录');
emit(EVENTS.SHOW_LOGIN); emit(EVENTS.SHOW_LOGIN);
return; return;
} }
switch (item.action) { switch (item.action) {
case 'drawer': case 'drawer':
if (item.key === 'announcements') { if (item.key === 'announcements') {
setDrawerOpen(true); setDrawerOpen(true);
} }
break; break;
case 'button': case 'button':
if (item.key === 'logout') { if (item.key === 'logout') {
logout(); logout();
} } else if (item.key === 'settings') {
break; emit(EVENTS.OPEN_SETTINGS);
} else if (item.key === 'auth') {
emit(EVENTS.SHOW_LOGIN);
}
break;
case 'external': case 'external':
if (item.target) { if (item.target) {
window.open(item.target, '_blank'); window.open(item.target, '_blank');
} }
break; break;
case 'navigate': case 'page':
case 'spa': if (item.target) {
if (item.key === 'auth') { emit(EVENTS.OPEN_PAGE, {
emit(EVENTS.SHOW_LOGIN); target: item.target,
} else if (item.target) { modalType: item.modalType ?? 'floating',
navigate(item.target); label: item.label,
} });
break; }
} break;
}, }
[isLoggedIn, logout, message], },
); [isLoggedIn, logout, message],
);
const macPaddingLeft = platform === 'darwin' ? 80 : 12; const macPaddingLeft = platform === 'darwin' ? 80 : 12;
return ( return (
<> <>
<div <div
className="navbar-drag-region flex items-center justify-between select-none" className="navbar-drag-region flex items-center justify-between select-none"
style={{ style={{
height: NAVBAR_HEIGHT, height: NAVBAR_HEIGHT,
paddingLeft: macPaddingLeft, paddingLeft: macPaddingLeft,
paddingRight: 12, paddingRight: 12,
background: isDark background: isDark
? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)' ? 'linear-gradient(180deg, #1E1B4B 0%, #1A1730 100%)'
: 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)', : 'linear-gradient(180deg, #FFFFFF 0%, #F5F3FF 100%)',
borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`, borderBottom: `1px solid ${isDark ? '#2E2A4A' : '#E5E7EB'}`,
boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)', boxShadow: isDark ? '0 1px 4px rgba(0,0,0,0.3)' : '0 1px 4px rgba(99,102,241,0.06)',
}} }}
> >
<div className="navbar-no-drag flex items-center gap-0.5"> <div className="navbar-no-drag flex items-center gap-0.5">
{visibleLeft.map((item) => ( {visibleLeft.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} /> <NavItem key={item.key} item={item} onClick={handleNavClick} />
))} ))}
</div>
<div className="navbar-no-drag flex items-center gap-0.5">
{visibleRight.map((item) => (
<NavItem key={item.key} item={item} onClick={handleNavClick} />
))}
</div>
</div> </div>
<div className="navbar-no-drag flex items-center gap-0.5"> <AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
{visibleRight.map((item) => ( </>
<NavItem key={item.key} item={item} onClick={handleNavClick} /> );
))}
</div>
</div>
<AnnouncementDrawer open={drawerOpen} onClose={() => setDrawerOpen(false)} />
</>
);
} }

View File

@@ -3,7 +3,7 @@
// 布局图标SVG在上文字在下 // 布局图标SVG在上文字在下
// ============================================================ // ============================================================
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import type { NavItemConfig } from './types'; import type { NavItemConfig } from './types';
interface NavItemProps { interface NavItemProps {

View File

@@ -1,3 +1 @@
export { NavBar } from './NavBar'; export { NavBar } from './NavBar';
export { AnnouncementDrawer } from './AnnouncementDrawer';
export type { NavItemConfig, NavBarConfig, NavAction } from './types';

View File

@@ -28,8 +28,9 @@ const personalLeft: NavItemConfig[] = [
key: 'compliance-assets', key: 'compliance-assets',
label: '合规素材库', label: '合规素材库',
iconSrc: complianceAssetsSvg, iconSrc: complianceAssetsSvg,
action: 'navigate', action: 'page',
target: '/compliance-assets', target: '/compliance-assets',
modalType: 'floating',
position: 'left', position: 'left',
}, },
]; ];
@@ -47,8 +48,9 @@ const personalRight: NavItemConfig[] = [
key: 'account', key: 'account',
label: '查帐户', label: '查帐户',
iconSrc: accountSvg, iconSrc: accountSvg,
action: 'navigate', action: 'page',
target: '/account', target: '/account',
modalType: 'floating',
position: 'right', position: 'right',
requireLogin: true, requireLogin: true,
}, },
@@ -56,16 +58,18 @@ const personalRight: NavItemConfig[] = [
key: 'vip-activate', key: 'vip-activate',
label: '开会员/激活', label: '开会员/激活',
iconSrc: membershipSvg, iconSrc: membershipSvg,
action: 'navigate', action: 'page',
target: '/vip', target: '/vip',
modalType: 'modal',
position: 'right', position: 'right',
}, },
{ {
key: 'recharge-points', key: 'recharge-points',
label: '充值积分', label: '充值积分',
iconSrc: rechargeSvg, iconSrc: rechargeSvg,
action: 'navigate', action: 'page',
target: '/recharge', target: '/recharge',
modalType: 'modal',
position: 'right', position: 'right',
requireLogin: true, requireLogin: true,
}, },
@@ -73,8 +77,9 @@ const personalRight: NavItemConfig[] = [
key: 'billing-history', key: 'billing-history',
label: '消费记录', label: '消费记录',
iconSrc: billingSvg, iconSrc: billingSvg,
action: 'navigate', action: 'page',
target: '/billing', target: '/billing',
modalType: 'floating',
position: 'right', position: 'right',
requireLogin: true, requireLogin: true,
}, },
@@ -82,8 +87,9 @@ const personalRight: NavItemConfig[] = [
key: 'order-details', key: 'order-details',
label: '订单明细', label: '订单明细',
iconSrc: orderDetailsSvg, iconSrc: orderDetailsSvg,
action: 'navigate', action: 'page',
target: '/orders', target: '/orders',
modalType: 'floating',
position: 'right', position: 'right',
requireLogin: true, requireLogin: true,
}, },
@@ -91,24 +97,23 @@ const personalRight: NavItemConfig[] = [
key: 'wechat-work', key: 'wechat-work',
label: '企业微信', label: '企业微信',
iconSrc: wechatWorkSvg, iconSrc: wechatWorkSvg,
action: 'external', action: 'page',
target: 'https://work.weixin.qq.com/', target: '/wechat-work',
modalType: 'modal',
position: 'right', position: 'right',
}, },
{ {
key: 'settings', key: 'settings',
label: '设置', label: '设置',
iconSrc: settingsSvg, iconSrc: settingsSvg,
action: 'navigate', action: 'button',
target: '/settings',
position: 'right', position: 'right',
}, },
{ {
key: 'auth', key: 'auth',
label: '登录/注册', label: '登录/注册',
iconSrc: accountSvg, iconSrc: accountSvg,
action: 'navigate', action: 'button',
target: '/login',
position: 'right', position: 'right',
requireGuest: true, requireGuest: true,
}, },
@@ -132,16 +137,18 @@ const enterpriseLeft: NavItemConfig[] = [
key: 'compliance-assets', key: 'compliance-assets',
label: '合规素材库', label: '合规素材库',
iconSrc: complianceAssetsSvg, iconSrc: complianceAssetsSvg,
action: 'navigate', action: 'page',
target: '/compliance-assets', target: '/compliance-assets',
modalType: 'floating',
position: 'left', position: 'left',
}, },
{ {
key: 'project-management', key: 'project-management',
label: '项目管理', label: '项目管理',
iconSrc: projectManagementSvg, iconSrc: projectManagementSvg,
action: 'navigate', action: 'page',
target: '/projects', target: '/projects',
modalType: 'floating',
position: 'left', position: 'left',
}, },
]; ];
@@ -159,8 +166,9 @@ const enterpriseRight: NavItemConfig[] = [
key: 'quota-check', key: 'quota-check',
label: '查配额', label: '查配额',
iconSrc: accountSvg, iconSrc: accountSvg,
action: 'navigate', action: 'page',
target: '/quota', target: '/quota',
modalType: 'floating',
position: 'right', position: 'right',
requireLogin: false, requireLogin: false,
}, },
@@ -168,16 +176,14 @@ const enterpriseRight: NavItemConfig[] = [
key: 'settings', key: 'settings',
label: '设置', label: '设置',
iconSrc: settingsSvg, iconSrc: settingsSvg,
action: 'navigate', action: 'button',
target: '/settings',
position: 'right', position: 'right',
}, },
{ {
key: 'auth', key: 'auth',
label: '登录/注册', label: '登录/注册',
iconSrc: accountSvg, iconSrc: accountSvg,
action: 'navigate', action: 'button',
target: '/login',
position: 'right', position: 'right',
requireGuest: true, requireGuest: true,
}, },

View File

@@ -6,10 +6,9 @@ import type { Edition } from '@shared/types';
/** 导航项的行为类型 */ /** 导航项的行为类型 */
export type NavAction = export type NavAction =
| 'navigate' // 新开窗口 | 'page' // 打开页面 Modal携带 target 路径标识符,由事件总线分发)
| 'spa' // 单页应用内导航(当前窗口)
| 'drawer' // 侧拉抽屉(公告等) | 'drawer' // 侧拉抽屉(公告等)
| 'button' // 纯按钮行为(退出登录) | 'button' // 纯按钮行为(退出登录
| 'external'; // 外部链接 | 'external'; // 外部链接
/** 单个导航项配置 */ /** 单个导航项配置 */
@@ -24,6 +23,8 @@ export interface NavItemConfig {
action: NavAction; action: NavAction;
/** 点击回调 / 路由路径 / 外部 URL取决于 action */ /** 点击回调 / 路由路径 / 外部 URL取决于 action */
target?: string; target?: string;
/** 窗口类型(仅 action='page' 时生效modal 居中模态 | drawer 侧滑 | floating 非模态浮动面板 */
modalType?: 'modal' | 'drawer' | 'floating';
/** 仅在指定版本下显示 */ /** 仅在指定版本下显示 */
edition?: Edition | Edition[]; edition?: Edition | Edition[];
/** 仅已登录时显示 */ /** 仅已登录时显示 */

View File

@@ -0,0 +1,215 @@
// ============================================================
// FloatingPanel — 非模态可拖拽浮动面板
//
// 特性:
// - 标题栏拖拽移动,无遮罩,不阻断主页面交互
// - 初始居中定位,支持暗色主题
// - 边界检测(不拖出视口)
// - 多个面板可同时打开(点击置顶)
// - 关闭按钮 + 最小尺寸约束
//
// 对应 QT 原版"非模态窗口"的 Web 映射
// ============================================================
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { useTheme } from '@/components/ThemeProvider';
// ---------- 类型 ----------
export interface FloatingPanelProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
/** 初始宽度px默认 480 */
width?: number;
/** 初始高度px默认 360 */
height?: number;
}
// ---------- 全局 z-index 管理 ----------
let globalZIndex = 1000;
function nextZIndex(): number {
globalZIndex += 1;
return globalZIndex;
}
// ---------- 组件 ----------
export function FloatingPanel({ open, onClose, title, children, width = 480, height = 360 }: FloatingPanelProps) {
const { isDark } = useTheme();
// 位置与尺寸
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [zIndex, setZIndex] = useState(() => nextZIndex());
const panelRef = useRef<HTMLDivElement>(null);
// 拖拽状态
const draggingRef = useRef(false);
const dragStartRef = useRef({ x: 0, y: 0, panelX: 0, panelY: 0 });
// 初始居中(首次 open 时计算)
useEffect(() => {
if (open && position === null) {
const x = Math.max(0, (window.innerWidth - width) / 2);
const y = Math.max(0, (window.innerHeight - height) / 2);
setPosition({ x, y });
}
}, [open, position, width, height]);
// 拖拽启动
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
// 仅标题栏区域可拖拽
if ((e.target as HTMLElement).closest('.fp-close-btn')) return;
e.preventDefault();
draggingRef.current = true;
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
panelX: position?.x ?? 0,
panelY: position?.y ?? 0,
};
},
[position],
);
// 全局 mousemove / mouseup
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!draggingRef.current) return;
const ds = dragStartRef.current;
const newX = ds.panelX + (e.clientX - ds.x);
const newY = ds.panelY + (e.clientY - ds.y);
// 边界约束:保留标题栏至少 40px 可见,确保用户能拖回
const minVisible = 40;
const maxX = window.innerWidth - minVisible;
const maxY = window.innerHeight - minVisible;
setPosition({
x: Math.max(-width + minVisible, Math.min(maxX, newX)),
y: Math.max(0, Math.min(maxY, newY)),
});
};
const handleMouseUp = () => {
draggingRef.current = false;
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [width]);
// 点击面板 → 置顶
const bringToFront = useCallback(() => {
setZIndex(nextZIndex());
}, []);
if (!open || !position) return null;
return (
<div
ref={panelRef}
onMouseDown={bringToFront}
className="fp-panel"
style={{
position: 'fixed',
left: position.x,
top: position.y,
width,
height,
zIndex,
display: 'flex',
flexDirection: 'column',
borderRadius: 8,
boxShadow: isDark
? '0 8px 32px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08)'
: '0 8px 32px rgba(0,0,0,0.15), 0 0 0 1px rgba(0,0,0,0.06)',
background: isDark ? '#1E1B4B' : '#FFFFFF',
overflow: 'hidden',
userSelect: 'none',
}}
>
{/* 标题栏 — 拖拽手柄 */}
<div
onMouseDown={handleMouseDown}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px 0 16px',
height: 40,
flexShrink: 0,
cursor: 'grab',
background: isDark
? 'linear-gradient(180deg, #2E2A5A 0%, #252050 100%)'
: 'linear-gradient(180deg, #F5F3FF 0%, #EEF2FF 100%)',
borderBottom: `1px solid ${isDark ? '#3E3A6A' : '#E5E7EB'}`,
}}
>
<span
style={{
fontSize: 13,
fontWeight: 600,
color: isDark ? '#E8E6F0' : '#1E1B4B',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{title}
</span>
<button
className="fp-close-btn"
type="button"
onClick={onClose}
title="关闭"
style={{
width: 28,
height: 28,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: 'none',
borderRadius: 4,
cursor: 'pointer',
fontSize: 16,
color: isDark ? '#A5A3C0' : '#9CA3AF',
background: 'transparent',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? 'rgba(239,68,68,0.2)' : 'rgba(239,68,68,0.1)';
e.currentTarget.style.color = '#EF4444';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = isDark ? '#A5A3C0' : '#9CA3AF';
}}
>
</button>
</div>
{/* 内容区 */}
<div
style={{
flex: 1,
overflow: 'auto',
userSelect: 'text',
padding: 16,
color: isDark ? '#E8E6F0' : '#1E1B4B',
}}
>
{children}
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@
import { Modal, Typography } from 'antd'; import { Modal, Typography } from 'antd';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联 // Vite ?raw 导入:将 .txt 内容作为字符串直接内联
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw'; import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';

View File

@@ -1,38 +0,0 @@
// ============================================================
// AppContext — 全局应用状态
// ============================================================
import { createContext, useContext } from 'react';
import type { Platform, Edition } from '@shared/types';
import type { LoginResponseBody } from '@/services/modules';
// ---------- Context 类型 ----------
export interface AppContextValue {
/** 当前操作系统平台 */
platform: Platform;
/** 产品版本(个人版 / 企业版) */
edition: Edition;
/** 用户是否已登录 */
isLoggedIn: boolean;
/** 当前登录用户信息(未登录时为 null结构与 LoginResponseBody 一致) */
user: LoginResponseBody | null;
/** 登录:保存 Token 并更新登录态auth 为登录接口返回值,异步加密存储) */
login: (auth: LoginResponseBody) => Promise<void>;
/** 退出登录:清除 Token 和用户状态 */
logout: () => void;
/** 是否为开发环境 */
isDevMode: boolean;
}
export const AppContext = createContext<AppContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
}
return ctx;
}

View File

@@ -1,94 +0,0 @@
// ============================================================
// SettingsContext — 设置项集中状态管理(类似 Vue Pinia store
//
// 职责:
// - 存储路径的集中管理read/write/clear
// - 持久化到 localStorage遵循 ele-heixiu-* 键名规范
// - 全局组件通过 useSettings() 消费
// ============================================================
import { createContext, useContext } from 'react';
// ---------- localStorage 键 ----------
const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
// ---------- Context 类型 ----------
export interface SettingsContextValue {
/** 大模型产物存储仓库路径 */
outputPath: string;
/** 团队创作存储仓库路径(企业版) */
teamRepoPath: string;
/** 设置产物输出路径 */
setOutputPath: (path: string) => void;
/** 设置团队创作仓库路径 */
setTeamRepoPath: (path: string) => void;
/** 清除产物输出路径 */
clearOutputPath: () => void;
/** 清除团队创作仓库路径 */
clearTeamRepoPath: () => void;
}
export const SettingsContext = createContext<SettingsContextValue | null>(null);
// ---------- 工具函数localStorage 读写) ----------
export function readStoredOutputPath(): string {
try {
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredOutputPath(path: string): void {
try {
localStorage.setItem(OUTPUT_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredOutputPath(): void {
try {
localStorage.removeItem(OUTPUT_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
export function readStoredTeamRepoPath(): string {
try {
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredTeamRepoPath(path: string): void {
try {
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredTeamRepoPath(): void {
try {
localStorage.removeItem(TEAM_REPO_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Consumer Hook ----------
export function useSettings(): SettingsContextValue {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
}
return ctx;
}

169
src/hooks/use-api.ts Normal file
View File

@@ -0,0 +1,169 @@
// ============================================================
// use-api.ts — 业务 API Hook 集合
//
// 基于 useAsyncData / useAsyncMutation 封装各模块 API
// 组件只需调用 Hook 获取 { data, loading, error } 三元组,
// 不再需要在组件内手写 useEffect + cancelled flag + useState。
//
// 每个 Hook 自动处理:
// - 登录状态门控enabled: isLoggedIn
// - 竞态条件
// - 错误日志
// - 用户可读错误信息errorMessage
// ============================================================
import {useMemo, useState} from 'react';
import {useAppContext} from '@/components/AppProvider';
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { fetchBanners, type Banner } from '@/services/modules/banner';
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
import {getCachedModels, setCachedModels} from '@/services/cache/model-cache';
// ============================================================
// Banner
// ============================================================
/** 首页轮播图数据 Hook */
export function useBannerList() {
const {isLoggedIn} = useAppContext();
return useAsyncData<Banner[]>(
fetchBanners,
[],
{enabled: isLoggedIn, label: 'banner-list'},
);
}
// ============================================================
// 模型
// ============================================================
/** 模块级:单次会话中是否已完成首次 API 拉取 */
let modelFirstFetchDone = false;
/** 模型列表数据 Hook缓存优先 + 后台刷新)
*
* 策略:
* 1. 启动时同步读取加密缓存 → 立即展示(避免每次打开都 loading
* 2. 异步拉取最新模型列表 → 覆盖缓存数据
* 3. 拉取成功后将最新数据加密写入 localStorage
*
* 因后端 category 查询参数实际不可用(传参返回空列表),
* 改为不带分类参数一次拉取全部,再由客户端根据响应中的
* category_name 字段手动分组。 */
export function useModelList() {
const {isLoggedIn} = useAppContext();
// 缓存即时数据(首次 render 同步读取,后续 API 返回后覆盖)
const [instantData, setInstantData] = useState<ModelsListResponseBody[] | null>(() =>
getCachedModels(),
);
const result: UseAsyncDataReturn<ModelsListResponseBody[]> = useAsyncData<ModelsListResponseBody[]>(
async () => {
const models = await ModelAPI.fetchModels({page: 1, page_size: 200});
// 异步写入加密缓存best-effort不影响数据流
setCachedModels(models).catch(() => {});
modelFirstFetchDone = true;
setInstantData(models);
return models;
},
[],
{enabled: isLoggedIn, label: 'model-list'},
);
// 合并策略API 数据 > 缓存即时数据
const mergedData = result.data ?? instantData;
// loading 仅在无任何数据时展示(缓存命中时不闪烁 loading
const loading = result.loading && mergedData === null;
// 按 category_name 分组(后端返回的 category_name 为 slug 格式如 img2img
const groupedModels: Map<string, ModelsListResponseBody[]> = useMemo(() => {
const groups = new Map<string, ModelsListResponseBody[]>();
mergedData?.forEach((model: ModelsListResponseBody) => {
const cat: ModelCategoryStringEnum = model.model_type;
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(model);
});
return groups;
}, [mergedData]);
return {
...result,
/** 合并后的模型数据(缓存优先) */
data: mergedData,
/** 仅在无缓存且正在加载时为 true */
loading,
/** 按 model_type 分组后的模型key 为后端 slug */
groupedModels,
};
}
// ============================================================
// 任务列表
// ============================================================
export interface UseTaskListParams {
/** 完整的 API 请求参数(由调用方根据 antd Table onChange 构建) */
requestParams: TaskListRequestParams;
/** 外部传入的刷新信号(任意变化触发重新请求) */
refreshSignal?: number;
}
/**
* 任务列表 Hook分页 + 筛选 + 排序)
*
* 调用方负责构建 TaskListRequestParams包括 edition-aware 的 user_ids 逻辑),
* 本 Hook 仅负责数据获取、竞态控制、错误日志。
*/
export function useTaskList(params: UseTaskListParams) {
const {isLoggedIn} = useAppContext();
const {requestParams, refreshSignal = 0} = params;
return useAsyncData(
() => TaskAPI.getTaskList(requestParams),
[requestParams, refreshSignal],
{enabled: isLoggedIn, label: 'task-list'},
);
}
// ============================================================
// 任务详情
// ============================================================
/** 任务详情 HooktaskId 为 null 时不请求) */
export function useTaskDetail(taskId: string | null | undefined) {
const {isLoggedIn} = useAppContext();
return useAsyncData<TaskInfoItemResponseBody>(
() => TaskAPI.getTaskInfo(taskId!),
[taskId],
{enabled: isLoggedIn && !!taskId, label: 'task-detail'},
);
}
// ============================================================
// 提交任务
// ============================================================
/** 提交任务 Mutation Hookdevice_id 由 API 层自动注入,调用方无需传入) */
export function useSubmitTask(options?: {
onSuccess?: (data: TaskInfoItemResponseBody) => void;
onError?: (err: Error) => void;
}) {
return useAsyncMutation<TaskInfoItemResponseBody, [Omit<CreatTaskRequestBody, 'device_id'>]>(
(body) => TaskAPI.submitTask(body),
{
label: 'submit-task',
onSuccess: options?.onSuccess,
onError: options?.onError,
},
);
}
// ---------- 重新导出类型和工具 ----------
export {MODEL_CATEGORY_LABELS};
export type {ModelCategoryStringEnum, ModelsListResponseBody, TaskInfoItemResponseBody, Banner};

254
src/hooks/use-async.ts Normal file
View File

@@ -0,0 +1,254 @@
// ============================================================
// useAsyncData / useAsyncMutation — 通用异步数据 Hook
//
// 职责:
// - 自动竞态处理(仅应用最后一次请求的结果)
// - 自动错误日志记录
// - RequestErrorType → 用户可读错误信息映射
// - 为 use-api.ts 提供基础设施
//
// 用法:
// const { data, loading, error, refetch } = useAsyncData(
// () => TaskAPI.getTaskList(params),
// [page, pageSize],
// { enabled: isLoggedIn },
// );
//
// const { execute, loading, error } = useAsyncMutation(
// (body) => TaskAPI.submitTask(body),
// { onSuccess: () => refetchTasks() },
// );
// ============================================================
import { useState, useEffect, useCallback, useRef } from 'react';
import { RequestError, RequestErrorType } from '@/services/types';
import { logger } from '@/utils/logger';
// ---------- 错误信息映射 ----------
/** RequestErrorType → 用户可读的默认错误信息 */
export function getErrorMessage(err: unknown): string {
if (err instanceof RequestError) {
switch (err.type) {
case RequestErrorType.NETWORK:
return '网络连接失败,请检查网络';
case RequestErrorType.TIMEOUT:
return '请求超时,请稍后重试';
case RequestErrorType.HTTP:
return err.message || '服务器响应异常';
case RequestErrorType.BUSINESS:
return err.message || '业务处理失败';
case RequestErrorType.CANCELLED:
return '请求已取消';
case RequestErrorType.AUTH_EXPIRED:
return '登录已过期,请重新登录';
default:
return err.message || '未知错误';
}
}
if (err instanceof Error) {
return err.message || '发生未知错误';
}
return '发生未知错误';
}
// ---------- useAsyncData — 数据获取 ----------
export interface UseAsyncDataOptions {
/** 是否启用请求false 时不触发 fetch常用于登录后才请求 */
enabled?: boolean;
/** 请求标识(用于日志,如 'task-list'、'models' */
label?: string;
}
export interface UseAsyncDataReturn<T> {
/** 响应数据 */
data: T | null;
/** 是否正在加载(含首次请求和后续刷新) */
loading: boolean;
/** 错误对象RequestError 或普通 Error */
error: Error | null;
/** 用户可读的错误信息 */
errorMessage: string | null;
/** 手动触发重新请求 */
refetch: () => void;
}
/**
* 通用数据获取 Hook。
*
* - 当 `deps` 变化或 `refetch()` 调用时自动重新请求
* - 自动处理竞态条件(仅最后一次请求的结果生效)
* - `enabled: false` 时不发起请求(适用于需登录后请求的场景)
*/
export function useAsyncData<T>(
fetcher: () => Promise<T>,
deps: unknown[],
options: UseAsyncDataOptions = {},
): UseAsyncDataReturn<T> {
const { enabled = true, label } = options;
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// 竞态计数器
const raceRef = useRef(0);
// 手动刷新触发器
const [refreshTick, setRefreshTick] = useState(0);
// 是否已挂载(避免 setState on unmounted
const mountedRef = useRef(true);
const refetch = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
useEffect(() => {
if (!enabled) {
setData(null);
setError(null);
return;
}
const raceId = ++raceRef.current;
let cancelled = false;
const run = async () => {
setLoading(true);
setError(null);
try {
const result = await fetcher();
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
setData(result);
}
} catch (err) {
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
const normalized = err instanceof Error ? err : new Error(String(err));
setError(normalized);
// 仅在非取消、非认证过期时记录日志(取消是预期行为,认证过期由拦截器统一处理)
if (err instanceof RequestError) {
if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) {
logger.warn('request', `${label || 'fetch'} 请求失败`, normalized);
}
} else {
logger.warn('request', `${label || 'fetch'} 请求失败`, normalized);
}
}
} finally {
if (!cancelled && raceId === raceRef.current && mountedRef.current) {
setLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, refreshTick, ...deps]);
return {
data,
loading,
error,
errorMessage: error ? getErrorMessage(error) : null,
refetch,
};
}
// ---------- useAsyncMutation — 数据变更 ----------
export interface UseAsyncMutationOptions<T, Args extends unknown[]> {
/** 请求标识(用于日志) */
label?: string;
/** 成功回调 */
onSuccess?: (data: T, ...args: Args) => void;
/** 失败回调 */
onError?: (err: Error, ...args: Args) => void;
}
export interface UseAsyncMutationReturn<T, Args extends unknown[]> {
/** 执行变更 */
execute: (...args: Args) => Promise<T | undefined>;
/** 是否正在执行 */
loading: boolean;
/** 最近一次错误 */
error: Error | null;
/** 用户可读的错误信息 */
errorMessage: string | null;
/** 清除错误状态 */
reset: () => void;
}
/**
* 通用数据变更 HookPOST / PUT / DELETE 等)。
*
* - 不自动执行,需手动调用 `execute()`
* - 返回 Promise调用方可 await 获取结果
* - 自动错误日志记录
*/
export function useAsyncMutation<T, Args extends unknown[] = []>(
mutator: (...args: Args) => Promise<T>,
options: UseAsyncMutationOptions<T, Args> = {},
): UseAsyncMutationReturn<T, Args> {
const { label, onSuccess, onError } = options;
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const execute = useCallback(
async (...args: Args): Promise<T | undefined> => {
setLoading(true);
setError(null);
try {
const result = await mutator(...args);
if (mountedRef.current) {
setLoading(false);
}
onSuccess?.(result, ...args);
return result;
} catch (err) {
const normalized = err instanceof Error ? err : new Error(String(err));
if (mountedRef.current) {
setError(normalized);
setLoading(false);
}
if (err instanceof RequestError) {
if (err.type !== RequestErrorType.CANCELLED && err.type !== RequestErrorType.AUTH_EXPIRED) {
logger.warn('request', `${label || 'mutation'} 执行失败`, normalized);
}
} else {
logger.warn('request', `${label || 'mutation'} 执行失败`, normalized);
}
onError?.(normalized, ...args);
return undefined;
}
},
[mutator, label, onSuccess, onError],
);
const reset = useCallback(() => {
setError(null);
}, []);
return {
execute,
loading,
error,
errorMessage: error ? getErrorMessage(error) : null,
reset,
};
}

View File

@@ -1,61 +0,0 @@
// ============================================================
// useTheme — 亮/暗主题切换 Hook
// ============================================================
import { createContext, useContext } from 'react';
// ---------- Context 类型 ----------
export interface ThemeContextValue {
isDark: boolean;
toggleTheme: () => void;
setLight: () => void;
setDark: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
// ---------- 工具函数 ----------
export const STORAGE_KEY = 'ele-heixiu-theme';
export function readStoredTheme(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark') return true;
if (stored === 'light') return false;
} catch {
/* 静默忽略 */
}
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}
export function writeStoredTheme(isDark: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
} catch {
/* 静默忽略 */
}
}
export function applyHtmlTheme(isDark: boolean): void {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// ---------- Consumer Hook ----------
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
}
return ctx;
}

View File

@@ -1,53 +1,53 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { App as AntdApp } from 'antd'; import {App as AntdApp} from 'antd';
import App from './App'; import App from './App';
import { ThemeProvider } from './components/ThemeProvider'; import {ThemeProvider} from './components/ThemeProvider';
import { AppProvider } from './components/AppProvider'; import {AppProvider} from './components/AppProvider';
import { SettingsProvider } from './components/SettingsProvider'; import {SettingsProvider} from './components/SettingsProvider';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置) // 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css'; import './theme/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
{/* AppProvider — 全局应用状态(平台/版本/登录态) */} {/* AppProvider — 全局应用状态(平台/版本/登录态) */}
<AppProvider> <AppProvider>
{/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */} {/* ThemeProvider 内部管理 ConfigProvider亮色/暗色切换) */}
<ThemeProvider> <ThemeProvider>
{/* SettingsProvider — 集中管理设置项(存储路径等) */} {/* SettingsProvider — 集中管理设置项(存储路径等) */}
<SettingsProvider> <SettingsProvider>
{/* AntdApp 提供 message / notification / modal 的静态方法上下文 */} {/* AntdApp 提供 message / notification / modal 的静态方法上下文 */}
<AntdApp> <AntdApp>
<App /> <App />
</AntdApp> </AntdApp>
</SettingsProvider> </SettingsProvider>
</ThemeProvider> </ThemeProvider>
</AppProvider> </AppProvider>
</React.StrictMode>, </React.StrictMode>,
); );
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过) // 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
import { safeIpcOn } from './utils/ipc'; import { safeIpcOn } from './utils/ipc';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => { safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message); console.log('[Main Process Message]:', message);
}); });
// 渲染进程全局未捕获错误(兜底记录到日志文件) // 渲染进程全局未捕获错误(兜底记录到日志文件)
import { logger } from './utils/logger'; import {logger} from './utils/logger';
window.addEventListener('error', (event) => { window.addEventListener('error', (event) => {
logger.error('ui', 'Uncaught error (renderer)', event.error, { logger.error('ui', 'Uncaught error (renderer)', event.error, {
filename: event.filename, filename: event.filename,
lineno: event.lineno, lineno: event.lineno,
colno: event.colno, colno: event.colno,
}); });
}); });
window.addEventListener('unhandledrejection', (event) => { window.addEventListener('unhandledrejection', (event) => {
const error = const error =
event.reason instanceof Error event.reason instanceof Error
? event.reason ? event.reason
: new Error(String(event.reason)); : new Error(String(event.reason));
logger.error('ui', 'Unhandled promise rejection (renderer)', error); logger.error('ui', 'Unhandled promise rejection (renderer)', error);
}); });

View File

@@ -0,0 +1,235 @@
import {useEffect, useState, useCallback} from 'react';
import {
Card,
Descriptions,
Button,
Typography,
Skeleton,
Result,
Space,
Tag,
Divider,
theme as antTheme,
} from 'antd';
import {
UserOutlined,
WalletOutlined,
CrownOutlined,
BarChartOutlined,
RightOutlined,
} from '@ant-design/icons';
import {AuthAPI, type UserInfoBody, UserAccountTypeLabelMap} from '@/services/modules/auth';
import {VipAPI, VipLevelsItem} from "@/services/modules/vip-api.ts";
const {Text, Title} = Typography;
/** 将分转为元,保留两位小数 */
function centToYuan(cent: number): string {
return (cent / 100).toFixed(2);
}
/** 格式化日期 */
function formatDate(date: Date | string | undefined): string {
if (!date) return '—';
const d = typeof date === 'string' ? new Date(date) : date;
return d.toLocaleDateString('zh-CN', {year: 'numeric', month: '2-digit', day: '2-digit'});
}
export function AccountInfo() {
const {token} = antTheme.useToken();
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
const [vipsInfo, setVipsInfo] = useState<VipLevelsItem[] | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const info: UserInfoBody = await AuthAPI.getUserInfo();
const vips: VipLevelsItem[] = await VipAPI.getVipLevels();
setVipsInfo(vips);
setUserInfo(info);
} catch (err) {
setError((err as Error).message || '加载账户信息失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// ======== 加载态 ========
if (loading) {
return (
<div style={{padding: 24}}>
<Skeleton active paragraph={{rows: 6}} />
</div>
);
}
// ======== 错误态 ========
if (error || !userInfo) {
return (
<Result
status="error"
title="加载失败"
subTitle={error || '未能获取账户信息'}
extra={
<Button type="primary" onClick={load}>
</Button>
}
/>
);
}
// ======== 数据展示 ========
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 (
<div style={{padding: 24, maxWidth: 640}}>
{/* ======== 标题:账户 ======== */}
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16}}>
<Space align="center" size={12}>
<UserOutlined style={{fontSize: 22, color: token.colorPrimary}} />
<div>
<Title level={4} style={{margin: 0}}>
{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>
<Text type="secondary" style={{fontSize: 12}}>
ID: {userInfo.id}
</Text>
</div>
</Space>
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
</Button>
</div>
{/* ======== 积分栏 ======== */}
<Card
size="small"
styles={{
body: {padding: '16px 20px'},
}}
style={{marginBottom: 16}}
>
<Text type="secondary" style={{fontSize: 12, marginBottom: 8, display: 'block'}}>
</Text>
<Title level={3} style={{margin: '0 0 12px 0'}}>
{centToYuan(totalBalance)}
<Text type="secondary" style={{fontSize: 14, marginLeft: 4}}></Text>
</Title>
<Space size={32}>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
</div>
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
</div>
{userInfo.discount_rate && userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
<div>
<Text type="secondary" style={{fontSize: 12}}></Text>
<br />
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
</div>
)}
</Space>
</Card>
{/* ======== 套餐信息 ======== */}
<Card
size="small"
styles={{
body: {padding: '16px 20px'},
}}
style={{marginBottom: 16}}
>
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12}}>
<Space size={8}>
<CrownOutlined style={{color: token.colorWarning}} />
<Text strong></Text>
</Space>
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
/
</Button>
</div>
<Descriptions column={1} size="small" colon={false}>
<Descriptions.Item label="套餐名称">
<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 label="到期时间">
<Text type={isExpired ? 'danger' : undefined}>
{formatDate(userInfo.software_expires_at)}
</Text>
</Descriptions.Item>
<Descriptions.Item label="席位上限">
{matchedVip?.seat_limit ?? userInfo.seat_limit ?? '—'}
</Descriptions.Item>
</Descriptions>
</Card>
{/* ======== 今日统计 ======== */}
<Card
size="small"
styles={{
body: {padding: '16px 20px'},
}}
style={{marginBottom: 16}}
>
<Space size={8} style={{marginBottom: 12}}>
<BarChartOutlined style={{color: token.colorSuccess}} />
<Text strong>使</Text>
</Space>
<Text type="secondary" style={{display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13}}>
</Text>
</Card>
{/* ======== 查看更多权益 ======== */}
<Divider style={{margin: '8px 0'}} />
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{color: token.colorPrimary}}>
</Button>
</div>
);
}

View File

@@ -0,0 +1,9 @@
import {Component} from "react";
export class Test1 extends Component {
render() {
return (
<></>
);
}
}

View File

@@ -0,0 +1,5 @@
export function Test3() {
return (
<></>
);
}

View File

@@ -0,0 +1,98 @@
// ============================================================
// WechatWorkPage — 企业微信二维码 Modal 内容
//
// 点击导航栏"企业微信"后以 Modal 形式展示二维码。
// 数据来源GET /api/v1/creativesBanner API
//
// 识别逻辑Banner 中 category === 'promotion' 的条目,
// 其 image_url 为企业微信二维码图片
// ============================================================
import { useEffect, useState, useCallback } from 'react';
import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/services/modules/banner';
export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const list = await fetchBanners();
// category === 'promotion' → 企业微信二维码
const promotion = list.find(
(item) => item.is_active && item.category === 'promotion',
);
setBanner(promotion ?? null);
} catch (err) {
setError((err as Error).message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// 加载态
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spin description="加载中..." />
</div>
);
}
// 无数据 / 错误
if (error || !banner || !banner.image_url) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Empty
image={<WechatOutlined style={{ fontSize: 48, color: '#9CA3AF' }} />}
description={error || '暂未获取到企业微信二维码,请联系管理员'}
/>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 16, padding: 8 }}>
{/* 二维码图片 */}
<div
style={{
width: 240,
height: 240,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
background: '#fff',
border: '1px solid #E5E7EB',
overflow: 'hidden',
}}
>
<img
src={banner.image_url}
alt="企业微信二维码"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
{/* 标题(如果 Banner 有标题则显示) */}
{banner.title && (
<p style={{ margin: 0, fontSize: 14, fontWeight: 500, color: 'inherit' }}>
{banner.title}
</p>
)}
{/* 提示文字 */}
<p style={{ margin: 0, fontSize: 13, color: 'inherit', opacity: 0.5 }}>
使
</p>
</div>
);
}

View File

@@ -0,0 +1,357 @@
// ============================================================
// HomeContent — 首页三列正文布局 + HomeProviderContext + Provider + Hook
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
// ============================================================
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { theme as antTheme, Empty } from 'antd';
import { useAppContext } from '@/components/AppProvider';
import type { ModelsListResponseBody } from '@/services/modules/models';
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import { LeftPanel } from './components/LeftPanel';
import { CenterPanel } from './components/CenterPanel';
import { RightPanel } from './components/RightPanel';
import { Typography } from 'antd';
const { Text } = Typography;
// ---------- Context 类型 ----------
export interface HomeContextValue {
/** 当前选中的模型 */
selectedModel: ModelsListResponseBody | null;
setSelectedModel: (model: ModelsListResponseBody | null) => void;
/** 任务记录分页 — 当前页码 */
taskPage: number;
/** 任务记录分页 — 每页条数 */
taskPageSize: number;
setTaskPage: (page: number) => void;
setTaskPageSize: (size: number) => void;
/** 当前选中的任务记录(用于右侧预览) */
selectedTask: TaskInfoItemResponseBody | null;
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
/** 侧边栏折叠状态 */
isLeftCollapsed: boolean;
toggleLeftCollapsed: () => void;
}
export const HomeContext = createContext<HomeContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useHomeContext(): HomeContextValue {
const ctx = useContext(HomeContext);
if (!ctx) {
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
}
return ctx;
}
// ---------- 常量 ----------
/** 左列最小/默认宽度 */
const LEFT_MIN = 400;
const LEFT_DEFAULT = 750;
/** 右列最小/默认宽度 */
const RIGHT_MIN = 300;
const RIGHT_DEFAULT = 480;
/** 中列最小宽度 */
const CENTER_MIN = 300;
/** 拖拽分隔条宽度 */
const DIVIDER_WIDTH = 6;
/** 布局固定消耗:左右 padding(12*2) + 两个分隔条(6*2) */
const LAYOUT_OVERHEAD = 36; // 24 + 12
// ---------- 组件 ----------
export function HomeContent() {
const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext();
// ======== HomeContext 状态 ========
const [selectedModel, setSelectedModel] = useState<ModelsListResponseBody | null>(null);
const [selectedTask, setSelectedTask] = useState<TaskInfoItemResponseBody | null>(null);
const [taskPage, setTaskPage] = useState(1);
const [taskPageSize, setTaskPageSize] = useState(10);
const [isLeftCollapsed, setIsLeftCollapsed] = useState(false);
const toggleLeftCollapsed = useCallback(() => {
setIsLeftCollapsed((v) => !v);
}, []);
const handleSetSelectedModel = useCallback(
(model: ModelsListResponseBody | null) => {
setSelectedModel(model);
setSelectedTask(null);
},
[],
);
const handleSetSelectedTask = useCallback(
(task: TaskInfoItemResponseBody | null) => {
setSelectedTask(task);
},
[],
);
const contextValue = useMemo(() => ({
selectedModel,
setSelectedModel: handleSetSelectedModel,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
selectedTask,
setSelectedTask: handleSetSelectedTask,
isLeftCollapsed,
toggleLeftCollapsed,
}), [
selectedModel,
handleSetSelectedModel,
taskPage,
taskPageSize,
selectedTask,
handleSetSelectedTask,
isLeftCollapsed,
toggleLeftCollapsed,
]);
// ======== 拖拽调整列宽 ========
const [leftWidth, setLeftWidth] = useState(LEFT_DEFAULT);
const [rightWidth, setRightWidth] = useState(RIGHT_DEFAULT);
const containerRef = useRef<HTMLDivElement>(null);
// 拖拽状态
const [dragging, setDragging] = useState<'left' | 'right' | null>(null);
// 分隔条 hover 状态
const [hoveredDivider, setHoveredDivider] = useState<'left' | 'right' | null>(null);
const dragState = useRef<{
startX: number;
startWidth: number;
}>({ startX: 0, startWidth: 0 });
// 用 ref 追踪最新列宽,避免拖拽 mousemove 闭包中读到过期值
const leftWidthRef = useRef(leftWidth);
const rightWidthRef = useRef(rightWidth);
const draggingRef = useRef(dragging);
useEffect(() => {
leftWidthRef.current = leftWidth;
rightWidthRef.current = rightWidth;
draggingRef.current = dragging;
}, [leftWidth, rightWidth, dragging]);
const handleDividerMouseDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
e.preventDefault();
// eslint-disable-next-line react-hooks/refs
dragState.current = { startX: e.clientX, startWidth: side === 'left' ? leftWidth : rightWidth };
setDragging(side);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging) return;
const containerEl = containerRef.current;
if (!containerEl) return;
const containerWidth = containerEl.clientWidth;
const ds = dragState.current;
const delta = e.clientX - ds.startX;
if (dragging === 'left') {
// 左列最大宽度 = 容器宽 - 固定消耗 - 中列最小宽 - 右列当前宽
// 确保中列至少保留 CENTER_MIN、右列不被挤出视口
const maxLeft = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - rightWidthRef.current;
const newWidth = Math.max(LEFT_MIN, Math.min(maxLeft, ds.startWidth + delta));
setLeftWidth(newWidth);
} else {
// 右列最大宽度同理:不能把中列挤到 CENTER_MIN 以下
const maxRight = containerWidth - LAYOUT_OVERHEAD - CENTER_MIN - leftWidthRef.current;
const newWidth = Math.max(RIGHT_MIN, Math.min(maxRight, ds.startWidth - delta));
setRightWidth(newWidth);
}
};
const handleMouseUp = () => {
setDragging(null);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging]);
// ======== 视口缩小自动收窄面板 ========
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
// 拖拽中由 mousemove handler 处理约束,避免冲突
if (draggingRef.current) return;
const containerWidth = el.clientWidth;
const currentLeft = leftWidthRef.current;
const currentRight = rightWidthRef.current;
// 检查当前总宽度是否超出容器
const totalNeeded = currentLeft + LAYOUT_OVERHEAD + CENTER_MIN + currentRight;
if (totalNeeded <= containerWidth) return;
// 超出容器 → 优先收缩右侧面板,再收左侧
const deficit = totalNeeded - containerWidth;
const rightSlack = currentRight - RIGHT_MIN;
if (rightSlack >= deficit) {
setRightWidth(currentRight - deficit);
} else {
setRightWidth(RIGHT_MIN);
const remainingDeficit = deficit - rightSlack;
setLeftWidth(Math.max(LEFT_MIN, currentLeft - remainingDeficit));
}
});
observer.observe(el);
return () => observer.disconnect();
}, []);
// ======== 分隔条样式工厂 ========
const dividerStyle = (side: 'left' | 'right'): React.CSSProperties => {
const isActive = dragging === side;
const isHovered = hoveredDivider === side;
return {
width: DIVIDER_WIDTH,
flexShrink: 0,
cursor: 'col-resize',
background: isActive
? token.colorPrimary
: isHovered
? `${token.colorPrimary}60`
: token.colorBorderSecondary,
position: 'relative' as const,
zIndex: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s linear',
};
};
// ======== 未登录提示 ========
if (!isLoggedIn) {
return (
<HomeContext.Provider value={contextValue}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
background: token.colorBgLayout,
}}
>
<Empty
description={
<Text type="secondary">
使
</Text>
}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
</HomeContext.Provider>
);
}
// ======== 三列布局(可拖拽) ========
return (
<HomeContext.Provider value={contextValue}>
<div
ref={containerRef}
style={{
display: 'flex',
padding: 12,
gap: 0,
flex: 1,
overflow: 'hidden',
background: token.colorBgLayout,
cursor: !!dragging ? 'col-resize' : undefined,
userSelect: !!dragging ? 'none' : undefined,
}}
>
{/* 左列 */}
{!isLeftCollapsed && (
<>
<div style={{ width: leftWidth, overflow: 'hidden', flexShrink: 0 }}>
<LeftPanel />
</div>
{/* 左分隔条 */}
<div
onMouseDown={handleDividerMouseDown('left')}
style={dividerStyle('left')}
onMouseEnter={() => setHoveredDivider('left')}
onMouseLeave={() => setHoveredDivider(null)}
>
{/* 拖拽手柄竖线 */}
<div style={{
width: 2,
height: 32,
borderRadius: 1,
background: dragging === 'left'
? '#fff'
: hoveredDivider === 'left'
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.15s linear',
}} />
</div>
</>
)}
{/* 中列 */}
<div style={{ flex: 1, overflow: 'hidden', minWidth: CENTER_MIN }}>
<CenterPanel />
</div>
{/* 右分隔条 */}
<div
onMouseDown={handleDividerMouseDown('right')}
style={dividerStyle('right')}
onMouseEnter={() => setHoveredDivider('right')}
onMouseLeave={() => setHoveredDivider(null)}
>
{/* 拖拽手柄竖线 */}
<div style={{
width: 2,
height: 32,
borderRadius: 1,
background: dragging === 'right'
? '#fff'
: hoveredDivider === 'right'
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.15s linear',
}} />
</div>
{/* 右列 */}
<div style={{ width: rightWidth, overflow: 'hidden', flexShrink: 0 }}>
<RightPanel />
</div>
</div>
</HomeContext.Provider>
);
}

View File

@@ -1,277 +1,14 @@
// ============================================================ // ============================================================
// HomePage — 首页仪表盘(从 App.tsx 提取 // HomePage — 首页(业务入口
// 组合BannerCarousel在 Layout 中)+ HomeContent三列正文
// ============================================================ // ============================================================
import { useState } from 'react'; import { HomeContent } from './HomeContent';
import {
Button,
Card,
Space,
Tag,
Typography,
Switch as AntSwitch,
Slider,
Progress,
theme as antTheme,
} from 'antd';
import {
ApiOutlined,
ThunderboltOutlined,
BulbOutlined,
BulbFilled,
DownloadOutlined,
SyncOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
RocketOutlined,
} from '@ant-design/icons';
import { getPlatformName, isDev } from '@/utils/platform';
import { gradientPrimary } from '@/theme/tokens';
import { useTheme } from '@/hooks/use-theme';
import { useUpdater } from '@/hooks/use-updater';
import { useAppContext } from '@/contexts/app-context';
const { Title, Text } = Typography;
export function HomePage() { export function HomePage() {
const { isDark, toggleTheme } = useTheme();
const { platform, edition, isLoggedIn, logout } = useAppContext();
const { token } = antTheme.useToken();
const [progress, setProgress] = useState(65);
const {
status: updateStatus,
progress: updateProgress,
info: updateInfo,
error: updateError,
checkForUpdates,
installUpdate,
} = useUpdater();
return ( return (
<div className="flex-1 p-8"> <div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div className="mx-auto max-w-4xl space-y-6"> <HomeContent />
{/* ---- 标题区 + 主题开关 ---- */}
<div className="text-center space-y-2">
<div className="flex items-center justify-center gap-4">
<Title level={1} className="mb-0! gradient-primary-text">
HeiXiu ·
</Title>
<AntSwitch
checked={isDark}
onChange={toggleTheme}
checkedChildren={<BulbFilled />}
unCheckedChildren={<BulbOutlined />}
title={isDark ? '切换亮色模式' : '切换暗色模式'}
/>
</div>
<Text type="secondary">
Electron + React 19 + TypeScript + Ant Design + Tailwind CSS
</Text>
<br />
<Space size={4}>
<Tag color={isDark ? 'blue' : 'purple'}>
{isDark ? '🌙 暗色' : '☀️ 亮色'}
</Tag>
<Tag color={edition === 'enterprise' ? 'gold' : 'green'}>
{edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
{isLoggedIn ? (
<>
<Tag color="blue"></Tag>
<Button size="small" type="link" danger onClick={logout}>
退
</Button>
</>
) : (
<Tag color="default"></Tag>
)}
</Space>
</div>
{/* ---- 导航栏状态提示 ---- */}
<Card size="small" variant="borderless" className="rounded-lg">
<Text type="secondary" className="text-xs">
💡 使 <Text strong>H5 </Text>
{platform === 'darwin'
? ' macOS 保留了最小 Edit 菜单以支持 Cmd+C/V 等快捷键。'
: ' Windows/Linux 已完全移除系统菜单栏。'}
</Text>
</Card>
{/* ---- 主题色预览 ---- */}
<Card
title="🎨 蓝紫渐变主题色预览"
variant="borderless"
className="rounded-lg shadow-lg"
>
<Space orientation="vertical" size="large" className="w-full">
{/* 渐变展示 */}
<div>
<Text strong></Text>
<div className="flex gap-3 mt-2">
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: gradientPrimary }}
>
135°
</div>
<div
className="flex-1 h-16 rounded-lg flex items-center justify-center text-white font-medium shadow-md"
style={{ background: 'linear-gradient(90deg, #4F46E5, #7C3AED)' }}
>
90°
</div>
</div>
</div>
{/* 组件示例 */}
<div>
<Text strong>Ant Design </Text>
<Space wrap className="mt-2">
<Button type="primary" icon={<ThunderboltOutlined />}>
</Button>
<Button></Button>
<Button type="dashed">线</Button>
<Button type="text"></Button>
<Button type="link"></Button>
</Space>
</div>
{/* 滑块 + 进度条 */}
<div>
<Text strong></Text>
<div className="flex items-center gap-4 mt-2">
<span className="text-sm" style={{ color: token.colorTextSecondary }}>
:
</span>
<Slider
className="flex-1"
value={progress}
onChange={setProgress}
min={0}
max={100}
/>
<Progress
type="circle"
percent={progress}
size={48}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
</div>
</div>
</Space>
</Card>
{/* ---- 平台信息 ---- */}
<Card title="🖥️ 系统信息" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">{getPlatformName()}</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color={isDev() ? 'blue' : 'green'}>
{isDev() ? '开发模式' : '生产模式'}
</Tag>
</div>
<div className="flex items-center gap-2">
<ApiOutlined style={{ color: token.colorPrimary }} />
<Text strong></Text>
<Tag color="purple">
H5 {edition === 'enterprise' ? '企业版' : '个人版'}
</Tag>
</div>
</Space>
</Card>
{/* ---- 版本更新 ---- */}
<Card title="🔄 版本更新" variant="borderless" className="rounded-lg shadow-lg">
<Space orientation="vertical" size="middle" className="w-full">
{/* 状态提示 */}
{updateStatus === 'checking' && (
<div className="flex items-center gap-2">
<SyncOutlined spin style={{ color: token.colorPrimary }} />
<Text type="secondary">...</Text>
</div>
)}
{updateStatus === 'no-update' && (
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text type="success"></Text>
</div>
)}
{updateStatus === 'error' && (
<div className="flex items-center gap-2">
<ExclamationCircleOutlined style={{ color: token.colorError }} />
<Text type="danger">{updateError?.message || '检查更新失败'}</Text>
</div>
)}
{/* 发现新版本 */}
{(updateStatus === 'available' || updateStatus === 'downloading') && (
<>
<div className="flex items-center gap-2">
<RocketOutlined style={{ color: token.colorPrimary }} />
<Text strong>{updateInfo?.version}</Text>
{updateInfo?.forceUpdate && <Tag color="red"></Tag>}
</div>
{updateInfo?.releaseNotes && (
<Text type="secondary" className="text-xs whitespace-pre-line">
{updateInfo.releaseNotes}
</Text>
)}
</>
)}
{/* 下载进度 */}
{updateStatus === 'downloading' && (
<Progress
percent={updateProgress.percent}
strokeColor={{ '0%': '#4F46E5', '100%': '#7C3AED' }}
/>
)}
{/* 下载完成 */}
{updateStatus === 'downloaded' && (
<>
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text></Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={installUpdate}
>
</Button>
</>
)}
{/* 操作按钮 */}
<div className="flex items-center gap-3">
<Button
icon={<SyncOutlined spin={updateStatus === 'checking'} />}
onClick={checkForUpdates}
disabled={updateStatus === 'checking'}
>
</Button>
{isDev() && (
<Text type="secondary" className="text-xs">
5
</Text>
)}
</div>
</Space>
</Card>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,112 @@
// ============================================================
// BannerCarousel — 首页顶部轮播图
// 使用 useBannerList() Hook 获取数据,组件仅负责渲染
//
// 关闭行为:
// - 关闭后当前会话不再显示(组件内 useState 驱动)
// - 刷新/重启应用 → state 重置 → 重新展示
// - 不做 localStorage 持久化,确保每次启动都可见
// ============================================================
import { useRef, useMemo, useState } from 'react';
import { Carousel, Skeleton, Button } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel';
import { useBannerList } from '@/hooks/use-api';
import type { Banner } from '@/services/modules/banner';
// ---------- 组件 Props ----------
interface BannerCarouselProps {
className?: string;
}
// ---------- 组件 ----------
export function BannerCarousel({ className }: BannerCarouselProps) {
const carouselRef = useRef<CarouselRef>(null);
const { data: rawBanners, loading } = useBannerList();
/** 当前会话是否已关闭轮播图(组件卸载即重置,下次启动重新展示) */
const [dismissed, setDismissed] = useState(false);
// 过滤 + 排序(纯计算,无副作用)
const banners = useMemo<Banner[]>(() => {
if (!rawBanners) return [];
return rawBanners
.filter((b) => b.is_active)
.filter((b) => b.category === 'banner')
.sort((a, b) => a.sort_order - b.sort_order);
}, [rawBanners]);
// 用户已关闭或加载完成但无数据 → 不渲染
if (dismissed || (!loading && banners.length === 0)) {
return null;
}
// 加载态 — 适配长条比例2280×90
if (loading) {
return (
<div className={className} style={{ width: '100%' }}>
<Skeleton.Input active block style={{ height: 90 }} />
</div>
);
}
return (
<div
className={`banner-carousel ${className ?? ''}`}
style={{ position: 'relative', width: '100%', overflow: 'hidden', lineHeight: 0 }}
>
<Carousel
ref={carouselRef}
autoplay
autoplaySpeed={5000}
effect="fade"
pauseOnHover
adaptiveHeight={true}
dots={banners.length > 1}
>
{banners.map((banner) => (
<div key={banner.id}>
<img
src={banner.image_url}
alt={banner.title}
style={{ width: '100%', height: 'auto', display: 'block' }}
/>
</div>
))}
</Carousel>
{/* 关闭按钮 — 右上角悬浮半透明底色hover 加深 */}
<Button
type="text"
size="small"
icon={<CloseOutlined />}
onClick={() => setDismissed(true)}
title="关闭轮播图"
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 10,
color: '#fff',
background: 'rgba(0, 0, 0, 0.35)',
borderRadius: 4,
width: 24,
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0, 0, 0, 0.55)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'rgba(0, 0, 0, 0.35)';
}}
/>
</div>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// CenterPanel — 中列容器(模型输入表单)
// ============================================================
import { theme as antTheme } from 'antd';
import { ModelInputForm } from './ModelInputForm';
export function CenterPanel() {
const { token } = antTheme.useToken();
return (
<div
style={{
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<ModelInputForm />
</div>
);
}

View File

@@ -0,0 +1,189 @@
// ============================================================
// LeftPanel — 左列容器(模型选择 + 可拖拽分割线 + 任务搜索 + 任务记录)
// 纵向拖拽分隔线支持鼠标调整上下区域高度比例
//
// 职责:布局 + edition 分发,子组件自行管理数据和搜索状态
// ============================================================
import { useState, useRef, useEffect, useCallback } from 'react';
import { theme as antTheme } from 'antd';
import { ModelSelector } from './ModelSelector';
import { TaskHistory } from './TaskHistory';
import { useModelList } from '@/hooks/use-api';
// ---------- 常量 ----------
/** 模型选择区最小高度 */
const TOP_MIN = 120;
/** 任务列表区最小高度 */
const BOTTOM_MIN = 200;
/** 默认模型选择区百分比 */
const TOP_DEFAULT_PCT = 0.40;
/** 拖拽分隔条高度 */
const DIVIDER_HEIGHT = 6;
// ---------- 组件 ----------
export function LeftPanel() {
const { token } = antTheme.useToken();
// 面板容器引用
const containerRef = useRef<HTMLDivElement>(null);
// ======== 模型数据(提升到父组件,避免两个子组件重复请求) ========
const { data: models, loading, error, groupedModels, refetch } = useModelList();
// 模型选择区高度(像素)
const [topHeight, setTopHeight] = useState<number | null>(null);
// 拖拽状态
const [dragging, setDragging] = useState(false);
// hover 状态
const [hovered, setHovered] = useState(false);
const dragState = useRef<{
startY: number;
startHeight: number;
}>({ startY: 0, startHeight: 0 });
// 初始化:按百分比计算顶部高度
const initHeight = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const total = el.clientHeight;
setTopHeight(Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT)));
}, []);
useEffect(() => {
initHeight();
}, [initHeight]);
// 响应容器 resize窗口大小变化时重新按比例计算
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const observer = new ResizeObserver(() => {
if (!dragging) {
const total = el.clientHeight;
setTopHeight((prev) => {
if (prev === null) return Math.max(TOP_MIN, Math.round(total * TOP_DEFAULT_PCT));
// 保持原有比例
const ratio = prev / total;
return Math.max(TOP_MIN, Math.round(total * Math.min(ratio, 0.8)));
});
}
});
observer.observe(el);
return () => observer.disconnect();
}, [dragging]);
// 拖拽事件
const handleDividerMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
dragState.current = { startY: e.clientY, startHeight: topHeight ?? 200 };
setDragging(true);
};
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!dragging) return;
const el = containerRef.current;
if (!el) return;
const total = el.clientHeight;
const ds = dragState.current;
const delta = e.clientY - ds.startY;
const newTop = Math.max(TOP_MIN, Math.min(total - BOTTOM_MIN, ds.startHeight + delta));
setTopHeight(newTop);
};
const handleMouseUp = () => {
setDragging(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging]);
// 分隔条样式
const isActive = dragging;
const isHovered = hovered;
return (
<div
ref={containerRef}
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
{/* 上方:模型选择(高度由拖拽控制) */}
<div
style={{
height: topHeight ?? '40%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}
>
<ModelSelector
models={models}
loading={loading}
error={error}
groupedModels={groupedModels}
refetch={refetch}
/>
</div>
{/* 纵向拖拽分隔条 */}
<div
onMouseDown={handleDividerMouseDown}
style={{
height: DIVIDER_HEIGHT,
flexShrink: 0,
cursor: 'row-resize',
background: isActive
? token.colorPrimary
: isHovered
? `${token.colorPrimary}60`
: token.colorBorderSecondary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s linear',
zIndex: 10,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* 拖拽手柄横线 */}
<div style={{
height: 2,
width: 32,
borderRadius: 1,
background: isActive
? '#fff'
: isHovered
? token.colorPrimary
: token.colorBorder,
transition: 'background 0.15s linear',
}} />
</div>
{/* 下方:任务记录(剩余高度) */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
<TaskHistory allModels={models} />
</div>
</div>
);
}

View File

@@ -0,0 +1,482 @@
// ============================================================
// ModelInputForm — 模型输入表单(纯布局外壳)
//
// 布局Header模型信息+meta → Bodyfields.map 渲染) → Footer标签+重置+提交)
//
// 与旧版的区别:
// - 控件渲染逻辑已提取到 useUI/ 组件体系
// - param_schema 解析已提取到 useModelUI Hook
// - 字段间条件依赖constraints_config.requires自动处理禁用
// - 本组件只负责布局、Form 容器、提交逻辑、依赖禁用
// ============================================================
import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
import {
Form,
Input,
Button,
Typography,
Empty,
Tag,
Space,
theme as antTheme,
message,
} from 'antd';
import type { FormInstance } from 'antd';
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/pages/home/HomeContent';
import { useAppContext } from '@/components/AppProvider';
import { useSubmitTask } from '@/hooks/use-api';
import { emit, EVENTS } from '@/utils/event-bus';
import { OutputTypeMap } from '@/services/modules/task';
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
import {
calculateCost,
formatYuan,
type ModelDefinition,
type ModelPricingConfig,
} from '@/utils/pricing';
const { Text, Title } = Typography;
// ---------- 工具 ----------
/**
* Form.Item 的 getValueFromEvent兼容两种 onChange 参数形态:
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
* 2. enable_switch 的裸值undefined / [] / [{...}] → 直接透传
*/
function handleFileWidgetValue(e: unknown): unknown {
if (e && typeof e === 'object' && 'fileList' in e) {
return (e as { fileList: unknown }).fileList;
}
return e;
}
// ---------- 依赖字段包装组件 ----------
/**
* DependentFormItem — 为单个字段处理条件依赖禁用。
*
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig
*
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
*/
function DependentFormItem({
field,
form,
value,
onChange,
disabled: formItemDisabled,
}: {
field: UIFieldConfig;
form: FormInstance;
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
}) {
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
// 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 =
deps.some((_, i) => isValueEmpty(depValues[i]));
const effectiveDisabled = formItemDisabled || isDepDisabled;
const effectiveConfig = effectiveDisabled
? { ...field.widgetConfig, disabled: true }
: field.widgetConfig;
const Component = field.component;
return (
<Component
value={value}
onChange={onChange}
disabled={effectiveDisabled}
config={effectiveConfig}
/>
);
}
/** 稳定空数组引用,避免 useMemo 重建 */
const EMPTY_ARRAY: never[] = [];
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
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() {
const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext();
const [form] = Form.useForm();
const [taskTag, setTaskTag] = useState('');
// 跟踪表单当前值(用于动态计价,每次字段变化时更新)
const [currentValues, setCurrentValues] = useState<Record<string, unknown>>({});
// 提交突变 Hook
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
void message.success('任务已提交');
emit(EVENTS.TASK_SUBMITTED);
}, []),
});
// ======== useModelUIparam_schema → UIFieldConfig[] ========
const fields: UIFieldConfig[] = useModelUI(
selectedModel?.param_schema || EMPTY_ARRAY,
(selectedModel?.constraints_config as Record<string, unknown>) || EMPTY_OBJ,
);
// 构建初始值(用作 setFieldsValue 的数据源,
// antd Form 在有外部 form 实例时会忽略 initialValues prop
const initialValues = useMemo(() => {
const iv: Record<string, unknown> = {};
fields.forEach((f) => {
if (f.defaultValue !== undefined && f.defaultValue !== null) {
iv[f.name] = f.defaultValue;
}
});
return iv;
}, [fields]);
// 选中模型时加载默认值useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
useLayoutEffect(() => {
if (selectedModel && Object.keys(initialValues).length > 0) {
form.setFieldsValue(initialValues);
setTaskTag('');
// 同步价格计算:切换模型时立即用默认值更新预估
setCurrentValues(initialValues);
} else if (!selectedModel) {
setCurrentValues({});
}
}, [selectedModel?.id, initialValues, selectedModel, form]);
// 重置参数(回到默认值 + 清空标签)
const handleReset = useCallback(() => {
form.resetFields();
form.setFieldsValue(initialValues);
setTaskTag('');
setCurrentValues(initialValues);
}, [form, initialValues]);
// ======== 提交 ========
const handleSubmit = async () => {
if (!isLoggedIn) {
message.warning('请先登录后再提交任务');
return;
}
if (!selectedModel) return;
try {
const values = await form.validateFields();
const params: Record<string, string> = {};
for (const field of fields) {
const value = values[field.name];
if (value === undefined || value === null) continue;
if (field.isFileWidget) {
const fileList = value as UploadFile[];
const urls = fileList
.filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[];
if (urls.length > 0) {
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
}
continue;
}
if (typeof value === 'boolean') {
params[field.name] = String(value);
continue;
}
params[field.name] = String(value);
}
await submitTask({
model_id: selectedModel.id,
params,
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
});
} catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) {
return;
}
message.error(errorMessage || '任务提交失败,请重试');
}
};
// ======== 动态预估消费金额(必须在所有提前返回之前) ========
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) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
}}
>
<Empty description="请在左侧选择一个模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ======== 提取 meta 信息 ========
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
const outputType = metaConfig.output_type as OutputTypeString | undefined;
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
const badge = uiConfig.badge as string | undefined;
const group = uiConfig.group as string | undefined;
const helpUrl = metaConfig.help_url as string | undefined;
return (
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
{/* ======== Header模型信息 ======== */}
<div
style={{
padding: '16px 20px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name}
</Title>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name}
{group
? ` · ${group}`
: selectedModel.category_name
? ` · ${selectedModel.category_name}`
: ''}
</Text>
</div>
<Space size={4} style={{ marginTop: 8 }} wrap>
{outputType && (
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
{OutputTypeMap[outputType] || outputType}
</Tag>
)}
{estimatedDuration !== undefined && (
<Tag
icon={<ClockCircleOutlined />}
color="default"
style={{ fontSize: 11, lineHeight: '18px' }}
>
{estimatedDuration}
</Tag>
)}
{helpUrl && (
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11 }}
>
使
</a>
)}
</Space>
</div>
{/* ======== Body参数列表useModelUI 驱动) ======== */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
{fields.length === 0 ? (
<Empty
description="该模型无需额外参数"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginTop: 24 }}
/>
) : (
<Form
form={form}
layout="vertical"
onValuesChange={(_, allValues) => setCurrentValues(allValues)}
>
{fields.map((field) => (
<Form.Item
key={`${selectedModel.id}-${field.name}`}
name={field.name}
label={
<span>
<Text strong={field.must}>{field.label}</Text>
{field.tips && (
<Text
type="secondary"
style={{ fontSize: 12, marginLeft: 6 }}
>
{field.tips.length > 60
? `${field.tips.slice(0, 60)}...`
: field.tips}
</Text>
)}
</span>
}
rules={
field.must
? [{ required: true, message: `请输入${field.label}` }]
: undefined
}
valuePropName={field.valuePropName}
{...(field.isFileWidget
? { getValueFromEvent: handleFileWidgetValue }
: {})}
>
<DependentFormItem field={field} form={form} />
</Form.Item>
))}
</Form>
)}
</div>
{/* ======== Footer标签 + 重置 + 提交 ======== */}
<div
style={{
padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
}}
>
<Input
placeholder="自定义任务标签(可选)"
value={taskTag}
onChange={(e) => setTaskTag(e.target.value)}
allowClear
maxLength={50}
style={{ flex: 1, minWidth: 0 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
disabled={!selectedModel}
title="重置参数"
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={() => void handleSubmit()}
loading={submitting}
disabled={!isLoggedIn || !selectedModel}
size="large"
style={{
background: token.colorPrimary,
height: 40,
fontSize: 14,
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{estimatedCostDisplay}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,240 @@
// ============================================================
// ModelSelector — 模型选择器Tabs 标签页分组 + 平铺列表)
// 模型数据由父组件 LeftPanel 通过 props 传入,避免多个兄弟组件各自请求
// ============================================================
import React, { useState, useMemo, useCallback } from 'react';
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import { useHomeContext } from '@/pages/home/HomeContent';
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
const { Text } = Typography;
/** "全部模型" 的虚拟 tab key */
const ALL_TAB_KEY = '__all__';
// ---------- 单模型列表项(平铺) ----------
/** 单个模型行hover 高亮 + 选中态 + 状态标签 + 禁用态处理 */
interface ModelItemProps {
model: ModelsListResponseBody;
isSelected: boolean;
onSelect: () => void;
}
function ModelItem({ model, isSelected, onSelect }: ModelItemProps) {
const { token } = antTheme.useToken();
const [isHovered, setIsHovered] = useState(false);
// 维护中的模型不可选
const isDisabled = model.status === 'maintenance';
const handleClick = useCallback(() => {
if (isDisabled) {
message.warning(`${model.name} 正在维护中,暂不可用`);
return;
}
onSelect();
}, [isDisabled, model.name, onSelect]);
// 背景色 / 边框色 优先级:选中 > hover > 默认
let bgColor = 'transparent';
let borderColor = 'transparent';
if (isSelected) {
bgColor = token.colorPrimaryBg;
borderColor = token.colorPrimaryBorder;
} else if (isHovered && !isDisabled) {
bgColor = token.colorFillSecondary;
borderColor = token.colorBorderSecondary;
}
return (
<div
onClick={handleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
padding: '6px 12px',
cursor: isDisabled ? 'not-allowed' : 'pointer',
borderRadius: 6,
background: bgColor,
border: `1px solid ${borderColor}`,
opacity: isDisabled ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
transition: 'background 0.15s linear, border-color 0.15s linear, opacity 0.15s linear',
}}
>
<Text
style={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 13,
color: isSelected ? token.colorPrimaryText : token.colorText,
}}
title={model.name}
>
{model.name}
</Text>
{model.status !== 'active' && (
<Tag
color={model.status === 'inactive' ? 'default' : 'orange'}
style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}
>
{model.status === 'maintenance' ? '维护' : '停用'}
</Tag>
)}
</div>
);
}
// ---------- 主组件 ----------
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 { selectedModel, setSelectedModel } = useHomeContext();
const [activeTab, setActiveTab] = useState(ALL_TAB_KEY);
// 当前 Tab 对应的模型列表
const filteredModels:ModelsListResponseBody[] = useMemo(() => {
if (activeTab === ALL_TAB_KEY) return models ?? [];
return groupedModels.get(activeTab) ?? [];
}, [activeTab, models, groupedModels]);
// 构建 Tab 项(全部 + 各分类,顺序由 MODEL_CATEGORIES 控制)
const tabItems: { key: string; label: React.ReactNode }[] = useMemo(() => {
const items: { key: string; label: React.ReactNode }[] = [
{
key: ALL_TAB_KEY,
label: `全部 (${models?.length ?? 0})`,
},
];
// 按 MODEL_CATEGORIES 定义的顺序遍历,确保 Tab 顺序可控
const seenCategories = new Set<string>();
MODEL_CATEGORIES.forEach((cat: ModelCategoryStringEnum) => {
const groupModels = groupedModels.get(cat);
if (groupModels && groupModels.length > 0) {
seenCategories.add(cat);
items.push({
key: cat,
label: `${resolveCategoryLabel(cat)} (${groupModels.length})`,
});
}
});
// 兜底API 返回了 MODEL_CATEGORIES 中未注册的新分类,追加到末尾
groupedModels.forEach((groupModels, category) => {
if (!seenCategories.has(category) && groupModels.length > 0) {
items.push({
key: category,
label: `${resolveCategoryLabel(category)} (${groupModels.length})`,
});
}
});
return items;
}, [models, groupedModels]);
// ---------- 加载态 ----------
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 24 }}>
<Spin size="small" />
</div>
);
}
// ---------- 错误态 ----------
if (error) {
return (
<Result
status="error"
title="模型列表加载失败"
subTitle={error.message || '请检查网络连接后重试'}
extra={
<Button type="primary" size="small" onClick={refetch}>
</Button>
}
/>
);
}
// ---------- 空态 ----------
if (!models || models.length === 0) {
return <Empty description="暂无可用模型" image={Empty.PRESENTED_IMAGE_SIMPLE} />;
}
// ---------- 正常渲染 ----------
return (
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minHeight: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '8px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<AppstoreOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}>
</Text>
</div>
{/* 分类 Tabs */}
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
items={tabItems}
size="small"
type="card"
tabBarStyle={{ margin: '0 0 4px 0', padding: '0 8px' }}
/>
{/* 模型列表 */}
<div style={{ flex: 1, overflow: 'auto', padding: '0 8px 8px' }}>
{filteredModels.length === 0 ? (
<Empty
description="该分类暂无模型"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ marginTop: 24 }}
/>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredModels.map((model) => (
<ModelItem
key={model.id}
model={model}
isSelected={selectedModel?.id === model.id}
onSelect={() => setSelectedModel(model)}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
// ============================================================
// OutputPreview — 任务输出预览(图片 / 视频)
// 使用 useTaskDetail() Hook 获取任务详情,组件仅负责渲染
// ============================================================
import { useMemo } from 'react';
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
import {
EyeOutlined,
DownloadOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useHomeContext } from '@/pages/home/HomeContent';
import { useTaskDetail } from '@/hooks/use-api';
import type { TaskStatusString } from '@/services/modules/task';
const { Text } = Typography;
// ---------- 状态标签配置 ----------
const STATUS_CONFIG: Record<TaskStatusString, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
// ---------- 工具函数 ----------
function isVideoUrl(url: string): boolean {
return /\.(mp4|webm|mov|avi|mkv)(\?|$)/i.test(url);
}
function splitAssets(assets: Array<{ url: string }>): { images: string[]; videos: string[] } {
const images: string[] = [];
const videos: string[] = [];
assets.forEach(({ url }) => {
if (isVideoUrl(url)) videos.push(url);
else images.push(url);
});
return { images, videos };
}
// ---------- 组件 ----------
export function OutputPreview() {
const { token } = antTheme.useToken();
const { selectedTask } = useHomeContext();
// 数据获取Hook 自动处理taskId 为 null 时不请求、竞态、loading/error
const { data: detail, loading } = useTaskDetail(selectedTask?.id);
// 从 output_assets 提取图片/视频
const { images, videos } = useMemo(() => {
if (!detail?.output_assets || detail.output_assets.length === 0) {
return { images: [], videos: [] };
}
return splitAssets(detail.output_assets);
}, [detail]);
const status = selectedTask?.status as TaskStatusString | undefined;
const statusCfg = status
? (STATUS_CONFIG[status] || { color: 'default', label: status })
: { color: 'default', label: '未知' };
// ---- 无选中任务 ----
if (!selectedTask) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Empty description="选择任务记录查看输出" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 处理中 ----
if (status === 'pending' || status === 'submitted' || status === 'processing') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<Spin size="large" />
<Tag color={statusCfg.color}>{statusCfg.label}</Tag>
<Text type="secondary">...</Text>
</div>
);
}
// ---- 失败 ----
if (status === 'failed') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 12 }}>
<CloseCircleOutlined style={{ fontSize: 48, color: '#ff4d4f' }} />
<Text type="danger" strong></Text>
{detail?.error_message && (
<Text type="secondary" style={{ maxWidth: 300, textAlign: 'center', fontSize: 12 }}>
{detail.error_message}
</Text>
)}
</div>
);
}
// ---- 加载中 ----
if (loading) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
// ---- 无结果 ----
if (!detail || images.length + videos.length === 0) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
<Empty description="暂无输出结果" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
// ---- 展示 ----
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
padding: '12px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<EyeOutlined style={{ fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
<Tag color={statusCfg.color} style={{ fontSize: 11 }}>{statusCfg.label}</Tag>
</div>
{videos.length > 0 && (
<Button size="small" type="link" icon={<DownloadOutlined />} href={videos[0]} target="_blank">
</Button>
)}
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{videos.map((url, i) => (
<div key={i} style={{ borderRadius: 6, overflow: 'hidden', background: '#000' }}>
<video controls style={{ width: '100%', maxHeight: 360, display: 'block' }} src={url}>
</video>
</div>
))}
{images.length === 1 ? (
<Image src={images[0]} alt="输出结果" style={{ borderRadius: 6, width: '100%', objectFit: 'contain' }} />
) : (
<Image.PreviewGroup>
{images.map((url, i) => (
<Image key={i} src={url} alt={`输出结果 ${i + 1}`}
style={{ borderRadius: 6, width: '100%', objectFit: 'contain', marginBottom: 8 }} />
))}
</Image.PreviewGroup>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// RightPanel — 右列容器(输出预览)
// ============================================================
import { theme as antTheme } from 'antd';
import { OutputPreview } from './OutputPreview';
export function RightPanel() {
const { token } = antTheme.useToken();
return (
<div
style={{
height: '100%',
overflow: 'hidden',
borderRadius: 8,
border: `1px solid ${token.colorBorderSecondary}`,
}}
>
<OutputPreview />
</div>
);
}

View File

@@ -0,0 +1,749 @@
// ============================================================
// TaskHistory — 任务记录列表(分页 + 列头筛选 + 排序 + 滚动)
//
// 筛选策略(混合模式):
// - 服务端筛选(传 APIstatus、model_id、user_id企业版
// - 客户端筛选antd onFilteroutput_type、provider_name
// - 服务端不支持日期查询,时间范围筛选暂用 sorter 排序替代
// - 企业版才会在"用户"列显示筛选菜单
//
// 使用 antd Table onChange 统一管理 pagination + filters + sorter
// 参考 table-test.tsx 示例 4 的模式。
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList } from '@/hooks/use-api';
import type { ModelsListResponseBody } from '@/services/modules/models';
import { useAppContext } from '@/components/AppProvider';
import { useHomeContext } from '@/pages/home/HomeContent';
import { useTheme } from '@/components/ThemeProvider';
import { on, off, EVENTS } from '@/utils/event-bus';
import type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
} from '@/services/modules/task';
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
const { Text } = Typography;
// ---------- 辅助类型 ----------
/** antd Table onChange 中 filters 的类型 */
type TableFilters = Record<string, FilterValue | null>;
/** antd Table onChange 中 sorter 的类型(单列排序) */
interface TableSorter {
field?: React.Key | readonly React.Key[];
order?: 'ascend' | 'descend';
}
/** 表格参数(缓存 antd onChange 的结果,用于构建 API 请求) */
interface TableParams {
pagination: { current: number; pageSize: number };
filters: TableFilters;
sorter: TableSorter;
}
// ---------- 常量 ----------
/** 状态筛选选项(来自 API 枚举) */
const STATUS_FILTERS = Object.entries(TaskStatusMap).map(([value, label]) => ({
text: label,
value,
}));
/** 输出类型筛选选项(从 OutputTypeMap 派生) */
const OUTPUT_TYPE_FILTERS = Object.entries(OutputTypeMap).map(([value, text]) => ({
text,
value,
}));
/** 状态标签配置(渲染用,含未来未知状态的降级处理) */
const STATUS_CONFIG: Record<string, { color: string; label: string }> = {
pending: { color: 'blue', label: '排队中' },
submitted: { color: 'cyan', label: '已提交' },
processing: { color: 'orange', label: '处理中' },
success: { color: 'green', label: '已完成' },
failed: { color: 'red', label: '失败' },
};
/** 未知状态降级:后端新增状态时自动回退显示 key 原文 */
function resolveStatus(s: string): { color: string; label: string } {
return STATUS_CONFIG[s] || { color: 'default', label: s };
}
// ---------- 列可见性持久化 ----------
/** localStorage 键名(遵循 ele-heixiu-* 规范) */
const COLUMN_VISIBILITY_KEY = 'ele-heixiu-table-columns';
/** 列元数据(定义所有可用列 + 默认可见性) */
interface ColumnMeta { key: string; title: string; defaultVisible: boolean }
const ALL_COLUMNS: ColumnMeta[] = [
{ key: 'id', title: '任务 ID', defaultVisible: true },
{ key: 'created_at', title: '创建时间', defaultVisible: true },
{ key: 'model_name', title: '模型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'status', title: '状态', defaultVisible: true },
{ key: 'prompt', title: '提示词', defaultVisible: true },
{ key: 'duration', title: '时长', defaultVisible: true },
{ key: 'cost_cent', title: '费用', defaultVisible: true },
{ key: 'user_display_name', title: '用户', defaultVisible: true },
{ key: 'provider_name', title: '供应商', defaultVisible: false },
{ key: 'fps', title: '帧率', defaultVisible: false },
{ key: 'file_size', title: '文件大小', defaultVisible: false },
{ key: 'tags', title: '标签', defaultVisible: false },
{ key: 'error_message', title: '错误信息', defaultVisible: false },
];
function readStoredColumns(): Set<string> | null {
try {
const raw = localStorage.getItem(COLUMN_VISIBILITY_KEY);
if (raw) {
const arr: unknown = JSON.parse(raw);
if (Array.isArray(arr) && arr.every((v) => typeof v === 'string')) return new Set(arr);
}
} catch { /* ignore */ }
return null;
}
function writeStoredColumns(visible: Set<string>): void {
try { localStorage.setItem(COLUMN_VISIBILITY_KEY, JSON.stringify([...visible])); } catch { /* ignore */ }
}
// ---------- 工具函数 ----------
/** 从 filters 中提取单值筛选(如 status 单选) */
function pickOne(filters: TableFilters, key: string): string | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v[0] as string;
return undefined;
}
/** 从 filters 中提取多值筛选(如 model_ids、user_ids */
function pickMany(filters: TableFilters, key: string): string[] | undefined {
const v = filters[key];
if (Array.isArray(v) && v.length > 0) return v as string[];
return undefined;
}
// ---------- 组件 ----------
interface TaskHistoryProps {
/** 模型全量数据,由父组件 LeftPanel 传入(避免兄弟组件各自请求) */
allModels: ModelsListResponseBody[] | null;
}
export function TaskHistory({ allModels }: TaskHistoryProps) {
const { token } = antTheme.useToken();
const { edition } = useAppContext();
const {
selectedTask,
setSelectedTask,
taskPage,
taskPageSize,
setTaskPage,
setTaskPageSize,
} = useHomeContext();
const isEnterprise = edition === 'enterprise';
// -------- 列可见性 --------
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
const stored = readStoredColumns();
if (stored) return stored;
return new Set(ALL_COLUMNS.filter((c) => c.defaultVisible).map((c) => c.key));
});
const handleColumnToggle = useCallback((key: string, checked: boolean) => {
setVisibleColumns((prev) => {
const next = new Set(prev);
if (checked) next.add(key); else next.delete(key);
writeStoredColumns(next);
return next;
});
}, []);
// -------- 主题感知斑马纹 / hover / 选中行 CSS --------
const { isDark } = useTheme();
const tableCss = useMemo(() => {
const searchIconColor = token.colorPrimary;
if (isDark) {
return `
.task-table .task-row-odd > td { background: rgba(255,255,255,0.02) !important; }
.task-table .task-row-even > td { background: transparent !important; }
.task-table .task-row-selected > td { background: ${token.blue}1A !important; }
.task-table .task-row-selected.task-row-odd > td { background: ${token.blue}22 !important; }
.task-table .ant-table-row:hover > td { background: rgba(255,255,255,0.05) !important; }
.task-table .task-row-selected:hover > td { background: ${token.blue}28 !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
`;
}
return `
.task-table .task-row-odd > td { background: #fafafa !important; }
.task-table .task-row-even > td { background: #ffffff !important; }
.task-table .task-row-selected > td { background: #e6f4ff !important; }
.task-table .task-row-selected.task-row-odd > td { background: #dceeff !important; }
.task-table .ant-table-row:hover > td { background: #f0f5ff !important; }
.task-table .task-row-selected:hover > td { background: #d6ebff !important; }
.task-table .prompt-search-icon.filtered { color: ${searchIconColor} !important; }
`;
}, [isDark, token.blue, token.colorPrimary]);
// -------- antd Table onChange 参数缓存 --------
const [tableParams, setTableParams] = useState<TableParams>({
pagination: { current: taskPage, pageSize: taskPageSize },
filters: {},
sorter: {},
});
// 同步 context 分页 ↔ tableParams
useEffect(() => {
setTableParams((prev:TableParams) => ({
...prev,
pagination: { current: taskPage, pageSize: taskPageSize },
}));
}, [taskPage, taskPageSize]);
// -------- 模型列表(用于模型列筛选选项 + 模型名→ID 映射,由父组件传入)--------
const modelFilters:{text:string, value:string}[] = useMemo(() => {
if (!allModels) return [];
return allModels.map((m) => ({ text: m.name, value: m.id }));
}, [allModels]);
// -------- 供应商列表(从模型数据中提取)--------
const providerFilters = useMemo(() => {
if (!allModels) return [];
const seen = new Set<string>();
allModels.forEach((m) => {
if (m.provider_name) seen.add(m.provider_name);
});
return Array.from(seen).sort().map((name) => ({ text: name, value: name }));
}, [allModels]);
// -------- 用户列表(从当前任务数据中提取,仅企业版使用)--------
// TODO: 替换为独立的用户列表 API
const [userFilters, setUserFilters] = useState<Array<{ text: string; value: string }>>([]);
// -------- 构建 API 请求参数 --------
const requestParams = useMemo<TaskListRequestParams>(() => {
const req: TaskListRequestParams = {
page: tableParams.pagination.current,
page_size: tableParams.pagination.pageSize,
};
const { filters } = tableParams;
// 服务端筛选字段
const status = pickOne(filters, 'status');
if (status) req.status = status as TaskStatusString;
const modelIds = pickMany(filters, 'model_name');
if (modelIds) req.model_ids = modelIds;
// user_ids仅企业版传入
if (isEnterprise) {
const userIds = pickMany(filters, 'user_id');
if (userIds) req.user_ids = userIds;
}
return req;
}, [tableParams, isEnterprise]);
// -------- 数据获取 --------
const [refreshTick, setRefreshTick] = useState(0);
const handleTaskSubmitted = useCallback(() => {
setRefreshTick((t) => t + 1);
}, []);
useEffect(() => {
on(EVENTS.TASK_SUBMITTED, handleTaskSubmitted);
return () => { off(EVENTS.TASK_SUBMITTED, handleTaskSubmitted); };
}, [handleTaskSubmitted]);
const {
data: taskData,
loading,
errorMessage,
} = useTaskList({
requestParams,
refreshSignal: refreshTick,
});
const tasks = taskData?.items ?? [];
const total = taskData?.total ?? 0;
// 从任务数据中提取用户列表(企业版筛选用)
useEffect(() => {
if (!isEnterprise || !taskData?.items) return;
const seen = new Set<string>();
taskData.items.forEach((t) => {
if (t.user_id && t.user_display_name) {
seen.add(JSON.stringify({ text: t.user_display_name, value: t.user_id }));
}
});
const newList = Array.from(seen).map((s) => JSON.parse(s) as { text: string; value: string });
setUserFilters((prev) => {
if (prev.length === newList.length) return prev; // 避免不必要的重渲染
return newList;
});
}, [isEnterprise, taskData]);
// -------- antd Table onChange --------
const handleTableChange: TableProps<TaskInfoItemResponseBody>['onChange'] = (
pagination,
filters,
sorter,
) => {
// 同步分页到 Context
if (pagination.current && pagination.current !== taskPage) setTaskPage(pagination.current);
if (pagination.pageSize && pagination.pageSize !== taskPageSize) setTaskPageSize(pagination.pageSize);
// 缓存筛选和排序参数
const s = sorter as SorterResult<TaskInfoItemResponseBody>;
setTableParams({
pagination: {
current: pagination.current || taskPage,
pageSize: pagination.pageSize || taskPageSize,
},
filters: filters as TableFilters,
sorter: !Array.isArray(s) && s.field
? { field: s.field, order: s.order as 'ascend' | 'descend' | undefined }
: {},
});
};
// -------- 表格列定义(含列头筛选/排序 + 可见性过滤)--------
//
// 列顺序:任务 ID(固定) → 创建时间 → 模型 → 输出类型 → 状态 → 提示词 →
// 时长(视频) → 费用 → 供应商 → 帧率/文件大小/标签/错误信息 → 用户(企业版)
//
// 可见性:通过标题栏齿轮图标控制,持久化到 localStorage
//
// ⚠️ 重要columns 依赖中不出 token.colorPrimary否则主题切换时 columns 重新生成
// 会导致 Table 内部状态重置(页码、筛选、排序等丢失)。
// 主题色通过 searchIconColorRef 传递,不触发 columns 重建。
/** 提示词搜索图标颜色(通过 CSS class 承载,避免 columns 依赖 token 导致主题切换重置状态) */
/** 提取视频时长(秒),非视频输出类型返回 null → 渲染 "-" */
function getVideoDuration(record: TaskInfoItemResponseBody): number | null {
if (record.output_type === 'video') {
return record.ui_params.duration ?? null;
}
return null;
}
/** 格式化时长:<60s 显示秒≥60s 显示分秒 */
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return s > 0 ? `${m}${s}` : `${m} 分钟`;
}
const allColumns = useMemo<ColumnsType<TaskInfoItemResponseBody>>(() => {
const cols: ColumnsType<TaskInfoItemResponseBody> = [
// ---- 任务 ID固定列 ----
{
title: '任务 ID',
dataIndex: 'id',
key: 'id',
width: 140,
ellipsis: true,
fixed: 'left',
render: (id: string) => (
<Text copyable={{ text: id }} style={{ fontSize: 12, fontFamily: 'monospace' }}>
{id.slice(0, 10)}...
</Text>
),
},
// ---- 创建时间 ----
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 155,
sorter: (a, b) => {
const da = a.created_at ? new Date(a.created_at).getTime() : 0;
const db = b.created_at ? new Date(b.created_at).getTime() : 0;
return da - db;
},
sortOrder: tableParams.sorter.field === 'created_at' ? tableParams.sorter.order : null,
render: (v: Date) => (
<Text style={{ fontSize: 11 }}>
{v ? new Date(v).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
}) : '-'}
</Text>
),
},
// ---- 模型 ----
{
title: '模型',
dataIndex: 'model_name',
key: 'model_name',
width: 130,
ellipsis: true,
filters: modelFilters,
filteredValue: (tableParams.filters.model_name as string[]) || null,
onFilter: () => true, // 服务端筛选
filterSearch: true,
filterMode: 'menu' as const,
},
// ---- 输出类型 ----
{
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 90,
filters: OUTPUT_TYPE_FILTERS,
filteredValue: (tableParams.filters.output_type as string[]) || null,
onFilter: (value, record) => record.output_type === value, // 客户端筛选
render: (t: string) => (
<Tag style={{ fontSize: 10 }}>
{OutputTypeMap[t as keyof typeof OutputTypeMap] || t}
</Tag>
),
},
// ---- 状态 ----
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 80,
filters: STATUS_FILTERS,
filteredValue: (tableParams.filters.status as string[]) || null,
onFilter: () => true, // 服务端筛选
render: (s: string) => {
const cfg = resolveStatus(s);
return <Tag color={cfg.color} style={{ fontSize: 10 }}>{cfg.label}</Tag>;
},
},
// ---- 提示词 ----
{
title: '提示词',
dataIndex: 'prompt',
key: 'prompt',
width: 160,
ellipsis: true,
filterIcon: (filtered: boolean) => (
<SearchOutlined className={`prompt-search-icon${filtered ? ' filtered' : ''}`} style={{ fontSize: 12 }} />
),
filterDropdown: undefined, // TODO: 后续加自定义搜索框
render: (p: string | undefined) => (
<Text style={{ fontSize: 11 }} title={p}>{p || '-'}</Text>
),
},
// ---- 时长(仅视频有数据,否则 "-" ----
{
title: '时长',
key: 'duration',
width: 75,
sorter: (a, b) => {
const da = getVideoDuration(a) ?? -1;
const db = getVideoDuration(b) ?? -1;
return da - db;
},
sortOrder: tableParams.sorter.field === 'duration' ? tableParams.sorter.order : null,
render: (_: unknown, record: TaskInfoItemResponseBody) => {
const sec = getVideoDuration(record);
return <Text style={{ fontSize: 11 }}>{sec !== null ? formatDuration(sec) : '-'}</Text>;
},
},
// ---- 费用 ----
{
title: '费用',
dataIndex: 'cost_cent',
key: 'cost_cent',
width: 75,
sorter: (a, b) => a.cost_cent - b.cost_cent,
sortOrder: tableParams.sorter.field === 'cost_cent' ? tableParams.sorter.order : null,
render: (c: number) => (
<Text style={{ fontSize: 11 }}>¥{(c / 100).toFixed(2)}</Text>
),
},
// ---- 供应商(默认隐藏) ----
{
title: '供应商',
dataIndex: 'provider_name',
key: 'provider_name',
width: 100,
ellipsis: true,
filters: providerFilters,
filteredValue: (tableParams.filters.provider_name as string[]) || null,
onFilter: (value, record) => record.provider_name === value, // 客户端筛选
filterSearch: true,
},
// ---- 帧率(占位列,待后端支持) ----
{
title: '帧率',
key: 'fps',
width: 70,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 文件大小(占位列,待后端支持) ----
{
title: '文件大小',
key: 'file_size',
width: 85,
render: () => <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
// ---- 标签 ----
{
title: '标签',
dataIndex: 'tags',
key: 'tags',
width: 100,
ellipsis: true,
render: (tags: Array<string | null>) => {
const filtered = tags?.filter((t): t is string => t !== null) ?? [];
if (filtered.length === 0) return <Text type="secondary" style={{ fontSize: 11 }}>-</Text>;
return (
<Space size={2} wrap>
{filtered.slice(0, 2).map((t) => (
<Tag key={t} style={{ fontSize: 9, lineHeight: '14px', margin: 0 }}>{t}</Tag>
))}
{filtered.length > 2 && (
<Text type="secondary" style={{ fontSize: 9 }}>+{filtered.length - 2}</Text>
)}
</Space>
);
},
},
// ---- 错误信息(失败任务时展示) ----
{
title: '错误信息',
dataIndex: 'error_message',
key: 'error_message',
width: 140,
ellipsis: true,
render: (msg: string) => msg
? <Text type="danger" style={{ fontSize: 11 }} title={msg}>{msg}</Text>
: <Text type="secondary" style={{ fontSize: 11 }}>-</Text>,
},
];
// 用户列:仅企业版可筛选
if (isEnterprise) {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
filters: userFilters,
filteredValue: (tableParams.filters.user_id as string[]) || null,
onFilter: () => true, // 服务端筛选(传 user_ids
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
} else {
cols.push({
title: '用户',
dataIndex: 'user_display_name',
key: 'user_id',
width: 100,
ellipsis: true,
render: (n: string | null) => (
<Text style={{ fontSize: 11 }}>{n || '-'}</Text>
),
});
}
return cols;
}, [
modelFilters,
providerFilters,
userFilters,
tableParams.filters,
tableParams.sorter,
isEnterprise,
]);
// 按 visibleColumns 过滤实际展示的列
const columns = useMemo(
() => allColumns.filter((c) => visibleColumns.has(c.key as string)),
[allColumns, visibleColumns],
);
// -------- 表格高度 --------
//
// 核心策略:固定预留 pagination 区域高度,确保无论数据加载时机如何,
// 分页器始终有空间渲染,不会溢出可视区。
//
// scroll.y = wrapper高度 - thead高度 - PAGINATION_RESERVE - 水平滚动条高度
//
// 为什么固定预留 pagination 而不是动态测量?
// - 无数据/加载中时 pagination DOM 尚不存在 → offsetHeight=0 → scroll.y 偏大
// - 翻页/切换 pageSize 时 pagination 异步渲染 → 测量时机不可控
// - 固定预留值48px 覆盖 small-size pagination + 上下间距)避免上述所有时序问题
/** 预留 pagination 高度small-size: ~32px + 上下 padding/margin 8px × 2 */
const PAGINATION_RESERVE = 48;
const tableWrapperRef = useRef<HTMLDivElement>(null);
const [tableBodyHeight, setTableBodyHeight] = useState(400);
useEffect(() => {
const wrapper = tableWrapperRef.current;
if (!wrapper) return;
const calcHeight = () => {
const thead = wrapper.querySelector('.ant-table-thead') as HTMLElement | null;
const body = wrapper.querySelector('.ant-table-body') as HTMLElement | null;
const theadH = thead?.offsetHeight ?? 0;
// 水平滚动条占用高度antd Table scroll.x 触发时出现)
let hScrollH = 0;
if (body && body.scrollWidth > body.clientWidth + 1) {
hScrollH = body.offsetHeight - body.clientHeight;
}
const available = wrapper.clientHeight - theadH - PAGINATION_RESERVE - hScrollH;
setTableBodyHeight(Math.max(120, available));
};
// ResizeObserver容器尺寸变化窗口拉伸、面板拖拽
const resizeObserver = new ResizeObserver(calcHeight);
resizeObserver.observe(wrapper);
// MutationObserver内部 DOM 变化thead 高度变化如筛选菜单弹出、
// 数据加载后水平滚动条出现等)
const mutationObserver = new MutationObserver(calcHeight);
mutationObserver.observe(wrapper, { childList: true, subtree: true });
calcHeight();
return () => {
resizeObserver.disconnect();
mutationObserver.disconnect();
};
}, [taskPageSize]);
// -------- 行样式:斑马纹 + 选中行 --------
const rowClassName = (_record: TaskInfoItemResponseBody, index: number) => {
const base = index % 2 === 1 ? 'task-row-odd' : 'task-row-even';
return selectedTask?.id === _record.id ? `${base} task-row-selected` : base;
};
// -------- 是否有活跃筛选 --------
const hasActiveFilters = Object.values(tableParams.filters).some(
(v) => Array.isArray(v) && v.length > 0,
);
// ======== 渲染 ========
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', minHeight: 0 }}>
{/* 标题栏 */}
<div
style={{
padding: '6px 12px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
display: 'flex',
alignItems: 'center',
gap: 6,
flexShrink: 0,
}}
>
<HistoryOutlined style={{ fontSize: 14 }} />
<Text strong style={{ fontSize: 13 }}></Text>
{total > 0 && (
<Text type="secondary" style={{ fontSize: 11 }}> {total} </Text>
)}
{hasActiveFilters && (
<Tag color="blue" style={{ fontSize: 10, lineHeight: '16px', padding: '0 4px' }}>
</Tag>
)}
{errorMessage && (
<Text type="danger" style={{ fontSize: 11, marginLeft: 'auto' }}>{errorMessage}</Text>
)}
{/* 列可见性控制 — 齿轮图标 */}
<Popover
trigger="click"
placement="bottomRight"
title="显示/隐藏列"
content={
<Checkbox.Group
value={[...visibleColumns]}
style={{ display: 'flex', flexDirection: 'column', gap: 2 }}
>
{ALL_COLUMNS.map((col) => (
<Checkbox
key={col.key}
value={col.key}
disabled={col.key === 'id'}
onChange={(e) => handleColumnToggle(col.key, e.target.checked)}
>
{col.title}
</Checkbox>
))}
</Checkbox.Group>
}
>
<Button
type="text"
size="small"
icon={<SettingOutlined style={{ fontSize: 13 }} />}
style={{ marginLeft: 'auto' }}
title="列设置"
/>
</Popover>
</div>
{/* 表格 — 横向 + 纵向滚动,筛选/排序通过列头操作 */}
<div ref={tableWrapperRef} style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<style>{tableCss}</style>
<Table<TaskInfoItemResponseBody>
className="task-table"
columns={columns}
dataSource={tasks}
rowKey="id"
size="small"
loading={loading}
showHeader={true}
scroll={{ x: isEnterprise ? 1350 : 1250, y: tableBodyHeight }}
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
pagination={{
current: tableParams.pagination.current,
pageSize: tableParams.pagination.pageSize,
total,
size: 'small',
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50'],
placement: ['bottomCenter'],
style: { marginBottom: 0 },
}}
locale={{ emptyText: '暂无任务记录', filterReset: '重置', filterConfirm: '确定' }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,184 @@
// ============================================================
// useModelUI — param_schema → UIFieldConfig[] 转换 Hook
//
// 职责:
// - 遍历模型的 param_schema解析 spec/ui 字段
// - 查 WIDGET_MAP 获取对应的 React 组件
// - 解析 constraints_config.requires 计算字段间依赖
// - 组装出可直接渲染的 UIFieldConfig 数组
//
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
// 不需要关心 param_schema 的解析逻辑。
// ============================================================
import { useMemo } from 'react';
import type { ComponentType } from 'react';
import type { ModelsListResponseBody } from '@/services/modules/models';
import { WIDGET_MAP, FALLBACK_WIDGET } from './useUI';
import type { WidgetProps } from './useUI';
// ---------- 输出类型 ----------
/**
* 单个字段的 UI 配置。
*
* ModelInputForm 遍历此数组,每一项渲染一个 Form.Item。
*/
export interface UIFieldConfig {
/** 渲染顺序param_schema 中的索引,保证稳定) */
index: number;
/** 表单字段名map_to对应 Form.Item name */
name: string;
/** 要渲染的 React 组件 */
component: ComponentType<WidgetProps>;
/** 显示标签ui.label → Form.Item label */
label: string;
/** 是否必填spec.required → Form.Item rules */
must: boolean;
/** 默认值spec.default → Form initialValues */
defaultValue?: unknown;
/** 提示文本ui.tips */
tips?: string;
/** Form.Item 的 valuePropNamecheckbox 用 'checked',其他用默认 'value' */
valuePropName?: string;
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList */
isFileWidget: boolean;
/**
* 条件依赖:当这些字段的值为空时,本字段应被禁用。
*
* 来源于 constraints_config.requires 的逆向推导:
* { if: "sref", then: ["sw", "sv"] }
* → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"]
*/
dependsOn?: string[];
/** 传给 widget 组件的配置placeholder/options/min/max 等) */
widgetConfig: Record<string, unknown>;
}
// ---------- 文件类型 widget 集合 ----------
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
// ---------- 约束解析 ----------
interface ConstraintRule {
if: string;
then: string[];
}
/**
* 从 constraints_config.requires 构建反向依赖表。
*
* 输入:
* [{ if: "sref", then: ["sw", "sv"] }]
*
* 输出:
* { sw: ["sref"], sv: ["sref"] }
*
* 含义sw 和 sv 依赖 srefsref 为空时它们应被禁用。
*/
function buildDependencyMap(
constraintsConfig: Record<string, unknown>,
): Map<string, string[]> {
const depMap = new Map<string, string[]>();
const requires = constraintsConfig.requires as ConstraintRule[] | undefined;
if (!requires || !Array.isArray(requires)) return depMap;
for (const rule of requires) {
if (!rule.if || !rule.then || !Array.isArray(rule.then)) continue;
for (const target of rule.then) {
const existing = depMap.get(target) || [];
existing.push(rule.if);
depMap.set(target, existing);
}
}
return depMap;
}
// ---------- Hook ----------
/**
* 根据模型的 param_schema 和 constraints_config 构建 UI 字段配置数组。
*
* @param paramSchema — 来自 selectedModel.param_schema
* @param constraintsConfig — 来自 selectedModel.constraints_config
* @returns UIFieldConfig[] — 渲染就绪的字段配置列表
*/
export function useModelUI(
paramSchema: ModelsListResponseBody['param_schema'],
constraintsConfig: Record<string, unknown> = {},
): UIFieldConfig[] {
return useMemo(() => {
if (!paramSchema || paramSchema.length === 0) return [];
const depMap = buildDependencyMap(constraintsConfig);
return paramSchema.map((item, index) => {
const spec = (item.spec || {}) as Record<string, unknown>;
const ui = (item.ui || {}) as Record<string, unknown>;
const widget = (ui.widget as string) || 'lineEdit';
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
const widgetConfig: Record<string, unknown> = {
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
required: spec.required,
enumValues: spec.enum,
minValue: spec.min_value,
maxValue: spec.max_value,
step: spec.single_step,
minLength: spec.min_length,
maxLength: spec.max_length,
// imageInput / imageList / audioList
fileFilter: spec.file_filter,
maxFileSizeMb: spec.max_file_size_mb,
minFiles: spec.min_files,
maxFiles: spec.max_files,
enableSwitch: ui.enable_switch,
};
const isFile = FILE_WIDGETS.has(widget);
const isCheckbox = widget === 'checkbox';
const name = item.map_to;
const dependsOn = depMap.get(name);
return {
index,
name,
component,
label: (ui.label as string) || name,
must: (spec.required as boolean) || false,
defaultValue: spec.default,
tips: (ui.tips as string) || undefined,
valuePropName: isCheckbox ? 'checked' : undefined,
isFileWidget: isFile,
dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
widgetConfig,
};
});
}, [paramSchema, constraintsConfig]);
}
/** 判断 widget 类型是否为文件上传类控件 */
export function isFileWidget(widgetName: string): boolean {
return FILE_WIDGETS.has(widgetName);
}
// ---------- 值判空工具 ----------
/**
* 判断表单值是否为"空"(用于依赖禁用判断)。
*
* 注意:空数组 [] 不视为空。
* 对于 enable_switch 控件undefined = 开关关闭, [] = 开关开启但未选文件。
* 配合 constraints_config.requires开关开启即解锁依赖字段无需等待文件上传。
*/
export function isValueEmpty(value: unknown): boolean {
if (value === undefined || value === null) return true;
if (typeof value === 'string' && value.trim() === '') return true;
if (typeof value === 'boolean' && !value) return true;
return false;
}

View File

@@ -0,0 +1,43 @@
// ============================================================
// AudioListInput — audioList → Dragger音频文件拖拽上传
// 用于声纹克隆的音频样本上传场景
// ============================================================
import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
const { Dragger } = Upload;
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined;
return (
<Dragger
multiple
accept={
filters
? filters.map((ext) => `.${ext}`).join(',')
: '.wav,.mp3,.m4a,.flac'
}
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={() => false}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
{maxSizeMb}MB
</p>
</Dragger>
);
}

View File

@@ -0,0 +1,17 @@
// ============================================================
// Checkbox — checkbox → Switch
// 用于布尔开关原始模式、平铺、Base64 输出等
// ============================================================
import { Switch } from 'antd';
import type { WidgetProps } from './types';
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
return (
<Switch
checked={value as boolean}
onChange={(v) => onChange?.(v)}
disabled={disabled}
/>
);
}

View File

@@ -0,0 +1,24 @@
// ============================================================
// ComboBox — comboBox → Select
// 用于枚举选择:宽高比、音色、分辨率、模型等
// ============================================================
import { Select } from 'antd';
import type { WidgetProps } from './types';
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
const enumValues = config.enumValues as string[] | undefined;
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
return (
<Select
value={value as string}
onChange={(v) => onChange?.(v)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请选择'}
options={options}
allowClear={!config.required}
style={{ width: '100%' }}
/>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
// 用于小数输入:语速、音量、相似度等
// ============================================================
import { InputNumber } from 'antd';
import type { WidgetProps } from './types';
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
return (
<InputNumber
value={value as number}
onChange={(v) => onChange?.(v != null ? v : undefined)}
disabled={disabled}
min={config.minValue as number | undefined}
max={config.maxValue as number | undefined}
step={config.step as number || 0.1}
style={{ width: '100%', minWidth: 140 }}
placeholder={(config.placeholder as string) || undefined}
controls
/>
);
}

View File

@@ -0,0 +1,96 @@
// ============================================================
// ImageInput — imageInput → Upload单张图片
// 用于垫图、参考图等单图上传场景
//
// enable_switch 支持:
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
// 开关关闭时清除已上传文件。
//
// 表单值语义(配合 constraints_config.requires 依赖禁用):
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
// ============================================================
import { useState, useCallback } from 'react';
import { Upload, Switch, Space, Typography } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './uploadValidation';
const { Text } = Typography;
/** 构建图片 accept 字符串 */
function buildAccept(filters?: string[]): string {
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
return filters.map((ext) => `image/${ext}`).join(',');
}
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
const ENABLED_EMPTY: UploadFile[] = [];
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
// 有 enable_switch 时默认禁用,需手动开启
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
const isUploadDisabled = disabled || !switchOn;
const handleSwitchChange = useCallback(
(on: boolean) => {
setSwitchOn(on);
if (!on) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = true依赖字段禁用
onChange?.(undefined);
} else {
// 开启开关 → 空数组 = "已启用但未上传"isValueEmpty = false依赖字段启用
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
}
},
[onChange],
);
return (
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
{hasEnableSwitch && (
<Space size={8}>
<Switch
size="small"
checked={switchOn}
onChange={handleSwitchChange}
disabled={disabled}
/>
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
{switchOn ? '已启用' : '点击开关启用上传'}
</Text>
</Space>
)}
<Upload
listType="picture-card"
maxCount={1}
accept={buildAccept(config.fileFilter as string[] | undefined)}
fileList={fileList}
onChange={({ fileList: newList }) => {
// 仅当开关开启时上报文件变化
if (switchOn || !hasEnableSwitch) {
onChange?.(newList as unknown as UploadFile[]);
}
}}
disabled={isUploadDisabled}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
>
{fileList.length < 1 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
)}
</Upload>
</Space>
);
}

View File

@@ -0,0 +1,45 @@
// ============================================================
// ImageListInput — imageList → Dragger多张图片拖拽上传
// 用于图生图模型的多图输入场景
// ============================================================
import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './uploadValidation';
const { Dragger } = Upload;
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
const maxCount = (config.maxFiles as number) || 10;
const filters = config.fileFilter as string[] | undefined;
return (
<Dragger
multiple
listType="picture"
accept={
filters
? filters.map((ext) => `image/${ext}`).join(',')
: 'image/png,image/jpg,image/jpeg,image/webp'
}
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
{filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
{maxSizeMb}MB
</p>
</Dragger>
);
}

View File

@@ -0,0 +1,21 @@
// ============================================================
// LineInput — lineEdit → Input
// 用于单行文本输入(如 custom_voice_id
// 同时作为未知 widget 类型的兜底控件
// ============================================================
import { Input } from 'antd';
import type { WidgetProps } from './types';
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
return (
<Input
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请输入'}
maxLength={config.maxLength as number | undefined}
allowClear
/>
);
}

View File

@@ -0,0 +1,55 @@
// ============================================================
// SeedanceContent — seedance2Content → 自定义内容编排控件
//
// 对应 Seedance 2.0 视频生成模型的 content 参数json_array
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
//
// TODO: 当前为占位实现,后续需完善拖拽编排能力
// ============================================================
import { Input, Typography, theme as antTheme } from 'antd';
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { WidgetProps } from './types';
const { TextArea } = Input;
const { Text } = Typography;
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
const { token } = antTheme.useToken();
return (
<div
style={{
border: `1px dashed ${token.colorBorder}`,
borderRadius: 6,
padding: 12,
background: token.colorFillQuaternary,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
<Text type="secondary" style={{ fontSize: 12 }}>
+
</Text>
</div>
<TextArea
rows={4}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={
(config.placeholder as string) ||
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
}
maxLength={(config.maxLength as number) || 5000}
showCount
/>
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
💡 线 + +
</Text>
</div>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// SpinBox — spinBox → InputNumber (step=1)
// 用于整数输入:混沌、风格化、怪异、时长、音调等
// ============================================================
import { InputNumber } from 'antd';
import type { WidgetProps } from './types';
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
return (
<InputNumber
value={value as number}
onChange={(v) => onChange?.(v != null ? v : undefined)}
disabled={disabled}
min={config.minValue as number | undefined}
max={config.maxValue as number | undefined}
step={1}
style={{ width: '100%', minWidth: 140 }}
placeholder={(config.placeholder as string) || undefined}
controls
/>
);
}

View File

@@ -0,0 +1,23 @@
// ============================================================
// TextInput — textEdit → Input.TextArea
// 用于提示词、文本合成等长文本输入场景
// ============================================================
import { Input } from 'antd';
import type { WidgetProps } from './types';
const { TextArea } = Input;
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
return (
<TextArea
rows={4}
value={value as string}
onChange={(e) => onChange?.(e.target.value)}
disabled={disabled}
placeholder={(config.placeholder as string) || '请输入'}
maxLength={config.maxLength as number | undefined}
showCount={config.maxLength !== undefined}
/>
);
}

View File

@@ -0,0 +1,55 @@
// ============================================================
// useUI/index.ts — Widget 注册表
//
// 职责:
// 1. 定义 WidgetProps 统一接口
// 2. 维护 Qt 控件名 → React 组件的 WIDGET_MAP 映射
// 3. 导出 WidgetType 联合类型供外部使用
//
// 新增控件类型只需两步:
// ① 在 useUI/ 下创建组件文件
// ② 在 WIDGET_MAP 中加一行
// ============================================================
import type { ComponentType } from 'react';
import type { WidgetProps } from './types';
import { TextInput } from './TextInput';
import { LineInput } from './LineInput';
import { ComboBox } from './ComboBox';
import { Checkbox } from './Checkbox';
import { SpinBox } from './SpinBox';
import { DoubleSpinBox } from './DoubleSpinBox';
import { ImageInput } from './ImageInput';
import { ImageListInput } from './ImageListInput';
import { AudioListInput } from './AudioListInput';
import { SeedanceContent } from './SeedanceContent';
export type { WidgetProps } from './types';
// ---------- 注册表 ----------
/**
* Qt 控件名 → React 组件的映射表。
*
* 键来自 API param_schema[].ui.widget 字段(原 Python Qt 客户端的组件类名),
* 值为 useUI/ 下的对应 React 组件。
*/
export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
textEdit: TextInput,
lineEdit: LineInput,
comboBox: ComboBox,
checkbox: Checkbox,
spinBox: SpinBox,
doubleSpinBox: DoubleSpinBox,
imageInput: ImageInput,
imageList: ImageListInput,
audioList: AudioListInput,
seedance2Content: SeedanceContent,
};
/** 已知的 widget 类型字面量联合 */
export type WidgetType = keyof typeof WIDGET_MAP;
/** 未知控件类型的兜底组件(渲染为普通 Input */
export const FALLBACK_WIDGET: ComponentType<WidgetProps> = LineInput;

View File

@@ -0,0 +1,19 @@
// ============================================================
// useUI/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
// ============================================================
/**
* 所有 widget 组件的统一 Props非泛型
*
* 不设类型参数的原因WIDGET_MAP 需要统一存储不同值类型的组件,
* 带泛型的 WidgetProps<string> 和 WidgetProps<number> 因函数参数逆变
* 而互不兼容ComponentType 协变检查失败),改为统一 unknown 接口,
* 各组件内部通过类型断言收窄到已知类型。
*/
export interface WidgetProps {
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
/** 从 param_schema 提取的控件专属配置placeholder / options / min / max 等) */
config: Record<string, unknown>;
}

View 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;
};
}

View File

@@ -0,0 +1,189 @@
// ============================================================
// ChangePasswordModal — 修改密码弹窗(已登录用户)
//
// 场景:已登录用户通过旧密码验证后设置新密码
// 复用:可在设置页"修改密码"入口使用,也可在其他位置独立调用
//
// API
// - AuthAPI.changePassword({ current_password, new_password })
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
const { Text, Title } = Typography;
interface ChangePasswordModalProps {
open: boolean;
onClose: () => void;
}
export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps) {
const { isDark } = useTheme();
const { message } = App.useApp();
// ---- 表单字段 ----
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// ---- 错误信息 ----
const [errors, setErrors] = useState<Record<string, string>>({});
// ---- 提交状态 ----
const [submitting, setSubmitting] = useState(false);
// ---- 清除单个字段错误 ----
const clearError = useCallback((name: string) => {
setErrors((p) => {
if (!(name in p)) return p;
const n = { ...p };
delete n[name];
return n;
});
}, []);
// ---- 提交 ----
const handleSubmit = useCallback(async () => {
if (runFieldChecks(setErrors,
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
{ field: 'newPassword', error: validatePassword(newPassword) },
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
)) return;
setSubmitting(true);
try {
await AuthAPI.changePassword({
current_password: currentPassword,
new_password: newPassword,
});
message.success('密码修改成功');
// 重置表单
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
onClose();
} catch (err) {
message.error((err as Error).message || '密码修改失败');
} finally {
setSubmitting(false);
}
}, [currentPassword, newPassword, confirmPassword, onClose, message]);
// ---- 关闭时重置错误 ----
const handleClose = useCallback(() => {
setErrors({});
onClose();
}, [onClose]);
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={440}
centered
destroyOnHidden={true}
mask={{ closable: false }}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Title level={5} className="!mb-1 gradient-primary-text">
</Title>
<Text type="secondary" className="text-xs">
</Text>
</div>
{/* 表单 */}
<div className="space-y-4">
{/* 当前密码 */}
<div>
<Input.Password
size="large"
prefix={<SafetyOutlined className="opacity-40" />}
placeholder="当前密码"
value={currentPassword}
onChange={(e) => {
setCurrentPassword(e.target.value);
clearError('currentPassword');
}}
status={errors.currentPassword ? 'error' : undefined}
allowClear
/>
{errors.currentPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.currentPassword}
</Text>
)}
</div>
{/* 新密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="新密码6~128位"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
clearError('newPassword');
}}
status={errors.newPassword ? 'error' : undefined}
allowClear
/>
{errors.newPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.newPassword}
</Text>
)}
</div>
{/* 确认新密码 */}
<div>
<Input.Password
size="large"
prefix={<KeyOutlined className="opacity-40" />}
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
clearError('confirmPassword');
}}
status={errors.confirmPassword ? 'error' : undefined}
allowClear
/>
{errors.confirmPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.confirmPassword}
</Text>
)}
</div>
{/* 提交按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,255 @@
// ============================================================
// ForgotPasswordModal — 忘记密码弹窗
//
// 场景:未登录用户通过手机号 + 短信验证码重置密码
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
//
// API
// - AuthAPI.sendSms({ phone }) 发送短信验证码
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
// ============================================================
import { useState, useCallback } from 'react';
import { Modal, Input, Button, Typography, App } from 'antd';
import {
PhoneOutlined,
SafetyCertificateOutlined,
LockOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/components/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth';
import {
validatePhone,
validateSmsCode,
validatePassword,
validateConfirmPassword,
runFieldChecks,
} from '../config/validators';
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
const { Text, Title } = Typography;
interface ForgotPasswordModalProps {
open: boolean;
onClose: () => void;
}
export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps) {
const { isDark } = useTheme();
const { message } = App.useApp();
// ---- 表单字段 ----
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
// ---- 错误信息 ----
const [errors, setErrors] = useState<Record<string, string>>({});
// ---- 提交状态 ----
const [submitting, setSubmitting] = useState(false);
// ---- 短信冷却 ----
const { cooldown, secondsLeft, startCooldown } = useSmsCooldown(60);
// ---- 清除单个字段错误 ----
const clearError = useCallback((name: string) => {
setErrors((p) => {
if (!(name in p)) return p;
const n = { ...p };
delete n[name];
return n;
});
}, []);
// ---- 发送验证码 ----
const handleSendSms = useCallback(() => {
const err = validatePhone(phone);
if (err) {
setErrors((p) => ({ ...p, phone: err }));
return;
}
clearError('phone');
startCooldown();
AuthAPI.sendSms({ phone })
.then(() => message.success('验证码已发送'))
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
}, [phone, startCooldown, clearError, message]);
// ---- 提交重置 ----
const handleSubmit = useCallback(async () => {
if (runFieldChecks(setErrors,
{ field: 'phone', error: validatePhone(phone) },
{ field: 'smsCode', error: validateSmsCode(smsCode) },
{ field: 'newPassword', error: validatePassword(newPassword) },
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
)) return;
setSubmitting(true);
try {
await AuthAPI.resetPassword({
phone,
sms_code: smsCode,
new_password: newPassword,
});
message.success('密码重置成功,请使用新密码登录');
// 重置表单状态
setPhone('');
setSmsCode('');
setNewPassword('');
setConfirmPassword('');
onClose();
} catch (err) {
message.error((err as Error).message || '密码重置失败');
} finally {
setSubmitting(false);
}
}, [phone, smsCode, newPassword, confirmPassword, onClose, message]);
// ---- 关闭时重置 ----
const handleClose = useCallback(() => {
setErrors({});
onClose();
}, [onClose]);
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={440}
centered
destroyOnHidden={true}
mask={{ closable: false }}
styles={{
body: {
padding: '24px 32px 16px',
background: isDark ? '#1A1730' : '#FFFFFF',
},
}}
>
{/* 标题 */}
<div className="text-center mb-5">
<Title level={5} className="!mb-1 gradient-primary-text">
</Title>
<Text type="secondary" className="text-xs">
</Text>
</div>
{/* 表单 */}
<div className="space-y-4">
{/* 手机号 */}
<div>
<Input
size="large"
prefix={<PhoneOutlined className="opacity-40" />}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
setPhone(e.target.value);
clearError('phone');
}}
status={errors.phone ? 'error' : undefined}
allowClear
/>
{errors.phone && (
<Text type="danger" className="text-xs ml-1">
{errors.phone}
</Text>
)}
</div>
{/* 短信验证码 */}
<div>
<div className="flex gap-2">
<Input
size="large"
prefix={<SafetyCertificateOutlined className="opacity-40" />}
placeholder="请输入验证码"
value={smsCode}
onChange={(e) => {
setSmsCode(e.target.value);
clearError('smsCode');
}}
status={errors.smsCode ? 'error' : undefined}
className="flex-1"
allowClear
/>
<Button
size="large"
type="primary"
disabled={cooldown}
onClick={handleSendSms}
className="text-xs! shrink-0 px-3!"
>
{cooldown ? `${secondsLeft}s` : '发送验证码'}
</Button>
</div>
{errors.smsCode && (
<Text type="danger" className="text-xs ml-1">
{errors.smsCode}
</Text>
)}
</div>
{/* 新密码 */}
<div>
<Input.Password
size="large"
prefix={<LockOutlined className="opacity-40" />}
placeholder="新密码6~128位"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
clearError('newPassword');
}}
status={errors.newPassword ? 'error' : undefined}
allowClear
/>
{errors.newPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.newPassword}
</Text>
)}
</div>
{/* 确认新密码 */}
<div>
<Input.Password
size="large"
prefix={<KeyOutlined className="opacity-40" />}
placeholder="请再次输入新密码"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
clearError('confirmPassword');
}}
status={errors.confirmPassword ? 'error' : undefined}
allowClear
/>
{errors.confirmPassword && (
<Text type="danger" className="text-xs ml-1">
{errors.confirmPassword}
</Text>
)}
</div>
{/* 提交按钮 */}
<Button
type="primary"
block
size="large"
loading={submitting}
onClick={handleSubmit}
className="h-11! text-base! font-medium! mt-2!"
>
</Button>
</div>
</Modal>
);
}

View File

@@ -4,11 +4,11 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields'; import { loginFields } from '../config/login-fields';
import { validateForm } from '../config/validators'; import { validateForm } from '../config/validators';
import { LegalTextModal } from '@/components/common/LegalTextModal'; import { LegalTextModal } from '@/components/ui/LegalTextModal';
import type { LoginFormValues } from '../types'; import type { LoginFormValues } from '../types';
const { Text } = Typography; const { Text } = Typography;
@@ -22,6 +22,7 @@ interface LoginFormProps {
onSubmit: (values: LoginFormValues) => void; onSubmit: (values: LoginFormValues) => void;
onRememberChange: (checked: boolean) => void; onRememberChange: (checked: boolean) => void;
onAutoLoginChange: (checked: boolean) => void; onAutoLoginChange: (checked: boolean) => void;
onForgotPassword?: () => void;
} }
export function LoginForm({ export function LoginForm({
@@ -33,12 +34,13 @@ export function LoginForm({
onSubmit, onSubmit,
onRememberChange, onRememberChange,
onAutoLoginChange, onAutoLoginChange,
onForgotPassword,
}: LoginFormProps) { }: LoginFormProps) {
const [username, setUsername] = useState(initialUsername || ''); const [username, setUsername] = useState(initialUsername || '');
const [password, setPassword] = useState(initialPassword || ''); const [password, setPassword] = useState(initialPassword || '');
const [remember, setRemember] = useState(initialRemember); const [remember, setRemember] = useState(initialRemember);
const [autoLogin, setAutoLogin] = useState(initialAutoLogin); const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(true);
const [errors, setErrors] = useState<Record<string, string[]>>({}); const [errors, setErrors] = useState<Record<string, string[]>>({});
// 协议弹窗 // 协议弹窗
@@ -101,6 +103,8 @@ export function LoginForm({
setPassword(e.target.value); setPassword(e.target.value);
clearError('password'); clearError('password');
}} }}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
onPressEnter={handleSubmit}
status={errors.password ? 'error' : undefined} status={errors.password ? 'error' : undefined}
allowClear allowClear
/> />
@@ -147,12 +151,7 @@ export function LoginForm({
</Checkbox> </Checkbox>
</div> </div>
<a <a className="text-xs" onClick={() => onForgotPassword?.()}>
className="text-xs"
onClick={() => {
/* TODO: 忘记密码流程 */
}}
>
? ?
</a> </a>
</div> </div>
@@ -165,6 +164,11 @@ export function LoginForm({
setAgreed(e.target.checked); setAgreed(e.target.checked);
clearError('_agreement'); clearError('_agreement');
}} }}
onKeyDown={(event)=>{
if (event.key === 'Enter' || event.keyCode === 13) {
setAgreed(!agreed);
}
}}
className="text-xs" className="text-xs"
> >

View File

@@ -2,10 +2,10 @@
// RegisterForm — 注册表单(仅个人版) // RegisterForm — 注册表单(仅个人版)
// ============================================================ // ============================================================
import { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/common/LegalTextModal'; import { LegalTextModal } from '@/components/ui/LegalTextModal';
import { import {
UserOutlined, UserOutlined,
LockOutlined, LockOutlined,
@@ -87,17 +87,16 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
onSendSms?.(phone); onSendSms?.(phone);
}, [values.phone, onSendSms]); }, [values.phone, onSendSms]);
// 跨字段校验
const crossValidators = {
confirmPassword: (val: string) => {
if (!val) return '请再次输入密码';
if (val !== values.password) return '两次密码不一致';
return null;
},
};
// 提交 // 提交
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
const crossValidators = {
confirmPassword: (val: string) => {
if (!val) return '请再次输入密码';
if (val !== values.password) return '两次密码不一致';
return null;
},
};
const fieldErrors = validateForm(values, registerFields, crossValidators); const fieldErrors = validateForm(values, registerFields, crossValidators);
if (Object.keys(fieldErrors).length > 0) { if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors); setErrors(fieldErrors);
@@ -208,6 +207,11 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
return n; return n;
}); });
}} }}
onKeyDown={(e)=>{
if(e.key==="Enter" || e.keyCode == 13) {
setAgreed(!agreed);
}
}}
className="text-xs" className="text-xs"
> >

View File

@@ -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[] },空对象表示全部通过

View File

@@ -1,76 +1,34 @@
// ============================================================ // ============================================================
// useAuthState — 记住账号 / 自动登录 状态管理 // useAuthState — 记住账号 / 自动登录 表单状态 Hook
// //
// "记住密码":下次打开页面自动回填用户名(不再存储密码) // "记住密码":下次打开页面自动回填用户名(不再存储密码)
// "自动登录"登录成功时标记AppProvider 启动时用 refresh_token 静默续期 // "自动登录"登录成功时标记AppProvider 启动时用 refresh_token 静默续期
// //
// 持久化逻辑已提取到 services/auth-persistence.ts
// 此处仅负责 React 组件状态useState + useCallback
//
// 安全: // 安全:
// - 密码从不持久化存储access_token / refresh_token 由 safeStorage 加密保护) // - 密码从不持久化存储access_token / refresh_token 由 safeStorage 加密保护)
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障 // - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import {
saveAuth,
clearSavedAuth,
getSavedUsername,
getSavedRemember,
getSavedAutoLogin,
} from '@/services/auth-persistence';
const STORAGE_KEY = 'ele-heixiu-auth'; export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
interface StoredAuth {
/** 上次登录的用户名(表单回填用) */
username: string;
/** 是否记住用户名(勾选后表单自动回填用户名) */
remember: boolean;
/** 是否勾选了自动登录AppProvider 据此决定启动时是否刷新 token */
autoLogin: boolean;
}
function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
function writeAuth(auth: StoredAuth): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* 静默 */
}
}
// ---------- 工具函数(不依赖 ReactAppProvider 可直接调用)----------
/** 读取自动登录标记 */
export function getAutoLoginFlag(): boolean {
return readAuth()?.autoLogin || false;
}
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
export function clearAutoLoginFlag(): void {
const auth = readAuth();
if (!auth) return;
writeAuth({ ...auth, autoLogin: false });
}
/** 清除所有保存的认证信息 */
export function clearSavedAuth(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 静默 */
}
}
// ---------- Hook ---------- // ---------- Hook ----------
export function useAuthState() { export function useAuthState() {
const saved = readAuth(); const [username, setUsername] = useState(getSavedUsername);
const [remember, setRemember] = useState(getSavedRemember);
const [username, setUsername] = useState(saved?.username || ''); const [autoLogin, setAutoLogin] = useState(getSavedAutoLogin);
const [remember, setRemember] = useState(saved?.remember || false);
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
const handleRememberChange = useCallback((checked: boolean) => { const handleRememberChange = useCallback((checked: boolean) => {
setRemember(checked); setRemember(checked);
@@ -93,16 +51,11 @@ export function useAuthState() {
* @param doRemember - 是否勾选"记住用户名" * @param doRemember - 是否勾选"记住用户名"
* @param doAutoLogin - 是否勾选"自动登录" * @param doAutoLogin - 是否勾选"自动登录"
*/ */
const saveAuth = useCallback( const handleSaveAuth = useCallback(
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => { (user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
setUsername(user); setUsername(user);
if (doRemember || doAutoLogin) { if (doRemember || doAutoLogin) {
const payload: StoredAuth = { saveAuth(user, doRemember, doAutoLogin);
username: user,
remember: doRemember,
autoLogin: doAutoLogin,
};
writeAuth(payload);
} }
}, },
[], [],
@@ -115,6 +68,6 @@ export function useAuthState() {
setUsername, setUsername,
handleRememberChange, handleRememberChange,
handleAutoLoginChange, handleAutoLoginChange,
saveAuth, saveAuth: handleSaveAuth,
}; };
} }

View File

@@ -0,0 +1,57 @@
// ============================================================
// useSmsCooldown — 短信验证码发送冷却 Hook
//
// 从 RegisterForm 提取,供所有需要短信验证码的场景复用:
// - 注册RegisterForm
// - 忘记密码ForgotPasswordModal
// ============================================================
import { useState, useCallback, useRef, useEffect } from 'react';
export interface SmsCooldownState {
/** 是否在冷却中 */
cooldown: boolean;
/** 冷却剩余秒数 */
secondsLeft: number;
/** 开始冷却 */
startCooldown: () => void;
}
export function useSmsCooldown(cooldownSeconds: number = 60): SmsCooldownState {
const [cooldown, setCooldown] = useState(false);
const [secondsLeft, setSecondsLeft] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startCooldown = useCallback(() => {
// 清理之前的计时器(防止重复点击导致多个计时器叠加)
if (timerRef.current) {
clearInterval(timerRef.current);
}
setCooldown(true);
setSecondsLeft(cooldownSeconds);
timerRef.current = setInterval(() => {
setSecondsLeft((prev) => {
if (prev <= 1) {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setCooldown(false);
return 0;
}
return prev - 1;
});
}, 1000);
}, [cooldownSeconds]);
// 组件卸载时清理计时器
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
}, []);
return { cooldown, secondsLeft, startCooldown };
}

View File

@@ -10,12 +10,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Modal, Tabs, Typography, App } from 'antd'; import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/contexts/app-context.ts'; import { useAppContext } from '@/components/AppProvider';
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import { LoginForm } from './components/LoginForm'; import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm'; import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from './hooks/use-auth-state'; import { useAuthState } from './hooks/use-auth-state';
import {AuthAPI, LoginResponseBody} from '@/services/modules'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import { getDeviceId } from '@/utils/device'; import { getDeviceId } from '@/utils/device';
import type { LoginFormValues, RegisterFormValues } from './types'; import type { LoginFormValues, RegisterFormValues } from './types';
@@ -27,13 +28,13 @@ interface LoginPageProps {
} }
export function LoginPage({ open, onClose }: LoginPageProps) { export function LoginPage({ open, onClose }: LoginPageProps) {
const { edition, login } = useAppContext(); const { login } = useAppContext();
const { isDark } = useTheme(); const { isDark } = useTheme();
const { message } = App.useApp(); const { message } = App.useApp();
const isEnterprise = edition === 'enterprise';
const [activeTab, setActiveTab] = useState<string>('login'); const [activeTab, setActiveTab] = useState<string>('login');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [forgotOpen, setForgotOpen] = useState(false);
const authState = useAuthState(); const authState = useAuthState();
@@ -43,7 +44,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
async (values: LoginFormValues) => { async (values: LoginFormValues) => {
setSubmitting(true); setSubmitting(true);
try { try {
const auth:LoginResponseBody = await AuthAPI.login({ const auth: LoginResponseBody = await AuthAPI.login({
username: values.username, username: values.username,
password: values.password, password: values.password,
device_id: getDeviceId(), device_id: getDeviceId(),
@@ -111,13 +112,13 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
const loginTab = ( const loginTab = (
<LoginForm <LoginForm
initialUsername={authState.username} initialUsername={authState.username}
initialRemember={authState.remember} initialRemember={authState.remember}
initialAutoLogin={authState.autoLogin} initialAutoLogin={authState.autoLogin}
submitting={submitting} submitting={submitting}
onSubmit={handleLogin} onSubmit={handleLogin}
onRememberChange={authState.handleRememberChange} onRememberChange={authState.handleRememberChange}
onAutoLoginChange={authState.handleAutoLoginChange} onAutoLoginChange={authState.handleAutoLoginChange}
onForgotPassword={() => setForgotOpen(true)}
/> />
); );
@@ -126,37 +127,34 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
); );
return ( return (
<Modal <>
open={open} <Modal
onCancel={onClose} open={open}
footer={null} onCancel={onClose}
width={440} footer={null}
centered width={440}
destroyOnHidden={false} centered
mask={{ destroyOnHidden={false}
closable: false, mask={{
}} closable: false,
styles={{ }}
body: { styles={{
padding: '24px 32px 16px', body: {
background: isDark ? '#1A1730' : '#FFFFFF', padding: '24px 32px 16px',
}, background: isDark ? '#1A1730' : '#FFFFFF',
}} },
> }}
{/* 标题 */} >
<div className="text-center mb-5"> {/* 标题 */}
<Text strong className="text-lg gradient-primary-text block"> <div className="text-center mb-5">
· HeiXiu <Text strong className="text-lg gradient-primary-text block">
</Text> · HeiXiu
<Text type="secondary" className="text-xs"> </Text>
{isEnterprise ? '企业版 — 员工账号登录' : '个人版'} <Text type="secondary" className="text-xs">
</Text> 使·HeiXiu
</div> </Text>
</div>
{/* 个人版 Tab | 企业版仅登录 */}
{isEnterprise ? (
loginTab
) : (
<Tabs <Tabs
activeKey={activeTab} activeKey={activeTab}
onChange={setActiveTab} onChange={setActiveTab}
@@ -167,7 +165,10 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
{ key: 'register', label: '注册', children: registerTab }, { key: 'register', label: '注册', children: registerTab },
]} ]}
/> />
)} </Modal>
</Modal>
{/* 忘记密码弹窗 */}
<ForgotPasswordModal open={forgotOpen} onClose={() => setForgotOpen(false)} />
</>
); );
} }

View File

@@ -1,55 +1,72 @@
// ============================================================ // ============================================================
// SettingsPage — 设置页面(路由命中 /settings 时以居中 Modal 叠加展示 // SettingsPage — 设置页面(Modal 居中叠加,事件驱动显隐
// //
// 核心设计: // 核心设计:
// - 独立于 <Routes> 之外,通过 useLocation 判断是否渲染 // - 通过 open/onClose props 控制显隐,不使用路由(避免主页面被卸载)
// - 命中 /settings → Modal 居中弹出,首页内容保持在背景不动 // - Modal 居中弹出 + 遮罩层,首页内容始终保持挂载不动
// - 关闭 Modal → navigate(-1),仅可通过 X 按钮关闭 // - destroyOnHidden → 关闭即销毁 DOM不堆内存
// - destroyOnClose → 关闭即销毁 DOM不堆内存 // - Esc 键可关闭(跨平台统一)
// - macOS关闭按钮移至标题左侧遵循 macOS HIG
//
// 对应 QT 原版:设置窗口为独立窗口叠加在主窗口之上,主窗口不销毁
// ============================================================ // ============================================================
import {Modal} from 'antd'; import { useCallback } from 'react';
import {SettingOutlined} from '@ant-design/icons'; import { Modal } from 'antd';
import {useLocation, useNavigate} from 'react-router-dom'; import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
import {useAppContext} from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import {ThemeSetting} from './ThemeSetting'; import { isMacOS } from '@/utils/platform';
import {StoragePathSetting} from './StoragePathSetting'; import { ThemeSetting } from './blocks/ThemeSetting';
import {SystemInfoSetting} from './SystemInfoSetting'; import { StoragePathSetting } from './blocks/StoragePathSetting';
import {UpdateSetting} from './UpdateSetting'; import { SystemInfoSetting } from './blocks/SystemInfoSetting';
import { UpdateSetting } from './blocks/UpdateSetting';
import { PasswordSetting } from './blocks/PasswordSetting';
export function SettingsPage() { interface SettingsPageProps {
const location = useLocation(); open: boolean;
const navigate = useNavigate(); onClose: () => void;
const {edition} = useAppContext(); }
const open = location.pathname === '/settings';
return ( export function SettingsPage({ open, onClose }: SettingsPageProps) {
<Modal const { edition } = useAppContext();
open={open} const isMac = isMacOS();
width={480}
onCancel={() => navigate(-1)} // 关闭后移除焦点,避免跑到导航栏设置按钮上
destroyOnHidden={true} const handleAfterClose = useCallback(() => {
mask={ (document.activeElement as HTMLElement)?.blur();
{ }, []);
closable: false
} return (
} <Modal
keyboard={false} open={open}
footer={null} width={480}
title={ onCancel={onClose}
<span className="flex items-center gap-2"> afterClose={handleAfterClose}
destroyOnHidden={true}
mask={{ closable: false }}
closable={!isMac}
footer={null}
title={
<span className="flex items-center gap-2">
{isMac && (
<CloseOutlined
onClick={onClose}
className="cursor-pointer text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
/>
)}
<SettingOutlined /> <SettingOutlined />
</span> </span>
} }
styles={{body: {padding: 0, maxHeight: '70vh', overflow: 'auto'}}} styles={{ body: { padding: 0, maxHeight: '70vh', overflow: 'auto' } }}
> >
<div className="flex flex-col gap-4 p-4"> <div className="flex flex-col gap-4 p-4">
<ThemeSetting /> <ThemeSetting />
<StoragePathSetting edition={edition} /> <SystemInfoSetting />
<SystemInfoSetting /> <UpdateSetting />
<UpdateSetting /> <StoragePathSetting edition={edition} />
</div> <PasswordSetting />
</Modal> </div>
); </Modal>
);
} }

View File

@@ -0,0 +1,52 @@
// ============================================================
// PasswordSetting — 账号安全区块(密码操作入口)
//
// 提供两种密码操作,用户自选:
// 1. 修改密码 — 通过当前密码验证后设置新密码
// 2. 忘记密码 — 通过手机短信验证码重置密码
// ============================================================
import { useState } from 'react';
import { Card, Button, Typography } from 'antd';
import { LockOutlined, PhoneOutlined } from '@ant-design/icons';
import { ChangePasswordModal } from '@/pages/login/components/ChangePasswordModal';
import { ForgotPasswordModal } from '@/pages/login/components/ForgotPasswordModal';
const { Text } = Typography;
export function PasswordSetting() {
const [changePwdOpen, setChangePwdOpen] = useState(false);
const [forgotPwdOpen, setForgotPwdOpen] = useState(false);
return (
<>
<Card size="small" title="账号安全" className="rounded-md!">
<div className="flex flex-col gap-3">
{/* 修改密码:通过旧密码 */}
<div className="flex items-center justify-between">
<Text type="secondary" className="text-sm">
</Text>
<Button icon={<LockOutlined />} onClick={() => setChangePwdOpen(true)}>
</Button>
</div>
{/* 忘记密码:通过手机验证码重置 */}
<div className="flex items-center justify-between">
<Text type="secondary" className="text-sm">
</Text>
<Button icon={<PhoneOutlined />} onClick={() => setForgotPwdOpen(true)}>
</Button>
</div>
</div>
</Card>
<ChangePasswordModal open={changePwdOpen} onClose={() => setChangePwdOpen(false)} />
<ForgotPasswordModal open={forgotPwdOpen} onClose={() => setForgotPwdOpen(false)} />
</>
);
}

View File

@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
import { Card, Button, Input, Typography, Space, App } from 'antd'; import { Card, Button, Input, Typography, Space, App } from 'antd';
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import type { Edition } from '@shared/types'; import type { Edition } from '@shared/types';
import { useSettings } from '@/contexts/settings-context'; import { useSettings } from '@/components/SettingsProvider';
import { hasIpc, safeIpcInvoke } from '@/utils/ipc'; import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
@@ -159,8 +159,14 @@ function PathRow({
} }
export function StoragePathSetting({ edition }: StoragePathSettingProps) { export function StoragePathSetting({ edition }: StoragePathSettingProps) {
const { outputPath, teamRepoPath, setOutputPath, setTeamRepoPath, clearOutputPath, clearTeamRepoPath } = const {
useSettings(); outputPath,
teamRepoPath,
setOutputPath,
setTeamRepoPath,
clearOutputPath,
clearTeamRepoPath,
} = useSettings();
const isEnterprise = edition === 'enterprise'; const isEnterprise = edition === 'enterprise';

View File

@@ -9,7 +9,7 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
EnvironmentOutlined, EnvironmentOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { getPlatformName } from '@/utils/platform'; import { getPlatformName } from '@/utils/platform';
import { APP_VERSION } from '@shared/constants/version'; import { APP_VERSION } from '@shared/constants/version';
import { getEditionName } from '@shared/constants/app'; import { getEditionName } from '@shared/constants/app';
@@ -46,14 +46,14 @@ export function SystemInfoSetting() {
]; ];
return ( return (
<Card size="small" title="系统信息" className="rounded-md!"> <Card size="small" title="系统信息(开发显示信息)" className="rounded-md!">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{infoItems.map((item) => ( {infoItems.map((item) => (
<div key={item.label} className="flex items-center gap-2"> <div key={item.label} className="flex items-center gap-2">
<span style={{ color: 'var(--color-primary, #4F46E5)', fontSize: 14 }}> <span style={{ color: 'var(--color-primary, #4F46E5)', fontSize: 14 }}>
{item.icon} {item.icon}
</span> </span>
<Text type="secondary" className="text-sm min-w-[64px]"> <Text type="secondary" className="text-sm min-w-16">
{item.label} {item.label}
</Text> </Text>
<Tag color={item.color}>{item.value}</Tag> <Tag color={item.color}>{item.value}</Tag>

View File

@@ -4,7 +4,7 @@
import { Card, Segmented, Typography } from 'antd'; import { Card, Segmented, Typography } from 'antd';
import { BulbOutlined, BulbFilled } from '@ant-design/icons'; import { BulbOutlined, BulbFilled } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -65,15 +65,8 @@ function renderChangelog(markdown: string) {
} }
export function UpdateSetting() { export function UpdateSetting() {
const { const { status, info, progress, error, checkForUpdates, downloadUpdate, installUpdate } =
status, useUpdater();
info,
progress,
error,
checkForUpdates,
downloadUpdate,
installUpdate,
} = useUpdater();
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
@@ -210,15 +203,15 @@ export function UpdateSetting() {
title={`更新详情 — v${info?.version || ''}`} title={`更新详情 — v${info?.version || ''}`}
open={detailOpen} open={detailOpen}
onCancel={() => setDetailOpen(false)} onCancel={() => setDetailOpen(false)}
footer={ footer={<Button onClick={() => setDetailOpen(false)}></Button>}
<Button onClick={() => setDetailOpen(false)}></Button>
}
width={520} width={520}
> >
<div className="max-h-96 overflow-y-auto py-2"> <div className="max-h-96 overflow-y-auto py-2">
{info?.releaseNotes {info?.releaseNotes ? (
? renderChangelog(info.releaseNotes) renderChangelog(info.releaseNotes)
: <Text type="secondary"></Text>} ) : (
<Text type="secondary"></Text>
)}
</div> </div>
</Modal> </Modal>
</Card> </Card>

View File

@@ -3,7 +3,3 @@
// ============================================================ // ============================================================
export { SettingsPage } from './SettingsPage'; export { SettingsPage } from './SettingsPage';
export { ThemeSetting } from './ThemeSetting';
export { StoragePathSetting } from './StoragePathSetting';
export { SystemInfoSetting } from './SystemInfoSetting';
export { UpdateSetting } from './UpdateSetting';

View File

@@ -1,26 +0,0 @@
// ============================================================
// AppRoutes — 应用路由配置(由 App.tsx 在 HashRouter 内使用)
//
// 核心设计:
// - SettingsPage 在 <Routes> 外部,路由命中时以 Drawer 叠加首页
// - 首页始终保持挂载,不会因导航而卸载
// ============================================================
import { Routes, Route } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { HomePage } from '@/pages/home/HomePage';
import { SettingsPage } from '@/pages/settings';
export function AppRoutes() {
return (
<>
<Routes>
<Route path="/*" element={<Layout />}>
<Route index element={<HomePage />} />
</Route>
</Routes>
{/* 独立于 Routes根据 URL /settings 自动显隐 Drawer */}
<SettingsPage />
</>
);
}

View File

@@ -0,0 +1,84 @@
// ============================================================
// auth-persistence — 本地认证偏好持久化(记住账号 / 自动登录)
//
// 职责:
// - 存储/读取登录表单偏好(用户名回填、记住密码、自动登录标记)
// - 纯 localStorage 操作,不依赖 React
// - 密码从不持久化(安全性由 JWT + safeStorage 保障)
//
// 与 auth-token.ts 的分工:
// auth-token.ts → JWT token 生命周期refresh/accesssafeStorage 加密)
// auth-persistence.ts → 登录表单偏好localStorage 明文,仅用户名/标记)
// ============================================================ */
const STORAGE_KEY = 'ele-heixiu-auth';
interface StoredAuth {
/** 上次登录的用户名(表单回填用) */
username: string;
/** 是否记住用户名(勾选后表单自动回填用户名) */
remember: boolean;
/** 是否勾选了自动登录AppProvider 据此决定启动时是否刷新 token */
autoLogin: boolean;
}
function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
function writeAuth(auth: StoredAuth): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* 静默 */
}
}
// ---------- 公共 API ----------
/** 读取自动登录标记AppProvider 启动时调用) */
export function getAutoLoginFlag(): boolean {
return readAuth()?.autoLogin || false;
}
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
export function clearAutoLoginFlag(): void {
const auth = readAuth();
if (!auth) return;
writeAuth({ ...auth, autoLogin: false });
}
/** 清除所有保存的认证偏好 */
export function clearSavedAuth(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 静默 */
}
}
/** 保存认证偏好(登录成功时调用) */
export function saveAuth(username: string, remember: boolean, autoLogin: boolean): void {
writeAuth({ username, remember, autoLogin });
}
/** 读取已保存的用户名(表单回填用) */
export function getSavedUsername(): string {
return readAuth()?.username || '';
}
/** 读取记住密码标记 */
export function getSavedRemember(): boolean {
return readAuth()?.remember || false;
}
/** 读取自动登录标记 */
export function getSavedAutoLogin(): boolean {
return readAuth()?.autoLogin || false;
}

View File

@@ -12,8 +12,7 @@
// ============================================================ // ============================================================
import { setToken, setTokenRefreshHandler } from './request'; import { setToken, setTokenRefreshHandler } from './request';
import { AuthAPI } from './modules'; import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
import type { RefreshTokenResponseBody } from './modules';
import { import {
getSafeStore, getSafeStore,
setSafeStore, setSafeStore,

51
src/services/cache/model-cache.ts vendored Normal file
View 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 内部更新
}
}

View File

@@ -8,7 +8,7 @@ import {post, get} from '../request';
const AuthUrlObj = { const AuthUrlObj = {
login: '/api/v1/auth/login', login: '/api/v1/auth/login',
register: '/api/v1/auth/register', register: '/api/v1/auth/register',
sendSms: '/api/v1/auth/sms/send', sendSms: '/api/v1/auth/send-sms',
refreshToken: '/api/v1/auth/refresh', refreshToken: '/api/v1/auth/refresh',
userInfo: '/api/v1/auth/me', userInfo: '/api/v1/auth/me',
changePassword: '/api/v1/auth/change-password', changePassword: '/api/v1/auth/change-password',
@@ -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 {
@@ -149,42 +177,42 @@ export interface UsePhoneResetPasswordRequestBody {
export class AuthAPI { export class AuthAPI {
/** 登录 */ /** 登录 */
static login(data: LoginRequestBody): Promise<LoginResponseBody> { static async login(data: LoginRequestBody): Promise<LoginResponseBody> {
return post<LoginResponseBody>(AuthUrlObj.login, data); return await post<LoginResponseBody>(AuthUrlObj.login, data);
} }
/** 注册 */ /** 注册 */
static register(data: RegisterRequestBody): Promise<RegisterResponseBody> { static async register(data: RegisterRequestBody): Promise<RegisterResponseBody> {
return post<RegisterResponseBody>(AuthUrlObj.register, data); return await post<RegisterResponseBody>(AuthUrlObj.register, data);
} }
/** 发送短信验证码 */ /** 发送短信验证码 */
static sendSms(data: SendSMSRequestBody): Promise<void> { static async sendSms(data: SendSMSRequestBody): Promise<void> {
return post<void>(AuthUrlObj.sendSms, data); return await post<void>(AuthUrlObj.sendSms, data);
} }
/** 刷新 Token */ /** 刷新 Token */
static refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> { static async refreshToken(data: RefreshTokenRequestBody): Promise<RefreshTokenResponseBody> {
return post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data); return await post<RefreshTokenResponseBody>(AuthUrlObj.refreshToken, data);
} }
/** 获取当前用户信息 */ /** 获取当前用户信息 */
static getUserInfo(): Promise<UserInfoBody> { static async getUserInfo(): Promise<UserInfoBody> {
return get<UserInfoBody>(AuthUrlObj.userInfo); return await get<UserInfoBody>(AuthUrlObj.userInfo);
} }
/** 修改密码(已登录) */ /** 修改密码(已登录) */
static changePassword(data: ChangePasswordRequestBody): Promise<void> { static async changePassword(data: ChangePasswordRequestBody): Promise<void> {
return post<void>(AuthUrlObj.changePassword, data); return await post<void>(AuthUrlObj.changePassword, data);
} }
/** 手机验证码重置密码 */ /** 手机验证码重置密码 */
static resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> { static async resetPassword(data: UsePhoneResetPasswordRequestBody): Promise<void> {
return post<void>(AuthUrlObj.resetPassword, data); return await post<void>(AuthUrlObj.resetPassword, data);
} }
/** 退出登录 */ /** 退出登录 */
static logout(): Promise<void> { static async logout(): Promise<void> {
return post<void>(AuthUrlObj.logout); return await post<void>(AuthUrlObj.logout);
} }
} }

View File

@@ -0,0 +1,43 @@
// ============================================================
// Banner 模块 — 首页轮播图 API
// GET /api/v1/banners
// ============================================================
import {get} from '../request';
// ---------- 路由常量 ----------
const BannerUrlObj = {
list: '/api/v1/creatives',
} as const;
// ---------- 类型 ----------
/** 单条 Banner */
export interface Banner {
id: number;
title: string;
content: "真人素材提示";
image_url: string;
link_url?: string;
category: "promotion" | "banner";
vip_level_id?: number;
sort_order: number;
is_active: boolean;
created_by: string;
created_at: Date;
updated_at: Date;
}
/** Banner 列表响应 */
export type BannerListResponse = Banner[];
// ---------- API ----------
/**
* 获取 Banner 轮播图列表
* GET /api/v1/banners
*/
export function fetchBanners(): Promise<BannerListResponse> {
return get<BannerListResponse>(BannerUrlObj.list);
}

View File

@@ -1,24 +0,0 @@
// ============================================================
// API 模块统一导出
// ============================================================
export {
fetchAnnouncements,
type Announcement,
type AnnouncementType,
type AnnouncementListResponse,
} from './announcement';
export {
AuthAPI,
type SendSMSRequestBody,
type RegisterRequestBody,
type RegisterResponseBody,
type LoginRequestBody,
type LoginResponseBody,
type RefreshTokenRequestBody,
type RefreshTokenResponseBody,
type UserInfoBody,
type ChangePasswordRequestBody,
type UsePhoneResetPasswordRequestBody,
} from './auth';

View File

@@ -0,0 +1,159 @@
import {get} from '../request';
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。
// ---------- 路由常量 ----------
const ModelsUrlObj = {
modelList: '/api/v1/models',
getModelInfo: '/api/v1/models/{model_id}',
estimateCost: '/api/v1/models/{model_id}/estimate'
} as const;
// ---------- 类型 ----------
/** 模型类别常量数组(单一数据源,类型由此推导) */
export const MODEL_CATEGORIES = [
'image_to_image',
'text_to_image',
'image_to_video',
'text_to_audio',
] as const;
export type ModelCategoryStringEnum = typeof MODEL_CATEGORIES[number];
/**
* 模型参数 Schema 项
*
* 后端 /api/v1/models 返回的 param_schema 为对象数组,
* 每项描述一个模型入参的元信息标识、字段映射、规格约束、UI 配置)。
*/
export interface ParamSchemaItem {
id: string;
/** 映射到提交参数中的字段名 */
map_to: string;
/** 规格约束(类型、范围、默认值等) */
spec: Record<string, unknown>;
/** UI 控件配置 */
ui: Record<string, unknown>;
}
/** 模型类别中文映射 */
export const MODEL_CATEGORY_LABELS: Record<ModelCategoryStringEnum, string> = {
image_to_image: '图生图',
text_to_image: '文生图',
image_to_video: '图生视频',
text_to_audio: '文生音频',
};
/**
* 前端 ModelCategory → 后端分类 slug 映射
*
* 后端分类 API 使用简写 slug如 img2img而非前端的长标识符。
* 查询模型列表时需将前端类别转为后端 slug 作为 category 参数。
*/
export const CATEGORY_SLUG_MAP: Record<ModelCategoryStringEnum, string> = {
image_to_image: 'img2img',
text_to_image: 'txt2img',
image_to_video: 'img2video',
text_to_audio: 'txt2audio',
};
/** 后端 slug → 前端 ModelCategory 反向映射(由 CATEGORY_SLUG_MAP 自动推导) */
const SLUG_TO_CATEGORY: Record<string, ModelCategoryStringEnum> = Object.fromEntries(
Object.entries(CATEGORY_SLUG_MAP).map(([cat, slug]) => [slug, cat as ModelCategoryStringEnum]),
) as Record<string, ModelCategoryStringEnum>;
/**
* 根据任意 category_name 值解析出最佳中文标签
*
* 解析优先级:
* 1. 匹配 MODEL_CATEGORY_LABELS前端 ModelCategory 字面量)
* 2. 匹配 SLUG_TO_CATEGORY 反向映射(后端 slug → 中英文标签)
* 3. 兜底返回原始字符串
*/
export function resolveCategoryLabel(categoryName: string): string {
// 优先按 ModelCategory 字面量查找
if (categoryName in MODEL_CATEGORY_LABELS) {
return MODEL_CATEGORY_LABELS[categoryName as ModelCategoryStringEnum];
}
// 按后端 slug 反向查找
const cat = SLUG_TO_CATEGORY[categoryName];
if (cat) {
return MODEL_CATEGORY_LABELS[cat];
}
// 兜底返回原始值
return categoryName;
}
/**
* 将前端 ModelCategory 转为后端 API 用的分类 slug
*/
export function toCategorySlug(category: ModelCategoryStringEnum): string {
return CATEGORY_SLUG_MAP[category];
}
/**
* 查询可用模型的请求参数
*/
export interface ModelsListReqeustParams {
provider_name?: string;
category?: ModelCategoryStringEnum;
page: number;
page_size: number;
}
/**
* 查询可用模型的响应体
*/
export interface ModelsListResponseBody {
id: string;
name: string;
provider_name: string;
model_type: ModelCategoryStringEnum;
category_name: ModelCategoryStringEnum | null;
status: string;
priority: number;
response_mode: string;
pricing_config: Record<string, unknown> | null;
param_schema: ParamSchemaItem[];
quota_config: Record<string, unknown> | null;
ui_config: Record<string, unknown>;
meta_config: Record<string, unknown>;
constraints_config: Record<string, unknown>;
expires_at: string;
created_at: string;
updated_at: string;
}
/**
* 模型信息请求参数
*/
export interface ModelInfoRequestParams {
model_id: string;
}
/** 模型详情响应体(与列表项结构一致) */
export type ModelInfoResponseBody = ModelsListResponseBody;
// ---------- API ----------
export class ModelAPI {
/**
* 获取可用模型列表
* GET /api/v1/models
*/
static fetchModels(params: ModelsListReqeustParams): Promise<ModelsListResponseBody[]> {
return get<ModelsListResponseBody[]>(ModelsUrlObj.modelList, params as unknown as Record<string, unknown>);
}
/**
* 获取单个模型详情
* GET /api/v1/models/:id
*/
static fetchModelInfo(modelId: string): Promise<ModelInfoResponseBody> {
const url = ModelsUrlObj.getModelInfo.replace('{model_id}', modelId);
return get<ModelInfoResponseBody>(url);
}
}

View File

@@ -0,0 +1,236 @@
// ============================================================
// 任务模块 — 任务记录 CRUD API
// GET /api/v1/tasks → 任务列表(分页/游标)
// POST /api/v1/tasks → 提交任务
// GET /api/v1/tasks/:id → 任务详情
// GET /api/v1/tasks/:id/status → 轻量轮询状态
// GET /api/v1/tasks/export → 导出 CSV
//
// TaskAPI 的静态方法直接返回 get/post 的 Promise<T>
// 不做额外的 async/await 包装,由调用方决定是否 await。
// ============================================================
import {get, post} from '../request';
import {getDeviceId} from '../../utils/device';
// ============================================================
// 基础枚举 / 字面量类型
// ============================================================
/** 输出类型映射 */
export enum OutputTypeMap {
image = '图片',
video = '视频',
audio = '音频',
}
export type OutputTypeString = keyof typeof OutputTypeMap;
/** 任务状态 */
export enum TaskStatusMap {
pending = '待处理',
submitted = '已提交',
processing = '处理中',
success = '成功',
failed = '失败',
}
export type TaskStatusString = keyof typeof TaskStatusMap;
// ============================================================
// 请求体 / 参数类型
// ============================================================
/** 任务列表请求参数 */
export interface TaskListRequestParams {
status?: TaskStatusString;
user_ids?: string[];
model_ids?: string[];
/** 游标分页:上次最后一条的 created_atISO 8601 */
cursor?: string;
/** 页码OFFSET 分页模式default: 1 */
page?: number;
/** 每页条数default: 20 */
page_size?: number;
}
/** 创建任务请求体 */
export interface CreatTaskRequestBody {
model_id: string;
params: Record<string, string>;
device_id: string;
tags?: string[];
}
/** 导出 CSV 参数 */
export interface ExportTaskCSVParams {
start_date: Date;
end_date: Date;
status?: TaskStatusString;
user_ids?: string[];
model_ids?: string[];
}
// ============================================================
// 内容项 — 带判别标签的联合类型
// ============================================================
interface BaseContentItem {
type: string;
}
interface TextContent extends BaseContentItem {
type: 'text';
text: string;
}
interface ImageUrlContent extends BaseContentItem {
type: 'image_url';
role: 'reference_image';
image_url: { url: string };
}
interface VideoUrlContent extends BaseContentItem {
type: 'video_url';
role: 'reference_video';
video_url: { url: string };
}
interface AudioContent extends BaseContentItem {
type: 'audio_url';
role: 'reference_audio';
audio_url: { url: string };
}
export type ContentItem =
| TextContent
| ImageUrlContent
| VideoUrlContent
| AudioContent;
// ============================================================
// 各输出类型的 UI 参数
// ============================================================
export interface ImageUiParams {
imageUrls?: string[];
prompt: string;
aspectRatio: string;
resolution?: string;
}
export interface VideoUiParams {
resolution?: string;
ratio?: string;
model?: string;
generate_audio: boolean;
duration: number;
content: ContentItem[];
}
export interface AudioUiParams {
format?: string;
sampleRate?: number;
bitrate?: number;
}
// ============================================================
// 响应体 — 用判别联合按 output_type 分发
// ============================================================
/** 任务条目基类(不含 output_type 特有字段) */
interface BaseTaskItemResponseBody {
id: string;
user_id: string;
model_id: string;
model_name: string;
provider_name: string;
status: TaskStatusString;
external_task_id: string;
cost_cent: number;
estimated_cost_cent: number;
error_code: string;
poll_attempts: number;
is_favorite: boolean;
submitted_at: Date;
finished_at: Date;
created_at: Date;
updated_at: Date;
prompt?: string;
output_assets?: Array<{ url: string }>;
tags: Array<string | null>;
error_message: string;
user_display_name: string | null;
}
/** 判别联合:根据 output_type 收窄 ui_params */
export type TaskInfoItemResponseBody =
| (BaseTaskItemResponseBody & { output_type: 'image'; ui_params: ImageUiParams })
| (BaseTaskItemResponseBody & { output_type: 'video'; ui_params: VideoUiParams })
| (BaseTaskItemResponseBody & { output_type: 'audio'; ui_params: AudioUiParams });
/** 任务列表分页响应 */
export interface TaskInfoDataResponseBody {
has_more: boolean;
next_cursor: string;
page: number;
page_size: number;
total: number;
items: TaskInfoItemResponseBody[];
}
/** 轻量轮询状态响应 */
export interface TaskStatusResponseBody {
task_id: string;
status: TaskStatusString;
progress: number;
output_assets?: string[];
error_code?: string;
error_message?: string;
cost_cent?: number;
}
// ============================================================
// 路由常量
// ============================================================
const TaskUrlObj = {
task: '/api/v1/tasks',
taskInfo: (task_id: string) => `/api/v1/tasks/${task_id}`,
taskStatus: (task_id: string) => `/api/v1/tasks/${task_id}/status`,
tasksCSV: '/api/v1/tasks/export',
} as const;
// ============================================================
// TaskAPI — 静态方法直接返回 Promise<T>,不做二次 async 包装
// ============================================================
export class TaskAPI {
/** 获取任务列表(分页/游标) */
static getTaskList(params: TaskListRequestParams): Promise<TaskInfoDataResponseBody> {
return get<TaskInfoDataResponseBody>(TaskUrlObj.task, params as Record<string, unknown>);
}
/** 提交新任务device_id 由 API 层自动注入 getDeviceId(),调用方无需传入) */
static submitTask(body: Omit<CreatTaskRequestBody, 'device_id'>): Promise<TaskInfoItemResponseBody> {
return post<TaskInfoItemResponseBody>(TaskUrlObj.task, {
...body,
device_id: getDeviceId(),
});
}
/** 获取任务详情 */
static getTaskInfo(task_id: string): Promise<TaskInfoItemResponseBody> {
return get<TaskInfoItemResponseBody>(TaskUrlObj.taskInfo(task_id));
}
/** 轻量轮询任务状态 */
static getTaskStatus(task_id: string): Promise<TaskStatusResponseBody> {
return get<TaskStatusResponseBody>(TaskUrlObj.taskStatus(task_id));
}
/** 导出任务 CSV */
static exportTaskCSV(params: ExportTaskCSVParams): Promise<File> {
return get<File>(TaskUrlObj.tasksCSV, params as unknown as Record<string, unknown>);
}
}

View 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}`);
}
}

View File

@@ -177,7 +177,7 @@ instance.interceptors.response.use(
// HTTP 200 但业务码非 0 → 业务错误 // HTTP 200 但业务码非 0 → 业务错误
if (data && data.code !== 0) { if (data && data.code !== 0) {
// 401 → 尝试刷新 token 并重试(refresh 接口自身 401 必须直接拒绝,否则死循环 // 业务码 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝)
if (data.code === 401 && response.config) { if (data.code === 401 && response.config) {
if (isRefreshRequest(response.config)) { if (isRefreshRequest(response.config)) {
return Promise.reject(new RefreshError()); return Promise.reject(new RefreshError());
@@ -246,8 +246,11 @@ instance.interceptors.response.use(
// HTTP 状态码错误 // HTTP 状态码错误
const { status, data } = error.response; const { status, data } = error.response;
// 401 → 尝试刷新 token 并重试refresh 接口自身 401 直接拒绝,防死循环) // 401 → 尝试刷新 token 并重试
if (status === 401 && error.config) { // ⚠️ 同时检查响应体中的业务码 data.code
// data.code === 401 → token 过期 → 走刷新流程
// data.code !== 401 → 其他认证错误(如密码错误)→ 透传后端 message
if (status === 401 && error.config && data?.code === 401) {
if (isRefreshRequest(error.config)) { if (isRefreshRequest(error.config)) {
const refreshErr = new RefreshError(); const refreshErr = new RefreshError();
logger.debug('request', 'Refresh endpoint returned 401', { logger.debug('request', 'Refresh endpoint returned 401', {

View File

@@ -115,6 +115,7 @@ const sharedComponents: ThemeConfig['components'] = {
}, },
Pagination: { Pagination: {
itemActiveBg: colorPrimary, itemActiveBg: colorPrimary,
itemActiveColor: '#fff',
colorPrimary, colorPrimary,
colorPrimaryHover: colorPrimaryLight, colorPrimaryHover: colorPrimaryLight,
}, },
@@ -277,24 +278,24 @@ export const darkTheme: ThemeConfig = {
defaultBorderColor: darkColorBorder, defaultBorderColor: darkColorBorder,
}, },
Menu: { Menu: {
itemSelectedBg: '#312E81', itemSelectedBg: '#1E2460',
itemSelectedColor: colorPrimaryLight, itemSelectedColor: colorPrimaryLight,
itemHoverBg: '#242040', itemHoverBg: '#151A48',
itemActiveBg: '#1E1B4B', itemActiveBg: '#0F1442',
}, },
Layout: { Layout: {
siderBg: `linear-gradient(180deg, #0B0A15 0%, #1A1730 100%)`, siderBg: `linear-gradient(180deg, #050818 0%, #0D1140 100%)`,
headerBg: darkColorBgContainer, headerBg: darkColorBgContainer,
bodyBg: darkColorBgLayout, bodyBg: darkColorBgLayout,
}, },
Table: { Table: {
headerBg: '#1E1B4B', headerBg: '#0F1340',
headerColor: darkColorTextBase, headerColor: darkColorTextBase,
rowHoverBg: '#242040', rowHoverBg: '#181D52',
borderColor: darkColorBorderSecondary, borderColor: darkColorBorderSecondary,
}, },
Tag: { Tag: {
defaultBg: '#1E1B4B', defaultBg: '#1A1F54',
defaultColor: colorPrimaryLight, defaultColor: colorPrimaryLight,
}, },
}, },

130
src/theme/base.css Normal file
View File

@@ -0,0 +1,130 @@
/* ============================================================
* 基础样式 — 重置、滚动条、聚焦环、选中文本、链接、拖拽区域
*
* 改全局基础样式 → 改这个文件。
* ============================================================ */
/* ============================================================
* 全局重置
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-family-base);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 亮/暗切换过渡 */
color-scheme: light dark;
}
body {
margin: 0;
padding: 0;
color: var(--color-text-base);
background-color: var(--color-bg-base);
min-height: 100vh;
}
#root {
min-height: 100vh;
}
/* ============================================================
* 滚动条美化
* ============================================================ */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c7d2fe;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a5b4fc;
}
/* 暗色模式滚动条 */
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #4338ca;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* ============================================================
* 聚焦环 — 使用品牌色
* ============================================================ */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* ============================================================
* 选中文本 — 使用品牌色
* ============================================================ */
::selection {
background: rgba(99, 102, 241, 0.2);
color: var(--color-text-base);
}
[data-theme='dark'] ::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--color-text-base);
}
/* ============================================================
* 链接全局样式
* ============================================================ */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-duration) var(--transition-timing);
}
a:hover {
color: var(--color-primary-light);
}
/* ============================================================
* 窗口拖拽区域(自定义导航栏)
*
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
* .navbar-drag-region → 可拖拽(背景区域)
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
* ============================================================ */
.navbar-drag-region {
-webkit-app-region: drag;
user-select: none;
}
.navbar-no-drag {
-webkit-app-region: no-drag;
}
/* macOS 红绿灯按钮区域不遮挡内容 */
@supports (-webkit-app-region: drag) {
.navbar-drag-region {
-webkit-app-region: drag;
}
}

View File

@@ -1,257 +1,14 @@
/* ============================================================ /* ============================================================
* 全局样式 — Tailwind CSS v4 指令 + 蓝紫渐变主题扩展 + 重置样式 * 全局样式入口 — Tailwind CSS v4 + 设计令牌 + 过渡 + 基础样式
* Tailwind v4 使用 CSS @theme 指令替代 tailwind.config.ts *
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。 * 拆分为 4 个文件,按职责定位:
* tokens.css → 改颜色 / 品牌色 / 亮暗变量
* transitions.css → 改主题切换过渡参数
* base.css → 改重置 / 滚动条 / 链接 / 拖拽区域
* globals.css → 本文件(入口,只负责引入顺序)
* ============================================================ */ * ============================================================ */
@import 'tailwindcss'; @import 'tailwindcss';
@import './tokens.css';
/* ============================================================ @import './transitions.css';
* Tailwind CSS v4 主题扩展(用 @theme 指令) @import './base.css';
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
* 例如colors.primary → bg-primary, text-primary, border-primary 等。
* ============================================================ */
/* 品牌色 — 蓝紫渐变 */
@theme {
/* 主色系 */
--color-primary: #6366f1; /* Indigo-500 */
--color-primary-light: #818cf8; /* Indigo-400 */
--color-primary-dark: #4f46e5; /* Indigo-600 */
--color-primary-50: #eef2ff; /* Indigo-50 */
--color-primary-100: #e0e7ff; /* Indigo-100 */
--color-primary-200: #c7d2fe; /* Indigo-200 */
--color-primary-300: #a5b4fc; /* Indigo-300 */
--color-primary-400: #818cf8; /* Indigo-400 */
--color-primary-500: #6366f1; /* Indigo-500 */
--color-primary-600: #4f46e5; /* Indigo-600 */
--color-primary-700: #4338ca; /* Indigo-700 */
--color-primary-800: #3730a3; /* Indigo-800 */
--color-primary-900: #312e81; /* Indigo-900 */
/* 辅助色系 — Violet */
--color-secondary: #8b5cf6; /* Violet-500 */
--color-secondary-light: #a78bfa; /* Violet-400 */
--color-secondary-dark: #7c3aed; /* Violet-600 */
/* 功能色 */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* 圆角 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-base: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 字体 */
--font-family-base:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-mono:
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
/* 动画 */
--transition-duration: 0.2s;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
* 🌞 亮色模式 CSS 变量(默认)
* 这些变量与 tokens.ts 中的亮色令牌同步
* ============================================================ */
:root,
[data-theme='light'] {
/* 中性色 — 亮色 */
--color-text-base: #1e1b4b;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-bg-base: #ffffff;
--color-bg-container: #f9fafb;
--color-bg-layout: #f5f3ff;
--color-bg-elevated: #ffffff;
--color-border-base: #e5e7eb;
--color-border-secondary: #f3f4f6;
/* 阴影 */
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ============================================================
* 🌙 暗色模式 CSS 变量
* 当 <html data-theme="dark"> 时自动切换
* 这些变量与 tokens.ts 中的暗色令牌同步
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色 */
--color-text-base: #e8e6f0;
--color-text-secondary: #a5a0c9;
--color-text-tertiary: #6e6998;
--color-bg-base: #0f0d1a;
--color-bg-container: #1a1730;
--color-bg-layout: #0b0a15;
--color-bg-elevated: #242040;
--color-border-base: #2e2a4a;
--color-border-secondary: #1f1c38;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;
--color-primary-light: #a5b4fc;
--color-primary-dark: #6366f1;
/* 阴影 — 暗色下用深投影 */
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
/* ============================================================
* 蓝紫渐变工具类
* Tailwind v4 使用 @utility 定义自定义工具类
* ============================================================ */
@utility gradient-primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
}
@utility gradient-primary-hover {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
@utility gradient-primary-text {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-border {
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
}
/* ============================================================
* 全局重置样式
* 仅放必要的重置,不堆积组件样式
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-family-base);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 亮/暗切换过渡 */
color-scheme: light dark;
}
body {
margin: 0;
padding: 0;
color: var(--color-text-base);
background-color: var(--color-bg-base);
min-height: 100vh;
transition:
background-color 0.3s ease,
color 0.3s ease;
}
#root {
min-height: 100vh;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c7d2fe;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a5b4fc;
}
/* 暗色模式滚动条 */
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #4338ca;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* 聚焦环 — 使用品牌色 */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 选中文本 — 使用品牌色 */
::selection {
background: rgba(99, 102, 241, 0.2);
color: var(--color-text-base);
}
[data-theme='dark'] ::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--color-text-base);
}
/* 链接全局样式 */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-duration) var(--transition-timing);
}
a:hover {
color: var(--color-primary-light);
}
/* ============================================================
* 窗口拖拽区域(自定义导航栏)
*
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
* .navbar-drag-region → 可拖拽(背景区域)
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
* ============================================================ */
.navbar-drag-region {
-webkit-app-region: drag;
user-select: none;
}
.navbar-no-drag {
-webkit-app-region: no-drag;
}
/* macOS 红绿灯按钮区域不遮挡内容 */
@supports (-webkit-app-region: drag) {
.navbar-drag-region {
-webkit-app-region: drag;
}
}

139
src/theme/tokens.css Normal file
View File

@@ -0,0 +1,139 @@
/* ============================================================
* 设计令牌 — Tailwind CSS v4 @theme 扩展 + 亮暗 CSS 变量
*
* 与 tokens.ts / antd-theme.ts 保持同步。
* 改颜色 → 改这个文件。
* ============================================================ */
/* ============================================================
* Tailwind CSS v4 主题扩展(@theme 指令)
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
* 例如colors.primary → bg-primary, text-primary, border-primary 等。
* ============================================================ */
/* 品牌色 — 蓝紫渐变 */
@theme {
/* 主色系 */
--color-primary: #6366f1; /* Indigo-500 */
--color-primary-light: #818cf8; /* Indigo-400 */
--color-primary-dark: #4f46e5; /* Indigo-600 */
--color-primary-50: #eef2ff; /* Indigo-50 */
--color-primary-100: #e0e7ff; /* Indigo-100 */
--color-primary-200: #c7d2fe; /* Indigo-200 */
--color-primary-300: #a5b4fc; /* Indigo-300 */
--color-primary-400: #818cf8; /* Indigo-400 */
--color-primary-500: #6366f1; /* Indigo-500 */
--color-primary-600: #4f46e5; /* Indigo-600 */
--color-primary-700: #4338ca; /* Indigo-700 */
--color-primary-800: #3730a3; /* Indigo-800 */
--color-primary-900: #312e81; /* Indigo-900 */
/* 辅助色系 — Violet */
--color-secondary: #8b5cf6; /* Violet-500 */
--color-secondary-light: #a78bfa; /* Violet-400 */
--color-secondary-dark: #7c3aed; /* Violet-600 */
/* 功能色 */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* 圆角 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-base: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 字体 */
--font-family-base:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-mono:
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
/* 动画 */
--transition-duration: 0.2s;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
* 🌞 亮色模式 CSS 变量(默认)
* 这些变量与 tokens.ts 中的亮色令牌同步
* ============================================================ */
:root,
[data-theme='light'] {
/* 中性色 — 亮色 */
--color-text-base: #1e1b4b;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-bg-base: #ffffff;
--color-bg-container: #f9fafb;
--color-bg-layout: #f5f3ff;
--color-bg-elevated: #ffffff;
--color-border-base: #e5e7eb;
--color-border-secondary: #f3f4f6;
/* 阴影 */
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ============================================================
* 🌙 暗色模式 CSS 变量
* 当 <html data-theme="dark"> 时自动切换
* 这些变量与 tokens.ts 中的暗色令牌同步
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色(科技蓝紫) */
--color-text-base: #e8e8fa;
--color-text-secondary: #a0a8d8;
--color-text-tertiary: #6e78b0;
--color-bg-base: #a0a8d8;
--color-bg-container: #0f1340;
--color-bg-layout: #a0a8d8;
--color-bg-elevated: #171b52;
--color-border-base: #2a3278;
--color-border-secondary: #1c2250;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;
--color-primary-light: #a5b4fc;
--color-primary-dark: #6366f1;
/* 阴影 — 暗色下用深投影 */
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
/* ============================================================
* 蓝紫渐变工具类
* Tailwind v4 使用 @utility 定义自定义工具类
* ============================================================ */
@utility gradient-primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
}
@utility gradient-primary-hover {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
@utility gradient-primary-text {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-border {
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
}

View File

@@ -8,28 +8,14 @@
// ---------- 品牌色(蓝紫渐变) ---------- // ---------- 品牌色(蓝紫渐变) ----------
/** 品牌主色 — Indigo-500 */
export const colorPrimary = '#6366F1'; export const colorPrimary = '#6366F1';
/** 品牌主色浅色变体 */
export const colorPrimaryLight = '#818CF8'; export const colorPrimaryLight = '#818CF8';
/** 品牌主色深色变体 */
export const colorPrimaryDark = '#4F46E5'; export const colorPrimaryDark = '#4F46E5';
/** 品牌辅助色 — Violet-500渐变另一端 */
export const colorSecondary = '#8B5CF6'; export const colorSecondary = '#8B5CF6';
/** 品牌渐变色起始值 */ export const gradientStart = '#4F46E5';
export const gradientStart = '#4F46E5'; // Indigo-600 export const gradientEnd = '#7C3AED';
/** 品牌渐变色结束值 */
export const gradientEnd = '#7C3AED'; // Violet-600
/** 品牌渐变色CSS 值,可直接用于 background */
export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`; export const gradientPrimary = `linear-gradient(135deg, ${gradientStart}, ${gradientEnd})`;
/** 品牌渐变悬停态 */
export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`; export const gradientPrimaryHover = `linear-gradient(135deg, ${colorPrimary}, ${colorSecondary})`;
// ---------- 功能色 ---------- // ---------- 功能色 ----------
@@ -41,41 +27,43 @@ export const colorInfo = '#3B82F6';
// ---------- 中性色(亮色模式) ---------- // ---------- 中性色(亮色模式) ----------
export const colorTextBase = '#1E1B4B'; // 深蓝紫黑 export const colorTextBase = '#1E1B4B';
export const colorTextSecondary = '#6B7280'; export const colorTextSecondary = '#6B7280';
export const colorTextTertiary = '#9CA3AF'; export const colorTextTertiary = '#9CA3AF';
export const colorBgBase = '#FFFFFF'; export const colorBgBase = '#FFFFFF';
export const colorBgContainer = '#F9FAFB'; export const colorBgContainer = '#F9FAFB';
export const colorBgElevated = '#FFFFFF'; export const colorBgElevated = '#FFFFFF';
export const colorBgLayout = '#F5F3FF'; // Violet-50 export const colorBgLayout = '#F5F3FF';
export const colorBgSpotlight = '#F3F4F6'; export const colorBgSpotlight = '#F3F4F6';
export const colorBorder = '#E5E7EB'; /** 亮色模式 — 面板边框(稍明显) */
export const colorBorderSecondary = '#F3F4F6'; export const colorBorder = '#E2E0F0';
/** 亮色模式 — 内部分隔线(可见但不强烈) */
export const colorBorderSecondary = '#EEECF6';
// ---------- 中性色(暗色模式) ---------- // ---------- 中性色(暗色模式 — 融入品牌蓝紫调 ----------
/** 暗色模式 — 主文字 */ /** 暗色模式 — 主文字 */
export const darkColorTextBase = '#E8E6F0'; export const darkColorTextBase = '#E8E8FA';
/** 暗色模式 — 次要文字 */ /** 暗色模式 — 次要文字 */
export const darkColorTextSecondary = '#A5A0C9'; export const darkColorTextSecondary = '#A0A8D8';
/** 暗色模式 — 辅助文字 */ /** 暗色模式 — 辅助文字 */
export const darkColorTextTertiary = '#6E6998'; export const darkColorTextTertiary = '#6E78B0';
/** 暗色模式 — 基础背景 */ /** 暗色模式 — 页面底色(深邃蓝黑) */
export const darkColorBgBase = '#0F0D1A'; // 深蓝紫黑 export const darkColorBgBase = '#080C24';
/** 暗色模式 — 容器背景 */ /** 暗色模式 — 卡片/面板背景(暗蓝紫) */
export const darkColorBgContainer = '#1A1730'; // 抬升一层 export const darkColorBgContainer = '#0F1340';
/** 暗色模式 — 悬浮背景 */ /** 暗色模式 — 悬浮背景(微亮蓝紫) */
export const darkColorBgElevated = '#242040'; // 再抬升一层 export const darkColorBgElevated = '#171B52';
/** 暗色模式 — 布局背景 */ /** 暗色模式 — 页面布局底层 */
export const darkColorBgLayout = '#0B0A15'; // 最深背景 export const darkColorBgLayout = '#050818';
/** 暗色模式 — 边框 */ /** 暗色模式 — 面板外框(蓝紫边框,可见但不刺眼) */
export const darkColorBorder = '#2E2A4A'; export const darkColorBorder = '#2A3278';
/** 暗色模式 — 次要边框 */ /** 暗色模式 — 内部分隔线(柔和蓝紫,常驻可见) */
export const darkColorBorderSecondary = '#1F1C38'; export const darkColorBorderSecondary = '#1C2250';
// ---------- 圆角 ---------- // ---------- 圆角 ----------

126
src/theme/transitions.css Normal file
View File

@@ -0,0 +1,126 @@
/* ============================================================
* 主题切换过渡 — View Transitions API + antd 容器精准覆盖
*
* 策略:
* Chromium/ElectronView Transitions API GPU cross-fade1 个动画)
* 其他浏览器CSS transition 精准覆盖 ~80 个 antd 容器类
*
* 改过渡参数 → 改这个文件。
* ============================================================ */
/* ============================================================
* View Transitions API — GPU 合成器驱动的主题切换动画
* 替代 80+ 个独立 CSS 过渡,单次 cross-fade 消除 Electron 卡顿。
* 仅在 Chromium 111+Electron 28+)生效,其他浏览器回退 CSS 过渡。
* ============================================================ */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.15s;
animation-timing-function: linear;
}
/* ============================================================
* 主题切换过渡 — 精准覆盖大面积视觉容器,避免 * 全局选择器
* 在 Electron 中为数千个 DOM 节点同时启动过渡造成卡顿。
*
* 策略:
* 1. 容器/布局组件Card / Modal / Layout / Drawer 等)
* 2. 表格 & 列表Table / List / Timeline
* 3. 表单控件Input / Select / Picker
* 4. 导航 & 标签Menu / Tabs / Tag / Breadcrumb
* 5. 反馈 & 展示Alert / Empty / Result / Statistic / Skeleton
*
* 不覆盖交互组件自有 transitionButton / Switch / Slider 等
* 均使用 transition: all特异性更高不受影响
* ============================================================ */
/* —— 布局 & 容器 —— */
body,
.ant-layout,
.ant-layout-header,
.ant-layout-sider,
.ant-layout-content,
.ant-card,
.ant-card-head,
.ant-card-body,
.ant-card-actions,
.ant-modal-content,
.ant-modal-header,
.ant-modal-body,
.ant-modal-footer,
.ant-drawer-content,
.ant-drawer-header,
.ant-drawer-body,
.ant-drawer-footer,
/* —— 表格 & 列表 —— */
.ant-table,
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td,
.ant-table-tbody > tr:hover > td,
.ant-list,
.ant-list-item,
.ant-timeline-item,
.ant-transfer-list,
.ant-tree-node-content-wrapper,
/* —— 表单控件 —— */
.ant-input,
.ant-input-affix-wrapper,
.ant-select-selector,
.ant-select-dropdown,
.ant-picker,
.ant-picker-input,
.ant-picker-dropdown,
.ant-picker-panel-container,
.ant-input-number,
.ant-input-number-input,
.ant-radio-button-wrapper,
.ant-radio-group,
.ant-checkbox-wrapper,
.ant-segmented,
.ant-segmented-item,
.ant-upload-drag,
.ant-upload-list-item,
/* —— 导航 & 标签 —— */
.ant-menu,
.ant-menu-item,
.ant-menu-submenu-title,
.ant-tabs-nav,
.ant-tabs-nav-list,
.ant-tabs-tab,
.ant-tabs-content,
.ant-tag,
.ant-breadcrumb,
.ant-pagination-item,
.ant-dropdown-menu,
/* —— 反馈 & 展示 —— */
.ant-alert,
.ant-empty,
.ant-result,
.ant-statistic,
.ant-statistic-content,
.ant-badge,
.ant-avatar,
.ant-divider,
.ant-skeleton,
.ant-skeleton-input,
.ant-skeleton-button,
.ant-notification-notice,
.ant-message-notice-content,
.ant-popover-inner,
.ant-tooltip-inner,
.ant-collapse,
.ant-collapse-item,
.ant-collapse-header,
.ant-collapse-content-box,
.ant-descriptions-item-container {
transition:
color 0.15s linear,
background-color 0.15s linear,
border-color 0.15s linear,
box-shadow 0.15s linear;
}

View File

@@ -69,4 +69,10 @@ export const EVENTS = {
LOGIN_SUCCESS: 'auth:login-success', LOGIN_SUCCESS: 'auth:login-success',
/** 退出登录 */ /** 退出登录 */
LOGOUT: 'auth:logout', LOGOUT: 'auth:logout',
/** 任务提交成功(→ 刷新任务列表) */
TASK_SUBMITTED: 'task:submitted',
/** 打开设置 Drawer导航栏"设置"按钮点击) */
OPEN_SETTINGS: 'settings:open',
/** 打开页面 Modal导航栏点击页面导航项触发携带 target 路径标识符) */
OPEN_PAGE: 'page:open',
} as const; } as const;

488
src/utils/pricing.ts Normal file
View 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;
}

View File

@@ -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`
@@ -28,11 +29,11 @@ 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` |
**响应 — 有更新时:** **响应 — 有更新时:**
@@ -68,19 +69,20 @@ 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 |
| `blockMapUrl` | string | 否 | blockmap 文件 URL缺失时客户端全量下载 | | `blockMapUrl` | string | 否 | blockmap 文件 URL缺失时客户端全量下载 |
| `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) | | `blockMapSize` | number | 否 | blockmap 文件字节数(`> 0` 时客户端启用增量下载) |
| `changelog` | string | 否 | Markdown 格式更新日志 | | `changelog` | string | 否 | Markdown 格式更新日志 |
| `releaseDate` | string | 否 | 发布日期YYYY-MM-DD | | `releaseDate` | string | 否 | 发布日期YYYY-MM-DD |
| `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,26 +118,27 @@ Content-Type: application/json
## 数据库表结构 ## 数据库表结构
```sql ```sql
CREATE TABLE releases ( CREATE TABLE releases
id BIGINT PRIMARY KEY AUTO_INCREMENT, (
platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux', id BIGINT PRIMARY KEY AUTO_INCREMENT,
edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise', platform VARCHAR(10) NOT NULL COMMENT 'win32 / darwin / linux',
version VARCHAR(20) NOT NULL COMMENT '三段式 semver', edition VARCHAR(10) NOT NULL COMMENT 'personal / enterprise',
file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址', version VARCHAR(20) NOT NULL COMMENT '三段式 semver',
file_size BIGINT NOT NULL COMMENT '安装包字节数', file_url VARCHAR(500) NOT NULL COMMENT '安装包 OSS HTTPS 地址',
sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64', file_size BIGINT NOT NULL COMMENT '安装包字节数',
blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)', sha512 VARCHAR(128) NOT NULL COMMENT '安装包 SHA512 Base64',
blockmap_size BIGINT COMMENT 'blockmap 文件字节数', blockmap_url VARCHAR(500) COMMENT 'blockmap OSS 地址(可为空)',
changelog TEXT COMMENT 'Markdown 格式更新日志', blockmap_size BIGINT COMMENT 'blockmap 文件字节数',
force_update TINYINT(1) DEFAULT 0, changelog TEXT COMMENT 'Markdown 格式更新日志',
min_version VARCHAR(20) DEFAULT '', force_update TINYINT(1) DEFAULT 0,
release_date DATE NOT NULL, min_version VARCHAR(20) DEFAULT '',
status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated', release_date DATE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP, status VARCHAR(10) DEFAULT 'draft' COMMENT 'draft / published / deprecated',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_platform_edition_version (platform, edition, version), UNIQUE KEY uk_platform_edition_version (platform, edition, version),
INDEX idx_platform_edition_status (platform, edition, status) INDEX idx_platform_edition_status (platform, edition, status)
); );
``` ```

View File

@@ -11,4 +11,4 @@
"types": ["node"] "types": ["node"]
}, },
"include": ["vite.config.ts", "electron/**/*.ts", "shared"] "include": ["vite.config.ts", "electron/**/*.ts", "shared"]
} }

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More