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>
This commit is contained in:
2026-06-09 19:12:37 +08:00
parent 1c27283b56
commit d95d155ab5
11 changed files with 110 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
# 船长 · HeiXiu — 前端架构文档
> 最后更新2026-06-08
> 最后更新2026-06-09
## 目录结构
@@ -165,18 +165,20 @@ NavBar / 设置页 → AppProvider.logout()
- 纯发布-订阅,不依赖任何框架
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS`
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
### 页面叠加层模式QT 多窗口模型映射)
本项目是 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 控制

View File

@@ -3,6 +3,21 @@
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
---
## 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 File

@@ -1,6 +1,6 @@
# TODO — 船长·HeiXiu 待办与优化清单
> 最后更新2026-06-08
> 最后更新2026-06-09
---
@@ -14,10 +14,10 @@
### 路由与页面
- [x] 账户信息页面(`/account``AccountInfo.tsx`AuthAPI.getUserInfo() 驱动
- [ ] 合规素材库页面(`/compliance-assets`
- [ ] 项目管理页面(`/projects`,企业版)
- [ ] 账户/会员/充值/消费记录/订单明细页面
- [ ] 各页面暗色主题适配
- [ ] 会员/充值/消费记录/订单明细页面
### 导航栏

View File

@@ -3,7 +3,7 @@
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
// ============================================================
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { theme as antTheme, Empty } from 'antd';
import { useAppContext } from '@/components/AppProvider';

View File

@@ -75,12 +75,17 @@ function DependentFormItem({
onChange?: (value: unknown) => void;
disabled?: boolean;
}) {
const depValues = (field.dependsOn || []).map((depName) =>
Form.useWatch(depName, form),
);
// 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 =
field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false;
deps.some((_, i) => isValueEmpty(depValues[i]));
const effectiveDisabled = formItemDisabled || isDepDisabled;
const effectiveConfig = effectiveDisabled
@@ -115,7 +120,7 @@ export function ModelInputForm() {
// 提交突变 Hook
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
message.success('任务已提交');
void message.success('任务已提交');
emit(EVENTS.TASK_SUBMITTED);
}, []),
});
@@ -147,7 +152,7 @@ export function ModelInputForm() {
form.setFieldsValue(initialValues);
setTaskTag('');
}
}, [selectedModel?.id, initialValues]);
}, [selectedModel?.id, initialValues, selectedModel, form]);
// 重置参数(回到默认值 + 清空标签)
const handleReset = useCallback(() => {
@@ -384,7 +389,7 @@ export function ModelInputForm() {
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmit}
onClick={() => void handleSubmit()}
loading={submitting}
disabled={!isLoggedIn || !selectedModel}
size="large"

View File

@@ -14,10 +14,11 @@
// ============================================================
import { useState, useCallback } from 'react';
import { Upload, Switch, Space, Typography, message } from 'antd';
import { Upload, Switch, Space, Typography } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { createImageBeforeUpload } from './uploadValidation';
const { Text } = Typography;
@@ -81,18 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
}
}}
disabled={isUploadDisabled}
beforeUpload={(file) => {
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;
}}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
>
{fileList.length < 1 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>

View File

@@ -3,10 +3,11 @@
// 用于图生图模型的多图输入场景
// ============================================================
import { Upload, message } from 'antd';
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;
@@ -28,18 +29,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
fileList={fileList}
onChange={({ fileList: newList }) => onChange?.(newList)}
disabled={disabled}
beforeUpload={(file) => {
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;
}}
beforeUpload={createImageBeforeUpload(maxSizeMb)}
maxCount={maxCount}
>
<p className="ant-upload-drag-icon">

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

@@ -14,7 +14,7 @@ import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth';
import { validatePassword, validateConfirmPassword } from '../config/validators';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
const { Text, Title } = Typography;
@@ -50,22 +50,12 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
// ---- 提交 ----
const handleSubmit = useCallback(async () => {
const newErrors: Record<string, string> = {};
if (runFieldChecks(setErrors,
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
{ field: 'newPassword', error: validatePassword(newPassword) },
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
)) return;
if (!currentPassword) {
newErrors.currentPassword = '请输入当前密码';
}
const pwdErr = validatePassword(newPassword);
if (pwdErr) newErrors.newPassword = pwdErr;
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
if (confirmErr) newErrors.confirmPassword = confirmErr;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
setSubmitting(true);
try {
await AuthAPI.changePassword({

View File

@@ -25,6 +25,7 @@ import {
validateSmsCode,
validatePassword,
validateConfirmPassword,
runFieldChecks,
} from '../config/validators';
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
@@ -80,23 +81,12 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
// ---- 提交重置 ----
const handleSubmit = useCallback(async () => {
// 逐字段校验
const newErrors: Record<string, string> = {};
const phoneErr = validatePhone(phone);
if (phoneErr) newErrors.phone = phoneErr;
const smsErr = validateSmsCode(smsCode);
if (smsErr) newErrors.smsCode = smsErr;
const pwdErr = validatePassword(newPassword);
if (pwdErr) newErrors.newPassword = pwdErr;
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
if (confirmErr) newErrors.confirmPassword = confirmErr;
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setErrors({});
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({

View File

@@ -102,6 +102,32 @@ export function validateField(value: string, rules: import('../types').FieldRule
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[] },空对象表示全部通过