feat: add find page, breadcrumb components and canvas workbench updates
This commit is contained in:
+16
-3
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user