feat: add find page, breadcrumb components and canvas workbench updates

This commit is contained in:
2026-08-30 14:38:09 +08:00
parent ee3b7745b7
commit bceb3a1542
21 changed files with 2239 additions and 73 deletions
+16 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useState } from 'react';
import type { ThemeMode } from './components/AppSettingsWindow';
import CanvasStudio from './pages/CanvasStudio';
import FindPage from './pages/FindPage';
import HelpPage from './pages/HelpPage';
import OrdersPage from './pages/OrdersPage';
import TemplateHome from './pages/TemplateHome';
@@ -8,7 +9,7 @@ import TestWorkbench from './pages/TestWorkbench';
import { CanvasDocument, WordcloudReplaceSession, WordcloudStickerPayload } from './types';
import { createDefaultDocument } from './lib/canvasDocument';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'help';
type AppPage = 'home' | 'canvas' | 'wordcloud' | 'orders' | 'find' | 'help';
const getStoredTheme = (): ThemeMode => {
const stored = window.localStorage.getItem('wordcloud-theme');
@@ -54,6 +55,7 @@ export default function App() {
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenCanvas={() => setPage('canvas')}
onOpenHome={() => setPage('home')}
onOpenHelp={openHelp}
replaceSession={pendingReplaceSession}
onConsumeReplaceSession={() => setPendingReplaceSession(null)}
@@ -100,6 +102,7 @@ export default function App() {
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenOrders={() => setPage('orders')}
onOpenFind={() => setPage('find')}
/>
);
}
@@ -115,6 +118,17 @@ export default function App() {
);
}
if (page === 'find') {
return (
<FindPage
themeMode={themeMode}
systemTheme={systemTheme}
onThemeModeChange={setThemeMode}
onOpenHome={() => setPage('home')}
/>
);
}
return (
<TemplateHome
themeMode={themeMode}
@@ -128,9 +142,8 @@ export default function App() {
setInitialDocument(template.document);
setPage('canvas');
}}
onOpenCanvas={() => setPage('canvas')}
onOpenWordcloud={() => setPage('wordcloud')}
onOpenOrders={() => setPage('orders')}
onOpenFind={() => setPage('find')}
onOpenHelp={openHelp}
/>
);
+40
View File
@@ -0,0 +1,40 @@
interface Crumb {
/** 显示文本 */
label: string;
/** 是否当前步(高亮) */
active?: boolean;
/** 点击回退到该级;非当前步才可点 */
onClick?: () => void;
}
interface BreadcrumbProps {
crumbs: Crumb[];
}
/**
* 线性主流程面包屑(模板库 › 画布 › 词云)。
* 玻璃 pill + 当前步高亮;旧步可点回退。用于每页薄导航条左侧。
*/
export default function Breadcrumb({ crumbs }: BreadcrumbProps) {
return (
<div className="breadcrumb">
{crumbs.map((c, i) => (
<span key={i} className="crumb-wrap">
{i > 0 && <span className="crumb-sep"></span>}
{c.active ? (
<span className="crumb active">{c.label}</span>
) : (
<button
type="button"
className="crumb"
onClick={c.onClick}
disabled={!c.onClick}
>
{c.label}
</button>
)}
</span>
))}
</div>
);
}
+3 -21
View File
@@ -1,6 +1,7 @@
import { useRef, useEffect, useState } from 'react';
import { NameLocation, JobResult } from '../types';
import type { NameLocation, JobResult } from '../types';
import { apiUrl } from '../lib/api';
import HighlightBox from './HighlightBox';
import { IconCloudy } from './Icons';
interface CanvasAreaProps {
@@ -57,29 +58,10 @@ export default function CanvasArea({
style={{ transform: `scale(${zoom})` }}
/>
{highlightLocation && jobResult && (
<HighlightBox location={highlightLocation} zoom={zoom} />
<HighlightBox location={highlightLocation} scale={zoom} />
)}
</div>
)}
</div>
);
}
function HighlightBox({ location, zoom }: { location: NameLocation; zoom: number }) {
const x = location.box_x ?? location.x;
const y = location.box_y ?? location.y;
const width = location.box_width ?? location.width ?? location.font_size ?? 24;
const height = location.box_height ?? location.height ?? location.font_size ?? 24;
return (
<div
className="highlight-box"
style={{
left: x * zoom,
top: y * zoom,
width: width * zoom,
height: height * zoom,
}}
/>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { NameLocation } from '../types';
interface HighlightBoxProps {
location: NameLocation;
/** 展示缩放系数:把画布坐标的命中框等比缩放到当前预览尺寸。 */
scale: number;
}
/** 在词云图上框住命中的名字位置。CanvasArea 与 FindPage 共用。 */
export default function HighlightBox({ location, scale }: HighlightBoxProps) {
const left = location.box_x ?? location.x;
const top = location.box_y ?? location.y;
const width = location.box_width ?? location.width ?? location.font_size ?? 24;
const height = location.box_height ?? location.height ?? location.font_size ?? 24;
return (
<div
className="highlight-box"
style={{
left: left * scale,
top: top * scale,
width: width * scale,
height: height * scale,
}}
/>
);
}
+2 -2
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import Breadcrumb from '../components/Breadcrumb';
import {
CanvasDocument,
CanvasElement,
@@ -1001,7 +1002,7 @@ export default function CanvasStudio({
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconGrid /></div>
<span className="navbar-brand-name"></span>
<Breadcrumb crumbs={[{ label: '模板', onClick: onOpenHome }, { label: '画布', active: true }]} />
</div>
<div className="navbar-actions">
{CANVAS_NAV_ITEMS.map(item => (
@@ -1016,7 +1017,6 @@ export default function CanvasStudio({
))}
</div>
<div className="navbar-end">
<button className="btn btn-secondary btn-sm" onClick={onOpenHome}></button>
<button className="btn btn-secondary btn-sm" onClick={onOpenWordcloud}></button>
{onOpenHelp && <button className="btn btn-secondary btn-sm" onClick={onOpenHelp}><IconHelp /> </button>}
<AppSettingsWindow
+676
View File
@@ -0,0 +1,676 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import HighlightBox from '../components/HighlightBox';
import {
IconArrowDown,
IconArrowUp,
IconFind,
IconGrid,
IconTrash,
} from '../components/Icons';
import { apiUrl, ensureOk } from '../lib/api';
import type { NameLocation } from '../types';
/** 与订单页共用同一套管理口令 / 登录 token,登录态互通。 */
const TOKEN_KEY = 'wordcloud-orders-token';
/** 画布视图:scale 相对图片原始像素,tx/ty 是相对 stage 原点的平移量。 */
interface ViewState {
scale: number;
tx: number;
ty: number;
}
type FitMode = 'fit' | 'zoom';
interface JobListItem {
job_id: string;
status: string;
created_at: string;
artifacts: Record<string, string>;
}
interface JobLocationResult {
query: string;
mode: string;
total: number;
canvas_width: number;
canvas_height: number;
matches: NameLocation[];
}
interface FindPageProps {
themeMode: ThemeMode;
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenHome: () => void;
}
function shortJobId(id: string) {
return id.length > 10 ? `${id.slice(0, 10)}` : id;
}
function formatTime(value: string) {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value || '';
return d.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function clamp(v: number, lo: number, hi: number) {
return Math.min(hi, Math.max(lo, v));
}
function touchDist(a: React.Touch, b: React.Touch) {
return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
}
const MIN_SCALE = 0.05;
const MAX_SCALE = 40;
// 画布布局栅格:把图固定渲染到这个分辨率(而非画布×适配比例的小栅格),
// 外层缩放只放大这块高分辨率栅格 → 放大后依然清晰。
// 4096 远低于浏览器 GPU 可贴图纹理极限(~8192),不会触发局部渲染缺失。
const RASTER_CAP = 4096;
function boxRect(loc: NameLocation) {
return {
x: loc.box_x ?? loc.x ?? 0,
y: loc.box_y ?? loc.y ?? 0,
w: loc.box_width ?? loc.width ?? loc.font_size ?? 24,
h: loc.box_height ?? loc.height ?? loc.font_size ?? 24,
};
}
export default function FindPage({
themeMode,
systemTheme,
onThemeModeChange,
onOpenHome,
}: FindPageProps) {
const [token, setToken] = useState<string>(() => localStorage.getItem(TOKEN_KEY) || '');
const [password, setPassword] = useState('');
const [loginError, setLoginError] = useState('');
// 任务列表
const [jobs, setJobs] = useState<JobListItem[]>([]);
const [loadingJobs, setLoadingJobs] = useState(false);
const [selectedJob, setSelectedJob] = useState<string>('');
// 搜索
const [query, setQuery] = useState('');
const [matchMode, setMatchMode] = useState<'exact' | 'contains'>('exact');
const [searching, setSearching] = useState(false);
const [error, setError] = useState('');
// 结果 + 预览
const [results, setResults] = useState<NameLocation[]>([]);
const [currentIdx, setCurrentIdx] = useState(-1);
const [searched, setSearched] = useState(false);
const [stageSize, setStageSize] = useState({ w: 0, h: 0 });
// 画布尺寸:优先用搜索结果返回的 canvas 尺寸;否则用图片自然尺寸兜底,
// 保证首次查找前也能把图按比例适配进舞台。
const [canvasWidth, setCanvasWidth] = useState(0);
const [canvasHeight, setCanvasHeight] = useState(0);
const [imageSize, setImageSize] = useState<{ w: number; h: number } | null>(null);
const stageRef = useRef<HTMLDivElement>(null);
// 视图变换(平移 / 缩放)
const [view, setView] = useState<ViewState>({ scale: 0, tx: 0, ty: 0 });
const [fitMode, setFitMode] = useState<FitMode>('fit');
const [panning, setPanning] = useState(false);
// 离散跳转(定位/整图)时开启 transform 过渡动画;拖动/捏合过程连续改视图,需即时跟手 → 关
const [animating, setAnimating] = useState(false);
const panRef = useRef({ active: false, startX: 0, startY: 0, tx0: 0, ty0: 0 });
const pinchingRef = useRef(false);
const pinchRef = useRef<{ dist0: number; scale0: number } | null>(null);
// 放大态下切换上/下一个名字:两段跳转(先缩回整图,再放大到下一个)。守卫阻止
// currentIdx 变更的自动 zoomToName 抢先执行;令牌用于取消仍在途的「入图」阶段。
const jumpingRef = useRef(false);
const jumpTokenRef = useRef<number | null>(null);
const jumpTargetRef = useRef(-1); // 两段跳转的入图目标索引
const touchedRef = useRef(false); // 用户手动平移/缩放后,尺寸变化不再自动 fit
useEffect(() => {
setImageSize(null);
setResults([]);
setCurrentIdx(-1);
setSearched(false);
setError('');
setFitMode('fit');
touchedRef.current = false;
}, [selectedJob]);
// 后备尺寸:防止 canvas 尺寸为 0 时预览框塌陷为 auto(首查前适配用)
const effW = canvasWidth || imageSize?.w || 0;
const effH = canvasHeight || imageSize?.h || 0;
const loadJobs = async (t: string) => {
setLoadingJobs(true);
setError('');
try {
const res = await fetch(apiUrl('/api/jobs'), { headers: { Authorization: `Bearer ${t}` } });
if (res.status === 403 || res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
setToken('');
return;
}
await ensureOk(res, '读取任务列表失败');
const all: JobListItem[] = await res.json();
// 只保留可查找的任务:成功且生成了位置数据库
const searchable = all.filter(
(job) => job.status === 'success' && !!job.artifacts.db
);
setJobs(searchable);
} catch (e) {
setError(e instanceof Error ? e.message : '读取任务列表失败');
} finally {
setLoadingJobs(false);
}
};
useEffect(() => {
if (token) loadJobs(token);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
const doSearch = useCallback(async (jobId: string, q: string, mode: 'exact' | 'contains') => {
setSearching(true);
setError('');
setSearched(false);
try {
const url = apiUrl(`/api/jobs/${jobId}/find?name=${encodeURIComponent(q)}&mode=${mode}`);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (res.status === 403 || res.status === 401) {
localStorage.removeItem(TOKEN_KEY);
setToken('');
return;
}
await ensureOk(res, '查找失败');
const data: JobLocationResult = await res.json();
setCanvasWidth(data.canvas_width);
setCanvasHeight(data.canvas_height);
setResults(data.matches);
setCurrentIdx(data.matches.length > 0 ? 0 : -1);
setSearched(true);
// 命中后自动放大定位到第一个名字
setFitMode(data.matches.length > 0 ? 'zoom' : 'fit');
} catch (e) {
setResults([]);
setCurrentIdx(-1);
setSearched(true);
setError(e instanceof Error ? e.message : '查找失败');
} finally {
setSearching(false);
}
}, [token]);
const handleFind = () => {
if (!selectedJob || !query.trim()) return;
void doSearch(selectedJob, query.trim(), matchMode);
};
const clearJump = () => {
if (jumpTokenRef.current !== null) {
window.clearTimeout(jumpTokenRef.current);
jumpTokenRef.current = null;
}
jumpingRef.current = false;
};
const step = (delta: number) => {
if (results.length === 0) return;
const next = (currentIdx + delta + results.length) % results.length;
clearJump(); // 取消上一段可能仍在途的入图
setCurrentIdx(next);
if (fitMode === 'zoom') {
// 两段跳转:先缩回整图(动画),停顿一瞬,再放大到下一个名字(动画)。
jumpingRef.current = true;
jumpTargetRef.current = next; // 定时器读取它而非 currentIdx state,避免闭包错位
const f = computeFit();
setAnimating(true);
if (f) setView(f);
jumpTokenRef.current = window.setTimeout(() => {
jumpingRef.current = false;
jumpTokenRef.current = null;
const n = results[jumpTargetRef.current];
if (n) focusName(n);
}, 560);
}
// fitMode 非 zoom 时:仅切换高亮框,保持整图视图
};
// 监听预览舞台尺寸变化(ResizeObserver
useEffect(() => {
const el = stageRef.current;
if (!el) return;
const update = () => setStageSize({ w: el.clientWidth, h: el.clientHeight });
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [selectedJob]);
// 整体适配:等比满铺居中(fixed gui: false)。
// 布局栅格 = min(画布, RASTER_CAP),高亮框以「画布→布局」比例 K 定位到同一栅格。
const computeFit = useCallback((): ViewState | null => {
if (effW <= 0 || effH <= 0 || stageSize.w <= 0 || stageSize.h <= 0) return null;
const layoutW = Math.min(effW, RASTER_CAP);
const layoutH = layoutW * (effH / effW);
const s = Math.min(stageSize.w / layoutW, stageSize.h / layoutH);
return {
scale: s,
tx: (stageSize.w - layoutW * s) / 2,
ty: (stageSize.h - layoutH * s) / 2,
};
}, [effW, effH, stageSize.w, stageSize.h]);
// 初次适配:图加载后(尺寸从 0 变为有效),且用户未手动操作过,自动铺满
useEffect(() => {
if (effW <= 0 || effH <= 0 || stageSize.w <= 0 || stageSize.h <= 0) return;
if (touchedRef.current) return;
const f = computeFit();
if (f) { setView(f); setAnimating(true); }
}, [effW, effH, stageSize.w, stageSize.h, computeFit]);
const fitToPage = useCallback(() => {
clearJump(); // 取消两段跳转中仍在途的入图
const f = computeFit();
if (f) setView(f);
setFitMode('fit');
touchedRef.current = false;
setAnimating(true);
}, [computeFit]);
// 放大到给定名字框:约占视口并居中(在布局栅格坐标系里计算)。
// 关键:不读 currentIdx state —— 入图段由定时器触发,若闭包捕获调度时刻的
// currentIdx 会放大到旧名字,而高亮框已换到新名字(错位)。显式传入目标即可。
const focusName = useCallback((n: NameLocation) => {
if (!n || effW <= 0 || effH <= 0 || stageSize.w <= 0 || stageSize.h <= 0) return;
const b = boxRect(n);
const layoutW = Math.min(effW, RASTER_CAP);
const K = layoutW / effW; // 画布→布局比例
const bw = b.w * K, bh = b.h * K; // 名字框(布局坐标)
// 放大倍数同时受宽度与高度约束:竖排名字(宽小高大)按宽放大会纵向爆出屏幕,
// 取两者的最小值让整条名字框完整落在视口内。
const s = Math.min((stageSize.w * 0.6) / bw, (stageSize.h * 0.6) / bh);
setView({
scale: clamp(s, 0.001, MAX_SCALE),
tx: stageSize.w / 2 - (b.x + b.w / 2) * K * s,
ty: stageSize.h / 2 - (b.y + b.h / 2) * K * s,
});
setFitMode('zoom');
touchedRef.current = true;
setAnimating(true);
}, [effW, effH, stageSize.w, stageSize.h]);
// 放大到当前被选名字
const zoomToName = useCallback(() => {
if (currentIdx < 0 || results.length === 0) return;
focusName(results[currentIdx]);
}, [results, currentIdx, focusName]);
// zoom 态:切换上 / 下一个名字时,自动重新聚焦到当前名字
useEffect(() => {
if (fitMode !== 'zoom' || currentIdx < 0) return;
if (jumpingRef.current) return; // 两段跳转中:出图阶段交给 step() 的定时器
zoomToName();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fitMode, currentIdx, results]);
// ── 平移 / 缩放交互(指针拖动 + 滚轮 / 捏合)──────────────────
const zoomAt = useCallback((cx: number, cy: number, factor: number) => {
clearJump(); // 手动缩放吞掉还在途的两段跳转入图
setView((prev) => {
const scale = clamp(prev.scale * factor, MIN_SCALE, MAX_SCALE);
const ratio = scale / prev.scale;
return { scale, tx: cx - (cx - prev.tx) * ratio, ty: cy - (cy - prev.ty) * ratio };
});
setAnimating(false);
touchedRef.current = true;
}, []);
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (pinchingRef.current) return;
if (e.pointerType === 'mouse' && e.button !== 0) return;
clearJump(); // 拖动吞掉两段跳转仍在途的入图
panRef.current = { active: true, startX: e.clientX, startY: e.clientY, tx0: view.tx, ty0: view.ty };
setPanning(true);
setAnimating(false);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const p = panRef.current;
if (!p.active || pinchingRef.current) return;
setView((prev) => ({
...prev,
tx: p.tx0 + (e.clientX - p.startX),
ty: p.ty0 + (e.clientY - p.startY),
}));
touchedRef.current = true;
};
const onPointerEnd = () => {
panRef.current.active = false;
setPanning(false);
};
const onWheel = (e: React.WheelEvent<HTMLDivElement>) => {
e.preventDefault();
const rect = stageRef.current?.getBoundingClientRect();
if (!rect) return;
const cx = e.clientX - rect.left;
const cy = e.clientY - rect.top;
if (e.ctrlKey) {
// 触摸板捏合 / 按住 Ctrl 滚轮 → 缩放
zoomAt(cx, cy, Math.exp(-e.deltaY * 0.01));
} else {
// 双指滚动 → 平移
setView((prev) => ({ ...prev, tx: prev.tx - e.deltaX, ty: prev.ty - e.deltaY }));
setAnimating(false);
touchedRef.current = true;
}
};
const onTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
if (e.touches.length === 2) {
pinchingRef.current = true;
setAnimating(false);
pinchRef.current = { dist0: touchDist(e.touches[0], e.touches[1]), scale0: view.scale };
}
};
const onTouchMove = (e: React.TouchEvent<HTMLDivElement>) => {
const p = pinchRef.current;
if (!p || e.touches.length < 2) return;
const cur = touchDist(e.touches[0], e.touches[1]);
if (cur < 8) return;
const rect = stageRef.current?.getBoundingClientRect();
if (!rect) return;
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
setView((prev) => {
const scale = clamp(p.scale0 * (cur / p.dist0), MIN_SCALE, MAX_SCALE);
const ratio = scale / prev.scale;
return { scale, tx: cx - (cx - prev.tx) * ratio, ty: cy - (cy - prev.ty) * ratio };
});
touchedRef.current = true;
e.preventDefault();
};
const onTouchEnd = () => {
pinchRef.current = null;
pinchingRef.current = false;
};
const current = results[currentIdx] ?? null;
const onLogin = async (e: FormEvent) => {
e.preventDefault();
setLoginError('');
try {
const res = await fetch(apiUrl('/api/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
if (res.status === 401) {
setLoginError('口令错误');
return;
}
await ensureOk(res, '登录失败');
const data = await res.json();
localStorage.setItem(TOKEN_KEY, data.token);
setToken(data.token);
setPassword('');
} catch (err) {
setLoginError(err instanceof Error ? err.message : '登录失败');
}
};
const onLogout = () => {
localStorage.removeItem(TOKEN_KEY);
setToken('');
setJobs([]);
setSelectedJob('');
setResults([]);
setCurrentIdx(-1);
};
const navbar = (
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconFind /></div>
<span className="navbar-brand-name"></span>
</div>
<div className="navbar-actions" />
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenHome}>
<span className="nav-btn-icon"><IconGrid /></span>
<span className="nav-btn-label"></span>
</button>
{token && (
<button className="nav-btn" onClick={onLogout}>
<span className="nav-btn-icon"><IconTrash /></span>
<span className="nav-btn-label">退</span>
</button>
)}
<AppSettingsWindow themeMode={themeMode} systemTheme={systemTheme} onThemeModeChange={onThemeModeChange} />
</div>
</nav>
);
if (!token) {
return (
<div className="orders-page find-page">
{navbar}
<main className="orders-main">
<form className="login-card" onSubmit={onLogin}>
<h2 className="login-title"> · </h2>
<p className="orders-hint"></p>
<input
type="password"
className="orders-input"
placeholder="管理口令"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{loginError && <div className="orders-error">{loginError}</div>}
<button type="submit" className="btn btn-primary btn-block">
</button>
</form>
</main>
</div>
);
}
const isZoomed = fitMode === 'zoom' && view.scale > 0;
const fitView = computeFit();
// 固定布局栅格:画布渲染到这个分辨率(最多 RASTER_CAP px),放大时复用同一高分辨率栅格 → 画面同步清晰。
const layoutW = effW > 0 ? Math.min(effW, RASTER_CAP) : 0;
const layoutH = effW > 0 ? layoutW * (effH / effW) : 0;
const K = layoutW > 0 ? layoutW / effW : 0; // 画布→布局栅格比例(高亮框/名字坐标用它)
return (
<div className="orders-page find-page">
{navbar}
{/* 背景光斑:让玻璃模糊有内容可折射 */}
<div className="find-page-bg" aria-hidden="true">
<div className="blob blob-1" />
<div className="blob blob-2" />
<div className="blob blob-3" />
</div>
<main className="find-main">
{/* 词云视口:整图默认铺满,可拖动平移、滚轮 / 捏合缩放 */}
<div
className={`find-stage${panning ? ' panning' : ''}`}
ref={stageRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onWheel={onWheel}
onTouchStart={onTouchStart}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchEnd}
>
{!selectedJob ? (
<div className="table-empty">
{jobs.length === 0 && loadingJobs ? '(加载中…)' : ''}
</div>
) : (
<div
className={`find-viewport${animating ? ' animating' : ''}`}
style={{ transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale || 1})` }}
>
<div
className="find-preview-frame"
style={{ width: layoutW || 1, height: layoutH || 1 }}
>
<img
className="find-preview-img"
src={apiUrl(`/api/jobs/${selectedJob}/files/png`)}
alt="词云预览"
draggable={false}
onLoad={(e) => {
const el = e.currentTarget;
if (el.naturalWidth > 0) setImageSize({ w: el.naturalWidth, h: el.naturalHeight });
}}
/>
{current && K > 0 && (
<HighlightBox location={current} scale={K} />
)}
</div>
</div>
)}
</div>
</main>
{/* 悬浮玻璃工具条:任务选择 / 匹配方式 / 名字输入 / 查找 / 上一下 */}
<div className="find-glassbar">
<div className="glass-field">
<label className="glass-label"> / </label>
<select
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
disabled={loadingJobs}
>
<option value="">{loadingJobs ? '加载中…' : '选择任务'}</option>
{jobs.map((job) => (
<option key={job.job_id} value={job.job_id}>
{formatTime(job.created_at)} · {shortJobId(job.job_id)}
</option>
))}
</select>
</div>
<div className="glass-field">
<label className="glass-label"></label>
<div className="mode-toggle">
<button
type="button"
className={matchMode === 'exact' ? 'active' : ''}
onClick={() => setMatchMode('exact')}
>
</button>
<button
type="button"
className={matchMode === 'contains' ? 'active' : ''}
onClick={() => setMatchMode('contains')}
>
</button>
</div>
</div>
<div className="glass-field">
<label className="glass-label"></label>
<input
type="text"
placeholder="输入名字,如 张三"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleFind()}
disabled={!selectedJob}
/>
</div>
<div className="find-actions">
<button
className="find-btn find-btn-primary"
onClick={handleFind}
disabled={!selectedJob || !query.trim() || searching}
title="查找"
>
{searching ? <><span className="spinner" style={{ width: 11, height: 11 }} /></> : <IconFind />}
</button>
<button
className="find-btn find-btn-ghost"
onClick={() => step(-1)}
disabled={results.length === 0}
title="上一个"
>
<IconArrowUp />
</button>
<button
className="find-btn find-btn-ghost"
onClick={() => step(1)}
disabled={results.length === 0}
title="下一个"
>
<IconArrowDown />
</button>
</div>
</div>
{error && <div className="find-error-fixed">{error}</div>}
{/* 底部:查找结果统计 */}
<div className="find-footer">
{searched ? (
results.length > 0 ? (
<span>
<strong>{results.length}</strong> {query}{' '}
<strong>{Math.max(0, currentIdx) + 1}</strong> / {results.length}
{current?.name ? ` · ${current.name}` : ''}
</span>
) : (
<span>{query}</span>
)
) : (
<span></span>
)}
</div>
{/* 右下角:查看整图 / 重新放大 */}
<button
className="find-corner-btn"
onClick={() => (fitMode === 'zoom' ? fitToPage() : zoomToName())}
disabled={!current}
>
{isZoomed ? (
<>
<IconGrid />
</>
) : (
<>
<IconFind />
</>
)}
</button>
{/* 缩放指示 */}
<div className="find-scale-tag">
{fitMode === 'fit' || !fitView
? '适应页面'
: `× ${(view.scale / fitView.scale).toFixed(1)}`}
</div>
</div>
);
}
+7
View File
@@ -2,6 +2,7 @@ import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWind
import {
IconCanvas,
IconCloud,
IconFind,
IconGrid,
IconHelp,
} from '../components/Icons';
@@ -14,6 +15,7 @@ interface HelpPageProps {
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
onOpenOrders: () => void;
onOpenFind: () => void;
}
const quickSteps = [
@@ -62,6 +64,7 @@ export default function HelpPage({
onOpenCanvas,
onOpenWordcloud,
onOpenOrders,
onOpenFind,
}: HelpPageProps) {
return (
<div className="help-page">
@@ -88,6 +91,10 @@ export default function HelpPage({
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenFind}>
<span className="nav-btn-icon"><IconFind /></span>
<span className="nav-btn-label"></span>
</button>
<AppSettingsWindow
themeMode={themeMode}
systemTheme={systemTheme}
+9 -14
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import Breadcrumb from '../components/Breadcrumb';
import { BackendAsset, CanvasTemplate } from '../types';
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
import {
@@ -18,8 +19,8 @@ import { loadStickerLibrary } from '../lib/stickerLibrary';
import {
IconGrid,
IconCloud,
IconFind,
IconRefresh,
IconCanvas,
IconDownload,
IconHelp,
} from '../components/Icons';
@@ -30,9 +31,8 @@ interface TemplateHomeProps {
onThemeModeChange: (mode: ThemeMode) => void;
onCreateBlank: () => void;
onUseTemplate: (template: CanvasTemplate) => void;
onOpenCanvas: () => void;
onOpenWordcloud: () => void;
onOpenOrders: () => void;
onOpenFind: () => void;
onOpenHelp: () => void;
}
@@ -42,9 +42,8 @@ export default function TemplateHome({
onThemeModeChange,
onCreateBlank,
onUseTemplate,
onOpenCanvas,
onOpenWordcloud,
onOpenOrders,
onOpenFind,
onOpenHelp,
}: TemplateHomeProps) {
const [templates, setTemplates] = useState<CanvasTemplate[]>([]);
@@ -120,7 +119,7 @@ export default function TemplateHome({
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconGrid /></div>
<span className="navbar-brand-name"></span>
<Breadcrumb crumbs={[{ label: '模板', active: true }]} />
</div>
<div className="navbar-actions">
<button className="nav-btn active" onClick={refresh}>
@@ -140,18 +139,14 @@ export default function TemplateHome({
/>
</div>
<div className="navbar-end">
<button className="nav-btn" onClick={onOpenCanvas}>
<span className="nav-btn-icon"><IconCanvas /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenWordcloud}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenOrders}>
<span className="nav-btn-icon"><IconCloud /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenFind}>
<span className="nav-btn-icon"><IconFind /></span>
<span className="nav-btn-label"></span>
</button>
<button className="nav-btn" onClick={onOpenHelp}>
<span className="nav-btn-icon"><IconHelp /></span>
<span className="nav-btn-label"></span>
+8 -4
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import * as XLSX from 'xlsx';
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
import Breadcrumb from '../components/Breadcrumb';
import {
NameEntry,
JobParams,
@@ -100,6 +101,7 @@ interface TestWorkbenchProps {
systemTheme: 'light' | 'dark';
onThemeModeChange: (mode: ThemeMode) => void;
onOpenCanvas?: () => void;
onOpenHome?: () => void;
onImportWordcloudSticker?: (payload: WordcloudStickerPayload) => void;
onOpenHelp?: () => void;
replaceSession?: WordcloudReplaceSession | null;
@@ -111,6 +113,7 @@ export default function TestWorkbench({
systemTheme,
onThemeModeChange,
onOpenCanvas,
onOpenHome,
onImportWordcloudSticker,
onOpenHelp,
replaceSession,
@@ -860,7 +863,11 @@ export default function TestWorkbench({
<nav className="navbar">
<div className="navbar-brand">
<div className="navbar-brand-icon"><IconCloud /></div>
<span className="navbar-brand-name"></span>
<Breadcrumb crumbs={[
{ label: '模板', onClick: onOpenHome },
{ label: '画布', onClick: onOpenCanvas },
{ label: '词云', active: true },
]} />
{activeReplaceSession && (
<span className="navbar-mode-badge"></span>
)}
@@ -878,9 +885,6 @@ export default function TestWorkbench({
))}
</div>
<div className="navbar-end">
{onOpenCanvas && (
<button className="btn btn-secondary btn-sm" onClick={onOpenCanvas}></button>
)}
<button
className="btn btn-primary btn-sm"
onClick={handleGenerate}
+652 -9
View File
@@ -30,14 +30,19 @@
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06);
--shadow-md: 0 10px 28px rgba(17,24,39,0.10);
--shadow-lg: 0 22px 60px rgba(17,24,39,0.18);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 20px;
--nav-height: 56px;
--panel-width: 240px;
--transition: 200ms cubic-bezier(0.4, 0, 0.2, 1);
--font-main: 'DM Sans', sans-serif;
--font-mono: 'DM Mono', monospace;
/* 液态玻璃 */
--glass-bg: rgba(255, 255, 255, 0.55);
--glass-border: rgba(255, 255, 255, 0.72);
--glass-ring: rgba(24, 36, 56, 0.10);
--glass-blur: 16px;
}
:root[data-theme="dark"] {
@@ -64,6 +69,9 @@
--shadow-sm: 0 1px 3px rgba(0,0,0,0.36);
--shadow-md: 0 8px 22px rgba(0,0,0,0.34);
--shadow-lg: 0 18px 44px rgba(0,0,0,0.42);
--glass-bg: rgba(24, 28, 34, 0.52);
--glass-border: rgba(255, 255, 255, 0.14);
--glass-ring: rgba(0, 0, 0, 0.34);
}
html, body, #root {
@@ -131,6 +139,46 @@ body {
letter-spacing: 0;
}
/* 线性主流程面包屑:玻璃 pill,当前步高亮 */
.breadcrumb {
display: flex;
align-items: center;
gap: 4px;
}
.crumb {
padding: 4px 10px;
border-radius: 9px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text-secondary);
transition: background var(--transition), color var(--transition), border-color var(--transition);
}
.crumb:hover:not(:disabled) {
background: var(--accent-light);
color: var(--text-primary);
}
.crumb.active {
background: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.crumb:disabled {
opacity: 0.55;
cursor: default;
}
.crumb-sep {
color: var(--text-muted);
font-weight: 300;
padding: 0 2px;
}
.crumb-wrap {
display: inline-flex;
align-items: center;
}
.navbar-mode-badge {
display: inline-flex;
align-items: center;
@@ -1962,11 +2010,41 @@ body.resizing {
/* ===== HELP PAGE ===== */
.help-page {
height: 100vh;
background: var(--bg);
background:
radial-gradient(85% 55% at 90% -8%, var(--lf-blob-1), transparent 60%),
radial-gradient(70% 50% at 0% 30%, var(--lf-blob-2), transparent 60%),
radial-gradient(80% 45% at 70% 115%, var(--lf-blob-3), transparent 60%),
linear-gradient(to bottom, var(--lf-bg-a) 0%, var(--lf-bg-b) 100%);
color: var(--text-primary);
overflow: hidden;
}
/* 帮助页玻璃化(复用 --lf-* 令牌) */
.help-page .navbar {
background: var(--lf-nav-bg);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--lf-nav-border);
box-shadow: none;
}
.help-page .navbar-brand-name { color: var(--lf-text); }
.help-page .navbar-mode-badge,
.help-page .nav-btn-label { color: var(--lf-text-dim); }
.help-page .help-hero,
.help-page .help-step,
.help-page .help-item,
.help-page .help-row {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(18px) saturate(170%);
backdrop-filter: blur(18px) saturate(170%);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 6px 20px var(--lf-shadow);
}
.help-page .help-hero h1 { color: var(--lf-text); }
.help-page .help-hero p,
.help-page .help-section h2 { color: var(--lf-text-dim); }
.help-page .help-kicker { color: var(--lf-accent); }
.help-main {
height: calc(100vh - var(--nav-height));
overflow: auto;
@@ -2205,10 +2283,15 @@ body.resizing {
align-items: center;
justify-content: center;
padding: 28px;
background: rgba(0, 0, 0, 0.34);
/* 只模糊、不压暗:去掉黑遮罩,保留毛玻璃磨砂。
模糊从 0 缓慢增强到目标值(700ms ease-out),进展不突兀 */
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(10px);
animation: modal-backdrop-in 700ms ease-out;
}
:root[data-theme="dark"] .template-modal-backdrop {
background: rgba(10, 12, 18, 0.14);
}
.template-modal {
width: min(960px, 100%);
max-height: calc(100vh - 56px);
@@ -2238,7 +2321,7 @@ body.resizing {
height: 82px;
object-fit: cover;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
border-radius: var(--radius-md);
background: var(--bg-panel-alt);
flex-shrink: 0;
transition: outline 0.1s;
@@ -2291,6 +2374,19 @@ body.resizing {
transform: scale(1);
}
}
/* 遮罩只模糊不压暗 → 让模糊从 0 慢慢增强到目标值,而非瞬间全糊(进展缓一点) */
@keyframes modal-backdrop-in {
from {
opacity: 0;
-webkit-backdrop-filter: blur(0px);
backdrop-filter: blur(0px);
}
to {
opacity: 1;
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
}
}
@media (max-width: 900px) {
.navbar {
@@ -2402,8 +2498,110 @@ body.resizing .studio-panel {
transition: none !important;
}
/* ── OrdersPage(生产订单,登录后可见)────────────── */
.orders-page { min-height: 100vh; display: flex; flex-direction: column; background: var(--workspace-bg, var(--bg)); }
/* ── TemplateHome(模板库)LeleFlix 玻璃化 ──────────
复用全局 --lf-* 令牌(:root / dark 各一套),仅作用域此页。 */
.template-home {
background:
radial-gradient(90% 60% at 85% -8%, var(--lf-blob-1), transparent 60%),
radial-gradient(70% 55% at 0% 20%, var(--lf-blob-2), transparent 60%),
radial-gradient(80% 50% at 70% 110%, var(--lf-blob-3), transparent 60%),
linear-gradient(to bottom, var(--lf-bg-a) 0%, var(--lf-bg-b) 100%);
}
.template-home .navbar {
background: var(--lf-nav-bg);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--lf-nav-border);
box-shadow: none;
}
.template-home .navbar-brand { border-right-color: var(--lf-glass-soft); }
.template-home .navbar-brand-name,
.template-home .template-title { color: var(--lf-text); }
.template-home .navbar-mode-badge,
.template-home .nav-btn-label { color: var(--lf-text-dim); }
/* 卡片 = 图片本身:卡片本体透明,圆角靠 overflow:hidden 裁出,阴影只留一层大投影(无玻璃衬底) */
.template-home .template-masonry-card {
position: relative;
background: transparent;
border: none;
box-shadow: 0 16px 40px var(--lf-shadow-strong);
}
.template-home .template-masonry-card:hover {
transform: translateY(-3px);
box-shadow: 0 22px 54px var(--lf-shadow-strong);
}
/* 图片填满卡片(透明卡片外框用它承载圆角+阴影),自身不圆角不投影 */
.template-home .template-masonry-card .template-preview {
background: #fff;
}
/* 标题悬浮玻璃条:新的一层圆角玻璃,浮在图片下部。
只模糊、不压暗——去掉 saturate 提饱和,底色压到很低透明,让图保持原亮度 */
.template-home .template-masonry-card .template-masonry-info {
position: absolute;
left: 12px; right: 12px; bottom: 12px;
background: rgba(255, 255, 255, 0.32);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
border-radius: var(--radius-md);
box-shadow:
0 10px 26px var(--lf-shadow),
inset 0 1px 0 var(--lf-glass-highlight);
padding: 10px 14px 8px;
}
:root[data-theme="dark"] .template-home .template-masonry-card .template-masonry-info {
background: rgba(10, 12, 18, 0.38);
}
.template-home .template-masonry-card .template-description {
color: var(--lf-text-dim);
max-height: 2.6em; line-height: 1.3;
overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; display: -webkit-box;
-webkit-box-orient: vertical;
}
.template-home .template-masonry-card .template-meta span {
border-color: var(--lf-glass-soft);
color: var(--lf-text-dim);
background: var(--lf-input-bg);
}
/* 模态大图预览(独立圆角层,不悬浮标题条) */
.template-home .template-modal .template-preview {
background: #fff;
border-radius: var(--radius-lg);
box-shadow: inset 0 0 0 1px var(--lf-glass-soft), 0 12px 34px var(--lf-shadow-strong);
}
/* 空态与模态玻璃化 */
.template-home .template-empty {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(16px) saturate(170%);
backdrop-filter: blur(16px) saturate(170%);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 10px 30px var(--lf-shadow);
}
.template-home .template-empty-title { color: var(--lf-text-dim); }
.template-home .template-modal {
background: var(--lf-glass-bg);
-webkit-backdrop-filter: blur(24px) saturate(180%);
backdrop-filter: blur(24px) saturate(180%);
box-shadow:
0 24px 70px var(--lf-shadow-strong),
inset 0 1px 0 var(--lf-glass-highlight);
}
/* ── OrdersPage(生产订单,登录后可见)──────────────
根背景多层光斑(复用 --lf-* 令牌),双主题随 :root。 */
.orders-page {
min-height: 100vh; display: flex; flex-direction: column;
background:
radial-gradient(85% 60% at 10% -5%, var(--lf-blob-2), transparent 60%),
radial-gradient(75% 55% at 92% 15%, var(--lf-blob-1), transparent 60%),
radial-gradient(90% 50% at 60% 115%, var(--lf-blob-3), transparent 60%),
linear-gradient(to bottom, var(--lf-bg-a) 0%, var(--lf-bg-b) 100%);
}
.orders-main { flex: 1; padding: 24px 32px; max-width: 1080px; width: 100%; margin: 0 auto; box-sizing: border-box; }
.orders-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.orders-count { color: var(--text-secondary); font-size: 14px; }
@@ -2451,3 +2649,448 @@ body.resizing .studio-panel {
.orders-layer-thumb { box-shadow: var(--shadow-sm); }
.orders-layer-name { color: var(--text-secondary); font-size: 12px; margin-top: 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.nav-btn-label.active { color: var(--accent); font-weight: 600; }
/* ── OrdersPage 已登录双栏玻璃化(复用 --lf-* 令牌)── */
.orders-page .navbar {
background: var(--lf-nav-bg);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--lf-nav-border);
box-shadow: none;
}
.orders-page .navbar-brand { border-right-color: var(--lf-glass-soft); }
.orders-page .navbar-brand-name,
.orders-page .orders-detail-title { color: var(--lf-text); }
.orders-page .navbar-mode-badge,
.orders-page .nav-btn-label,
.orders-page .orders-detail-meta,
.orders-page .orders-count,
.orders-page .orders-layer-name,
.orders-page .orders-preview-label { color: var(--lf-text-dim); }
/* 左侧订单队列玻璃 */
.orders-page .orders-queue {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(18px) saturate(170%);
backdrop-filter: blur(18px) saturate(170%);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), inset 0 -1px 0 var(--lf-glass-shade), 0 6px 20px var(--lf-shadow);
}
.orders-page .orders-queue-head { border-bottom-color: var(--lf-glass-soft); color: var(--lf-text); }
.orders-page .orders-q-item:hover { background: var(--lf-accent-hover); }
.orders-page .orders-q-item.active { border-color: var(--lf-accent-border); background: var(--lf-accent-soft); }
.orders-page .orders-q-item-sub { color: var(--lf-text-faint); }
/* 右侧详情:预览卡片 / 表格玻璃化 */
.orders-page .orders-preview-block,
.orders-page .orders-table {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(18px) saturate(170%);
backdrop-filter: blur(18px) saturate(170%);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 6px 20px var(--lf-shadow);
}
.orders-page .orders-table th { background: var(--lf-input-bg); color: var(--lf-text-dim); }
.orders-page .orders-table td { color: var(--lf-text); }
.orders-page .orders-table tr:last-child td { border-bottom: none; }
/* 状态 pill 保留红/绿/蓝状态底色,只加玻璃内高光与描边 */
.orders-page .orders-status {
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 0 0 1px var(--lf-glass-soft);
}
.orders-page .status-success { color: var(--lf-text); }
.orders-page .status-running { color: var(--lf-accent); }
.orders-page .status-failed { color: var(--lf-text); }
.orders-page .status-queued { color: var(--lf-text-dim); }
/* ── CanvasStudio(画布)/ Wordcloud 壳玻璃化 ──────
只玻璃化面板/浮动窗/底部 pill;保留 workspace 棋盘格与透明底标识。 */
.side-panel,
.floating-panel {
background: var(--lf-glass-bg);
border-color: var(--lf-glass-border);
-webkit-backdrop-filter: blur(18px) saturate(170%);
backdrop-filter: blur(18px) saturate(170%);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
inset 0 -1px 0 var(--lf-glass-shade),
0 8px 26px var(--lf-shadow);
}
.floating-panel {
box-shadow:
0 22px 60px var(--lf-shadow-strong),
inset 0 1px 0 var(--lf-glass-highlight);
}
.studio-bottombar .canvas-size-pill,
.zoom-controls {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(16px) saturate(170%);
backdrop-filter: blur(16px) saturate(170%);
color: var(--lf-text-dim);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 4px 14px var(--lf-shadow);
}
.studio-bottombar .zoom-controls span { color: var(--lf-text); }
/* 词云工作台:舞台容器玻璃背景(网格底下透光斑) */
.canvas-area {
background:
radial-gradient(80% 55% at 85% -5%, var(--lf-blob-1), transparent 60%),
radial-gradient(70% 55% at 0% 25%, var(--lf-blob-2), transparent 60%),
radial-gradient(85% 50% at 75% 115%, var(--lf-blob-3), transparent 60%),
linear-gradient(to bottom, var(--lf-bg-a) 0%, var(--lf-bg-b) 100%);
}
/* ═══════════════════════════════════════════════════════════
FindPage — LeleFlix 设计语言(主题驱动,浅色/深色两套)
液体玻璃:blur+saturate+内高光+光折射扫过。
只影响查找页(.find-page 作用域),其余页面保持原主题。
═══════════════════════════════════════════════════════════ */
/* ── 设计令牌:浅色(默认)────────────────────────── */
:root {
--lf-bg-a: #eef3f9;
--lf-bg-b: #e6ebf4;
--lf-nav-bg: rgba(255, 255, 255, 0.62);
--lf-nav-border: rgba(17, 24, 39, 0.06);
--lf-text: #171a1f;
--lf-text-dim: rgba(30, 41, 59, 0.72);
--lf-text-faint: rgba(30, 41, 59, 0.5);
--lf-glass-bg: rgba(255, 255, 255, 0.62);
--lf-glass-border: rgba(255, 255, 255, 0.8);
--lf-glass-soft: rgba(17, 24, 39, 0.09);
--lf-glass-highlight: rgba(255, 255, 255, 0.9);
--lf-glass-shade: rgba(17, 24, 39, 0.08);
--lf-shadow: rgba(17, 24, 39, 0.15);
--lf-shadow-strong: rgba(17, 24, 39, 0.24);
--lf-input-bg: rgba(255, 255, 255, 0.7);
--lf-opt-bg: #ffffff;
--lf-accent: #2563eb;
--lf-accent-soft: rgba(59, 130, 246, 0.16);
--lf-accent-border: rgba(59, 130, 246, 0.5);
--lf-accent-hover: rgba(59, 130, 246, 0.24);
--lf-sweep: rgba(255, 255, 255, 0.65);
--lf-sweep-soft: rgba(255, 255, 255, 0.5);
--lf-frame-bg: #ffffff;
--lf-frame-shadow: rgba(17, 24, 39, 0.22);
--lf-err-bg: rgba(254, 226, 226, 0.75);
--lf-err-border: rgba(220, 38, 38, 0.28);
--lf-err-text: #b91c1c;
--lf-blob-1: rgba(96, 165, 250, 0.32);
--lf-blob-2: rgba(131, 120, 255, 0.22);
--lf-blob-3: rgba(16, 185, 129, 0.16);
}
/* ── 设计令牌(深色)─────────────────────────────── */
:root[data-theme="dark"] {
--lf-bg-a: #141824;
--lf-bg-b: #07080c;
--lf-nav-bg: rgba(8, 10, 15, 0.55);
--lf-nav-border: rgba(255, 255, 255, 0.06);
--lf-text: #f5f5f7;
--lf-text-dim: rgba(235, 235, 245, 0.62);
--lf-text-faint: rgba(235, 235, 245, 0.5);
--lf-glass-bg: rgba(16, 18, 24, 0.55);
--lf-glass-border: rgba(255, 255, 255, 0.10);
--lf-glass-soft: rgba(255, 255, 255, 0.06);
--lf-glass-highlight: rgba(255, 255, 255, 0.13);
--lf-glass-shade: rgba(0, 0, 0, 0.25);
--lf-shadow: rgba(0, 0, 0, 0.38);
--lf-shadow-strong: rgba(0, 0, 0, 0.5);
--lf-input-bg: rgba(8, 10, 15, 0.45);
--lf-opt-bg: #131621;
--lf-accent: #93c5fd;
--lf-accent-soft: rgba(96, 165, 250, 0.2);
--lf-accent-border: rgba(147, 197, 253, 0.55);
--lf-accent-hover: rgba(96, 165, 250, 0.3);
--lf-sweep: rgba(255, 255, 255, 0.10);
--lf-sweep-soft: rgba(255, 255, 255, 0.05);
--lf-frame-bg: #ffffff; /* 预览图底恒定白,保证名字可读 */
--lf-frame-shadow: rgba(0, 0, 0, 0.5);
--lf-err-bg: rgba(127, 29, 29, 0.5);
--lf-err-border: rgba(251, 113, 133, 0.25);
--lf-err-text: #fda4af;
--lf-blob-1: rgba(96, 165, 250, 0.34);
--lf-blob-2: rgba(131, 120, 255, 0.28);
--lf-blob-3: rgba(20, 201, 164, 0.2);
}
/* ── 页面基底(两套背景) ─────────────────────────── */
.find-page {
background:
radial-gradient(120% 80% at 50% -10%, var(--lf-bg-a) 0%, transparent 55%),
linear-gradient(to bottom, var(--lf-bg-b) 0%, var(--lf-bg-b) 100%);
}
.find-page .navbar {
background: var(--lf-nav-bg);
-webkit-backdrop-filter: blur(20px) saturate(160%);
backdrop-filter: blur(20px) saturate(160%);
border-bottom: 1px solid var(--lf-nav-border);
}
.find-page .navbar-brand-name { color: var(--lf-text); }
.find-page .nav-btn-label { color: var(--lf-text-dim); }
.find-page .find-page-bg .blob-1 { background: radial-gradient(circle, var(--lf-blob-1), transparent 70%); }
.find-page .find-page-bg .blob-2 { background: radial-gradient(circle, var(--lf-blob-2), transparent 70%); }
.find-page .find-page-bg .blob-3 { background: radial-gradient(circle, var(--lf-blob-3), transparent 70%); }
/* 液体玻璃通用(LeleFlix 配方) */
.find-page .glass,
.find-page .find-footer {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
inset 0 -1px 0 var(--lf-glass-shade);
}
/* 词云视口:透明舞台 —— 不做大色板/遮罩,画布干净浮起,
玻璃效果只给按钮。
固定铺满视口可交互区(顶部避让玻璃工具条,底部避让结果 pill),
必须有真实宽高,ResizeObserver 才拿得到尺寸、画布才能适配进来 */
.find-page .find-stage {
position: fixed; left: 16px; right: 16px;
top: calc(var(--nav-height) + 100px);
bottom: 78px;
border-radius: 18px;
background: transparent;
touch-action: none;
}
.find-stage.panning { cursor: grabbing; }
.find-stage:not(.panning) { cursor: grab; }
.find-viewport { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform; }
/* 离散跳转(放大定位 / 查看整图)时平滑过渡;拖动 / 捏合过程视图要即时跟手 → 无过渡 */
.find-viewport { transition: transform 0s; }
.find-viewport.animating { transition: transform 360ms cubic-bezier(0.22, 1, 0.36, 1); }
/* 预览图背景恒定白色,深色模式下名字依然清晰;
只留一道柔和投影让白色画布从页面浮起,不做厚重毛边/遮罩 */
.find-page .find-preview-frame {
position: relative; flex-shrink: 0;
background: var(--lf-frame-bg);
border-radius: 4px;
box-shadow: 0 14px 40px var(--lf-frame-shadow);
}
.find-preview-img { display: block; width: 100%; height: 100%; object-fit: contain; user-select: none; -webkit-user-drag: none; pointer-events: none; }
/* 悬浮玻璃工具条:LeleFlix 控制条风格(胶囊 + 光扫) */
.find-page .find-glassbar {
position: fixed; z-index: 30;
top: calc(var(--nav-height) + 14px);
left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 12px; flex-wrap: wrap; justify-content: center;
padding: 12px 16px; border-radius: 24px;
max-width: calc(100vw - 24px);
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
inset 0 -1px 0 var(--lf-glass-shade);
overflow: hidden;
}
/* 光折射扫过(工具条静止时缓慢扫过,最像 LeleFlix 控制栏) */
.find-page .find-glassbar::before {
content: '';
position: absolute;
top: 0; left: -100%;
width: 45%; height: 100%;
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, var(--lf-sweep-soft) 50%, rgba(255,255,255,0) 100%);
pointer-events: none;
animation: findGlassSweep 5.5s ease-in-out 1.2s infinite;
}
@keyframes findGlassSweep {
0% { left: -100%; }
55%, 100% { left: 220%; }
}
.find-page .find-glassbar .glass-field { display: flex; flex-direction: column; gap: 3px; }
.find-page .find-glassbar .glass-label { font-size: 11px; color: var(--lf-text-faint); padding-left: 4px; letter-spacing: 0.04em; }
.find-page .find-glassbar select,
.find-page .find-glassbar input[type="text"] {
border: 1px solid var(--lf-glass-soft); border-radius: 14px;
background: var(--lf-input-bg); color: var(--lf-text); font-size: 13px;
padding: 10px 12px; outline: none; font-family: var(--font-main);
-webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px);
transition: border-color var(--transition), box-shadow var(--transition);
}
.find-page .find-glassbar select { width: 230px; min-width: 150px; cursor: pointer; }
.find-page .find-glassbar input[type="text"] { width: 220px; }
.find-page .find-glassbar select:focus,
.find-page .find-glassbar input[type="text"]:focus {
border-color: var(--lf-accent-border);
box-shadow: 0 0 0 3px var(--lf-accent-soft);
}
.find-page .find-glassbar select option { background: var(--lf-opt-bg); color: var(--lf-text); }
.find-page .find-glassbar select:disabled { opacity: 0.5; }
/* 圆形液体玻璃媒体按钮(播放/暂停式) */
.find-page .find-actions { display: flex; align-items: center; gap: 10px; }
.find-page .find-btn {
width: 42px; height: 42px; padding: 0;
border-radius: 50%; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--lf-glass-border);
background: var(--lf-glass-bg);
color: var(--lf-text); font-size: 13px; font-family: var(--font-main);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
inset 0 -1px 0 var(--lf-glass-shade);
position: relative; overflow: hidden;
transition: transform var(--transition), background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
/* 光折射扫过动画 */
.find-page .find-btn::before {
content: '';
position: absolute; top: 0; left: -100%;
width: 60%; height: 100%;
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, var(--lf-sweep) 50%, rgba(255,255,255,0) 100%);
pointer-events: none;
}
.find-page .find-btn:hover:not(:disabled)::before { animation: findGlassSweep 0.9s ease-out forwards; }
/* 内发光层 */
.find-page .find-btn::after {
content: '';
position: absolute; inset: 0; border-radius: 50%;
background: linear-gradient(135deg, var(--lf-glass-highlight) 0%, rgba(255,255,255,0) 50%, var(--lf-glass-soft) 100%);
pointer-events: none;
}
.find-page .find-btn svg { position: relative; z-index: 2; flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3)); }
.find-page .find-btn:hover:not(:disabled) {
background: var(--lf-accent-soft);
border-color: var(--lf-accent-border);
transform: scale(1.1);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
0 0 20px var(--lf-accent-soft);
}
.find-page .find-btn:active:not(:disabled) { transform: scale(0.96); }
.find-page .find-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 查找 = 加大号主媒体按钮 */
.find-page .find-btn.find-btn-primary {
width: 48px; height: 48px;
background: var(--lf-accent-soft);
border: 1px solid var(--lf-accent-border);
color: var(--lf-accent);
}
.find-page .find-btn.find-btn-primary:hover:not(:disabled) {
background: var(--lf-accent-hover);
border-color: var(--lf-accent);
box-shadow:
0 14px 40px var(--lf-shadow-strong),
inset 0 1px 0 var(--lf-glass-highlight),
0 0 26px var(--lf-accent-soft);
}
.find-page .find-btn.find-btn-primary svg { filter: drop-shadow(0 2px 5px rgba(0, 0, 0, 0.25)); }
/* 精确 / 包含 切换(玻璃胶囊) */
.find-page .mode-toggle {
display: inline-flex; gap: 2px;
border: 1px solid var(--lf-glass-soft); border-radius: 14px;
padding: 3px;
background: var(--lf-input-bg);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
}
.find-page .mode-toggle button {
flex: 1; padding: 8px 12px; border: none; border-radius: 11px;
background: transparent; color: var(--lf-text-dim);
font-size: 12px; font-family: var(--font-main); cursor: pointer;
transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}
.find-page .mode-toggle button.active {
background: var(--lf-glass-bg);
color: var(--lf-text); font-weight: 600;
box-shadow: inset 0 1px 0 var(--lf-glass-highlight), 0 2px 8px var(--lf-shadow-strong);
}
/* 底部结果统计(玻璃 pill) */
.find-page .find-footer {
position: fixed; z-index: 30; bottom: 14px; left: 50%; transform: translateX(-50%);
color: var(--lf-text-dim); font-size: 13px;
padding: 8px 18px; border-radius: 999px;
white-space: nowrap; max-width: calc(100vw - 160px); overflow: hidden; text-overflow: ellipsis;
}
.find-page .find-footer strong { color: var(--lf-text); font-weight: 600; }
/* 右下角「查看整图 / 重新放大」:大玻璃胶囊 + 光扫 */
.find-page .find-corner-btn {
position: fixed; z-index: 30; right: 20px; bottom: 20px;
height: 46px; padding: 0 20px; border-radius: 23px; cursor: pointer;
border: 1px solid var(--lf-glass-border);
background: var(--lf-glass-bg); color: var(--lf-text);
font-size: 13px; font-family: var(--font-main); font-weight: 600;
display: inline-flex; align-items: center; gap: 8px;
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
inset 0 -1px 0 var(--lf-glass-shade);
overflow: hidden;
transition: transform var(--transition), background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.find-page .find-corner-btn::before {
content: '';
position: absolute; top: 0; left: -100%;
width: 50%; height: 100%;
background: linear-gradient(90deg, rgba(255,255,255,0) 0%, var(--lf-sweep) 50%, rgba(255,255,255,0) 100%);
pointer-events: none;
}
.find-page .find-corner-btn:hover:not(:disabled)::before { animation: findGlassSweep 1s ease-out forwards; }
.find-page .find-corner-btn svg { flex-shrink: 0; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); }
.find-page .find-corner-btn:hover:not(:disabled) {
background: var(--lf-accent-soft);
border-color: var(--lf-accent-border);
transform: translateY(-2px);
box-shadow:
inset 0 1px 0 var(--lf-glass-highlight),
0 0 22px var(--lf-accent-soft);
}
.find-page .find-corner-btn:active:not(:disabled) { transform: translateY(0) scale(0.98); }
.find-page .find-corner-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* 缩放提示 */
.find-page .find-scale-tag {
position: fixed; z-index: 29; right: 22px; bottom: 78px;
font-size: 11px; color: var(--lf-text-faint); padding: 4px 12px; border-radius: 999px;
background: var(--lf-glass-bg); border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
user-select: none; pointer-events: none;
}
/* 悬浮错误提示(玻璃红条) */
.find-page .find-error-fixed {
position: fixed; z-index: 40;
top: calc(var(--nav-height) + 92px); left: 50%; transform: translateX(-50%);
color: var(--lf-err-text); background: var(--lf-err-bg);
border: 1px solid var(--lf-err-border);
padding: 8px 14px; border-radius: 12px; font-size: 13px;
-webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
white-space: nowrap; max-width: calc(100vw - 24px); overflow: hidden; text-overflow: ellipsis;
}
/* 登录卡片玻璃化 */
.orders-page .login-card,
.find-page .login-card {
background: var(--lf-glass-bg);
border: 1px solid var(--lf-glass-border);
-webkit-backdrop-filter: blur(22px) saturate(180%);
backdrop-filter: blur(22px) saturate(180%);
box-shadow: inset 0 1px 0 var(--lf-glass-highlight);
}
.orders-page .login-title,
.find-page .login-title { color: var(--lf-text); }
.orders-page .orders-hint,
.find-page .orders-hint { color: var(--lf-text-dim); }
.orders-page .orders-input,
.find-page .orders-input {
background: var(--lf-input-bg); border-color: var(--lf-glass-soft);
color: var(--lf-text);
}
.orders-page .orders-input:focus,
.find-page .orders-input:focus { border-color: var(--lf-accent-border); }
/* 未选任务时的居中占位 */
.find-page .find-stage .table-empty { height: 100%; display: flex; align-items: center; justify-content: center; color: var(--lf-text-faint); }