feat: 构建系统重构 + VCHSM 架构迁移 + 右键菜单修复 + 尺寸参数统一

## 构建、打包与分发系统重构 (build/)

- 构建系统收敛至 build/ 目录:build.mjs + electron-builder.js + 6 个脚本

- electron-builder 配置从 package.json 提取为 build/electron-builder.js

- 新增 --edition (personal/enterprise) 和 --channel (stable/beta/alpha) 参数

- OSS 路径按渠道隔离

- 新增 pre-build-check.mjs:Git/CHANGELOG/版本/环境变量校验

- 新增 bump-version.mjs:版本号管理 + CHANGELOG 自动生成

- updater.ts 发送 channel/edition/deviceFingerprint

- 新增 src/shared/utils/rollout.ts 灰度测试工具

- 新增 10 条 NPM 脚本

## VCHSM 五层架构迁移

- 7 个业务模块 + ~500 处导入路径同步

## 修复

- MediaCardContextMenu 改用共享 ContextMenu 组件

- ComboBox 尺寸参数显示统一 (size-format.ts)

## 文档

- UpdateA.md / build/README.md / README.md / .env.example 更新

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-29 18:41:07 +08:00
parent e0d91335c7
commit 337d6c1205
187 changed files with 5252 additions and 1702 deletions

View File

@@ -1,14 +1,14 @@
import { useState, useEffect, useCallback } from 'react';
import { App as AntdApp } from 'antd';
import { useAppContext } from '@/components/providers/AppProvider';
import { LoginPage } from './pages/auth';
import { SettingsPage } from './pages/settings';
import { on, off, emit, EVENTS } from './utils/bus';
import type { BusinessErrorEvent } from './utils/bus';
import { Layout } from '@/components/frame/Layout';
import { HomePage } from '@/pages/home/HomePage';
import { PageDispatcher } from '@/components/frame/PageDispatcher';
import { useAppContext } from '@/app/providers/AppProvider';
import { LoginPage } from '@/modules/auth/views/AuthPage';
import { SettingsPage } from '@/modules/settings';
import { on, off, emit, EVENTS } from '@/shared/utils/bus';
import type { BusinessErrorEvent } from '@/shared/utils/bus';
import { Layout } from '@/app/layout/Layout';
import { HomePage } from '@/modules/home/views/HomePage';
import { PageDispatcher } from '@/app/layout/PageDispatcher';
/**
*

View File

@@ -5,8 +5,8 @@
import type { ReactNode } from 'react';
import { theme as antTheme } from 'antd';
import { NavBar } from './navbar';
import { BannerCarousel } from '@/pages/home/features/BannerCarousel';
import { NavBar } from '@/modules/navbar';
import { BannerCarousel } from '@/modules/home/views/right/BannerCarousel';
export function Layout({ children }: { children: ReactNode }) {
const { token } = antTheme.useToken();

View File

@@ -18,15 +18,15 @@
import {type ComponentType, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Drawer, Modal} from 'antd';
import {EVENTS, off, on} from '@/utils/bus';
import {FloatingPanel} from '@/components/ui/FloatingPanel';
import {EVENTS, off, on} from '@/shared/utils/bus';
import {FloatingPanel} from '@/shared/components/FloatingPanel';
import {WechatWorkPage} from '@/pages/panels/WechatWorkPage';
import {AccountInfo} from '@/pages/panels/AccountInfo';
import {BillingInfo} from '@/pages/panels/BillingInfo';
import {RechargePage} from '@/pages/panels/RechargePage';
import {OrdersPage} from '@/pages/panels/OrdersPage';
import {VipPage} from '@/pages/panels/VipPage';
import {WechatWorkPage} from '@/modules/account/views/wechat-work/WechatWorkPage';
import {AccountInfo} from '@/modules/account/views/info/AccountPage';
import {BillingInfo} from '@/modules/account/views/billing/BillingPage';
import {RechargePage} from '@/modules/account/views/recharge/RechargePage';
import {OrdersPage} from '@/modules/account/views/orders/OrdersPage';
import {VipPage} from '@/modules/account/views/vip/VipPage';
// ---------- 类型 ----------

View File

@@ -3,12 +3,12 @@ import ReactDOM from 'react-dom/client';
import {App as AntdApp} from 'antd';
import App from './App';
import {ThemeProvider} from './components/providers/ThemeProvider';
import {AppProvider} from './components/providers/AppProvider';
import {SettingsProvider} from './components/providers/SettingsProvider';
import CanvasApp from './pages/infinite-canvas/CanvasApp';
import {ThemeProvider} from './providers/ThemeProvider';
import {AppProvider} from './providers/AppProvider';
import {SettingsProvider} from './providers/SettingsProvider';
import CanvasApp from '@/modules/canvas/views/CanvasApp';
// 全局样式(包含 Tailwind 指令 + 主题扩展 + 重置)
import './theme/globals.css';
import '@/shared/theme/globals.css';
/** 判断当前是否为画布窗口(通过 URL hash 区分) */
const isCanvasMode = window.location.hash.startsWith('#/canvas');
@@ -38,13 +38,13 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
);
// 仅在 Electron 环境中监听主进程消息(浏览器预览时跳过)
import { safeIpcOn } from './utils/bus';
import { safeIpcOn } from '@/shared/utils/bus';
safeIpcOn('main-process-message', (_event: unknown, message: unknown) => {
console.log('[Main Process Message]:', message);
});
// 渲染进程全局未捕获错误(兜底记录到日志文件)
import {logger} from './utils/bus';
import {logger} from '@/shared/utils/bus';
window.addEventListener('error', (event) => {
logger.error('ui', 'Uncaught error (renderer)', event.error, {

View File

@@ -10,13 +10,13 @@
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/env';
import { safeIpcOn, safeIpcOff } from '@/utils/bus';
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/bus';
import { logger } from '@/utils/bus';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
import { getPlatform, isDev } from '@/shared/utils/env';
import { safeIpcOn, safeIpcOff } from '@/shared/utils/bus';
import { AuthAPI, type LoginResponseBody } from '@/modules/auth/controllers/auth-api';
import { setToken, clearToken, initTokenStore } from '@/shared/services/http';
import { emit, EVENTS } from '@/shared/utils/bus';
import { logger } from '@/shared/utils/bus';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/modules/auth/controllers/auth-persistence';
import {
initAuthRefresh,
getStoredRefreshToken,
@@ -25,9 +25,9 @@ import {
initRefreshTokenStore,
scheduleProactiveRefresh,
clearProactiveRefresh,
} from '@/services/auth-token';
import {initModelCache} from '@/services/cache/model-cache';
import {initStorage} from '@/infrastructure/storage';
} from '@/modules/auth/controllers/token-manager';
import {initModelCache} from '@/shared/services/cache/model-cache';
import {initStorage} from '@/shared/infrastructure/storage';
// ---------- Context 类型 ----------
@@ -174,6 +174,9 @@ export function AppProvider({ children }: AppProviderProps) {
};
setUser(u);
emit(EVENTS.LOGIN_SUCCESS, u);
}).catch((err) => {
if (aborted) return;
logger.warn('auth', '自动登录-获取用户信息失败', err instanceof Error ? err : undefined);
});
})
.catch(() => {
@@ -183,6 +186,9 @@ export function AppProvider({ children }: AppProviderProps) {
// 仅清除自动登录标记,保留用户名供下次表单回填
clearAutoLoginFlag();
});
}).catch((err) => {
if (aborted) return;
logger.warn('auth', '应用初始化失败(存储/Token/模型缓存)', err instanceof Error ? err : undefined);
});
return () => { aborted = true; };

View File

@@ -8,7 +8,7 @@
// ============================================================
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/infrastructure/storage';
import { setOutputPath as saveOutputPath, setTeamRepoPath as saveTeamRepoPath } from '@/shared/infrastructure/storage';
// ---------- localStorage 键 ----------

View File

@@ -12,7 +12,7 @@ import { flushSync } from 'react-dom';
import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import { getThemeConfig } from '../../theme/antd-theme';
import { getThemeConfig } from '@/shared/theme/antd-theme';
// ---------- Context 类型 ----------

View File

@@ -1 +0,0 @@
export { NavBar } from './NavBar';

View File

