feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
@@ -178,6 +178,12 @@ export default function AdvancedPanel({
|
||||
</div>
|
||||
</div>
|
||||
<Hint>名单较少时可增大重复次数(如 5~10)提升填充观感</Hint>
|
||||
<BoolField
|
||||
label="自动重复填充至轮廓完整"
|
||||
checked={params.autoRepeatToFill}
|
||||
onChange={v => onParamsChange({ autoRepeatToFill: v })}
|
||||
hint="开启后,当掩膜轮廓填不满时自动循环追加名字副本,直到形状轮廓填充完毕(最多 20 次)"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>字号与比例</SectionTitle>
|
||||
@@ -198,8 +204,17 @@ export default function AdvancedPanel({
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="竖排概率 VERTICAL_RATIO"
|
||||
value={params.verticalRatio}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ verticalRatio: v ?? 0.18 })}
|
||||
/>
|
||||
</div>
|
||||
<Hint>SIZE_RATIO 控制最大与最小字号跨度,默认 2.0;过大会出现极端字号差</Hint>
|
||||
<Hint>VERTICAL_RATIO 为每个词竖排的概率,默认 0.18;横竖混排可打散过于规整的观感</Hint>
|
||||
|
||||
<div className="form-row">
|
||||
<NumberField
|
||||
@@ -235,7 +250,7 @@ export default function AdvancedPanel({
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.01}
|
||||
onChange={v => onParamsChange({ workScale: v ?? 0.2 })}
|
||||
onChange={v => onParamsChange({ workScale: v ?? 0.18 })}
|
||||
/>
|
||||
<NumberField
|
||||
label="目标填充率 TARGET_FILL"
|
||||
@@ -257,6 +272,15 @@ export default function AdvancedPanel({
|
||||
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>调试</SectionTitle>
|
||||
<BoolField
|
||||
label="生成调试文件"
|
||||
checked={params.saveDebugImages}
|
||||
onChange={v => onParamsChange({ saveDebugImages: v })}
|
||||
hint="开启后保存掩膜和占用网格等中间图片;正常生成建议关闭以减少磁盘 I/O"
|
||||
/>
|
||||
|
||||
<div className="section-divider" />
|
||||
<SectionTitle>画布</SectionTitle>
|
||||
<div className="form-row">
|
||||
|
||||
@@ -9,10 +9,11 @@ interface CanvasAreaProps {
|
||||
viewMode: '2d' | '3d';
|
||||
zoom: number;
|
||||
highlightLocation: NameLocation | null;
|
||||
onImageLoaded?: () => void;
|
||||
}
|
||||
|
||||
export default function CanvasArea({
|
||||
maskFile, jobResult, viewMode, zoom, highlightLocation
|
||||
maskFile, jobResult, viewMode, zoom, highlightLocation, onImageLoaded
|
||||
}: CanvasAreaProps) {
|
||||
const [maskPreviewUrl, setMaskPreviewUrl] = useState<string | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
@@ -44,7 +45,7 @@ export default function CanvasArea({
|
||||
</div>
|
||||
) : viewMode === '3d' ? (
|
||||
<div className="view-3d-container">
|
||||
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" />
|
||||
<img src={displayUrl} alt="wordcloud 3D" className="view-3d-image" onLoad={onImageLoaded} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="canvas-image-wrapper">
|
||||
@@ -52,6 +53,7 @@ export default function CanvasArea({
|
||||
src={displayUrl}
|
||||
alt="wordcloud"
|
||||
className="canvas-image"
|
||||
onLoad={onImageLoaded}
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
/>
|
||||
{highlightLocation && jobResult && (
|
||||
|
||||
@@ -268,3 +268,12 @@ export function IconHelp() {
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCopy() {
|
||||
return (
|
||||
<Icon>
|
||||
<rect x="4" y="2" width="9" height="11" rx="1.5" />
|
||||
<path d="M3 5h-.5a1.5 1.5 0 0 0-1.5 1.5v6A1.5 1.5 0 0 0 2.5 14h6A1.5 1.5 0 0 0 10 12.5V12" />
|
||||
</Icon>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,33 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { SSEProgress } from '../types';
|
||||
import { IconCross, IconCheckmark, IconGear } from './Icons';
|
||||
import { IconCross, IconCheckmark, IconGear, IconCopy } from './Icons';
|
||||
|
||||
interface ProgressPanelProps {
|
||||
progress: SSEProgress | null;
|
||||
logLines: string[];
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function ProgressPanel({ progress, visible }: ProgressPanelProps) {
|
||||
export default function ProgressPanel({ progress, logLines, visible }: ProgressPanelProps) {
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll the log view to the bottom whenever new lines arrive.
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
}, [logLines]);
|
||||
|
||||
// All hooks MUST run before any early return, so derive the values the
|
||||
// callback needs without depending on `progress` being non-null.
|
||||
const message = progress?.message ?? '';
|
||||
|
||||
const handleCopyError = useCallback(() => {
|
||||
const text = logLines.length > 0 ? logLines.join('\n') : message;
|
||||
navigator.clipboard.writeText(text).catch(() => {
|
||||
const el = document.querySelector('.error-detail-textarea') as HTMLTextAreaElement | null;
|
||||
if (el) { el.select(); document.execCommand('copy'); }
|
||||
});
|
||||
}, [logLines, message]);
|
||||
|
||||
if (!visible || !progress) return null;
|
||||
|
||||
const isFailed = progress.stage === '生成失败' || progress.stage === '错误';
|
||||
@@ -20,6 +41,13 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
<div className="progress-stage" style={isFailed ? { color: 'var(--danger)' } : {}}>
|
||||
{progress.stage}
|
||||
</div>
|
||||
{(progress.elapsedSeconds != null || progress.clientElapsedSeconds != null) && (
|
||||
<div className="progress-timing" style={{ color: isDone ? 'var(--success)' : 'var(--text-muted)' }}>
|
||||
{progress.elapsedSeconds != null && `后端耗时 ${progress.elapsedSeconds.toFixed(2)} 秒`}
|
||||
{progress.elapsedSeconds != null && progress.clientElapsedSeconds != null ? ' · ' : ''}
|
||||
{progress.clientElapsedSeconds != null && `前端显示耗时 ${progress.clientElapsedSeconds.toFixed(2)} 秒`}
|
||||
</div>
|
||||
)}
|
||||
{!isFailed && (
|
||||
<div className="progress-bar-track">
|
||||
<div
|
||||
@@ -31,6 +59,33 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* ── 详细日志(实时滚动) ───────────────────────────────── */}
|
||||
{logLines.length > 0 && (
|
||||
<div
|
||||
className="progress-log-view"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-secondary, #1a1a2e)',
|
||||
borderRadius: 6,
|
||||
padding: '8px 10px',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.6,
|
||||
color: 'var(--text-muted, #888)',
|
||||
border: '1px solid var(--border-color, #333)',
|
||||
}}
|
||||
>
|
||||
{logLines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
)}
|
||||
{/* ── 失败时的错误详情 + 复制按钮 ───────────────────────── */}
|
||||
<div
|
||||
className="progress-message"
|
||||
style={{
|
||||
@@ -41,7 +96,19 @@ export default function ProgressPanel({ progress, visible }: ProgressPanelProps)
|
||||
marginTop: isFailed ? 8 : 0,
|
||||
}}
|
||||
>
|
||||
{progress.message}
|
||||
{isFailed ? (
|
||||
<div className="error-detail-container">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={handleCopyError}
|
||||
title="复制完整日志"
|
||||
style={{ marginBottom: 6, display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
<IconCopy /> 复制完整日志
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { CanvasDocument, StickerAsset } from '../types';
|
||||
import { normalizeDocument } from './canvasDocument';
|
||||
import { apiUrl } from './api';
|
||||
import { createZip, ZipFileInput } from './zip';
|
||||
|
||||
interface PackageAssetMeta {
|
||||
id: string;
|
||||
originalAssetId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function stickerMimeType(asset: StickerAsset): string {
|
||||
if (asset.mimeType) return asset.mimeType;
|
||||
return asset.type === 'svg' ? 'image/svg+xml' : 'image/png';
|
||||
}
|
||||
|
||||
function stickerFileExtension(asset: StickerAsset): string {
|
||||
if (asset.type === 'svg') return '.svg';
|
||||
if (asset.mimeType === 'image/jpeg') return '.jpg';
|
||||
if (asset.mimeType === 'image/png') return '.png';
|
||||
const source = asset.source.toLowerCase();
|
||||
if (source.endsWith('.jpg') || source.endsWith('.jpeg')) return '.jpg';
|
||||
return '.png';
|
||||
}
|
||||
|
||||
export function safePackageBaseName(name: string): string {
|
||||
const cleaned = name.trim().replace(/[\\/:*?"<>|\n\t]/g, '_').replace(/\s+/g, '_').slice(0, 80);
|
||||
return cleaned || '画布设计';
|
||||
}
|
||||
|
||||
export async function exportCanvasPackage(
|
||||
documentModel: CanvasDocument,
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
name = '画布设计',
|
||||
description = '',
|
||||
): Promise<Blob> {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const usedAssetIds: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
doc.elements.forEach(element => {
|
||||
if (element.type !== 'sticker') return;
|
||||
if (seen.has(element.assetId)) return;
|
||||
seen.add(element.assetId);
|
||||
usedAssetIds.push(element.assetId);
|
||||
});
|
||||
|
||||
const packageIdByAsset = new Map<string, string>();
|
||||
usedAssetIds.forEach((assetId, index) => {
|
||||
packageIdByAsset.set(assetId, `asset-${String(index + 1).padStart(3, '0')}`);
|
||||
});
|
||||
|
||||
const files: ZipFileInput[] = [];
|
||||
const packageAssets: PackageAssetMeta[] = [];
|
||||
|
||||
for (const assetId of usedAssetIds) {
|
||||
const asset = stickerById.get(assetId);
|
||||
if (!asset) throw new Error(`画布引用了缺失素材:${assetId}`);
|
||||
const res = await fetch(apiUrl(asset.source));
|
||||
if (!res.ok) throw new Error(`读取素材失败:${asset.name} (${res.status})`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const packageId = packageIdByAsset.get(assetId) || assetId;
|
||||
const ext = stickerFileExtension(asset);
|
||||
packageAssets.push({
|
||||
id: packageId,
|
||||
originalAssetId: assetId,
|
||||
name: asset.name,
|
||||
type: asset.type,
|
||||
mimeType: stickerMimeType(asset),
|
||||
size: bytes.length,
|
||||
});
|
||||
files.push({
|
||||
name: `assets/${packageId}${ext}`,
|
||||
content: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
const packageDocument = {
|
||||
...doc,
|
||||
elements: doc.elements.map(element => {
|
||||
if (element.type !== 'sticker') return element;
|
||||
return {
|
||||
...element,
|
||||
assetId: packageIdByAsset.get(element.assetId) || element.assetId,
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
const manifest = {
|
||||
format: 'wordcloud-canvas',
|
||||
version: 1,
|
||||
name: name.trim() || '画布设计',
|
||||
description: description.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
canvas: {
|
||||
width: doc.width,
|
||||
height: doc.height,
|
||||
background: doc.background,
|
||||
},
|
||||
assets: packageAssets,
|
||||
fonts: [],
|
||||
};
|
||||
|
||||
files.unshift({
|
||||
name: 'manifest.json',
|
||||
content: JSON.stringify(manifest, null, 2),
|
||||
});
|
||||
files.splice(1, 0, {
|
||||
name: 'document.json',
|
||||
content: JSON.stringify(packageDocument, null, 2),
|
||||
});
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
@@ -115,6 +115,7 @@ export async function loadStickerLibrary(): Promise<StickerAsset[]> {
|
||||
source: a.file_url,
|
||||
createdAt: a.created_at,
|
||||
tint: tints[a.asset_id] as StickerAsset['tint'],
|
||||
mimeType: a.mime_type,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -136,6 +137,7 @@ export async function addStickerAsset(
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
tint: input.tint,
|
||||
mimeType: asset.mime_type,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
@@ -160,6 +162,7 @@ export async function addStickerAssetFromJob(
|
||||
type: asset.mime_type === 'image/svg+xml' ? 'svg' : 'image',
|
||||
source: asset.file_url,
|
||||
createdAt: asset.created_at,
|
||||
mimeType: asset.mime_type,
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent(STICKER_LIBRARY_EVENT));
|
||||
return sticker;
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createZip } from './zip';
|
||||
export interface SerializeOptions {
|
||||
layerIds?: string[];
|
||||
includeBackground?: boolean;
|
||||
/** Add two in-canvas registration dots for physical/image alignment. */
|
||||
addRegistrationMarks?: boolean;
|
||||
}
|
||||
|
||||
async function fetchBlobAsDataUrl(url: string): Promise<string> {
|
||||
@@ -108,6 +110,11 @@ export async function serializeDocument(
|
||||
parts.push(`<line x1="0" y1="${element.height / 2}" x2="${element.width}" y2="${element.height / 2}" stroke="${escapeXml(element.stroke)}" stroke-width="${element.strokeWidth}" stroke-linecap="round" opacity="${opacity}" transform="${transform}"/>`);
|
||||
}
|
||||
|
||||
// Keep marks last so canvas elements cannot cover the alignment targets.
|
||||
if (options.addRegistrationMarks) {
|
||||
parts.push(serializeRegistrationMarks(doc.width, doc.height));
|
||||
}
|
||||
|
||||
parts.push('</svg>');
|
||||
return parts.join('\n');
|
||||
}
|
||||
@@ -117,6 +124,7 @@ export async function createLayerExportZip(
|
||||
stickerById: Map<string, StickerAsset>,
|
||||
selectedLayerIds: string[],
|
||||
selectedFolderIds: string[],
|
||||
options: Pick<SerializeOptions, 'addRegistrationMarks'> = {},
|
||||
) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const files: { name: string; content: string }[] = [];
|
||||
@@ -125,7 +133,7 @@ export async function createLayerExportZip(
|
||||
if (hasCanvasBackground(doc.background)) {
|
||||
files.push({
|
||||
name: uniqueSvgName('背景', used),
|
||||
content: serializeBackgroundLayer(doc),
|
||||
content: serializeBackgroundLayer(doc, options),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,7 +142,7 @@ export async function createLayerExportZip(
|
||||
if (!layer) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(layer.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false }),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: [layer.id], includeBackground: false, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -143,24 +151,37 @@ export async function createLayerExportZip(
|
||||
if (!folder) continue;
|
||||
files.push({
|
||||
name: uniqueSvgName(folder.name, used),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false }),
|
||||
content: await serializeDocument(doc, stickerById, { layerIds: folder.layerIds, includeBackground: false, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
return createZip(files);
|
||||
}
|
||||
|
||||
function serializeBackgroundLayer(documentModel: CanvasDocument) {
|
||||
function serializeBackgroundLayer(documentModel: CanvasDocument, options: Pick<SerializeOptions, 'addRegistrationMarks'> = {}) {
|
||||
const doc = normalizeDocument(documentModel);
|
||||
const widthMm = pxToMm(doc.width).toFixed(1);
|
||||
const heightMm = pxToMm(doc.height).toFixed(1);
|
||||
return [
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${widthMm}mm" height="${heightMm}mm" viewBox="0 0 ${doc.width} ${doc.height}">`,
|
||||
`<rect width="100%" height="100%" fill="${escapeXml(doc.background)}"/>`,
|
||||
...(options.addRegistrationMarks ? [serializeRegistrationMarks(doc.width, doc.height)] : []),
|
||||
'</svg>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function serializeRegistrationMarks(width: number, height: number) {
|
||||
const minDimension = Math.max(1, Math.min(width, height));
|
||||
// Keep the circles inside the canvas so neither SVG nor raster consumers clip them.
|
||||
const inset = Math.max(4, minDimension * 0.012);
|
||||
const radius = Math.max(1.5, minDimension * 0.004);
|
||||
const format = (value: number) => formatSvgNumber(value);
|
||||
return [
|
||||
`<circle cx="${format(inset)}" cy="${format(inset)}" r="${format(radius)}" fill="#000000"/>`,
|
||||
`<circle cx="${format(width - inset)}" cy="${format(height - inset)}" r="${format(radius)}" fill="#000000"/>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function serializeInlineSvgSticker(
|
||||
svgText: string,
|
||||
targetWidth: number,
|
||||
|
||||
@@ -46,6 +46,20 @@ export async function createCanvasTemplate(input: {
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function importCanvasTemplate(
|
||||
file: File,
|
||||
name = '',
|
||||
description = '',
|
||||
): Promise<CanvasTemplate> {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (name) fd.append('name', name.trim());
|
||||
if (description) fd.append('description', description.trim());
|
||||
const res = await ensureOk(await fetch(apiUrl('/api/design-templates/import'), { method: 'POST', body: fd }), '导入设计包失败');
|
||||
const template = (await res.json()) as CanvasTemplate;
|
||||
return { ...template, document: normalizeDocument(template.document) };
|
||||
}
|
||||
|
||||
export async function updateCanvasTemplate(
|
||||
id: string,
|
||||
partial: {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from '../lib/canvasDocument';
|
||||
import { createCanvasTemplate, duplicateDocument, uploadAsset } from '../lib/templateLibrary';
|
||||
import { createLayerExportZip, serializeDocument } from '../lib/svgExport';
|
||||
import { exportCanvasPackage, safePackageBaseName } from '../lib/canvasPackage';
|
||||
import { apiUrl, ensureOk } from '../lib/api';
|
||||
import {
|
||||
IconGrid,
|
||||
@@ -461,16 +462,16 @@ export default function CanvasStudio({
|
||||
});
|
||||
};
|
||||
|
||||
const exportSvg = useCallback(async () => {
|
||||
const svg = await serializeDocument(normalizedDocument, stickerById);
|
||||
const exportSvg = useCallback(async (addRegistrationMarks: boolean) => {
|
||||
const svg = await serializeDocument(normalizedDocument, stickerById, { addRegistrationMarks });
|
||||
downloadBlob(
|
||||
new Blob([svg], { type: 'image/svg+xml;charset=utf-8' }),
|
||||
'canvas-design.svg',
|
||||
);
|
||||
}, [normalizedDocument, stickerById]);
|
||||
|
||||
const exportLayerZip = async (layerIds: string[], folderIds: string[]) => {
|
||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds);
|
||||
const exportLayerZip = async (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => {
|
||||
const blob = await createLayerExportZip(normalizedDocument, stickerById, layerIds, folderIds, { addRegistrationMarks });
|
||||
downloadBlob(blob, 'canvas-layers.zip');
|
||||
};
|
||||
|
||||
@@ -858,6 +859,7 @@ export default function CanvasStudio({
|
||||
activeLayerId={activeLayerId}
|
||||
onActiveLayerChange={setActiveLayerId}
|
||||
onChange={setDocumentModel}
|
||||
stickerById={stickerById}
|
||||
/>
|
||||
);
|
||||
case 'sticker':
|
||||
@@ -1096,16 +1098,121 @@ export default function CanvasStudio({
|
||||
);
|
||||
}
|
||||
|
||||
function LayerThumbnail({
|
||||
documentModel,
|
||||
layer,
|
||||
stickerById,
|
||||
}: {
|
||||
documentModel: CanvasDocument;
|
||||
layer: CanvasLayer;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
}) {
|
||||
const elements = documentModel.elements.filter(element => element.layerId === layer.id);
|
||||
const fitScale = Math.min(1, 52 / Math.max(documentModel.width, documentModel.height, 1));
|
||||
const scaledWidth = Math.max(1, documentModel.width * fitScale);
|
||||
const scaledHeight = Math.max(1, documentModel.height * fitScale);
|
||||
return (
|
||||
<div className={`layer-thumb${layer.visible === false ? ' layer-thumb-hidden' : ''}`}>
|
||||
<div
|
||||
className="layer-thumb-scale"
|
||||
style={{ width: scaledWidth, height: scaledHeight }}
|
||||
>
|
||||
<div
|
||||
className="layer-thumb-doc"
|
||||
style={{ width: documentModel.width, height: documentModel.height, transform: `scale(${fitScale})` }}
|
||||
>
|
||||
{elements.map(element => (
|
||||
<LayerThumbElement
|
||||
key={element.id}
|
||||
element={element}
|
||||
asset={element.type === 'sticker' ? stickerById.get(element.assetId) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerThumbElement({
|
||||
element,
|
||||
asset,
|
||||
}: {
|
||||
element: CanvasElement;
|
||||
asset?: StickerAsset;
|
||||
}) {
|
||||
const baseStyle: CSSProperties = {
|
||||
position: 'absolute',
|
||||
left: element.x,
|
||||
top: element.y,
|
||||
width: element.width,
|
||||
height: element.height,
|
||||
opacity: element.opacity,
|
||||
transform: `rotate(${element.rotation}deg)`,
|
||||
};
|
||||
|
||||
if (element.type === 'sticker') {
|
||||
if (!asset) return (<div className="missing-sticker" style={baseStyle}>贴纸缺失</div>);
|
||||
return (
|
||||
<img
|
||||
className={`studio-sticker-image${asset.tint === 'gray' ? ' gray-mask' : ''} layer-thumb-image`}
|
||||
style={baseStyle}
|
||||
src={assetToDataUrl(asset)}
|
||||
alt={asset.name}
|
||||
draggable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'text') {
|
||||
return (
|
||||
<div
|
||||
className="layer-thumb-text"
|
||||
style={{
|
||||
...baseStyle,
|
||||
color: element.fill,
|
||||
fontFamily: element.fontFamily,
|
||||
fontSize: element.fontSize,
|
||||
fontWeight: element.fontWeight,
|
||||
}}
|
||||
>
|
||||
{element.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (element.type === 'line') {
|
||||
return (
|
||||
<div className="layer-thumb-line" style={baseStyle}>
|
||||
<div style={{ width: '100%', height: Math.max(1, element.strokeWidth), background: element.stroke }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`layer-thumb-shape${element.type === 'ellipse' ? ' layer-thumb-ellipse' : ''}`}
|
||||
style={{
|
||||
...baseStyle,
|
||||
background: element.fill === 'transparent' ? undefined : element.fill,
|
||||
border: element.strokeWidth > 0 ? `${Math.max(0, element.strokeWidth)}px solid ${element.stroke}` : undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LayersPanel({
|
||||
documentModel,
|
||||
activeLayerId,
|
||||
onActiveLayerChange,
|
||||
onChange,
|
||||
stickerById,
|
||||
}: {
|
||||
documentModel: CanvasDocument;
|
||||
activeLayerId: string;
|
||||
onActiveLayerChange: (id: string) => void;
|
||||
onChange: (documentModel: CanvasDocument) => void;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
}) {
|
||||
const layers = documentModel.layers || [];
|
||||
const folders = documentModel.layerFolders || [];
|
||||
@@ -1206,6 +1313,10 @@ function LayersPanel({
|
||||
))}
|
||||
{layers.slice().reverse().map(layer => (
|
||||
<div key={layer.id} className={`layer-row${layer.id === activeLayerId ? ' active' : ''}`}>
|
||||
<div className="layer-thumb-wrap">
|
||||
<LayerThumbnail documentModel={documentModel} layer={layer} stickerById={stickerById} />
|
||||
</div>
|
||||
<div className="layer-info">
|
||||
<div className="layer-main-line">
|
||||
<button className="icon-btn layer-icon-btn" title="显示/隐藏" onClick={() => updateLayer(layer.id, { visible: !layer.visible })}>
|
||||
{layer.visible ? <IconEyeOpen /> : <IconEyeClosed />}
|
||||
@@ -1235,6 +1346,7 @@ function LayersPanel({
|
||||
<button className="icon-btn layer-icon-btn" title="删除" onClick={() => deleteLayer(layer.id)}><IconTrash /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1308,8 +1420,8 @@ function CanvasExportPanel({
|
||||
documentModel: CanvasDocument;
|
||||
stickerById: Map<string, StickerAsset>;
|
||||
onUpdateDocument: (partial: Partial<CanvasDocument>) => void;
|
||||
onExportSvg: () => void;
|
||||
onExportLayerZip: (layerIds: string[], folderIds: string[]) => void;
|
||||
onExportSvg: (addRegistrationMarks: boolean) => void;
|
||||
onExportLayerZip: (layerIds: string[], folderIds: string[], addRegistrationMarks: boolean) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const [selectedLayerIds, setSelectedLayerIds] = useState<string[]>([]);
|
||||
@@ -1318,6 +1430,8 @@ function CanvasExportPanel({
|
||||
const [templateName, setTemplateName] = useState('');
|
||||
const [templateDescription, setTemplateDescription] = useState('');
|
||||
const [referenceFiles, setReferenceFiles] = useState<File[]>([]);
|
||||
const [addRegistrationMarks, setAddRegistrationMarks] = useState(false);
|
||||
const [exportingPackage, setExportingPackage] = useState(false);
|
||||
const layers = documentModel.layers || [];
|
||||
const folders = documentModel.layerFolders || [];
|
||||
const backgroundEnabled = hasCanvasBackground(documentModel.background);
|
||||
@@ -1355,6 +1469,24 @@ function CanvasExportPanel({
|
||||
}
|
||||
};
|
||||
|
||||
const exportPackage = async () => {
|
||||
if (exportingPackage) return;
|
||||
setExportingPackage(true);
|
||||
try {
|
||||
const blob = await exportCanvasPackage(
|
||||
documentModel,
|
||||
stickerById,
|
||||
templateName || '画布设计',
|
||||
templateDescription,
|
||||
);
|
||||
downloadBlob(blob, `${safePackageBaseName(templateName || '画布设计')}.wcd`);
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : '导出设计包失败');
|
||||
} finally {
|
||||
setExportingPackage(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="form-row">
|
||||
@@ -1412,7 +1544,18 @@ function CanvasExportPanel({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary btn-block" onClick={onExportSvg}>导出总图 SVG</button>
|
||||
<div className="form-group">
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={addRegistrationMarks}
|
||||
onChange={event => setAddRegistrationMarks(event.target.checked)}
|
||||
/>
|
||||
<span>添加定位点</span>
|
||||
</label>
|
||||
<div className="note-text">在导出的总图和每个分层文件左上角、右下角添加对齐点</div>
|
||||
</div>
|
||||
<button className="btn btn-primary btn-block" onClick={() => onExportSvg(addRegistrationMarks)}>导出总图 SVG</button>
|
||||
|
||||
<div className="section-divider" />
|
||||
<div className="section-title">分层打包导出</div>
|
||||
@@ -1441,7 +1584,7 @@ function CanvasExportPanel({
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-block"
|
||||
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds)}
|
||||
onClick={() => onExportLayerZip(selectedLayerIds, selectedFolderIds, addRegistrationMarks)}
|
||||
>
|
||||
打包导出 SVG
|
||||
</button>
|
||||
@@ -1469,6 +1612,13 @@ function CanvasExportPanel({
|
||||
<button className="btn btn-secondary btn-block" disabled={savingTemplate} onClick={saveTemplate}>
|
||||
{savingTemplate ? '保存中' : '保存当前画布为模板'}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary btn-block"
|
||||
disabled={exportingPackage}
|
||||
onClick={exportPackage}
|
||||
>
|
||||
{exportingPackage ? '打包中' : '导出 .wcd'}
|
||||
</button>
|
||||
<button className="btn btn-danger btn-block" onClick={onReset}>清空画布</button>
|
||||
<span style={{ display: 'none' }}>{stickerById.size}</span>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import AppSettingsWindow, { type ThemeMode } from '../components/AppSettingsWindow';
|
||||
import { BackendAsset, CanvasTemplate } from '../types';
|
||||
import { formatMm, normalizeDocument } from '../lib/canvasDocument';
|
||||
import {
|
||||
assetUrl,
|
||||
deleteCanvasTemplate,
|
||||
importCanvasTemplate,
|
||||
listAssets,
|
||||
listDesignTemplates,
|
||||
templateCoverId,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
IconCloud,
|
||||
IconRefresh,
|
||||
IconCanvas,
|
||||
IconDownload,
|
||||
IconHelp,
|
||||
} from '../components/Icons';
|
||||
|
||||
@@ -48,7 +50,9 @@ export default function TemplateHome({
|
||||
const [selected, setSelected] = useState<CanvasTemplate | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [stickerById, setStickerById] = useState(() => new Map<string, import('../types').StickerAsset>());
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadStickerLibrary().then(items => {
|
||||
@@ -92,6 +96,23 @@ export default function TemplateHome({
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleImportFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file || importing) return;
|
||||
setImporting(true);
|
||||
setError('');
|
||||
try {
|
||||
await importCanvasTemplate(file);
|
||||
await refresh();
|
||||
alert('设计包已导入');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '导入设计包失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="template-home">
|
||||
<nav className="navbar">
|
||||
@@ -104,6 +125,17 @@ export default function TemplateHome({
|
||||
<span className="nav-btn-icon"><IconRefresh /></span>
|
||||
<span className="nav-btn-label">刷新</span>
|
||||
</button>
|
||||
<button className="nav-btn" onClick={() => importFileRef.current?.click()} disabled={importing}>
|
||||
<span className="nav-btn-icon"><IconDownload /></span>
|
||||
<span className="nav-btn-label">{importing ? '导入中' : '导入 .wcd'}</span>
|
||||
</button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
accept=".wcd"
|
||||
onChange={handleImportFile}
|
||||
/>
|
||||
</div>
|
||||
<div className="navbar-end">
|
||||
<button className="nav-btn" onClick={onOpenCanvas}>
|
||||
|
||||
@@ -45,14 +45,21 @@ const DEFAULT_PARAMS: JobParams = {
|
||||
fontColor: '#000000',
|
||||
nRepetitions: 1,
|
||||
strokeWeights: true,
|
||||
autoRepeatToFill: true,
|
||||
// Intermediate mask/occupancy images are useful for diagnosis but add
|
||||
// extra disk I/O, so keep them disabled for normal generation.
|
||||
saveDebugImages: false,
|
||||
|
||||
// 字号与填充
|
||||
sizeRatio: 2.0,
|
||||
packingEfficiency: 0.9,
|
||||
verticalRatio: 0.18,
|
||||
targetFillRatio: 0.45,
|
||||
userMinFontSize: null,
|
||||
userMaxFontSize: null,
|
||||
minReadableHeightPx: 22,
|
||||
// 0.18 keeps the 750-word service request complete on the default mask;
|
||||
// lower values can make the coarse grid too small to place every word.
|
||||
workScale: 0.18,
|
||||
|
||||
// 画布
|
||||
@@ -118,7 +125,9 @@ export default function TestWorkbench({
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [jobResult, setJobResult] = useState<JobResult | null>(null);
|
||||
const [progress, setProgress] = useState<SSEProgress | null>(null);
|
||||
const [logLines, setLogLines] = useState<string[]>([]);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [clientElapsedSeconds, setClientElapsedSeconds] = useState<number | null>(null);
|
||||
const [openPanels, setOpenPanels] = useState<WorkbenchPanelId[]>(['import', 'advanced']);
|
||||
const [activeReplaceSession, setActiveReplaceSession] = useState<WordcloudReplaceSession | null>(null);
|
||||
const [replaceMaskReady, setReplaceMaskReady] = useState(false);
|
||||
@@ -130,6 +139,10 @@ export default function TestWorkbench({
|
||||
const [fonts, setFonts] = useState<Font[]>([]);
|
||||
const [selectedFontId, setSelectedFontId] = useState<string>('__default__');
|
||||
const sseRef = useRef<EventSource | null>(null);
|
||||
const requestStartedAtRef = useRef<number | null>(null);
|
||||
const displayedJobRef = useRef<string | null>(null);
|
||||
const previewRequestedJobRef = useRef<string | null>(null);
|
||||
const displayElapsedRef = useRef<number | null>(null);
|
||||
const floatingPanels = useFloatingPanels('wb-floating-panels', WORKBENCH_PANEL_LAYOUT);
|
||||
const focusWorkbenchPanel = floatingPanels.focusPanel;
|
||||
|
||||
@@ -408,7 +421,13 @@ export default function TestWorkbench({
|
||||
}
|
||||
|
||||
setIsGenerating(true);
|
||||
requestStartedAtRef.current = performance.now();
|
||||
displayedJobRef.current = null;
|
||||
previewRequestedJobRef.current = null;
|
||||
displayElapsedRef.current = null;
|
||||
setClientElapsedSeconds(null);
|
||||
setProgress({ stage: '准备中', percent: 0, message: '正在提交任务...' });
|
||||
setLogLines([]);
|
||||
setJobResult(null);
|
||||
setHighlightLocation(null);
|
||||
|
||||
@@ -427,8 +446,11 @@ export default function TestWorkbench({
|
||||
FONT_COLOR: params.fontColor || '#000000',
|
||||
N_REPETITIONS: params.nRepetitions,
|
||||
ENABLE_STROKE_WEIGHTS: params.strokeWeights,
|
||||
AUTO_REPEAT_TO_FILL: params.autoRepeatToFill,
|
||||
SAVE_DEBUG_IMAGES: params.saveDebugImages,
|
||||
SIZE_RATIO: params.sizeRatio,
|
||||
PACKING_EFFICIENCY: params.packingEfficiency,
|
||||
VERTICAL_RATIO: params.verticalRatio,
|
||||
TARGET_FILL_RATIO: params.targetFillRatio,
|
||||
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
|
||||
WORK_SCALE: params.workScale,
|
||||
@@ -487,11 +509,23 @@ export default function TestWorkbench({
|
||||
sse.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
const localElapsed = requestStartedAtRef.current == null
|
||||
? undefined
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setProgress({
|
||||
stage: msg.stage ?? '',
|
||||
percent: msg.progress_percent ?? 0,
|
||||
message: msg.message ?? '',
|
||||
elapsedSeconds: typeof msg.elapsed_seconds === 'number' ? msg.elapsed_seconds : undefined,
|
||||
clientElapsedSeconds: localElapsed,
|
||||
});
|
||||
if (msg.message) {
|
||||
setLogLines(prev => [...prev, msg.message]);
|
||||
}
|
||||
if (msg.stage === 'preview_ready' && previewRequestedJobRef.current !== id) {
|
||||
previewRequestedJobRef.current = id;
|
||||
fetchPreview(id);
|
||||
}
|
||||
if (msg.progress_percent >= 100 || msg.stage === 'completed' || msg.stage === 'failed') {
|
||||
sse.close();
|
||||
fetchResult(id);
|
||||
@@ -512,12 +546,48 @@ export default function TestWorkbench({
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPreview = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(apiUrl(`/api/jobs/${id}/result`));
|
||||
if (!res.ok) return;
|
||||
const data: JobResult = await res.json();
|
||||
if (!data.image_url || data.status === 'failed') return;
|
||||
const imageUrl = data.image_url || `/api/jobs/${id}/files/png`;
|
||||
const elapsed = requestStartedAtRef.current == null
|
||||
? undefined
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setClientElapsedSeconds(elapsed ?? null);
|
||||
setJobResult(prev => {
|
||||
// The final request may win the race with this preview fetch. Never
|
||||
// downgrade a completed result back to the partial running payload.
|
||||
if (prev?.status === 'success') return prev;
|
||||
return { ...prev, ...data, image_url: imageUrl };
|
||||
});
|
||||
setProgress(prev => {
|
||||
if (prev?.stage === '完成' || prev?.stage === '生成失败') return prev;
|
||||
return {
|
||||
stage: 'preview_ready',
|
||||
percent: Math.max(prev?.percent ?? 0, 94),
|
||||
message: '预览已显示,后台继续导出其余文件',
|
||||
elapsedSeconds: prev?.elapsedSeconds,
|
||||
clientElapsedSeconds: elapsed,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
// The final result request remains the source of truth if preview fetch fails.
|
||||
}
|
||||
};
|
||||
|
||||
// ─── 获取结果 ─────────────────────────────────────────────────────────────
|
||||
// GET /api/jobs/{job_id}/result
|
||||
const fetchResult = async (id: string) => {
|
||||
try {
|
||||
const res = await ensureOk(await fetch(apiUrl(`/api/jobs/${id}/result`)), '获取结果失败');
|
||||
const data: JobResult = await res.json();
|
||||
const resultElapsed = requestStartedAtRef.current == null
|
||||
? null
|
||||
: (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
setClientElapsedSeconds(resultElapsed);
|
||||
|
||||
if (data.status === 'failed') {
|
||||
// 尝试从 /detail 拿更详细的错误信息
|
||||
@@ -526,7 +596,8 @@ export default function TestWorkbench({
|
||||
const dr = await fetch(apiUrl(`/api/jobs/${id}/detail`));
|
||||
if (dr.ok) {
|
||||
const dd = await dr.json();
|
||||
detail = dd.error ?? dd.message ?? '';
|
||||
// API 返回 { status: { error: "...", ... }, recent_events: [...] }
|
||||
detail = dd.status?.error ?? '';
|
||||
} else {
|
||||
detail = await readApiError(dr);
|
||||
}
|
||||
@@ -534,7 +605,11 @@ export default function TestWorkbench({
|
||||
setProgress({
|
||||
stage: '生成失败',
|
||||
percent: 0,
|
||||
message: `任务失败${detail ? ':' + detail : ''}。可用 docker logs 查看后端堆栈。`,
|
||||
message: detail
|
||||
? `任务失败:\n${detail}`
|
||||
: '任务失败。可用 docker logs 查看后端堆栈。',
|
||||
elapsedSeconds: typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined,
|
||||
clientElapsedSeconds: resultElapsed ?? undefined,
|
||||
});
|
||||
setIsGenerating(false);
|
||||
return;
|
||||
@@ -546,8 +621,17 @@ export default function TestWorkbench({
|
||||
const svgUrl = data.svg_url || `/api/jobs/${id}/files/svg`;
|
||||
|
||||
setJobResult({ ...data, image_url: imageUrl, svg_url: svgUrl });
|
||||
setProgress({ stage: '完成', percent: 100, message: '词云生成完成!' });
|
||||
setTimeout(() => setProgress(null), 3000);
|
||||
const backendElapsed = typeof data.elapsed_seconds === 'number' ? data.elapsed_seconds : undefined;
|
||||
const visibleElapsed = displayElapsedRef.current ?? resultElapsed ?? undefined;
|
||||
setProgress({
|
||||
stage: '完成',
|
||||
percent: 100,
|
||||
message: backendElapsed != null
|
||||
? `词云生成完成,用时 ${backendElapsed.toFixed(2)} 秒${visibleElapsed != null ? `,前端显示 ${visibleElapsed.toFixed(2)} 秒(QoS判断根据)` : ''}`
|
||||
: '词云生成完成!',
|
||||
elapsedSeconds: backendElapsed,
|
||||
clientElapsedSeconds: visibleElapsed,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '未知错误';
|
||||
setProgress({ stage: '错误', percent: 0, message: msg });
|
||||
@@ -556,6 +640,28 @@ export default function TestWorkbench({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageLoaded = () => {
|
||||
const currentJobId = jobResult?.job_id;
|
||||
if (!currentJobId) return;
|
||||
const isFinal = progress?.stage === '完成';
|
||||
const displayKey = `${currentJobId}:${isFinal ? 'final' : 'preview'}`;
|
||||
if (displayedJobRef.current === displayKey) return;
|
||||
displayedJobRef.current = displayKey;
|
||||
if (requestStartedAtRef.current == null) return;
|
||||
const elapsed = (performance.now() - requestStartedAtRef.current) / 1000;
|
||||
displayElapsedRef.current = elapsed;
|
||||
setClientElapsedSeconds(elapsed);
|
||||
setProgress(prev => prev ? {
|
||||
...prev,
|
||||
clientElapsedSeconds: elapsed,
|
||||
message: isFinal
|
||||
? (prev.elapsedSeconds != null
|
||||
? `词云生成完成,用时 ${prev.elapsedSeconds.toFixed(2)} 秒,前端显示 ${elapsed.toFixed(2)} 秒`
|
||||
: `词云生成完成,前端显示 ${elapsed.toFixed(2)} 秒`)
|
||||
: `预览已显示,前端耗时 ${elapsed.toFixed(2)} 秒`,
|
||||
} : prev);
|
||||
};
|
||||
|
||||
const handleLocate = (loc: NameLocation) => {
|
||||
setHighlightLocation(loc);
|
||||
setViewMode('2d');
|
||||
@@ -803,9 +909,10 @@ export default function TestWorkbench({
|
||||
viewMode={viewMode}
|
||||
zoom={zoom}
|
||||
highlightLocation={highlightLocation}
|
||||
onImageLoaded={handleImageLoaded}
|
||||
/>
|
||||
|
||||
<ProgressPanel progress={progress} visible={isGenerating || !!progress} />
|
||||
<ProgressPanel progress={progress} logLines={logLines} visible={isGenerating || !!progress} />
|
||||
|
||||
<ViewControls
|
||||
zoom={zoom}
|
||||
|
||||
+83
-2
@@ -1640,8 +1640,90 @@ body.resizing {
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
padding: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.layer-info {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.layer-thumb-wrap {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-panel);
|
||||
}
|
||||
|
||||
.layer-thumb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, color-mix(in srgb, var(--border) 32%, transparent) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--border) 32%, transparent) 75%);
|
||||
background-size: 8px 8px;
|
||||
background-position: 0 0, 0 4px, 4px -4px, -4px 0;
|
||||
}
|
||||
|
||||
.layer-thumb-hidden {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.layer-thumb-scale {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layer-thumb-doc {
|
||||
position: relative;
|
||||
transform-origin: 0 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.layer-thumb-image,
|
||||
.layer-thumb-text,
|
||||
.layer-thumb-shape,
|
||||
.layer-thumb-line {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.layer-thumb-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.layer-thumb-text {
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.layer-thumb-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.layer-thumb-ellipse {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.layer-row.active {
|
||||
@@ -1667,7 +1749,6 @@ body.resizing {
|
||||
|
||||
.layer-sub-line {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
padding-left: 49px;
|
||||
}
|
||||
|
||||
.layer-action-group {
|
||||
|
||||
@@ -18,10 +18,13 @@ export interface JobParams {
|
||||
fontColor: string;
|
||||
nRepetitions: number;
|
||||
strokeWeights: boolean;
|
||||
autoRepeatToFill: boolean;
|
||||
saveDebugImages: boolean;
|
||||
|
||||
// 字号与填充
|
||||
sizeRatio: number;
|
||||
packingEfficiency: number;
|
||||
verticalRatio: number;
|
||||
targetFillRatio: number;
|
||||
userMinFontSize: number | null;
|
||||
userMaxFontSize: number | null;
|
||||
@@ -47,6 +50,7 @@ export interface JobResult {
|
||||
svg_stroke_url: string;
|
||||
db_url: string;
|
||||
metrics_url: string;
|
||||
elapsed_seconds?: number | null;
|
||||
}
|
||||
|
||||
export interface NameLocation {
|
||||
@@ -71,6 +75,8 @@ export interface SSEProgress {
|
||||
stage: string;
|
||||
percent: number;
|
||||
message: string;
|
||||
elapsedSeconds?: number;
|
||||
clientElapsedSeconds?: number;
|
||||
}
|
||||
|
||||
export type PanelType = 'import' | 'export' | 'edit' | 'find' | 'advanced' | null;
|
||||
@@ -104,6 +110,7 @@ export interface StickerAsset {
|
||||
source: string;
|
||||
createdAt: string;
|
||||
tint?: string;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export type CanvasElementType = 'sticker' | 'text' | 'rect' | 'ellipse' | 'line';
|
||||
|
||||
Reference in New Issue
Block a user