refactor: Home 模块 VCHSM 三栏重构 + Services 迁回 + 右键菜单体系 + 公告修复

## Home 模块三栏 VCHSM 重组
- 按渲染归属重新分配文件:ModelSelector/TaskHistory → left/,ModelInputForm/widgets → center/,OutputPreview → right/
- BannerCarousel 移至 shared/views/(全局组件)
- controls/ → widgets/ 重命名,消除与 controllers/ 的命名歧义
- 新增 barrel 文件(left/center/right/shared 各级 index.ts)

## Services 层迁回 src/services/modules/
- 全部 API 类型定义与请求函数从 modules/*/controllers/ 迁回 services/modules/{domain}/
- 恢复原始文件名(model-api.ts → models.ts,task-api.ts → task.ts 等)
- 新增 domain 子文件夹(home/auth/announcement/account)
- 更新全局 ~70 处导入路径

## TaskHistory 提取 Controller + Hook 层
- 新建 left/controllers/task-table-config.ts(常量 + 纯工具函数,零 React 依赖)
- 新建 left/hooks/useTaskTable.tsx(状态管理 + 数据获取 + 列定义 + 滚动同步)
- TaskHistory.tsx 从 915 行精简为 265 行纯视图

## 右键菜单体系完善
- 共享 <ContextMenu> 组件(antd Dropdown 封装,防 Select 冲突)
- 四组菜单定义:任务列表 / 文本输入 / 媒体参考 / 预览区域
- 统一的 ContextMenuItems → AntD MenuProps 转换

## 公告功能修复
- useAnnouncements 增加 isLoggedIn 登录门控,避免未登录时 401 双重请求
- 抽屉打开时触发 refetch(),确保拿到最新数据

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-30 13:39:38 +08:00
parent 337d6c1205
commit 904edecd90
73 changed files with 1478 additions and 1034 deletions

View File

@@ -0,0 +1,158 @@
// ============================================================
// upload-widgets — 单文件上传控件
// 合并自 ImageInput.tsx + VideoInput.tsx
//
// 两者共享 ~90% 的 enable_switch + Upload 模式,
// 差异仅在于accept 类型、beforeUpload 校验、图标、默认 maxSize。
// 提取 useEnableSwitch() + SingleUpload 去重。
// ============================================================
import { useState, useCallback } from 'react';
import { Upload, Switch, Space, Typography } from 'antd';
import { UploadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/shared/hooks/use-api';
import {
buildAccept,
buildVideoAccept,
createMediaBeforeUpload,
createVideoBeforeUpload,
} from '@/shared/utils/media-upload';
const { Text } = Typography;
// ════ enable_switch 共享 Hook ════
const ENABLED_EMPTY: UploadFile[] = [];
function useEnableSwitch(
hasEnableSwitch: boolean,
disabled: boolean,
onChange: WidgetProps['onChange'],
) {
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
const isUploadDisabled = disabled || !switchOn;
const handleSwitchChange = useCallback(
(on: boolean) => {
setSwitchOn(on);
if (!on) {
onChange?.(undefined);
} else {
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
}
},
[onChange],
);
return { switchOn, isUploadDisabled, handleSwitchChange };
}
// ════ SingleUpload 内部组件 ════
interface SingleUploadProps {
value: unknown;
onChange: WidgetProps['onChange'];
disabled: boolean;
config: Record<string, unknown>;
/** 'image' | 'video' */
mediaType: 'image' | 'video';
icon: React.ReactNode;
uploadLabel: string;
defaultMaxSizeMb: number;
}
function SingleUpload({
value,
onChange,
disabled,
config,
mediaType,
icon,
uploadLabel,
defaultMaxSizeMb,
}: SingleUploadProps) {
const fileList = (value as UploadFile[]) || [];
const maxSizeMb = (config.maxFileSizeMb as number) || defaultMaxSizeMb;
const filters = config.fileFilter as string[] | undefined;
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
const { customRequest } = useFileUpload();
const { switchOn, isUploadDisabled, handleSwitchChange } = useEnableSwitch(hasEnableSwitch, disabled, onChange);
const accept = mediaType === 'video'
? buildVideoAccept(filters)
: buildAccept(filters);
const beforeUpload = mediaType === 'video'
? createVideoBeforeUpload(maxSizeMb)
: createMediaBeforeUpload(maxSizeMb, filters);
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={accept}
fileList={fileList}
customRequest={customRequest}
onChange={({ fileList: newList }) => {
if (switchOn || !hasEnableSwitch) onChange?.(newList as unknown as UploadFile[]);
}}
disabled={isUploadDisabled}
beforeUpload={beforeUpload}
>
{fileList.length < 1 && (
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
{icon}
<div style={{ marginTop: 4, fontSize: 12 }}>{uploadLabel}</div>
</div>
)}
</Upload>
</Space>
);
}
// ════ 公开导出(保持原组件名兼容 WIDGET_MAP ════
export function ImageInput(props: WidgetProps) {
const { value, onChange, disabled, config } = props;
return (
<SingleUpload
value={value}
onChange={onChange}
disabled={disabled ?? false}
config={config}
mediaType="image"
icon={<UploadOutlined />}
uploadLabel="上传"
defaultMaxSizeMb={10}
/>
);
}
export function VideoInput(props: WidgetProps) {
const { value, onChange, disabled, config } = props;
return (
<SingleUpload
value={value}
onChange={onChange}
disabled={disabled ?? false}
config={config}
mediaType="video"
icon={<VideoCameraOutlined />}
uploadLabel="上传视频"
defaultMaxSizeMb={50}
/>
);
}