Compare commits
8 Commits
eb12248a62
...
1c27283b56
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c27283b56 | |||
| c18d9c8404 | |||
| 0c7b33aac1 | |||
| 4261915679 | |||
| d5ba7e63cd | |||
| 76e66a37c8 | |||
| 7982b69219 | |||
| 53df926973 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -36,5 +36,5 @@ dist-ssr
|
|||||||
*.py
|
*.py
|
||||||
*.pyi
|
*.pyi
|
||||||
WORKLOG.md
|
WORKLOG.md
|
||||||
*openapi*.json
|
*open*.json
|
||||||
response_*.json
|
response_*.json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
||||||
import { useAppContext } from './contexts/app-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { LoginPage } from './pages/login';
|
import { LoginPage } from './pages/login';
|
||||||
import { SettingsPage } from './pages/settings';
|
import { SettingsPage } from './pages/settings';
|
||||||
import { on, off, EVENTS } from './utils/event-bus';
|
import { on, off, EVENTS } from './utils/event-bus';
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// AppProvider — 应用状态 Provider 组件
|
// AppProvider — 全局应用状态 Context + Provider + Hook
|
||||||
|
//
|
||||||
|
// 导出:
|
||||||
|
// AppContext, useAppContext() — Context + Consumer Hook
|
||||||
|
// AppContextValue — 类型
|
||||||
|
// AppProvider — Provider 组件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import type { Platform, Edition } from '@shared/types';
|
import type { Platform, Edition } from '@shared/types';
|
||||||
|
|
||||||
import { getPlatform, isDev } from '@/utils/platform';
|
import { getPlatform, isDev } from '@/utils/platform';
|
||||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||||
import { AppContext } from '@/contexts/app-context';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
import type { AppContextValue } from '@/contexts/app-context';
|
|
||||||
import type { LoginResponseBody } from '@/services/modules';
|
|
||||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import { logger } from '@/utils/logger';
|
import { logger } from '@/utils/logger';
|
||||||
import { AuthAPI } from '@/services/modules';
|
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
|
|
||||||
import {
|
import {
|
||||||
initAuthRefresh,
|
initAuthRefresh,
|
||||||
getStoredRefreshToken,
|
getStoredRefreshToken,
|
||||||
@@ -23,6 +25,39 @@ import {
|
|||||||
initRefreshTokenStore,
|
initRefreshTokenStore,
|
||||||
} from '@/services/auth-token';
|
} from '@/services/auth-token';
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface AppContextValue {
|
||||||
|
/** 当前操作系统平台 */
|
||||||
|
platform: Platform;
|
||||||
|
/** 产品版本(个人版 / 企业版) */
|
||||||
|
edition: Edition;
|
||||||
|
/** 用户是否已登录 */
|
||||||
|
isLoggedIn: boolean;
|
||||||
|
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
||||||
|
user: LoginResponseBody | null;
|
||||||
|
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */
|
||||||
|
login: (auth: LoginResponseBody) => Promise<void>;
|
||||||
|
/** 退出登录:清除 Token 和用户状态 */
|
||||||
|
logout: () => void;
|
||||||
|
/** 是否为开发环境 */
|
||||||
|
isDevMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppContext = createContext<AppContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useAppContext(): AppContextValue {
|
||||||
|
const ctx = useContext(AppContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
interface AppProviderProps {
|
interface AppProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,22 @@
|
|||||||
// - drawer → antd Drawer(侧滑抽屉)
|
// - drawer → antd Drawer(侧滑抽屉)
|
||||||
// - floating → FloatingPanel(非模态浮动面板,可拖拽,不阻断交互)
|
// - floating → FloatingPanel(非模态浮动面板,可拖拽,不阻断交互)
|
||||||
//
|
//
|
||||||
// 支持多面板共存(floating 类型可同时打开多个)
|
// 全局互斥:同一时刻最多打开一个页面组件。
|
||||||
|
// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。
|
||||||
|
//
|
||||||
|
// PAGE_MAP 集中注册所有可调度页面:
|
||||||
|
// 有真实组件 → { component: XxxPage, label: '...', width?: number }
|
||||||
|
// 占位页面 → 只写 label,自动渲染 "页面开发中"
|
||||||
|
// 懒加载页面 → component: React.lazy(() => import('...'))
|
||||||
|
// 新增页面只需在这里加一行,无需创建独立文件
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {useState, useEffect, useCallback, useRef, type ComponentType} from 'react';
|
import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {Modal, Drawer} from 'antd';
|
import { Drawer, Modal } from 'antd';
|
||||||
import {on, off, EVENTS} from '@/utils/event-bus';
|
import { EVENTS, off, on } from '@/utils/event-bus';
|
||||||
import {FloatingPanel} from '@/components/common/FloatingPanel';
|
import { FloatingPanel } from '@/components/ui/FloatingPanel';
|
||||||
import {ComplianceAssetsPage} from '@/pages/compliance-assets/ComplianceAssetsPage';
|
import { WechatWorkPage } from '@/pages/headers/WechatWorkPage';
|
||||||
import {AccountPage} from '@/pages/account/AccountPage';
|
import { AccountInfo } from '@/pages/headers/AccountInfo.tsx';
|
||||||
import {VipPage} from '@/pages/vip/VipPage';
|
|
||||||
import {RechargePage} from '@/pages/recharge/RechargePage';
|
|
||||||
import {BillingPage} from '@/pages/billing/BillingPage';
|
|
||||||
import {OrdersPage} from '@/pages/orders/OrdersPage';
|
|
||||||
import {ProjectsPage} from '@/pages/projects/ProjectsPage';
|
|
||||||
import {QuotaPage} from '@/pages/quota/QuotaPage';
|
|
||||||
import {WechatWorkPage} from '@/pages/wechat-work/WechatWorkPage';
|
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -33,122 +33,175 @@ interface PageEventPayload {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivePanel {
|
/** 当前活跃面板(id 用于 key / 关闭定位) */
|
||||||
|
interface ActivePanel extends PageEventPayload {
|
||||||
id: string;
|
id: string;
|
||||||
target: string;
|
}
|
||||||
|
|
||||||
|
interface PageConfig {
|
||||||
|
/** 页面组件(未提供则使用自动生成的占位组件)。
|
||||||
|
* 支持 React.lazy(() => import('...')) 懒加载。 */
|
||||||
|
component?: ComponentType;
|
||||||
|
/** 页面标签(显示在容器标题栏) */
|
||||||
label: string;
|
label: string;
|
||||||
|
/** 容器宽度(px)。也支持函数动态计算:() => window.innerWidth * 0.6 */
|
||||||
|
width?: number | (() => number);
|
||||||
|
/** 容器高度(px),仅 FloatingPanel 生效。也支持函数动态计算 */
|
||||||
|
height?: number | (() => number);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
|
||||||
|
|
||||||
|
function PlaceholderPage({ label }: { label: string }) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 8 }}>
|
||||||
|
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>{label} — 页面开发中</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为每个占位页预创建稳定组件引用 */
|
||||||
|
function createPlaceholder(label: string): ComponentType {
|
||||||
|
const C = () => <PlaceholderPage label={label} />;
|
||||||
|
C.displayName = `Placeholder_${label}`;
|
||||||
|
return C;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- target → 页面组件映射 ----------
|
// ---------- target → 页面组件映射 ----------
|
||||||
|
|
||||||
const PAGE_MAP: Record<string, ComponentType> = {
|
const PAGE_MAP: Record<string, PageConfig> = {
|
||||||
'/compliance-assets': ComplianceAssetsPage,
|
'/wechat-work': { component: WechatWorkPage, label: '企业微信' },
|
||||||
'/account': AccountPage,
|
'/compliance-assets': { label: '合规素材库' },
|
||||||
'/vip': VipPage,
|
'/account': {
|
||||||
'/recharge': RechargePage,
|
component: AccountInfo,
|
||||||
'/billing': BillingPage,
|
label: '账户',
|
||||||
'/orders': OrdersPage,
|
width: () => Math.min(window.innerWidth * 0.6, 720),
|
||||||
'/projects': ProjectsPage,
|
height: () => Math.min(window.innerHeight * 0.6, 680),
|
||||||
'/quota': QuotaPage,
|
},
|
||||||
'/wechat-work': WechatWorkPage,
|
'/vip': { label: '开会员/激活' },
|
||||||
|
'/recharge': { label: '充值积分' },
|
||||||
|
'/billing': { label: '消费记录' },
|
||||||
|
'/orders': { label: '订单明细' },
|
||||||
|
'/projects': { label: '项目管理' },
|
||||||
|
'/quota': { label: '查配额' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
|
||||||
|
const RESOLVED_PAGE_MAP = new Map<string, ComponentType>();
|
||||||
|
for (const [key, config] of Object.entries(PAGE_MAP)) {
|
||||||
|
RESOLVED_PAGE_MAP.set(key, config.component || createPlaceholder(config.label));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析页面组件(纯查表,不创建新组件) */
|
||||||
|
function resolvePage(target: string): ComponentType | null {
|
||||||
|
return RESOLVED_PAGE_MAP.get(target) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析容器宽度(支持静态数值或动态函数) */
|
||||||
|
function resolveWidth(target: string): number | undefined {
|
||||||
|
const w = PAGE_MAP[target]?.width;
|
||||||
|
return typeof w === 'function' ? w() : w;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析容器高度(支持静态数值或动态函数) */
|
||||||
|
function resolveHeight(target: string): number | undefined {
|
||||||
|
const h = PAGE_MAP[target]?.height;
|
||||||
|
return typeof h === 'function' ? h() : h;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
// ---------- 组件 ----------
|
||||||
|
|
||||||
export function PageDispatcher() {
|
export function PageDispatcher() {
|
||||||
// 单实例(modal / drawer)—— 同一时间只有一个
|
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
||||||
const [singleton, setSingleton] = useState<PageEventPayload | null>(null);
|
|
||||||
|
|
||||||
// 浮动面板列表(支持多个同时打开)
|
|
||||||
const [floatingPanels, setFloatingPanels] = useState<ActivePanel[]>([]);
|
|
||||||
const nextIdRef = useRef(0);
|
const nextIdRef = useRef(0);
|
||||||
|
|
||||||
// 监听 OPEN_PAGE 事件
|
// 监听 OPEN_PAGE 事件
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (payload: unknown) => {
|
const handler = (payload: unknown) => {
|
||||||
const {target, modalType, label} = payload as PageEventPayload;
|
const { target, modalType, label } = payload as PageEventPayload;
|
||||||
|
|
||||||
if (!target || !PAGE_MAP[target]) {
|
if (!target || !PAGE_MAP[target]) {
|
||||||
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modalType === 'floating') {
|
// 全局互斥:已有面板打开时,禁止打开新面板
|
||||||
// 浮动面板:追加到列表
|
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
||||||
const id = `fp-${nextIdRef.current++}`;
|
setActivePanel((prev) => {
|
||||||
setFloatingPanels((prev) => [...prev, {id, target, label}]);
|
if (prev) return prev;
|
||||||
} else {
|
return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
|
||||||
// modal / drawer:单实例,覆盖之前的
|
});
|
||||||
setSingleton({target, modalType, label});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
on(EVENTS.OPEN_PAGE, handler);
|
on(EVENTS.OPEN_PAGE, handler);
|
||||||
return () => off(EVENTS.OPEN_PAGE, handler);
|
return () => off(EVENTS.OPEN_PAGE, handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 关闭单实例
|
// 关闭面板
|
||||||
const closeSingleton = useCallback(() => {
|
const closePanel = useCallback(() => {
|
||||||
setSingleton(null);
|
setActivePanel(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 关闭指定浮动面板
|
// 页面组件(useMemo 稳定引用)
|
||||||
const closeFloating = useCallback((id: string) => {
|
const PanelPage = useMemo<ComponentType | null>(
|
||||||
setFloatingPanels((prev) => prev.filter((p) => p.id !== id));
|
() => (activePanel ? resolvePage(activePanel.target) : null),
|
||||||
}, []);
|
[activePanel],
|
||||||
|
);
|
||||||
|
|
||||||
// 单实例对应的页面组件
|
const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
|
||||||
const SingletonPage = singleton ? PAGE_MAP[singleton.target] : null;
|
const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined;
|
||||||
|
|
||||||
|
// 容器默认尺寸(动态计算,视口缩放自动适配)
|
||||||
|
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
|
||||||
|
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ======== Modal ======== */}
|
{/* ======== Modal ======== */}
|
||||||
{singleton?.modalType === 'modal' && SingletonPage && (
|
{activePanel?.modalType === 'modal' && PanelPage && (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open
|
||||||
centered
|
centered
|
||||||
title={singleton.label}
|
title={activePanel.label}
|
||||||
onCancel={closeSingleton}
|
onCancel={closePanel}
|
||||||
footer={null}
|
footer={null}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
mask={{
|
mask={{ closable: true }}
|
||||||
closable: true,
|
width={panelWidth ?? defaultModalWidth}
|
||||||
}}
|
|
||||||
width={640}
|
|
||||||
>
|
>
|
||||||
<SingletonPage />
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
<PanelPage />
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ======== Drawer ======== */}
|
{/* ======== Drawer ======== */}
|
||||||
{singleton?.modalType === 'drawer' && SingletonPage && (
|
{activePanel?.modalType === 'drawer' && PanelPage && (
|
||||||
<Drawer
|
<Drawer
|
||||||
open
|
open
|
||||||
title={singleton.label}
|
title={activePanel.label}
|
||||||
onClose={closeSingleton}
|
onClose={closePanel}
|
||||||
destroyOnHidden={true}
|
destroyOnHidden={true}
|
||||||
size={480}
|
size={panelWidth ?? defaultDrawerWidth}
|
||||||
>
|
>
|
||||||
<SingletonPage />
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
|
<PanelPage />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ======== Floating Panels ======== */}
|
{/* ======== Floating Panel ======== */}
|
||||||
{floatingPanels.map((panel) => {
|
{activePanel?.modalType === 'floating' && PanelPage && (
|
||||||
const PageContent = PAGE_MAP[panel.target];
|
<FloatingPanel
|
||||||
if (!PageContent) return null;
|
key={activePanel.id}
|
||||||
|
open
|
||||||
return (
|
title={activePanel.label}
|
||||||
<FloatingPanel
|
onClose={closePanel}
|
||||||
key={panel.id}
|
width={panelWidth ?? defaultDrawerWidth}
|
||||||
open
|
height={panelHeight}
|
||||||
title={panel.label}
|
>
|
||||||
onClose={() => closeFloating(panel.id)}
|
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||||
>
|
<PanelPage />
|
||||||
<PageContent />
|
</FloatingPanel>
|
||||||
</FloatingPanel>
|
)}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,99 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// SettingsProvider — 设置状态 Provider 组件
|
// SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
|
||||||
//
|
//
|
||||||
// 职责:
|
// 职责:
|
||||||
// - 初始化时从 localStorage 读取已存储的设置项
|
// - 存储路径的集中管理(read/write/clear)
|
||||||
// - 提供 SettingsContext 给所有子组件
|
// - 持久化到 localStorage,遵循 ele-heixiu-* 键名规范
|
||||||
// - 写操作同步更新 state + localStorage
|
// - 全局组件通过 useSettings() 消费
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
|
||||||
import {
|
|
||||||
SettingsContext,
|
// ---------- localStorage 键 ----------
|
||||||
readStoredOutputPath,
|
|
||||||
writeStoredOutputPath,
|
const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
|
||||||
clearStoredOutputPath,
|
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
|
||||||
readStoredTeamRepoPath,
|
|
||||||
writeStoredTeamRepoPath,
|
// ---------- Context 类型 ----------
|
||||||
clearStoredTeamRepoPath,
|
|
||||||
} from '@/contexts/settings-context';
|
export interface SettingsContextValue {
|
||||||
import type { SettingsContextValue } from '@/contexts/settings-context';
|
/** 大模型产物存储仓库路径 */
|
||||||
|
outputPath: string;
|
||||||
|
/** 团队创作存储仓库路径(企业版) */
|
||||||
|
teamRepoPath: string;
|
||||||
|
/** 设置产物输出路径 */
|
||||||
|
setOutputPath: (path: string) => void;
|
||||||
|
/** 设置团队创作仓库路径 */
|
||||||
|
setTeamRepoPath: (path: string) => void;
|
||||||
|
/** 清除产物输出路径 */
|
||||||
|
clearOutputPath: () => void;
|
||||||
|
/** 清除团队创作仓库路径 */
|
||||||
|
clearTeamRepoPath: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- 工具函数(localStorage 读写)----------
|
||||||
|
|
||||||
|
export function readStoredOutputPath(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredOutputPath(path: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(OUTPUT_PATH_KEY, path);
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredOutputPath(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(OUTPUT_PATH_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredTeamRepoPath(): string {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredTeamRepoPath(path: string): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredTeamRepoPath(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(TEAM_REPO_PATH_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useSettings(): SettingsContextValue {
|
||||||
|
const ctx = useContext(SettingsContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
interface SettingsProviderProps {
|
interface SettingsProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ThemeProvider — 主题 Provider 组件
|
// ThemeProvider — 主题 Context + Provider + Hook + 工具函数
|
||||||
//
|
//
|
||||||
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
||||||
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
||||||
@@ -7,20 +7,70 @@
|
|||||||
// 浏览器不支持时回退到 CSS transition 方案。
|
// 浏览器不支持时回退到 CSS transition 方案。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import zhCN from 'antd/locale/zh_CN';
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
|
||||||
import { getThemeConfig } from '../theme/antd-theme';
|
import { getThemeConfig } from '../theme/antd-theme';
|
||||||
import {
|
|
||||||
ThemeContext,
|
// ---------- Context 类型 ----------
|
||||||
readStoredTheme,
|
|
||||||
writeStoredTheme,
|
export interface ThemeContextValue {
|
||||||
applyHtmlTheme,
|
isDark: boolean;
|
||||||
STORAGE_KEY,
|
toggleTheme: () => void;
|
||||||
} from '../hooks/use-theme';
|
setLight: () => void;
|
||||||
import type { ThemeContextValue } from '../hooks/use-theme';
|
setDark: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- localStorage 工具函数 ----------
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'ele-heixiu-theme';
|
||||||
|
|
||||||
|
export function readStoredTheme(): boolean {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (stored === 'dark') return true;
|
||||||
|
if (stored === 'light') return false;
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeStoredTheme(isDark: boolean): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
|
||||||
|
} catch {
|
||||||
|
/* 静默忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyHtmlTheme(isDark: boolean): void {
|
||||||
|
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
|
if (isDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useTheme(): ThemeContextValue {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Provider 组件 ----------
|
||||||
|
|
||||||
interface ThemeProviderProps {
|
interface ThemeProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {useEffect, useState, useCallback} from 'react';
|
|||||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||||
import {NotificationOutlined} from '@ant-design/icons';
|
import {NotificationOutlined} from '@ant-design/icons';
|
||||||
|
|
||||||
import {fetchAnnouncements, type Announcement, AnnouncementListResponse} from '@/services/modules';
|
import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement';
|
||||||
|
|
||||||
const {Text, Paragraph} = Typography;
|
const {Text, Paragraph} = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
import {useState, useCallback, useMemo} from 'react';
|
import {useState, useCallback, useMemo} from 'react';
|
||||||
import {App} from 'antd';
|
import {App} from 'antd';
|
||||||
|
|
||||||
import {useAppContext} from '@/contexts/app-context';
|
import {useAppContext} from '@/components/AppProvider';
|
||||||
import {useTheme} from '@/hooks/use-theme';
|
import {useTheme} from '@/components/ThemeProvider';
|
||||||
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||||
import {NavItem} from './NavItem';
|
import {NavItem} from './NavItem';
|
||||||
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 布局:图标(SVG)在上,文字在下
|
// 布局:图标(SVG)在上,文字在下
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import type { NavItemConfig } from './types';
|
import type { NavItemConfig } from './types';
|
||||||
|
|
||||||
interface NavItemProps {
|
interface NavItemProps {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
|
|
||||||
// ---------- 类型 ----------
|
// ---------- 类型 ----------
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
import { Modal, Typography } from 'antd';
|
import { Modal, Typography } from 'antd';
|
||||||
import { FileTextOutlined } from '@ant-design/icons';
|
import { FileTextOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
|
|
||||||
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
||||||
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AppContext — 全局应用状态
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
import type { Platform, Edition } from '@shared/types';
|
|
||||||
import type { LoginResponseBody } from '@/services/modules';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface AppContextValue {
|
|
||||||
/** 当前操作系统平台 */
|
|
||||||
platform: Platform;
|
|
||||||
/** 产品版本(个人版 / 企业版) */
|
|
||||||
edition: Edition;
|
|
||||||
/** 用户是否已登录 */
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
/** 当前登录用户信息(未登录时为 null,结构与 LoginResponseBody 一致) */
|
|
||||||
user: LoginResponseBody | null;
|
|
||||||
/** 登录:保存 Token 并更新登录态(auth 为登录接口返回值,异步加密存储) */
|
|
||||||
login: (auth: LoginResponseBody) => Promise<void>;
|
|
||||||
/** 退出登录:清除 Token 和用户状态 */
|
|
||||||
logout: () => void;
|
|
||||||
/** 是否为开发环境 */
|
|
||||||
isDevMode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppContext = createContext<AppContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useAppContext(): AppContextValue {
|
|
||||||
const ctx = useContext(AppContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// HomeContext — 首页状态管理
|
|
||||||
// 管理:选中模型、任务分页、选中任务记录、刷新信号
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
|
||||||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface HomeContextValue {
|
|
||||||
/** 当前选中的模型 */
|
|
||||||
selectedModel: ModelsListResponseBody | null;
|
|
||||||
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
|
||||||
|
|
||||||
/** 任务记录分页 — 当前页码 */
|
|
||||||
taskPage: number;
|
|
||||||
/** 任务记录分页 — 每页条数 */
|
|
||||||
taskPageSize: number;
|
|
||||||
setTaskPage: (page: number) => void;
|
|
||||||
setTaskPageSize: (size: number) => void;
|
|
||||||
|
|
||||||
/** 当前选中的任务记录(用于右侧预览) */
|
|
||||||
selectedTask: TaskInfoItemResponseBody | null;
|
|
||||||
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
|
||||||
|
|
||||||
/** 侧边栏折叠状态 */
|
|
||||||
isLeftCollapsed: boolean;
|
|
||||||
toggleLeftCollapsed: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HomeContext = createContext<HomeContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useHomeContext(): HomeContextValue {
|
|
||||||
const ctx = useContext(HomeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useHomeContext() 必须在 <HomeProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// SettingsContext — 设置项集中状态管理(类似 Vue Pinia store)
|
|
||||||
//
|
|
||||||
// 职责:
|
|
||||||
// - 存储路径的集中管理(read/write/clear)
|
|
||||||
// - 持久化到 localStorage,遵循 ele-heixiu-* 键名规范
|
|
||||||
// - 全局组件通过 useSettings() 消费
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
// ---------- localStorage 键 ----------
|
|
||||||
|
|
||||||
const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
|
|
||||||
const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface SettingsContextValue {
|
|
||||||
/** 大模型产物存储仓库路径 */
|
|
||||||
outputPath: string;
|
|
||||||
/** 团队创作存储仓库路径(企业版) */
|
|
||||||
teamRepoPath: string;
|
|
||||||
/** 设置产物输出路径 */
|
|
||||||
setOutputPath: (path: string) => void;
|
|
||||||
/** 设置团队创作仓库路径 */
|
|
||||||
setTeamRepoPath: (path: string) => void;
|
|
||||||
/** 清除产物输出路径 */
|
|
||||||
clearOutputPath: () => void;
|
|
||||||
/** 清除团队创作仓库路径 */
|
|
||||||
clearTeamRepoPath: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SettingsContext = createContext<SettingsContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- 工具函数(localStorage 读写) ----------
|
|
||||||
|
|
||||||
export function readStoredOutputPath(): string {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredOutputPath(path: string): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(OUTPUT_PATH_KEY, path);
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearStoredOutputPath(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(OUTPUT_PATH_KEY);
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function readStoredTeamRepoPath(): string {
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredTeamRepoPath(path: string): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearStoredTeamRepoPath(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(TEAM_REPO_PATH_KEY);
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useSettings(): SettingsContextValue {
|
|
||||||
const ctx = useContext(SettingsContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -13,19 +13,10 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import {useMemo} from 'react';
|
import {useMemo} from 'react';
|
||||||
import {useAppContext} from '@/contexts/app-context';
|
import {useAppContext} from '@/components/AppProvider';
|
||||||
import {
|
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
|
||||||
TaskAPI,
|
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
ModelAPI,
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
fetchBanners,
|
|
||||||
MODEL_CATEGORY_LABELS,
|
|
||||||
type TaskListRequestParams,
|
|
||||||
type TaskInfoItemResponseBody,
|
|
||||||
type CreatTaskRequestBody,
|
|
||||||
type ModelsListResponseBody,
|
|
||||||
type Banner,
|
|
||||||
type ModelCategoryStringEnum,
|
|
||||||
} from '@/services/modules';
|
|
||||||
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// useTheme — 亮/暗主题切换 Hook
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
import { createContext, useContext } from 'react';
|
|
||||||
|
|
||||||
// ---------- Context 类型 ----------
|
|
||||||
|
|
||||||
export interface ThemeContextValue {
|
|
||||||
isDark: boolean;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
setLight: () => void;
|
|
||||||
setDark: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
||||||
|
|
||||||
// ---------- 工具函数 ----------
|
|
||||||
|
|
||||||
export const STORAGE_KEY = 'ele-heixiu-theme';
|
|
||||||
|
|
||||||
export function readStoredTheme(): boolean {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored === 'dark') return true;
|
|
||||||
if (stored === 'light') return false;
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function writeStoredTheme(isDark: boolean): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
|
|
||||||
} catch {
|
|
||||||
/* 静默忽略 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyHtmlTheme(isDark: boolean): void {
|
|
||||||
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
|
||||||
if (isDark) {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Consumer Hook ----------
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextValue {
|
|
||||||
const ctx = useContext(ThemeContext);
|
|
||||||
if (!ctx) {
|
|
||||||
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
|
|
||||||
}
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// AccountPage — 查帐户(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function AccountPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>查帐户 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// BillingPage — 消费记录(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function BillingPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>消费记录 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ComplianceAssetsPage — 合规素材库(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function ComplianceAssetsPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>合规素材库 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
210
src/pages/headers/AccountInfo.tsx
Normal file
210
src/pages/headers/AccountInfo.tsx
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// ============================================================
|
||||||
|
// AccountInfo — 账户信息展示
|
||||||
|
// 数据来源:AuthAPI.getUserInfo() → GET /api/v1/auth/me
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Descriptions,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
Skeleton,
|
||||||
|
Result,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Divider,
|
||||||
|
theme as antTheme,
|
||||||
|
} from 'antd';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
WalletOutlined,
|
||||||
|
CrownOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { AuthAPI, type UserInfoBody } from '@/services/modules/auth';
|
||||||
|
|
||||||
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
|
/** 将分转为元,保留两位小数 */
|
||||||
|
function centToYuan(cent: number): string {
|
||||||
|
return (cent / 100).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期 */
|
||||||
|
function formatDate(date: Date | string | undefined): string {
|
||||||
|
if (!date) return '—';
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountInfo() {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
const [userInfo, setUserInfo] = useState<UserInfoBody | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const info = await AuthAPI.getUserInfo();
|
||||||
|
setUserInfo(info);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || '加载账户信息失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
// ======== 加载态 ========
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24 }}>
|
||||||
|
<Skeleton active paragraph={{ rows: 6 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 错误态 ========
|
||||||
|
if (error || !userInfo) {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="加载失败"
|
||||||
|
subTitle={error || '未能获取账户信息'}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={load}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======== 数据展示 ========
|
||||||
|
const totalBalance = userInfo.balance_cent + userInfo.gift_balance_cent;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, maxWidth: 640 }}>
|
||||||
|
{/* ======== 标题:账户 ======== */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
|
||||||
|
<Space align="center" size={12}>
|
||||||
|
<UserOutlined style={{ fontSize: 22, color: token.colorPrimary }} />
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>
|
||||||
|
{userInfo.display_name || userInfo.username}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
ID: {userInfo.id}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
{userInfo.role !== 'user' && (
|
||||||
|
<Tag color="gold" style={{ lineHeight: '18px', fontSize: 11 }}>
|
||||||
|
{userInfo.role === 'admin' ? '管理员' : '超级管理员'}
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
<Button type="primary" ghost size="small" icon={<WalletOutlined />}>
|
||||||
|
充值
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======== 积分栏 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: { padding: '16px 20px' },
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12, marginBottom: 8, display: 'block' }}>
|
||||||
|
账户总积分
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: '0 0 12px 0' }}>
|
||||||
|
{centToYuan(totalBalance)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 14, marginLeft: 4 }}>积分</Text>
|
||||||
|
</Title>
|
||||||
|
<Space size={32}>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>主积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(userInfo.balance_cent)}</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>赠送积分</Text>
|
||||||
|
<br />
|
||||||
|
<Text strong>{centToYuan(userInfo.gift_balance_cent)}</Text>
|
||||||
|
</div>
|
||||||
|
{userInfo.discount_rate > 0 && userInfo.discount_rate < 1 && (
|
||||||
|
<div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>折扣率</Text>
|
||||||
|
<br />
|
||||||
|
<Tag color="green">{Math.round(userInfo.discount_rate * 100)}%</Tag>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 套餐信息 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: { padding: '16px 20px' },
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
|
||||||
|
<Space size={8}>
|
||||||
|
<CrownOutlined style={{ color: token.colorWarning }} />
|
||||||
|
<Text strong>当前套餐</Text>
|
||||||
|
</Space>
|
||||||
|
<Button type="link" size="small" icon={<RightOutlined />} iconPlacement="end">
|
||||||
|
续费 / 升级
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Descriptions column={1} size="small" colon={false}>
|
||||||
|
<Descriptions.Item label="套餐名称">
|
||||||
|
<Text strong>{userInfo.subscription_plan || '免费版'}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="到期时间">
|
||||||
|
<Text type={new Date(userInfo.subscription_expires_at) < new Date() ? 'danger' : undefined}>
|
||||||
|
{formatDate(userInfo.subscription_expires_at)}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="席位上限">
|
||||||
|
{userInfo.seat_limit || '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 今日统计 ======== */}
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: { padding: '16px 20px' },
|
||||||
|
}}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
>
|
||||||
|
<Space size={8} style={{ marginBottom: 12 }}>
|
||||||
|
<BarChartOutlined style={{ color: token.colorSuccess }} />
|
||||||
|
<Text strong>今日使用</Text>
|
||||||
|
</Space>
|
||||||
|
<Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '12px 0', fontSize: 13 }}>
|
||||||
|
统计数据将在次日更新
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* ======== 查看更多权益 ======== */}
|
||||||
|
<Divider style={{ margin: '8px 0' }} />
|
||||||
|
<Button type="link" block icon={<RightOutlined />} iconPlacement="end" style={{ color: token.colorPrimary }}>
|
||||||
|
查看更多权益
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
src/pages/headers/Test1.tsx
Normal file
9
src/pages/headers/Test1.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import {Component} from "react";
|
||||||
|
|
||||||
|
export class Test1 extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/pages/headers/Test3.tsx
Normal file
5
src/pages/headers/Test3.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export function Test3() {
|
||||||
|
return (
|
||||||
|
<></>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Spin, Empty } from 'antd';
|
import { Spin, Empty } from 'antd';
|
||||||
import { WechatOutlined } from '@ant-design/icons';
|
import { WechatOutlined } from '@ant-design/icons';
|
||||||
import { fetchBanners, type Banner } from '@/services/modules';
|
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||||
|
|
||||||
export function WechatWorkPage() {
|
export function WechatWorkPage() {
|
||||||
const [banner, setBanner] = useState<Banner | null>(null);
|
const [banner, setBanner] = useState<Banner | null>(null);
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// HomeContent — 首页三列正文布局 + HomeProvider
|
// HomeContent — 首页三列正文布局 + HomeProvider(Context + Provider + Hook)
|
||||||
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
// 可拖拽调整三栏宽度,所有数据请求在登录后发送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import { theme as antTheme, Empty } from 'antd';
|
import { theme as antTheme, Empty } from 'antd';
|
||||||
|
|
||||||
import { HomeContext } from '@/contexts/home-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
import { LeftPanel } from './components/LeftPanel';
|
import { LeftPanel } from './components/LeftPanel';
|
||||||
import { CenterPanel } from './components/CenterPanel';
|
import { CenterPanel } from './components/CenterPanel';
|
||||||
import { RightPanel } from './components/RightPanel';
|
import { RightPanel } from './components/RightPanel';
|
||||||
@@ -17,6 +17,38 @@ import { Typography } from 'antd';
|
|||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// ---------- Context 类型 ----------
|
||||||
|
|
||||||
|
export interface HomeContextValue {
|
||||||
|
/** 当前选中的模型 */
|
||||||
|
selectedModel: ModelsListResponseBody | null;
|
||||||
|
setSelectedModel: (model: ModelsListResponseBody | null) => void;
|
||||||
|
/** 任务记录分页 — 当前页码 */
|
||||||
|
taskPage: number;
|
||||||
|
/** 任务记录分页 — 每页条数 */
|
||||||
|
taskPageSize: number;
|
||||||
|
setTaskPage: (page: number) => void;
|
||||||
|
setTaskPageSize: (size: number) => void;
|
||||||
|
/** 当前选中的任务记录(用于右侧预览) */
|
||||||
|
selectedTask: TaskInfoItemResponseBody | null;
|
||||||
|
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
|
||||||
|
/** 侧边栏折叠状态 */
|
||||||
|
isLeftCollapsed: boolean;
|
||||||
|
toggleLeftCollapsed: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HomeContext = createContext<HomeContextValue | null>(null);
|
||||||
|
|
||||||
|
// ---------- Consumer Hook ----------
|
||||||
|
|
||||||
|
export function useHomeContext(): HomeContextValue {
|
||||||
|
const ctx = useContext(HomeContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 常量 ----------
|
// ---------- 常量 ----------
|
||||||
|
|
||||||
/** 左列最小/默认宽度 */
|
/** 左列最小/默认宽度 */
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { CloseOutlined } from '@ant-design/icons';
|
|||||||
import type { CarouselRef } from 'antd/es/carousel';
|
import type { CarouselRef } from 'antd/es/carousel';
|
||||||
|
|
||||||
import { useBannerList } from '@/hooks/use-api';
|
import { useBannerList } from '@/hooks/use-api';
|
||||||
import type { Banner } from '@/services/modules';
|
import type { Banner } from '@/services/modules/banner';
|
||||||
|
|
||||||
// ---------- 组件 Props ----------
|
// ---------- 组件 Props ----------
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +1,163 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// ModelInputForm — 动态模型输入表单
|
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||||||
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件
|
//
|
||||||
|
// 布局:Header(模型信息+meta) → Body(fields.map 渲染) → Footer(标签+重置+提交)
|
||||||
|
//
|
||||||
|
// 与旧版的区别:
|
||||||
|
// - 控件渲染逻辑已提取到 useUI/ 组件体系
|
||||||
|
// - param_schema 解析已提取到 useModelUI Hook
|
||||||
|
// - 字段间条件依赖(constraints_config.requires)自动处理禁用
|
||||||
|
// - 本组件只负责:布局、Form 容器、提交逻辑、依赖禁用
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
InputNumber,
|
|
||||||
Select,
|
|
||||||
Slider,
|
|
||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
Empty,
|
Empty,
|
||||||
Upload,
|
Tag,
|
||||||
Switch,
|
Space,
|
||||||
theme as antTheme,
|
theme as antTheme,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import type { FormInstance } from 'antd';
|
||||||
SendOutlined,
|
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||||
UploadOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import type { UploadFile } from 'antd/es/upload';
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
|
||||||
import { useHomeContext } from '@/contexts/home-context';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { useSubmitTask } from '@/hooks/use-api';
|
import { useSubmitTask } from '@/hooks/use-api';
|
||||||
import { emit, EVENTS } from '@/utils/event-bus';
|
import { emit, EVENTS } from '@/utils/event-bus';
|
||||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
import { OutputTypeMap } from '@/services/modules/task';
|
||||||
|
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||||
|
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
const { TextArea } = Input;
|
|
||||||
const { Dragger } = Upload;
|
|
||||||
|
|
||||||
// ---------- 参数类型映射 ----------
|
// ---------- 工具 ----------
|
||||||
|
|
||||||
/** 从 ui_config 中解析的参数描述 */
|
|
||||||
interface ParamDescriptor {
|
|
||||||
/** React key(item.id,保证唯一) */
|
|
||||||
key: string;
|
|
||||||
/** API 字段名(item.map_to) */
|
|
||||||
name: string;
|
|
||||||
label: string;
|
|
||||||
/** 控件类型 */
|
|
||||||
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
|
|
||||||
defaultValue?: unknown;
|
|
||||||
required?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
/** 数值型:最小值 */
|
|
||||||
min?: number;
|
|
||||||
/** 数值型:最大值 */
|
|
||||||
max?: number;
|
|
||||||
/** 数值型:步长 */
|
|
||||||
step?: number;
|
|
||||||
/** select 型:选项列表 */
|
|
||||||
options?: Array<{ label: string; value: string | number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 从 ui_config 对象中解析控件类型 */
|
|
||||||
function inferControlType(config: Record<string, unknown>): ParamDescriptor['controlType'] {
|
|
||||||
const type = config.type as string | undefined;
|
|
||||||
if (!type) return 'text';
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'number':
|
|
||||||
case 'integer':
|
|
||||||
case 'float':
|
|
||||||
return 'number';
|
|
||||||
case 'textarea':
|
|
||||||
case 'text_area':
|
|
||||||
case 'prompt':
|
|
||||||
return 'textarea';
|
|
||||||
case 'select':
|
|
||||||
case 'dropdown':
|
|
||||||
case 'enum':
|
|
||||||
return 'select';
|
|
||||||
case 'slider':
|
|
||||||
case 'range':
|
|
||||||
return 'slider';
|
|
||||||
case 'switch':
|
|
||||||
case 'boolean':
|
|
||||||
case 'bool':
|
|
||||||
return 'switch';
|
|
||||||
case 'image':
|
|
||||||
case 'image-upload':
|
|
||||||
case 'file':
|
|
||||||
return 'image-upload';
|
|
||||||
default:
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 param_schema 构建参数描述符列表
|
* Form.Item 的 getValueFromEvent,兼容两种 onChange 参数形态:
|
||||||
*
|
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
|
||||||
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组),
|
* 2. enable_switch 的裸值:undefined / [] / [{...}] → 直接透传
|
||||||
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
|
|
||||||
*/
|
*/
|
||||||
function buildParamDescriptors(
|
function handleFileWidgetValue(e: unknown): unknown {
|
||||||
paramSchema: ModelsListResponseBody['param_schema'],
|
if (e && typeof e === 'object' && 'fileList' in e) {
|
||||||
): ParamDescriptor[] {
|
return (e as { fileList: unknown }).fileList;
|
||||||
return paramSchema.map((item) => {
|
}
|
||||||
const config = (item.ui || {}) as Record<string, unknown>;
|
return e;
|
||||||
const controlType = inferControlType(config);
|
|
||||||
return {
|
|
||||||
key: item.id,
|
|
||||||
name: item.map_to,
|
|
||||||
label: (config.label as string) || item.map_to,
|
|
||||||
controlType,
|
|
||||||
defaultValue: config.default ?? config.defaultValue,
|
|
||||||
required: (config.required as boolean) || false,
|
|
||||||
placeholder: (config.placeholder as string) || `请输入${config.label || item.map_to}`,
|
|
||||||
min: config.min as number | undefined,
|
|
||||||
max: config.max as number | undefined,
|
|
||||||
step: config.step as number | undefined,
|
|
||||||
options: (config.options as ParamDescriptor['options']) || undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 组件 ----------
|
// ---------- 依赖字段包装组件 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DependentFormItem — 为单个字段处理条件依赖禁用。
|
||||||
|
*
|
||||||
|
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
|
||||||
|
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig)。
|
||||||
|
*
|
||||||
|
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
|
||||||
|
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
|
||||||
|
*/
|
||||||
|
function DependentFormItem({
|
||||||
|
field,
|
||||||
|
form,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled: formItemDisabled,
|
||||||
|
}: {
|
||||||
|
field: UIFieldConfig;
|
||||||
|
form: FormInstance;
|
||||||
|
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) {
|
||||||
|
const depValues = (field.dependsOn || []).map((depName) =>
|
||||||
|
Form.useWatch(depName, form),
|
||||||
|
);
|
||||||
|
|
||||||
|
const isDepDisabled =
|
||||||
|
field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false;
|
||||||
|
|
||||||
|
const effectiveDisabled = formItemDisabled || isDepDisabled;
|
||||||
|
const effectiveConfig = effectiveDisabled
|
||||||
|
? { ...field.widgetConfig, disabled: true }
|
||||||
|
: field.widgetConfig;
|
||||||
|
|
||||||
|
const Component = field.component;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={effectiveDisabled}
|
||||||
|
config={effectiveConfig}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 稳定空数组引用,避免 useMemo 重建 */
|
||||||
|
const EMPTY_ARRAY: never[] = [];
|
||||||
|
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
|
||||||
|
const EMPTY_OBJ: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
// ---------- 主组件 ----------
|
||||||
|
|
||||||
export function ModelInputForm() {
|
export function ModelInputForm() {
|
||||||
const { token } = antTheme.useToken();
|
const { token } = antTheme.useToken();
|
||||||
const { isLoggedIn } = useAppContext();
|
const { isLoggedIn } = useAppContext();
|
||||||
const { selectedModel } = useHomeContext();
|
const { selectedModel } = useHomeContext();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
|
const [taskTag, setTaskTag] = useState('');
|
||||||
|
|
||||||
// 提交突变 Hook(自动处理 loading / error / 日志)
|
// 提交突变 Hook
|
||||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||||
onSuccess: useCallback(
|
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||||
(_data: TaskInfoItemResponseBody) => {
|
message.success('任务已提交');
|
||||||
message.success('任务已提交');
|
emit(EVENTS.TASK_SUBMITTED);
|
||||||
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal)
|
}, []),
|
||||||
emit(EVENTS.TASK_SUBMITTED);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告)
|
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||||
useEffect(() => {
|
|
||||||
if (selectedModel) {
|
const fields: UIFieldConfig[] = useModelUI(
|
||||||
form.resetFields();
|
selectedModel?.param_schema || EMPTY_ARRAY,
|
||||||
|
(selectedModel?.constraints_config as Record<string, unknown>) || EMPTY_OBJ,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 构建初始值(用作 setFieldsValue 的数据源,
|
||||||
|
// antd Form 在有外部 form 实例时会忽略 initialValues prop)
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
const iv: Record<string, unknown> = {};
|
||||||
|
fields.forEach((f) => {
|
||||||
|
if (f.defaultValue !== undefined && f.defaultValue !== null) {
|
||||||
|
iv[f.name] = f.defaultValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return iv;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
|
||||||
|
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
||||||
|
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||||
|
form.setFieldsValue(initialValues);
|
||||||
|
setTaskTag('');
|
||||||
}
|
}
|
||||||
setReferenceFiles([]);
|
}, [selectedModel?.id, initialValues]);
|
||||||
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
// 参数描述符
|
// 重置参数(回到默认值 + 清空标签)
|
||||||
const paramDescriptors = useMemo(() => {
|
const handleReset = useCallback(() => {
|
||||||
if (!selectedModel) return [];
|
form.resetFields();
|
||||||
return buildParamDescriptors(selectedModel.param_schema || []);
|
form.setFieldsValue(initialValues);
|
||||||
}, [selectedModel]);
|
setTaskTag('');
|
||||||
|
}, [form, initialValues]);
|
||||||
|
|
||||||
|
// ======== 提交 ========
|
||||||
|
|
||||||
// 提交
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
message.warning('请先登录后再提交任务');
|
message.warning('请先登录后再提交任务');
|
||||||
@@ -167,128 +168,47 @@ export function ModelInputForm() {
|
|||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const values = await form.validateFields();
|
||||||
|
|
||||||
// 分离固定字段和模型动态参数
|
const params: Record<string, string> = {};
|
||||||
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
|
|
||||||
|
|
||||||
// 构建提交参数(全部转为字符串值)
|
for (const field of fields) {
|
||||||
const params: Record<string, string> = {
|
const value = values[field.name];
|
||||||
prompt: String(prompt ?? ''),
|
if (value === undefined || value === null) continue;
|
||||||
negative_prompt: String(negative_prompt ?? ''),
|
|
||||||
num_outputs: String(num_outputs ?? 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
// 动态参数统一转字符串
|
if (field.isFileWidget) {
|
||||||
for (const [key, value] of Object.entries(dynamicParams)) {
|
const fileList = value as UploadFile[];
|
||||||
if (value !== undefined && value !== null) {
|
const urls = fileList
|
||||||
params[key] = String(value);
|
.filter((f) => f.status === 'done')
|
||||||
|
.map((f) => f.response?.url || f.url || f.name)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
if (urls.length > 0) {
|
||||||
|
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
params[field.name] = String(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
params[field.name] = String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 参考图
|
await submitTask({
|
||||||
if (referenceFiles.length > 0) {
|
model_id: selectedModel.id,
|
||||||
const refUrls = referenceFiles
|
params,
|
||||||
.filter((f) => f.status === 'done')
|
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
|
||||||
.map((f) => f.response?.url || f.url || f.name)
|
});
|
||||||
.filter(Boolean) as string[];
|
|
||||||
if (refUrls.length > 0) {
|
|
||||||
params.reference_images = JSON.stringify(refUrls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 mutation Hook 提交(自动处理 loading / error / 日志)
|
|
||||||
await submitTask({ model_id: selectedModel.id, params });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||||
// 表单验证错误,antd 会自动提示
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 错误消息由 Hook 自动通过 errorMessage 提供
|
|
||||||
message.error(errorMessage || '任务提交失败,请重试');
|
message.error(errorMessage || '任务提交失败,请重试');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染单个参数控件
|
// ======== 未选择模型 ========
|
||||||
const renderParamControl = (desc: ParamDescriptor) => {
|
|
||||||
const commonProps = {
|
|
||||||
placeholder: desc.placeholder,
|
|
||||||
style: { width: '100%' },
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (desc.controlType) {
|
|
||||||
case 'number':
|
|
||||||
return (
|
|
||||||
<InputNumber
|
|
||||||
{...commonProps}
|
|
||||||
min={desc.min}
|
|
||||||
max={desc.max}
|
|
||||||
step={desc.step}
|
|
||||||
style={{ width: '100%' }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'textarea':
|
|
||||||
return <TextArea rows={3} {...commonProps} />;
|
|
||||||
|
|
||||||
case 'select':
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
{...commonProps}
|
|
||||||
options={desc.options}
|
|
||||||
allowClear
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'slider':
|
|
||||||
return (
|
|
||||||
<Slider
|
|
||||||
min={desc.min ?? 1}
|
|
||||||
max={desc.max ?? 100}
|
|
||||||
step={desc.step ?? 1}
|
|
||||||
marks={
|
|
||||||
desc.min !== undefined && desc.max !== undefined
|
|
||||||
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
tooltip={{ formatter: (v) => v }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'switch':
|
|
||||||
return <Switch />;
|
|
||||||
|
|
||||||
case 'image-upload':
|
|
||||||
return (
|
|
||||||
<Upload
|
|
||||||
listType="picture-card"
|
|
||||||
maxCount={1}
|
|
||||||
accept="image/png,image/jpg,image/jpeg,image/webp"
|
|
||||||
beforeUpload={(file) => {
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (!isLt10M) {
|
|
||||||
message.error('图片大小不能超过 10MB');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false; // 手动上传
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<UploadOutlined />
|
|
||||||
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
|
||||||
</div>
|
|
||||||
</Upload>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return <Input {...commonProps} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- 未选择模型 ----
|
|
||||||
if (!selectedModel) {
|
if (!selectedModel) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -304,7 +224,24 @@ export function ModelInputForm() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 表单 ----
|
// ======== 提取 meta 信息 ========
|
||||||
|
|
||||||
|
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
|
||||||
|
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
|
||||||
|
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const outputType = metaConfig.output_type as OutputTypeString | undefined;
|
||||||
|
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
|
||||||
|
const badge = uiConfig.badge as string | undefined;
|
||||||
|
const group = uiConfig.group as string | undefined;
|
||||||
|
const helpUrl = metaConfig.help_url as string | undefined;
|
||||||
|
|
||||||
|
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
|
||||||
|
const price = pricingConfig.price as number | undefined;
|
||||||
|
const priceType = pricingConfig.price_type as string | undefined;
|
||||||
|
|
||||||
|
// ======== 正常渲染 ========
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -314,7 +251,7 @@ export function ModelInputForm() {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 模型标题 */}
|
{/* ======== Header:模型信息 ======== */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '16px 20px',
|
padding: '16px 20px',
|
||||||
@@ -322,145 +259,146 @@ export function ModelInputForm() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
{selectedModel.name}
|
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
|
||||||
</Title>
|
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
{selectedModel.name}
|
||||||
{selectedModel.provider_name} · {selectedModel.category_name}
|
</Title>
|
||||||
</Text>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 可滚动表单区 */}
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
<Form
|
{selectedModel.provider_name}
|
||||||
form={form}
|
{group
|
||||||
layout="vertical"
|
? ` · ${group}`
|
||||||
size="small"
|
: selectedModel.category_name
|
||||||
initialValues={{ num_outputs: 1 }}
|
? ` · ${selectedModel.category_name}`
|
||||||
>
|
: ''}
|
||||||
{/* ======== 固定字段 ======== */}
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 提示词 */}
|
<Space size={4} style={{ marginTop: 8 }} wrap>
|
||||||
<Form.Item
|
{outputType && (
|
||||||
name="prompt"
|
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||||
label={<Text strong>提示词</Text>}
|
{OutputTypeMap[outputType] || outputType}
|
||||||
rules={[{ required: true, message: '请输入提示词' }]}
|
</Tag>
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
rows={4}
|
|
||||||
placeholder="描述你想要的画面、视频或音频内容..."
|
|
||||||
showCount
|
|
||||||
maxLength={2000}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 负面提示词 */}
|
|
||||||
<Form.Item
|
|
||||||
name="negative_prompt"
|
|
||||||
label={<Text type="secondary">负面提示词</Text>}
|
|
||||||
>
|
|
||||||
<TextArea
|
|
||||||
rows={2}
|
|
||||||
placeholder="描述你不希望在结果中出现的内容(可选)"
|
|
||||||
maxLength={1000}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 参考图 */}
|
|
||||||
<Form.Item label={<Text strong>参考图</Text>}>
|
|
||||||
<Dragger
|
|
||||||
multiple
|
|
||||||
listType="picture"
|
|
||||||
fileList={referenceFiles}
|
|
||||||
onChange={({ fileList }) => setReferenceFiles(fileList)}
|
|
||||||
accept="image/png,image/jpg,image/jpeg,image/webp"
|
|
||||||
beforeUpload={(file) => {
|
|
||||||
const isImage = file.type.startsWith('image/');
|
|
||||||
const isLt10M = file.size / 1024 / 1024 < 10;
|
|
||||||
if (!isImage) {
|
|
||||||
message.error('只能上传图片文件');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
if (!isLt10M) {
|
|
||||||
message.error('图片大小不能超过 10MB');
|
|
||||||
return Upload.LIST_IGNORE;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}}
|
|
||||||
maxCount={5}
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
>
|
|
||||||
<p className="ant-upload-drag-icon">
|
|
||||||
<InboxOutlined />
|
|
||||||
</p>
|
|
||||||
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
|
||||||
<p className="ant-upload-hint">
|
|
||||||
支持 PNG / JPG / WebP,单张不超过 10MB
|
|
||||||
</p>
|
|
||||||
</Dragger>
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* 生成数量 */}
|
|
||||||
<Form.Item
|
|
||||||
name="num_outputs"
|
|
||||||
label={<Text strong>生成数量</Text>}
|
|
||||||
>
|
|
||||||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
|
||||||
</Form.Item>
|
|
||||||
|
|
||||||
{/* ======== 模型动态参数 ======== */}
|
|
||||||
|
|
||||||
{paramDescriptors.length > 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: 16,
|
|
||||||
paddingTop: 16,
|
|
||||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
|
||||||
模型参数
|
|
||||||
</Text>
|
|
||||||
{paramDescriptors.map((desc) => (
|
|
||||||
<Form.Item
|
|
||||||
key={desc.key}
|
|
||||||
name={desc.name}
|
|
||||||
label={desc.label}
|
|
||||||
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined}
|
|
||||||
initialValue={desc.defaultValue}
|
|
||||||
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'}
|
|
||||||
>
|
|
||||||
{renderParamControl(desc)}
|
|
||||||
</Form.Item>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</Form>
|
{estimatedDuration !== undefined && (
|
||||||
|
<Tag
|
||||||
|
icon={<ClockCircleOutlined />}
|
||||||
|
color="default"
|
||||||
|
style={{ fontSize: 11, lineHeight: '18px' }}
|
||||||
|
>
|
||||||
|
约 {estimatedDuration} 秒
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
{helpUrl && (
|
||||||
|
<a
|
||||||
|
href={helpUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ fontSize: 11 }}
|
||||||
|
>
|
||||||
|
使用帮助 ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
{/* ======== Body:参数列表(useModelUI 驱动) ======== */}
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '16px 20px' }}>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<Empty
|
||||||
|
description="该模型无需额外参数"
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
style={{ marginTop: 24 }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Form.Item
|
||||||
|
key={`${selectedModel.id}-${field.name}`}
|
||||||
|
name={field.name}
|
||||||
|
label={
|
||||||
|
<span>
|
||||||
|
<Text strong={field.must}>{field.label}</Text>
|
||||||
|
{field.tips && (
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 12, marginLeft: 6 }}
|
||||||
|
>
|
||||||
|
{field.tips.length > 60
|
||||||
|
? `${field.tips.slice(0, 60)}...`
|
||||||
|
: field.tips}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
rules={
|
||||||
|
field.must
|
||||||
|
? [{ required: true, message: `请输入${field.label}` }]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
valuePropName={field.valuePropName}
|
||||||
|
{...(field.isFileWidget
|
||||||
|
? { getValueFromEvent: handleFileWidgetValue }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
<DependentFormItem field={field} form={form} />
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ======== Footer:标签 + 重置 + 提交 ======== */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 20px',
|
padding: '12px 20px',
|
||||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Input
|
||||||
|
placeholder="自定义任务标签(可选)"
|
||||||
|
value={taskTag}
|
||||||
|
onChange={(e) => setTaskTag(e.target.value)}
|
||||||
|
allowClear
|
||||||
|
maxLength={50}
|
||||||
|
style={{ flex: 1, minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={handleReset}
|
||||||
|
disabled={!selectedModel}
|
||||||
|
title="重置参数"
|
||||||
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<SendOutlined />}
|
icon={<SendOutlined />}
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
disabled={!isLoggedIn || !selectedModel}
|
disabled={!isLoggedIn || !selectedModel}
|
||||||
block
|
|
||||||
size="large"
|
size="large"
|
||||||
style={{
|
style={{
|
||||||
background: token.colorPrimary,
|
background: token.colorPrimary,
|
||||||
height: 44,
|
height: 40,
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
开始生成
|
{priceType === 'SIMPLE' && price !== undefined
|
||||||
|
? `消费 ¥${price}/${priceUnit || '次'}`
|
||||||
|
: '消费'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a
|
|||||||
import { AppstoreOutlined } from '@ant-design/icons';
|
import { AppstoreOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useModelList } from '@/hooks/use-api';
|
import { useModelList } from '@/hooks/use-api';
|
||||||
import { useHomeContext } from '@/contexts/home-context';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules';
|
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||||
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useHomeContext } from '@/contexts/home-context';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { useTaskDetail } from '@/hooks/use-api';
|
import { useTaskDetail } from '@/hooks/use-api';
|
||||||
import type { TaskStatusString } from '@/services/modules';
|
import type { TaskStatusString } from '@/services/modules/task';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,16 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
|||||||
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTaskList, useModelList } from '@/hooks/use-api';
|
import { useTaskList, useModelList } from '@/hooks/use-api';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { useHomeContext } from '@/contexts/home-context';
|
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { on, off, EVENTS } from '@/utils/event-bus';
|
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||||
import type {
|
import type {
|
||||||
TaskInfoItemResponseBody,
|
TaskInfoItemResponseBody,
|
||||||
TaskListRequestParams,
|
TaskListRequestParams,
|
||||||
TaskStatusString,
|
TaskStatusString,
|
||||||
} from '@/services/modules';
|
} from '@/services/modules/task';
|
||||||
import { TaskStatusMap, OutputTypeMap } from '@/services/modules';
|
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
184
src/pages/home/components/useModelUI.ts
Normal file
184
src/pages/home/components/useModelUI.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useModelUI — param_schema → UIFieldConfig[] 转换 Hook
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 遍历模型的 param_schema,解析 spec/ui 字段
|
||||||
|
// - 查 WIDGET_MAP 获取对应的 React 组件
|
||||||
|
// - 解析 constraints_config.requires 计算字段间依赖
|
||||||
|
// - 组装出可直接渲染的 UIFieldConfig 数组
|
||||||
|
//
|
||||||
|
// ModelInputForm 只需遍历 fields 渲染 Form.Item 即可,
|
||||||
|
// 不需要关心 param_schema 的解析逻辑。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||||
|
import { WIDGET_MAP, FALLBACK_WIDGET } from './useUI';
|
||||||
|
import type { WidgetProps } from './useUI';
|
||||||
|
|
||||||
|
// ---------- 输出类型 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单个字段的 UI 配置。
|
||||||
|
*
|
||||||
|
* ModelInputForm 遍历此数组,每一项渲染一个 Form.Item。
|
||||||
|
*/
|
||||||
|
export interface UIFieldConfig {
|
||||||
|
/** 渲染顺序(param_schema 中的索引,保证稳定) */
|
||||||
|
index: number;
|
||||||
|
/** 表单字段名(map_to),对应 Form.Item name */
|
||||||
|
name: string;
|
||||||
|
/** 要渲染的 React 组件 */
|
||||||
|
component: ComponentType<WidgetProps>;
|
||||||
|
/** 显示标签(ui.label → Form.Item label) */
|
||||||
|
label: string;
|
||||||
|
/** 是否必填(spec.required → Form.Item rules) */
|
||||||
|
must: boolean;
|
||||||
|
/** 默认值(spec.default → Form initialValues) */
|
||||||
|
defaultValue?: unknown;
|
||||||
|
/** 提示文本(ui.tips) */
|
||||||
|
tips?: string;
|
||||||
|
/** Form.Item 的 valuePropName(checkbox 用 'checked',其他用默认 'value') */
|
||||||
|
valuePropName?: string;
|
||||||
|
/** 是否为文件上传类控件(需要 getValueFromEvent 提取 fileList) */
|
||||||
|
isFileWidget: boolean;
|
||||||
|
/**
|
||||||
|
* 条件依赖:当这些字段的值为空时,本字段应被禁用。
|
||||||
|
*
|
||||||
|
* 来源于 constraints_config.requires 的逆向推导:
|
||||||
|
* { if: "sref", then: ["sw", "sv"] }
|
||||||
|
* → sw.dependsOn = ["sref"], sv.dependsOn = ["sref"]
|
||||||
|
*/
|
||||||
|
dependsOn?: string[];
|
||||||
|
/** 传给 widget 组件的配置(placeholder/options/min/max 等) */
|
||||||
|
widgetConfig: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 文件类型 widget 集合 ----------
|
||||||
|
|
||||||
|
const FILE_WIDGETS = new Set(['imageInput', 'imageList', 'audioList']);
|
||||||
|
|
||||||
|
// ---------- 约束解析 ----------
|
||||||
|
|
||||||
|
interface ConstraintRule {
|
||||||
|
if: string;
|
||||||
|
then: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 constraints_config.requires 构建反向依赖表。
|
||||||
|
*
|
||||||
|
* 输入:
|
||||||
|
* [{ if: "sref", then: ["sw", "sv"] }]
|
||||||
|
*
|
||||||
|
* 输出:
|
||||||
|
* { sw: ["sref"], sv: ["sref"] }
|
||||||
|
*
|
||||||
|
* 含义:sw 和 sv 依赖 sref,sref 为空时它们应被禁用。
|
||||||
|
*/
|
||||||
|
function buildDependencyMap(
|
||||||
|
constraintsConfig: Record<string, unknown>,
|
||||||
|
): Map<string, string[]> {
|
||||||
|
const depMap = new Map<string, string[]>();
|
||||||
|
const requires = constraintsConfig.requires as ConstraintRule[] | undefined;
|
||||||
|
|
||||||
|
if (!requires || !Array.isArray(requires)) return depMap;
|
||||||
|
|
||||||
|
for (const rule of requires) {
|
||||||
|
if (!rule.if || !rule.then || !Array.isArray(rule.then)) continue;
|
||||||
|
for (const target of rule.then) {
|
||||||
|
const existing = depMap.get(target) || [];
|
||||||
|
existing.push(rule.if);
|
||||||
|
depMap.set(target, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return depMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Hook ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据模型的 param_schema 和 constraints_config 构建 UI 字段配置数组。
|
||||||
|
*
|
||||||
|
* @param paramSchema — 来自 selectedModel.param_schema
|
||||||
|
* @param constraintsConfig — 来自 selectedModel.constraints_config
|
||||||
|
* @returns UIFieldConfig[] — 渲染就绪的字段配置列表
|
||||||
|
*/
|
||||||
|
export function useModelUI(
|
||||||
|
paramSchema: ModelsListResponseBody['param_schema'],
|
||||||
|
constraintsConfig: Record<string, unknown> = {},
|
||||||
|
): UIFieldConfig[] {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!paramSchema || paramSchema.length === 0) return [];
|
||||||
|
|
||||||
|
const depMap = buildDependencyMap(constraintsConfig);
|
||||||
|
|
||||||
|
return paramSchema.map((item, index) => {
|
||||||
|
const spec = (item.spec || {}) as Record<string, unknown>;
|
||||||
|
const ui = (item.ui || {}) as Record<string, unknown>;
|
||||||
|
const widget = (ui.widget as string) || 'lineEdit';
|
||||||
|
|
||||||
|
const component = WIDGET_MAP[widget] || FALLBACK_WIDGET;
|
||||||
|
|
||||||
|
const widgetConfig: Record<string, unknown> = {
|
||||||
|
placeholder: ui.placeholder || `请输入${ui.label || item.map_to}`,
|
||||||
|
required: spec.required,
|
||||||
|
enumValues: spec.enum,
|
||||||
|
minValue: spec.min_value,
|
||||||
|
maxValue: spec.max_value,
|
||||||
|
step: spec.single_step,
|
||||||
|
minLength: spec.min_length,
|
||||||
|
maxLength: spec.max_length,
|
||||||
|
// imageInput / imageList / audioList
|
||||||
|
fileFilter: spec.file_filter,
|
||||||
|
maxFileSizeMb: spec.max_file_size_mb,
|
||||||
|
minFiles: spec.min_files,
|
||||||
|
maxFiles: spec.max_files,
|
||||||
|
enableSwitch: ui.enable_switch,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFile = FILE_WIDGETS.has(widget);
|
||||||
|
const isCheckbox = widget === 'checkbox';
|
||||||
|
const name = item.map_to;
|
||||||
|
const dependsOn = depMap.get(name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
index,
|
||||||
|
name,
|
||||||
|
component,
|
||||||
|
label: (ui.label as string) || name,
|
||||||
|
must: (spec.required as boolean) || false,
|
||||||
|
defaultValue: spec.default,
|
||||||
|
tips: (ui.tips as string) || undefined,
|
||||||
|
valuePropName: isCheckbox ? 'checked' : undefined,
|
||||||
|
isFileWidget: isFile,
|
||||||
|
dependsOn: dependsOn && dependsOn.length > 0 ? dependsOn : undefined,
|
||||||
|
widgetConfig,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [paramSchema, constraintsConfig]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 widget 类型是否为文件上传类控件 */
|
||||||
|
export function isFileWidget(widgetName: string): boolean {
|
||||||
|
return FILE_WIDGETS.has(widgetName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 值判空工具 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断表单值是否为"空"(用于依赖禁用判断)。
|
||||||
|
*
|
||||||
|
* 注意:空数组 [] 不视为空。
|
||||||
|
* 对于 enable_switch 控件:undefined = 开关关闭, [] = 开关开启但未选文件。
|
||||||
|
* 配合 constraints_config.requires:开关开启即解锁依赖字段,无需等待文件上传。
|
||||||
|
*/
|
||||||
|
export function isValueEmpty(value: unknown): boolean {
|
||||||
|
if (value === undefined || value === null) return true;
|
||||||
|
if (typeof value === 'string' && value.trim() === '') return true;
|
||||||
|
if (typeof value === 'boolean' && !value) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
43
src/pages/home/components/useUI/AudioListInput.tsx
Normal file
43
src/pages/home/components/useUI/AudioListInput.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// ============================================================
|
||||||
|
// AudioListInput — audioList → Dragger(音频文件拖拽上传)
|
||||||
|
// 用于声纹克隆的音频样本上传场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
export function AudioListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
accept={
|
||||||
|
filters
|
||||||
|
? filters.map((ext) => `.${ext}`).join(',')
|
||||||
|
: '.wav,.mp3,.m4a,.flac'
|
||||||
|
}
|
||||||
|
fileList={fileList}
|
||||||
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
|
disabled={disabled}
|
||||||
|
beforeUpload={() => false}
|
||||||
|
maxCount={maxCount}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽音频文件到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持 {filters?.join(' / ').toUpperCase() || 'WAV / MP3 / M4A / FLAC'}
|
||||||
|
,单文件不超过 {maxSizeMb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/pages/home/components/useUI/Checkbox.tsx
Normal file
17
src/pages/home/components/useUI/Checkbox.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Checkbox — checkbox → Switch
|
||||||
|
// 用于布尔开关:原始模式、平铺、Base64 输出等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Switch } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function Checkbox({ value, onChange, disabled }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
checked={value as boolean}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/pages/home/components/useUI/ComboBox.tsx
Normal file
24
src/pages/home/components/useUI/ComboBox.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ComboBox — comboBox → Select
|
||||||
|
// 用于枚举选择:宽高比、音色、分辨率、模型等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Select } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function ComboBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const enumValues = config.enumValues as string[] | undefined;
|
||||||
|
const options = enumValues?.map((v) => ({ label: v, value: v })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={value as string}
|
||||||
|
onChange={(v) => onChange?.(v)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请选择'}
|
||||||
|
options={options}
|
||||||
|
allowClear={!config.required}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/components/useUI/DoubleSpinBox.tsx
Normal file
23
src/pages/home/components/useUI/DoubleSpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// DoubleSpinBox — doubleSpinBox → InputNumber (step=config.step)
|
||||||
|
// 用于小数输入:语速、音量、相似度等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { InputNumber } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function DoubleSpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={config.step as number || 0.1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/pages/home/components/useUI/ImageInput.tsx
Normal file
106
src/pages/home/components/useUI/ImageInput.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ImageInput — imageInput → Upload(单张图片)
|
||||||
|
// 用于垫图、参考图等单图上传场景
|
||||||
|
//
|
||||||
|
// enable_switch 支持:
|
||||||
|
// 当 API ui.enable_switch === true 时,默认禁用(灰色),
|
||||||
|
// 需点击 Switch 开关后才能上传——匹配原 Qt 客户端行为。
|
||||||
|
// 开关关闭时清除已上传文件。
|
||||||
|
//
|
||||||
|
// 表单值语义(配合 constraints_config.requires 依赖禁用):
|
||||||
|
// undefined → 开关关闭 / 未启用 → isValueEmpty = true → 依赖字段禁用
|
||||||
|
// [] → 开关开启但未上传文件 → isValueEmpty = false → 依赖字段启用
|
||||||
|
// [{...}] → 已上传文件 → isValueEmpty = false → 依赖字段启用
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { Upload, Switch, Space, Typography, message } from 'antd';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
/** 构建图片 accept 字符串 */
|
||||||
|
function buildAccept(filters?: string[]): string {
|
||||||
|
if (!filters || filters.length === 0) return 'image/png,image/jpg,image/jpeg,image/webp';
|
||||||
|
return filters.map((ext) => `image/${ext}`).join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 标记"已启用但空"的空数组,避免 react 渲染时引用变化 */
|
||||||
|
const ENABLED_EMPTY: UploadFile[] = [];
|
||||||
|
|
||||||
|
export function ImageInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const hasEnableSwitch = (config.enableSwitch as boolean) || false;
|
||||||
|
|
||||||
|
// 有 enable_switch 时默认禁用,需手动开启
|
||||||
|
const [switchOn, setSwitchOn] = useState(!hasEnableSwitch);
|
||||||
|
const isUploadDisabled = disabled || !switchOn;
|
||||||
|
|
||||||
|
const handleSwitchChange = useCallback(
|
||||||
|
(on: boolean) => {
|
||||||
|
setSwitchOn(on);
|
||||||
|
if (!on) {
|
||||||
|
// 关闭开关 → 表单值置为 undefined(isValueEmpty = true,依赖字段禁用)
|
||||||
|
onChange?.(undefined);
|
||||||
|
} else {
|
||||||
|
// 开启开关 → 空数组 = "已启用但未上传"(isValueEmpty = false,依赖字段启用)
|
||||||
|
onChange?.(ENABLED_EMPTY as unknown as UploadFile[]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size={8}>
|
||||||
|
{hasEnableSwitch && (
|
||||||
|
<Space size={8}>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={switchOn}
|
||||||
|
onChange={handleSwitchChange}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
<Text type={switchOn ? 'success' : 'secondary'} style={{ fontSize: 12 }}>
|
||||||
|
{switchOn ? '已启用' : '点击开关启用上传'}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
maxCount={1}
|
||||||
|
accept={buildAccept(config.fileFilter as string[] | undefined)}
|
||||||
|
fileList={fileList}
|
||||||
|
onChange={({ fileList: newList }) => {
|
||||||
|
// 仅当开关开启时上报文件变化
|
||||||
|
if (switchOn || !hasEnableSwitch) {
|
||||||
|
onChange?.(newList as unknown as UploadFile[]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isUploadDisabled}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{fileList.length < 1 && (
|
||||||
|
<div style={{ opacity: isUploadDisabled ? 0.4 : 1 }}>
|
||||||
|
<UploadOutlined />
|
||||||
|
<div style={{ marginTop: 4, fontSize: 12 }}>上传</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/pages/home/components/useUI/ImageListInput.tsx
Normal file
55
src/pages/home/components/useUI/ImageListInput.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// ImageListInput — imageList → Dragger(多张图片拖拽上传)
|
||||||
|
// 用于图生图模型的多图输入场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Upload, message } from 'antd';
|
||||||
|
import { InboxOutlined } from '@ant-design/icons';
|
||||||
|
import type { UploadFile } from 'antd/es/upload';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { Dragger } = Upload;
|
||||||
|
|
||||||
|
export function ImageListInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const fileList = (value as UploadFile[]) || [];
|
||||||
|
const maxSizeMb = (config.maxFileSizeMb as number) || 10;
|
||||||
|
const maxCount = (config.maxFiles as number) || 10;
|
||||||
|
const filters = config.fileFilter as string[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dragger
|
||||||
|
multiple
|
||||||
|
listType="picture"
|
||||||
|
accept={
|
||||||
|
filters
|
||||||
|
? filters.map((ext) => `image/${ext}`).join(',')
|
||||||
|
: 'image/png,image/jpg,image/jpeg,image/webp'
|
||||||
|
}
|
||||||
|
fileList={fileList}
|
||||||
|
onChange={({ fileList: newList }) => onChange?.(newList)}
|
||||||
|
disabled={disabled}
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('只能上传图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size / 1024 / 1024 >= maxSizeMb) {
|
||||||
|
message.error(`图片大小不能超过 ${maxSizeMb}MB`);
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}}
|
||||||
|
maxCount={maxCount}
|
||||||
|
>
|
||||||
|
<p className="ant-upload-drag-icon">
|
||||||
|
<InboxOutlined />
|
||||||
|
</p>
|
||||||
|
<p className="ant-upload-text">点击或拖拽图片到此区域上传</p>
|
||||||
|
<p className="ant-upload-hint">
|
||||||
|
支持 {filters?.join(' / ').toUpperCase() || 'PNG / JPG / WebP'}
|
||||||
|
,单张不超过 {maxSizeMb}MB
|
||||||
|
</p>
|
||||||
|
</Dragger>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/pages/home/components/useUI/LineInput.tsx
Normal file
21
src/pages/home/components/useUI/LineInput.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LineInput — lineEdit → Input
|
||||||
|
// 用于单行文本输入(如 custom_voice_id)
|
||||||
|
// 同时作为未知 widget 类型的兜底控件
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function LineInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
allowClear
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/pages/home/components/useUI/SeedanceContent.tsx
Normal file
55
src/pages/home/components/useUI/SeedanceContent.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// SeedanceContent — seedance2Content → 自定义内容编排控件
|
||||||
|
//
|
||||||
|
// 对应 Seedance 2.0 视频生成模型的 content 参数(json_array)
|
||||||
|
// 最终形态:文本 + 参考图 + 参考视频自由组合编排
|
||||||
|
//
|
||||||
|
// TODO: 当前为占位实现,后续需完善拖拽编排能力
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input, Typography, theme as antTheme } from 'antd';
|
||||||
|
import { PictureOutlined, VideoCameraOutlined } from '@ant-design/icons';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
export function SeedanceContent({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
const { token } = antTheme.useToken();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: `1px dashed ${token.colorBorder}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: 12,
|
||||||
|
background: token.colorFillQuaternary,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
|
||||||
|
<PictureOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<VideoCameraOutlined style={{ color: token.colorPrimary, fontSize: 14 }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
内容编排 — 描述动作 + 参考素材
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={
|
||||||
|
(config.placeholder as string) ||
|
||||||
|
'输入视频内容的文字描述(后续版本将支持拖拽编排图片/视频参考素材)'
|
||||||
|
}
|
||||||
|
maxLength={(config.maxLength as number) || 5000}
|
||||||
|
showCount
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text type="secondary" style={{ display: 'block', marginTop: 6, fontSize: 11 }}>
|
||||||
|
💡 更多内容编排能力即将上线:支持文本 + 参考图 + 参考视频自由组合
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/components/useUI/SpinBox.tsx
Normal file
23
src/pages/home/components/useUI/SpinBox.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// SpinBox — spinBox → InputNumber (step=1)
|
||||||
|
// 用于整数输入:混沌、风格化、怪异、时长、音调等
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { InputNumber } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
export function SpinBox({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
value={value as number}
|
||||||
|
onChange={(v) => onChange?.(v != null ? v : undefined)}
|
||||||
|
disabled={disabled}
|
||||||
|
min={config.minValue as number | undefined}
|
||||||
|
max={config.maxValue as number | undefined}
|
||||||
|
step={1}
|
||||||
|
style={{ width: '100%', minWidth: 140 }}
|
||||||
|
placeholder={(config.placeholder as string) || undefined}
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
src/pages/home/components/useUI/TextInput.tsx
Normal file
23
src/pages/home/components/useUI/TextInput.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// ============================================================
|
||||||
|
// TextInput — textEdit → Input.TextArea
|
||||||
|
// 用于提示词、文本合成等长文本输入场景
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
export function TextInput({ value, onChange, disabled, config }: WidgetProps) {
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
rows={4}
|
||||||
|
value={value as string}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
placeholder={(config.placeholder as string) || '请输入'}
|
||||||
|
maxLength={config.maxLength as number | undefined}
|
||||||
|
showCount={config.maxLength !== undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/pages/home/components/useUI/index.ts
Normal file
55
src/pages/home/components/useUI/index.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useUI/index.ts — Widget 注册表
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// 1. 定义 WidgetProps 统一接口
|
||||||
|
// 2. 维护 Qt 控件名 → React 组件的 WIDGET_MAP 映射
|
||||||
|
// 3. 导出 WidgetType 联合类型供外部使用
|
||||||
|
//
|
||||||
|
// 新增控件类型只需两步:
|
||||||
|
// ① 在 useUI/ 下创建组件文件
|
||||||
|
// ② 在 WIDGET_MAP 中加一行
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
import { TextInput } from './TextInput';
|
||||||
|
import { LineInput } from './LineInput';
|
||||||
|
import { ComboBox } from './ComboBox';
|
||||||
|
import { Checkbox } from './Checkbox';
|
||||||
|
import { SpinBox } from './SpinBox';
|
||||||
|
import { DoubleSpinBox } from './DoubleSpinBox';
|
||||||
|
import { ImageInput } from './ImageInput';
|
||||||
|
import { ImageListInput } from './ImageListInput';
|
||||||
|
import { AudioListInput } from './AudioListInput';
|
||||||
|
import { SeedanceContent } from './SeedanceContent';
|
||||||
|
|
||||||
|
export type { WidgetProps } from './types';
|
||||||
|
|
||||||
|
// ---------- 注册表 ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Qt 控件名 → React 组件的映射表。
|
||||||
|
*
|
||||||
|
* 键来自 API param_schema[].ui.widget 字段(原 Python Qt 客户端的组件类名),
|
||||||
|
* 值为 useUI/ 下的对应 React 组件。
|
||||||
|
*/
|
||||||
|
export const WIDGET_MAP: Record<string, ComponentType<WidgetProps>> = {
|
||||||
|
textEdit: TextInput,
|
||||||
|
lineEdit: LineInput,
|
||||||
|
comboBox: ComboBox,
|
||||||
|
checkbox: Checkbox,
|
||||||
|
spinBox: SpinBox,
|
||||||
|
doubleSpinBox: DoubleSpinBox,
|
||||||
|
imageInput: ImageInput,
|
||||||
|
imageList: ImageListInput,
|
||||||
|
audioList: AudioListInput,
|
||||||
|
seedance2Content: SeedanceContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 已知的 widget 类型字面量联合 */
|
||||||
|
export type WidgetType = keyof typeof WIDGET_MAP;
|
||||||
|
|
||||||
|
/** 未知控件类型的兜底组件(渲染为普通 Input) */
|
||||||
|
export const FALLBACK_WIDGET: ComponentType<WidgetProps> = LineInput;
|
||||||
19
src/pages/home/components/useUI/types.ts
Normal file
19
src/pages/home/components/useUI/types.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// ============================================================
|
||||||
|
// useUI/types.ts — Widget 类型定义(与 index.ts 分离以避免循环引用)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 所有 widget 组件的统一 Props(非泛型)。
|
||||||
|
*
|
||||||
|
* 不设类型参数的原因:WIDGET_MAP 需要统一存储不同值类型的组件,
|
||||||
|
* 带泛型的 WidgetProps<string> 和 WidgetProps<number> 因函数参数逆变
|
||||||
|
* 而互不兼容(ComponentType 协变检查失败),改为统一 unknown 接口,
|
||||||
|
* 各组件内部通过类型断言收窄到已知类型。
|
||||||
|
*/
|
||||||
|
export interface WidgetProps {
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 从 param_schema 提取的控件专属配置(placeholder / options / min / max 等) */
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
}
|
||||||
@@ -12,8 +12,8 @@ import { useState, useCallback } from 'react';
|
|||||||
import { Modal, Input, Button, Typography, App } from 'antd';
|
import { Modal, Input, Button, Typography, App } from 'antd';
|
||||||
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import {
|
|||||||
KeyOutlined,
|
KeyOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { AuthAPI } from '@/services/modules';
|
import { AuthAPI } from '@/services/modules/auth';
|
||||||
import {
|
import {
|
||||||
validatePhone,
|
validatePhone,
|
||||||
validateSmsCode,
|
validateSmsCode,
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { loginFields } from '../config/login-fields';
|
import { loginFields } from '../config/login-fields';
|
||||||
import { validateForm } from '../config/validators';
|
import { validateForm } from '../config/validators';
|
||||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
import type { LoginFormValues } from '../types';
|
import type { LoginFormValues } from '../types';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -40,7 +40,7 @@ export function LoginForm({
|
|||||||
const [password, setPassword] = useState(initialPassword || '');
|
const [password, setPassword] = useState(initialPassword || '');
|
||||||
const [remember, setRemember] = useState(initialRemember);
|
const [remember, setRemember] = useState(initialRemember);
|
||||||
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||||
|
|
||||||
// 协议弹窗
|
// 协议弹窗
|
||||||
@@ -103,6 +103,8 @@ export function LoginForm({
|
|||||||
setPassword(e.target.value);
|
setPassword(e.target.value);
|
||||||
clearError('password');
|
clearError('password');
|
||||||
}}
|
}}
|
||||||
|
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||||
|
onPressEnter={handleSubmit}
|
||||||
status={errors.password ? 'error' : undefined}
|
status={errors.password ? 'error' : undefined}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
@@ -162,6 +164,11 @@ export function LoginForm({
|
|||||||
setAgreed(e.target.checked);
|
setAgreed(e.target.checked);
|
||||||
clearError('_agreement');
|
clearError('_agreement');
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(event)=>{
|
||||||
|
if (event.key === 'Enter' || event.keyCode === 13) {
|
||||||
|
setAgreed(!agreed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
已阅读并同意
|
已阅读并同意
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
// RegisterForm — 注册表单(仅个人版)
|
// RegisterForm — 注册表单(仅个人版)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import React, { useState, useCallback, useRef } from 'react';
|
||||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||||
|
|
||||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||||
import {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
@@ -87,17 +87,16 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
|||||||
onSendSms?.(phone);
|
onSendSms?.(phone);
|
||||||
}, [values.phone, onSendSms]);
|
}, [values.phone, onSendSms]);
|
||||||
|
|
||||||
// 跨字段校验
|
|
||||||
const crossValidators = {
|
|
||||||
confirmPassword: (val: string) => {
|
|
||||||
if (!val) return '请再次输入密码';
|
|
||||||
if (val !== values.password) return '两次密码不一致';
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// 提交
|
// 提交
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
|
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
|
||||||
|
const crossValidators = {
|
||||||
|
confirmPassword: (val: string) => {
|
||||||
|
if (!val) return '请再次输入密码';
|
||||||
|
if (val !== values.password) return '两次密码不一致';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
||||||
if (Object.keys(fieldErrors).length > 0) {
|
if (Object.keys(fieldErrors).length > 0) {
|
||||||
setErrors(fieldErrors);
|
setErrors(fieldErrors);
|
||||||
@@ -208,6 +207,11 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
|||||||
return n;
|
return n;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(e)=>{
|
||||||
|
if(e.key==="Enter" || e.keyCode == 13) {
|
||||||
|
setAgreed(!agreed);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="text-xs"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
已阅读并同意
|
已阅读并同意
|
||||||
|
|||||||
@@ -1,76 +1,34 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// useAuthState — 记住账号 / 自动登录 状态管理
|
// useAuthState — 记住账号 / 自动登录 表单状态 Hook
|
||||||
//
|
//
|
||||||
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
|
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
|
||||||
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
||||||
//
|
//
|
||||||
|
// 持久化逻辑已提取到 services/auth-persistence.ts,
|
||||||
|
// 此处仅负责 React 组件状态(useState + useCallback)。
|
||||||
|
//
|
||||||
// 安全:
|
// 安全:
|
||||||
// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护)
|
// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护)
|
||||||
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
|
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
|
import {
|
||||||
|
saveAuth,
|
||||||
|
clearSavedAuth,
|
||||||
|
getSavedUsername,
|
||||||
|
getSavedRemember,
|
||||||
|
getSavedAutoLogin,
|
||||||
|
} from '@/services/auth-persistence';
|
||||||
|
|
||||||
const STORAGE_KEY = 'ele-heixiu-auth';
|
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
|
||||||
|
|
||||||
interface StoredAuth {
|
|
||||||
/** 上次登录的用户名(表单回填用) */
|
|
||||||
username: string;
|
|
||||||
/** 是否记住用户名(勾选后表单自动回填用户名) */
|
|
||||||
remember: boolean;
|
|
||||||
/** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */
|
|
||||||
autoLogin: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAuth(): StoredAuth | null {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
return JSON.parse(raw) as StoredAuth;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeAuth(auth: StoredAuth): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
|
||||||
} catch {
|
|
||||||
/* 静默 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 工具函数(不依赖 React,AppProvider 可直接调用)----------
|
|
||||||
|
|
||||||
/** 读取自动登录标记 */
|
|
||||||
export function getAutoLoginFlag(): boolean {
|
|
||||||
return readAuth()?.autoLogin || false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
|
|
||||||
export function clearAutoLoginFlag(): void {
|
|
||||||
const auth = readAuth();
|
|
||||||
if (!auth) return;
|
|
||||||
writeAuth({ ...auth, autoLogin: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 清除所有保存的认证信息 */
|
|
||||||
export function clearSavedAuth(): void {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
|
||||||
} catch {
|
|
||||||
/* 静默 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- Hook ----------
|
// ---------- Hook ----------
|
||||||
|
|
||||||
export function useAuthState() {
|
export function useAuthState() {
|
||||||
const saved = readAuth();
|
const [username, setUsername] = useState(getSavedUsername);
|
||||||
|
const [remember, setRemember] = useState(getSavedRemember);
|
||||||
const [username, setUsername] = useState(saved?.username || '');
|
const [autoLogin, setAutoLogin] = useState(getSavedAutoLogin);
|
||||||
const [remember, setRemember] = useState(saved?.remember || false);
|
|
||||||
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
|
|
||||||
|
|
||||||
const handleRememberChange = useCallback((checked: boolean) => {
|
const handleRememberChange = useCallback((checked: boolean) => {
|
||||||
setRemember(checked);
|
setRemember(checked);
|
||||||
@@ -93,16 +51,11 @@ export function useAuthState() {
|
|||||||
* @param doRemember - 是否勾选"记住用户名"
|
* @param doRemember - 是否勾选"记住用户名"
|
||||||
* @param doAutoLogin - 是否勾选"自动登录"
|
* @param doAutoLogin - 是否勾选"自动登录"
|
||||||
*/
|
*/
|
||||||
const saveAuth = useCallback(
|
const handleSaveAuth = useCallback(
|
||||||
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
||||||
setUsername(user);
|
setUsername(user);
|
||||||
if (doRemember || doAutoLogin) {
|
if (doRemember || doAutoLogin) {
|
||||||
const payload: StoredAuth = {
|
saveAuth(user, doRemember, doAutoLogin);
|
||||||
username: user,
|
|
||||||
remember: doRemember,
|
|
||||||
autoLogin: doAutoLogin,
|
|
||||||
};
|
|
||||||
writeAuth(payload);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
@@ -115,6 +68,6 @@ export function useAuthState() {
|
|||||||
setUsername,
|
setUsername,
|
||||||
handleRememberChange,
|
handleRememberChange,
|
||||||
handleAutoLoginChange,
|
handleAutoLoginChange,
|
||||||
saveAuth,
|
saveAuth: handleSaveAuth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,13 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Modal, Tabs, Typography, App } from 'antd';
|
import { Modal, Tabs, Typography, App } from 'antd';
|
||||||
|
|
||||||
import { useAppContext } from '@/contexts/app-context.ts';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { useTheme } from '@/hooks/use-theme.ts';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { LoginForm } from './components/LoginForm';
|
import { LoginForm } from './components/LoginForm';
|
||||||
import { RegisterForm } from './components/RegisterForm';
|
import { RegisterForm } from './components/RegisterForm';
|
||||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||||
import { useAuthState } from './hooks/use-auth-state';
|
import { useAuthState } from './hooks/use-auth-state';
|
||||||
import { AuthAPI, LoginResponseBody } from '@/services/modules';
|
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||||
import { getDeviceId } from '@/utils/device';
|
import { getDeviceId } from '@/utils/device';
|
||||||
import type { LoginFormValues, RegisterFormValues } from './types';
|
import type { LoginFormValues, RegisterFormValues } from './types';
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// OrdersPage — 订单明细(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function OrdersPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>订单明细 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// ProjectsPage — 项目管理(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function ProjectsPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>项目管理 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// QuotaPage — 查配额(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function QuotaPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>查配额 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// RechargePage — 充值积分(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function RechargePage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>充值积分 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { Modal } from 'antd';
|
import { Modal } from 'antd';
|
||||||
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { isMacOS } from '@/utils/platform';
|
import { isMacOS } from '@/utils/platform';
|
||||||
import { ThemeSetting } from './blocks/ThemeSetting';
|
import { ThemeSetting } from './blocks/ThemeSetting';
|
||||||
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
||||||
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import type { Edition } from '@shared/types';
|
import type { Edition } from '@shared/types';
|
||||||
import { useSettings } from '@/contexts/settings-context';
|
import { useSettings } from '@/components/SettingsProvider';
|
||||||
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
||||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAppContext } from '@/contexts/app-context';
|
import { useAppContext } from '@/components/AppProvider';
|
||||||
import { getPlatformName } from '@/utils/platform';
|
import { getPlatformName } from '@/utils/platform';
|
||||||
import { APP_VERSION } from '@shared/constants/version';
|
import { APP_VERSION } from '@shared/constants/version';
|
||||||
import { getEditionName } from '@shared/constants/app';
|
import { getEditionName } from '@shared/constants/app';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import { Card, Segmented, Typography } from 'antd';
|
import { Card, Segmented, Typography } from 'antd';
|
||||||
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||||
import { useTheme } from '@/hooks/use-theme';
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// VipPage — 开会员/激活(占位组件,后续实现具体功能)
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export function VipPage() {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 8 }}>
|
|
||||||
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>开会员/激活 — 页面开发中</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
84
src/services/auth-persistence.ts
Normal file
84
src/services/auth-persistence.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
// ============================================================
|
||||||
|
// auth-persistence — 本地认证偏好持久化(记住账号 / 自动登录)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// - 存储/读取登录表单偏好(用户名回填、记住密码、自动登录标记)
|
||||||
|
// - 纯 localStorage 操作,不依赖 React
|
||||||
|
// - 密码从不持久化(安全性由 JWT + safeStorage 保障)
|
||||||
|
//
|
||||||
|
// 与 auth-token.ts 的分工:
|
||||||
|
// auth-token.ts → JWT token 生命周期(refresh/access,safeStorage 加密)
|
||||||
|
// auth-persistence.ts → 登录表单偏好(localStorage 明文,仅用户名/标记)
|
||||||
|
// ============================================================ */
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'ele-heixiu-auth';
|
||||||
|
|
||||||
|
interface StoredAuth {
|
||||||
|
/** 上次登录的用户名(表单回填用) */
|
||||||
|
username: string;
|
||||||
|
/** 是否记住用户名(勾选后表单自动回填用户名) */
|
||||||
|
remember: boolean;
|
||||||
|
/** 是否勾选了自动登录(AppProvider 据此决定启动时是否刷新 token) */
|
||||||
|
autoLogin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAuth(): StoredAuth | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw) as StoredAuth;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAuth(auth: StoredAuth): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 公共 API ----------
|
||||||
|
|
||||||
|
/** 读取自动登录标记(AppProvider 启动时调用) */
|
||||||
|
export function getAutoLoginFlag(): boolean {
|
||||||
|
return readAuth()?.autoLogin || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
|
||||||
|
export function clearAutoLoginFlag(): void {
|
||||||
|
const auth = readAuth();
|
||||||
|
if (!auth) return;
|
||||||
|
writeAuth({ ...auth, autoLogin: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有保存的认证偏好 */
|
||||||
|
export function clearSavedAuth(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存认证偏好(登录成功时调用) */
|
||||||
|
export function saveAuth(username: string, remember: boolean, autoLogin: boolean): void {
|
||||||
|
writeAuth({ username, remember, autoLogin });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取已保存的用户名(表单回填用) */
|
||||||
|
export function getSavedUsername(): string {
|
||||||
|
return readAuth()?.username || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取记住密码标记 */
|
||||||
|
export function getSavedRemember(): boolean {
|
||||||
|
return readAuth()?.remember || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取自动登录标记 */
|
||||||
|
export function getSavedAutoLogin(): boolean {
|
||||||
|
return readAuth()?.autoLogin || false;
|
||||||
|
}
|
||||||
@@ -12,8 +12,7 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { setToken, setTokenRefreshHandler } from './request';
|
import { setToken, setTokenRefreshHandler } from './request';
|
||||||
import { AuthAPI } from './modules';
|
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
|
||||||
import type { RefreshTokenResponseBody } from './modules';
|
|
||||||
import {
|
import {
|
||||||
getSafeStore,
|
getSafeStore,
|
||||||
setSafeStore,
|
setSafeStore,
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
// ============================================================
|
|
||||||
// API 模块统一导出
|
|
||||||
// ============================================================
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetchAnnouncements,
|
|
||||||
type Announcement,
|
|
||||||
type AnnouncementType,
|
|
||||||
type AnnouncementListResponse,
|
|
||||||
} from './announcement';
|
|
||||||
|
|
||||||
export {
|
|
||||||
AuthAPI,
|
|
||||||
type SendSMSRequestBody,
|
|
||||||
type RegisterRequestBody,
|
|
||||||
type RegisterResponseBody,
|
|
||||||
type LoginRequestBody,
|
|
||||||
type LoginResponseBody,
|
|
||||||
type RefreshTokenRequestBody,
|
|
||||||
type RefreshTokenResponseBody,
|
|
||||||
type UserInfoBody,
|
|
||||||
type ChangePasswordRequestBody,
|
|
||||||
type UsePhoneResetPasswordRequestBody,
|
|
||||||
} from './auth';
|
|
||||||
|
|
||||||
export {
|
|
||||||
fetchBanners,
|
|
||||||
type Banner,
|
|
||||||
type BannerListResponse,
|
|
||||||
} from './banner';
|
|
||||||
|
|
||||||
export {
|
|
||||||
ModelAPI,
|
|
||||||
MODEL_CATEGORIES,
|
|
||||||
MODEL_CATEGORY_LABELS,
|
|
||||||
CATEGORY_SLUG_MAP,
|
|
||||||
resolveCategoryLabel,
|
|
||||||
toCategorySlug,
|
|
||||||
type ModelsListReqeustParams,
|
|
||||||
type ModelsListResponseBody,
|
|
||||||
type ModelInfoRequestParams,
|
|
||||||
type ModelInfoResponseBody,
|
|
||||||
type ModelCategoryStringEnum,
|
|
||||||
type ParamSchemaItem,
|
|
||||||
} from './models';
|
|
||||||
|
|
||||||
export {
|
|
||||||
TaskAPI,
|
|
||||||
OutputTypeMap,
|
|
||||||
TaskStatusMap,
|
|
||||||
type OutputTypeString,
|
|
||||||
type TaskStatusString,
|
|
||||||
type TaskListRequestParams,
|
|
||||||
type CreatTaskRequestBody,
|
|
||||||
type ExportTaskCSVParams,
|
|
||||||
type ContentItem,
|
|
||||||
type ImageUiParams,
|
|
||||||
type VideoUiParams,
|
|
||||||
type AudioUiParams,
|
|
||||||
type TaskInfoItemResponseBody,
|
|
||||||
type TaskInfoDataResponseBody,
|
|
||||||
type TaskStatusResponseBody,
|
|
||||||
} from './task';
|
|
||||||
130
src/theme/base.css
Normal file
130
src/theme/base.css
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 基础样式 — 重置、滚动条、聚焦环、选中文本、链接、拖拽区域
|
||||||
|
*
|
||||||
|
* 改全局基础样式 → 改这个文件。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 全局重置
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
/* 亮/暗切换过渡 */
|
||||||
|
color-scheme: light dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--color-text-base);
|
||||||
|
background-color: var(--color-bg-base);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 滚动条美化
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c7d2fe;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a5b4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗色模式滚动条 */
|
||||||
|
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 聚焦环 — 使用品牌色
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 选中文本 — 使用品牌色
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] ::selection {
|
||||||
|
background: rgba(129, 140, 248, 0.3);
|
||||||
|
color: var(--color-text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 链接全局样式
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color var(--transition-duration) var(--transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: var(--color-primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 窗口拖拽区域(自定义导航栏)
|
||||||
|
*
|
||||||
|
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
|
||||||
|
* .navbar-drag-region → 可拖拽(背景区域)
|
||||||
|
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
.navbar-drag-region {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* macOS 红绿灯按钮区域不遮挡内容 */
|
||||||
|
@supports (-webkit-app-region: drag) {
|
||||||
|
.navbar-drag-region {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,374 +1,14 @@
|
|||||||
/* ============================================================
|
/* ============================================================
|
||||||
* 全局样式 —— Tailwind CSS v4 指令 + 蓝紫渐变主题扩展 + 重置样式
|
* 全局样式入口 — Tailwind CSS v4 + 设计令牌 + 过渡 + 基础样式
|
||||||
* Tailwind v4 使用 CSS @theme 指令替代 tailwind.config.ts,
|
*
|
||||||
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。
|
* 拆分为 4 个文件,按职责定位:
|
||||||
|
* tokens.css → 改颜色 / 品牌色 / 亮暗变量
|
||||||
|
* transitions.css → 改主题切换过渡参数
|
||||||
|
* base.css → 改重置 / 滚动条 / 链接 / 拖拽区域
|
||||||
|
* globals.css → 本文件(入口,只负责引入顺序)
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
|
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
@import './tokens.css';
|
||||||
/* ============================================================
|
@import './transitions.css';
|
||||||
* Tailwind CSS v4 主题扩展(用 @theme 指令)
|
@import './base.css';
|
||||||
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
|
|
||||||
* 例如:colors.primary → bg-primary, text-primary, border-primary 等。
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/* 品牌色 — 蓝紫渐变 */
|
|
||||||
@theme {
|
|
||||||
/* 主色系 */
|
|
||||||
--color-primary: #6366f1; /* Indigo-500 */
|
|
||||||
--color-primary-light: #818cf8; /* Indigo-400 */
|
|
||||||
--color-primary-dark: #4f46e5; /* Indigo-600 */
|
|
||||||
--color-primary-50: #eef2ff; /* Indigo-50 */
|
|
||||||
--color-primary-100: #e0e7ff; /* Indigo-100 */
|
|
||||||
--color-primary-200: #c7d2fe; /* Indigo-200 */
|
|
||||||
--color-primary-300: #a5b4fc; /* Indigo-300 */
|
|
||||||
--color-primary-400: #818cf8; /* Indigo-400 */
|
|
||||||
--color-primary-500: #6366f1; /* Indigo-500 */
|
|
||||||
--color-primary-600: #4f46e5; /* Indigo-600 */
|
|
||||||
--color-primary-700: #4338ca; /* Indigo-700 */
|
|
||||||
--color-primary-800: #3730a3; /* Indigo-800 */
|
|
||||||
--color-primary-900: #312e81; /* Indigo-900 */
|
|
||||||
|
|
||||||
/* 辅助色系 — Violet */
|
|
||||||
--color-secondary: #8b5cf6; /* Violet-500 */
|
|
||||||
--color-secondary-light: #a78bfa; /* Violet-400 */
|
|
||||||
--color-secondary-dark: #7c3aed; /* Violet-600 */
|
|
||||||
|
|
||||||
/* 功能色 */
|
|
||||||
--color-success: #10b981;
|
|
||||||
--color-success-light: #d1fae5;
|
|
||||||
--color-warning: #f59e0b;
|
|
||||||
--color-warning-light: #fef3c7;
|
|
||||||
--color-error: #ef4444;
|
|
||||||
--color-error-light: #fee2e2;
|
|
||||||
--color-info: #3b82f6;
|
|
||||||
--color-info-light: #dbeafe;
|
|
||||||
|
|
||||||
/* 圆角 */
|
|
||||||
--radius-xs: 4px;
|
|
||||||
--radius-sm: 6px;
|
|
||||||
--radius-base: 8px;
|
|
||||||
--radius-lg: 12px;
|
|
||||||
--radius-xl: 16px;
|
|
||||||
--radius-2xl: 24px;
|
|
||||||
|
|
||||||
/* 字体 */
|
|
||||||
--font-family-base:
|
|
||||||
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
|
|
||||||
'PingFang SC', 'Microsoft YaHei', sans-serif;
|
|
||||||
--font-family-mono:
|
|
||||||
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
|
|
||||||
|
|
||||||
/* 动画 */
|
|
||||||
--transition-duration: 0.2s;
|
|
||||||
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 🌞 亮色模式 CSS 变量(默认)
|
|
||||||
* 这些变量与 tokens.ts 中的亮色令牌同步
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
:root,
|
|
||||||
[data-theme='light'] {
|
|
||||||
/* 中性色 — 亮色 */
|
|
||||||
--color-text-base: #1e1b4b;
|
|
||||||
--color-text-secondary: #6b7280;
|
|
||||||
--color-text-tertiary: #9ca3af;
|
|
||||||
--color-bg-base: #ffffff;
|
|
||||||
--color-bg-container: #f9fafb;
|
|
||||||
--color-bg-layout: #f5f3ff;
|
|
||||||
--color-bg-elevated: #ffffff;
|
|
||||||
--color-border-base: #e5e7eb;
|
|
||||||
--color-border-secondary: #f3f4f6;
|
|
||||||
|
|
||||||
/* 阴影 */
|
|
||||||
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 🌙 暗色模式 CSS 变量
|
|
||||||
* 当 <html data-theme="dark"> 时自动切换
|
|
||||||
* 这些变量与 tokens.ts 中的暗色令牌同步
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
[data-theme='dark'] {
|
|
||||||
/* 中性色 — 暗色(科技蓝紫) */
|
|
||||||
--color-text-base: #e8e8fa;
|
|
||||||
--color-text-secondary: #a0a8d8;
|
|
||||||
--color-text-tertiary: #6e78b0;
|
|
||||||
--color-bg-base: #080c24;
|
|
||||||
--color-bg-container: #0f1340;
|
|
||||||
--color-bg-layout: #050818;
|
|
||||||
--color-bg-elevated: #171b52;
|
|
||||||
--color-border-base: #2a3278;
|
|
||||||
--color-border-secondary: #1c2250;
|
|
||||||
|
|
||||||
/* 暗色模式下品牌色提亮以保持对比度 */
|
|
||||||
--color-primary: #818cf8;
|
|
||||||
--color-primary-light: #a5b4fc;
|
|
||||||
--color-primary-dark: #6366f1;
|
|
||||||
|
|
||||||
/* 阴影 — 暗色下用深投影 */
|
|
||||||
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
|
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 蓝紫渐变工具类
|
|
||||||
* Tailwind v4 使用 @utility 定义自定义工具类
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
@utility gradient-primary {
|
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility gradient-primary-hover {
|
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility gradient-primary-text {
|
|
||||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
@utility gradient-border {
|
|
||||||
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 全局重置样式
|
|
||||||
* 仅放必要的重置,不堆积组件样式
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
font-family: var(--font-family-base);
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.5;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
/* 亮/暗切换过渡 */
|
|
||||||
color-scheme: light dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
color: var(--color-text-base);
|
|
||||||
background-color: var(--color-bg-base);
|
|
||||||
min-height: 100vh;
|
|
||||||
transition:
|
|
||||||
background-color 0.15s linear,
|
|
||||||
color 0.15s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* View Transitions API — GPU 合成器驱动的主题切换动画
|
|
||||||
* 替代 80+ 个独立 CSS 过渡,单次 cross-fade 消除 Electron 卡顿。
|
|
||||||
* 仅在 Chromium 111+(Electron 28+)生效,其他浏览器回退 CSS 过渡。
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
::view-transition-old(root),
|
|
||||||
::view-transition-new(root) {
|
|
||||||
animation-duration: 0.15s;
|
|
||||||
animation-timing-function: linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 主题切换过渡 — 精准覆盖大面积视觉容器,避免 * 全局选择器
|
|
||||||
* 在 Electron 中为数千个 DOM 节点同时启动过渡造成卡顿。
|
|
||||||
*
|
|
||||||
* 策略:
|
|
||||||
* 1. 容器/布局组件(Card / Modal / Layout / Drawer 等)
|
|
||||||
* 2. 表格 & 列表(Table / List / Timeline)
|
|
||||||
* 3. 表单控件(Input / Select / Picker)
|
|
||||||
* 4. 导航 & 标签(Menu / Tabs / Tag / Breadcrumb)
|
|
||||||
* 5. 反馈 & 展示(Alert / Empty / Result / Statistic / Skeleton)
|
|
||||||
*
|
|
||||||
* 不覆盖交互组件自有 transition(Button / Switch / Slider 等
|
|
||||||
* 均使用 transition: all,特异性更高不受影响)。
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
/* —— 布局 & 容器 —— */
|
|
||||||
body,
|
|
||||||
.ant-layout,
|
|
||||||
.ant-layout-header,
|
|
||||||
.ant-layout-sider,
|
|
||||||
.ant-layout-content,
|
|
||||||
.ant-card,
|
|
||||||
.ant-card-head,
|
|
||||||
.ant-card-body,
|
|
||||||
.ant-card-actions,
|
|
||||||
.ant-modal-content,
|
|
||||||
.ant-modal-header,
|
|
||||||
.ant-modal-body,
|
|
||||||
.ant-modal-footer,
|
|
||||||
.ant-drawer-content,
|
|
||||||
.ant-drawer-header,
|
|
||||||
.ant-drawer-body,
|
|
||||||
.ant-drawer-footer,
|
|
||||||
|
|
||||||
/* —— 表格 & 列表 —— */
|
|
||||||
.ant-table,
|
|
||||||
.ant-table-thead > tr > th,
|
|
||||||
.ant-table-tbody > tr > td,
|
|
||||||
.ant-table-tbody > tr:hover > td,
|
|
||||||
.ant-list,
|
|
||||||
.ant-list-item,
|
|
||||||
.ant-timeline-item,
|
|
||||||
.ant-transfer-list,
|
|
||||||
.ant-tree-node-content-wrapper,
|
|
||||||
|
|
||||||
/* —— 表单控件 —— */
|
|
||||||
.ant-input,
|
|
||||||
.ant-input-affix-wrapper,
|
|
||||||
.ant-select-selector,
|
|
||||||
.ant-select-dropdown,
|
|
||||||
.ant-picker,
|
|
||||||
.ant-picker-input,
|
|
||||||
.ant-picker-dropdown,
|
|
||||||
.ant-picker-panel-container,
|
|
||||||
.ant-input-number,
|
|
||||||
.ant-input-number-input,
|
|
||||||
.ant-radio-button-wrapper,
|
|
||||||
.ant-radio-group,
|
|
||||||
.ant-checkbox-wrapper,
|
|
||||||
.ant-segmented,
|
|
||||||
.ant-segmented-item,
|
|
||||||
.ant-upload-drag,
|
|
||||||
.ant-upload-list-item,
|
|
||||||
|
|
||||||
/* —— 导航 & 标签 —— */
|
|
||||||
.ant-menu,
|
|
||||||
.ant-menu-item,
|
|
||||||
.ant-menu-submenu-title,
|
|
||||||
.ant-tabs-nav,
|
|
||||||
.ant-tabs-nav-list,
|
|
||||||
.ant-tabs-tab,
|
|
||||||
.ant-tabs-content,
|
|
||||||
.ant-tag,
|
|
||||||
.ant-breadcrumb,
|
|
||||||
.ant-pagination-item,
|
|
||||||
.ant-dropdown-menu,
|
|
||||||
|
|
||||||
/* —— 反馈 & 展示 —— */
|
|
||||||
.ant-alert,
|
|
||||||
.ant-empty,
|
|
||||||
.ant-result,
|
|
||||||
.ant-statistic,
|
|
||||||
.ant-statistic-content,
|
|
||||||
.ant-badge,
|
|
||||||
.ant-avatar,
|
|
||||||
.ant-divider,
|
|
||||||
.ant-skeleton,
|
|
||||||
.ant-skeleton-input,
|
|
||||||
.ant-skeleton-button,
|
|
||||||
.ant-notification-notice,
|
|
||||||
.ant-message-notice-content,
|
|
||||||
.ant-popover-inner,
|
|
||||||
.ant-tooltip-inner,
|
|
||||||
.ant-collapse,
|
|
||||||
.ant-collapse-item,
|
|
||||||
.ant-collapse-header,
|
|
||||||
.ant-collapse-content-box,
|
|
||||||
.ant-descriptions-item-container {
|
|
||||||
transition:
|
|
||||||
color 0.15s linear,
|
|
||||||
background-color 0.15s linear,
|
|
||||||
border-color 0.15s linear,
|
|
||||||
box-shadow 0.15s linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
#root {
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 滚动条美化 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #c7d2fe;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #a5b4fc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色模式滚动条 */
|
|
||||||
[data-theme='dark'] ::-webkit-scrollbar-thumb {
|
|
||||||
background: #4338ca;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #4f46e5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 聚焦环 — 使用品牌色 */
|
|
||||||
:focus-visible {
|
|
||||||
outline: 2px solid var(--color-primary);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 选中文本 — 使用品牌色 */
|
|
||||||
::selection {
|
|
||||||
background: rgba(99, 102, 241, 0.2);
|
|
||||||
color: var(--color-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme='dark'] ::selection {
|
|
||||||
background: rgba(129, 140, 248, 0.3);
|
|
||||||
color: var(--color-text-base);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 链接全局样式 */
|
|
||||||
a {
|
|
||||||
color: var(--color-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color var(--transition-duration) var(--transition-timing);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--color-primary-light);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 窗口拖拽区域(自定义导航栏)
|
|
||||||
*
|
|
||||||
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
|
|
||||||
* .navbar-drag-region → 可拖拽(背景区域)
|
|
||||||
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
.navbar-drag-region {
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-no-drag {
|
|
||||||
-webkit-app-region: no-drag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* macOS 红绿灯按钮区域不遮挡内容 */
|
|
||||||
@supports (-webkit-app-region: drag) {
|
|
||||||
.navbar-drag-region {
|
|
||||||
-webkit-app-region: drag;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
139
src/theme/tokens.css
Normal file
139
src/theme/tokens.css
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 设计令牌 — Tailwind CSS v4 @theme 扩展 + 亮暗 CSS 变量
|
||||||
|
*
|
||||||
|
* 与 tokens.ts / antd-theme.ts 保持同步。
|
||||||
|
* 改颜色 → 改这个文件。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* Tailwind CSS v4 主题扩展(@theme 指令)
|
||||||
|
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
|
||||||
|
* 例如:colors.primary → bg-primary, text-primary, border-primary 等。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* 品牌色 — 蓝紫渐变 */
|
||||||
|
@theme {
|
||||||
|
/* 主色系 */
|
||||||
|
--color-primary: #6366f1; /* Indigo-500 */
|
||||||
|
--color-primary-light: #818cf8; /* Indigo-400 */
|
||||||
|
--color-primary-dark: #4f46e5; /* Indigo-600 */
|
||||||
|
--color-primary-50: #eef2ff; /* Indigo-50 */
|
||||||
|
--color-primary-100: #e0e7ff; /* Indigo-100 */
|
||||||
|
--color-primary-200: #c7d2fe; /* Indigo-200 */
|
||||||
|
--color-primary-300: #a5b4fc; /* Indigo-300 */
|
||||||
|
--color-primary-400: #818cf8; /* Indigo-400 */
|
||||||
|
--color-primary-500: #6366f1; /* Indigo-500 */
|
||||||
|
--color-primary-600: #4f46e5; /* Indigo-600 */
|
||||||
|
--color-primary-700: #4338ca; /* Indigo-700 */
|
||||||
|
--color-primary-800: #3730a3; /* Indigo-800 */
|
||||||
|
--color-primary-900: #312e81; /* Indigo-900 */
|
||||||
|
|
||||||
|
/* 辅助色系 — Violet */
|
||||||
|
--color-secondary: #8b5cf6; /* Violet-500 */
|
||||||
|
--color-secondary-light: #a78bfa; /* Violet-400 */
|
||||||
|
--color-secondary-dark: #7c3aed; /* Violet-600 */
|
||||||
|
|
||||||
|
/* 功能色 */
|
||||||
|
--color-success: #10b981;
|
||||||
|
--color-success-light: #d1fae5;
|
||||||
|
--color-warning: #f59e0b;
|
||||||
|
--color-warning-light: #fef3c7;
|
||||||
|
--color-error: #ef4444;
|
||||||
|
--color-error-light: #fee2e2;
|
||||||
|
--color-info: #3b82f6;
|
||||||
|
--color-info-light: #dbeafe;
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
--radius-xs: 4px;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-base: 8px;
|
||||||
|
--radius-lg: 12px;
|
||||||
|
--radius-xl: 16px;
|
||||||
|
--radius-2xl: 24px;
|
||||||
|
|
||||||
|
/* 字体 */
|
||||||
|
--font-family-base:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
|
||||||
|
'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
--font-family-mono:
|
||||||
|
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
--transition-duration: 0.2s;
|
||||||
|
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 🌞 亮色模式 CSS 变量(默认)
|
||||||
|
* 这些变量与 tokens.ts 中的亮色令牌同步
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
:root,
|
||||||
|
[data-theme='light'] {
|
||||||
|
/* 中性色 — 亮色 */
|
||||||
|
--color-text-base: #1e1b4b;
|
||||||
|
--color-text-secondary: #6b7280;
|
||||||
|
--color-text-tertiary: #9ca3af;
|
||||||
|
--color-bg-base: #ffffff;
|
||||||
|
--color-bg-container: #f9fafb;
|
||||||
|
--color-bg-layout: #f5f3ff;
|
||||||
|
--color-bg-elevated: #ffffff;
|
||||||
|
--color-border-base: #e5e7eb;
|
||||||
|
--color-border-secondary: #f3f4f6;
|
||||||
|
|
||||||
|
/* 阴影 */
|
||||||
|
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 🌙 暗色模式 CSS 变量
|
||||||
|
* 当 <html data-theme="dark"> 时自动切换
|
||||||
|
* 这些变量与 tokens.ts 中的暗色令牌同步
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
/* 中性色 — 暗色(科技蓝紫) */
|
||||||
|
--color-text-base: #e8e8fa;
|
||||||
|
--color-text-secondary: #a0a8d8;
|
||||||
|
--color-text-tertiary: #6e78b0;
|
||||||
|
--color-bg-base: #a0a8d8;
|
||||||
|
--color-bg-container: #0f1340;
|
||||||
|
--color-bg-layout: #a0a8d8;
|
||||||
|
--color-bg-elevated: #171b52;
|
||||||
|
--color-border-base: #2a3278;
|
||||||
|
--color-border-secondary: #1c2250;
|
||||||
|
|
||||||
|
/* 暗色模式下品牌色提亮以保持对比度 */
|
||||||
|
--color-primary: #818cf8;
|
||||||
|
--color-primary-light: #a5b4fc;
|
||||||
|
--color-primary-dark: #6366f1;
|
||||||
|
|
||||||
|
/* 阴影 — 暗色下用深投影 */
|
||||||
|
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 蓝紫渐变工具类
|
||||||
|
* Tailwind v4 使用 @utility 定义自定义工具类
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
@utility gradient-primary {
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility gradient-primary-hover {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility gradient-primary-text {
|
||||||
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility gradient-border {
|
||||||
|
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
|
||||||
|
}
|
||||||
126
src/theme/transitions.css
Normal file
126
src/theme/transitions.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/* ============================================================
|
||||||
|
* 主题切换过渡 — View Transitions API + antd 容器精准覆盖
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* Chromium/Electron:View Transitions API GPU cross-fade(1 个动画)
|
||||||
|
* 其他浏览器:CSS transition 精准覆盖 ~80 个 antd 容器类
|
||||||
|
*
|
||||||
|
* 改过渡参数 → 改这个文件。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* View Transitions API — GPU 合成器驱动的主题切换动画
|
||||||
|
* 替代 80+ 个独立 CSS 过渡,单次 cross-fade 消除 Electron 卡顿。
|
||||||
|
* 仅在 Chromium 111+(Electron 28+)生效,其他浏览器回退 CSS 过渡。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation-duration: 0.15s;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
* 主题切换过渡 — 精准覆盖大面积视觉容器,避免 * 全局选择器
|
||||||
|
* 在 Electron 中为数千个 DOM 节点同时启动过渡造成卡顿。
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* 1. 容器/布局组件(Card / Modal / Layout / Drawer 等)
|
||||||
|
* 2. 表格 & 列表(Table / List / Timeline)
|
||||||
|
* 3. 表单控件(Input / Select / Picker)
|
||||||
|
* 4. 导航 & 标签(Menu / Tabs / Tag / Breadcrumb)
|
||||||
|
* 5. 反馈 & 展示(Alert / Empty / Result / Statistic / Skeleton)
|
||||||
|
*
|
||||||
|
* 不覆盖交互组件自有 transition(Button / Switch / Slider 等
|
||||||
|
* 均使用 transition: all,特异性更高不受影响)。
|
||||||
|
* ============================================================ */
|
||||||
|
|
||||||
|
/* —— 布局 & 容器 —— */
|
||||||
|
body,
|
||||||
|
.ant-layout,
|
||||||
|
.ant-layout-header,
|
||||||
|
.ant-layout-sider,
|
||||||
|
.ant-layout-content,
|
||||||
|
.ant-card,
|
||||||
|
.ant-card-head,
|
||||||
|
.ant-card-body,
|
||||||
|
.ant-card-actions,
|
||||||
|
.ant-modal-content,
|
||||||
|
.ant-modal-header,
|
||||||
|
.ant-modal-body,
|
||||||
|
.ant-modal-footer,
|
||||||
|
.ant-drawer-content,
|
||||||
|
.ant-drawer-header,
|
||||||
|
.ant-drawer-body,
|
||||||
|
.ant-drawer-footer,
|
||||||
|
|
||||||
|
/* —— 表格 & 列表 —— */
|
||||||
|
.ant-table,
|
||||||
|
.ant-table-thead > tr > th,
|
||||||
|
.ant-table-tbody > tr > td,
|
||||||
|
.ant-table-tbody > tr:hover > td,
|
||||||
|
.ant-list,
|
||||||
|
.ant-list-item,
|
||||||
|
.ant-timeline-item,
|
||||||
|
.ant-transfer-list,
|
||||||
|
.ant-tree-node-content-wrapper,
|
||||||
|
|
||||||
|
/* —— 表单控件 —— */
|
||||||
|
.ant-input,
|
||||||
|
.ant-input-affix-wrapper,
|
||||||
|
.ant-select-selector,
|
||||||
|
.ant-select-dropdown,
|
||||||
|
.ant-picker,
|
||||||
|
.ant-picker-input,
|
||||||
|
.ant-picker-dropdown,
|
||||||
|
.ant-picker-panel-container,
|
||||||
|
.ant-input-number,
|
||||||
|
.ant-input-number-input,
|
||||||
|
.ant-radio-button-wrapper,
|
||||||
|
.ant-radio-group,
|
||||||
|
.ant-checkbox-wrapper,
|
||||||
|
.ant-segmented,
|
||||||
|
.ant-segmented-item,
|
||||||
|
.ant-upload-drag,
|
||||||
|
.ant-upload-list-item,
|
||||||
|
|
||||||
|
/* —— 导航 & 标签 —— */
|
||||||
|
.ant-menu,
|
||||||
|
.ant-menu-item,
|
||||||
|
.ant-menu-submenu-title,
|
||||||
|
.ant-tabs-nav,
|
||||||
|
.ant-tabs-nav-list,
|
||||||
|
.ant-tabs-tab,
|
||||||
|
.ant-tabs-content,
|
||||||
|
.ant-tag,
|
||||||
|
.ant-breadcrumb,
|
||||||
|
.ant-pagination-item,
|
||||||
|
.ant-dropdown-menu,
|
||||||
|
|
||||||
|
/* —— 反馈 & 展示 —— */
|
||||||
|
.ant-alert,
|
||||||
|
.ant-empty,
|
||||||
|
.ant-result,
|
||||||
|
.ant-statistic,
|
||||||
|
.ant-statistic-content,
|
||||||
|
.ant-badge,
|
||||||
|
.ant-avatar,
|
||||||
|
.ant-divider,
|
||||||
|
.ant-skeleton,
|
||||||
|
.ant-skeleton-input,
|
||||||
|
.ant-skeleton-button,
|
||||||
|
.ant-notification-notice,
|
||||||
|
.ant-message-notice-content,
|
||||||
|
.ant-popover-inner,
|
||||||
|
.ant-tooltip-inner,
|
||||||
|
.ant-collapse,
|
||||||
|
.ant-collapse-item,
|
||||||
|
.ant-collapse-header,
|
||||||
|
.ant-collapse-content-box,
|
||||||
|
.ant-descriptions-item-container {
|
||||||
|
transition:
|
||||||
|
color 0.15s linear,
|
||||||
|
background-color 0.15s linear,
|
||||||
|
border-color 0.15s linear,
|
||||||
|
box-shadow 0.15s linear;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user