Compare commits

..

8 Commits

Author SHA1 Message Date
1c27283b56 docs:重命名文件、变量名称 2026-06-09 18:54:01 +08:00
c18d9c8404 feat: 统一页面调度体系 — 全局互斥 + 动态尺寸 + 账户信息页
PageDispatcher 重构:
- singleton + floatingPanels[] 双状态 → 单一 activePanel 状态
- 全局互斥:已打开面板时禁止打开新面板(保留拖拽/图片拖放等互操作)
- 占位组件预计算为模块级 Map,消除 ESLint static-components 误报
- PAGE_MAP 支持 width/height 配置,动态函数计算(视口比例自适应)
- 容器默认尺寸改为动态:Modal=min(w*0.55,720) Drawer=min(w*0.38,520)
- 修复 FloatingPanel 未接收 width/height 配置的问题

Test2 账户信息页:
- AuthAPI.getUserInfo() → 三卡片布局(积分/套餐/今日统计)
- 加载态 Skeleton / 错误态 Result+重试
- centToYuan 积分转换、formatDate 日期格式化

目录调整:wechat-work/ → headers/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 18:43:50 +08:00
0c7b33aac1 feat: constraints_config.requires 联动控制 — enable_switch 开关同步启用/禁用依赖字段
- DependentFormItem: 透传 disabled 为顶层 prop(之前藏在 config.disabled 中,widget 组件读取不到)
- ImageInput: 值语义分层 — undefined(开关关) / [](开关开但空) / [{...}](已上传)
- isValueEmpty: 空数组[] 不再视为空,配合 enable_switch 语义
- handleFileWidgetValue: 兼容 Upload DOM事件 和 enable_switch 裸值两种 onChange 形态

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:36:53 +08:00
4261915679 fix: 修复模型默认值不加载 + DependentFormItem prop 透传断裂
- DependentFormItem 透传 Form.Item 注入的 value/onChange/disabled prop
- 稳定化 constraintsConfig 空对象引用(EMPTY_OBJ),避免 useMemo 失效导致无限重渲染
- useLayoutEffect 手动 setFieldsValue(initialValues)替代 antd 被忽略的 initialValues prop
- handleReset 使用 resetFields + setFieldsValue 组合确保默认值归位

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 17:00:44 +08:00
d5ba7e63cd refactor: 中间栏三层架构 — useUI 组件体系 + useModelUI Hook
- 新建 useUI/ 目录:12 个文件,10 个 Qt 控件 → antd 组件封装 + 类型 + 注册表
- 新建 useModelUI Hook:param_schema → UIFieldConfig[] 自动映射
- ModelInputForm 瘦身 ~160 行:删除 buildParamDescriptors/renderParamControl/文件状态管理
- WIDGET_MAP: Record<string, ComponentType<WidgetProps>> — 零 any,完全类型安全
- 新增控件只需两步:创建组件文件 + 在 WIDGET_MAP 加一行
- 删除旧 widgets/ 目录

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 15:32:17 +08:00
76e66a37c8 refactor: 消除占位页面 + 删除 barrel 导出 — 页面集中注册 + 直接导入
- 删除 8 个占位页面文件(Account/Vip/Recharge/Billing/Orders/Projects/Quota/ComplianceAssets)
- PageDispatcher: PAGE_MAP 支持无组件页面自动渲染 PlaceholderPage
- 新增页面只需在 PAGE_MAP 加一行配置,无需创建独立文件
- 删除 services/modules/index.ts barrel,13 个文件改为直接导入
- IDE 跳转到定义一步到位,import 自文档化
- TypeScript 编译零错误

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:32:14 +08:00
7982b69219 refactor: CSS 拆分 + auth 持久化提取 + 目录整理
- globals.css 拆分为 4 文件:tokens.css(颜色)/ transitions.css(过渡)/ base.css(重置)/ globals.css(入口)
- 提取 auth-persistence.ts 到 services/,解除 AppProvider→pages/login/hooks 反向依赖
- use-auth-state.ts 精简为纯 React Hook,持久化逻辑委托给 services/auth-persistence.ts
- components/common/ → components/ui/(目录名语义更清晰)
- TypeScript + Vite 构建通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:20:07 +08:00
53df926973 refactor: Context 与 Provider 合并 — 消除 contexts/ 目录,一文件一状态单元
- ThemeContext + useTheme → ThemeProvider.tsx(原 hooks/use-theme.ts 删除)
- AppContext + useAppContext → AppProvider.tsx(原 contexts/app-context.ts 删除)
- SettingsContext + useSettings → SettingsProvider.tsx(原 contexts/settings-context.ts 删除)
- HomeContext + useHomeContext → HomeContent.tsx(原 contexts/home-context.ts 删除)
- 删除空的 contexts/ 目录,20+ 导入路径同步更新
- TypeScript 编译通过(npx tsc --noEmit 零错误)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:02:48 +08:00
64 changed files with 2086 additions and 1345 deletions

2
.gitignore vendored
View File

@@ -36,5 +36,5 @@ dist-ssr
*.py *.py
*.pyi *.pyi
WORKLOG.md WORKLOG.md
*openapi*.json *open*.json
response_*.json response_*.json

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useAppContext } from './contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { LoginPage } from './pages/login'; import { LoginPage } from './pages/login';
import { SettingsPage } from './pages/settings'; import { SettingsPage } from './pages/settings';
import { on, off, EVENTS } from './utils/event-bus'; import { on, off, EVENTS } from './utils/event-bus';

View File

