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:
@@ -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';
|
||||
|
||||
/**
|
||||
* 根组件 — 主窗口
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -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, {
|
||||
@@ -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; };
|
||||
@@ -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 键 ----------
|
||||
|
||||
@@ -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 类型 ----------
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { NavBar } from './NavBar';
|
||||
@@ -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',
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, post } from '@/services/request';
|
||||
import { get, post } from '@/shared/services/http';
|
||||
|
||||
export const PAYMENT_METHODS = {
|
||||
Alipay: 'alipay',
|
||||
@@ -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";
|
||||
|
||||
|
||||
// ============================================================
|
||||
7
src/modules/account/index.ts
Normal file
7
src/modules/account/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 || '加载订单列表失败');
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -3,7 +3,7 @@
|
||||
// GET /api/v1/announcements
|
||||
// ============================================================
|
||||
|
||||
import { get } from '../request';
|
||||
import { get } from '@/shared/services/http';
|
||||
|
||||
// ============================================================
|
||||
// AnnouncementUrlObj — 公告模块路由常量
|
||||
5
src/modules/announcement/index.ts
Normal file
5
src/modules/announcement/index.ts
Normal 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';
|
||||
@@ -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={
|
||||
@@ -1,4 +1,4 @@
|
||||
import {post, get} from '../request';
|
||||
import {post, get} from '@/shared/services/http';
|
||||
|
||||
// ============================================================
|
||||
// AuthUrlObj — 认证模块路由常量
|
||||
@@ -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();
|
||||
@@ -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 ----------
|
||||
|
||||
6
src/modules/auth/index.ts
Normal file
6
src/modules/auth/index.ts
Normal 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';
|
||||
@@ -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';
|
||||
@@ -3,7 +3,7 @@
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
import type { FieldConfig } from '../model/types';
|
||||
|
||||
export const loginFields: FieldConfig[] = [
|
||||
{
|
||||
@@ -3,7 +3,7 @@
|
||||
// 修改校验规则或字段属性只需改此文件,无需动 UI 代码
|
||||
// ============================================================
|
||||
|
||||
import type { FieldConfig } from '../types';
|
||||
import type { FieldConfig } from '../model/types';
|
||||
|
||||
export const registerFields: FieldConfig[] = [
|
||||
{
|
||||
@@ -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[]> = {};
|
||||
@@ -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 || '验证码发送失败');
|
||||
}
|
||||
},
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
38
src/modules/canvas/controllers/menus/episode-menu.ts
Normal file
38
src/modules/canvas/controllers/menus/episode-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
15
src/modules/canvas/controllers/menus/index.ts
Normal file
15
src/modules/canvas/controllers/menus/index.ts
Normal 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';
|
||||
54
src/modules/canvas/controllers/menus/media-card-menu.ts
Normal file
54
src/modules/canvas/controllers/menus/media-card-menu.ts
Normal 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;
|
||||
}
|
||||
46
src/modules/canvas/controllers/menus/project-menu.ts
Normal file
46
src/modules/canvas/controllers/menus/project-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
50
src/modules/canvas/controllers/menus/shot-menu.ts
Normal file
50
src/modules/canvas/controllers/menus/shot-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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() {
|
||||
@@ -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,
|
||||
@@ -2,7 +2,7 @@
|
||||
// constants.ts — 无限画布常量配置
|
||||
// ============================================================
|
||||
|
||||
import type { StageType } from '../store/useCanvasStore';
|
||||
import type { StageType } from '../stores/useCanvasStore';
|
||||
|
||||
// ---------- 布局 ----------
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 实现 Grid 模式和 Single 模式的坐标计算。
|
||||
// ============================================================
|
||||
|
||||
import type { CatalogItem } from '../store/useCanvasStore';
|
||||
import type { CatalogItem } from '../stores/useCanvasStore';
|
||||
import {
|
||||
ITEM_W,
|
||||
ITEM_H,
|
||||
@@ -9,7 +9,7 @@
|
||||
// - shotNameMatchesQuery(shotName, query) — 镜头名称匹配
|
||||
// ============================================================
|
||||
|
||||
import type { StageType } from '../store/useCanvasStore';
|
||||
import type { StageType } from '../stores/useCanvasStore';
|
||||
|
||||
// ---------- 阶段检测 ----------
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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';
|
||||
@@ -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() 在原生事件中返回旧值)
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
87
src/modules/canvas/views/core/MediaCardContextMenu.tsx
Normal file
87
src/modules/canvas/views/core/MediaCardContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
// ── 分页控件 ──
|
||||
|
||||
@@ -5,17 +5,21 @@
|
||||
// 1. 项目下拉选择器(ProjectSelector)
|
||||
// 2. 集数下拉选择器(EpisodeSelector,Assets 阶段隐藏)
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// GET /api/v1/banners
|
||||
// ============================================================
|
||||
|
||||
import {get} from '../request';
|
||||
import {get} from '@/shared/services/http';
|
||||
|
||||
// ---------- 路由常量 ----------
|
||||
|
||||
15
src/modules/home/controllers/menus/index.ts
Normal file
15
src/modules/home/controllers/menus/index.ts
Normal 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';
|
||||
51
src/modules/home/controllers/menus/media-ref-menu.ts
Normal file
51
src/modules/home/controllers/menus/media-ref-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
49
src/modules/home/controllers/menus/preview-area-menu.ts
Normal file
49
src/modules/home/controllers/menus/preview-area-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
87
src/modules/home/controllers/menus/task-list-menu.ts
Normal file
87
src/modules/home/controllers/menus/task-list-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
110
src/modules/home/controllers/menus/text-input-menu.ts
Normal file
110
src/modules/home/controllers/menus/text-input-menu.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
120
src/modules/home/controllers/menus/text-input-ops.ts
Normal file
120
src/modules/home/controllers/menus/text-input-ops.ts
Normal 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 = '';
|
||||
}
|
||||
@@ -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 映射。
|
||||
@@ -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";
|
||||
|
||||
// ============================================================
|
||||
// 基础枚举 / 字面量类型
|
||||
@@ -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';
|
||||
|
||||
// ---------- 输出类型 ----------
|
||||
|
||||
@@ -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 类型 ----------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -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';
|
||||
|
||||
// ---------- 常量 ----------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
|
||||
{/* 悬浮右键菜单 — 手动控制 open,trigger=[] 避免 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>
|
||||
);
|
||||
}
|
||||
5
src/modules/navbar/index.ts
Normal file
5
src/modules/navbar/index.ts
Normal 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
Reference in New Issue
Block a user