fix(platform): 工作台界面 - 统一卡片、控件与弹窗视觉并清理重复样式
This commit is contained in:
11
AGENTS.md
11
AGENTS.md
@@ -123,7 +123,16 @@
|
||||
- 页面级分类导航至少 44px 高,并提供”图标 + 明确文字”入口和一致选中态;重要设置不能只藏在无文字图标中。
|
||||
- 可编辑的工作区、画布和对象名称常显铅笔图标并支持单击编辑;双击和右键只能作为快捷方式,不能作为唯一发现路径。
|
||||
- 不把页面大段说明文字当作”如何使用”的常驻教程;通过明确控件、空态、反馈和可发现的图标+文字表达功能。
|
||||
- **卡片、列表、交互动效遵循 [UI 设计系统规范](docs/ui-design-system.md)**:统一卡片表面色、边框、投影、hover状态(240ms/translateY -4px)、双主题均衡策略。改造卡片前必须先读该规范,确保新增样式符合设计token体系。
|
||||
- **卡片、列表、交互动效遵循 [UI 设计系统规范](docs/ui-design-system.md)**:统一卡片表面、状态描边和 hover(180ms/translateY -2px);普通卡片默认无边框、无 hairline、无投影。改造前先读规范并核对现有 token。
|
||||
|
||||
#### UI 修复防回归
|
||||
|
||||
- 主操作、普通选中、Checkbox/Radio 勾选和 Switch 是四种颜色角色;每种状态都必须为明暗主题成对定义背景与前景,禁止把 `primary` 实心色直接复用为普通选中态。
|
||||
- 持久切换按钮使用 `aria-pressed` 表达状态;`type="primary"` 只用于当前流程的主要命令,不用于表示“已选中”。
|
||||
- AntD Button、Switch、Checkbox、Radio、Segmented 的共性状态只在 `web/src/lib/app-theme.ts` 和全局兼容区维护;不得新增页面级 `.dark .ant-switch-*`、`.ant-checkbox-*` 或 Segmented 选中色补丁。组件级 CSS 只允许处理尺寸和布局。
|
||||
- 新增 CSS 前先搜索同名选择器和 token;回到唯一源规则修改,并删除被替代的末尾 override。新增 token 后必须确认定义真实存在且明暗主题均可解析,不能只在文档中声明。
|
||||
- 卡片层级通过 `--library-surface`、明度、间距和信息密度表达;禁止装饰性左侧竖条、默认 border、hairline、inset stroke、渐变光和卡片阴影。业务 selected、warning、error 等真实状态才可保留克制的语义描边或颜色。
|
||||
- 工作区 Modal 统一使用 `workspace-modal`,并按语义选择 `workspace-modal-compact`、默认 standard 或 `workspace-modal-wide`;素材选择统一复用 `AssetLibraryPickerModal`,页面不得复制按钮、弹窗宽度和素材网格样式。
|
||||
|
||||
### 6.2 空间、响应式和动效
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal } from "antd";
|
||||
import { Button, Modal } from "antd";
|
||||
import { Check, FileText, FolderOpen, Image as ImageIcon, LoaderCircle, Music2, Search, Upload, UserRound, Video } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
@@ -155,8 +155,7 @@ export function AssetLibraryPickerModal({
|
||||
onCancel={() => {
|
||||
if (!working) onClose();
|
||||
}}
|
||||
width="min(1040px, calc(100vw - 24px))"
|
||||
className="asset-library-picker-modal"
|
||||
className="workspace-modal workspace-modal-wide asset-library-picker-modal"
|
||||
styles={{ container: { padding: 0 }, body: { padding: 0 } }}
|
||||
>
|
||||
<div className="asset-picker-shell">
|
||||
@@ -168,7 +167,7 @@ export function AssetLibraryPickerModal({
|
||||
<div className="asset-picker-body">
|
||||
<nav className="asset-picker-categories" aria-label="素材分类">
|
||||
{categories.map((value) => (
|
||||
<button key={value} type="button" className={category === value ? "is-active" : ""} onClick={() => setCategory(value)}>
|
||||
<button key={value} type="button" className={category === value ? "is-active" : ""} aria-pressed={category === value} onClick={() => setCategory(value)}>
|
||||
<span>{categoryLabels[value] || "其他"}</span><em>{countFor(value)}</em>
|
||||
</button>
|
||||
))}
|
||||
@@ -195,10 +194,10 @@ export function AssetLibraryPickerModal({
|
||||
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
|
||||
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
|
||||
<div className="asset-picker-actions">
|
||||
<button type="button" onClick={onClose} disabled={working}>取消</button>
|
||||
<button type="button" className="is-primary" disabled={working || !selectedIds.length} onClick={() => void confirm()}>
|
||||
{working && !uploading ? <LoaderCircle className="animate-spin" /> : <Check />}{confirmLabel(selectedIds.length)}
|
||||
</button>
|
||||
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
|
||||
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
|
||||
{confirmLabel(selectedIds.length)}
|
||||
</Button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type CSSProperties, type ReactNode } from "react";
|
||||
import { Button, Checkbox, Modal, Space, Switch, Tag } from "antd";
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { Button, Checkbox, Modal, Space, Switch } from "antd";
|
||||
import { Ellipsis, Settings2, Type } from "lucide-react";
|
||||
|
||||
import { FloatingDock, type FloatingDockEntry } from "@/components/ui/aceternity/floating-dock";
|
||||
@@ -46,35 +46,34 @@ export function ImageToolSettingsModal({ open, tools, selectedIds, showLabels, o
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="workspace-modal workspace-modal-compact canvas-image-toolbar-settings-modal"
|
||||
title={<span className="inline-flex h-6 items-center gap-2 text-sm font-semibold"><Settings2 className="size-3.5" />自定义节点 Dock</span>}
|
||||
open={open}
|
||||
centered
|
||||
width={520}
|
||||
onCancel={onCancel}
|
||||
destroyOnHidden
|
||||
styles={{ body: { padding: 0 }, footer: { marginTop: 0 } }}
|
||||
footer={<Space size={6}><Button size="small" onClick={onCancel}>取消</Button><Button size="small" type="primary" onClick={onSave}>保存设置</Button></Space>}
|
||||
footer={<Space size={8}><Button onClick={onCancel}>取消</Button><Button type="primary" onClick={onSave}>保存设置</Button></Space>}
|
||||
>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-4" style={{ borderColor: theme.toolbar.border }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 px-4">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-md)]" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}><Type className="size-3.5" /></span>
|
||||
<span className="canvas-image-tool-heading-icon grid size-7 shrink-0 place-items-center rounded-[var(--r-md)]"><Type className="size-3.5" /></span>
|
||||
<span className="flex h-5 items-center text-xs font-medium leading-none">显示功能名</span>
|
||||
</span>
|
||||
<Switch size="small" checked={showLabels} onChange={onShowLabelsChange} aria-label="显示节点 Dock 功能名" />
|
||||
</div>
|
||||
<div className="relative grid h-[92px] place-items-center overflow-hidden border-b" style={{ background: theme.canvas.background, borderColor: theme.toolbar.border }}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(currentColor_1px,transparent_1px)] opacity-15 [background-size:18px_18px]" />
|
||||
<div className="relative grid h-[92px] place-items-center overflow-hidden" style={{ background: theme.canvas.background }}>
|
||||
<div className="thin-scrollbar relative flex max-w-full overflow-x-auto px-4 py-3">
|
||||
<FloatingDock items={previewItems} size="compact" showLabels={showLabels} ariaLabel="图片节点工具预览" className="shrink-0" style={canvasDockStyle(theme, theme.node.text)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3">
|
||||
<div className="mb-2 flex h-5 items-center justify-between"><span className="text-xs font-semibold">快捷工具</span><Tag className="m-0 leading-5 text-[var(--fs-tiny)]" style={{ background: theme.accent.primarySoft, borderColor: theme.spatial.glowStrong, color: theme.accent.primary }}>{selectedTools.length}/{maxSelected}</Tag></div>
|
||||
<div className="mb-2 flex h-5 items-center justify-between"><span className="text-xs font-semibold">快捷工具</span><span className="canvas-image-tool-count">{selectedTools.length}/{maxSelected}</span></div>
|
||||
<Checkbox.Group value={selectedIds} className="grid w-full grid-cols-2 gap-1 sm:grid-cols-4" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
|
||||
{tools.map((tool) => (
|
||||
<label key={tool.id} className="flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-[var(--r-md)] border px-1.5 transition-colors" style={{ background: selected.has(tool.id) ? theme.accent.primarySoft : "transparent", borderColor: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.border, color: selected.has(tool.id) ? theme.accent.primary : theme.node.text }}>
|
||||
<Checkbox className="canvas-image-tool-checkbox shrink-0" style={{ "--tool-accent": theme.accent.primary } as CSSProperties} value={tool.id} disabled={!selected.has(tool.id) && selectedTools.length >= maxSelected} />
|
||||
<span className="grid size-5 shrink-0 place-items-center rounded-[var(--r-sm)] [&_svg]:size-3" style={{ background: selected.has(tool.id) ? theme.accent.primary : theme.toolbar.itemHover, color: selected.has(tool.id) ? theme.accent.onPrimary : theme.node.muted }}>{tool.icon}</span>
|
||||
<label key={tool.id} className={`canvas-image-tool-option flex h-8 min-w-0 cursor-pointer items-center gap-1 rounded-[var(--r-md)] px-1.5 transition-colors${selected.has(tool.id) ? " is-selected" : ""}`}>
|
||||
<Checkbox className="shrink-0" value={tool.id} disabled={!selected.has(tool.id) && selectedTools.length >= maxSelected} />
|
||||
<span className="canvas-image-tool-option-icon grid size-5 shrink-0 place-items-center rounded-[var(--r-sm)] [&_svg]:size-3">{tool.icon}</span>
|
||||
<span className="min-w-0 truncate text-[var(--fs-tiny)] font-medium leading-none">{tool.label}</span>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -99,9 +99,9 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
|
||||
<Modal className="workspace-modal workspace-modal-wide" title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} centered destroyOnHidden>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-lg bg-surface-active p-0">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||
{image ? (
|
||||
@@ -129,10 +129,10 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
<Button type={mode === "paint" ? "primary" : "default"} aria-pressed={mode === "paint"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} aria-pressed={mode === "erase"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
</Button>
|
||||
</div>
|
||||
@@ -157,7 +157,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{error ? <div className="text-xs font-medium text-[#ef4444]">{error}</div> : null}
|
||||
{error ? <div className="text-xs font-medium text-destructive">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
|
||||
@@ -492,12 +492,11 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className="canvas-node-info-modal"
|
||||
className="workspace-modal canvas-node-info-modal"
|
||||
title={title}
|
||||
open={open && Boolean(node)}
|
||||
centered
|
||||
footer={null}
|
||||
width={760}
|
||||
onCancel={onClose}
|
||||
styles={{ body: { paddingTop: 4 } }}
|
||||
>
|
||||
@@ -522,7 +521,7 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
<div className="canvas-node-inspector-options">
|
||||
{assetCategoryOptions.map((option) => {
|
||||
const active = assetCategory === option.value;
|
||||
return <button key={option.value} type="button" disabled={readOnly} onClick={() => saveAssetCategory(option.value)} className={active ? "is-active" : ""} style={{ "--inspector-accent": theme.accent.primary, "--inspector-accent-soft": theme.accent.primarySoft } as CSSProperties}>{option.label}</button>;
|
||||
return <button key={option.value} type="button" disabled={readOnly} aria-pressed={active} onClick={() => saveAssetCategory(option.value)} className={active ? "is-active" : ""}>{option.label}</button>;
|
||||
})}
|
||||
</div>
|
||||
<p className="canvas-node-inspector-help">生成后会按此分类进入项目资产;角色、场景和画风工作流会自动预填。</p>
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
import type { ThemeConfig } from "antd";
|
||||
import { theme as antdTheme } from "antd";
|
||||
|
||||
const neutral = {
|
||||
// 主操作、普通选择和开关是不同交互语义,必须各自维护成对的背景/前景色。
|
||||
const controlTheme = {
|
||||
light: {
|
||||
primary: "#171717",
|
||||
primaryHover: "#000000",
|
||||
primaryText: "#ffffff",
|
||||
solidBg: "#171717",
|
||||
solidHoverBg: "#303030",
|
||||
solidActiveBg: "#404040",
|
||||
solidFg: "#ffffff",
|
||||
selectedBg: "rgba(17, 17, 17, 0.09)",
|
||||
selectedHoverBg: "rgba(17, 17, 17, 0.13)",
|
||||
selectedActiveBg: "rgba(17, 17, 17, 0.16)",
|
||||
selectedFg: "#171717",
|
||||
controlSurface: "#ffffff",
|
||||
controlBorder: "rgba(17, 17, 17, 0.18)",
|
||||
controlDisabledBg: "rgba(17, 17, 17, 0.05)",
|
||||
controlDisabledFg: "rgba(17, 17, 17, 0.36)",
|
||||
controlFocus: "rgba(17, 17, 17, 0.24)",
|
||||
switchOffBg: "rgba(17, 17, 17, 0.22)",
|
||||
switchOffHoverBg: "rgba(17, 17, 17, 0.3)",
|
||||
switchOffHandle: "#ffffff",
|
||||
menuBg: "#f5f5f5",
|
||||
menuText: "#171717",
|
||||
selectActiveBg: "rgba(17, 17, 17, 0.035)",
|
||||
@@ -15,9 +29,22 @@ const neutral = {
|
||||
tableSelectedHoverBg: "rgba(17, 17, 17, 0.08)",
|
||||
},
|
||||
dark: {
|
||||
primary: "#fafafa",
|
||||
primaryHover: "#ffffff",
|
||||
primaryText: "#171717",
|
||||
solidBg: "#f5f5f5",
|
||||
solidHoverBg: "#ffffff",
|
||||
solidActiveBg: "#e5e5e5",
|
||||
solidFg: "#171717",
|
||||
selectedBg: "rgba(255, 255, 255, 0.12)",
|
||||
selectedHoverBg: "rgba(255, 255, 255, 0.16)",
|
||||
selectedActiveBg: "rgba(255, 255, 255, 0.2)",
|
||||
selectedFg: "#f5f5f5",
|
||||
controlSurface: "rgba(255, 255, 255, 0.035)",
|
||||
controlBorder: "rgba(255, 255, 255, 0.24)",
|
||||
controlDisabledBg: "rgba(255, 255, 255, 0.06)",
|
||||
controlDisabledFg: "rgba(255, 255, 255, 0.38)",
|
||||
controlFocus: "rgba(255, 255, 255, 0.24)",
|
||||
switchOffBg: "rgba(255, 255, 255, 0.22)",
|
||||
switchOffHoverBg: "rgba(255, 255, 255, 0.3)",
|
||||
switchOffHandle: "#f5f5f5",
|
||||
menuBg: "#262626",
|
||||
menuText: "#fafafa",
|
||||
selectActiveBg: "rgba(255, 255, 255, 0.055)",
|
||||
@@ -29,35 +56,27 @@ const neutral = {
|
||||
};
|
||||
|
||||
export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
const color = dark ? neutral.dark : neutral.light;
|
||||
const color = dark ? controlTheme.dark : controlTheme.light;
|
||||
const elevatedBackground = dark ? "rgba(31, 31, 32, 0.96)" : "rgba(255, 255, 255, 0.96)";
|
||||
const subtleBackground = dark ? "rgba(255, 255, 255, 0.055)" : "rgba(17, 17, 17, 0.035)";
|
||||
const interactiveBorder = dark ? "rgba(255, 255, 255, 0.18)" : "rgba(17, 17, 17, 0.18)";
|
||||
// 黑白主题使用边框表达焦点,避免输入控件周围出现蓝紫色光圈。
|
||||
const focusShadow = "none";
|
||||
// 暗色界面的黑白主按钮与选择控件分色,避免开关轨道、勾选符号和滑块融成一片。
|
||||
const selectionControl = dark
|
||||
? {
|
||||
active: "#ffffff",
|
||||
border: "rgba(255, 255, 255, 0.3)",
|
||||
disabledBackground: "rgba(255, 255, 255, 0.06)",
|
||||
focus: "rgba(255, 255, 255, 0.22)",
|
||||
hover: "#e5e5e5",
|
||||
primary: "#f5f5f5",
|
||||
surface: "rgba(255, 255, 255, 0.035)",
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
cssVar: { key: dark ? "infinite-canvas-dark" : "infinite-canvas-light" },
|
||||
token: {
|
||||
colorPrimary: color.primary,
|
||||
colorInfo: color.primary,
|
||||
colorLink: color.primary,
|
||||
colorLinkHover: color.primaryHover,
|
||||
colorLinkActive: color.primary,
|
||||
colorTextLightSolid: color.primaryText,
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryBg: color.selectedBg,
|
||||
colorPrimaryBgHover: color.selectedHoverBg,
|
||||
colorInfo: color.solidBg,
|
||||
colorLink: color.solidBg,
|
||||
colorLinkHover: color.solidHoverBg,
|
||||
colorLinkActive: color.solidActiveBg,
|
||||
colorTextLightSolid: color.solidFg,
|
||||
colorBgElevated: elevatedBackground,
|
||||
colorBorderSecondary: dark ? "rgba(255, 255, 255, 0.1)" : "rgba(17, 17, 17, 0.09)",
|
||||
boxShadowSecondary: dark ? "0 24px 72px rgba(0, 0, 0, 0.48)" : "0 22px 64px rgba(15, 23, 42, 0.14)",
|
||||
@@ -80,28 +99,19 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
paddingInline: 14,
|
||||
paddingInlineLG: 16,
|
||||
paddingInlineSM: 10,
|
||||
...(dark
|
||||
? {
|
||||
colorPrimary: "#f5f5f4",
|
||||
colorPrimaryHover: "#ffffff",
|
||||
colorPrimaryActive: "#e7e5e4",
|
||||
primaryColor: "#171717",
|
||||
defaultBg: "#262626",
|
||||
defaultColor: "#f5f5f4",
|
||||
defaultBorderColor: "#404040",
|
||||
defaultHoverBg: "#303030",
|
||||
defaultHoverColor: "#ffffff",
|
||||
defaultHoverBorderColor: "#525252",
|
||||
defaultActiveBg: "#1f1f1f",
|
||||
defaultActiveColor: "#ffffff",
|
||||
defaultActiveBorderColor: "#525252",
|
||||
}
|
||||
: {
|
||||
/* 浅色模式:primary 近黑(#171717),antd 默认 hover 加暗到 #000000 几乎不可见。
|
||||
改为提亮 hover/active,让交互反馈可感知(审计 #121)*/
|
||||
colorPrimaryHover: "#404040",
|
||||
colorPrimaryActive: "#525252",
|
||||
}),
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
primaryColor: color.solidFg,
|
||||
defaultBg: color.controlSurface,
|
||||
defaultColor: color.selectedFg,
|
||||
defaultBorderColor: color.controlBorder,
|
||||
defaultHoverBg: color.selectedBg,
|
||||
defaultHoverColor: color.selectedFg,
|
||||
defaultHoverBorderColor: color.controlBorder,
|
||||
defaultActiveBg: color.selectedHoverBg,
|
||||
defaultActiveColor: color.selectedFg,
|
||||
defaultActiveBorderColor: color.controlBorder,
|
||||
},
|
||||
Input: {
|
||||
paddingInline: 11,
|
||||
@@ -119,60 +129,51 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
activeShadow: focusShadow,
|
||||
},
|
||||
Switch: {
|
||||
handleBg: dark ? "#fafafa" : "#ffffff",
|
||||
handleBg: color.switchOffHandle,
|
||||
handleShadow: dark ? "0 1px 4px rgba(0, 0, 0, 0.42)" : "0 1px 2px rgba(0, 0, 0, 0.2)",
|
||||
...(selectionControl
|
||||
? {
|
||||
colorPrimary: selectionControl.primary,
|
||||
colorPrimaryActive: selectionControl.active,
|
||||
colorPrimaryHover: selectionControl.hover,
|
||||
colorTextQuaternary: "rgba(255, 255, 255, 0.16)",
|
||||
colorTextTertiary: "rgba(255, 255, 255, 0.24)",
|
||||
controlOutline: selectionControl.focus,
|
||||
}
|
||||
: {}),
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
colorTextQuaternary: color.switchOffBg,
|
||||
colorTextTertiary: color.switchOffHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
},
|
||||
Checkbox: {
|
||||
...(selectionControl
|
||||
? {
|
||||
colorBgContainer: selectionControl.surface,
|
||||
colorBgContainerDisabled: selectionControl.disabledBackground,
|
||||
colorBorder: selectionControl.border,
|
||||
colorPrimary: selectionControl.primary,
|
||||
colorPrimaryActive: selectionControl.active,
|
||||
colorPrimaryHover: selectionControl.hover,
|
||||
controlOutline: selectionControl.focus,
|
||||
}
|
||||
: {}),
|
||||
colorBgContainer: color.controlSurface,
|
||||
colorBgContainerDisabled: color.controlDisabledBg,
|
||||
colorBorder: color.controlBorder,
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
},
|
||||
Radio: {
|
||||
radioSize: 16,
|
||||
dotSize: 6,
|
||||
...(selectionControl
|
||||
? {
|
||||
buttonBg: selectionControl.surface,
|
||||
buttonCheckedBg: color.selectSelectedBg,
|
||||
buttonSolidCheckedActiveBg: selectionControl.active,
|
||||
buttonSolidCheckedBg: selectionControl.primary,
|
||||
buttonSolidCheckedHoverBg: selectionControl.hover,
|
||||
colorBgContainer: selectionControl.surface,
|
||||
colorBgContainerDisabled: selectionControl.disabledBackground,
|
||||
colorBorder: selectionControl.border,
|
||||
colorPrimary: selectionControl.primary,
|
||||
colorPrimaryActive: selectionControl.active,
|
||||
colorPrimaryHover: selectionControl.hover,
|
||||
controlOutline: selectionControl.focus,
|
||||
}
|
||||
: {}),
|
||||
buttonBg: color.controlSurface,
|
||||
buttonCheckedBg: color.selectedBg,
|
||||
buttonCheckedBgDisabled: color.controlDisabledBg,
|
||||
buttonCheckedColorDisabled: color.controlDisabledFg,
|
||||
buttonSolidCheckedColor: color.solidFg,
|
||||
buttonSolidCheckedActiveBg: color.solidActiveBg,
|
||||
buttonSolidCheckedBg: color.solidBg,
|
||||
buttonSolidCheckedHoverBg: color.solidHoverBg,
|
||||
colorBgContainer: color.controlSurface,
|
||||
colorBgContainerDisabled: color.controlDisabledBg,
|
||||
colorBorder: color.controlBorder,
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryActive: color.solidActiveBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
controlOutline: color.controlFocus,
|
||||
},
|
||||
Menu: {
|
||||
itemActiveBg: color.menuBg,
|
||||
itemHoverBg: color.menuBg,
|
||||
itemSelectedBg: color.menuBg,
|
||||
itemSelectedColor: color.menuText,
|
||||
darkItemHoverBg: neutral.dark.menuBg,
|
||||
darkItemSelectedBg: neutral.dark.menuBg,
|
||||
darkItemSelectedColor: neutral.dark.menuText,
|
||||
darkItemHoverBg: controlTheme.dark.menuBg,
|
||||
darkItemSelectedBg: controlTheme.dark.menuBg,
|
||||
darkItemSelectedColor: controlTheme.dark.menuText,
|
||||
},
|
||||
Select: {
|
||||
selectorBg: elevatedBackground,
|
||||
@@ -200,16 +201,19 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
Pagination: {
|
||||
itemBg: "transparent",
|
||||
itemLinkBg: "transparent",
|
||||
itemActiveBg: dark ? "#fafafa" : "#171717",
|
||||
itemActiveColor: dark ? "#171717" : "#ffffff",
|
||||
itemActiveColorHover: dark ? "#171717" : "#ffffff",
|
||||
itemActiveBg: color.solidBg,
|
||||
itemActiveColor: color.solidFg,
|
||||
itemActiveColorHover: color.solidFg,
|
||||
},
|
||||
Segmented: {
|
||||
trackBg: subtleBackground,
|
||||
trackPadding: 3,
|
||||
itemSelectedBg: elevatedBackground,
|
||||
itemSelectedColor: dark ? "#fafafa" : "#171717",
|
||||
itemHoverBg: dark ? "rgba(255, 255, 255, 0.07)" : "rgba(17, 17, 17, 0.055)",
|
||||
itemColor: color.controlDisabledFg,
|
||||
itemHoverColor: color.selectedFg,
|
||||
itemHoverBg: color.selectedBg,
|
||||
itemActiveBg: color.selectedActiveBg,
|
||||
itemSelectedBg: color.selectedBg,
|
||||
itemSelectedColor: color.selectedFg,
|
||||
},
|
||||
Modal: {
|
||||
headerBg: "transparent",
|
||||
|
||||
@@ -293,7 +293,7 @@ export default function AssetsPage() {
|
||||
|
||||
<div className="canvas-library-frame assets-library-frame">
|
||||
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto border-b border-border/70 py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:border-b-0 lg:border-r lg:pr-3">
|
||||
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
|
||||
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
|
||||
<AssetFilterGroup title="业务分类" options={categoryOptions} value={categoryFilter} counts={categoryCounts} onChange={(value) => { setCategoryFilter(value as AssetCategory | "all"); setPage(1); }} className="lg:mt-5" />
|
||||
</aside>
|
||||
@@ -327,7 +327,7 @@ export default function AssetsPage() {
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
|
||||
<Modal className="library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -387,7 +387,7 @@ export default function AssetsPage() {
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
<div className="lg:border-l lg:border-border lg:pl-4">
|
||||
<div className="lg:pl-4">
|
||||
<Typography.Text strong className="text-xs">预览</Typography.Text>
|
||||
<div className="mt-2 overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">
|
||||
{coverUrl || imageDraft?.dataUrl ? (
|
||||
@@ -395,7 +395,7 @@ export default function AssetsPage() {
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||||
)}
|
||||
<div className="border-x border-b border-border bg-background p-3">
|
||||
<div className="bg-background p-3">
|
||||
<Typography.Text strong ellipsis className="block">
|
||||
{title || "未命名素材"}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ProjectDetailPage() {
|
||||
return (
|
||||
<WorkspacePage className="project-workbench-page !overflow-hidden" fluid>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<header className="project-workbench-header shrink-0 border-b border-border/65 bg-workspace-glass px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<header className="project-workbench-header shrink-0 bg-workspace-glass px-3 py-2 backdrop-blur-md sm:px-4 lg:px-5 lg:py-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 lg:min-h-16 lg:flex-nowrap">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 lg:w-[250px] lg:flex-none xl:w-[290px]">
|
||||
<button type="button" onClick={() => navigate("/projects")} className="grid size-9 shrink-0 place-items-center rounded-md text-foreground/42 transition-colors hover:bg-surface-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-label="返回项目" title="返回项目"><ArrowLeft className="size-4" /></button>
|
||||
@@ -81,7 +81,7 @@ export default function ProjectDetailPage() {
|
||||
<span className="hidden shrink-0 text-[var(--fs-tiny)] text-foreground/42 sm:inline">{detail.data.project.status === "archived" ? "已归档" : "进行中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:border-l lg:border-border/55 lg:pl-3" aria-label="项目导航">
|
||||
<nav className="thin-scrollbar order-last mt-1 flex h-11 w-full min-w-0 items-center gap-0.5 overflow-x-auto lg:order-none lg:mt-0 lg:h-16 lg:flex-1 lg:pl-3" aria-label="项目导航">
|
||||
{views.map((item) => { const Icon = item.icon; const active = item.key === activeView; const href = item.key === "chapters" ? chapterHref : `/projects/${projectId}/${item.key}`; return <Link key={item.key} to={href} className={`relative flex h-11 shrink-0 items-center gap-2 rounded-md px-2.5 text-[var(--fs-body)] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:px-3 ${active ? "bg-[var(--workspace-accent-soft)] font-medium text-foreground lg:after:absolute lg:after:inset-x-3 lg:after:bottom-0 lg:after:h-0.5 lg:after:rounded-full lg:after:bg-[var(--workspace-accent)]" : "text-foreground/52 hover:bg-surface-hover hover:text-foreground"}`} aria-current={active ? "page" : undefined}><Icon className={`size-4 shrink-0 ${active ? "text-[var(--workspace-accent)]" : "text-foreground/45"}`} /><span className="sm:hidden">{item.shortLabel}</span><span className="hidden sm:inline">{item.label}</span></Link>; })}
|
||||
</nav>
|
||||
<Tooltip title={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><Button size="small" className="!h-9 !shrink-0 !px-2 sm:!px-3" icon={<Plus className="size-4" />} onClick={createCanvas} aria-label={activeView === "chapters" && detail.data.units.length ? "新建当前章节画布" : "新建项目画布"}><span className="hidden sm:inline">新建画布</span></Button></Tooltip>
|
||||
|
||||
@@ -180,7 +180,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<header className="flex min-h-[72px] flex-col gap-4 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<header className="flex min-h-[72px] flex-col gap-4 pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2.5">
|
||||
@@ -190,7 +190,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
<div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-[var(--fs-label)] text-foreground/48">
|
||||
<span className="inline-flex items-center gap-1.5"><UserRound className="size-3.5" />{characterAssets.length} 个角色</span>
|
||||
<span className="inline-flex items-center gap-1.5"><Box className="size-3.5" />{mediaAssetCount} 项媒体</span>
|
||||
{pendingCandidates.length ? <span className="inline-flex items-center gap-1.5 text-[var(--workspace-accent)]"><Sparkles className="size-3.5" />{pendingCandidates.length} 个待确认</span> : null}
|
||||
{pendingCandidates.length ? <span className="inline-flex items-center gap-1.5 text-foreground/55"><Sparkles className="size-3.5" />{pendingCandidates.length} 个待确认</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,19 +200,19 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
</div>
|
||||
</header>
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-[156px_minmax(0,1fr)]">
|
||||
<nav className="space-y-0.5 border-r border-border/70 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<nav className="space-y-0.5 pr-2" aria-label="资产分类">{categoryCounts.map((item) => <button key={item.value} type="button" onClick={() => setCategory(item.value)} className={`flex h-11 w-full items-center justify-between rounded-md px-2 text-left text-xs ${category === item.value ? "bg-surface-active font-medium" : "text-foreground/55 hover:bg-surface-hover"}`}><span>{item.value === "all" ? "全部资产" : categoryLabels[item.value]}</span><span className="min-w-5 rounded bg-foreground/[.05] px-1 text-center text-[var(--fs-tiny)] tabular-nums">{item.count}</span></button>)}</nav>
|
||||
<div className="min-w-0">
|
||||
{(category === "all" || category === "character") && pendingCandidates.length ? (
|
||||
<section className="mb-4" aria-label="待确认角色">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-[var(--workspace-accent)]" />剧情识别出的角色</div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium"><Sparkles className="size-3.5 text-foreground/50" />剧情识别出的角色</div>
|
||||
<span className="text-[var(--fs-tiny)] tabular-nums text-foreground/42">剩余 {pendingCandidates.length} 个待确认</span>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{pendingCandidates.map((candidate) => {
|
||||
const confirming = confirmingCandidateId === candidate.id;
|
||||
return (
|
||||
<article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg border border-dashed border-border p-3">
|
||||
<article key={candidate.id} className="flex min-h-28 items-center gap-3 rounded-lg bg-surface-active p-3">
|
||||
<span className="grid size-12 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/25"><UserRound className="size-5" /></span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold">{candidate.name}</div>
|
||||
@@ -267,7 +267,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
|
||||
await bindImagesMutation.mutateAsync(ids[0]);
|
||||
}}
|
||||
/>
|
||||
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
|
||||
<Modal className="workspace-modal workspace-modal-compact" title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between pt-1"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -276,8 +276,8 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
const field = (key: CharacterFormKey) => characterFields.find(([name]) => name === key)!;
|
||||
const textArea = (key: CharacterFormKey, rows = 3) => <Form.Item name={key} label={field(key)[1]}><Input.TextArea rows={rows} placeholder={key === "appearance" ? "先描述用户能看到的稳定特征…" : undefined} /></Form.Item>;
|
||||
const input = (key: CharacterFormKey) => <Form.Item name={key} label={field(key)[1]}><Input /></Form.Item>;
|
||||
return <Modal title={null} open={open} forceRender width={860} okText={editing ? "保存角色设定" : "创建角色卡"} cancelText="取消" okButtonProps={{ loading }} onCancel={onClose} onOk={onSave} styles={{ body: { paddingTop: 0 } }}>
|
||||
<div className="mb-1 border-b border-border/70 pb-4"><div className="text-[var(--fs-label)] font-medium text-[var(--workspace-accent)]">角色设定</div><h2 className="mt-1 text-xl font-semibold">{editing ? "调整角色设定" : "建立一张角色卡"}</h2></div>
|
||||
return <Modal className="workspace-modal workspace-modal-wide library-modal" title={null} open={open} forceRender okText={editing ? "保存角色设定" : "创建角色卡"} cancelText="取消" okButtonProps={{ loading }} onCancel={onClose} onOk={onSave} styles={{ body: { paddingTop: 0 } }}>
|
||||
<div className="mb-1 pb-4"><div className="text-[var(--fs-label)] font-medium text-foreground/52">角色设定</div><h2 className="mt-1 text-xl font-semibold">{editing ? "调整角色设定" : "建立一张角色卡"}</h2></div>
|
||||
<Form form={form} layout="vertical" requiredMark={false} className="pt-2"><Form.Item name="name" label="角色名称" rules={[{ required: true, message: "请输入角色名称" }]}><Input size="large" placeholder="例如:林默" /></Form.Item><Tabs items={[{ key: "identity", label: "身份与外观", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{input("role")}{input("aliases")}{textArea("appearance", 4)}{input("physique")}{input("clothing")}{input("props")}{textArea("consistencyPrompt", 4)}{textArea("multiViewPrompt", 3)}</div> }, { key: "performance", label: "表演与声音", forceRender: true, children: <div className="grid gap-x-5 sm:grid-cols-2">{textArea("personality", 4)}{input("voiceLanguage")}{input("voiceAge")}{input("voiceTimbre")}</div> }]} /></Form>
|
||||
</Modal>;
|
||||
}
|
||||
@@ -285,7 +285,7 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
|
||||
type CharacterFormKey = (typeof characterFields)[number][0];
|
||||
|
||||
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-surface-hover"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-foreground/42" /></div><div className="mt-2 flex items-center justify-between gap-2 pt-1"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
|
||||
}
|
||||
|
||||
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
|
||||
@@ -293,7 +293,7 @@ function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, o
|
||||
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover);
|
||||
const previewClass = asset?.category === "character" ? "is-character" : personalAsset?.kind === "video" ? "is-video" : personalAsset?.kind === "audio" ? "is-audio" : personalAsset?.kind === "text" ? "is-text" : "is-image";
|
||||
return (
|
||||
<Modal className="library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} width={840} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
|
||||
{asset ? <div className="project-asset-preview-layout">
|
||||
<div className={`project-asset-preview-stage ${previewClass}`}>
|
||||
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
|
||||
|
||||
@@ -43,9 +43,9 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="flex items-end justify-between gap-3 border-b border-border/70 pb-3"><div><h2 className="text-lg font-semibold">项目设置</h2><p className="mt-1 text-xs text-foreground/48">基础信息、项目画风与归档管理</p></div><Button type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!dirty || !name.trim()} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存设置" : "已保存"}</Button></header>
|
||||
<header className="flex items-end justify-between gap-3 pb-3"><div><h2 className="text-lg font-semibold">项目设置</h2><p className="mt-1 text-xs text-foreground/48">基础信息、项目画风与归档管理</p></div><Button type={dirty ? "primary" : "default"} icon={dirty ? <Save className="size-3.5" /> : <Check className="size-3.5" />} disabled={!dirty || !name.trim()} loading={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{dirty ? "保存设置" : "已保存"}</Button></header>
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<section className="py-5">
|
||||
<h3 className="mb-3 text-sm font-semibold">基础设置</h3>
|
||||
<div className="grid gap-x-4 gap-y-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Field label="项目名称" className="xl:col-span-2"><Input value={name} onChange={(event) => setName(event.target.value)} /></Field>
|
||||
@@ -55,29 +55,29 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border/70 py-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">项目保存当前版本快照;修改“我的风格”不会自动改写历史项目</p></div>{styleProfile ? <span className="text-[var(--fs-label)] text-[var(--workspace-accent)]">{styleProfile.source === "user" ? "来自我的风格" : styleProfile.source === "external" ? "外部导入" : "系统预设"}</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="flex flex-col gap-3 border-y border-border/70 py-3 lg:flex-row lg:items-center">
|
||||
<section className="py-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3"><div><h3 className="text-sm font-semibold">项目画风</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/45">项目保存当前版本快照;修改“我的风格”不会自动改写历史项目</p></div>{styleProfile ? <span className="text-[var(--fs-label)] text-foreground/52">{styleProfile.source === "user" ? "来自我的风格" : styleProfile.source === "external" ? "外部导入" : "系统预设"}</span> : <span className="text-[var(--fs-label)] text-foreground/40">未设置</span>}</div>
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-surface-active p-3 lg:flex-row lg:items-center">
|
||||
{selectedStyle ? <img src={selectedStyle.imageUrl} width="160" height="90" alt={`${selectedStyle.title}画风示意`} className="aspect-video w-40 shrink-0 rounded-md object-cover" /> : <span className="grid aspect-video w-40 shrink-0 place-items-center rounded-md bg-foreground/5 text-foreground/35"><Palette className="size-5" /></span>}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{styleProfile?.title || selectedStyle?.title || "尚未设置项目画风"}</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs leading-5 text-foreground/48">{styleProfile?.description || selectedStyle?.description || "从系统风格开始,或创建可自由编辑的项目视觉规范。"}</p>
|
||||
{styleProfile ? <div className="mt-2 flex flex-wrap gap-1">{styleProfile.tags.map((tag) => <span key={tag} className="rounded bg-foreground/10 px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/55">{tag}</span>)}</div> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2"><Button icon={<Eye className="size-3.5" />} disabled={!selectedStyle} onClick={() => setStyleDetail(selectedStyle || null)}>查看规范</Button><Button type={styleProfile ? "primary" : "default"} icon={<Pencil className="size-3.5" />} disabled={!styleProfile} onClick={() => { setStyleEditorRequested(true); setStylePickerOpen(true); }}>编辑画风</Button><Button icon={<Palette className="size-3.5" />} onClick={() => { setStyleEditorRequested(false); setStylePickerOpen(true); }}>{selectedStyle ? "更换画风" : "选择画风"}</Button></div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2"><Button icon={<Eye className="size-3.5" />} disabled={!selectedStyle} onClick={() => setStyleDetail(selectedStyle || null)}>查看规范</Button><Button icon={<Pencil className="size-3.5" />} disabled={!styleProfile} onClick={() => { setStyleEditorRequested(true); setStylePickerOpen(true); }}>编辑画风</Button><Button icon={<Palette className="size-3.5" />} onClick={() => { setStyleEditorRequested(false); setStylePickerOpen(true); }}>{selectedStyle ? "更换画风" : "选择画风"}</Button></div>
|
||||
</div>
|
||||
{styleProfile ? <div className="grid grid-cols-2 border-b border-border text-xs sm:grid-cols-5"><StyleMetric label="执行策略" value={styleProfile.executionPolicy === "strict-assets" ? "严格校验" : "兼容降级"} /><StyleMetric label="绑定资产" value={`${styleProfile.assets.length} 个`} /><StyleMetric label="已启用" value={`${enabledStyleAssets.length} 个`} /><StyleMetric label="图片执行" value={styleExecutionStatusLabel(styleExecutionPlans?.image.status)} /><StyleMetric label="视频执行" value={styleExecutionStatusLabel(styleExecutionPlans?.video.status)} /></div> : null}
|
||||
{styleExecutionPlans && (styleExecutionPlans.image.warnings.length || styleExecutionPlans.video.warnings.length) ? <div className="grid gap-1 border-b border-border px-3 py-2 text-[var(--fs-label)] leading-5 text-amber-600 dark:text-amber-400">{styleExecutionPlans.image.warnings.length ? <p>图片:{styleExecutionPlans.image.warnings.join(";")}</p> : null}{styleExecutionPlans.video.warnings.length ? <p>视频:{styleExecutionPlans.video.warnings.join(";")}</p> : null}</div> : null}
|
||||
{styleProfile ? <div className="mt-2 grid grid-cols-2 gap-2 text-xs sm:grid-cols-5"><StyleMetric label="执行策略" value={styleProfile.executionPolicy === "strict-assets" ? "严格校验" : "兼容降级"} /><StyleMetric label="绑定资产" value={`${styleProfile.assets.length} 个`} /><StyleMetric label="已启用" value={`${enabledStyleAssets.length} 个`} /><StyleMetric label="图片执行" value={styleExecutionStatusLabel(styleExecutionPlans?.image.status)} /><StyleMetric label="视频执行" value={styleExecutionStatusLabel(styleExecutionPlans?.video.status)} /></div> : null}
|
||||
{styleExecutionPlans && (styleExecutionPlans.image.warnings.length || styleExecutionPlans.video.warnings.length) ? <div className="mt-2 grid gap-1 rounded-md bg-amber-500/5 px-3 py-2 text-[var(--fs-label)] leading-5 text-amber-600 dark:text-amber-400">{styleExecutionPlans.image.warnings.length ? <p>图片:{styleExecutionPlans.image.warnings.join(";")}</p> : null}{styleExecutionPlans.video.warnings.length ? <p>视频:{styleExecutionPlans.video.warnings.join(";")}</p> : null}</div> : null}
|
||||
</section>
|
||||
|
||||
<section className="py-4">
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-red-500/20 bg-red-500/5 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-3 rounded-lg bg-red-500/5 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5"><span className="grid size-7 shrink-0 place-items-center rounded bg-red-500/10 text-red-500"><Archive className="size-3.5" /></span><div className="min-w-0"><h3 className="text-sm font-medium">{project.status === "archived" ? "恢复项目" : "归档项目"}</h3><p className="mt-0.5 text-[var(--fs-label)] text-foreground/48">{project.status === "archived" ? "恢复后可继续创建章节、画布和生成任务" : "保留全部章节、画布和资产,停止项目内新建与生成"}</p></div></div>
|
||||
<Button size="small" danger={project.status !== "archived"} icon={project.status === "archived" ? <Check className="size-3.5" /> : <ShieldAlert className="size-3.5" />} onClick={() => setArchiveOpen(true)}>{project.status === "archived" ? "恢复项目" : "归档项目"}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Modal title={project.status === "archived" ? "恢复项目" : "归档项目"} open={archiveOpen} okText={project.status === "archived" ? "确认恢复" : "确认归档"} cancelText="取消" okButtonProps={{ danger: project.status !== "archived", loading: archiveMutation.isPending }} onCancel={() => setArchiveOpen(false)} onOk={() => archiveMutation.mutate()} width={440} styles={{ body: { paddingTop: 12 } }}><p className="m-0 text-sm leading-6 text-foreground/65">{project.status === "archived" ? "恢复后项目会重新进入可编辑状态。" : "归档不会删除章节、画布或资产,画布文档仍可在创作画布中打开。"}</p></Modal>
|
||||
<Modal className="workspace-modal workspace-modal-compact" title={project.status === "archived" ? "恢复项目" : "归档项目"} open={archiveOpen} okText={project.status === "archived" ? "确认恢复" : "确认归档"} cancelText="取消" okButtonProps={{ danger: project.status !== "archived", loading: archiveMutation.isPending }} onCancel={() => setArchiveOpen(false)} onOk={() => archiveMutation.mutate()} styles={{ body: { paddingTop: 12 } }}><p className="m-0 text-sm leading-6 text-foreground/65">{project.status === "archived" ? "恢复后项目会重新进入可编辑状态。" : "归档不会删除章节、画布或资产,画布文档仍可在创作画布中打开。"}</p></Modal>
|
||||
<CanvasStylePickerModal open={stylePickerOpen} value={stylePresetId} currentProfile={styleProfile} startInEditor={styleEditorRequested} onClose={() => { setStylePickerOpen(false); setStyleEditorRequested(false); }} onSelect={(preset) => { applyStyle(preset); setStylePickerOpen(false); setStyleEditorRequested(false); }} />
|
||||
<CanvasStyleDetailModal open={Boolean(styleDetail)} preset={styleDetail} selected={styleDetail?.id === stylePresetId} onClose={() => setStyleDetail(null)} onSelect={(preset) => { applyStyle(preset); setStyleDetail(null); }} />
|
||||
</div>
|
||||
@@ -90,7 +90,7 @@ export default function ProjectSettingsView({ detail, refreshProject }: ProjectD
|
||||
}
|
||||
|
||||
function StyleMetric({ label, value }: { label: string; value: string }) {
|
||||
return <div className="min-w-0 border-r border-t border-border px-3 py-2 first:border-l sm:border-t-0"><span className="block text-[var(--fs-tiny)] text-foreground/40">{label}</span><span className="mt-0.5 block truncate font-medium text-foreground/65">{value}</span></div>;
|
||||
return <div className="min-w-0 rounded-md bg-surface-active px-3 py-2"><span className="block text-[var(--fs-tiny)] text-foreground/40">{label}</span><span className="mt-0.5 block truncate font-medium text-foreground/65">{value}</span></div>;
|
||||
}
|
||||
|
||||
function styleExecutionStatusLabel(status?: "ready" | "degraded" | "blocked") {
|
||||
|
||||
@@ -149,7 +149,7 @@ export function PromptPreferencesPane() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col">
|
||||
<header className="shrink-0 border-b border-border pb-4">
|
||||
<header className="shrink-0 pb-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-2 block text-xs font-medium text-foreground/55" htmlFor="prompt-template-select">提示词模板</label>
|
||||
@@ -205,7 +205,7 @@ export function PromptPreferencesPane() {
|
||||
onChange={(event) => setAppendContent(event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-96 flex-1 overflow-hidden rounded-md border border-border">
|
||||
<div className="min-h-96 flex-1 overflow-hidden rounded-md bg-surface-active">
|
||||
<PromptCodeEditor
|
||||
value={mode === "inherit" ? templateContent : rewriteContent}
|
||||
readOnly={mode === "inherit"}
|
||||
@@ -216,7 +216,7 @@ export function PromptPreferencesPane() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="min-h-0 border-t border-border pt-4 lg:border-l lg:border-t-0 lg:pl-4 lg:pt-0">
|
||||
<aside className="min-h-0 pt-4 lg:pl-6 lg:pt-0">
|
||||
<Tabs
|
||||
size="small"
|
||||
items={[
|
||||
@@ -233,7 +233,7 @@ export function PromptPreferencesPane() {
|
||||
{
|
||||
key: "preview",
|
||||
label: "最终结构",
|
||||
children: <div className="space-y-4 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{previewCreative || "尚未填写"}</pre></section><section className="border-t border-border pt-4"><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">当前剧情、项目画风、当前角色版本、画布资产与受保护输出契约。</p></section></div>,
|
||||
children: <div className="space-y-5 text-xs leading-6"><section><div className="mb-2 font-medium text-foreground/80">创作策略</div><pre className="thin-scrollbar max-h-64 overflow-auto whitespace-pre-wrap text-foreground/65">{previewCreative || "尚未填写"}</pre></section><section><div className="mb-2 font-medium text-foreground/80">运行时强制追加</div><p className="text-foreground/55">当前剧情、项目画风、当前角色版本、画布资产与受保护输出契约。</p></section></div>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -241,7 +241,7 @@ export default function SkillsPage() {
|
||||
|
||||
function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
|
||||
return (
|
||||
<article className={`library-card skill-library-card group${skill.is_added ? " is-added" : ""}`}>
|
||||
<article className={`library-card library-card-surface skill-library-card group${skill.is_added ? " is-selected is-added" : ""}`}>
|
||||
<div className="skill-card-top">
|
||||
<button type="button" className="skill-card-title-button" onClick={onOpen}>
|
||||
<h3>{skill.skill_name}</h3>
|
||||
@@ -279,7 +279,7 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
|
||||
? <div className="skill-card-action"><span className="skill-card-owner-flag">我创建的</span><span className="skill-card-added-count">{formatSkillCount(skill.added_count)} 人已加入</span></div>
|
||||
: (
|
||||
<div className="skill-card-action">
|
||||
<button type="button" disabled={loading} className={`skill-card-join${skill.is_added ? " is-added" : ""}`} onClick={onAdd}>
|
||||
<button type="button" disabled={loading} aria-pressed={skill.is_added} className={`skill-card-join${skill.is_added ? " is-added" : ""}`} onClick={onAdd}>
|
||||
{loading ? <LoaderCircle className="size-3.5 animate-spin" /> : skill.is_added ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
|
||||
<span>{skill.is_added ? "已加入" : "加入我的技能库"}</span>
|
||||
</button>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { ArrowDownLeft, ArrowUpRight, CalendarCheck, Coins, RefreshCw, RotateCcw
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { PaginationBar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { CometCard } from "@/components/ui/aceternity/comet-card";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { checkinCredits, getWallet, redeemCredits, type CreditLedgerEntry, type WalletSummary } from "@/services/api/wallet";
|
||||
import { modelDisplayName, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -143,7 +142,7 @@ export default function WalletPage() {
|
||||
</div>
|
||||
|
||||
<section className="library-feature-grid mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
|
||||
<CometCard rotateDepth={2.2} translateDepth={2} glare={!reducedMotion} className="credit-balance-card overflow-hidden rounded-lg border">
|
||||
<section className="credit-balance-card overflow-hidden rounded-lg">
|
||||
<div className="wallet-balance-inner flex min-h-[210px] flex-col justify-between p-5 sm:p-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
@@ -157,21 +156,21 @@ export default function WalletPage() {
|
||||
</div>
|
||||
<div className="mt-2 text-xs opacity-45">最近更新 {formatTime(account?.updatedAt)}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-current/15 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/10 px-2.5 py-1 text-[var(--fs-label)] font-medium opacity-70">
|
||||
<ShieldCheck className="size-3.5" />
|
||||
账户正常
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-current/10 pt-4">
|
||||
<div className="grid grid-cols-2 gap-3 pt-4">
|
||||
<BalanceMetric label="冻结积分" description="调用中或待核对" value={account?.reservedMicrocredits || 0} icon={<TicketCheck className="size-4" />} />
|
||||
<BalanceMetric label="账户总额" description="可用与冻结合计" value={totalMicrocredits} icon={<Coins className="size-4" />} />
|
||||
</div>
|
||||
</div>
|
||||
</CometCard>
|
||||
</section>
|
||||
|
||||
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg border p-5 backdrop-blur-xl sm:p-6">
|
||||
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg p-5 backdrop-blur-xl sm:p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="wallet-redeem-icon grid size-9 shrink-0 place-items-center rounded-xl border">
|
||||
<span className="wallet-redeem-icon grid size-9 shrink-0 place-items-center rounded-lg">
|
||||
<TicketCheck className="size-4" />
|
||||
</span>
|
||||
<div>
|
||||
@@ -193,7 +192,7 @@ export default function WalletPage() {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="wallet-ledger-panel app-workspace-surface mt-9 rounded-lg border p-4 backdrop-blur-xl sm:p-5">
|
||||
<section className="wallet-ledger-panel app-workspace-surface mt-9 rounded-lg p-4 backdrop-blur-xl sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">积分流水</h2>
|
||||
@@ -215,7 +214,7 @@ export default function WalletPage() {
|
||||
<Table className="app-data-table wallet-ledger-table" rowKey="id" size="middle" loading={loading} columns={columns} dataSource={entries} pagination={false} tableLayout="fixed" scroll={{ x: 990 }} />
|
||||
</TableSurface>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-border/70 bg-background">{entries.length ? entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />) : <WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />}</div>
|
||||
<div className="grid gap-1 overflow-hidden rounded-md bg-transparent">{entries.length ? entries.map((entry) => <LedgerMobileRow key={entry.id} config={config} entry={entry} />) : <WorkspaceState compact icon="wallet" title="没有匹配的积分记录" description="切换流水类型,或完成一次生成后再回来查看。" />}</div>
|
||||
)}
|
||||
<PaginationBar
|
||||
current={page}
|
||||
@@ -235,7 +234,7 @@ export default function WalletPage() {
|
||||
|
||||
function BalanceMetric({ label, description, value, icon }: { label: string; description: string; value: number; icon: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 rounded-xl bg-current/[0.055] px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-3 px-1 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[var(--fs-tiny)] opacity-48">{label}</div>
|
||||
<div className="mt-0.5 truncate text-base font-semibold tabular-nums">{formatCredits(value, 6)}</div>
|
||||
@@ -249,7 +248,7 @@ function BalanceMetric({ label, description, value, icon }: { label: string; des
|
||||
function LedgerMobileRow({ config, entry }: { config: AiConfig; entry: CreditLedgerEntry }) {
|
||||
const meta = ledgerTypeMeta(entry.type);
|
||||
return (
|
||||
<article className="flex items-start gap-3 border-b border-border px-4 py-4 last:border-b-0">
|
||||
<article className="flex items-start gap-3 rounded-md bg-foreground/[.025] px-4 py-4">
|
||||
<span className={`mt-0.5 grid size-8 shrink-0 place-items-center rounded-md ${meta.iconClass}`}>{meta.icon}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user