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:
@@ -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 |
|
||||
| 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 — Drawer,NavBar 内部 state 控制
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -3,6 +3,21 @@
|
||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
||||
---
|
||||
|
||||
## 0.0.16(2026-06-09)
|
||||
|
||||
**模型输入栏重构 + 统一页面调度 + 依赖联动**
|
||||
|
||||
- 模型输入栏三层架构:useUI 组件体系(10 种 widget → antd 映射)+ useModelUI Hook(param_schema → UIFieldConfig[])
|
||||
- constraints_config.requires 动态联动:enable_switch 开关控制依赖字段启用/禁用
|
||||
- 修复模型默认值不加载(antd 外部 form 实例忽略 initialValues)+ DependentFormItem prop 透传断裂
|
||||
- PageDispatcher 统一页面调度:Modal/Drawer/FloatingPanel 三容器,全局互斥,动态视口尺寸
|
||||
- 账户信息页:AuthAPI.getUserInfo() 驱动,积分/套餐/统计三卡片布局
|
||||
- 代码去重:runFieldChecks 校验运行器 + createImageBeforeUpload 上传校验工厂
|
||||
- 主题切换性能优化 — View Transitions API + 0.15s 统一过渡 + 设置页增强(0.0.15 延续)
|
||||
- 目录调整:wechat-work/ → headers/
|
||||
|
||||
---
|
||||
|
||||
## 0.0.15(2026-06-09)
|
||||
|
||||
**主题切换性能优化 — 桌面端流畅度提升**
|
||||
|
||||
6
TODO.md
6
TODO.md
@@ -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`,企业版)
|
||||
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
||||
- [ ] 各页面暗色主题适配
|
||||
- [ ] 会员/充值/消费记录/订单明细页面
|
||||
|
||||
### 导航栏
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// ============================================================
|
||||
// uploadValidation — 文件上传前校验工具
|
||||
// ImageInput / ImageListInput 共用
|
||||
// ============================================================
|
||||
|
||||
import { Upload, message } from 'antd';
|
||||
import type { RcFile } from 'antd/es/upload';
|
||||
|
||||
/**
|
||||
* 创建图片上传前的类型 & 大小校验函数
|
||||
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||
*/
|
||||
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||
return (file: RcFile) => {
|
||||
const isImage = file.type.startsWith('image/');
|
||||
if (!isImage) {
|
||||
message.error('只能上传图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { 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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[] },空对象表示全部通过
|
||||
|
||||
Reference in New Issue
Block a user