diff --git a/studio/api/routers/projects/curation.py b/studio/api/routers/projects/curation.py index 12dc262..af4668c 100644 --- a/studio/api/routers/projects/curation.py +++ b/studio/api/routers/projects/curation.py @@ -118,9 +118,11 @@ def list_files(pid: int, bucket: str = "download") -> dict[str, Any]: if pdir.exists(): for f in sorted(pdir.iterdir()): if f.is_file() and f.suffix.lower() in datasets.IMAGE_EXTS: + st = f.stat() items.append({ "name": f.name, - "size": f.stat().st_size, + "size": st.st_size, + "mtime": st.st_mtime, "has_meta": f.with_suffix(".booru.txt").exists(), }) return {"items": items, "count": len(items)} diff --git a/studio/web/src/App.tsx b/studio/web/src/App.tsx index 919c3d5..31d75f2 100644 --- a/studio/web/src/App.tsx +++ b/studio/web/src/App.tsx @@ -108,20 +108,22 @@ function RootLayout() { }, [navOpen]) return ( -
- setNavOpen(false)} /> - {isMobile && navOpen && ( -
setNavOpen(false)} - aria-hidden="true" - /> - )} - {/* Tailark 式 inset 面板:内容区作为一张浮在侧栏底色上的卡片(手机端全屏无边距)。 */} -
-
+ // The whole app is one white panel on the warm canvas (mockup .viewport / + // .app): sidebar and main column share it. On a phone the panel is + // full-bleed and the sidebar slides in as a drawer. +
+
+ setNavOpen(false)} /> + {isMobile && navOpen && ( +
setNavOpen(false)} + aria-hidden="true" + /> + )} +
setNavOpen(true)} /> -
+
diff --git a/studio/web/src/api/client.ts b/studio/web/src/api/client.ts index d0fd2f8..ae454bf 100644 --- a/studio/web/src/api/client.ts +++ b/studio/web/src/api/client.ts @@ -1008,6 +1008,8 @@ export interface Job { export interface DownloadFile { name: string size: number + /** File mtime (unix seconds); absent from servers older than the 2026 redesign. */ + mtime?: number has_meta: boolean } diff --git a/studio/web/src/components/BulkActionBar.tsx b/studio/web/src/components/BulkActionBar.tsx index 62346ca..5ff2faf 100644 --- a/studio/web/src/components/BulkActionBar.tsx +++ b/studio/web/src/components/BulkActionBar.tsx @@ -16,6 +16,9 @@ interface Props { tagSuggestions?: string[] /** 用来给"未选时"的 hint 显示总数。 */ totalCount: number + /** 'bar': one-line green strip under the image grid (mockup TagEdit); + * remove / replace then live in the tag statistics card. */ + variant?: 'panel' | 'bar' } /** 批量操作面板 — V2「行式」布局。 @@ -38,6 +41,7 @@ export default function BulkActionBar({ onClearSelection, tagSuggestions = [], totalCount, + variant = 'panel', }: Props) { const { t } = useTranslation() const { toast } = useToast() @@ -146,6 +150,51 @@ export default function BulkActionBar({ const noneSelected = selectedKeys.length === 0 const opDisabled = noneSelected + if (variant === 'bar') { + return ( +
+ + {t('bulkAction.selectedTotal', { n: selectedKeys.length, total: totalCount })} + + + + {(['front', 'back'] as const).map((p) => ( + + ))} + + + + + {selectedKeys.length < totalCount && ( + + )} + +
+ ) + } + return (
{/* selection summary + selection management — kept as before. */} @@ -379,7 +428,7 @@ function PositionToggle({ // 不抢「添加」主按钮的颜色。 const activeStyle = { background: 'var(--bg-canvas)', - color: 'var(--accent)', + color: 'var(--accent-text, var(--accent))', boxShadow: 'inset 0 0 0 1px color-mix(in oklch, var(--accent) 30%, var(--border-default))', } @@ -421,9 +470,13 @@ interface TagsFieldProps { placeholder: string suggestions: string[] ariaLabel?: string + className?: string + style?: React.CSSProperties + /** Open the suggestion list above the field (bar at the bottom of a card). */ + dropUp?: boolean } -function TagsField({ value, onChange, placeholder, suggestions, ariaLabel }: TagsFieldProps) { +function TagsField({ value, onChange, placeholder, suggestions, ariaLabel, className = 'input input-mono', style = { fontSize: 'var(--t-xs)' }, dropUp }: TagsFieldProps) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -456,12 +509,12 @@ function TagsField({ value, onChange, placeholder, suggestions, ariaLabel }: Tag onFocus={() => setOpen(true)} placeholder={placeholder} aria-label={ariaLabel} - className="input input-mono w-full" - style={{ fontSize: 'var(--t-xs)' }} + className={className + ' w-full'} + style={style} /> {open && matches.length > 0 && (
    {matches.map((s) => ( diff --git a/studio/web/src/components/CommandPalette.tsx b/studio/web/src/components/CommandPalette.tsx index e4276de..42a2116 100644 --- a/studio/web/src/components/CommandPalette.tsx +++ b/studio/web/src/components/CommandPalette.tsx @@ -1,17 +1,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' -import { api, type CaptionEntry, type PresetSummary, type ProjectSummary } from '../api/client' +import { api, type CaptionEntry, type PresetSummary, type ProjectSummary, type Task } from '../api/client' import { useProjectCtx } from '../context/ProjectContext' import { useSettingsDrawer } from '../lib/SettingsDrawer' type IconKey = 'folder' | 'queue' | 'preset' | 'monitor' | 'cog' | 'image' | 'step' | 'tag' +/** Right-hand label of a row (commandPalette.kind.*). */ +type Kind = 'project' | 'step' | 'task' | 'tag' | 'page' | 'preset' | 'setting' interface Item { id: string label: string sub?: string group: string + kind: Kind icon: IconKey /** 路由跳转。跟 action 二选一。 */ path?: string @@ -25,12 +28,6 @@ const SEARCH_ICON = ( ) -const CHEVRON_ICON = ( - - - -) - /** 每个结果左侧的小图标(prototype CommandPalette:icon + label + sub + chevron)。 */ const ITEM_ICONS: Record = { folder: , @@ -64,6 +61,8 @@ export default function CommandPalette({ open, onClose }: Props) { const [presets, setPresets] = useState([]) const [presetsLoaded, setPresetsLoaded] = useState(false) + const [tasks, setTasks] = useState([]) + const [captions, setCaptions] = useState([]) const [captionsCacheKey, setCaptionsCacheKey] = useState(null) const [captionsLoading, setCaptionsLoading] = useState(false) @@ -90,6 +89,13 @@ export default function CommandPalette({ open, onClose }: Props) { return () => { cancelled = true } }, [open, projectsLoaded]) + useEffect(() => { + if (!open) return + let cancelled = false + api.listQueue().then((items) => { if (!cancelled) setTasks(items) }).catch(() => {}) + return () => { cancelled = true } + }, [open]) + useEffect(() => { if (!open || presetsLoaded) return let cancelled = false @@ -126,31 +132,17 @@ export default function CommandPalette({ open, onClose }: Props) { const allItems = useMemo(() => { const items: Item[] = [] - - items.push({ id: 'home', label: t('commandPalette.home'), sub: t('commandPalette.homeSub'), group: t('commandPalette.pages'), icon: 'folder', path: '/' }) - items.push({ id: 'queue', label: t('nav.queue'), sub: t('commandPalette.queueSub'), group: t('commandPalette.pages'), icon: 'queue', path: '/queue' }) - items.push({ id: 'generate', label: t('nav.generate'), sub: t('commandPalette.generateSub'), group: t('commandPalette.pages'), icon: 'image', path: '/tools/generate' }) - items.push({ id: 'presets', label: t('nav.presets'), sub: t('commandPalette.presetsSub'), group: t('commandPalette.pages'), icon: 'preset', path: '/tools/presets' }) - items.push({ id: 'monitor', label: t('nav.monitor'), sub: t('commandPalette.monitorSub'), group: t('commandPalette.pages'), icon: 'monitor', path: '/tools/monitor' }) - items.push({ id: 'settings', label: t('nav.settings'), sub: t('commandPalette.settingsSub'), group: t('commandPalette.pages'), icon: 'cog', action: () => settingsDrawer.open() }) - - for (const p of presets) { - items.push({ - id: `preset:${p.name}`, - label: p.name, - sub: t('commandPalette.presetItem'), - group: t('commandPalette.presets'), - icon: 'preset', - path: '/tools/presets', - }) - } + const gProjects = t('commandPalette.groupProjects') + const gTasks = t('commandPalette.groupTasks') + const gActions = t('commandPalette.groupActions') for (const p of projects) { items.push({ id: `project:${p.id}`, label: p.title || `#${p.id}`, sub: p.slug ? `/${p.slug}` : t('commandPalette.projectItem', { id: p.id }), - group: t('commandPalette.projects'), + group: gProjects, + kind: 'project', icon: 'folder', path: `/projects/${p.id}`, }) @@ -159,20 +151,51 @@ export default function CommandPalette({ open, onClose }: Props) { if (ctx) { const cpid = ctx.project.id const cvid = ctx.activeVersion?.id - const group = t('commandPalette.currentProject') - items.push({ id: `overview:${cpid}`, label: t('nav.overview'), sub: ctx.project.title, group, icon: 'folder', path: `/projects/${cpid}` }) - items.push({ id: `download:${cpid}`, label: t('nav.download'), sub: ctx.project.title, group, icon: 'step', path: `/projects/${cpid}/download` }) + const sub = ctx.activeVersion ? `${ctx.project.title} / ${ctx.activeVersion.label}` : ctx.project.title + items.push({ id: `overview:${cpid}`, label: t('nav.overview'), sub: ctx.project.title, group: gProjects, kind: 'step', icon: 'folder', path: `/projects/${cpid}` }) + items.push({ id: `download:${cpid}`, label: t('nav.download'), sub: ctx.project.title, group: gProjects, kind: 'step', icon: 'step', path: `/projects/${cpid}/download` }) if (cvid) { const base = `/projects/${cpid}/v/${cvid}` - items.push({ id: `curate:${cpid}`, label: t('nav.curate'), sub: ctx.project.title, group, icon: 'step', path: `${base}/curate` }) - items.push({ id: `edit:${cpid}`, label: t('nav.tagEdit'), sub: ctx.project.title, group, icon: 'step', path: `${base}/edit` }) - items.push({ id: `reg:${cpid}`, label: t('nav.reg'), sub: ctx.project.title, group, icon: 'step', path: `${base}/reg` }) - items.push({ id: `train:${cpid}`, label: t('nav.train'), sub: ctx.project.title, group, icon: 'step', path: `${base}/train` }) + items.push({ id: `curate:${cpid}`, label: t('nav.curate'), sub, group: gProjects, kind: 'step', icon: 'step', path: `${base}/curate` }) + items.push({ id: `edit:${cpid}`, label: t('nav.tagEdit'), sub, group: gProjects, kind: 'step', icon: 'step', path: `${base}/edit` }) + items.push({ id: `reg:${cpid}`, label: t('nav.reg'), sub, group: gProjects, kind: 'step', icon: 'step', path: `${base}/reg` }) + items.push({ id: `train:${cpid}`, label: t('nav.train'), sub, group: gProjects, kind: 'step', icon: 'step', path: `${base}/train` }) } } + for (const task of tasks) { + items.push({ + id: `task:${task.id}`, + label: `#${task.id} · ${task.name}`, + sub: t(`status.${task.status === 'pending' ? 'queued' : task.status}`), + group: gTasks, + kind: 'task', + icon: 'queue', + path: `/queue/${task.id}`, + }) + } + + items.push({ id: 'home', label: t('commandPalette.home'), sub: t('commandPalette.homeSub'), group: gActions, kind: 'page', icon: 'folder', path: '/' }) + items.push({ id: 'queue', label: t('nav.queue'), sub: t('commandPalette.queueSub'), group: gActions, kind: 'page', icon: 'queue', path: '/queue' }) + items.push({ id: 'generate', label: t('nav.generate'), sub: t('commandPalette.generateSub'), group: gActions, kind: 'page', icon: 'image', path: '/tools/generate' }) + items.push({ id: 'presets', label: t('nav.presets'), sub: t('commandPalette.presetsSub'), group: gActions, kind: 'page', icon: 'preset', path: '/tools/presets' }) + items.push({ id: 'monitor', label: t('nav.monitor'), sub: t('commandPalette.monitorSub'), group: gActions, kind: 'page', icon: 'monitor', path: '/tools/monitor' }) + items.push({ id: 'settings', label: t('nav.settings'), sub: t('commandPalette.settingsSub'), group: gActions, kind: 'setting', icon: 'cog', action: () => settingsDrawer.open() }) + + for (const p of presets) { + items.push({ + id: `preset:${p.name}`, + label: p.name, + sub: t('commandPalette.presetItem'), + group: gActions, + kind: 'preset', + icon: 'preset', + path: '/tools/presets', + }) + } + return items - }, [projects, presets, ctx, t, settingsDrawer]) + }, [projects, presets, tasks, ctx, t, settingsDrawer]) const filteredNav = useMemo(() => { if (!query.trim()) return allItems @@ -207,7 +230,8 @@ export default function CommandPalette({ open, onClose }: Props) { id: `tag:${tag}`, label: tag, sub: t('commandPalette.imageCount', { n: count }), - group: t('commandPalette.tags'), + group: t('commandPalette.groupTasks'), + kind: 'tag', icon: 'tag', path: `/projects/${cpid}/v/${cvid}/edit`, })) @@ -217,12 +241,15 @@ export default function CommandPalette({ open, onClose }: Props) { const grouped = useMemo(() => { const map = new Map() + for (const g of [t('commandPalette.groupProjects'), t('commandPalette.groupTasks'), t('commandPalette.groupActions')]) { + if (filtered.some((i) => i.group === g)) map.set(g, []) + } for (const item of filtered) { if (!map.has(item.group)) map.set(item.group, []) map.get(item.group)!.push(item) } return map - }, [filtered]) + }, [filtered, t]) const flatItems = useMemo(() => { const out: Item[] = [] @@ -263,70 +290,61 @@ export default function CommandPalette({ open, onClose }: Props) { if (!open) return null + const q = query.trim() return (
    { if (e.target === e.currentTarget) onClose() }} >
    e.stopPropagation()} > -
    - {SEARCH_ICON} +
    + {SEARCH_ICON} { setQuery(e.target.value); setActiveIdx(0) }} onKeyDown={handleKeyDown} /> - {captionsLoading && ( - {t('commandPalette.searchingTags')} - )} - esc + {captionsLoading && {t('commandPalette.searchingTags')}} + {q && {t('commandPalette.matches', { count: flatItems.length })}}
    -
    +
    {filtered.length === 0 ? ( -
    {t('commandPalette.noResults')}
    +
    {t('commandPalette.noResults')}
    ) : ( [...grouped.entries()].map(([group, items]) => ( -
    -
    - - {group} - -
    +
    +
    {group}
    {items.map((item) => { const idx = flatItems.indexOf(item) - const isActive = idx === activeIdx return ( ) })} @@ -335,15 +353,25 @@ export default function CommandPalette({ open, onClose }: Props) { )}
    -
    - ↑↓ {t('commandPalette.navigate')} - enter {t('commandPalette.select')} - esc {t('commandPalette.close')} - {queryEnoughForTags && ctx?.activeVersion && ( - {t('commandPalette.searchTagsHint', { n: captions.length })} - )} +
    + ↑↓{t('commandPalette.navigate')} + Enter{t('commandPalette.select')} + Esc{t('commandPalette.close')} + + {queryEnoughForTags && ctx?.activeVersion + ? t('commandPalette.searchTagsHint', { n: captions.length }) + : t('commandPalette.shortcutHint')} +
    ) } + +/** Marks the first case-insensitive occurrence of `q` in `text`. */ +function Highlight({ text, q }: { text: string; q: string }) { + if (!q) return <>{text} + const i = text.toLowerCase().indexOf(q.toLowerCase()) + if (i < 0) return <>{text} + return <>{text.slice(0, i)}{text.slice(i, i + q.length)}{text.slice(i + q.length)} +} diff --git a/studio/web/src/components/ConfigYamlPanel.tsx b/studio/web/src/components/ConfigYamlPanel.tsx index a695efa..b67a725 100644 --- a/studio/web/src/components/ConfigYamlPanel.tsx +++ b/studio/web/src/components/ConfigYamlPanel.tsx @@ -47,15 +47,16 @@ export default function ConfigYamlPanel({ return (
    - {fileLabel} - - {t('schema.fieldCount', { n: fieldCount })} + {fileLabel} + + {t('schema.fieldCount', { n: fieldCount, count: fieldCount })} - {hint && {hint}} + {hint && {hint}}
    -
    +      
             {yamlText}
           
    diff --git a/studio/web/src/components/DualPeakSampling.test.tsx b/studio/web/src/components/DualPeakSampling.test.tsx index 2cc9a1c..d8bbaa6 100644 --- a/studio/web/src/components/DualPeakSampling.test.tsx +++ b/studio/web/src/components/DualPeakSampling.test.tsx @@ -52,7 +52,7 @@ describe('Dual Peak sampling controls', () => { } const { rerender } = render() expect(screen.getByRole('option', { name: 'Dual Peak — два пика для стиля' })).toBeInTheDocument() - expect(screen.queryByText('Timestep Shift')).not.toBeInTheDocument() + expect(screen.queryByText('timestep_shift')).not.toBeInTheDocument() expect(screen.getAllByRole('textbox')).toHaveLength(10) const secondPeak = screen.getByDisplayValue('0.85') fireEvent.change(secondPeak, { target: { value: '0.82' } }) @@ -60,8 +60,8 @@ describe('Dual Peak sampling controls', () => { expect(onChange).toHaveBeenCalledWith({ ...values, dual_peak_peak2_position: 0.82 }) onChange.mockClear() rerender() - expect(screen.queryByText('Dual Peak Peak1 Position')).not.toBeInTheDocument() - expect(screen.getByText('Timestep Shift')).toBeInTheDocument() + expect(screen.queryByText('dual_peak_peak1_position')).not.toBeInTheDocument() + expect(screen.getByText('timestep_shift')).toBeInTheDocument() expect(onChange).not.toHaveBeenCalled() }) }) diff --git a/studio/web/src/components/DuplicateReviewPanel.tsx b/studio/web/src/components/DuplicateReviewPanel.tsx index 10ce69f..a5657f2 100644 --- a/studio/web/src/components/DuplicateReviewPanel.tsx +++ b/studio/web/src/components/DuplicateReviewPanel.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { api, @@ -23,7 +23,9 @@ interface Props { onPreview: (name: string) => void } -/** ADR 0010 fixup: train rel path "1_data/X.png" → train bucket thumb URL。 */ +type GroupFilter = 'all' | 'exact' | 'similar' + +/** Train rel path "1_data/X.png" → folder + file name for the thumb URL. */ function splitRel(rel: string): { folder: string; filename: string } { const i = rel.lastIndexOf('/') return i >= 0 @@ -31,6 +33,12 @@ function splitRel(rel: string): { folder: string; filename: string } { : { folder: '', filename: rel } } +const isExact = (g: DuplicateGroup) => g.best?.match_type === 'strict-duplicate' + +/** Duplicate review column (mockup PreprocessDupes): group filter chips and + * the scan summary, then one card per group. Selected images are the ones + * to remove; each image toggles on its own, "Keep best" re-applies the + * scanner's suggestion for that group. */ export default function DuplicateReviewPanel({ projectId, versionId, @@ -41,206 +49,166 @@ export default function DuplicateReviewPanel({ onPreview, }: Props) { const { t } = useTranslation() + const [filter, setFilter] = useState('all') const suggested = useMemo( - () => - result - ? result.groups.flatMap((group) => - group.items.filter((item) => !item.keep).map((item) => item.name) - ) - : [], - [result] + () => (result ? result.groups.flatMap((g) => g.items.filter((it) => !it.keep).map((it) => it.name)) : []), + [result], ) - // 二态:selected = 去除(黄),unselected = 留存(绿)。每张图独立选择, - // 无 "每组至少留一张" 校验,也无 "设为保留 → 反选其他" 联动。 + const groups = useMemo(() => { + const all = result?.groups ?? [] + if (filter === 'exact') return all.filter(isExact) + if (filter === 'similar') return all.filter((g) => !isExact(g)) + return all + }, [result, filter]) + const exactCount = (result?.groups ?? []).filter(isExact).length + const toggleName = (name: string) => { const next = new Set(selected) if (next.has(name)) next.delete(name) else next.add(name) onSelect(next) } + const keepBest = (g: DuplicateGroup) => { + const next = new Set(selected) + for (const it of g.items) { + if (it.keep) next.delete(it.name) + else next.add(it.name) + } + onSelect(next) + } + return ( -
    -
    -
    -

    {t('duplicates.reviewTitle')}

    - - {result - ? t('duplicates.summary', { - groups: result.group_count, - candidates: result.candidate_count, - total: result.total_images, - }) - : t('duplicates.empty')} - - - - -
    +
    +
    + {([ + ['all', t('ppFrame.dupAllGroups'), result?.group_count ?? 0], + ['exact', t('ppFrame.dupExact'), exactCount], + ['similar', t('ppFrame.dupSimilar'), (result?.group_count ?? 0) - exactCount], + ] as Array<[GroupFilter, string, number]>).map(([id, label, n]) => ( + + ))} + + {result && ( + + {t('ppFrame.dupChecked', { n: result.readable_images, total: result.total_images, sel: selected.size })} + + )} + {selected.size > 0 ? ( + + ) : ( + + )} +
    -
    +
    {!result ? ( -
    -
    {t('duplicates.emptyTitle')}
    -

    {t('duplicates.empty')}

    -
    - ) : result.groups.length === 0 ? ( -
    - {t('duplicates.noGroups', { total: result.total_images })} +
    + {t('duplicates.emptyTitle')} + {t('ppFrame.dupEmpty')}
    + ) : groups.length === 0 ? ( +
    {t('duplicates.noGroups', { total: result.total_images })}
    ) : ( -
    - {result.groups.map((group) => ( - - ))} -
    + groups.map((group, i) => ( + keepBest(group)} + onPreview={onPreview} + /> + )) )}
    -
    +
    ) } -function DuplicateGroupCard({ - projectId, - versionId, - group, - selected, - busy, - onToggle, - onPreview, -}: { +function DuplicateGroupCard({ index, projectId, versionId, group, selected, busy, onToggle, onKeepBest, onPreview }: { + index: number projectId: number versionId: number group: DuplicateGroup selected: Set busy: boolean onToggle: (name: string) => void + onKeepBest: () => void onPreview: (name: string) => void }) { const { t } = useTranslation() + const score = group.best ? Math.round(group.best.score) : null return ( -
    -
    - #{group.group_id} - {t('duplicates.groupCandidates', { n: group.items.length })} - {group.best && ( - - {group.best.match_type} · {group.best.score} +
    +
    + {t('ppFrame.dupGroup', { n: index })} + {score != null && ( + + {t('ppFrame.dupSimilarity', { n: score })} )} + {t('ppFrame.dupFiles', { count: group.items.length })} + +
    -
    +
    {group.items.map((item) => ( onToggle(item.name)} onPreview={() => onPreview(item.name)} /> ))}
    -
    +
    ) } -function DuplicateItemCell({ - projectId, - versionId, - item, - selected, - suggestedKeep, - busy, - onToggle, - onPreview, -}: { +function DuplicateItemCell({ projectId, versionId, item, remove, busy, onToggle, onPreview }: { projectId: number versionId: number item: DuplicateItem - selected: boolean - suggestedKeep: boolean + remove: boolean busy: boolean onToggle: () => void onPreview: () => void }) { const { t } = useTranslation() - const metrics = item.metrics + const { folder, filename } = splitRel(item.name) + const title = `${item.name}\n${item.width}×${item.height} · ${item.filesize_kb} KB${item.metrics ? `\n${item.metrics.match_type} · ${item.metrics.score}` : ''}` return ( -
    - -
    -
    - - {suggestedKeep && ( - {t('duplicates.suggestedBadge')} - )} - {item.name} -
    -
    - {item.width}x{item.height} · {item.filesize_kb}KB -
    - {metrics && ( -
    - {metrics.match_type} · {metrics.score} -
    - )} -
    + + {remove ? t('duplicates.selectedRemove') : item.keep ? t('ppFrame.dupKeep') : t('duplicates.keep')} + + + {filename}
    ) } diff --git a/studio/web/src/components/Field.tsx b/studio/web/src/components/Field.tsx index a85c805..eb88649 100644 --- a/studio/web/src/components/Field.tsx +++ b/studio/web/src/components/Field.tsx @@ -2,8 +2,9 @@ import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { SchemaProperty } from '../api/client' import { useProjectCtx } from '../context/ProjectContext' -import { controlKind, fieldLabel, schemaEnumLabel } from '../lib/schema' +import { controlKind, schemaEnumLabel, schemaFieldLabel } from '../lib/schema' import { useAutoGrowTextarea } from '../lib/useAutoGrowTextarea' +import FieldLabel from './ds/FieldLabel' import PathPicker from './PathPicker' import ResumeFieldPicker from './ResumeFieldPicker' @@ -31,75 +32,110 @@ interface Props { * 按当前 values 计算;title 显示 disabledOptionHint 解释为什么不可选。 */ disabledEnumOptions?: string[] disabledOptionHint?: string + /** Value the field had before this session's edits; set only when it + * differs, and shown as a green dot plus "was: …" under the control. */ + changedFrom?: { value: unknown } } -// input 覆盖 .input 默认值(更紧凑;背景用 canvas 而不是 surface) -const inputStyle: React.CSSProperties = { - width: '100%', padding: '5px 10px', - background: 'var(--bg-canvas)', border: '1px solid var(--border-default)', - borderRadius: 'var(--r-sm)', fontSize: 'var(--t-sm)', - color: 'var(--fg-primary)', +function formatWas(v: unknown): string { + if (v === null || v === undefined || v === '') return '—' + if (Array.isArray(v)) return v.join(', ') + if (typeof v === 'object') return JSON.stringify(v) + return String(v) } -// disabled 灰显:行内 inputStyle 指定了 background/color,浏览器默认的 -// disabled 外观被盖掉 —— input/textarea 必须显式叠加(checkbox 的 -// opacity-60 wrapper / select 的原生灰显不受此影响)。 -const disabledInputStyle: React.CSSProperties = { - ...inputStyle, opacity: 0.55, cursor: 'not-allowed', +/** One setting row as in the mockup (.field): name, parameter key and badges, + * and the control on the right. The explanation opens on hover over the name. + * Multi-line controls take the full width under the text instead (stack). */ +function FieldShell({ + name, label, hintNode, helpNode, changedFrom, stack, children, below, +}: { + name: string + label: string + hintNode: React.ReactNode + helpNode: React.ReactNode + changedFrom?: { value: unknown } + stack?: boolean + children: React.ReactNode + /** Rendered after the row (pickers anchored to it). */ + below?: React.ReactNode +}) { + const { t } = useTranslation() + return ( +
    +
    +
    + {changedFrom &&
    +
    +
    + {children} + {changedFrom && {t('field.was', { value: formatWas(changedFrom.value) })}} +
    + {below} +
    + ) } -const fieldStyle = (disabled: boolean): React.CSSProperties => - disabled ? disabledInputStyle : inputStyle - -const FieldHint = ({ children }: { children: React.ReactNode }) => ( - {children} +const LockIcon = ( + ) /** 单个表单字段,按 control kind 分发渲染。 */ export default function Field({ name, prop, value, onChange, disabled = false, hint, descriptionOverride, suffix, - enumOptions, disabledEnumOptions, disabledOptionHint, + enumOptions, disabledEnumOptions, disabledOptionHint, changedFrom, }: Props) { const { t } = useTranslation() const kind = controlKind(prop) - const label = fieldLabel(name) - const help = descriptionOverride ?? prop.description + const label = schemaFieldLabel(name, t) + const rawHelp = descriptionOverride ?? prop.description + // A description that only repeats the name adds nothing to the hover tip. + const help = rawHelp && rawHelp.trim().toLowerCase() !== label.trim().toLowerCase() ? rawHelp : undefined // Recommended value lives in the locale files (schema.recommend.) and is - // shown under the description. It is advice, not a constraint: nothing reads it + // shown in the hover tip after the description. It is advice, not a constraint: nothing reads it // back, so a field without one simply renders as before. const recommend = t(`schema.recommend.${name}`, { defaultValue: '' }) const helpNode = (help || recommend) ? ( -
    - {help &&
    {help}
    } + <> + {help &&
    {help}
    } {recommend && ( -
    - {t('field.recommended')}: {recommend} +
    + {t('field.recommended')}: {recommend}
    )} -
    + ) : null const hintText = hint ?? (disabled ? t('field.autoProject') : null) - const hintNode = hintText ? {hintText} : null + const hintNode = hintText + ? disabled + ? {LockIcon}{hintText} + : {hintText} + : null + const shell = { name, label, hintNode, helpNode, changedFrom } // bool ---------------------------------------------------------------- if (kind === 'bool') { + const on = Boolean(value) return ( - + + + ) } @@ -107,10 +143,7 @@ export default function Field({ if (kind === 'tristate') { const triValue = value === true ? 'true' : value === false ? 'false' : '' return ( -
    -
    - {label}{hintNode} -
    + - {helpNode} -
    + ) } // select -------------------------------------------------------------- if (kind === 'select') { return ( -
    -
    - {label}{hintNode} -
    + - {helpNode} -
    + ) } // textarea ------------------------------------------------------------ if (kind === 'textarea') { return ( - + + + ) } // string-list --------------------------------------------------------- if (kind === 'string-list') { return ( - + + + {t('field.multilineHint')} + ) } // int-list (e.g. resolution: [512, 768, 1024]) ----------------------- if (kind === 'int-list') { return ( - + + + ) } // float-list (e.g. per-cycle learning rates) ------------------------- if (kind === 'float-list') { return ( - + + + ) } // code ---------------------------------------------------------------- if (kind === 'code') { return ( - + + + ) } // int / float --------------------------------------------------------- if (kind === 'int' || kind === 'float') { return ( - + + + ) } // string / path ------------------------------------------------------- return ( ) } interface TextareaFieldProps { - label: string - helpNode: React.ReactNode value: unknown onChange: (v: unknown) => void disabled?: boolean - hintNode?: React.ReactNode + label: string } -function TextareaField({ - label, helpNode, value, onChange, disabled = false, hintNode, -}: TextareaFieldProps) { +const areaStyle = (disabled: boolean): React.CSSProperties => ({ + height: 'auto', padding: '8px 11px', lineHeight: 1.55, resize: 'none', overflow: 'hidden', + ...(disabled ? { opacity: 0.55, cursor: 'not-allowed' } : null), +}) + +function TextareaField({ value, onChange, disabled = false, label }: TextareaFieldProps) { const taRef = useRef(null) const text = String(value ?? '') useAutoGrowTextarea(taRef, text) return ( -
    -
    - {label}{hintNode} -
    -