@@ -1,5 +1,5 @@
import {type Nullable} from "@/utils/display";
import {get} from "@/services/request.ts";
import {type Nullable} from "@/shared/utils/display";
import {get} from "@/shared/services/http.ts";
const BillingUrlObj = {
getBalance: '/api/v1/billing/balance',

View File

@@ -1,4 +1,4 @@
import { get, post } from '@/services/request';
import { get, post } from '@/shared/services/http';
export const PAYMENT_METHODS = {
Alipay: 'alipay',

View File

@@ -1,6 +1,6 @@
import {post, get} from '../request';
import {type UserInfoBody} from "@/services/modules/auth";
import {type Nullable} from "@/utils/display";
import {post, get} from '@/shared/services/http';
import {type UserInfoBody} from "@/modules/auth/controllers/auth-api";
import {type Nullable} from "@/shared/utils/display";
// ============================================================

View File

@@ -0,0 +1,7 @@
// account 模块 — 账户与支付
export { AccountInfo } from './views/info/AccountPage';
export { VipPage } from './views/vip/VipPage';
export { RechargePage } from './views/recharge/RechargePage';
export { BillingInfo } from './views/billing/BillingPage';
export { OrdersPage } from './views/orders/OrdersPage';
export { WechatWorkPage } from './views/wechat-work/WechatWorkPage';

View File

@@ -15,10 +15,10 @@ import {
RiseOutlined,
ApiOutlined,
} from '@ant-design/icons';
import {useBillingBalance, useBillingLedger} from '@/hooks/use-api';
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/services/modules/billing';
import {EnumResolver} from '@/utils/enum-resolver';
import {centToYuan, formatDate} from '@/utils/display';
import {useBillingBalance, useBillingLedger} from '@/shared/hooks/use-api';
import type {EntryTypeValue, LedgerItemResponseBody, LedgerReqeustParam} from '@/modules/account/controllers/billing-api';
import {EnumResolver} from '@/shared/utils/enum-resolver';
import {centToYuan, formatDate} from '@/shared/utils/display';
const {Text, Title} = Typography;

View File

@@ -17,10 +17,10 @@ import {
BarChartOutlined,
RightOutlined,
} from '@ant-design/icons';
import {EnumResolver} from '@/utils/enum-resolver';
import type { VipLevelsItem } from '@/services/modules/vip-api';
import { useAccountInfo } from '@/hooks/use-api';
import { centToYuan, formatDate } from '@/utils/display';
import {EnumResolver} from '@/shared/utils/enum-resolver';
import type { VipLevelsItem } from '@/modules/account/controllers/vip-api';
import { useAccountInfo } from '@/shared/hooks/use-api';
import { centToYuan, formatDate } from '@/shared/utils/display';
const {Text, Title} = Typography;

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { logger } from '@/shared/utils/bus';
import {
Card,
Table,
@@ -20,16 +21,16 @@ import {
type PaymentStatus,
type OrderListResponseBody,
type OrderInfoResponseBody,
} from '@/services/modules/payments';
} from '@/modules/account/controllers/payment-api';
import {
VipAPI,
VipOrderStatus,
type VipOrderStatusValue,
type VipOrderRead,
} from '@/services/modules/vip-api';
import { centToYuan, formatDate } from '@/utils/display';
import { EnumResolver } from '@/utils/enum-resolver';
import { RequestError, RequestErrorType } from '@/services/request';
} from '@/modules/account/controllers/vip-api';
import { centToYuan, formatDate } from '@/shared/utils/display';
import { EnumResolver } from '@/shared/utils/enum-resolver';
import { RequestError, RequestErrorType } from '@/shared/services/http';
const { Text } = Typography;
@@ -120,6 +121,7 @@ export function OrdersPage() {
} catch (err) {
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
logger.warn('ui', '加载订单列表失败', err instanceof Error ? err : new Error(String(err)));
if (mountedRef.current) {
setError((err as Error)?.message || '加载订单列表失败');
}

View File

@@ -11,6 +11,7 @@
// ============================================================
import { useState, useMemo, useCallback, useEffect } from 'react';
import { logger } from '@/shared/utils/bus';
import {
Radio,
InputNumber,
@@ -30,16 +31,16 @@ import {
DollarOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { LegalTextModal } from '@/components/ui/LegalTextModal';
import { useTheme } from '@/app/providers/ThemeProvider';
import { LegalTextModal } from '@/shared/components/LegalTextModal';
import {
PaymentAPI,
PAYMENT_METHODS,
PAYMENT_STATUS,
getPaymentStatusText,
type PaymentStatus,
} from '@/services/modules/payments';
import { emit, EVENTS } from '@/utils/bus';
} from '@/modules/account/controllers/payment-api';
import { emit, EVENTS } from '@/shared/utils/bus';
const { Text } = Typography;
@@ -162,6 +163,7 @@ export function RechargePage() {
window.open(res.pay_url, '_blank');
void message.success('订单已创建,请在新页面中完成支付');
} catch (err: unknown) {
logger.warn('ui', '充值创建订单失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '创建订单失败,请重试');
} finally {
setSubmitting(false);

View File

@@ -10,6 +10,7 @@
// ============================================================
import {useState, useCallback, useEffect, useRef} from 'react';
import { logger } from '@/shared/utils/bus';
import {
Radio,
Button,
@@ -26,18 +27,18 @@ import {
} from 'antd';
import {CrownOutlined, GiftOutlined, DollarOutlined} from '@ant-design/icons';
import {useTheme} from '@/components/providers/ThemeProvider';
import {LegalTextModal} from '@/components/ui/LegalTextModal';
import {useTheme} from '@/app/providers/ThemeProvider';
import {LegalTextModal} from '@/shared/components/LegalTextModal';
import {
VipAPI,
VipOrderStatus,
type VipLevelsItem,
type VipOrderStatusValue,
} from '@/services/modules/vip-api';
import {centToYuan} from '@/utils/display';
import {EnumResolver} from '@/utils/enum-resolver';
import {emit, EVENTS} from '@/utils/bus';
import {RequestError, RequestErrorType} from '@/services/request';
} from '@/modules/account/controllers/vip-api';
import {centToYuan} from '@/shared/utils/display';
import {EnumResolver} from '@/shared/utils/enum-resolver';
import {emit, EVENTS} from '@/shared/utils/bus';
import {RequestError, RequestErrorType} from '@/shared/services/http';
const {Text} = Typography;
@@ -119,6 +120,7 @@ export function VipPage() {
} catch (err) {
// axios 拦截器将取消错误转为 RequestError(CANCELLED),非原始 AbortError
if (err instanceof RequestError && err.type === RequestErrorType.CANCELLED) return;
logger.warn('ui', '加载VIP套餐列表失败', err instanceof Error ? err : new Error(String(err)));
if (mountedRef.current) {
setFetchError((err as Error)?.message || '加载套餐列表失败');
}
@@ -179,6 +181,7 @@ export function VipPage() {
window.open(res.pay_url ?? '', '_blank');
void message.success('订单已创建,请在新页面中完成支付');
} catch (err: unknown) {
logger.warn('ui', 'VIP创建订单失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '创建订单失败,请重试');
} finally {
setSubmitting(false);
@@ -200,6 +203,7 @@ export function VipPage() {
void message.success('激活成功VIP 权益已生效');
emit(EVENTS.CLOSE_PAGE);
} catch (err: unknown) {
logger.warn('ui', 'VIP激活失败', err instanceof Error ? err : new Error(String(err)));
message.error((err as Error)?.message || '激活失败,请检查激活码是否正确');
} finally {
setActivating(false);

View File

@@ -9,9 +9,10 @@
// ============================================================
import { useEffect, useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/services/modules/banner';
import { fetchBanners, type Banner } from '@/modules/home/controllers/banner-api';
export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null);
@@ -29,6 +30,7 @@ export function WechatWorkPage() {
);
setBanner(promotion ?? null);
} catch (err) {
logger.warn('ui', '企业微信二维码加载失败', err instanceof Error ? err : new Error(String(err)));
setError((err as Error).message || '加载失败');
} finally {
setLoading(false);

View File

@@ -3,7 +3,7 @@
// GET /api/v1/announcements
// ============================================================
import { get } from '../request';
import { get } from '@/shared/services/http';
// ============================================================
// AnnouncementUrlObj — 公告模块路由常量

View File

@@ -0,0 +1,5 @@
// announcement 模块 — 公告
export { AnnouncementDrawer } from './views/AnnouncementDrawer';
export { fetchAnnouncements } from './controllers/announcement-api';
export type { Announcement, AnnouncementType, AnnouncementTypeValue, AnnouncementListResponse } from './controllers/announcement-api';
export { AnnouncementTypeEnum, AnnouncementTypeLabelMap, AnnouncementTypeColorMap } from './controllers/announcement-api';

View File

@@ -1,14 +1,17 @@
// ============================================================
// AnnouncementDrawer — 公告侧拉抽屉SPA 内部弹出)
// 数据来源GET /api/v1/announcements
//
// 数据由父组件NavBar通过 useAnnouncements Hook 提供:
// - 挂载时自动拉取 + 每 15 分钟轮询
// - 打开抽屉时已标记已读Badge 消除)
// ============================================================
import {useEffect, useState, useCallback} from 'react';
import {useState} from 'react';
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import {NotificationOutlined} from '@ant-design/icons';
import { fetchAnnouncements, type Announcement, type AnnouncementListResponse, type AnnouncementTypeValue } from '@/services/modules/announcement';
import { EnumResolver } from '@/utils/enum-resolver';
import type { Announcement, AnnouncementTypeValue } from '@/modules/announcement/controllers/announcement-api';
import { EnumResolver } from '@/shared/utils/enum-resolver';
const {Text, Paragraph} = Typography;
@@ -34,37 +37,16 @@ function formatDate(iso: string): string {
interface AnnouncementDrawerProps {
open: boolean;
onClose: () => void;
/** 公告列表(由 useAnnouncements Hook 提供) */
announcements: Announcement[];
/** 是否正在加载(由 useAnnouncements Hook 提供) */
loading: boolean;
}
export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
const [list, setList] = useState<Announcement[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
export function AnnouncementDrawer({open, onClose, announcements, loading}: AnnouncementDrawerProps) {
// 详情 Modal
const [detail, setDetail] = useState<Announcement | null>(null);
// 打开抽屉时请求公告列表
const loadAnnouncements = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data: AnnouncementListResponse = await fetchAnnouncements();
setList(data.filter((item: Announcement) => item.is_active));
} catch (err) {
setError((err as Error).message || '加载公告失败');
setList([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnnouncements();
}
}, [open, loadAnnouncements]);
// ---- 渲染 ----
return (
@@ -86,17 +68,13 @@ export function AnnouncementDrawer({open, onClose}: AnnouncementDrawerProps) {
<div className="flex items-center justify-center h-64">
<Spin description="加载中..." />
</div>
) : error ? (
<div className="flex items-center justify-center h-64">
<Empty description={error} />
</div>
) : list.length === 0 ? (
) : announcements.length === 0 ? (
<div className="flex items-center justify-center h-64">
<Empty description="暂无公告" />
</div>
) : (
<div className="flex flex-col gap-3 p-3">
{list.map((item:Announcement) => (
{announcements.map((item:Announcement) => (
<Tooltip
key={item.id}
title={

View File

@@ -1,4 +1,4 @@
import {post, get} from '../request';
import {post, get} from '@/shared/services/http';
// ============================================================
// AuthUrlObj — 认证模块路由常量

View File

@@ -16,15 +16,16 @@
// - 内存缓存供同步读取setTokenRefreshHandler 回调内使用)
// ============================================================
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from './request';
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
import { isRefreshError } from './types';
import { setToken, setTokenRefreshHandler, RequestError, RequestErrorType } from '@/shared/services/http';
import { AuthAPI, type RefreshTokenResponseBody } from './auth-api';
import { isRefreshError } from '@/shared/services/types';
import {
getSafeStore,
setSafeStore,
clearSafeStore,
initSafeStore,
} from '../utils/safe-storage';
} from '@/shared/utils/safe-storage';
import { logger } from '@/shared/utils/bus';
const REFRESH_KEY = 'ele-heixiu-refresh-token';
@@ -175,6 +176,7 @@ export function initAuthRefresh(): void {
} catch (err) {
// ---- 永久失败refresh_token 已过期/无效 ----
if (isRefreshError(err)) {
logger.warn('auth', 'Token 刷新永久失败refresh_token 已过期)', err instanceof Error ? err : undefined);
clearStoredRefreshToken();
clearProactiveRefresh();
throw new RefreshTokenExpiredError();
@@ -183,11 +185,13 @@ export function initAuthRefresh(): void {
// ---- 临时失败:网络超时/无连接 → 保留 token下次再试 ----
if (err instanceof RequestError) {
if (err.type === RequestErrorType.NETWORK || err.type === RequestErrorType.TIMEOUT) {
logger.warn('auth', 'Token 刷新临时失败(网络问题),保留现有 token', err);
return null; // 临时失败,不登出
}
}
// ---- HTTP/Business 错误(如 5xx→ 保守清除 ----
logger.warn('auth', 'Token 刷新失败HTTP/Business 错误),强制登出', err instanceof Error ? err : undefined);
clearStoredRefreshToken();
clearProactiveRefresh();
throw new RefreshTokenExpiredError();

View File

@@ -19,9 +19,9 @@ import {
getSavedUsername,
getSavedRemember,
getSavedAutoLogin,
} from '@/services/auth-persistence';
} from '@/modules/auth/controllers/auth-persistence';
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/modules/auth/controllers/auth-persistence';
// ---------- Hook ----------

View File

@@ -0,0 +1,6 @@
// auth 模块 — 认证(登录/注册/密码/Token
export { LoginPage } from './views/AuthPage';
export { AuthAPI } from './controllers/auth-api';
export type { LoginResponseBody } from './controllers/auth-api';
export { initAuthRefresh } from './controllers/token-manager';
export { getAutoLoginFlag, clearAutoLoginFlag } from './controllers/auth-persistence';

View File

@@ -26,4 +26,4 @@ export interface RegisterFormValues {
}
// 从 ui/ 重新导出通用字段类型(保持向后兼容)
export type { FieldRule, InputType, FieldConfig } from '@/components/ui/types';
export type { FieldRule, InputType, FieldConfig } from '@/shared/components/types';

View File

@@ -3,7 +3,7 @@
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
import type { FieldConfig } from '../model/types';
export const loginFields: FieldConfig[] = [
{

View File

@@ -3,7 +3,7 @@
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
// ============================================================
import type { FieldConfig } from '../types';
import type { FieldConfig } from '../model/types';
export const registerFields: FieldConfig[] = [
{

View File

@@ -76,7 +76,7 @@ export function validateReferralCode(value: string): ValidateResult {
*
* @returns
*/
export function validateField(value: string, rules: import('../types').FieldRule[]): string[] {
export function validateField(value: string, rules: import('../model/types').FieldRule[]): string[] {
const errors: string[] = [];
for (const rule of rules) {
// required
@@ -134,7 +134,7 @@ export function runFieldChecks(
*/
export function validateForm<T extends Record<string, string>>(
values: T,
fieldConfigs: import('../types').FieldConfig[],
fieldConfigs: import('../model/types').FieldConfig[],
crossValidators?: Record<string, (value: string, allValues: T) => string | null>,
): Record<string, string[]> {
const errors: Record<string, string[]> = {};

View File

@@ -8,17 +8,18 @@
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/components/providers/AppProvider';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useAppContext } from '@/app/providers/AppProvider';
import { useTheme } from '@/app/providers/ThemeProvider';
import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from './hooks/use-auth-state';
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import { getDeviceId } from '@/utils/env';
import type { LoginFormValues, RegisterFormValues } from './types';
import { useAuthState } from '../hooks/use-auth-state';
import { AuthAPI, type LoginResponseBody } from '../controllers/auth-api';
import { getDeviceId } from '@/shared/utils/env';
import type { LoginFormValues, RegisterFormValues } from '../model/types';
const { Text } = Typography;
@@ -60,6 +61,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
message.success(`登录成功,欢迎 ${auth.display_name || auth.username}`);
onClose();
} catch (err) {
logger.warn('auth', '登录失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '登录失败,请检查用户名和密码');
} finally {
setSubmitting(false);
@@ -87,6 +89,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
message.success('注册成功,请登录');
setActiveTab('login');
} catch (err) {
logger.warn('auth', '注册失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '注册失败');
} finally {
setSubmitting(false);
@@ -101,6 +104,7 @@ export function LoginPage({ open, onClose }: LoginPageProps) {
await AuthAPI.sendSms({ phone });
message.success('验证码已发送');
} catch (err) {
logger.warn('auth', '验证码发送失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '验证码发送失败');
}
},

View File

@@ -9,12 +9,13 @@
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../config/validators';
import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
import { validatePassword, validateConfirmPassword, runFieldChecks } from '../../settings/validators';
const { Text, Title } = Typography;
@@ -69,6 +70,7 @@ export function ChangePasswordModal({ open, onClose }: ChangePasswordModalProps)
setConfirmPassword('');
onClose();
} catch (err) {
logger.warn('auth', '密码修改失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '密码修改失败');
} finally {
setSubmitting(false);

View File

@@ -10,6 +10,7 @@
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Modal, Input, Button, Typography, App } from 'antd';
import {
PhoneOutlined,
@@ -18,16 +19,16 @@ import {
KeyOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { AuthAPI } from '@/services/modules/auth';
import { useTheme } from '@/app/providers/ThemeProvider';
import { AuthAPI } from '@/modules/auth/controllers/auth-api';
import {
validatePhone,
validateSmsCode,
validatePassword,
validateConfirmPassword,
runFieldChecks,
} from '../config/validators';
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
} from '../../settings/validators';
import { useSmsCooldown } from '../../hooks/use-sms-cooldown';
const { Text, Title } = Typography;
@@ -76,7 +77,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
startCooldown();
AuthAPI.sendSms({ phone })
.then(() => message.success('验证码已发送'))
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
.catch((e) => { logger.warn('auth', '忘记密码-验证码发送失败', e instanceof Error ? e : undefined); message.error((e as Error).message || '验证码发送失败'); });
}, [phone, startCooldown, clearError, message]);
// ---- 提交重置 ----
@@ -102,6 +103,7 @@ export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps)
setConfirmPassword('');
onClose();
} catch (err) {
logger.warn('auth', '密码重置失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '密码重置失败');
} finally {
setSubmitting(false);

View File

@@ -6,10 +6,10 @@ import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields';
import { validateForm } from '../config/validators';
import { LegalTextModal } from '@/components/ui/LegalTextModal';
import type { LoginFormValues } from '../types';
import { loginFields } from '../../settings/login-fields';
import { validateForm } from '../../settings/validators';
import { LegalTextModal } from '@/shared/components/LegalTextModal';
import type { LoginFormValues } from '../../model/types';
const { Text } = Typography;

View File

@@ -5,7 +5,7 @@
import React, { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/ui/LegalTextModal';
import { LegalTextModal } from '@/shared/components/LegalTextModal';
import {
UserOutlined,
LockOutlined,
@@ -17,9 +17,9 @@ import {
GiftOutlined,
} from '@ant-design/icons';
import { registerFields } from '../config/register-fields';
import { validateForm } from '../config/validators';
import type { RegisterFormValues } from '../types';
import { registerFields } from '../../settings/register-fields';
import { validateForm } from '../../settings/validators';
import type { RegisterFormValues } from '../../model/types';
const { Text } = Typography;

View File

@@ -0,0 +1,38 @@
// ============================================================
// episode-menu.ts — 集数选择器右键菜单定义
//
// 菜单结构:新建集数 | 统计集数 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface EpisodeMenuOptions {
canCreate: boolean;
canOpenFolder: boolean;
onCreate: () => void;
onStats: () => void;
onOpenFolder: () => void;
}
export function getEpisodeMenuItems(opts: EpisodeMenuOptions): ContextMenuItems {
return [
{
key: 'new-episode',
label: '新建集数',
onClick: opts.onCreate,
disabled: !opts.canCreate,
},
{
key: 'stats-episode',
label: '统计集数',
onClick: opts.onStats,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: opts.onOpenFolder,
disabled: !opts.canOpenFolder,
},
];
}

View File

@@ -0,0 +1,15 @@
// ============================================================
// menus — 无限画布右键菜单定义统一导出
// ============================================================
export { getProjectMenuItems } from './project-menu';
export type { ProjectMenuOptions } from './project-menu';
export { getEpisodeMenuItems } from './episode-menu';
export type { EpisodeMenuOptions } from './episode-menu';
export { getShotMenuItems } from './shot-menu';
export type { ShotMenuOptions } from './shot-menu';
export { getMediaCardMenuItems } from './media-card-menu';
export type { MediaCardMenuOptions } from './media-card-menu';

View File

@@ -0,0 +1,54 @@
// ============================================================
// media-card-menu.ts — 画布媒体卡片右键菜单定义
//
// Assets 阶段:
// 复制文件路径 | 在文件夹中打开
//
// 非 Assets 阶段Storyboard / Keyframe / Video 等):
// 标记为待审核 | 标记为通过 | 标记为返修 | 标记为已弃用 | —— | 复制文件路径 | 在文件夹中打开
//
// 通用操作(复制路径、在文件夹中打开)统一放到底部分隔线以下。
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
import type { AuditStatus } from '../../stores/useCanvasStore';
/** 审核状态 → 菜单显示文本 */
const AUDIT_LABELS: Record<AuditStatus, string> = {
pending: '标记为待审核',
approved: '标记为通过',
rework: '标记为返修',
deprecated: '标记为已弃用',
};
export interface MediaCardMenuOptions {
/** 是否可审核(非 Assets 阶段为 true */
isAuditable: boolean;
onSetAudit: (status: AuditStatus) => void;
onCopyPath: () => void;
onOpenFolder: () => void;
}
export function getMediaCardMenuItems(opts: MediaCardMenuOptions): ContextMenuItems {
const items: ContextMenuItems = [];
if (opts.isAuditable) {
const statuses: AuditStatus[] = ['pending', 'approved', 'rework', 'deprecated'];
for (const status of statuses) {
items.push({
key: `audit-${status}`,
label: AUDIT_LABELS[status],
onClick: () => opts.onSetAudit(status),
});
}
items.push('divider');
}
// 底部通用操作
items.push(
{ key: 'copy-path', label: '复制文件路径', onClick: opts.onCopyPath },
{ key: 'open-folder', label: '在文件夹中打开', onClick: opts.onOpenFolder },
);
return items;
}

View File

@@ -0,0 +1,46 @@
// ============================================================
// project-menu.ts — 项目选择器右键菜单定义
//
// 菜单结构:新建项目 | 统计项目 | 批量创建镜头 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface ProjectMenuOptions {
/** 是否可以新建(如未选工作区则禁用) */
canCreate: boolean;
/** 当前选中项目(用于"在文件夹中打开" */
canOpenFolder: boolean;
onCreate: () => void;
onStats: () => void;
onBatchShots: () => void;
onOpenFolder: () => void;
}
export function getProjectMenuItems(opts: ProjectMenuOptions): ContextMenuItems {
return [
{
key: 'new-project',
label: '新建项目',
onClick: opts.onCreate,
disabled: !opts.canCreate,
},
{
key: 'stats-project',
label: '统计项目',
onClick: opts.onStats,
},
{
key: 'batch-shots',
label: '批量创建镜头',
onClick: opts.onBatchShots,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: opts.onOpenFolder,
disabled: !opts.canOpenFolder,
},
];
}

View File

@@ -0,0 +1,50 @@
// ============================================================
// shot-menu.ts — 镜头列表项右键菜单定义
//
// 菜单结构:新建镜头 | —— | 重命名 | 删除 | —— | 在文件夹中打开
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface ShotMenuOptions {
/** 是否可操作(未选项目/集数时禁用) */
canOperate: boolean;
/** 选中的镜头名称 */
shotName: string;
onNewShot: () => void;
onRename: (shotName: string) => void;
onDelete: (shotName: string) => void;
onOpenFolder: (shotName: string) => void;
}
export function getShotMenuItems(opts: ShotMenuOptions): ContextMenuItems {
return [
{
key: 'new-shot',
label: '新建镜头',
onClick: opts.onNewShot,
disabled: !opts.canOperate,
},
'divider',
{
key: 'rename-shot',
label: '重命名',
onClick: () => opts.onRename(opts.shotName),
disabled: !opts.canOperate,
},
{
key: 'delete-shot',
label: '删除',
onClick: () => opts.onDelete(opts.shotName),
disabled: !opts.canOperate,
danger: true,
},
'divider',
{
key: 'open-folder',
label: '在文件夹中打开',
onClick: () => opts.onOpenFolder(opts.shotName),
disabled: !opts.canOperate,
},
];
}

View File

@@ -8,7 +8,7 @@
// ============================================================
import { useEffect, useCallback } from 'react';
import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore';
import { useCanvasStore, type InteractionMode } from '../stores/useCanvasStore';
export function useCanvasInteraction() {
const interactionMode = useCanvasStore((s) => s.interactionMode);

View File

@@ -10,7 +10,8 @@
// ============================================================
import { useEffect, useRef, useCallback } from 'react';
import { useCanvasStore } from '../store/useCanvasStore';
import { logger } from '@/shared/utils/bus';
import { useCanvasStore } from '../stores/useCanvasStore';
export function useDirectoryScan() {
// 选择性订阅
@@ -112,7 +113,7 @@ export function useDirectoryScan() {
setScanProgress(null);
} catch (err) {
if (controller.signal.aborted || epoch !== epochRef.current) return;
console.error('[useDirectoryScan] ❌ 扫描失败:', err);
logger.warn('canvas', '目录扫描失败', err instanceof Error ? err : new Error(String(err)));
} finally {
if (epoch === epochRef.current) {
setIsScanning(false);

View File

@@ -6,7 +6,7 @@
// ============================================================
import { useRef, useCallback } from 'react';
import { useCanvasStore, getCanvasStore } from '../store/useCanvasStore';
import { useCanvasStore, getCanvasStore } from '../stores/useCanvasStore';
import type { SidecarMetadata } from '../utils/ICanvasDataSource';
export function useSidecarSync() {

View File

@@ -5,7 +5,7 @@
// 非 Electron 环境(浏览器开发模式)降级返回空数据/抛出友好错误。
// ============================================================
import { hasIpc, safeIpcInvoke } from '@/utils/bus';
import { hasIpc, safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
import type {
ICanvasDataSource,

View File

@@ -2,7 +2,7 @@
// constants.ts — 无限画布常量配置
// ============================================================
import type { StageType } from '../store/useCanvasStore';
import type { StageType } from '../stores/useCanvasStore';
// ---------- 布局 ----------

View File

@@ -4,7 +4,7 @@
// 实现 Grid 模式和 Single 模式的坐标计算。
// ============================================================
import type { CatalogItem } from '../store/useCanvasStore';
import type { CatalogItem } from '../stores/useCanvasStore';
import {
ITEM_W,
ITEM_H,

View File

@@ -9,7 +9,7 @@
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
// ============================================================
import type { StageType } from '../store/useCanvasStore';
import type { StageType } from '../stores/useCanvasStore';
// ---------- 阶段检测 ----------

View File

@@ -10,8 +10,9 @@
// ============================================================
import { useMemo, useEffect } from 'react';
import { LocalFileDataSource } from './utils/LocalFileDataSource';
import { createCanvasStoreProvider, useCanvasStore } from './store/useCanvasStore';
import { logger } from '@/shared/utils/bus';
import { LocalFileDataSource } from '../utils/LocalFileDataSource';
import { createCanvasStoreProvider, useCanvasStore } from '../stores/useCanvasStore';
import { CanvasPage } from './CanvasPage';
/** localStorage 键名 —— 与 SettingsProvider 保持一致 */
@@ -85,7 +86,7 @@ function WorkspaceInitializer() {
setProjects(projects);
})
.catch((err) => {
console.error('[CanvasApp] ❌ 加载项目列表失败:', err);
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
});
}, [workspaceRoot, setProjects, getDataSource]);

View File

@@ -14,9 +14,9 @@
import { useState, useCallback } from 'react';
import { Layout } from 'antd';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from './store/useCanvasStore';
import { useDirectoryScan } from './hooks/useDirectoryScan';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../stores/useCanvasStore';
import { useDirectoryScan } from '../hooks/useDirectoryScan';
import { CanvasToolbar } from './toolbar/CanvasToolbar';
import { CanvasViewport } from './core/CanvasViewport';
import { MediaPreview } from './core/MediaPreview';

View File

@@ -10,8 +10,9 @@
// ============================================================
import { useRef, useCallback, useEffect, useState, useMemo } from 'react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../store/useCanvasStore';
import { logger } from '@/shared/utils/bus';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { MediaCard } from './MediaCard';
import { MediaCardContextMenu } from './MediaCardContextMenu';
import { PagerOverlay, ZoomOverlay, ModeSwitch } from './ViewportControls';
@@ -21,8 +22,8 @@ import {
gridCanvasSize,
singleLayout,
singlePageInfo,
} from '../utils/layout';
import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../utils/constants';
} from '../../utils/layout';
import { ITEM_H, ITEM_SPACING, ROW_LABEL_WIDTH, MAX_ITEMS_PER_PAGE } from '../../utils/constants';
/** 缩放倍率范围 */
const ZOOM_MIN = 0.02;
@@ -199,7 +200,7 @@ export function CanvasViewport({ onPreviewOpen, previewActive }: CanvasViewportP
try {
e.preventDefault();
} catch (err) {
console.error('[CanvasViewport] ⚠️ preventDefault 失败passive 冲突):', err);
logger.warn('canvas', 'preventDefault 失败passive 冲突)', err instanceof Error ? err : undefined);
return;
}
// 从 ref 读取当前 zoom避免 Zustand v5 getState() 在原生事件中返回旧值)

View File

@@ -10,9 +10,10 @@
// ============================================================
import { useState, useEffect, useRef } from 'react';
import { useCanvasStore } from '../store/useCanvasStore';
import { LOD_LEVELS } from '../utils/constants';
import type { LODLevel } from '../utils/constants';
import { logger } from '@/shared/utils/bus';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { LOD_LEVELS } from '../../utils/constants';
import type { LODLevel } from '../../utils/constants';
interface LODImageProps {
itemId: number;
@@ -79,7 +80,8 @@ export function LODImage({ itemId, filePath, targetLevel }: LODImageProps) {
loadThumbnail(itemId, url, targetLevel);
loadingRef.current = false;
})
.catch(() => {
.catch((err) => {
logger.warn('canvas', 'LOD 图片加载失败', err instanceof Error ? err : undefined);
setLoading(false);
setError(true);
loadingRef.current = false;

View File

@@ -12,8 +12,8 @@
import { useCallback, memo } from 'react';
import { LODImage, zoomToLODIndex } from './LODImage';
import { useCanvasStore, type InteractionMode } from '../store/useCanvasStore';
import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../utils/constants';
import { useCanvasStore, type InteractionMode } from '../../stores/useCanvasStore';
import { AUDIT_STATUS_COLORS, ITEM_W, ITEM_H } from '../../utils/constants';
export interface MediaCardProps {
itemId: number;

View File

@@ -0,0 +1,87 @@
// ============================================================
// MediaCardContextMenu — 画布媒体卡片右键菜单
//
// 菜单项定义见 menus/media-card-menu.ts
// 组件职责:读取 Store 状态 + 绑定 IPC 操作 → 生成菜单项
// ============================================================
import { message } from 'antd';
import { useCallback } from 'react';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useSidecarSync } from '../../hooks/useSidecarSync';
import { getMediaCardMenuItems } from '../../controllers/menus';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { safeIpcInvoke } from '@/shared/utils/bus';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
import { ITEM_W, ITEM_H } from '../../utils/constants';
import type { AuditStatus } from '../../stores/useCanvasStore';
interface MediaCardContextMenuProps {
itemId: number;
children: React.ReactNode;
/** 画布中的绝对定位 */
x: number;
y: number;
}
export function MediaCardContextMenu({ itemId, children, x, y }: MediaCardContextMenuProps) {
const item = useCanvasStore((s) => s.items.find((i) => i.id === itemId));
const { commitMetadata } = useSidecarSync();
// ── 回调:复制文件路径 ──
const handleCopyPath = useCallback(async () => {
if (!item) return;
try {
await navigator.clipboard.writeText(item.path);
void message.success('已复制文件路径');
} catch {
void message.error('复制失败');
}
}, [item]);
// ── 回调:在文件夹中打开 ──
const handleOpenFolder = useCallback(async () => {
if (!item) return;
await safeIpcInvoke(BIDIRECTIONAL.SHOW_ITEM_IN_FOLDER, {
filePath: item.path,
});
}, [item]);
// ── 回调:设置审核状态 ──
const handleSetAudit = useCallback(
(status: AuditStatus) => {
if (!item) return;
commitMetadata(itemId, item.path, { audit_status: status });
void message.success(`审核状态已更新为:${status}`);
},
[item, itemId, commitMetadata],
);
if (!item) return <>{children}</>;
const isAuditable = ['Storyboard', 'Keyframe', 'Video', 'Audio', 'LipSync'].includes(item.stage);
// ── 通过菜单定义函数生成 ContextMenuItems ──
const menuItems = getMediaCardMenuItems({
isAuditable,
onSetAudit: handleSetAudit,
onCopyPath: handleCopyPath,
onOpenFolder: handleOpenFolder,
});
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width: ITEM_W,
height: ITEM_H,
}}
>
<ContextMenu items={menuItems}>
{children}
</ContextMenu>
</div>
);
}

View File

@@ -13,7 +13,7 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Button } from 'antd';
import { CloseOutlined, LeftOutlined, RightOutlined, ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons';
import { useCanvasStore } from '../store/useCanvasStore';
import { useCanvasStore } from '../../stores/useCanvasStore';
interface MediaPreviewProps {
itemId: number | null;

View File

@@ -16,11 +16,11 @@ import {
CheckSquareOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../store/useCanvasStore';
import { useCanvasInteraction } from '../hooks/useCanvasInteraction';
import { gridPageInfo, singlePageInfo } from '../utils/layout';
import { MAX_ITEMS_PER_PAGE } from '../utils/constants';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useCanvasInteraction } from '../../hooks/useCanvasInteraction';
import { gridPageInfo, singlePageInfo } from '../../utils/layout';
import { MAX_ITEMS_PER_PAGE } from '../../utils/constants';
// ── 分页控件 ──

View File

@@ -5,17 +5,21 @@
// 1. 项目下拉选择器ProjectSelector
// 2. 集数下拉选择器EpisodeSelectorAssets 阶段隐藏)
// 3. 镜头号搜索输入框
// 4. 镜头单选列表(ShotList上下滚动
// 4. 镜头单选列表(每项支持右键菜单,定义见 menus/shot-menu.ts
// 5. 刷新镜头列表按钮
// ============================================================
import { useState, useCallback } from 'react';
import { Input, Button, App } from 'antd';
import { logger } from '@/shared/utils/bus';
import { Input, Button, App, Modal } from 'antd';
import { SearchOutlined, ReloadOutlined, FolderOpenOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../store/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ProjectSelector } from './ProjectSelector';
import { EpisodeSelector } from './EpisodeSelector';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { getShotMenuItems } from '../../controllers/menus';
import { DirectoryCreateModal } from './DirectoryCreateModal';
export function CanvasSidebar() {
const { isDark } = useTheme();
@@ -37,6 +41,7 @@ export function CanvasSidebar() {
// ── 本地状态 ──
const [shotSearchQuery, setShotSearchQuery] = useState('');
const [isRefreshing, setIsRefreshing] = useState(false);
const [createShotOpen, setCreateShotOpen] = useState(false);
const isAssets = currentStage === 'Assets';
@@ -53,6 +58,11 @@ export function CanvasSidebar() {
[selectShot],
);
// ── 镜头目录基础路径 ──
const shotsBasePath = workspaceRoot && selectedProject
? `${workspaceRoot}\\${selectedProject}\\shots`
: '';
// ── 刷新镜头列表 ──
const handleRefreshShots = useCallback(async () => {
if (!workspaceRoot || !selectedProject || !selectedEpisode) return;
@@ -64,6 +74,7 @@ export function CanvasSidebar() {
setShots(updated);
message.success(`镜头列表已刷新(${updated.length} 个镜头)`);
} catch (err) {
logger.warn('canvas', '刷新镜头列表失败', err instanceof Error ? err : undefined);
message.error(`刷新失败: ${(err as Error).message}`);
} finally {
setIsRefreshing(false);
@@ -81,14 +92,86 @@ export function CanvasSidebar() {
const projects = await dataSource.listSubdirs(dir);
setProjects(projects);
} catch (err) {
console.error('[CanvasSidebar] 加载项目列表失败:', err);
logger.warn('canvas', '加载项目列表失败', err instanceof Error ? err : new Error(String(err)));
}
}
} catch (err) {
logger.warn('canvas', '打开目录失败', err instanceof Error ? err : undefined);
message.error((err as Error).message || '打开目录失败');
}
};
// ── 右键菜单操作 ──
// 新建镜头
const handleCreateShot = useCallback(async (dirName: string) => {
if (!shotsBasePath) return;
try {
const dataSource = getDataSource();
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
await dataSource.createDirectory(episodePath, dirName);
// 刷新镜头列表
const updated = await dataSource.listSubdirs(episodePath);
setShots(updated);
// 自动选中
selectShot(dirName);
message.success(`镜头 "${dirName}" 已创建`);
} catch (err) {
logger.warn('canvas', '创建镜头失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, selectShot, message]);
// 重命名镜头
const handleRenameShot = useCallback((_shotName: string) => {
message.info('重命名功能待实现');
}, [message]);
// 删除镜头
const handleDeleteShot = useCallback((shotName: string) => {
if (!shotsBasePath || !selectedEpisode) return;
Modal.confirm({
title: '确认删除',
content: `确定要删除镜头「${shotName}」及其所有内容吗?此操作不可撤销。`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const dataSource = getDataSource();
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
await dataSource.deleteFile(shotPath);
// 刷新列表
const episodePath = `${shotsBasePath}\\${selectedEpisode}`;
const updated = await dataSource.listSubdirs(episodePath);
setShots(updated);
message.success(`已删除镜头「${shotName}`);
} catch (err) {
logger.warn('canvas', '删除镜头失败', err instanceof Error ? err : undefined);
message.error(`删除失败: ${(err as Error).message}`);
}
},
});
}, [shotsBasePath, selectedEpisode, getDataSource, setShots, message]);
// 在文件夹中打开
const handleOpenShotFolder = useCallback((shotName: string) => {
if (!shotsBasePath || !selectedEpisode) return;
const shotPath = `${shotsBasePath}\\${selectedEpisode}\\${shotName}`;
window.fileStorage?.showItemInFolder(shotPath);
}, [shotsBasePath, selectedEpisode]);
// 生成单个镜头项的右键菜单
const getShotContextItems = useCallback((shotName: string) =>
getShotMenuItems({
canOperate: !!workspaceRoot && !!selectedProject && !!selectedEpisode,
shotName,
onNewShot: () => setCreateShotOpen(true),
onRename: handleRenameShot,
onDelete: handleDeleteShot,
onOpenFolder: handleOpenShotFolder,
}), [workspaceRoot, selectedProject, selectedEpisode, handleRenameShot, handleDeleteShot, handleOpenShotFolder]);
// ── 样式变量 ──
const bg = isDark ? '#0F0D21' : '#F5F3FF';
const borderColor = isDark ? '#2D2A4A' : '#E5E0F0';
@@ -203,8 +286,7 @@ export function CanvasSidebar() {
</div>
)}
{/* ── 镜头列表 ── */}
{/* minHeight: 0 是 flexbox 关键修复:防止列表内容撑开容器、导致底部按钮被推出视口 */}
{/* ── 镜头列表(每项支持右键菜单) ── */}
<div
style={{
flex: 1,
@@ -212,7 +294,6 @@ export function CanvasSidebar() {
overflowX: 'hidden',
minHeight: 0,
padding: '2px 0',
// 自定义滚动条样式
scrollbarWidth: 'thin' as const,
scrollbarColor: isDark
? '#3E3A6A transparent'
@@ -271,33 +352,34 @@ export function CanvasSidebar() {
{filteredShots.map((shot) => {
const active = shot === selectedShot;
return (
<div
key={shot}
onClick={() => handleShotClick(shot)}
style={{
padding: '5px 12px',
cursor: 'pointer',
fontSize: 13,
color: active ? activeColor : textColor,
background: active ? activeBg : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
borderLeft: active
? `3px solid ${activeColor}`
: '3px solid transparent',
}}
onMouseEnter={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background =
'transparent';
}}
>
{shot}
</div>
<ContextMenu key={shot} items={getShotContextItems(shot)}>
<div
onClick={() => handleShotClick(shot)}
style={{
padding: '5px 12px',
cursor: 'pointer',
fontSize: 13,
color: active ? activeColor : textColor,
background: active ? activeBg : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
borderLeft: active
? `3px solid ${activeColor}`
: '3px solid transparent',
}}
onMouseEnter={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background = hoverBg;
}}
onMouseLeave={(e) => {
if (!active)
(e.currentTarget as HTMLElement).style.background =
'transparent';
}}
>
{shot}
</div>
</ContextMenu>
);
})}
</>
@@ -335,6 +417,14 @@ export function CanvasSidebar() {
</Button>
</div>
)}
{/* 新建镜头 Modal */}
<DirectoryCreateModal
open={createShotOpen}
title="新建镜头"
onOk={handleCreateShot}
onCancel={() => setCreateShotOpen(false)}
/>
</div>
);
}

View File

@@ -2,14 +2,16 @@
// EpisodeSelector — 集数下拉选择器
//
// 列出 selectedProject/shots/ 下的子文件夹作为可选集数。
// 右击可新建集数。Assets 阶段隐藏。
// 右击菜单项定义见 menus/episode-menu.ts
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Select, App } from 'antd';
import { useCanvasStore } from '../store/useCanvasStore';
import { ContextMenu } from './ContextMenu';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { DirectoryCreateModal } from './DirectoryCreateModal';
import { getEpisodeMenuItems } from '../../controllers/menus';
export function EpisodeSelector() {
const { message } = App.useApp();
@@ -28,7 +30,6 @@ export function EpisodeSelector() {
const handleSelect = useCallback(
async (name: string) => {
selectEpisode(name);
// 异步加载该集数下的镜头列表
try {
if (!workspaceRoot || !selectedProject) return;
const dataSource = getDataSource();
@@ -36,7 +37,7 @@ export function EpisodeSelector() {
const shots = await dataSource.listSubdirs(shotsPath);
setShots(shots);
} catch (err) {
console.error('[EpisodeSelector] 加载镜头列表失败:', err);
logger.warn('canvas', '加载镜头列表失败', err instanceof Error ? err : new Error(String(err)));
}
},
[workspaceRoot, selectedProject, selectEpisode, setShots, getDataSource],
@@ -49,19 +50,33 @@ export function EpisodeSelector() {
const dataSource = getDataSource();
const parentPath = `${workspaceRoot}\\${selectedProject}\\shots`;
await dataSource.createDirectory(parentPath, dirName);
// 刷新集数列表
const updated = await dataSource.listSubdirs(parentPath);
setEpisodes(updated);
// 自动选中新集数
await handleSelect(dirName);
message.success(`集数 "${dirName}" 已创建`);
} catch (err) {
message.error(`创建失败: ${(err as Error).message}`);
logger.warn('canvas', '创建集数失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
},
[workspaceRoot, selectedProject, getDataSource, setEpisodes, handleSelect, message],
);
// ── 右键菜单定义 ──
const menuItems = getEpisodeMenuItems({
canCreate: !!selectedProject,
canOpenFolder: !!selectedProject && !!selectedEpisode && !!workspaceRoot,
onCreate: () => setCreateOpen(true),
onStats: () => message.info('统计集数功能待实现'),
onOpenFolder: () => {
if (workspaceRoot && selectedProject && selectedEpisode) {
const path = `${workspaceRoot}\\${selectedProject}\\shots\\${selectedEpisode}`;
window.fileStorage?.showItemInFolder(path);
}
},
});
// Assets 阶段隐藏集数选择器
if (currentStage === 'Assets') return null;
@@ -69,15 +84,7 @@ export function EpisodeSelector() {
return (
<>
<ContextMenu
items={[
{
label: '新建集数',
onClick: () => setCreateOpen(true),
disabled: !selectedProject,
},
]}
>
<ContextMenu items={menuItems}>
<Select
value={selectedEpisode}
onChange={handleSelect}

View File

@@ -2,14 +2,16 @@
// ProjectSelector — 项目下拉选择器
//
// 列出 workspaceRoot 下的子文件夹作为可选项目。
// 右击可新建项目。
// 右击菜单项定义见 menus/project-menu.ts
// ============================================================
import { useState, useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Select, App } from 'antd';
import { useCanvasStore } from '../store/useCanvasStore';
import { ContextMenu } from './ContextMenu';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { DirectoryCreateModal } from './DirectoryCreateModal';
import { getProjectMenuItems } from '../../controllers/menus';
export function ProjectSelector() {
const { message } = App.useApp();
@@ -26,14 +28,13 @@ export function ProjectSelector() {
const handleSelect = useCallback(
async (name: string) => {
selectProject(name);
// 异步加载该项目的集数列表
try {
const dataSource = getDataSource();
const epPath = `${workspaceRoot}\\${name}\\shots`;
const episodes = await dataSource.listSubdirs(epPath);
setEpisodes(episodes);
} catch (err) {
console.error('[ProjectSelector] 加载集数列表失败:', err);
logger.warn('canvas', '加载集数列表失败', err instanceof Error ? err : new Error(String(err)));
}
},
[workspaceRoot, selectProject, setEpisodes, getDataSource],
@@ -45,32 +46,39 @@ export function ProjectSelector() {
try {
const dataSource = getDataSource();
await dataSource.createDirectory(workspaceRoot, dirName);
// 刷新项目列表
const updated = await dataSource.listSubdirs(workspaceRoot);
setProjects(updated);
// 自动选中新项目
await handleSelect(dirName);
message.success(`项目 "${dirName}" 已创建`);
} catch (err) {
message.error(`创建失败: ${(err as Error).message}`);
logger.warn('canvas', '创建项目失败', err instanceof Error ? err : undefined);
message.error(`创建失败: ${(err as Error).message}`);
}
},
[workspaceRoot, getDataSource, setProjects, handleSelect, message],
);
// ── 右键菜单定义 ──
const menuItems = getProjectMenuItems({
canCreate: !!workspaceRoot,
canOpenFolder: !!selectedProject && !!workspaceRoot,
onCreate: () => setCreateOpen(true),
onStats: () => message.info('统计项目功能待实现'),
onBatchShots: () => message.info('批量创建镜头功能待实现'),
onOpenFolder: () => {
if (workspaceRoot && selectedProject) {
const path = `${workspaceRoot}\\${selectedProject}`;
window.fileStorage?.showItemInFolder(path);
}
},
});
const options = projects.map((p) => ({ value: p, label: p }));
return (
<>
<ContextMenu
items={[
{
label: '新建项目',
onClick: () => setCreateOpen(true),
disabled: !workspaceRoot,
},
]}
>
<ContextMenu items={menuItems}>
<Select
value={selectedProject}
onChange={handleSelect}

View File

@@ -6,8 +6,8 @@
// ============================================================
import { useCallback } from 'react';
import { useCanvasStore } from '../store/useCanvasStore';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
const SHOT_LIST_WIDTH = 200;

View File

@@ -6,6 +6,4 @@ export { ProjectSelector } from './ProjectSelector';
export { EpisodeSelector } from './EpisodeSelector';
export { ShotList } from './ShotList';
export { CanvasSidebar } from './CanvasSidebar';
export { ContextMenu } from './ContextMenu';
export type { ContextMenuItem } from './ContextMenu';
export { DirectoryCreateModal } from './DirectoryCreateModal';
// ContextMenu 已提升为全应用共享组件:@/shared/components/ContextMenuexport { DirectoryCreateModal } from './DirectoryCreateModal';

View File

@@ -9,8 +9,8 @@
import { useCallback } from 'react';
import { Input } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../store/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
export function CanvasFilterBar() {
const { isDark } = useTheme();

View File

@@ -8,8 +8,8 @@
// 注:项目/集数选择器已移至 CanvasSidebar。
// ============================================================
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore } from '../store/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore } from '../../stores/useCanvasStore';
import { StageTabs } from './StageTabs';
import { CategoryTabs } from './CategoryTabs';
import { CanvasFilterBar } from './CanvasFilterBar';

View File

@@ -14,8 +14,8 @@
// ============================================================
import { useMemo } from 'react';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../store/useCanvasStore';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore, type AssetTypeFilter, type AuditStatus } from '../../stores/useCanvasStore';
interface CategoryTab {
key: string;

View File

@@ -9,11 +9,12 @@
// ============================================================
import { useCallback } from 'react';
import { logger } from '@/shared/utils/bus';
import { Button } from 'antd';
import { ExpandOutlined } from '@ant-design/icons';
import { useTheme } from '@/components/providers/ThemeProvider';
import { useCanvasStore, type StageType } from '../store/useCanvasStore';
import { STAGE_CONFIGS } from '../utils/constants';
import { useTheme } from '@/app/providers/ThemeProvider';
import { useCanvasStore, type StageType } from '../../stores/useCanvasStore';
import { STAGE_CONFIGS } from '../../utils/constants';
export function StageTabs() {
const { isDark } = useTheme();
@@ -27,7 +28,7 @@ export function StageTabs() {
const projectPath = getProjectPath();
window.canvas?.openWindow(projectPath ?? undefined);
} catch (err) {
console.error('[StageTabs] 打开无限画布窗口失败:', err);
logger.warn('canvas', '打开无限画布窗口失败', err instanceof Error ? err : new Error(String(err)));
}
}, [getProjectPath]);

View File

@@ -3,7 +3,7 @@
// GET /api/v1/banners
// ============================================================
import {get} from '../request';
import {get} from '@/shared/services/http';
// ---------- 路由常量 ----------

View File

@@ -0,0 +1,15 @@
// ============================================================
// menus — 首页右键菜单定义统一导出
// ============================================================
export { getTaskListMenuItems } from './task-list-menu';
export type { TaskListMenuOptions } from './task-list-menu';
export { getTextInputMenuItems } from './text-input-menu';
export type { TextInputMenuOptions } from './text-input-menu';
export { getMediaRefImageMenu, getMediaRefBlankMenu } from './media-ref-menu';
export type { MediaRefMenuOptions } from './media-ref-menu';
export { getPreviewImageMenu, getPreviewEmptyMenu } from './preview-area-menu';
export type { PreviewAreaMenuOptions } from './preview-area-menu';

View File

@@ -0,0 +1,51 @@
// ============================================================
// media-ref-menu.ts — 媒体参考输入框右键菜单定义
//
// 两个子场景:
// [预览图上右击]
// 打开文件列表 | 复制文件到粘贴板
// [空白区右击]
// 删除已选素材(需搭配左键单击多选)
//
// 菜单项由调用方根据右击位置target动态选择使用哪组定义。
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface MediaRefMenuOptions {
/** 是否有已选素材(影响"删除已选素材"的禁用状态) */
hasSelection: boolean;
onOpenFileList: () => void;
onCopyToClipboard: () => void;
onDeleteSelected: () => void;
}
/** 在预览图上右击(文件可点区域) */
export function getMediaRefImageMenu(opts: MediaRefMenuOptions): ContextMenuItems {
return [
{
key: 'open-file-list',
label: '打开文件列表',
onClick: opts.onOpenFileList,
},
{
key: 'copy-to-clipboard',
label: '复制文件到粘贴板',
onClick: opts.onCopyToClipboard,
},
];
}
/** 在空白区域右击(批量操作) */
export function getMediaRefBlankMenu(opts: MediaRefMenuOptions): ContextMenuItems {
return [
{
key: 'delete-selected',
label: '删除已选素材',
onClick: opts.onDeleteSelected,
disabled: !opts.hasSelection,
danger: true,
},
];
}

View File

@@ -0,0 +1,49 @@
// ============================================================
// preview-area-menu.ts — 任务输出预览区域右键菜单定义
//
// 两个子场景:
// [有预览图]
// 打开文件所在位置 | 复制到粘贴板(图片)
// [无预览图 / 未选择任务]
// 打开保存路径
//
// 菜单项由调用方根据当前状态动态选择。
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface PreviewAreaMenuOptions {
/** 当前是否有预览内容 */
hasPreview: boolean;
onOpenFileLocation: () => void;
onCopyImageToClipboard: () => void;
onOpenSavePath: () => void;
}
/** 有预览图时 */
export function getPreviewImageMenu(opts: PreviewAreaMenuOptions): ContextMenuItems {
return [
{
key: 'open-file-location',
label: '打开文件所在位置',
onClick: opts.onOpenFileLocation,
},
{
key: 'copy-image',
label: '复制到粘贴板(图片)',
onClick: opts.onCopyImageToClipboard,
},
];
}
/** 无预览图 / 未选择任务时(直接触发打开保存路径,返回单一项) */
export function getPreviewEmptyMenu(opts: PreviewAreaMenuOptions): ContextMenuItems {
return [
{
key: 'open-save-path',
label: '打开保存路径',
onClick: opts.onOpenSavePath,
},
];
}

View File

@@ -0,0 +1,87 @@
// ============================================================
// task-list-menu.ts — 任务列表行右键菜单定义
//
// 菜单结构:
// 打开输出目录 | 复制资源链接
// 重新编辑 | 再次生成 | 拉取结果 | 重新下载 | 收藏
// ——
// 删除记录
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface TaskListMenuOptions {
/** 任务输出目录是否可用(仅 success 状态) */
canOpenOutput: boolean;
/** 是否可重新编辑 */
canReedit: boolean;
/** 是否可再次生成 */
canRegen: boolean;
/** 是否可拉取结果 */
canPullResult: boolean;
/** 是否可重新下载 */
canRedownload: boolean;
/** 是否已收藏 */
isFavorited: boolean;
onOpenOutputDir: () => void;
onCopyResourceLink: () => void;
onReedit: () => void;
onRegen: () => void;
onPullResult: () => void;
onRedownload: () => void;
onToggleFavorite: () => void;
onDelete: () => void;
}
export function getTaskListMenuItems(opts: TaskListMenuOptions): ContextMenuItems {
return [
{
key: 'open-output',
label: '打开输出目录',
onClick: opts.onOpenOutputDir,
disabled: !opts.canOpenOutput,
},
{
key: 'copy-link',
label: '复制资源链接',
onClick: opts.onCopyResourceLink,
},
{
key: 'reedit',
label: '重新编辑',
onClick: opts.onReedit,
disabled: !opts.canReedit,
},
{
key: 'regen',
label: '再次生成',
onClick: opts.onRegen,
disabled: !opts.canRegen,
},
{
key: 'pull-result',
label: '拉取结果',
onClick: opts.onPullResult,
disabled: !opts.canPullResult,
},
{
key: 'redownload',
label: '重新下载',
onClick: opts.onRedownload,
disabled: !opts.canRedownload,
},
{
key: 'favorite',
label: opts.isFavorited ? '取消收藏' : '收藏',
onClick: opts.onToggleFavorite,
},
'divider',
{
key: 'delete',
label: '删除记录',
onClick: opts.onDelete,
danger: true,
},
];
}

View File

@@ -0,0 +1,110 @@
// ============================================================
// text-input-menu.ts — 提示词输入框右键菜单定义
//
// 菜单结构:
// 撤销 | 重做
// ——
// 剪切 | 复制 | 粘贴 | 清空
// ——
// 全选
// ——
// 增大字号 | 减小字号 | 恢复默认字号
//
// 注意:
// - 标准操作(撤销/重做/剪切/复制/粘贴/全选)通过 document.execCommand()
// 操作当前聚焦的 input/textarea 元素
// - 字号操作需要外部传入 getFocusedInput / setFontSize 回调
// - 建议在 ContextMenu 外层保持输入框聚焦(见 onContextMenu 处理)
// ============================================================
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
export interface TextInputMenuOptions {
/** 是否有选中文本(影响剪切/复制的禁用状态) */
hasSelection: boolean;
/** 输入框是否为空(影响清空的禁用状态) */
isEmpty: boolean;
/** 当前字号相对于默认字号的偏移(>0 增大,<0 减小0 默认) */
fontSizeLevel: number;
onUndo: () => void;
onRedo: () => void;
onCut: () => void;
onCopy: () => void;
onPaste: () => void;
onClear: () => void;
onSelectAll: () => void;
onIncreaseFont: () => void;
onDecreaseFont: () => void;
onResetFont: () => void;
}
export function getTextInputMenuItems(opts: TextInputMenuOptions): ContextMenuItems {
return [
{
key: 'undo',
label: '撤销',
shortcut: 'Ctrl+Z',
onClick: opts.onUndo,
},
{
key: 'redo',
label: '重做',
shortcut: 'Ctrl+Y',
onClick: opts.onRedo,
},
'divider',
{
key: 'cut',
label: '剪切',
shortcut: 'Ctrl+X',
onClick: opts.onCut,
disabled: !opts.hasSelection,
},
{
key: 'copy',
label: '复制',
shortcut: 'Ctrl+C',
onClick: opts.onCopy,
disabled: !opts.hasSelection,
},
{
key: 'paste',
label: '粘贴',
shortcut: 'Ctrl+V',
onClick: opts.onPaste,
},
{
key: 'clear',
label: '清空',
onClick: opts.onClear,
disabled: opts.isEmpty,
},
'divider',
{
key: 'select-all',
label: '全选',
shortcut: 'Ctrl+A',
onClick: opts.onSelectAll,
},
'divider',
{
key: 'increase-font',
label: '增大字号',
shortcut: 'Ctrl+Plus',
onClick: opts.onIncreaseFont,
},
{
key: 'decrease-font',
label: '减小字号',
shortcut: 'Ctrl+Minus',
onClick: opts.onDecreaseFont,
},
{
key: 'reset-font',
label: '恢复默认字号',
onClick: opts.onResetFont,
disabled: opts.fontSizeLevel === 0,
},
];
}

View File

@@ -0,0 +1,120 @@
// ============================================================
// text-input-ops.ts — 文本输入框通用操作工具
//
// 提供撤销/重做/剪切/复制/粘贴/清空/全选等标准操作,
// 操作当前聚焦的 input/textarea 元素。
//
// 用于 text-input-menu 的回调实现,避免在 JSX 中写 DOM 操作。
// ============================================================
/** 获取当前聚焦的输入元素,且仅在 input/textarea 类型时返回 */
function getFocusedInput(): HTMLInputElement | HTMLTextAreaElement | null {
const el = document.activeElement;
if (!el) return null;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return el as HTMLInputElement | HTMLTextAreaElement;
}
// contenteditable 也算可编辑区域
if ((el as HTMLElement).isContentEditable) {
return el as HTMLElement as HTMLTextAreaElement; // 近似execCommand 兼容
}
return null;
}
export function execUndo() {
document.execCommand('undo');
}
export function execRedo() {
document.execCommand('redo');
}
export function execCut() {
document.execCommand('cut');
}
export function execCopy() {
document.execCommand('copy');
}
export function execPaste() {
document.execCommand('paste');
}
export function execClear() {
const el = getFocusedInput();
if (!el) return;
// 记录撤销点
el.focus();
document.execCommand('selectAll');
document.execCommand('delete');
}
export function execSelectAll() {
const el = getFocusedInput();
if (!el) return;
el.focus();
el.select();
}
/** 检查当前聚焦输入框是否有选中文本 */
export function hasTextSelection(): boolean {
const el = getFocusedInput();
if (!el) return false;
return el.selectionStart !== el.selectionEnd;
}
/** 检查当前聚焦输入框是否为空 */
export function isInputEmpty(): boolean {
const el = getFocusedInput();
if (!el) return true;
return el.value.length === 0;
}
// ── 字号管理 ──
const DEFAULT_FONT_SIZE = 14;
const FONT_STEP = 2;
const FONT_MIN = 10;
const FONT_MAX = 28;
/** 获取当前输入框字号(或返回默认值) */
export function getFontSize(): number {
const el = getFocusedInput();
if (!el) return DEFAULT_FONT_SIZE;
const current = parseInt(window.getComputedStyle(el).fontSize, 10);
return Number.isNaN(current) ? DEFAULT_FONT_SIZE : current;
}
/** 计算字号偏移级别(相对于默认值,每级 ±FONT_STEP px */
export function getFontSizeLevel(): number {
const size = getFontSize();
return Math.round((size - DEFAULT_FONT_SIZE) / FONT_STEP);
}
export function execIncreaseFont() {
const el = getFocusedInput();
if (!el) return;
const current = getFontSize();
const next = Math.min(FONT_MAX, current + FONT_STEP);
if (next !== current) {
el.style.fontSize = `${next}px`;
}
}
export function execDecreaseFont() {
const el = getFocusedInput();
if (!el) return;
const current = getFontSize();
const next = Math.max(FONT_MIN, current - FONT_STEP);
if (next !== current) {
el.style.fontSize = `${next}px`;
}
}
export function execResetFont() {
const el = getFocusedInput();
if (!el) return;
el.style.fontSize = '';
}

View File

@@ -1,4 +1,4 @@
import {get} from '../request';
import {get} from '@/shared/services/http';
// 注意:后端 /api/v1/models 的 category 查询参数实际不可用(传参返回空列表),
// 因此客户端采用"全量拉取 + 按 category_name 字段分组"的策略。
// 后端分类 slug 格式为 img2img / txt2img 等,前端通过 CATEGORY_SLUG_MAP 映射。

View File

@@ -10,9 +10,9 @@
// 不做额外的 async/await 包装,由调用方决定是否 await。
// ============================================================
import {get, post} from '../request';
import {getDeviceId} from '@/utils/env';
import {Nullable} from "@/utils/display";
import {get, post} from '@/shared/services/http';
import {getDeviceId} from '@/shared/utils/env';
import {Nullable} from "@/shared/utils/display";
// ============================================================
// 基础枚举 / 字面量类型

View File

@@ -14,9 +14,9 @@
import { useMemo } from 'react';
import type { ComponentType } from 'react';
import type { ModelsListResponseBody } from '@/services/modules/models';
import { WIDGET_MAP, FALLBACK_WIDGET } from '../controls';
import type { WidgetProps } from '../controls';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
import { WIDGET_MAP, FALLBACK_WIDGET } from '../views/left/controls';
import type { WidgetProps } from '../views/left/controls';
// ---------- 输出类型 ----------

View File

@@ -7,8 +7,8 @@
// ============================================================
import { createContext, useContext } from 'react';
import type { ModelsListResponseBody } from '@/services/modules/models';
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
// ---------- Context 类型 ----------

View File

@@ -6,16 +6,16 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { theme as antTheme, Empty } from 'antd';
import { useAppContext } from '@/components/providers/AppProvider';
import type { ModelsListResponseBody } from '@/services/modules/models';
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import { LeftPanel } from './panels/LeftPanel';
import { CenterPanel } from './panels/CenterPanel';
import { RightPanel } from './panels/RightPanel';
import { useAppContext } from '@/app/providers/AppProvider';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
import type { TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
import { LeftPanel } from './left/LeftPanel';
import { CenterPanel } from './center/CenterPanel';
import { RightPanel } from './right/RightPanel';
import { Typography } from 'antd';
import { HomeContext } from './HomeContext';
import { HomeContext } from '../stores/HomeContext';
const { Text } = Typography;

View File

@@ -3,7 +3,7 @@
// ============================================================
import { theme as antTheme } from 'antd';
import { ModelInputForm } from '../features/ModelInputForm';
import { ModelInputForm } from '../right/ModelInputForm';
export function CenterPanel() {
const { token } = antTheme.useToken();

View File

@@ -7,9 +7,9 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { theme as antTheme } from 'antd';
import { ModelSelector } from '../features/ModelSelector';
import { TaskHistory } from '../features/TaskHistory';
import { useModelList } from '@/hooks/use-api';
import { ModelSelector } from '../right/ModelSelector';
import { TaskHistory } from '../right/TaskHistory';
import { useModelList } from '@/shared/hooks/use-api';
// ---------- 常量 ----------

View File

@@ -19,7 +19,10 @@ import {
} from '@ant-design/icons';
import type { UploadFile, RcFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
import { useFileUpload } from '@/shared/hooks/use-api';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { getMediaRefImageMenu, getMediaRefBlankMenu } from '../../../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
const { TextArea } = Input;
const { Dragger } = Upload;
@@ -85,10 +88,12 @@ interface UploadSectionProps {
label: string;
customRequest: ReturnType<typeof useFileUpload>['customRequest'];
onChange: (urls: string[]) => void;
/** 可选:上传预览图区域的右键菜单项 */
previewContextMenu?: ContextMenuItems;
}
function UploadSection({
type, urls, maxCount, maxSizeMb, disabled, icon, label, customRequest, onChange,
type, urls, maxCount, maxSizeMb, disabled, icon, label, customRequest, onChange, previewContextMenu,
}: UploadSectionProps) {
const fileList = useMemo(() => urlsToFileList(urls, type), [urls, type]);
@@ -114,12 +119,8 @@ function UploadSection({
if (maxCount <= 0) return null;
return (
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{icon} {label} {maxCount}
</Text>
<Dragger
const draggerNode = (
<Dragger
multiple={maxCount > 1}
listType="picture"
accept={type === 'image' ? 'image/*' : type === 'video' ? 'video/*' : 'audio/*'}
@@ -136,6 +137,18 @@ function UploadSection({
<p className="ant-upload-text">{label}</p>
<p className="ant-upload-hint"> {maxSizeMb}MB</p>
</Dragger>
);
return (
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 4 }}>
{icon} {label} {maxCount}
</Text>
{previewContextMenu ? (
<ContextMenu items={previewContextMenu}>{draggerNode}</ContextMenu>
) : (
draggerNode
)}
</div>
);
}
@@ -168,7 +181,47 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
[emit],
);
// ── 右键菜单 ──
const totalUploadedUrls = [...cv.imageUrls, ...cv.videoUrls, ...cv.audioUrls];
const hasSelection = totalUploadedUrls.length > 0;
const handleOpenFileList = useCallback(() => {
message.info('打开文件列表功能待实现');
}, []);
const handleCopyToClipboard = useCallback(async () => {
if (totalUploadedUrls.length > 0) {
try {
await navigator.clipboard.writeText(totalUploadedUrls.join('\n'));
message.success('已复制文件链接');
} catch {
message.error('复制失败');
}
}
}, [totalUploadedUrls]);
const handleDeleteSelected = useCallback(() => {
emit({ imageUrls: [], videoUrls: [], audioUrls: [] });
message.success('已清空已选素材');
}, [emit]);
const imageMenuItems = getMediaRefImageMenu({
hasSelection,
onOpenFileList: handleOpenFileList,
onCopyToClipboard: handleCopyToClipboard,
onDeleteSelected: handleDeleteSelected,
});
const blankMenuItems = getMediaRefBlankMenu({
hasSelection,
onOpenFileList: handleOpenFileList,
onCopyToClipboard: handleCopyToClipboard,
onDeleteSelected: handleDeleteSelected,
});
return (
<ContextMenu items={blankMenuItems}>
<div
style={{
border: `1px dashed ${token.colorBorder}`,
@@ -211,6 +264,7 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
label="参考图片"
customRequest={customRequest}
onChange={(urls) => emit({ imageUrls: urls })}
previewContextMenu={imageMenuItems}
/>
)}
@@ -226,6 +280,7 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
label="参考视频"
customRequest={customRequest}
onChange={(urls) => emit({ videoUrls: urls })}
previewContextMenu={imageMenuItems}
/>
)}
@@ -241,8 +296,10 @@ export function SeedanceContent({ value, onChange, disabled, config }: WidgetPro
label="参考音频"
customRequest={customRequest}
onChange={(urls) => emit({ audioUrls: urls })}
previewContextMenu={imageMenuItems}
/>
)}
</div>
</ContextMenu>
);
}

View File

@@ -10,8 +10,8 @@ import { Upload } from 'antd';
import { InboxOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import type { WidgetProps } from './types';
import { useFileUpload } from '@/hooks/use-api';
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from '@/utils/media-upload';
import { useFileUpload } from '@/shared/hooks/use-api';
import { buildAccept, createMediaBeforeUpload, VIDEO_EXTENSIONS } from '@/shared/utils/media-upload';
const { Dragger } = Upload;

View File

@@ -9,6 +9,7 @@
import { Input, InputNumber, Select, Switch } from 'antd';
import type { WidgetProps } from './types';
import { normalizeSizeDisplay } from '@/shared/utils/size-format';
// ════ Checkbox (checkbox → Switch) ════
@@ -59,7 +60,9 @@ export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
const enumValues = config.enumValues as string[] | undefined;
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
const rawOptions = enumValues?.map((v) => ({ label: v, value: v })) || [];
// 统一尺寸显示格式将像素值1024x2048/宽高比统一为简化宽高比
const options = normalizeSizeDisplay(rawOptions);
return (
<Select

View File

@@ -12,13 +12,13 @@ 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 '@/hooks/use-api';
import { useFileUpload } from '@/shared/hooks/use-api';
import {
buildAccept,
buildVideoAccept,
createMediaBeforeUpload,
createVideoBeforeUpload,
} from '@/utils/media-upload';
} from '@/shared/utils/media-upload';
const { Text } = Typography;

View File

@@ -13,8 +13,8 @@ import { Carousel, Skeleton, Button } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel';
import { useBannerList } from '@/hooks/use-api';
import type { Banner } from '@/services/modules/banner';
import { useBannerList } from '@/shared/hooks/use-api';
import type { Banner } from '@/modules/home/controllers/banner-api';
// ---------- 组件 Props ----------

View File

@@ -22,24 +22,32 @@ import {
theme as antTheme,
message,
Modal,
Dropdown,
} from 'antd';
import type { FormInstance } from 'antd';
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/pages/home/HomeContext';
import { useAppContext } from '@/components/providers/AppProvider';
import { useSubmitTask } from '@/hooks/use-api';
import { emit, EVENTS } from '@/utils/bus';
import { OutputTypeMap } from '@/services/modules/task';
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../hooks/useModelUI';
import { useHomeContext } from '@/modules/home/stores/HomeContext';
import { useAppContext } from '@/app/providers/AppProvider';
import { useSubmitTask } from '@/shared/hooks/use-api';
import { emit, EVENTS } from '@/shared/utils/bus';
import { OutputTypeMap } from '@/modules/home/controllers/task-api';
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/modules/home/controllers/task-api';
import { useModelUI, isValueEmpty, type UIFieldConfig } from '../../hooks/useModelUI';
import {
calculateCost,
formatYuan,
type ModelDefinition,
type ModelPricingConfig,
} from '@/utils/pricing';
} from '@/shared/utils/pricing';
import { getTextInputMenuItems } from '../../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
import {
execUndo, execRedo, execCut, execCopy, execPaste,
execClear, execSelectAll, execIncreaseFont, execDecreaseFont,
execResetFont, hasTextSelection, isInputEmpty, getFontSizeLevel,
} from '../../controllers/menus/text-input-ops';
const { Text, Title } = Typography;
@@ -385,6 +393,62 @@ export function ModelInputForm() {
return '消费';
}, [selectedModel, currentValues]);
// ── 文本输入框右键菜单(手动控制,仅 TEXTAREA / input[type=text] / contentEditable 上触发)──
const [textMenu, setTextMenu] = useState<{ x: number; y: number } | null>(null);
function isTextEditor(el: HTMLElement): boolean {
const tag = el.tagName;
if (tag === 'TEXTAREA' || el.isContentEditable) return true;
if (tag === 'INPUT') {
const type = (el as HTMLInputElement).type;
return type === 'text' || type === '';
}
return false;
}
const handleBodyContextMenu = useCallback((e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (!isTextEditor(target)) return;
e.preventDefault();
e.stopPropagation();
setTextMenu({ x: e.clientX, y: e.clientY });
}, []);
// 点击菜单外部关闭
useEffect(() => {
if (!textMenu) return;
const handler = (e: MouseEvent) => {
const menuEl = document.querySelector('.ant-dropdown');
if (menuEl && !menuEl.contains(e.target as Node)) {
setTextMenu(null);
}
};
const timer = setTimeout(() => {
document.addEventListener('mousedown', handler, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler, true);
};
}, [textMenu]);
/** ContextMenuItems → AntD MenuProps['items'] */
const toAntdMenu = useCallback((items: ContextMenuItems) =>
items.map((item) => {
if (item === 'divider') return { type: 'divider' as const };
return { key: item.key, label: item.label, onClick: item.onClick, disabled: item.disabled, danger: item.danger };
}), []);
const textMenuAntdItems = toAntdMenu(getTextInputMenuItems({
hasSelection: hasTextSelection(),
isEmpty: isInputEmpty(),
fontSizeLevel: getFontSizeLevel(),
onUndo: execUndo, onRedo: execRedo, onCut: execCut, onCopy: execCopy,
onPaste: execPaste, onClear: execClear, onSelectAll: execSelectAll,
onIncreaseFont: execIncreaseFont, onDecreaseFont: execDecreaseFont, onResetFont: execResetFont,
}));
// ======== 未选择模型 ========
if (!selectedModel) {
@@ -478,7 +542,10 @@ export function ModelInputForm() {
</div>
{/* ======== Body参数列表useModelUI 驱动) ======== */}
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
<div
style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}
onContextMenu={handleBodyContextMenu}
>
{fields.length === 0 ? (
<Empty
description="该模型无需额外参数"
@@ -572,6 +639,19 @@ export function ModelInputForm() {
{estimatedCostDisplay}
</Button>
</div>
{/* 文本输入框悬浮右键菜单(仅 input/textarea 上触发) */}
<Dropdown
open={!!textMenu}
onOpenChange={(open) => { if (!open) setTextMenu(null); }}
menu={{ items: textMenuAntdItems, onClick: () => setTextMenu(null) }}
trigger={[]}
>
<div
onContextMenu={(e) => { e.preventDefault(); setTextMenu(null); }}
style={{ position: 'fixed', left: textMenu?.x ?? -9999, top: textMenu?.y ?? -9999, width: 1, height: 1 }}
/>
</Dropdown>
</div>
);
}

View File

@@ -7,9 +7,9 @@ import React, { useState, useMemo, useCallback } from 'react';
import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as antTheme } from 'antd';
import { AppstoreOutlined } from '@ant-design/icons';
import { useHomeContext } from '@/pages/home/HomeContext';
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import { EnumResolver } from '@/utils/enum-resolver';
import { useHomeContext } from '@/modules/home/stores/HomeContext';
import { MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/modules/home/controllers/model-api';
import { EnumResolver } from '@/shared/utils/enum-resolver';
const { Text } = Typography;

View File

@@ -12,17 +12,19 @@
// 实现精确检测。
// ============================================================
import { useMemo, useRef, useEffect } from 'react';
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme } from 'antd';
import { useMemo, useRef, useEffect, useCallback } from 'react';
import { Image, Empty, Spin, Typography, Button, Tag, theme as antTheme, App } from 'antd';
import {
EyeOutlined,
DownloadOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useHomeContext } from '@/pages/home/HomeContext';
import { useTaskDetail } from '@/hooks/use-api';
import { POLLING_STATUSES, type TaskStatusString } from '@/services/modules/task';
import { useHomeContext } from '@/modules/home/stores/HomeContext';
import { useTaskDetail } from '@/shared/hooks/use-api';
import { POLLING_STATUSES, type TaskStatusString } from '@/modules/home/controllers/task-api';
import { ContextMenu } from '@/shared/components/ContextMenu';
import { getPreviewImageMenu, getPreviewEmptyMenu } from '../../controllers/menus';
const { Text } = Typography;
@@ -80,6 +82,7 @@ function splitAssets(
export function OutputPreview() {
const { token } = antTheme.useToken();
const { message } = App.useApp();
const { selectedTask } = useHomeContext();
// 数据获取Hook 自动处理taskId 为 null 时不请求、竞态、loading/error
@@ -117,6 +120,33 @@ export function OutputPreview() {
? (STATUS_CONFIG[status] || { color: 'default', label: status })
: { color: 'default', label: '未知' };
// ── 右键菜单 ──
const hasPreview = (images.length + videos.length) > 0;
const handleOpenFileLocation = useCallback(() => {
// 云端任务输出为 CDN URL暂无本地路径概念
message.info('云端任务输出无本地文件路径');
}, [message]);
const handleCopyImageToClipboard = useCallback(async () => {
if (images.length > 0) {
try {
await navigator.clipboard.writeText(images[0]);
message.success('已复制图片链接');
} catch {
message.error('复制失败');
}
}
}, [images, message]);
const handleOpenSavePath = useCallback(() => {
message.info('打开保存路径功能待实现');
}, [message]);
const previewMenuItems = hasPreview
? getPreviewImageMenu({ hasPreview, onOpenFileLocation: handleOpenFileLocation, onCopyImageToClipboard: handleCopyImageToClipboard, onOpenSavePath: handleOpenSavePath })
: getPreviewEmptyMenu({ hasPreview, onOpenFileLocation: handleOpenFileLocation, onCopyImageToClipboard: handleCopyImageToClipboard, onOpenSavePath: handleOpenSavePath });
// ---- 无选中任务 ----
if (!selectedTask) {
return (
@@ -172,6 +202,7 @@ export function OutputPreview() {
// ---- 展示 ----
return (
<ContextMenu items={previewMenuItems}>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{
padding: '12px 16px',
@@ -219,5 +250,6 @@ export function OutputPreview() {
)}
</div>
</div>
</ContextMenu>
);
}

View File

@@ -3,7 +3,7 @@
// ============================================================
import { theme as antTheme } from 'antd';
import { OutputPreview } from '../features/OutputPreview';
import { OutputPreview } from './OutputPreview';
export function RightPanel() {
const { token } = antTheme.useToken();

View File

@@ -12,23 +12,25 @@
// ============================================================
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space, Pagination } from 'antd';
import { Table, Tag, Typography, theme as antTheme, Popover, Checkbox, Button, Space, Pagination, Dropdown, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList, useTaskPolling } from '@/hooks/use-api';
import type { ModelsListResponseBody } from '@/services/modules/models';
import { useAppContext } from '@/components/providers/AppProvider';
import { useHomeContext } from '@/pages/home/HomeContext';
import { useTheme } from '@/components/providers/ThemeProvider';
import { on, off, EVENTS } from '@/utils/bus';
import { useTaskList, useTaskPolling } from '@/shared/hooks/use-api';
import type { ModelsListResponseBody } from '@/modules/home/controllers/model-api';
import { useAppContext } from '@/app/providers/AppProvider';
import { useHomeContext } from '@/modules/home/stores/HomeContext';
import { useTheme } from '@/app/providers/ThemeProvider';
import { on, off, EVENTS } from '@/shared/utils/bus';
import type {
TaskInfoItemResponseBody,
TaskListRequestParams,
TaskStatusString,
} from '@/services/modules/task';
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
} from '@/modules/home/controllers/task-api';
import { TaskStatusMap, OutputTypeMap } from '@/modules/home/controllers/task-api';
import { getTaskListMenuItems } from '../../controllers/menus';
import type { ContextMenuItems } from '@/shared/components/ContextMenu';
const { Text } = Typography;
@@ -90,7 +92,7 @@ const ALL_COLUMNS: ColumnMeta[] = [
{ key: 'id', title: '任务 ID', defaultVisible: true },
{ key: 'created_at', title: '创建时间', defaultVisible: true },
{ key: 'model_name', title: '模型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'output_type', title: '输出类型', defaultVisible: true },
{ key: 'status', title: '状态', defaultVisible: true },
{ key: 'prompt', title: '提示词', defaultVisible: true },
{ key: 'duration', title: '时长', defaultVisible: true },
@@ -155,6 +157,76 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
const isEnterprise = edition === 'enterprise';
// -------- 右键菜单 --------
const [contextMenu, setContextMenu] = useState<{
record: TaskInfoItemResponseBody;
x: number;
y: number;
} | null>(null);
/** ContextMenuItems → AntD MenuProps['items'] 转换 */
const toAntdMenu = useCallback((items: ContextMenuItems) =>
items.map((item) => {
if (item === 'divider') return { type: 'divider' as const };
return {
key: item.key,
label: item.label,
onClick: item.onClick,
disabled: item.disabled,
danger: item.danger,
};
}), []);
/** 根据任务记录生成右键菜单项 */
const buildTaskMenuItems = useCallback((record: TaskInfoItemResponseBody) => {
return toAntdMenu(getTaskListMenuItems({
canOpenOutput: record.status === 'success',
canReedit: true,
canRegen: true,
canPullResult: record.status === 'success',
canRedownload: record.status === 'success',
isFavorited: false,
onOpenOutputDir: () => { message.info('打开输出目录功能待实现'); },
onCopyResourceLink: () => {
const assets = record.output_assets;
if (assets && assets.length > 0) {
navigator.clipboard.writeText(assets[0].url).then(
() => message.success('已复制资源链接'),
() => message.error('复制失败'),
);
} else {
message.warning('该任务无输出资源');
}
},
onReedit: () => { message.info('重新编辑功能待实现'); },
onRegen: () => { message.info('再次生成功能待实现'); },
onPullResult: () => { message.info('拉取结果功能待实现'); },
onRedownload: () => { message.info('重新下载功能待实现'); },
onToggleFavorite: () => { message.info('收藏功能待实现'); },
onDelete: () => { message.info('删除记录功能待实现'); },
}));
}, [toAntdMenu, message]);
// 菜单打开时监听全局 mousedown → 点击菜单外区域自动关闭
useEffect(() => {
if (!contextMenu) return;
const handler = (e: MouseEvent) => {
const menuEl = document.querySelector('.ant-dropdown');
if (menuEl && !menuEl.contains(e.target as Node)) {
setContextMenu(null);
}
};
// setTimeout 0 避免捕获打开菜单的同一次右击 mousedown
const timer = setTimeout(() => {
document.addEventListener('mousedown', handler, true);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler, true);
};
}, [contextMenu]);
// -------- 列可见性 --------
const [visibleColumns, setVisibleColumns] = useState<Set<string>>(() => {
@@ -446,7 +518,7 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
title: '输出类型',
dataIndex: 'output_type',
key: 'output_type',
width: 90,
width: 120,
filters: OUTPUT_TYPE_FILTERS,
filteredValue: (tableParams.filters.output_type as string[]) || null,
onFilter: (value, record) => record.output_type === value, // 客户端筛选
@@ -795,6 +867,11 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
rowClassName={rowClassName}
onRow={(record) => ({
onClick: () => setSelectedTask(record),
onContextMenu: (e) => {
e.preventDefault();
setSelectedTask(record);
setContextMenu({ record, x: e.clientX, y: e.clientY });
},
style: { cursor: 'pointer' },
})}
onChange={handleTableChange}
@@ -822,6 +899,28 @@ export function TaskHistory({ allModels }: TaskHistoryProps) {
}}
/>
</div>
{/* 悬浮右键菜单 — 手动控制 opentrigger=[] 避免 AntD 自动监听干扰 onRow.onContextMenu */}
<Dropdown
open={!!contextMenu}
onOpenChange={(open) => { if (!open) setContextMenu(null); }}
menu={{
items: contextMenu ? buildTaskMenuItems(contextMenu.record) : [],
onClick: () => setContextMenu(null),
}}
trigger={[]}
>
<div
onContextMenu={(e) => { e.preventDefault(); setContextMenu(null); }}
style={{
position: 'fixed',
left: contextMenu?.x ?? -9999,
top: contextMenu?.y ?? -9999,
width: 1,
height: 1,
}}
/>
</Dropdown>
</div>
);
}

View File

@@ -0,0 +1,5 @@
// navbar 模块 — 导航栏 UI 壳
export { NavBar } from './views/NavBar';
export { NavItem } from './views/NavItem';
export { personalLeft, personalRight, enterpriseLeft, enterpriseRight } from './settings/nav-config';
export type { NavItemConfig, NavAction, NavBarConfig } from './model/types';

Some files were not shown because too many files have changed in this diff Show More