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
|
||||
*.pyi
|
||||
WORKLOG.md
|
||||
*openapi*.json
|
||||
*open*.json
|
||||
response_*.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { useAppContext } from './contexts/app-context';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { LoginPage } from './pages/login';
|
||||
import { SettingsPage } from './pages/settings';
|
||||
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 { getPlatform, isDev } from '@/utils/platform';
|
||||
import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
|
||||
import { AppContext } from '@/contexts/app-context';
|
||||
import type { AppContextValue } from '@/contexts/app-context';
|
||||
import type { LoginResponseBody } from '@/services/modules';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { setToken, clearToken, initTokenStore } from '@/services/request';
|
||||
import { emit, EVENTS } from '@/utils/event-bus';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { AuthAPI } from '@/services/modules';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
|
||||
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
|
||||
import {
|
||||
initAuthRefresh,
|
||||
getStoredRefreshToken,
|
||||
@@ -23,6 +25,39 @@ import {
|
||||
initRefreshTokenStore,
|
||||
} 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 {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -6,22 +6,22 @@
|
||||
// - drawer → antd Drawer(侧滑抽屉)
|
||||
// - 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 {Modal, Drawer} from 'antd';
|
||||
import {on, off, EVENTS} from '@/utils/event-bus';
|
||||
import {FloatingPanel} from '@/components/common/FloatingPanel';
|
||||
import {ComplianceAssetsPage} from '@/pages/compliance-assets/ComplianceAssetsPage';
|
||||
import {AccountPage} from '@/pages/account/AccountPage';
|
||||
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';
|
||||
import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Drawer, Modal } from 'antd';
|
||||
import { EVENTS, off, on } from '@/utils/event-bus';
|
||||
import { FloatingPanel } from '@/components/ui/FloatingPanel';
|
||||
import { WechatWorkPage } from '@/pages/headers/WechatWorkPage';
|
||||
import { AccountInfo } from '@/pages/headers/AccountInfo.tsx';
|
||||
|
||||
// ---------- 类型 ----------
|
||||
|
||||
@@ -33,34 +33,86 @@ interface PageEventPayload {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ActivePanel {
|
||||
/** 当前活跃面板(id 用于 key / 关闭定位) */
|
||||
interface ActivePanel extends PageEventPayload {
|
||||
id: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
interface PageConfig {
|
||||
/** 页面组件(未提供则使用自动生成的占位组件)。
|
||||
* 支持 React.lazy(() => import('...')) 懒加载。 */
|
||||
component?: ComponentType;
|
||||
/** 页面标签(显示在容器标题栏) */
|
||||
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 → 页面组件映射 ----------
|
||||
|
||||
const PAGE_MAP: Record<string, ComponentType> = {
|
||||
'/compliance-assets': ComplianceAssetsPage,
|
||||
'/account': AccountPage,
|
||||
'/vip': VipPage,
|
||||
'/recharge': RechargePage,
|
||||
'/billing': BillingPage,
|
||||
'/orders': OrdersPage,
|
||||
'/projects': ProjectsPage,
|
||||
'/quota': QuotaPage,
|
||||
'/wechat-work': WechatWorkPage,
|
||||
const PAGE_MAP: Record<string, PageConfig> = {
|
||||
'/wechat-work': { component: WechatWorkPage, label: '企业微信' },
|
||||
'/compliance-assets': { label: '合规素材库' },
|
||||
'/account': {
|
||||
component: AccountInfo,
|
||||
label: '账户',
|
||||
width: () => Math.min(window.innerWidth * 0.6, 720),
|
||||
height: () => Math.min(window.innerHeight * 0.6, 680),
|
||||
},
|
||||
'/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() {
|
||||
// 单实例(modal / drawer)—— 同一时间只有一个
|
||||
const [singleton, setSingleton] = useState<PageEventPayload | null>(null);
|
||||
|
||||
// 浮动面板列表(支持多个同时打开)
|
||||
const [floatingPanels, setFloatingPanels] = useState<ActivePanel[]>([]);
|
||||
const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
|
||||
const nextIdRef = useRef(0);
|
||||
|
||||
// 监听 OPEN_PAGE 事件
|
||||
@@ -73,82 +125,83 @@ export function PageDispatcher() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (modalType === 'floating') {
|
||||
// 浮动面板:追加到列表
|
||||
const id = `fp-${nextIdRef.current++}`;
|
||||
setFloatingPanels((prev) => [...prev, {id, target, label}]);
|
||||
} else {
|
||||
// modal / drawer:单实例,覆盖之前的
|
||||
setSingleton({target, modalType, label});
|
||||
}
|
||||
// 全局互斥:已有面板打开时,禁止打开新面板
|
||||
// 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
|
||||
setActivePanel((prev) => {
|
||||
if (prev) return prev;
|
||||
return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
|
||||
});
|
||||
};
|
||||
|
||||
on(EVENTS.OPEN_PAGE, handler);
|
||||
return () => off(EVENTS.OPEN_PAGE, handler);
|
||||
}, []);
|
||||
|
||||
// 关闭单实例
|
||||
const closeSingleton = useCallback(() => {
|
||||
setSingleton(null);
|
||||
// 关闭面板
|
||||
const closePanel = useCallback(() => {
|
||||
setActivePanel(null);
|
||||
}, []);
|
||||
|
||||
// 关闭指定浮动面板
|
||||
const closeFloating = useCallback((id: string) => {
|
||||
setFloatingPanels((prev) => prev.filter((p) => p.id !== id));
|
||||
}, []);
|
||||
// 页面组件(useMemo 稳定引用)
|
||||
const PanelPage = useMemo<ComponentType | null>(
|
||||
() => (activePanel ? resolvePage(activePanel.target) : null),
|
||||
[activePanel],
|
||||
);
|
||||
|
||||
// 单实例对应的页面组件
|
||||
const SingletonPage = singleton ? PAGE_MAP[singleton.target] : null;
|
||||
const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
|
||||
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 (
|
||||
<>
|
||||
{/* ======== Modal ======== */}
|
||||
{singleton?.modalType === 'modal' && SingletonPage && (
|
||||
{activePanel?.modalType === 'modal' && PanelPage && (
|
||||
<Modal
|
||||
open
|
||||
centered
|
||||
title={singleton.label}
|
||||
onCancel={closeSingleton}
|
||||
title={activePanel.label}
|
||||
onCancel={closePanel}
|
||||
footer={null}
|
||||
destroyOnHidden={true}
|
||||
mask={{
|
||||
closable: true,
|
||||
}}
|
||||
width={640}
|
||||
mask={{ closable: true }}
|
||||
width={panelWidth ?? defaultModalWidth}
|
||||
>
|
||||
<SingletonPage />
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<PanelPage />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* ======== Drawer ======== */}
|
||||
{singleton?.modalType === 'drawer' && SingletonPage && (
|
||||
{activePanel?.modalType === 'drawer' && PanelPage && (
|
||||
<Drawer
|
||||
open
|
||||
title={singleton.label}
|
||||
onClose={closeSingleton}
|
||||
title={activePanel.label}
|
||||
onClose={closePanel}
|
||||
destroyOnHidden={true}
|
||||
size={480}
|
||||
size={panelWidth ?? defaultDrawerWidth}
|
||||
>
|
||||
<SingletonPage />
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<PanelPage />
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
{/* ======== Floating Panels ======== */}
|
||||
{floatingPanels.map((panel) => {
|
||||
const PageContent = PAGE_MAP[panel.target];
|
||||
if (!PageContent) return null;
|
||||
|
||||
return (
|
||||
{/* ======== Floating Panel ======== */}
|
||||
{activePanel?.modalType === 'floating' && PanelPage && (
|
||||
<FloatingPanel
|
||||
key={panel.id}
|
||||
key={activePanel.id}
|
||||
open
|
||||
title={panel.label}
|
||||
onClose={() => closeFloating(panel.id)}
|
||||
title={activePanel.label}
|
||||
onClose={closePanel}
|
||||
width={panelWidth ?? defaultDrawerWidth}
|
||||
height={panelHeight}
|
||||
>
|
||||
<PageContent />
|
||||
{/* eslint-disable-next-line react-hooks/static-components */}
|
||||
<PanelPage />
|
||||
</FloatingPanel>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,99 @@
|
||||
// ============================================================
|
||||
// SettingsProvider — 设置状态 Provider 组件
|
||||
// SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
|
||||
//
|
||||
// 职责:
|
||||
// - 初始化时从 localStorage 读取已存储的设置项
|
||||
// - 提供 SettingsContext 给所有子组件
|
||||
// - 写操作同步更新 state + localStorage
|
||||
// - 存储路径的集中管理(read/write/clear)
|
||||
// - 持久化到 localStorage,遵循 ele-heixiu-* 键名规范
|
||||
// - 全局组件通过 useSettings() 消费
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import {
|
||||
SettingsContext,
|
||||
readStoredOutputPath,
|
||||
writeStoredOutputPath,
|
||||
clearStoredOutputPath,
|
||||
readStoredTeamRepoPath,
|
||||
writeStoredTeamRepoPath,
|
||||
clearStoredTeamRepoPath,
|
||||
} from '@/contexts/settings-context';
|
||||
import type { SettingsContextValue } from '@/contexts/settings-context';
|
||||
import { createContext, useContext, useState, useCallback, type ReactNode } 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;
|
||||
}
|
||||
|
||||
// ---------- Provider 组件 ----------
|
||||
|
||||
interface SettingsProviderProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ============================================================
|
||||
// ThemeProvider — 主题 Provider 组件
|
||||
// ThemeProvider — 主题 Context + Provider + Hook + 工具函数
|
||||
//
|
||||
// 桌面端(Electron)使用 View Transitions API 驱动主题切换:
|
||||
// GPU 合成器截取旧/新两帧做一次 cross-fade,替代 80+ 个
|
||||
@@ -7,20 +7,70 @@
|
||||
// 浏览器不支持时回退到 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 { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
import { getThemeConfig } from '../theme/antd-theme';
|
||||
import {
|
||||
ThemeContext,
|
||||
readStoredTheme,
|
||||
writeStoredTheme,
|
||||
applyHtmlTheme,
|
||||
STORAGE_KEY,
|
||||
} from '../hooks/use-theme';
|
||||
import type { ThemeContextValue } from '../hooks/use-theme';
|
||||
|
||||
// ---------- Context 类型 ----------
|
||||
|
||||
export interface ThemeContextValue {
|
||||
isDark: boolean;
|
||||
toggleTheme: () => void;
|
||||
setLight: () => void;
|
||||
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 {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -7,7 +7,7 @@ import {useEffect, useState, useCallback} from 'react';
|
||||
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
|
||||
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;
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
import {useState, useCallback, useMemo} from 'react';
|
||||
import {App} from 'antd';
|
||||
|
||||
import {useAppContext} from '@/contexts/app-context';
|
||||
import {useTheme} from '@/hooks/use-theme';
|
||||
import {useAppContext} from '@/components/AppProvider';
|
||||
import {useTheme} from '@/components/ThemeProvider';
|
||||
import {emit, EVENTS} from '@/utils/event-bus.ts';
|
||||
import {NavItem} from './NavItem';
|
||||
import {AnnouncementDrawer} from './AnnouncementDrawer';
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// 布局:图标(SVG)在上,文字在下
|
||||
// ============================================================
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import type { NavItemConfig } from './types';
|
||||
|
||||
interface NavItemProps {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// ============================================================
|
||||
|
||||
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 { FileTextOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
|
||||
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联
|
||||
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 {useAppContext} from '@/contexts/app-context';
|
||||
import {
|
||||
TaskAPI,
|
||||
ModelAPI,
|
||||
fetchBanners,
|
||||
MODEL_CATEGORY_LABELS,
|
||||
type TaskListRequestParams,
|
||||
type TaskInfoItemResponseBody,
|
||||
type CreatTaskRequestBody,
|
||||
type ModelsListResponseBody,
|
||||
type Banner,
|
||||
type ModelCategoryStringEnum,
|
||||
} from '@/services/modules';
|
||||
import {useAppContext} from '@/components/AppProvider';
|
||||
import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
|
||||
import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
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 { Spin, Empty } from 'antd';
|
||||
import { WechatOutlined } from '@ant-design/icons';
|
||||
import { fetchBanners, type Banner } from '@/services/modules';
|
||||
import { fetchBanners, type Banner } from '@/services/modules/banner';
|
||||
|
||||
export function WechatWorkPage() {
|
||||
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 { HomeContext } from '@/contexts/home-context';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import type { ModelsListResponseBody } from '@/services/modules/models';
|
||||
import type { TaskInfoItemResponseBody } from '@/services/modules/task';
|
||||
import { LeftPanel } from './components/LeftPanel';
|
||||
import { CenterPanel } from './components/CenterPanel';
|
||||
import { RightPanel } from './components/RightPanel';
|
||||
@@ -17,6 +17,38 @@ import { Typography } from 'antd';
|
||||
|
||||
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 { useBannerList } from '@/hooks/use-api';
|
||||
import type { Banner } from '@/services/modules';
|
||||
import type { Banner } from '@/services/modules/banner';
|
||||
|
||||
// ---------- 组件 Props ----------
|
||||
|
||||
|
||||
@@ -1,162 +1,163 @@
|
||||
// ============================================================
|
||||
// ModelInputForm — 动态模型输入表单
|
||||
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件
|
||||
// ModelInputForm — 模型输入表单(纯布局外壳)
|
||||
//
|
||||
// 布局: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 {
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Slider,
|
||||
Button,
|
||||
Typography,
|
||||
Empty,
|
||||
Upload,
|
||||
Switch,
|
||||
Tag,
|
||||
Space,
|
||||
theme as antTheme,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
SendOutlined,
|
||||
UploadOutlined,
|
||||
InboxOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { FormInstance } from 'antd';
|
||||
import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload';
|
||||
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { useSubmitTask } from '@/hooks/use-api';
|
||||
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 { 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 构建参数描述符列表
|
||||
*
|
||||
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组),
|
||||
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
|
||||
* Form.Item 的 getValueFromEvent,兼容两种 onChange 参数形态:
|
||||
* 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
|
||||
* 2. enable_switch 的裸值:undefined / [] / [{...}] → 直接透传
|
||||
*/
|
||||
function buildParamDescriptors(
|
||||
paramSchema: ModelsListResponseBody['param_schema'],
|
||||
): ParamDescriptor[] {
|
||||
return paramSchema.map((item) => {
|
||||
const config = (item.ui || {}) as Record<string, unknown>;
|
||||
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,
|
||||
};
|
||||
});
|
||||
function handleFileWidgetValue(e: unknown): unknown {
|
||||
if (e && typeof e === 'object' && 'fileList' in e) {
|
||||
return (e as { fileList: unknown }).fileList;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
|
||||
// ---------- 组件 ----------
|
||||
// ---------- 依赖字段包装组件 ----------
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
const { token } = antTheme.useToken();
|
||||
const { isLoggedIn } = useAppContext();
|
||||
const { selectedModel } = useHomeContext();
|
||||
const [form] = Form.useForm();
|
||||
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]);
|
||||
const [taskTag, setTaskTag] = useState('');
|
||||
|
||||
// 提交突变 Hook(自动处理 loading / error / 日志)
|
||||
// 提交突变 Hook
|
||||
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
|
||||
onSuccess: useCallback(
|
||||
(_data: TaskInfoItemResponseBody) => {
|
||||
onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
|
||||
message.success('任务已提交');
|
||||
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal)
|
||||
emit(EVENTS.TASK_SUBMITTED);
|
||||
},
|
||||
[],
|
||||
),
|
||||
}, []),
|
||||
});
|
||||
|
||||
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告)
|
||||
useEffect(() => {
|
||||
if (selectedModel) {
|
||||
form.resetFields();
|
||||
// ======== useModelUI:param_schema → UIFieldConfig[] ========
|
||||
|
||||
const fields: UIFieldConfig[] = useModelUI(
|
||||
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;
|
||||
}
|
||||
setReferenceFiles([]);
|
||||
}, [selectedModel?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
return iv;
|
||||
}, [fields]);
|
||||
|
||||
// 参数描述符
|
||||
const paramDescriptors = useMemo(() => {
|
||||
if (!selectedModel) return [];
|
||||
return buildParamDescriptors(selectedModel.param_schema || []);
|
||||
}, [selectedModel]);
|
||||
|
||||
// 提交
|
||||
// 选中模型时加载默认值(useLayoutEffect 在 DOM 更新后、浏览器绘制前执行,避免闪烁)
|
||||
// antd 有外部 form 实例时 initialValues prop 被忽略,必须手动 setFieldsValue
|
||||
useLayoutEffect(() => {
|
||||
if (selectedModel && Object.keys(initialValues).length > 0) {
|
||||
form.setFieldsValue(initialValues);
|
||||
setTaskTag('');
|
||||
}
|
||||
}, [selectedModel?.id, initialValues]);
|
||||
|
||||
// 重置参数(回到默认值 + 清空标签)
|
||||
const handleReset = useCallback(() => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue(initialValues);
|
||||
setTaskTag('');
|
||||
}, [form, initialValues]);
|
||||
|
||||
// ======== 提交 ========
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!isLoggedIn) {
|
||||
message.warning('请先登录后再提交任务');
|
||||
@@ -167,128 +168,47 @@ export function ModelInputForm() {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
|
||||
// 分离固定字段和模型动态参数
|
||||
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// 构建提交参数(全部转为字符串值)
|
||||
const params: Record<string, string> = {
|
||||
prompt: String(prompt ?? ''),
|
||||
negative_prompt: String(negative_prompt ?? ''),
|
||||
num_outputs: String(num_outputs ?? 1),
|
||||
};
|
||||
for (const field of fields) {
|
||||
const value = values[field.name];
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
// 动态参数统一转字符串
|
||||
for (const [key, value] of Object.entries(dynamicParams)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
params[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 参考图
|
||||
if (referenceFiles.length > 0) {
|
||||
const refUrls = referenceFiles
|
||||
if (field.isFileWidget) {
|
||||
const fileList = value as UploadFile[];
|
||||
const urls = fileList
|
||||
.filter((f) => f.status === 'done')
|
||||
.map((f) => f.response?.url || f.url || f.name)
|
||||
.filter(Boolean) as string[];
|
||||
if (refUrls.length > 0) {
|
||||
params.reference_images = JSON.stringify(refUrls);
|
||||
if (urls.length > 0) {
|
||||
params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 使用 mutation Hook 提交(自动处理 loading / error / 日志)
|
||||
await submitTask({ model_id: selectedModel.id, params });
|
||||
if (typeof value === 'boolean') {
|
||||
params[field.name] = String(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
params[field.name] = String(value);
|
||||
}
|
||||
|
||||
await submitTask({
|
||||
model_id: selectedModel.id,
|
||||
params,
|
||||
...(taskTag.trim() ? { tags: [taskTag.trim()] } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'errorFields' in err) {
|
||||
// 表单验证错误,antd 会自动提示
|
||||
return;
|
||||
}
|
||||
// 错误消息由 Hook 自动通过 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) {
|
||||
return (
|
||||
<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 (
|
||||
<div
|
||||
style={{
|
||||
@@ -314,7 +251,7 @@ export function ModelInputForm() {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* 模型标题 */}
|
||||
{/* ======== Header:模型信息 ======== */}
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 20px',
|
||||
@@ -322,145 +259,146 @@ export function ModelInputForm() {
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||
{badge && <span style={{ fontSize: 20 }}>{badge}</span>}
|
||||
<Title level={4} style={{ margin: 0, fontSize: 18 }}>
|
||||
{selectedModel.name}
|
||||
</Title>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedModel.provider_name} · {selectedModel.category_name}
|
||||
{selectedModel.provider_name}
|
||||
{group
|
||||
? ` · ${group}`
|
||||
: selectedModel.category_name
|
||||
? ` · ${selectedModel.category_name}`
|
||||
: ''}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* 可滚动表单区 */}
|
||||
<Space size={4} style={{ marginTop: 8 }} wrap>
|
||||
{outputType && (
|
||||
<Tag color="blue" style={{ fontSize: 11, lineHeight: '18px' }}>
|
||||
{OutputTypeMap[outputType] || outputType}
|
||||
</Tag>
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* ======== 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"
|
||||
size="small"
|
||||
initialValues={{ num_outputs: 1 }}
|
||||
>
|
||||
{/* ======== 固定字段 ======== */}
|
||||
|
||||
{/* 提示词 */}
|
||||
{fields.map((field) => (
|
||||
<Form.Item
|
||||
name="prompt"
|
||||
label={<Text strong>提示词</Text>}
|
||||
rules={[{ required: true, message: '请输入提示词' }]}
|
||||
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 }}
|
||||
>
|
||||
<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 }}>
|
||||
模型参数
|
||||
{field.tips.length > 60
|
||||
? `${field.tips.slice(0, 60)}...`
|
||||
: field.tips}
|
||||
</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'}
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
rules={
|
||||
field.must
|
||||
? [{ required: true, message: `请输入${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
valuePropName={field.valuePropName}
|
||||
{...(field.isFileWidget
|
||||
? { getValueFromEvent: handleFileWidgetValue }
|
||||
: {})}
|
||||
>
|
||||
{renderParamControl(desc)}
|
||||
<DependentFormItem field={field} form={form} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
{/* ======== Footer:标签 + 重置 + 提交 ======== */}
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
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
|
||||
type="primary"
|
||||
icon={<SendOutlined />}
|
||||
onClick={handleSubmit}
|
||||
loading={submitting}
|
||||
disabled={!isLoggedIn || !selectedModel}
|
||||
block
|
||||
size="large"
|
||||
style={{
|
||||
background: token.colorPrimary,
|
||||
height: 44,
|
||||
fontSize: 16,
|
||||
height: 40,
|
||||
fontSize: 14,
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
开始生成
|
||||
{priceType === 'SIMPLE' && price !== undefined
|
||||
? `消费 ¥${price}/${priceUnit || '次'}`
|
||||
: '消费'}
|
||||
</Button>
|
||||
</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 { useModelList } from '@/hooks/use-api';
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules';
|
||||
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { useTaskDetail } from '@/hooks/use-api';
|
||||
import type { TaskStatusString } from '@/services/modules';
|
||||
import type { TaskStatusString } from '@/services/modules/task';
|
||||
|
||||
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 { useTaskList, useModelList } from '@/hooks/use-api';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useHomeContext } from '@/contexts/home-context';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { useHomeContext } from '@/pages/home/HomeContent';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { on, off, EVENTS } from '@/utils/event-bus';
|
||||
import type {
|
||||
TaskInfoItemResponseBody,
|
||||
TaskListRequestParams,
|
||||
TaskStatusString,
|
||||
} from '@/services/modules';
|
||||
import { TaskStatusMap, OutputTypeMap } from '@/services/modules';
|
||||
} from '@/services/modules/task';
|
||||
import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
|
||||
|
||||
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 { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { AuthAPI } from '@/services/modules';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import { validatePassword, validateConfirmPassword } from '../config/validators';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -18,8 +18,8 @@ import {
|
||||
KeyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { AuthAPI } from '@/services/modules';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { AuthAPI } from '@/services/modules/auth';
|
||||
import {
|
||||
validatePhone,
|
||||
validateSmsCode,
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
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 { validateForm } from '../config/validators';
|
||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
||||
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||
import type { LoginFormValues } from '../types';
|
||||
|
||||
const { Text } = Typography;
|
||||
@@ -40,7 +40,7 @@ export function LoginForm({
|
||||
const [password, setPassword] = useState(initialPassword || '');
|
||||
const [remember, setRemember] = useState(initialRemember);
|
||||
const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
|
||||
// 协议弹窗
|
||||
@@ -103,6 +103,8 @@ export function LoginForm({
|
||||
setPassword(e.target.value);
|
||||
clearError('password');
|
||||
}}
|
||||
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
|
||||
onPressEnter={handleSubmit}
|
||||
status={errors.password ? 'error' : undefined}
|
||||
allowClear
|
||||
/>
|
||||
@@ -162,6 +164,11 @@ export function LoginForm({
|
||||
setAgreed(e.target.checked);
|
||||
clearError('_agreement');
|
||||
}}
|
||||
onKeyDown={(event)=>{
|
||||
if (event.key === 'Enter' || event.keyCode === 13) {
|
||||
setAgreed(!agreed);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
已阅读并同意
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
// RegisterForm — 注册表单(仅个人版)
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { Input, Button, Checkbox, Typography } from 'antd';
|
||||
|
||||
import { LegalTextModal } from '@/components/common/LegalTextModal';
|
||||
import { LegalTextModal } from '@/components/ui/LegalTextModal';
|
||||
import {
|
||||
UserOutlined,
|
||||
LockOutlined,
|
||||
@@ -87,7 +87,9 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
||||
onSendSms?.(phone);
|
||||
}, [values.phone, onSendSms]);
|
||||
|
||||
// 跨字段校验
|
||||
// 提交
|
||||
const handleSubmit = useCallback(() => {
|
||||
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
|
||||
const crossValidators = {
|
||||
confirmPassword: (val: string) => {
|
||||
if (!val) return '请再次输入密码';
|
||||
@@ -95,9 +97,6 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
// 提交
|
||||
const handleSubmit = useCallback(() => {
|
||||
const fieldErrors = validateForm(values, registerFields, crossValidators);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
setErrors(fieldErrors);
|
||||
@@ -208,6 +207,11 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
|
||||
return n;
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e)=>{
|
||||
if(e.key==="Enter" || e.keyCode == 13) {
|
||||
setAgreed(!agreed);
|
||||
}
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
已阅读并同意
|
||||
|
||||
@@ -1,76 +1,34 @@
|
||||
// ============================================================
|
||||
// useAuthState — 记住账号 / 自动登录 状态管理
|
||||
// useAuthState — 记住账号 / 自动登录 表单状态 Hook
|
||||
//
|
||||
// "记住密码":下次打开页面自动回填用户名(不再存储密码)
|
||||
// "自动登录":登录成功时标记,AppProvider 启动时用 refresh_token 静默续期
|
||||
//
|
||||
// 持久化逻辑已提取到 services/auth-persistence.ts,
|
||||
// 此处仅负责 React 组件状态(useState + useCallback)。
|
||||
//
|
||||
// 安全:
|
||||
// - 密码从不持久化存储(access_token / refresh_token 由 safeStorage 加密保护)
|
||||
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
|
||||
// ============================================================
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
saveAuth,
|
||||
clearSavedAuth,
|
||||
getSavedUsername,
|
||||
getSavedRemember,
|
||||
getSavedAutoLogin,
|
||||
} from '@/services/auth-persistence';
|
||||
|
||||
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 {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 工具函数(不依赖 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 {
|
||||
/* 静默 */
|
||||
}
|
||||
}
|
||||
export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
|
||||
|
||||
// ---------- Hook ----------
|
||||
|
||||
export function useAuthState() {
|
||||
const saved = readAuth();
|
||||
|
||||
const [username, setUsername] = useState(saved?.username || '');
|
||||
const [remember, setRemember] = useState(saved?.remember || false);
|
||||
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
|
||||
const [username, setUsername] = useState(getSavedUsername);
|
||||
const [remember, setRemember] = useState(getSavedRemember);
|
||||
const [autoLogin, setAutoLogin] = useState(getSavedAutoLogin);
|
||||
|
||||
const handleRememberChange = useCallback((checked: boolean) => {
|
||||
setRemember(checked);
|
||||
@@ -93,16 +51,11 @@ export function useAuthState() {
|
||||
* @param doRemember - 是否勾选"记住用户名"
|
||||
* @param doAutoLogin - 是否勾选"自动登录"
|
||||
*/
|
||||
const saveAuth = useCallback(
|
||||
const handleSaveAuth = useCallback(
|
||||
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
|
||||
setUsername(user);
|
||||
if (doRemember || doAutoLogin) {
|
||||
const payload: StoredAuth = {
|
||||
username: user,
|
||||
remember: doRemember,
|
||||
autoLogin: doAutoLogin,
|
||||
};
|
||||
writeAuth(payload);
|
||||
saveAuth(user, doRemember, doAutoLogin);
|
||||
}
|
||||
},
|
||||
[],
|
||||
@@ -115,6 +68,6 @@ export function useAuthState() {
|
||||
setUsername,
|
||||
handleRememberChange,
|
||||
handleAutoLoginChange,
|
||||
saveAuth,
|
||||
saveAuth: handleSaveAuth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Modal, Tabs, Typography, App } from 'antd';
|
||||
|
||||
import { useAppContext } from '@/contexts/app-context.ts';
|
||||
import { useTheme } from '@/hooks/use-theme.ts';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { LoginForm } from './components/LoginForm';
|
||||
import { RegisterForm } from './components/RegisterForm';
|
||||
import { ForgotPasswordModal } from './components/ForgotPasswordModal';
|
||||
import { useAuthState } from './hooks/use-auth-state';
|
||||
import { AuthAPI, LoginResponseBody } from '@/services/modules';
|
||||
import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
|
||||
import { getDeviceId } from '@/utils/device';
|
||||
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 { Modal } from 'antd';
|
||||
import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { isMacOS } from '@/utils/platform';
|
||||
import { ThemeSetting } from './blocks/ThemeSetting';
|
||||
import { StoragePathSetting } from './blocks/StoragePathSetting';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
|
||||
import { Card, Button, Input, Typography, Space, App } from 'antd';
|
||||
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import type { Edition } from '@shared/types';
|
||||
import { useSettings } from '@/contexts/settings-context';
|
||||
import { useSettings } from '@/components/SettingsProvider';
|
||||
import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
|
||||
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
InfoCircleOutlined,
|
||||
EnvironmentOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAppContext } from '@/contexts/app-context';
|
||||
import { useAppContext } from '@/components/AppProvider';
|
||||
import { getPlatformName } from '@/utils/platform';
|
||||
import { APP_VERSION } from '@shared/constants/version';
|
||||
import { getEditionName } from '@shared/constants/app';
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import { Card, Segmented, Typography } from 'antd';
|
||||
import { BulbOutlined, BulbFilled } from '@ant-design/icons';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
|
||||
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 { AuthAPI } from './modules';
|
||||
import type { RefreshTokenResponseBody } from './modules';
|
||||
import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
|
||||
import {
|
||||
getSafeStore,
|
||||
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 v4 使用 CSS @theme 指令替代 tailwind.config.ts,
|
||||
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。
|
||||
* 全局样式入口 — Tailwind CSS v4 + 设计令牌 + 过渡 + 基础样式
|
||||
*
|
||||
* 拆分为 4 个文件,按职责定位:
|
||||
* tokens.css → 改颜色 / 品牌色 / 亮暗变量
|
||||
* transitions.css → 改主题切换过渡参数
|
||||
* base.css → 改重置 / 滚动条 / 链接 / 拖拽区域
|
||||
* globals.css → 本文件(入口,只负责引入顺序)
|
||||
* ============================================================ */
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* ============================================================
|
||||
* 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: #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;
|
||||
}
|
||||
}
|
||||
@import './tokens.css';
|
||||
@import './transitions.css';
|
||||
@import './base.css';
|
||||
|
||||
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