## 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>
159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
// ============================================================
|
||
// 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}
|
||
/>
|
||
);
|
||
}
|