@@ -1,20 +1,22 @@
// ============================================================ // ============================================================
// AppProvider — 应用状态 Provider 组件 // AppProvider — 全局应用状态 Context + Provider + Hook
//
// 导出:
// AppContext, useAppContext() — Context + Consumer Hook
// AppContextValue — 类型
// AppProvider — Provider 组件
// ============================================================ // ============================================================
import { useState, useMemo, useCallback, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useMemo, useCallback, useEffect, type ReactNode } from 'react';
import type { Platform, Edition } from '@shared/types'; import type { Platform, Edition } from '@shared/types';
import { getPlatform, isDev } from '@/utils/platform'; import { getPlatform, isDev } from '@/utils/platform';
import { safeIpcOn, safeIpcOff } from '@/utils/ipc'; import { safeIpcOn, safeIpcOff } from '@/utils/ipc';
import { AppContext } from '@/contexts/app-context'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import type { AppContextValue } from '@/contexts/app-context';
import type { LoginResponseBody } from '@/services/modules';
import { setToken, clearToken, initTokenStore } from '@/services/request'; import { setToken, clearToken, initTokenStore } from '@/services/request';
import { emit, EVENTS } from '@/utils/event-bus'; import { emit, EVENTS } from '@/utils/event-bus';
import { logger } from '@/utils/logger'; import { logger } from '@/utils/logger';
import { AuthAPI } from '@/services/modules'; import { getAutoLoginFlag, clearAutoLoginFlag } from '@/services/auth-persistence';
import { getAutoLoginFlag, clearAutoLoginFlag } from '@/pages/login/hooks/use-auth-state';
import { import {
initAuthRefresh, initAuthRefresh,
getStoredRefreshToken, getStoredRefreshToken,
@@ -23,6 +25,39 @@ import {
initRefreshTokenStore, initRefreshTokenStore,
} from '@/services/auth-token'; } from '@/services/auth-token';
// ---------- Context 类型 ----------
export interface AppContextValue {
/** 当前操作系统平台 */
platform: Platform;
/** 产品版本(个人版 / 企业版) */
edition: Edition;
/** 用户是否已登录 */
isLoggedIn: boolean;
/** 当前登录用户信息(未登录时为 null结构与 LoginResponseBody 一致) */
user: LoginResponseBody | null;
/** 登录:保存 Token 并更新登录态auth 为登录接口返回值,异步加密存储) */
login: (auth: LoginResponseBody) => Promise<void>;
/** 退出登录:清除 Token 和用户状态 */
logout: () => void;
/** 是否为开发环境 */
isDevMode: boolean;
}
export const AppContext = createContext<AppContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useAppContext(): AppContextValue {
const ctx = useContext(AppContext);
if (!ctx) {
throw new Error('useAppContext() 必须在 <AppProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface AppProviderProps { interface AppProviderProps {
children: ReactNode; children: ReactNode;
} }

View File

@@ -6,22 +6,22 @@
// - drawer → antd Drawer侧滑抽屉 // - drawer → antd Drawer侧滑抽屉
// - floating → FloatingPanel非模态浮动面板可拖拽不阻断交互 // - floating → FloatingPanel非模态浮动面板可拖拽不阻断交互
// //
// 支持多面板共存floating 类型可同时打开多个) // 全局互斥:同一时刻最多打开一个页面组件。
// 已打开时不响应 OPEN_PAGE 事件(保留面板内互操作能力:拖拽、图片拖放等)。
//
// PAGE_MAP 集中注册所有可调度页面:
// 有真实组件 → { component: XxxPage, label: '...', width?: number }
// 占位页面 → 只写 label自动渲染 "页面开发中"
// 懒加载页面 → component: React.lazy(() => import('...'))
// 新增页面只需在这里加一行,无需创建独立文件
// ============================================================ // ============================================================
import {useState, useEffect, useCallback, useRef, type ComponentType} from 'react'; import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {Modal, Drawer} from 'antd'; import { Drawer, Modal } from 'antd';
import {on, off, EVENTS} from '@/utils/event-bus'; import { EVENTS, off, on } from '@/utils/event-bus';
import {FloatingPanel} from '@/components/common/FloatingPanel'; import { FloatingPanel } from '@/components/ui/FloatingPanel';
import {ComplianceAssetsPage} from '@/pages/compliance-assets/ComplianceAssetsPage'; import { WechatWorkPage } from '@/pages/headers/WechatWorkPage';
import {AccountPage} from '@/pages/account/AccountPage'; import { AccountInfo } from '@/pages/headers/AccountInfo.tsx';
import {VipPage} from '@/pages/vip/VipPage';
import {RechargePage} from '@/pages/recharge/RechargePage';
import {BillingPage} from '@/pages/billing/BillingPage';
import {OrdersPage} from '@/pages/orders/OrdersPage';
import {ProjectsPage} from '@/pages/projects/ProjectsPage';
import {QuotaPage} from '@/pages/quota/QuotaPage';
import {WechatWorkPage} from '@/pages/wechat-work/WechatWorkPage';
// ---------- 类型 ---------- // ---------- 类型 ----------
@@ -33,122 +33,175 @@ interface PageEventPayload {
label: string; label: string;
} }
interface ActivePanel { /** 当前活跃面板id 用于 key / 关闭定位) */
interface ActivePanel extends PageEventPayload {
id: string; id: string;
target: string; }
interface PageConfig {
/** 页面组件(未提供则使用自动生成的占位组件)。
* 支持 React.lazy(() => import('...')) 懒加载。 */
component?: ComponentType;
/** 页面标签(显示在容器标题栏) */
label: string; label: string;
/** 容器宽度px。也支持函数动态计算() => window.innerWidth * 0.6 */
width?: number | (() => number);
/** 容器高度px仅 FloatingPanel 生效。也支持函数动态计算 */
height?: number | (() => number);
}
// ---------- 占位组件(模块级,引用稳定,避免渲染中创建组件) ----------
function PlaceholderPage({ label }: { label: string }) {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>{label} </p>
</div>
);
}
/** 为每个占位页预创建稳定组件引用 */
function createPlaceholder(label: string): ComponentType {
const C = () => <PlaceholderPage label={label} />;
C.displayName = `Placeholder_${label}`;
return C;
} }
// ---------- target → 页面组件映射 ---------- // ---------- target → 页面组件映射 ----------
const PAGE_MAP: Record<string, ComponentType> = { const PAGE_MAP: Record<string, PageConfig> = {
'/compliance-assets': ComplianceAssetsPage, '/wechat-work': { component: WechatWorkPage, label: '企业微信' },
'/account': AccountPage, '/compliance-assets': { label: '合规素材库' },
'/vip': VipPage, '/account': {
'/recharge': RechargePage, component: AccountInfo,
'/billing': BillingPage, label: '账户',
'/orders': OrdersPage, width: () => Math.min(window.innerWidth * 0.6, 720),
'/projects': ProjectsPage, height: () => Math.min(window.innerHeight * 0.6, 680),
'/quota': QuotaPage, },
'/wechat-work': WechatWorkPage, '/vip': { label: '开会员/激活' },
'/recharge': { label: '充值积分' },
'/billing': { label: '消费记录' },
'/orders': { label: '订单明细' },
'/projects': { label: '项目管理' },
'/quota': { label: '查配额' },
}; };
// 预计算所有占位组件(模块加载时执行一次,引用永久稳定)
const RESOLVED_PAGE_MAP = new Map<string, ComponentType>();
for (const [key, config] of Object.entries(PAGE_MAP)) {
RESOLVED_PAGE_MAP.set(key, config.component || createPlaceholder(config.label));
}
/** 解析页面组件(纯查表,不创建新组件) */
function resolvePage(target: string): ComponentType | null {
return RESOLVED_PAGE_MAP.get(target) || null;
}
/** 解析容器宽度(支持静态数值或动态函数) */
function resolveWidth(target: string): number | undefined {
const w = PAGE_MAP[target]?.width;
return typeof w === 'function' ? w() : w;
}
/** 解析容器高度(支持静态数值或动态函数) */
function resolveHeight(target: string): number | undefined {
const h = PAGE_MAP[target]?.height;
return typeof h === 'function' ? h() : h;
}
// ---------- 组件 ---------- // ---------- 组件 ----------
export function PageDispatcher() { export function PageDispatcher() {
// 单实例modal / drawer—— 同一时间只有一个 const [activePanel, setActivePanel] = useState<ActivePanel | null>(null);
const [singleton, setSingleton] = useState<PageEventPayload | null>(null);
// 浮动面板列表(支持多个同时打开)
const [floatingPanels, setFloatingPanels] = useState<ActivePanel[]>([]);
const nextIdRef = useRef(0); const nextIdRef = useRef(0);
// 监听 OPEN_PAGE 事件 // 监听 OPEN_PAGE 事件
useEffect(() => { useEffect(() => {
const handler = (payload: unknown) => { const handler = (payload: unknown) => {
const {target, modalType, label} = payload as PageEventPayload; const { target, modalType, label } = payload as PageEventPayload;
if (!target || !PAGE_MAP[target]) { if (!target || !PAGE_MAP[target]) {
console.warn(`[PageDispatcher] 未找到页面组件: ${target}`); console.warn(`[PageDispatcher] 未找到页面组件: ${target}`);
return; return;
} }
if (modalType === 'floating') { // 全局互斥:已有面板打开时,禁止打开新面板
// 浮动面板:追加到列表 // 不关闭已有面板,保留其内部互操作能力(拖拽、图片拖放等)
const id = `fp-${nextIdRef.current++}`; setActivePanel((prev) => {
setFloatingPanels((prev) => [...prev, {id, target, label}]); if (prev) return prev;
} else { return { id: `pnl-${nextIdRef.current++}`, target, modalType, label };
// modal / drawer单实例覆盖之前的 });
setSingleton({target, modalType, label});
}
}; };
on(EVENTS.OPEN_PAGE, handler); on(EVENTS.OPEN_PAGE, handler);
return () => off(EVENTS.OPEN_PAGE, handler); return () => off(EVENTS.OPEN_PAGE, handler);
}, []); }, []);
// 关闭单实例 // 关闭面板
const closeSingleton = useCallback(() => { const closePanel = useCallback(() => {
setSingleton(null); setActivePanel(null);
}, []); }, []);
// 关闭指定浮动面板 // 页面组件useMemo 稳定引用)
const closeFloating = useCallback((id: string) => { const PanelPage = useMemo<ComponentType | null>(
setFloatingPanels((prev) => prev.filter((p) => p.id !== id)); () => (activePanel ? resolvePage(activePanel.target) : null),
}, []); [activePanel],
);
// 单实例对应的页面组件 const panelWidth = activePanel ? resolveWidth(activePanel.target) : undefined;
const SingletonPage = singleton ? PAGE_MAP[singleton.target] : null; const panelHeight = activePanel ? resolveHeight(activePanel.target) : undefined;
// 容器默认尺寸(动态计算,视口缩放自动适配)
const defaultModalWidth = Math.min(window.innerWidth * 0.55, 720);
const defaultDrawerWidth = Math.min(window.innerWidth * 0.38, 520);
return ( return (
<> <>
{/* ======== Modal ======== */} {/* ======== Modal ======== */}
{singleton?.modalType === 'modal' && SingletonPage && ( {activePanel?.modalType === 'modal' && PanelPage && (
<Modal <Modal
open open
centered centered
title={singleton.label} title={activePanel.label}
onCancel={closeSingleton} onCancel={closePanel}
footer={null} footer={null}
destroyOnHidden={true} destroyOnHidden={true}
mask={{ mask={{ closable: true }}
closable: true, width={panelWidth ?? defaultModalWidth}
}}
width={640}
> >
<SingletonPage /> {/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Modal> </Modal>
)} )}
{/* ======== Drawer ======== */} {/* ======== Drawer ======== */}
{singleton?.modalType === 'drawer' && SingletonPage && ( {activePanel?.modalType === 'drawer' && PanelPage && (
<Drawer <Drawer
open open
title={singleton.label} title={activePanel.label}
onClose={closeSingleton} onClose={closePanel}
destroyOnHidden={true} destroyOnHidden={true}
size={480} size={panelWidth ?? defaultDrawerWidth}
> >
<SingletonPage /> {/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</Drawer> </Drawer>
)} )}
{/* ======== Floating Panels ======== */} {/* ======== Floating Panel ======== */}
{floatingPanels.map((panel) => { {activePanel?.modalType === 'floating' && PanelPage && (
const PageContent = PAGE_MAP[panel.target];
if (!PageContent) return null;
return (
<FloatingPanel <FloatingPanel
key={panel.id} key={activePanel.id}
open open
title={panel.label} title={activePanel.label}
onClose={() => closeFloating(panel.id)} onClose={closePanel}
width={panelWidth ?? defaultDrawerWidth}
height={panelHeight}
> >
<PageContent /> {/* eslint-disable-next-line react-hooks/static-components */}
<PanelPage />
</FloatingPanel> </FloatingPanel>
); )}
})}
</> </>
); );
} }

View File

@@ -1,23 +1,99 @@
// ============================================================ // ============================================================
// SettingsProvider — 设置状态 Provider 组件 // SettingsProvider — 设置项集中状态管理 Context + Provider + Hook
// //
// 职责: // 职责:
// - 初始化时从 localStorage 读取已存储的设置项 // - 存储路径的集中管理read/write/clear
// - 提供 SettingsContext 给所有子组件 // - 持久化到 localStorage遵循 ele-heixiu-* 键名规范
// - 写操作同步更新 state + localStorage // - 全局组件通过 useSettings() 消费
// ============================================================ // ============================================================
import { useState, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import {
SettingsContext, // ---------- localStorage 键 ----------
readStoredOutputPath,
writeStoredOutputPath, const OUTPUT_PATH_KEY = 'ele-heixiu-output-path';
clearStoredOutputPath, const TEAM_REPO_PATH_KEY = 'ele-heixiu-team-repo-path';
readStoredTeamRepoPath,
writeStoredTeamRepoPath, // ---------- Context 类型 ----------
clearStoredTeamRepoPath,
} from '@/contexts/settings-context'; export interface SettingsContextValue {
import type { SettingsContextValue } from '@/contexts/settings-context'; /** 大模型产物存储仓库路径 */
outputPath: string;
/** 团队创作存储仓库路径(企业版) */
teamRepoPath: string;
/** 设置产物输出路径 */
setOutputPath: (path: string) => void;
/** 设置团队创作仓库路径 */
setTeamRepoPath: (path: string) => void;
/** 清除产物输出路径 */
clearOutputPath: () => void;
/** 清除团队创作仓库路径 */
clearTeamRepoPath: () => void;
}
export const SettingsContext = createContext<SettingsContextValue | null>(null);
// ---------- 工具函数localStorage 读写)----------
export function readStoredOutputPath(): string {
try {
return localStorage.getItem(OUTPUT_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredOutputPath(path: string): void {
try {
localStorage.setItem(OUTPUT_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredOutputPath(): void {
try {
localStorage.removeItem(OUTPUT_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
export function readStoredTeamRepoPath(): string {
try {
return localStorage.getItem(TEAM_REPO_PATH_KEY) || '';
} catch {
return '';
}
}
export function writeStoredTeamRepoPath(path: string): void {
try {
localStorage.setItem(TEAM_REPO_PATH_KEY, path);
} catch {
/* 静默忽略 */
}
}
export function clearStoredTeamRepoPath(): void {
try {
localStorage.removeItem(TEAM_REPO_PATH_KEY);
} catch {
/* 静默忽略 */
}
}
// ---------- Consumer Hook ----------
export function useSettings(): SettingsContextValue {
const ctx = useContext(SettingsContext);
if (!ctx) {
throw new Error('useSettings() 必须在 <SettingsProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface SettingsProviderProps { interface SettingsProviderProps {
children: ReactNode; children: ReactNode;

View File

@@ -1,5 +1,5 @@
// ============================================================ // ============================================================
// ThemeProvider — 主题 Provider 组件 // ThemeProvider — 主题 Context + Provider + Hook + 工具函数
// //
// 桌面端Electron使用 View Transitions API 驱动主题切换: // 桌面端Electron使用 View Transitions API 驱动主题切换:
// GPU 合成器截取旧/新两帧做一次 cross-fade替代 80+ 个 // GPU 合成器截取旧/新两帧做一次 cross-fade替代 80+ 个
@@ -7,20 +7,70 @@
// 浏览器不支持时回退到 CSS transition 方案。 // 浏览器不支持时回退到 CSS transition 方案。
// ============================================================ // ============================================================
import { useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import zhCN from 'antd/locale/zh_CN'; import zhCN from 'antd/locale/zh_CN';
import { getThemeConfig } from '../theme/antd-theme'; import { getThemeConfig } from '../theme/antd-theme';
import {
ThemeContext, // ---------- Context 类型 ----------
readStoredTheme,
writeStoredTheme, export interface ThemeContextValue {
applyHtmlTheme, isDark: boolean;
STORAGE_KEY, toggleTheme: () => void;
} from '../hooks/use-theme'; setLight: () => void;
import type { ThemeContextValue } from '../hooks/use-theme'; setDark: () => void;
}
export const ThemeContext = createContext<ThemeContextValue | null>(null);
// ---------- localStorage 工具函数 ----------
export const STORAGE_KEY = 'ele-heixiu-theme';
export function readStoredTheme(): boolean {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'dark') return true;
if (stored === 'light') return false;
} catch {
/* 静默忽略 */
}
if (typeof window !== 'undefined' && window.matchMedia) {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}
export function writeStoredTheme(isDark: boolean): void {
try {
localStorage.setItem(STORAGE_KEY, isDark ? 'dark' : 'light');
} catch {
/* 静默忽略 */
}
}
export function applyHtmlTheme(isDark: boolean): void {
document.documentElement.setAttribute('data-theme', isDark ? 'dark' : 'light');
if (isDark) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
// ---------- Consumer Hook ----------
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) {
throw new Error('useTheme() 必须在 <ThemeProvider> 内部调用');
}
return ctx;
}
// ---------- Provider 组件 ----------
interface ThemeProviderProps { interface ThemeProviderProps {
children: ReactNode; children: ReactNode;

View File

@@ -7,7 +7,7 @@ import {useEffect, useState, useCallback} from 'react';
import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd'; import {Drawer, Card, Typography, Empty, Tag, Spin, Modal, Tooltip} from 'antd';
import {NotificationOutlined} from '@ant-design/icons'; import {NotificationOutlined} from '@ant-design/icons';
import {fetchAnnouncements, type Announcement, AnnouncementListResponse} from '@/services/modules'; import { fetchAnnouncements, type Announcement, type AnnouncementListResponse } from '@/services/modules/announcement';
const {Text, Paragraph} = Typography; const {Text, Paragraph} = Typography;

View File

@@ -10,8 +10,8 @@
import {useState, useCallback, useMemo} from 'react'; import {useState, useCallback, useMemo} from 'react';
import {App} from 'antd'; import {App} from 'antd';
import {useAppContext} from '@/contexts/app-context'; import {useAppContext} from '@/components/AppProvider';
import {useTheme} from '@/hooks/use-theme'; import {useTheme} from '@/components/ThemeProvider';
import {emit, EVENTS} from '@/utils/event-bus.ts'; import {emit, EVENTS} from '@/utils/event-bus.ts';
import {NavItem} from './NavItem'; import {NavItem} from './NavItem';
import {AnnouncementDrawer} from './AnnouncementDrawer'; import {AnnouncementDrawer} from './AnnouncementDrawer';

View File

@@ -3,7 +3,7 @@
// 布局图标SVG在上文字在下 // 布局图标SVG在上文字在下
// ============================================================ // ============================================================
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import type { NavItemConfig } from './types'; import type { NavItemConfig } from './types';
interface NavItemProps { interface NavItemProps {

View File

@@ -12,7 +12,7 @@
// ============================================================ // ============================================================
import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react'; import { useState, useRef, useCallback, useEffect, type ReactNode } from 'react';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
// ---------- 类型 ---------- // ---------- 类型 ----------

View File

@@ -6,7 +6,7 @@
import { Modal, Typography } from 'antd'; import { Modal, Typography } from 'antd';
import { FileTextOutlined } from '@ant-design/icons'; import { FileTextOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
// Vite ?raw 导入:将 .txt 内容作为字符串直接内联 // Vite ?raw 导入:将 .txt 内容作为字符串直接内联
import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw'; import serviceAgreement from '@/assets/legal/用户服务协议.txt?raw';

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -13,19 +13,10 @@
// ============================================================ // ============================================================
import {useMemo} from 'react'; import {useMemo} from 'react';
import {useAppContext} from '@/contexts/app-context'; import {useAppContext} from '@/components/AppProvider';
import { import { TaskAPI, type TaskListRequestParams, type TaskInfoItemResponseBody, type CreatTaskRequestBody } from '@/services/modules/task';
TaskAPI, import { ModelAPI, MODEL_CATEGORY_LABELS, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
ModelAPI, import { fetchBanners, type Banner } from '@/services/modules/banner';
fetchBanners,
MODEL_CATEGORY_LABELS,
type TaskListRequestParams,
type TaskInfoItemResponseBody,
type CreatTaskRequestBody,
type ModelsListResponseBody,
type Banner,
type ModelCategoryStringEnum,
} from '@/services/modules';
import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async'; import {useAsyncData, UseAsyncDataReturn, useAsyncMutation} from './use-async';
// ============================================================ // ============================================================

View File

@@ -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;
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// AccountPage — 查帐户(占位组件,后续实现具体功能)
// ============================================================
export function AccountPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// BillingPage — 消费记录(占位组件,后续实现具体功能)
// ============================================================
export function BillingPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// ComplianceAssetsPage — 合规素材库(占位组件,后续实现具体功能)
// ============================================================
export function ComplianceAssetsPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,9 @@
import {Component} from "react";
export class Test1 extends Component {
render() {
return (
<></>
);
}
}

View File

@@ -0,0 +1,5 @@
export function Test3() {
return (
<></>
);
}

View File

@@ -11,7 +11,7 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Spin, Empty } from 'antd'; import { Spin, Empty } from 'antd';
import { WechatOutlined } from '@ant-design/icons'; import { WechatOutlined } from '@ant-design/icons';
import { fetchBanners, type Banner } from '@/services/modules'; import { fetchBanners, type Banner } from '@/services/modules/banner';
export function WechatWorkPage() { export function WechatWorkPage() {
const [banner, setBanner] = useState<Banner | null>(null); const [banner, setBanner] = useState<Banner | null>(null);

View File

@@ -1,14 +1,14 @@
// ============================================================ // ============================================================
// HomeContent — 首页三列正文布局 + HomeProvider // HomeContent — 首页三列正文布局 + HomeProviderContext + Provider + Hook
// 可拖拽调整三栏宽度,所有数据请求在登录后发送 // 可拖拽调整三栏宽度,所有数据请求在登录后发送
// ============================================================ // ============================================================
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { createContext, useContext, useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { theme as antTheme, Empty } from 'antd'; import { theme as antTheme, Empty } from 'antd';
import { HomeContext } from '@/contexts/home-context'; import { useAppContext } from '@/components/AppProvider';
import { useAppContext } from '@/contexts/app-context'; import type { ModelsListResponseBody } from '@/services/modules/models';
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules'; import type { TaskInfoItemResponseBody } from '@/services/modules/task';
import { LeftPanel } from './components/LeftPanel'; import { LeftPanel } from './components/LeftPanel';
import { CenterPanel } from './components/CenterPanel'; import { CenterPanel } from './components/CenterPanel';
import { RightPanel } from './components/RightPanel'; import { RightPanel } from './components/RightPanel';
@@ -17,6 +17,38 @@ import { Typography } from 'antd';
const { Text } = Typography; const { Text } = Typography;
// ---------- Context 类型 ----------
export interface HomeContextValue {
/** 当前选中的模型 */
selectedModel: ModelsListResponseBody | null;
setSelectedModel: (model: ModelsListResponseBody | null) => void;
/** 任务记录分页 — 当前页码 */
taskPage: number;
/** 任务记录分页 — 每页条数 */
taskPageSize: number;
setTaskPage: (page: number) => void;
setTaskPageSize: (size: number) => void;
/** 当前选中的任务记录(用于右侧预览) */
selectedTask: TaskInfoItemResponseBody | null;
setSelectedTask: (task: TaskInfoItemResponseBody | null) => void;
/** 侧边栏折叠状态 */
isLeftCollapsed: boolean;
toggleLeftCollapsed: () => void;
}
export const HomeContext = createContext<HomeContextValue | null>(null);
// ---------- Consumer Hook ----------
export function useHomeContext(): HomeContextValue {
const ctx = useContext(HomeContext);
if (!ctx) {
throw new Error('useHomeContext() 必须在 <HomeContent> 内部调用');
}
return ctx;
}
// ---------- 常量 ---------- // ---------- 常量 ----------
/** 左列最小/默认宽度 */ /** 左列最小/默认宽度 */

View File

@@ -14,7 +14,7 @@ import { CloseOutlined } from '@ant-design/icons';
import type { CarouselRef } from 'antd/es/carousel'; import type { CarouselRef } from 'antd/es/carousel';
import { useBannerList } from '@/hooks/use-api'; import { useBannerList } from '@/hooks/use-api';
import type { Banner } from '@/services/modules'; import type { Banner } from '@/services/modules/banner';
// ---------- 组件 Props ---------- // ---------- 组件 Props ----------

View File

@@ -1,162 +1,163 @@
// ============================================================ // ============================================================
// ModelInputForm — 动态模型输入表单 // ModelInputForm — 模型输入表单(纯布局外壳)
// 根据选中模型的 param_schema + ui_config 动态渲染表单控件 //
// 布局Header模型信息+meta → Bodyfields.map 渲染) → Footer标签+重置+提交)
//
// 与旧版的区别:
// - 控件渲染逻辑已提取到 useUI/ 组件体系
// - param_schema 解析已提取到 useModelUI Hook
// - 字段间条件依赖constraints_config.requires自动处理禁用
// - 本组件只负责布局、Form 容器、提交逻辑、依赖禁用
// ============================================================ // ============================================================
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useLayoutEffect, useMemo, useCallback } from 'react';
import { import {
Form, Form,
Input, Input,
InputNumber,
Select,
Slider,
Button, Button,
Typography, Typography,
Empty, Empty,
Upload, Tag,
Switch, Space,
theme as antTheme, theme as antTheme,
message, message,
} from 'antd'; } from 'antd';
import { import type { FormInstance } from 'antd';
SendOutlined, import { SendOutlined, ClockCircleOutlined, ReloadOutlined } from '@ant-design/icons';
UploadOutlined,
InboxOutlined,
} from '@ant-design/icons';
import type { UploadFile } from 'antd/es/upload'; import type { UploadFile } from 'antd/es/upload';
import { useHomeContext } from '@/contexts/home-context'; import { useHomeContext } from '@/pages/home/HomeContent';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { useSubmitTask } from '@/hooks/use-api'; import { useSubmitTask } from '@/hooks/use-api';
import { emit, EVENTS } from '@/utils/event-bus'; import { emit, EVENTS } from '@/utils/event-bus';
import type { ModelsListResponseBody, TaskInfoItemResponseBody } from '@/services/modules'; import { OutputTypeMap } from '@/services/modules/task';
import type { OutputTypeString, TaskInfoItemResponseBody } from '@/services/modules/task';
import { useModelUI, isValueEmpty, type UIFieldConfig } from './useModelUI';
const { Text, Title } = Typography; const { Text, Title } = Typography;
const { TextArea } = Input;
const { Dragger } = Upload;
// ---------- 参数类型映射 ---------- // ---------- 工具 ----------
/** 从 ui_config 中解析的参数描述 */
interface ParamDescriptor {
/** React keyitem.id保证唯一 */
key: string;
/** API 字段名item.map_to */
name: string;
label: string;
/** 控件类型 */
controlType: 'text' | 'number' | 'textarea' | 'select' | 'slider' | 'switch' | 'image-upload';
defaultValue?: unknown;
required?: boolean;
placeholder?: string;
/** 数值型:最小值 */
min?: number;
/** 数值型:最大值 */
max?: number;
/** 数值型:步长 */
step?: number;
/** select 型:选项列表 */
options?: Array<{ label: string; value: string | number }>;
}
/** 从 ui_config 对象中解析控件类型 */
function inferControlType(config: Record<string, unknown>): ParamDescriptor['controlType'] {
const type = config.type as string | undefined;
if (!type) return 'text';
switch (type) {
case 'number':
case 'integer':
case 'float':
return 'number';
case 'textarea':
case 'text_area':
case 'prompt':
return 'textarea';
case 'select':
case 'dropdown':
case 'enum':
return 'select';
case 'slider':
case 'range':
return 'slider';
case 'switch':
case 'boolean':
case 'bool':
return 'switch';
case 'image':
case 'image-upload':
case 'file':
return 'image-upload';
default:
return 'text';
}
}
/** /**
* 从 param_schema 构建参数描述符列表 * Form.Item 的 getValueFromEvent兼容两种 onChange 参数形态:
* * 1. Upload 组件的 DOM 事件:{ fileList: UploadFile[] } → 提取 fileList
* 后端 param_schema 格式为 ParamSchemaItem[](对象数组), * 2. enable_switch 的裸值undefined / [] / [{...}] → 直接透传
* 每项的 ui 字段内嵌了控件类型、标签、校验规则等 UI 配置。
*/ */
function buildParamDescriptors( function handleFileWidgetValue(e: unknown): unknown {
paramSchema: ModelsListResponseBody['param_schema'], if (e && typeof e === 'object' && 'fileList' in e) {
): ParamDescriptor[] { return (e as { fileList: unknown }).fileList;
return paramSchema.map((item) => { }
const config = (item.ui || {}) as Record<string, unknown>; return e;
const controlType = inferControlType(config);
return {
key: item.id,
name: item.map_to,
label: (config.label as string) || item.map_to,
controlType,
defaultValue: config.default ?? config.defaultValue,
required: (config.required as boolean) || false,
placeholder: (config.placeholder as string) || `请输入${config.label || item.map_to}`,
min: config.min as number | undefined,
max: config.max as number | undefined,
step: config.step as number | undefined,
options: (config.options as ParamDescriptor['options']) || undefined,
};
});
} }
// ---------- 组件 ---------- // ---------- 依赖字段包装组件 ----------
/**
* DependentFormItem — 为单个字段处理条件依赖禁用。
*
* 通过 Form.useWatch 监听 dependsOn 列表中的字段值,
* 任一依赖为空时自动禁用本字段(注入 disabled: true 到 widgetConfig
*
* 每个 DependentFormItem 实例的 dependsOn 长度固定,
* 切换模型时通过外层 key 变化自动重新挂载,满足 React hooks 规则。
*/
function DependentFormItem({
field,
form,
value,
onChange,
disabled: formItemDisabled,
}: {
field: UIFieldConfig;
form: FormInstance;
/** Form.Item 通过 React.cloneElement 注入,必须透传给 widget */
value?: unknown;
onChange?: (value: unknown) => void;
disabled?: boolean;
}) {
const depValues = (field.dependsOn || []).map((depName) =>
Form.useWatch(depName, form),
);
const isDepDisabled =
field.dependsOn?.some((_, i) => isValueEmpty(depValues[i])) ?? false;
const effectiveDisabled = formItemDisabled || isDepDisabled;
const effectiveConfig = effectiveDisabled
? { ...field.widgetConfig, disabled: true }
: field.widgetConfig;
const Component = field.component;
return (
<Component
value={value}
onChange={onChange}
disabled={effectiveDisabled}
config={effectiveConfig}
/>
);
}
/** 稳定空数组引用,避免 useMemo 重建 */
const EMPTY_ARRAY: never[] = [];
/** 稳定空对象引用,避免每次渲染创建新 {} 导致 useMemo 失效 */
const EMPTY_OBJ: Record<string, unknown> = {};
// ---------- 主组件 ----------
export function ModelInputForm() { export function ModelInputForm() {
const { token } = antTheme.useToken(); const { token } = antTheme.useToken();
const { isLoggedIn } = useAppContext(); const { isLoggedIn } = useAppContext();
const { selectedModel } = useHomeContext(); const { selectedModel } = useHomeContext();
const [form] = Form.useForm(); const [form] = Form.useForm();
const [referenceFiles, setReferenceFiles] = useState<UploadFile[]>([]); const [taskTag, setTaskTag] = useState('');
// 提交突变 Hook(自动处理 loading / error / 日志) // 提交突变 Hook
const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({ const { execute: submitTask, loading: submitting, errorMessage } = useSubmitTask({
onSuccess: useCallback( onSuccess: useCallback((_data: TaskInfoItemResponseBody) => {
(_data: TaskInfoItemResponseBody) => {
message.success('任务已提交'); message.success('任务已提交');
// 通过事件总线通知 TaskHistory 刷新(替代 Context 中的 refreshTaskSignal
emit(EVENTS.TASK_SUBMITTED); emit(EVENTS.TASK_SUBMITTED);
}, }, []),
[],
),
}); });
// 选中模型时重置表单(仅当有选中模型时操作,避免 form 未连接警告) // ======== useModelUIparam_schema → UIFieldConfig[] ========
useEffect(() => {
if (selectedModel) { const fields: UIFieldConfig[] = useModelUI(
form.resetFields(); selectedModel?.param_schema || EMPTY_ARRAY,
(selectedModel?.constraints_config as Record<string, unknown>) || EMPTY_OBJ,
);
// 构建初始值(用作 setFieldsValue 的数据源,
// antd Form 在有外部 form 实例时会忽略 initialValues prop
const initialValues = useMemo(() => {
const iv: Record<string, unknown> = {};
fields.forEach((f) => {
if (f.defaultValue !== undefined && f.defaultValue !== null) {
iv[f.name] = f.defaultValue;
} }
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 () => { const handleSubmit = async () => {
if (!isLoggedIn) { if (!isLoggedIn) {
message.warning('请先登录后再提交任务'); message.warning('请先登录后再提交任务');
@@ -167,128 +168,47 @@ export function ModelInputForm() {
try { try {
const values = await form.validateFields(); const values = await form.validateFields();
// 分离固定字段和模型动态参数 const params: Record<string, string> = {};
const { prompt, negative_prompt, num_outputs, ...dynamicParams } = values;
// 构建提交参数(全部转为字符串值) for (const field of fields) {
const params: Record<string, string> = { const value = values[field.name];
prompt: String(prompt ?? ''), if (value === undefined || value === null) continue;
negative_prompt: String(negative_prompt ?? ''),
num_outputs: String(num_outputs ?? 1),
};
// 动态参数统一转字符串 if (field.isFileWidget) {
for (const [key, value] of Object.entries(dynamicParams)) { const fileList = value as UploadFile[];
if (value !== undefined && value !== null) { const urls = fileList
params[key] = String(value);
}
}
// 参考图
if (referenceFiles.length > 0) {
const refUrls = referenceFiles
.filter((f) => f.status === 'done') .filter((f) => f.status === 'done')
.map((f) => f.response?.url || f.url || f.name) .map((f) => f.response?.url || f.url || f.name)
.filter(Boolean) as string[]; .filter(Boolean) as string[];
if (refUrls.length > 0) { if (urls.length > 0) {
params.reference_images = JSON.stringify(refUrls); params[field.name] = urls.length === 1 ? urls[0] : JSON.stringify(urls);
} }
continue;
} }
// 使用 mutation Hook 提交(自动处理 loading / error / 日志) if (typeof value === 'boolean') {
await submitTask({ model_id: selectedModel.id, params }); 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) { } catch (err) {
if (err && typeof err === 'object' && 'errorFields' in err) { if (err && typeof err === 'object' && 'errorFields' in err) {
// 表单验证错误antd 会自动提示
return; return;
} }
// 错误消息由 Hook 自动通过 errorMessage 提供
message.error(errorMessage || '任务提交失败,请重试'); message.error(errorMessage || '任务提交失败,请重试');
} }
}; };
// 渲染单个参数控件 // ======== 未选择模型 ========
const renderParamControl = (desc: ParamDescriptor) => {
const commonProps = {
placeholder: desc.placeholder,
style: { width: '100%' },
};
switch (desc.controlType) {
case 'number':
return (
<InputNumber
{...commonProps}
min={desc.min}
max={desc.max}
step={desc.step}
style={{ width: '100%' }}
/>
);
case 'textarea':
return <TextArea rows={3} {...commonProps} />;
case 'select':
return (
<Select
{...commonProps}
options={desc.options}
allowClear
/>
);
case 'slider':
return (
<Slider
min={desc.min ?? 1}
max={desc.max ?? 100}
step={desc.step ?? 1}
marks={
desc.min !== undefined && desc.max !== undefined
? { [desc.min]: `${desc.min}`, [desc.max]: `${desc.max}` }
: undefined
}
tooltip={{ formatter: (v) => v }}
/>
);
case 'switch':
return <Switch />;
case 'image-upload':
return (
<Upload
listType="picture-card"
maxCount={1}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false; // 手动上传
}}
>
<div>
<UploadOutlined />
<div style={{ marginTop: 4, fontSize: 12 }}></div>
</div>
</Upload>
);
default:
return <Input {...commonProps} />;
}
};
// ---- 未选择模型 ----
if (!selectedModel) { if (!selectedModel) {
return ( return (
<div <div
@@ -304,7 +224,24 @@ export function ModelInputForm() {
); );
} }
// ---- 表单 ---- // ======== 提取 meta 信息 ========
const metaConfig = (selectedModel.meta_config || {}) as Record<string, unknown>;
const uiConfig = (selectedModel.ui_config || {}) as Record<string, unknown>;
const pricingConfig = (selectedModel.pricing_config || {}) as Record<string, unknown>;
const outputType = metaConfig.output_type as OutputTypeString | undefined;
const estimatedDuration = metaConfig.estimated_duration_seconds as number | undefined;
const badge = uiConfig.badge as string | undefined;
const group = uiConfig.group as string | undefined;
const helpUrl = metaConfig.help_url as string | undefined;
const priceUnit = (pricingConfig.unit as Record<string, unknown>)?.name as string | undefined;
const price = pricingConfig.price as number | undefined;
const priceType = pricingConfig.price_type as string | undefined;
// ======== 正常渲染 ========
return ( return (
<div <div
style={{ style={{
@@ -314,7 +251,7 @@ export function ModelInputForm() {
overflow: 'hidden', overflow: 'hidden',
}} }}
> >
{/* 模型标题 */} {/* ======== Header模型信息 ======== */}
<div <div
style={{ style={{
padding: '16px 20px', padding: '16px 20px',
@@ -322,145 +259,146 @@ export function ModelInputForm() {
flexShrink: 0, flexShrink: 0,
}} }}
> >
<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 }}> <Title level={4} style={{ margin: 0, fontSize: 18 }}>
{selectedModel.name} {selectedModel.name}
</Title> </Title>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Text type="secondary" style={{ fontSize: 12 }}> <Text type="secondary" style={{ fontSize: 12 }}>
{selectedModel.provider_name} · {selectedModel.category_name} {selectedModel.provider_name}
{group
? ` · ${group}`
: selectedModel.category_name
? ` · ${selectedModel.category_name}`
: ''}
</Text> </Text>
</div> </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' }}> <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={form} form={form}
layout="vertical" layout="vertical"
size="small"
initialValues={{ num_outputs: 1 }}
> >
{/* ======== 固定字段 ======== */} {fields.map((field) => (
{/* 提示词 */}
<Form.Item <Form.Item
name="prompt" key={`${selectedModel.id}-${field.name}`}
label={<Text strong></Text>} name={field.name}
rules={[{ required: true, message: '请输入提示词' }]} label={
<span>
<Text strong={field.must}>{field.label}</Text>
{field.tips && (
<Text
type="secondary"
style={{ fontSize: 12, marginLeft: 6 }}
> >
<TextArea {field.tips.length > 60
rows={4} ? `${field.tips.slice(0, 60)}...`
placeholder="描述你想要的画面、视频或音频内容..." : field.tips}
showCount
maxLength={2000}
/>
</Form.Item>
{/* 负面提示词 */}
<Form.Item
name="negative_prompt"
label={<Text type="secondary"></Text>}
>
<TextArea
rows={2}
placeholder="描述你不希望在结果中出现的内容(可选)"
maxLength={1000}
/>
</Form.Item>
{/* 参考图 */}
<Form.Item label={<Text strong></Text>}>
<Dragger
multiple
listType="picture"
fileList={referenceFiles}
onChange={({ fileList }) => setReferenceFiles(fileList)}
accept="image/png,image/jpg,image/jpeg,image/webp"
beforeUpload={(file) => {
const isImage = file.type.startsWith('image/');
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isImage) {
message.error('只能上传图片文件');
return Upload.LIST_IGNORE;
}
if (!isLt10M) {
message.error('图片大小不能超过 10MB');
return Upload.LIST_IGNORE;
}
return false;
}}
maxCount={5}
style={{ marginBottom: 8 }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"></p>
<p className="ant-upload-hint">
PNG / JPG / WebP 10MB
</p>
</Dragger>
</Form.Item>
{/* 生成数量 */}
<Form.Item
name="num_outputs"
label={<Text strong></Text>}
>
<InputNumber min={1} max={10} style={{ width: '100%' }} />
</Form.Item>
{/* ======== 模型动态参数 ======== */}
{paramDescriptors.length > 0 && (
<div
style={{
marginTop: 16,
paddingTop: 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
</Text> </Text>
{paramDescriptors.map((desc) => ( )}
<Form.Item </span>
key={desc.key} }
name={desc.name} rules={
label={desc.label} field.must
rules={desc.required ? [{ required: true, message: `请输入${desc.label}` }] : undefined} ? [{ required: true, message: `请输入${field.label}` }]
initialValue={desc.defaultValue} : undefined
valuePropName={desc.controlType === 'switch' ? 'checked' : 'value'} }
valuePropName={field.valuePropName}
{...(field.isFileWidget
? { getValueFromEvent: handleFileWidgetValue }
: {})}
> >
{renderParamControl(desc)} <DependentFormItem field={field} form={form} />
</Form.Item> </Form.Item>
))} ))}
</div>
)}
</Form> </Form>
)}
</div> </div>
{/* 提交按钮 */} {/* ======== Footer标签 + 重置 + 提交 ======== */}
<div <div
style={{ style={{
padding: '12px 20px', padding: '12px 20px',
borderTop: `1px solid ${token.colorBorderSecondary}`, borderTop: `1px solid ${token.colorBorderSecondary}`,
flexShrink: 0, flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 8,
}} }}
> >
<Input
placeholder="自定义任务标签(可选)"
value={taskTag}
onChange={(e) => setTaskTag(e.target.value)}
allowClear
maxLength={50}
style={{ flex: 1, minWidth: 0 }}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleReset}
disabled={!selectedModel}
title="重置参数"
/>
<Button <Button
type="primary" type="primary"
icon={<SendOutlined />} icon={<SendOutlined />}
onClick={handleSubmit} onClick={handleSubmit}
loading={submitting} loading={submitting}
disabled={!isLoggedIn || !selectedModel} disabled={!isLoggedIn || !selectedModel}
block
size="large" size="large"
style={{ style={{
background: token.colorPrimary, background: token.colorPrimary,
height: 44, height: 40,
fontSize: 16, fontSize: 14,
whiteSpace: 'nowrap',
flexShrink: 0,
}} }}
> >
{priceType === 'SIMPLE' && price !== undefined
? `消费 ¥${price}/${priceUnit || '次'}`
: '消费'}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -8,9 +8,8 @@ import { Tabs, Spin, Empty, Tag, Typography, Button, Result, message, theme as a
import { AppstoreOutlined } from '@ant-design/icons'; import { AppstoreOutlined } from '@ant-design/icons';
import { useModelList } from '@/hooks/use-api'; import { useModelList } from '@/hooks/use-api';
import { useHomeContext } from '@/contexts/home-context'; import { useHomeContext } from '@/pages/home/HomeContent';
import { resolveCategoryLabel, MODEL_CATEGORIES } from '@/services/modules'; import { resolveCategoryLabel, MODEL_CATEGORIES, type ModelsListResponseBody, type ModelCategoryStringEnum } from '@/services/modules/models';
import type { ModelsListResponseBody, ModelCategoryStringEnum } from '@/services/modules';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -11,9 +11,9 @@ import {
CloseCircleOutlined, CloseCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useHomeContext } from '@/contexts/home-context'; import { useHomeContext } from '@/pages/home/HomeContent';
import { useTaskDetail } from '@/hooks/use-api'; import { useTaskDetail } from '@/hooks/use-api';
import type { TaskStatusString } from '@/services/modules'; import type { TaskStatusString } from '@/services/modules/task';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -18,16 +18,16 @@ import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons'; import { HistoryOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons';
import { useTaskList, useModelList } from '@/hooks/use-api'; import { useTaskList, useModelList } from '@/hooks/use-api';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { useHomeContext } from '@/contexts/home-context'; import { useHomeContext } from '@/pages/home/HomeContent';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
import { on, off, EVENTS } from '@/utils/event-bus'; import { on, off, EVENTS } from '@/utils/event-bus';
import type { import type {
TaskInfoItemResponseBody, TaskInfoItemResponseBody,
TaskListRequestParams, TaskListRequestParams,
TaskStatusString, TaskStatusString,
} from '@/services/modules'; } from '@/services/modules/task';
import { TaskStatusMap, OutputTypeMap } from '@/services/modules'; import { TaskStatusMap, OutputTypeMap } from '@/services/modules/task';
const { Text } = Typography; const { Text } = Typography;

View 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 的 valuePropNamecheckbox 用 '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 依赖 srefsref 为空时它们应被禁用。
*/
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;
}

View 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>
);
}

View 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}
/>
);
}

View 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%' }}
/>
);
}

View 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
/>
);
}

View 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) {
// 关闭开关 → 表单值置为 undefinedisValueEmpty = 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>
);
}

View 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>
);
}

View 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
/>
);
}

View 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>
);
}

View 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
/>
);
}

View 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}
/>
);
}

View 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;

View 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>;
}

View File

@@ -12,8 +12,8 @@ import { useState, useCallback } from 'react';
import { Modal, Input, Button, Typography, App } from 'antd'; import { Modal, Input, Button, Typography, App } from 'antd';
import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons'; import { LockOutlined, KeyOutlined, SafetyOutlined } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import { AuthAPI } from '@/services/modules'; import { AuthAPI } from '@/services/modules/auth';
import { validatePassword, validateConfirmPassword } from '../config/validators'; import { validatePassword, validateConfirmPassword } from '../config/validators';
const { Text, Title } = Typography; const { Text, Title } = Typography;

View File

@@ -18,8 +18,8 @@ import {
KeyOutlined, KeyOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import { AuthAPI } from '@/services/modules'; import { AuthAPI } from '@/services/modules/auth';
import { import {
validatePhone, validatePhone,
validateSmsCode, validateSmsCode,

View File

@@ -4,11 +4,11 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons'; import { UserOutlined, LockOutlined, EyeTwoTone, EyeInvisibleOutlined } from '@ant-design/icons';
import { loginFields } from '../config/login-fields'; import { loginFields } from '../config/login-fields';
import { validateForm } from '../config/validators'; import { validateForm } from '../config/validators';
import { LegalTextModal } from '@/components/common/LegalTextModal'; import { LegalTextModal } from '@/components/ui/LegalTextModal';
import type { LoginFormValues } from '../types'; import type { LoginFormValues } from '../types';
const { Text } = Typography; const { Text } = Typography;
@@ -40,7 +40,7 @@ export function LoginForm({
const [password, setPassword] = useState(initialPassword || ''); const [password, setPassword] = useState(initialPassword || '');
const [remember, setRemember] = useState(initialRemember); const [remember, setRemember] = useState(initialRemember);
const [autoLogin, setAutoLogin] = useState(initialAutoLogin); const [autoLogin, setAutoLogin] = useState(initialAutoLogin);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(true);
const [errors, setErrors] = useState<Record<string, string[]>>({}); const [errors, setErrors] = useState<Record<string, string[]>>({});
// 协议弹窗 // 协议弹窗
@@ -103,6 +103,8 @@ export function LoginForm({
setPassword(e.target.value); setPassword(e.target.value);
clearError('password'); clearError('password');
}} }}
iconRender={(visible) => (visible ? <EyeTwoTone /> : <EyeInvisibleOutlined />)}
onPressEnter={handleSubmit}
status={errors.password ? 'error' : undefined} status={errors.password ? 'error' : undefined}
allowClear allowClear
/> />
@@ -162,6 +164,11 @@ export function LoginForm({
setAgreed(e.target.checked); setAgreed(e.target.checked);
clearError('_agreement'); clearError('_agreement');
}} }}
onKeyDown={(event)=>{
if (event.key === 'Enter' || event.keyCode === 13) {
setAgreed(!agreed);
}
}}
className="text-xs" className="text-xs"
> >

View File

@@ -2,10 +2,10 @@
// RegisterForm — 注册表单(仅个人版) // RegisterForm — 注册表单(仅个人版)
// ============================================================ // ============================================================
import { useState, useCallback, useRef } from 'react'; import React, { useState, useCallback, useRef } from 'react';
import { Input, Button, Checkbox, Typography } from 'antd'; import { Input, Button, Checkbox, Typography } from 'antd';
import { LegalTextModal } from '@/components/common/LegalTextModal'; import { LegalTextModal } from '@/components/ui/LegalTextModal';
import { import {
UserOutlined, UserOutlined,
LockOutlined, LockOutlined,
@@ -87,7 +87,9 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
onSendSms?.(phone); onSendSms?.(phone);
}, [values.phone, onSendSms]); }, [values.phone, onSendSms]);
// 跨字段校验 // 提交
const handleSubmit = useCallback(() => {
// 跨字段校验(内联避免每次渲染创建新引用致使用 useCallback 失效)
const crossValidators = { const crossValidators = {
confirmPassword: (val: string) => { confirmPassword: (val: string) => {
if (!val) return '请再次输入密码'; if (!val) return '请再次输入密码';
@@ -95,9 +97,6 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
return null; return null;
}, },
}; };
// 提交
const handleSubmit = useCallback(() => {
const fieldErrors = validateForm(values, registerFields, crossValidators); const fieldErrors = validateForm(values, registerFields, crossValidators);
if (Object.keys(fieldErrors).length > 0) { if (Object.keys(fieldErrors).length > 0) {
setErrors(fieldErrors); setErrors(fieldErrors);
@@ -208,6 +207,11 @@ export function RegisterForm({ submitting = false, onSubmit, onSendSms }: Regist
return n; return n;
}); });
}} }}
onKeyDown={(e)=>{
if(e.key==="Enter" || e.keyCode == 13) {
setAgreed(!agreed);
}
}}
className="text-xs" className="text-xs"
> >

View File

@@ -1,76 +1,34 @@
// ============================================================ // ============================================================
// useAuthState — 记住账号 / 自动登录 状态管理 // useAuthState — 记住账号 / 自动登录 表单状态 Hook
// //
// "记住密码":下次打开页面自动回填用户名(不再存储密码) // "记住密码":下次打开页面自动回填用户名(不再存储密码)
// "自动登录"登录成功时标记AppProvider 启动时用 refresh_token 静默续期 // "自动登录"登录成功时标记AppProvider 启动时用 refresh_token 静默续期
// //
// 持久化逻辑已提取到 services/auth-persistence.ts
// 此处仅负责 React 组件状态useState + useCallback
//
// 安全: // 安全:
// - 密码从不持久化存储access_token / refresh_token 由 safeStorage 加密保护) // - 密码从不持久化存储access_token / refresh_token 由 safeStorage 加密保护)
// - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障 // - 安全性由 refresh_token + access_token 的 JWT 双重机制 + OS 密钥链保障
// ============================================================ // ============================================================
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import {
saveAuth,
clearSavedAuth,
getSavedUsername,
getSavedRemember,
getSavedAutoLogin,
} from '@/services/auth-persistence';
const STORAGE_KEY = 'ele-heixiu-auth'; export { getAutoLoginFlag, clearAutoLoginFlag, clearSavedAuth } from '@/services/auth-persistence';
interface StoredAuth {
/** 上次登录的用户名(表单回填用) */
username: string;
/** 是否记住用户名(勾选后表单自动回填用户名) */
remember: boolean;
/** 是否勾选了自动登录AppProvider 据此决定启动时是否刷新 token */
autoLogin: boolean;
}
function readAuth(): StoredAuth | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw) as StoredAuth;
} catch {
return null;
}
}
function writeAuth(auth: StoredAuth): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} catch {
/* 静默 */
}
}
// ---------- 工具函数(不依赖 ReactAppProvider 可直接调用)----------
/** 读取自动登录标记 */
export function getAutoLoginFlag(): boolean {
return readAuth()?.autoLogin || false;
}
/** 仅清除自动登录标记(保留用户名,下次还能回填表单) */
export function clearAutoLoginFlag(): void {
const auth = readAuth();
if (!auth) return;
writeAuth({ ...auth, autoLogin: false });
}
/** 清除所有保存的认证信息 */
export function clearSavedAuth(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* 静默 */
}
}
// ---------- Hook ---------- // ---------- Hook ----------
export function useAuthState() { export function useAuthState() {
const saved = readAuth(); const [username, setUsername] = useState(getSavedUsername);
const [remember, setRemember] = useState(getSavedRemember);
const [username, setUsername] = useState(saved?.username || ''); const [autoLogin, setAutoLogin] = useState(getSavedAutoLogin);
const [remember, setRemember] = useState(saved?.remember || false);
const [autoLogin, setAutoLogin] = useState(saved?.autoLogin || false);
const handleRememberChange = useCallback((checked: boolean) => { const handleRememberChange = useCallback((checked: boolean) => {
setRemember(checked); setRemember(checked);
@@ -93,16 +51,11 @@ export function useAuthState() {
* @param doRemember - 是否勾选"记住用户名" * @param doRemember - 是否勾选"记住用户名"
* @param doAutoLogin - 是否勾选"自动登录" * @param doAutoLogin - 是否勾选"自动登录"
*/ */
const saveAuth = useCallback( const handleSaveAuth = useCallback(
(user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => { (user: string, _password: string, doRemember: boolean, doAutoLogin: boolean) => {
setUsername(user); setUsername(user);
if (doRemember || doAutoLogin) { if (doRemember || doAutoLogin) {
const payload: StoredAuth = { saveAuth(user, doRemember, doAutoLogin);
username: user,
remember: doRemember,
autoLogin: doAutoLogin,
};
writeAuth(payload);
} }
}, },
[], [],
@@ -115,6 +68,6 @@ export function useAuthState() {
setUsername, setUsername,
handleRememberChange, handleRememberChange,
handleAutoLoginChange, handleAutoLoginChange,
saveAuth, saveAuth: handleSaveAuth,
}; };
} }

View File

@@ -10,13 +10,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { Modal, Tabs, Typography, App } from 'antd'; import { Modal, Tabs, Typography, App } from 'antd';
import { useAppContext } from '@/contexts/app-context.ts'; import { useAppContext } from '@/components/AppProvider';
import { useTheme } from '@/hooks/use-theme.ts'; import { useTheme } from '@/components/ThemeProvider';
import { LoginForm } from './components/LoginForm'; import { LoginForm } from './components/LoginForm';
import { RegisterForm } from './components/RegisterForm'; import { RegisterForm } from './components/RegisterForm';
import { ForgotPasswordModal } from './components/ForgotPasswordModal'; import { ForgotPasswordModal } from './components/ForgotPasswordModal';
import { useAuthState } from './hooks/use-auth-state'; import { useAuthState } from './hooks/use-auth-state';
import { AuthAPI, LoginResponseBody } from '@/services/modules'; import { AuthAPI, type LoginResponseBody } from '@/services/modules/auth';
import { getDeviceId } from '@/utils/device'; import { getDeviceId } from '@/utils/device';
import type { LoginFormValues, RegisterFormValues } from './types'; import type { LoginFormValues, RegisterFormValues } from './types';

View File

@@ -1,11 +0,0 @@
// ============================================================
// OrdersPage — 订单明细(占位组件,后续实现具体功能)
// ============================================================
export function OrdersPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// ProjectsPage — 项目管理(占位组件,后续实现具体功能)
// ============================================================
export function ProjectsPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// QuotaPage — 查配额(占位组件,后续实现具体功能)
// ============================================================
export function QuotaPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -1,11 +0,0 @@
// ============================================================
// RechargePage — 充值积分(占位组件,后续实现具体功能)
// ============================================================
export function RechargePage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}> </p>
</div>
);
}

View File

@@ -14,7 +14,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { Modal } from 'antd'; import { Modal } from 'antd';
import { SettingOutlined, CloseOutlined } from '@ant-design/icons'; import { SettingOutlined, CloseOutlined } from '@ant-design/icons';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { isMacOS } from '@/utils/platform'; import { isMacOS } from '@/utils/platform';
import { ThemeSetting } from './blocks/ThemeSetting'; import { ThemeSetting } from './blocks/ThemeSetting';
import { StoragePathSetting } from './blocks/StoragePathSetting'; import { StoragePathSetting } from './blocks/StoragePathSetting';

View File

@@ -9,7 +9,7 @@ import { useState, useCallback } from 'react';
import { Card, Button, Input, Typography, Space, App } from 'antd'; import { Card, Button, Input, Typography, Space, App } from 'antd';
import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'; import { FolderOpenOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
import type { Edition } from '@shared/types'; import type { Edition } from '@shared/types';
import { useSettings } from '@/contexts/settings-context'; import { useSettings } from '@/components/SettingsProvider';
import { hasIpc, safeIpcInvoke } from '@/utils/ipc'; import { hasIpc, safeIpcInvoke } from '@/utils/ipc';
import { BIDIRECTIONAL } from '@shared/constants/ipc-channels'; import { BIDIRECTIONAL } from '@shared/constants/ipc-channels';

View File

@@ -9,7 +9,7 @@ import {
InfoCircleOutlined, InfoCircleOutlined,
EnvironmentOutlined, EnvironmentOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useAppContext } from '@/contexts/app-context'; import { useAppContext } from '@/components/AppProvider';
import { getPlatformName } from '@/utils/platform'; import { getPlatformName } from '@/utils/platform';
import { APP_VERSION } from '@shared/constants/version'; import { APP_VERSION } from '@shared/constants/version';
import { getEditionName } from '@shared/constants/app'; import { getEditionName } from '@shared/constants/app';

View File

@@ -4,7 +4,7 @@
import { Card, Segmented, Typography } from 'antd'; import { Card, Segmented, Typography } from 'antd';
import { BulbOutlined, BulbFilled } from '@ant-design/icons'; import { BulbOutlined, BulbFilled } from '@ant-design/icons';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/components/ThemeProvider';
const { Text } = Typography; const { Text } = Typography;

View File

@@ -1,11 +0,0 @@
// ============================================================
// VipPage — 开会员/激活(占位组件,后续实现具体功能)
// ============================================================
export function VipPage() {
return (
<div style={{ padding: 8 }}>
<p style={{ color: 'inherit', opacity: 0.6, margin: 0 }}>/ </p>
</div>
);
}

View File

@@ -0,0 +1,84 @@
// ============================================================
// auth-persistence — 本地认证偏好持久化(记住账号 / 自动登录)
//
// 职责:
// - 存储/读取登录表单偏好(用户名回填、记住密码、自动登录标记)
// - 纯 localStorage 操作,不依赖 React
// - 密码从不持久化(安全性由 JWT + safeStorage 保障)
//
// 与 auth-token.ts 的分工:
// auth-token.ts → JWT token 生命周期refresh/accesssafeStorage 加密)
// 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;
}

View File

@@ -12,8 +12,7 @@
// ============================================================ // ============================================================
import { setToken, setTokenRefreshHandler } from './request'; import { setToken, setTokenRefreshHandler } from './request';
import { AuthAPI } from './modules'; import { AuthAPI, type RefreshTokenResponseBody } from './modules/auth';
import type { RefreshTokenResponseBody } from './modules';
import { import {
getSafeStore, getSafeStore,
setSafeStore, setSafeStore,

View File

@@ -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
View 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;
}
}

View File

@@ -1,374 +1,14 @@
/* ============================================================ /* ============================================================
* 全局样式 — Tailwind CSS v4 指令 + 蓝紫渐变主题扩展 + 重置样式 * 全局样式入口 — Tailwind CSS v4 + 设计令牌 + 过渡 + 基础样式
* Tailwind v4 使用 CSS @theme 指令替代 tailwind.config.ts *
* 设计令牌需与 tokens.ts / antd-theme.ts 保持同步。 * 拆分为 4 个文件,按职责定位:
* tokens.css → 改颜色 / 品牌色 / 亮暗变量
* transitions.css → 改主题切换过渡参数
* base.css → 改重置 / 滚动条 / 链接 / 拖拽区域
* globals.css → 本文件(入口,只负责引入顺序)
* ============================================================ */ * ============================================================ */
@import 'tailwindcss'; @import 'tailwindcss';
@import './tokens.css';
/* ============================================================ @import './transitions.css';
* Tailwind CSS v4 主题扩展(用 @theme 指令) @import './base.css';
* 此处定义的自定义令牌会自动生成对应的 Tailwind 工具类。
* 例如colors.primary → bg-primary, text-primary, border-primary 等。
* ============================================================ */
/* 品牌色 — 蓝紫渐变 */
@theme {
/* 主色系 */
--color-primary: #6366f1; /* Indigo-500 */
--color-primary-light: #818cf8; /* Indigo-400 */
--color-primary-dark: #4f46e5; /* Indigo-600 */
--color-primary-50: #eef2ff; /* Indigo-50 */
--color-primary-100: #e0e7ff; /* Indigo-100 */
--color-primary-200: #c7d2fe; /* Indigo-200 */
--color-primary-300: #a5b4fc; /* Indigo-300 */
--color-primary-400: #818cf8; /* Indigo-400 */
--color-primary-500: #6366f1; /* Indigo-500 */
--color-primary-600: #4f46e5; /* Indigo-600 */
--color-primary-700: #4338ca; /* Indigo-700 */
--color-primary-800: #3730a3; /* Indigo-800 */
--color-primary-900: #312e81; /* Indigo-900 */
/* 辅助色系 — Violet */
--color-secondary: #8b5cf6; /* Violet-500 */
--color-secondary-light: #a78bfa; /* Violet-400 */
--color-secondary-dark: #7c3aed; /* Violet-600 */
/* 功能色 */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* 圆角 */
--radius-xs: 4px;
--radius-sm: 6px;
--radius-base: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
--radius-2xl: 24px;
/* 字体 */
--font-family-base:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans',
'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-family-mono:
'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, 'Courier New', monospace;
/* 动画 */
--transition-duration: 0.2s;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
}
/* ============================================================
* 🌞 亮色模式 CSS 变量(默认)
* 这些变量与 tokens.ts 中的亮色令牌同步
* ============================================================ */
:root,
[data-theme='light'] {
/* 中性色 — 亮色 */
--color-text-base: #1e1b4b;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
--color-bg-base: #ffffff;
--color-bg-container: #f9fafb;
--color-bg-layout: #f5f3ff;
--color-bg-elevated: #ffffff;
--color-border-base: #e5e7eb;
--color-border-secondary: #f3f4f6;
/* 阴影 */
--shadow-base: 0 1px 3px 0 rgba(99, 102, 241, 0.08), 0 1px 2px -1px rgba(99, 102, 241, 0.08);
--shadow-lg: 0 10px 15px -3px rgba(99, 102, 241, 0.1), 0 4px 6px -4px rgba(99, 102, 241, 0.1);
}
/* ============================================================
* 🌙 暗色模式 CSS 变量
* 当 <html data-theme="dark"> 时自动切换
* 这些变量与 tokens.ts 中的暗色令牌同步
* ============================================================ */
[data-theme='dark'] {
/* 中性色 — 暗色(科技蓝紫) */
--color-text-base: #e8e8fa;
--color-text-secondary: #a0a8d8;
--color-text-tertiary: #6e78b0;
--color-bg-base: #080c24;
--color-bg-container: #0f1340;
--color-bg-layout: #050818;
--color-bg-elevated: #171b52;
--color-border-base: #2a3278;
--color-border-secondary: #1c2250;
/* 暗色模式下品牌色提亮以保持对比度 */
--color-primary: #818cf8;
--color-primary-light: #a5b4fc;
--color-primary-dark: #6366f1;
/* 阴影 — 暗色下用深投影 */
--shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.4);
}
/* ============================================================
* 蓝紫渐变工具类
* Tailwind v4 使用 @utility 定义自定义工具类
* ============================================================ */
@utility gradient-primary {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
}
@utility gradient-primary-hover {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
}
@utility gradient-primary-text {
background: linear-gradient(135deg, #4f46e5, #7c3aed);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-border {
border-image: linear-gradient(135deg, #4f46e5, #7c3aed) 1;
}
/* ============================================================
* 全局重置样式
* 仅放必要的重置,不堆积组件样式
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
font-family: var(--font-family-base);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
/* 亮/暗切换过渡 */
color-scheme: light dark;
}
body {
margin: 0;
padding: 0;
color: var(--color-text-base);
background-color: var(--color-bg-base);
min-height: 100vh;
transition:
background-color 0.15s linear,
color 0.15s linear;
}
/* ============================================================
* View Transitions API — GPU 合成器驱动的主题切换动画
* 替代 80+ 个独立 CSS 过渡,单次 cross-fade 消除 Electron 卡顿。
* 仅在 Chromium 111+Electron 28+)生效,其他浏览器回退 CSS 过渡。
* ============================================================ */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 0.15s;
animation-timing-function: linear;
}
/* ============================================================
* 主题切换过渡 — 精准覆盖大面积视觉容器,避免 * 全局选择器
* 在 Electron 中为数千个 DOM 节点同时启动过渡造成卡顿。
*
* 策略:
* 1. 容器/布局组件Card / Modal / Layout / Drawer 等)
* 2. 表格 & 列表Table / List / Timeline
* 3. 表单控件Input / Select / Picker
* 4. 导航 & 标签Menu / Tabs / Tag / Breadcrumb
* 5. 反馈 & 展示Alert / Empty / Result / Statistic / Skeleton
*
* 不覆盖交互组件自有 transitionButton / Switch / Slider 等
* 均使用 transition: all特异性更高不受影响
* ============================================================ */
/* —— 布局 & 容器 —— */
body,
.ant-layout,
.ant-layout-header,
.ant-layout-sider,
.ant-layout-content,
.ant-card,
.ant-card-head,
.ant-card-body,
.ant-card-actions,
.ant-modal-content,
.ant-modal-header,
.ant-modal-body,
.ant-modal-footer,
.ant-drawer-content,
.ant-drawer-header,
.ant-drawer-body,
.ant-drawer-footer,
/* —— 表格 & 列表 —— */
.ant-table,
.ant-table-thead > tr > th,
.ant-table-tbody > tr > td,
.ant-table-tbody > tr:hover > td,
.ant-list,
.ant-list-item,
.ant-timeline-item,
.ant-transfer-list,
.ant-tree-node-content-wrapper,
/* —— 表单控件 —— */
.ant-input,
.ant-input-affix-wrapper,
.ant-select-selector,
.ant-select-dropdown,
.ant-picker,
.ant-picker-input,
.ant-picker-dropdown,
.ant-picker-panel-container,
.ant-input-number,
.ant-input-number-input,
.ant-radio-button-wrapper,
.ant-radio-group,
.ant-checkbox-wrapper,
.ant-segmented,
.ant-segmented-item,
.ant-upload-drag,
.ant-upload-list-item,
/* —— 导航 & 标签 —— */
.ant-menu,
.ant-menu-item,
.ant-menu-submenu-title,
.ant-tabs-nav,
.ant-tabs-nav-list,
.ant-tabs-tab,
.ant-tabs-content,
.ant-tag,
.ant-breadcrumb,
.ant-pagination-item,
.ant-dropdown-menu,
/* —— 反馈 & 展示 —— */
.ant-alert,
.ant-empty,
.ant-result,
.ant-statistic,
.ant-statistic-content,
.ant-badge,
.ant-avatar,
.ant-divider,
.ant-skeleton,
.ant-skeleton-input,
.ant-skeleton-button,
.ant-notification-notice,
.ant-message-notice-content,
.ant-popover-inner,
.ant-tooltip-inner,
.ant-collapse,
.ant-collapse-item,
.ant-collapse-header,
.ant-collapse-content-box,
.ant-descriptions-item-container {
transition:
color 0.15s linear,
background-color 0.15s linear,
border-color 0.15s linear,
box-shadow 0.15s linear;
}
#root {
min-height: 100vh;
}
/* 滚动条美化 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #c7d2fe;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a5b4fc;
}
/* 暗色模式滚动条 */
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: #4338ca;
}
[data-theme='dark'] ::-webkit-scrollbar-thumb:hover {
background: #4f46e5;
}
/* 聚焦环 — 使用品牌色 */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 选中文本 — 使用品牌色 */
::selection {
background: rgba(99, 102, 241, 0.2);
color: var(--color-text-base);
}
[data-theme='dark'] ::selection {
background: rgba(129, 140, 248, 0.3);
color: var(--color-text-base);
}
/* 链接全局样式 */
a {
color: var(--color-primary);
text-decoration: none;
transition: color var(--transition-duration) var(--transition-timing);
}
a:hover {
color: var(--color-primary-light);
}
/* ============================================================
* 窗口拖拽区域(自定义导航栏)
*
* macOS hiddenInset 模式下,整个导航栏区域可拖拽移动窗口。
* .navbar-drag-region → 可拖拽(背景区域)
* .navbar-no-drag → 不可拖拽(按钮、链接等交互元素)
* ============================================================ */
.navbar-drag-region {
-webkit-app-region: drag;
user-select: none;
}
.navbar-no-drag {
-webkit-app-region: no-drag;
}
/* macOS 红绿灯按钮区域不遮挡内容 */
@supports (-webkit-app-region: drag) {
.navbar-drag-region {
-webkit-app-region: drag;
}
}

139
src/theme/tokens.css Normal file
View 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
View File

@@ -0,0 +1,126 @@
/* ============================================================
* 主题切换过渡 — View Transitions API + antd 容器精准覆盖
*
* 策略:
* Chromium/ElectronView Transitions API GPU cross-fade1 个动画)
* 其他浏览器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
*
* 不覆盖交互组件自有 transitionButton / 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;
}