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 — 前端架构文档
|
# 船长 · HeiXiu — 前端架构文档
|
||||||
|
|
||||||
> 最后更新:2026-06-08
|
> 最后更新:2026-06-09
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@@ -165,18 +165,20 @@ NavBar / 设置页 → AppProvider.logout()
|
|||||||
|
|
||||||
- 纯发布-订阅,不依赖任何框架
|
- 纯发布-订阅,不依赖任何框架
|
||||||
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
- 用于跨模块解耦通信(如 axios 拦截器无法直接用 React Context)
|
||||||
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS`
|
- 预定义事件:`AUTH_REQUIRED` / `SHOW_LOGIN` / `LOGIN_SUCCESS` / `LOGOUT` / `TASK_SUBMITTED` / `OPEN_SETTINGS` / `OPEN_PAGE`
|
||||||
|
|
||||||
### 页面叠加层模式(QT 多窗口模型映射)
|
### 页面叠加层模式(QT 多窗口模型映射)
|
||||||
|
|
||||||
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则:
|
本项目是 Python QT 桌面应用的 Web 重构版。QT 应用以"新开窗口"为常态,原窗口不因新窗口打开而销毁。Web 端对应原则:
|
||||||
|
|
||||||
| QT 原版 | Web 对应 | 实现方式 |
|
| QT 原版 | Web 对应 | 实现方式 |
|
||||||
|---------|---------|---------|
|
|---------------|----------------|-------------------------------|
|
||||||
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
|
| 新开窗口叠加在主窗口之上 | Modal / Drawer | 事件总线驱动,主页面不卸载 |
|
||||||
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
|
| Tab 切换 / 视图替换 | React Router | 跨页状态放 Provider 或 localStorage |
|
||||||
|
|
||||||
**当前叠加层**:
|
**当前叠加层**:
|
||||||
|
|
||||||
|
- `PageDispatcher` — 统一页面调度器,监听 `OPEN_PAGE` 事件,根据 `modalType` 渲染 Modal/Drawer/FloatingPanel,全局互斥(同一时刻最多一个面板)
|
||||||
- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
|
- LoginPage — Modal,事件总线 `SHOW_LOGIN` / `AUTH_REQUIRED` 触发
|
||||||
- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发
|
- SettingsPage — Modal,事件总线 `OPEN_SETTINGS` 触发
|
||||||
- AnnouncementDrawer — Drawer,NavBar 内部 state 控制
|
- AnnouncementDrawer — Drawer,NavBar 内部 state 控制
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -3,6 +3,21 @@
|
|||||||
> 每次发版前修改此文件,`npm run build:win` 打包后会自动读取生成 update-info.json。
|
> 每次发版前修改此文件,`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)
|
## 0.0.15(2026-06-09)
|
||||||
|
|
||||||
**主题切换性能优化 — 桌面端流畅度提升**
|
**主题切换性能优化 — 桌面端流畅度提升**
|
||||||
|
|||||||
6
TODO.md
6
TODO.md
@@ -1,6 +1,6 @@
|
|||||||
# TODO — 船长·HeiXiu 待办与优化清单
|
# TODO — 船长·HeiXiu 待办与优化清单
|
||||||
|
|
||||||
> 最后更新:2026-06-08
|
> 最后更新:2026-06-09
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,10 +14,10 @@
|
|||||||
|
|
||||||
### 路由与页面
|
### 路由与页面
|
||||||
|
|
||||||
|
- [x] 账户信息页面(`/account`) — `AccountInfo.tsx`,AuthAPI.getUserInfo() 驱动
|
||||||
- [ ] 合规素材库页面(`/compliance-assets`)
|
- [ ] 合规素材库页面(`/compliance-assets`)
|
||||||
- [ ] 项目管理页面(`/projects`,企业版)
|
- [ ] 项目管理页面(`/projects`,企业版)
|
||||||
- [ ] 账户/会员/充值/消费记录/订单明细页面
|
- [ ] 会员/充值/消费记录/订单明细页面
|
||||||
- [ ] 各页面暗色主题适配
|
|
||||||
|
|
||||||
### 导航栏
|
### 导航栏
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/components/AppProvider';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ function DependentFormItem({
|
|||||||
onChange?: (value: unknown) => void;
|
onChange?: (value: unknown) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const depValues = (field.dependsOn || []).map((depName) =>
|
// Form.useWatch 必须在组件顶层无条件调用(不能放 .map() 或条件分支中)。
|
||||||
Form.useWatch(depName, form),
|
// dependsOn 长度由 param_schema 约束(当前 API 最多 2 个),
|
||||||
);
|
// 预留 3 个固定槽位覆盖未来扩展;无依赖时 watch 空串 → 返回 undefined。
|
||||||
|
const deps = field.dependsOn || [];
|
||||||
|
const d0 = Form.useWatch(deps[0] || '', form);
|
||||||
|
const d1 = Form.useWatch(deps[1] || '', form);
|
||||||
|
const d2 = Form.useWatch(deps[2] || '', form);
|
||||||
|
const depValues = [d0, d1, d2].slice(0, deps.length);
|
||||||
|
|
||||||
const isDepDisabled =
|
const isDepDisabled =
|
||||||
field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false;
|
deps.some((_, i) => isValueEmpty(depValues[i]));
|
||||||
|
|
||||||
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
||||||
const effectiveConfig = effectiveDisabled
|
const effectiveConfig = effectiveDisabled
|
||||||
@@ -115,7 +120,7 @@ export function ModelInputForm() {
|
|||||||
// 提交突变 Hook
|
// 提交突变 Hook
|
||||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||||
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||||
message.success('任务已提交');
|
void message.success('任务已提交');
|
||||||
emit(EVENTS.TASK_SUBMITTED);
|
emit(EVENTS.TASK_SUBMITTED);
|
||||||
}, []),
|
}, []),
|
||||||
});
|
});
|
||||||
@@ -147,7 +152,7 @@ export function ModelInputForm() {
|
|||||||
form.setFieldsValue(initialValues);
|
form.setFieldsValue(initialValues);
|
||||||
setTaskTag('');
|
setTaskTag('');
|
||||||
}
|
}
|
||||||
}, [selectedModel?.id, initialValues]);
|
}, [selectedModel?.id, initialValues, selectedModel, form]);
|
||||||
|
|
||||||
// 重置参数(回到默认值 + 清空标签)
|
// 重置参数(回到默认值 + 清空标签)
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
@@ -384,7 +389,7 @@ export function ModelInputForm() {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleSubmit}
|
onClick={() => void handleSubmit()}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={!isLoggedIn || !selectedModel}
|
disabled={!isLoggedIn || !selectedModel}
|
||||||
size="large"
|
size="large"
|
||||||
|
|||||||
@@ -14,10 +14,11 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Upload, Switch, Space, Typography, message } from 'antd';
|
import { Upload, Switch, Space, Typography } from 'antd';
|
||||||
import { UploadOutlined } from '@ant-design/icons';
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
import { createImageBeforeUpload } from './uploadValidation';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -81,18 +82,7 @@ export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={isUploadDisabled}
|
disabled={isUploadDisabled}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{fileList.length < 1 && (
|
{fileList.length < 1 && (
|
||||||
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
|||||||
@@ -3,10 +3,11 @@
|
|||||||
// 用于图生图模型的多图输入场景
|
// 用于图生图模型的多图输入场景
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { Upload, message } from 'antd';
|
import { Upload } from 'antd';
|
||||||
import { InboxOutlined } from '@ant-design/icons';
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
import type { WidgetProps } from './types';
|
import type { WidgetProps } from './types';
|
||||||
|
import { createImageBeforeUpload } from './uploadValidation';
|
||||||
|
|
||||||
const { Dragger } = Upload;
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
@@ -28,18 +29,7 @@ export function ImageListInput({ value, onChange, disabled, config }: WidgetProp
|
|||||||
fileList={fileList}
|
fileList={fileList}
|
||||||
onChange={({ fileList: newList }) => onChange?.(newList)}
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
beforeUpload={(file) => {
|
beforeUpload={createImageBeforeUpload(maxSizeMb)}
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
maxCount={maxCount}
|
maxCount={maxCount}
|
||||||
>
|
>
|
||||||
<p className="ant-upload-drag-icon">
|
<p className="ant-upload-drag-icon">
|
||||||
|
|||||||
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
26
src/pages/home/components/useUI/uploadValidation.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// ============================================================
|
||||||
|
// uploadValidation — 文件上传前校验工具
|
||||||
|
// ImageInput / ImageListInput 共用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload, message } from 'antd';
|
||||||
|
import type { RcFile } from 'antd/es/upload';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建图片上传前的类型 & 大小校验函数
|
||||||
|
* @param maxSizeMb 最大文件大小(MB),默认 10
|
||||||
|
*/
|
||||||
|
export function createImageBeforeUpload(maxSizeMb: number = 10) {
|
||||||
|
return (file: RcFile) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
|||||||
|
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules/auth';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
@@ -50,22 +50,12 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
|
|||||||
|
|
||||||
// ---- 提交 ----
|
// ---- 提交 ----
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const newErrors: Record<string, string> = {};
|
if (runFieldChecks(setErrors,
|
||||||
|
{ field: 'currentPassword', error: !currentPassword ? '请输入当前密码' : null },
|
||||||
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
|
)) return;
|
||||||
|
|
||||||
if (!currentPassword) {
|
|
||||||
newErrors.currentPassword = '请输入当前密码';
|
|
||||||
}
|
|
||||||
const pwdErr = validatePassword(newPassword);
|
|
||||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
|
||||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
|
||||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
|
||||||
setErrors(newErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors({});
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await AuthAPI.changePassword({
|
await AuthAPI.changePassword({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
validateSmsCode,
|
validateSmsCode,
|
||||||
validatePassword,
|
validatePassword,
|
||||||
validateConfirmPassword,
|
validateConfirmPassword,
|
||||||
|
runFieldChecks,
|
||||||
} from '../config/validators';
|
} from '../config/validators';
|
||||||
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||||||
|
|
||||||
@@ -80,23 +81,12 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
|
|||||||
|
|
||||||
// ---- 提交重置 ----
|
// ---- 提交重置 ----
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
// 逐字段校验
|
if (runFieldChecks(setErrors,
|
||||||
const newErrors: Record<string, string> = {};
|
{ field: 'phone', error: validatePhone(phone) },
|
||||||
const phoneErr = validatePhone(phone);
|
{ field: 'smsCode', error: validateSmsCode(smsCode) },
|
||||||
if (phoneErr) newErrors.phone = phoneErr;
|
{ field: 'newPassword', error: validatePassword(newPassword) },
|
||||||
const smsErr = validateSmsCode(smsCode);
|
{ field: 'confirmPassword', error: validateConfirmPassword(confirmPassword, newPassword) },
|
||||||
if (smsErr) newErrors.smsCode = smsErr;
|
)) return;
|
||||||
const pwdErr = validatePassword(newPassword);
|
|
||||||
if (pwdErr) newErrors.newPassword = pwdErr;
|
|
||||||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
|
||||||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
|
||||||
|
|
||||||
if (Object.keys(newErrors).length > 0) {
|
|
||||||
setErrors(newErrors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors({});
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await AuthAPI.resetPassword({
|
await AuthAPI.resetPassword({
|
||||||
|
|||||||
@@ -102,6 +102,32 @@ export function validateField(value: string, rules: import('../types').FieldRule
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逐字段运行校验器,有错误则设置并返回 true。
|
||||||
|
*
|
||||||
|
* 用法(在 handleSubmit 中):
|
||||||
|
* if (runFieldChecks(setErrors,
|
||||||
|
* { field: 'phone', error: validatePhone(phone) },
|
||||||
|
* { field: 'pwd', error: validatePassword(pwd) },
|
||||||
|
* { field: 'confirm', error: validateConfirmPassword(confirm, pwd) },
|
||||||
|
* )) return;
|
||||||
|
*/
|
||||||
|
export function runFieldChecks(
|
||||||
|
setErrors: (errors: Record<string, string>) => void,
|
||||||
|
...checks: Array<{ field: string; error: string | null }>
|
||||||
|
): boolean {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
for (const { field, error } of checks) {
|
||||||
|
if (error) newErrors[field] = error;
|
||||||
|
}
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setErrors(newErrors);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验整个表单
|
* 校验整个表单
|
||||||
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
* @returns 错误映射 { fieldName: errorMsg[] },空对象表示全部通过
|
||||||
|
|||||||
Reference in New Issue
Block a user