This commit is contained in:
iqudoo
2026-06-14 03:59:45 +08:00
parent 833eda5621
commit 1e03b7750a
23 changed files with 1499 additions and 153 deletions

View File

@@ -14,6 +14,9 @@ interface CanvasLabelImageProps {
variableDefaults?: Record<string, string> | null;
/** design=设计预览空变量显示占位output=排版/打印(空值隐藏图形元素) */
mode?: ContentResolveMode;
referenceBackground?: string;
referenceBackgroundOpacity?: number;
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */
renderScale?: number;
}
@@ -28,6 +31,9 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
transparentBackground = false,
variableDefaults = null,
mode = 'design' as ContentResolveMode,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
renderScale = 16,
}) => {
const [dataUrl, setDataUrl] = useState<string>('');
@@ -54,6 +60,9 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
variableDefaults,
mode,
scale: renderScale,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
})
.then((url) => {
if (!active) return;
@@ -88,6 +97,9 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
transparentBackground,
mode,
renderScale,
referenceBackground,
referenceBackgroundOpacity,
referenceBackgroundFit,
]);
if (initialLoading && !dataUrl) {

View File

@@ -86,3 +86,75 @@ export const CommitInput: React.FC<CommitInputProps> = ({
/>
);
};
export interface CommitTextareaProps
extends Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'value'> {
value: string;
onCommit: (val: string) => void;
}
/** 失焦提交型多行输入框 */
export const CommitTextarea: React.FC<CommitTextareaProps> = ({
value,
onCommit,
onFocus,
onBlur,
onKeyDown,
...props
}) => {
const [localVal, setLocalVal] = useState<string>(value);
const focusedRef = useRef(false);
const localValRef = useRef(localVal);
const valueRef = useRef(value);
const onCommitRef = useRef(onCommit);
localValRef.current = localVal;
valueRef.current = value;
onCommitRef.current = onCommit;
useEffect(() => {
if (!focusedRef.current) {
setLocalVal(value);
}
}, [value]);
useEffect(() => {
return () => {
if (focusedRef.current && localValRef.current !== valueRef.current) {
onCommitRef.current(localValRef.current);
}
};
}, []);
const commitIfChanged = () => {
if (localValRef.current !== valueRef.current) {
onCommitRef.current(localValRef.current);
}
};
const handleBlur = (e: React.FocusEvent<HTMLTextAreaElement>) => {
focusedRef.current = false;
onBlur?.(e);
commitIfChanged();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
(e.target as HTMLTextAreaElement).blur();
}
onKeyDown?.(e);
};
return (
<textarea
{...props}
value={localVal}
onChange={(e) => setLocalVal(e.target.value)}
onFocus={(e) => {
focusedRef.current = true;
onFocus?.(e);
}}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
);
};

View File

@@ -1,9 +1,9 @@
import React from 'react';
import { Filter } from 'lucide-react';
import React, { useMemo } from 'react';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
import { buildPrintablePages } from '../utils';
import { buildPrintablePages, extractTemplateVariables } from '../utils';
import { PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect';
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile';
@@ -25,7 +25,9 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
disabled = false,
}) => {
const isMobile = variant === 'mobile';
const rangeStats = buildPrintablePages(rows, paper, template, exportRange);
const variableDefaults = template.variableDefaults ?? null;
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]);
const rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults);
const patchRange = (patch: Partial<ExportRangeConfig>) => {
onChangeExportRange({ ...exportRange, ...patch });
@@ -36,7 +38,6 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
{!isMobile && <div className="ps-section-title"></div>}
<div>
{isMobile && <label className="mobile-field-label"></label>}
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
<FieldSelect
value={exportRange.mode}
disabled={disabled}
@@ -47,6 +48,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
<option value="conditional"></option>
</FieldSelect>
</div>
{exportRange.mode === 'custom' && (
@@ -66,6 +68,23 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
/>
</div>
)}
{exportRange.mode === 'conditional' && (
<VariableFilterRulesEditor
rules={exportRange.filterRules ?? []}
templateVariables={templateVariables}
disabled={disabled}
emptyHint="尚未添加过滤条件,将使用全部数据行"
description="全部条件满足的数据行才纳入排版与导出;按数据行与变量默认值判断。"
addTitle="添加数据范围条件"
editTitle="编辑数据范围条件"
onChangeRules={(filterRules) => patchRange({ filterRules })}
/>
)}
{rows.length > 0 && (
<p className={isMobile ? 'text-[10px] text-slate-500' : 'text-[10px] text-[#888]'}>
{rangeStats.selectedCount} / {rows.length}
</p>
)}
</div>
);
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import type { ElementVisibilityMode, VisibilityRule } from '../types';
import { isExportImageFilterConditional } from '../utils';
import { FieldSelect } from './PsSelect';
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
interface ExportImageFilterFieldsProps {
filterMode?: ElementVisibilityMode;
filterRules?: VisibilityRule[];
templateVariables: string[];
disabled?: boolean;
onChangeFilterMode: (mode: ElementVisibilityMode) => void;
onChangeFilterRules: (rules: VisibilityRule[]) => void;
}
export const ExportImageFilterFields: React.FC<ExportImageFilterFieldsProps> = ({
filterMode = 'always' as ElementVisibilityMode,
filterRules = [],
templateVariables,
disabled = false,
onChangeFilterMode,
onChangeFilterRules,
}) => {
const conditional = isExportImageFilterConditional({ exportImageFilterMode: filterMode });
const setRules = (next: VisibilityRule[]) => {
onChangeFilterMode('conditional');
onChangeFilterRules(next);
};
return (
<>
<div className="space-y-1.5">
<label className="ps-field-label mb-0"></label>
<FieldSelect
value={conditional ? 'conditional' : 'always'}
onChange={(e) => {
const nextConditional = e.target.value === 'conditional';
onChangeFilterMode(nextConditional ? 'conditional' : 'always');
if (nextConditional && (filterRules?.length ?? 0) === 0) {
onChangeFilterRules([]);
}
}}
className="ps-field text-[11px]"
disabled={disabled}
>
<option value="always"></option>
<option value="conditional"></option>
</FieldSelect>
</div>
{conditional && (
<VariableFilterRulesEditor
rules={filterRules}
templateVariables={templateVariables}
disabled={disabled}
emptyHint="尚未添加过滤条件,将导出全部标签"
description="全部条件满足时才导出该枚标签;按当前数据行与变量默认值判断。"
addTitle="添加导出过滤条件"
editTitle="编辑导出过滤条件"
onChangeRules={setRules}
/>
)}
</>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Database, Layout, Printer, Settings } from 'lucide-react';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig } from '../types';
import { ImportData, PaperConfig, LabelTemplate, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from '../types';
import { DataImporter } from './DataImporter';
import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
@@ -35,6 +35,14 @@ interface ExportSidebarProps {
draftSelectedCount: number;
printing: boolean;
onPrint: (printerId: string) => void;
imagesExporting?: boolean;
onExportImages?: () => void;
exportImageFileNamePattern?: string;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
exportImageFilterMode?: ElementVisibilityMode;
exportImageFilterRules?: VisibilityRule[];
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
printDisabled: boolean;
printDisabledReason?: string | null;
/** 移动端精简:仅数据、映射、数据范围 */
@@ -67,6 +75,14 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
draftSelectedCount,
printing,
onPrint,
imagesExporting = false,
onExportImages,
exportImageFileNamePattern = '',
onChangeExportImageFileNamePattern,
exportImageFilterMode = 'always',
exportImageFilterRules = [],
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
printDisabled,
printDisabledReason = null,
compact = false,
@@ -307,6 +323,15 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
disabledReason={printDisabledReason}
printing={printing}
onPrint={onPrint}
imagesExporting={imagesExporting}
onExportImages={onExportImages}
exportImageFileNamePattern={exportImageFileNamePattern}
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
exportImageFilterMode={exportImageFilterMode}
exportImageFilterRules={exportImageFilterRules}
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
templateVariables={templateVariables}
/>
</CollapsiblePanelSection>
</aside>

View File

@@ -1653,20 +1653,27 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>
<div
ref={canvasRef}
className="absolute inset-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden"
className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
template.previewReferenceBackground ? '' : 'bg-white'
}`}
>
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
{template.width} mm
</div>
<div
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]"
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
>
{template.height} mm
</div> */}
{template.previewReferenceBackground && (
<img
src={template.previewReferenceBackground}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none select-none z-0"
style={{
objectFit: template.previewReferenceBackgroundFit ?? 'cover',
opacity: Math.min(
1,
Math.max(0, (template.previewReferenceBackgroundOpacity ?? 100) / 100)
),
}}
/>
)}
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
<div className="absolute inset-0 overflow-hidden pointer-events-none z-[1]">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
@@ -1674,6 +1681,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
rowData={null}
rowIndex={0}
showBorder={false}
transparentBackground={!!template.previewReferenceBackground}
variableDefaults={previewDefaults}
renderScale={canvasRenderScale}
/>

View File

@@ -15,6 +15,8 @@ import {
ExportRangeConfig,
PaperConfig,
CutLineConfig,
ElementVisibilityMode,
VisibilityRule,
} from '../types';
import { isVariableMappingConfigured, type PrintableLabel } from '../utils';
import { DataImporter, downloadDataTemplate } from './DataImporter';
@@ -66,12 +68,18 @@ interface MobileExportViewProps {
activeRowIndex: number;
onSelectRowIndex: (idx: number) => void;
pdfGenerating: boolean;
imagesZipGenerating?: boolean;
printing: boolean;
onBack: () => void;
onExportPdf: () => void;
onPrint: (printerId: string) => void;
onExportImages?: () => void;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
printDisabled: boolean;
printDisabledReason?: string | null;
imagesExporting?: boolean;
}
export const MobileExportView: React.FC<MobileExportViewProps> = ({
@@ -104,12 +112,18 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
activeRowIndex,
onSelectRowIndex,
pdfGenerating,
imagesZipGenerating = false,
printing,
onBack,
onExportPdf,
onPrint,
printDisabled,
printDisabledReason = null,
onExportImages,
imagesExporting = false,
onChangeExportImageFileNamePattern,
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
}) => {
const [mobileModule, setMobileModule] = useState<MobileExportModule>('data');
@@ -128,7 +142,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
? '当前数据范围内无标签'
: null;
const exportDisabled = !!exportBlockedReason || pdfGenerating;
const exportDisabled = !!exportBlockedReason || pdfGenerating || imagesZipGenerating;
return (
<div className="mobile-export-view mobile-design-view">
@@ -281,6 +295,15 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
disabledReason={printDisabledReason}
printing={printing}
onPrint={onPrint}
imagesExporting={imagesExporting}
onExportImages={onExportImages}
exportImageFileNamePattern={template.exportImageFileNamePattern ?? ''}
onChangeExportImageFileNamePattern={onChangeExportImageFileNamePattern}
exportImageFilterMode={template.exportImageFilterMode ?? 'always'}
exportImageFilterRules={template.exportImageFilterRules ?? []}
onChangeExportImageFilterMode={onChangeExportImageFilterMode}
onChangeExportImageFilterRules={onChangeExportImageFilterRules}
templateVariables={templateVariables}
/>
</div>
)}

View File

@@ -15,6 +15,7 @@ import {
type PrintableLabel,
} from '../utils';
import { CanvasLabelImage } from './CanvasLabelImage';
import { resolveReferenceBackgroundForOutput } from '../labelRenderer';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect';
@@ -130,7 +131,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
const rangeForStats = exportRange ?? DEFAULT_EXPORT_RANGE;
const draftLayoutStats = useMemo(
() => buildPrintablePages(rows, draftPaper, template, rangeForStats),
() =>
buildPrintablePages(
rows,
draftPaper,
template,
rangeForStats,
template.variableDefaults ?? null
),
[rows, draftPaper, template, rangeForStats]
);
@@ -588,6 +596,15 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
const cutChannelXPx = cutChannel.x * previewScale;
const cutChannelYPx = cutChannel.y * previewScale;
const cutDash = formatCutLineSvgDashArray(cutLine, previewScale);
const referenceBackgroundRender = useMemo(
() => resolveReferenceBackgroundForOutput(template),
[
template.previewReferenceBackground,
template.previewReferenceBackgroundOpacity,
template.previewReferenceBackgroundFit,
template.includeReferenceBackgroundInOutput,
]
);
const pageCutLines = useMemo(
() =>
collectGridCutLinePositions(
@@ -705,16 +722,9 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
className={
isMobileVariant
? 'flex flex-col items-center space-y-4 pb-4'
: 'grid pb-8 justify-items-center content-start w-full'
}
style={
isMobileVariant
? undefined
: {
gridTemplateColumns: `repeat(auto-fill, minmax(${pageCardWidthPx}px, 1fr))`,
gap: `${previewPageGapPx}px`,
}
: 'flex flex-wrap justify-center content-start w-full pb-8'
}
style={isMobileVariant ? undefined : { gap: `${previewPageGapPx}px` }}
>
{visiblePages.map((pageRows, pageIdx) => (
<div
@@ -828,6 +838,7 @@ export const LayoutPreview: React.FC<LayoutPreviewProps> = ({
rowIndex={item.sourceIndex}
showBorder={false}
mode="output"
{...referenceBackgroundRender}
/>
</div>
</div>
@@ -871,7 +882,13 @@ export const PreviewGrid: React.FC<PreviewGridProps> = ({
activeRowIndex,
onSelectRowIndex,
}) => {
const layout = buildPrintablePages(rows, paper, template, DEFAULT_EXPORT_RANGE);
const layout = buildPrintablePages(
rows,
paper,
template,
DEFAULT_EXPORT_RANGE,
template.variableDefaults ?? null
);
return (
<div className="space-y-6">
<PaperSettingsPanel

View File

@@ -1,7 +1,9 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Loader2, Printer, RefreshCw } from 'lucide-react';
import { Images, Loader2, Printer, RefreshCw } from 'lucide-react';
import type { ElementVisibilityMode, VisibilityRule } from '../types';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import { CommitInput, CommitTextarea } from './CommitInput';
import { ExportImageFilterFields } from './ExportImageFilterFields';
import {
addCustomPrinter,
DEFAULT_PRINTER_ID,
@@ -17,6 +19,15 @@ interface PrintModuleProps {
disabledReason?: string | null;
printing: boolean;
onPrint: (printerId: string) => void;
imagesExporting?: boolean;
onExportImages?: () => void;
exportImageFileNamePattern?: string;
onChangeExportImageFileNamePattern?: (pattern: string) => void;
exportImageFilterMode?: ElementVisibilityMode;
exportImageFilterRules?: VisibilityRule[];
onChangeExportImageFilterMode?: (mode: ElementVisibilityMode) => void;
onChangeExportImageFilterRules?: (rules: VisibilityRule[]) => void;
templateVariables?: string[];
variant?: 'desktop' | 'mobile';
}
@@ -25,6 +36,15 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
disabledReason,
printing,
onPrint,
imagesExporting = false,
onExportImages,
exportImageFileNamePattern = '',
onChangeExportImageFileNamePattern,
exportImageFilterMode = 'always',
exportImageFilterRules = [],
onChangeExportImageFilterMode,
onChangeExportImageFilterRules,
templateVariables = [],
variant = 'desktop',
}) => {
const [printers, setPrinters] = useState<PrinterOption[]>([
@@ -73,6 +93,16 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
const directAvailable = supportsDirectPrinting(printers);
const selectedPrinter = printers.find((p) => p.id === selectedPrinterId);
const usesDirect = !!selectedPrinter?.direct;
const exportBusy = printing || imagesExporting;
const appendFileNameToken = (token: string) => {
if (!onChangeExportImageFileNamePattern) return;
const current = exportImageFileNamePattern.trim();
const next = current
? `${current}${current.endsWith('_') || current.endsWith('-') || current.endsWith(' ') ? '' : '_'}${token}`
: token;
onChangeExportImageFileNamePattern(next);
};
return (
<div className="space-y-3">
@@ -92,7 +122,7 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
value={selectedPrinterId}
onChange={(e) => setSelectedPrinterId(e.target.value)}
className="ps-field text-[11px]"
disabled={loadingPrinters || printing}
disabled={loadingPrinters || exportBusy}
>
{printers.map((printer) => (
<option key={printer.id} value={printer.id}>
@@ -111,12 +141,12 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
onCommit={setCustomPrinterName}
placeholder="与系统中打印机名称一致"
className="ps-field text-[11px] flex-1 min-w-0"
disabled={printing}
disabled={exportBusy}
/>
<button
type="button"
onClick={handleAddCustomPrinter}
disabled={!customPrinterName.trim() || printing}
disabled={!customPrinterName.trim() || exportBusy}
className="ps-btn text-[10px] shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
>
@@ -140,9 +170,9 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
<button
type="button"
onClick={handlePrint}
disabled={disabled || printing || loadingPrinters}
disabled={disabled || exportBusy || loadingPrinters}
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
!disabled && !printing && !loadingPrinters
!disabled && !exportBusy && !loadingPrinters
? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white'
: 'bg-[#444] text-[#666] cursor-not-allowed'
}`}
@@ -154,6 +184,98 @@ export const PrintModule: React.FC<PrintModuleProps> = ({
)}
{printing ? '准备打印…' : variant === 'mobile' ? '打印' : '开始打印'}
</button>
{onExportImages && (
<>
<div className="border-t border-[#3a3a3a] pt-3 space-y-2">
<p className="text-[10px] text-[#666] leading-relaxed">
ZIP
</p>
{onChangeExportImageFileNamePattern && (
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label className="ps-field-label mb-0"></label>
<button
type="button"
disabled={!exportImageFileNamePattern || exportBusy}
onClick={() => onChangeExportImageFileNamePattern('')}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:no-underline shrink-0"
>
</button>
</div>
<CommitTextarea
value={exportImageFileNamePattern}
onCommit={onChangeExportImageFileNamePattern}
placeholder="留空则按 001、002… 命名"
rows={3}
className="ps-field text-[11px] font-mono resize-y min-h-[72px]"
disabled={exportBusy}
/>
<p className="text-[9px] text-[#777] leading-relaxed">
使 {'{#SEQ}'}{'{#INDEX}'}{'{变量名}'}{'{#SEQ:4}'}
</p>
<div className="flex flex-wrap gap-1">
<button
type="button"
onClick={() => appendFileNameToken('{#SEQ}')}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{'{#SEQ}'}
</button>
<button
type="button"
onClick={() => appendFileNameToken('{#INDEX}')}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{'{#INDEX}'}
</button>
{templateVariables.map((variable) => (
<button
key={variable}
type="button"
onClick={() => appendFileNameToken(`{${variable}}`)}
disabled={exportBusy}
className="ps-btn text-[9px] font-mono"
>
{`{${variable}}`}
</button>
))}
</div>
</div>
)}
{onChangeExportImageFilterMode && onChangeExportImageFilterRules && (
<ExportImageFilterFields
filterMode={exportImageFilterMode}
filterRules={exportImageFilterRules}
templateVariables={templateVariables}
disabled={exportBusy}
onChangeFilterMode={onChangeExportImageFilterMode}
onChangeFilterRules={onChangeExportImageFilterRules}
/>
)}
<button
type="button"
onClick={onExportImages}
disabled={disabled || exportBusy || loadingPrinters}
className={`w-full inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold transition cursor-pointer ${
!disabled && !exportBusy && !loadingPrinters
? 'bg-[#383838] hover:bg-[#444] text-[#e8e8e8] border border-[#4a4a4a]'
: 'bg-[#444] text-[#666] cursor-not-allowed border border-[#333]'
}`}
>
{imagesExporting ? (
<Loader2 className="w-3.5 h-3.5 animate-spin shrink-0" />
) : (
<Images className="w-3.5 h-3.5 shrink-0" />
)}
{imagesExporting ? '正在导出图片…' : '批量保存为图片'}
</button>
</div>
</>
)}
</div>
);
};

View File

@@ -7,6 +7,7 @@ import {
isTemplateVariableInUse,
removeUnusedTemplateVariable,
resolveVisibilityRules,
toggleVisibilityRuleEnabled,
} from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
@@ -821,6 +822,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
rule={rule}
onEdit={() => setVisibilityRuleDialog({ mode: 'edit', index })}
onDelete={() => removeRule(index)}
onToggleEnabled={() =>
setRules(
rules.map((item, i) =>
i === index ? toggleVisibilityRuleEnabled(item) : item
)
)
}
/>
))}
</div>
@@ -843,8 +851,127 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
);
};
const renderPreviewReferenceBackgroundFields = () => (
<div className="space-y-3">
<h5 className="ps-section-title">
<ImageIcon className="w-3.5 h-3.5" />
</h5>
<p className="text-[9px] text-[#777] leading-relaxed">
便
</p>
<label className="ps-btn text-[10px] cursor-pointer inline-flex">
<Upload className="w-3.5 h-3.5" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () =>
onChangeTemplate({
...template,
previewReferenceBackground: reader.result as string,
previewReferenceBackgroundOpacity:
template.previewReferenceBackgroundOpacity ?? 100,
previewReferenceBackgroundFit:
template.previewReferenceBackgroundFit ?? 'cover',
});
reader.readAsDataURL(file);
e.target.value = '';
}}
/>
</label>
{template.previewReferenceBackground && (
<>
<div className="border border-[#3a3a3a] bg-[#1e1e1e] p-2">
<img
src={template.previewReferenceBackground}
alt="预览参考背景"
className="block max-h-24 mx-auto object-contain"
/>
</div>
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={template.previewReferenceBackgroundFit ?? 'cover'}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundFit: e.target.value as 'contain' | 'cover' | 'fill',
})
}
className="ps-field text-[11px]"
>
<option value="cover"> (cover)</option>
<option value="contain"> (contain)</option>
<option value="fill"> (fill)</option>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">
({template.previewReferenceBackgroundOpacity ?? 100}%)
</label>
<input
type="range"
min="0"
max="100"
step="1"
value={template.previewReferenceBackgroundOpacity ?? 100}
onChange={(e) =>
onChangeTemplate({
...template,
previewReferenceBackgroundOpacity: Number(e.target.value),
})
}
className="w-full accent-[#31a8ff]"
/>
</div>
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={template.includeReferenceBackgroundInOutput ?? false}
onChange={(e) =>
onChangeTemplate({
...template,
includeReferenceBackgroundInOutput: e.target.checked ? true : undefined,
})
}
className="ps-checkbox mt-0.5"
/>
<span className="text-xs text-[#ccc] leading-normal">
<span className="block text-[9px] text-[#777] mt-0.5">
PDF
</span>
</span>
</label>
<button
type="button"
onClick={() =>
onChangeTemplate({
...template,
previewReferenceBackground: undefined,
previewReferenceBackgroundOpacity: undefined,
previewReferenceBackgroundFit: undefined,
includeReferenceBackgroundInOutput: undefined,
})
}
className="text-[9px] text-[#31a8ff] hover:underline cursor-pointer"
>
</button>
</>
)}
</div>
);
const renderPropertiesPanelBody = () => (
<>
<div className="pb-3 border-b border-[#3a3a3a]">{renderPreviewReferenceBackgroundFields()}</div>
{/* Label Canvas Dimensions */}
{!selectedElem && (
<div className="space-y-4">

View File

@@ -0,0 +1,109 @@
import React, { useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import type { VisibilityRule } from '../types';
import { resolveVariableFilterRules, toggleVisibilityRuleEnabled } from '../utils';
import { VisibilityRuleDialog } from './VisibilityRuleDialog';
import { VisibilityRuleSummary } from './VisibilityRuleSummary';
interface VariableFilterRulesEditorProps {
rules?: VisibilityRule[];
templateVariables: string[];
disabled?: boolean;
emptyHint?: string;
description?: string;
addTitle?: string;
editTitle?: string;
onChangeRules: (rules: VisibilityRule[]) => void;
}
export const VariableFilterRulesEditor: React.FC<VariableFilterRulesEditorProps> = ({
rules = [],
templateVariables,
disabled = false,
emptyHint = '尚未添加过滤条件',
description = '与元素「显示控制」相同:全部条件满足时才纳入;按当前数据行与变量默认值判断。',
addTitle = '添加过滤条件',
editTitle = '编辑过滤条件',
onChangeRules,
}) => {
const [ruleDialog, setRuleDialog] = useState<{ mode: 'add' | 'edit'; index?: number } | null>(
null
);
const normalizedRules = resolveVariableFilterRules(rules);
const ruleDialogVariableOptions = useMemo(() => {
if (!ruleDialog) return templateVariables;
if (ruleDialog.mode === 'add') {
return templateVariables.filter((name) => !normalizedRules.some((rule) => rule.variable === name));
}
const usedByOthers = normalizedRules
.filter((_, index) => index !== ruleDialog.index)
.map((rule) => rule.variable);
return templateVariables.filter((name) => !usedByOthers.includes(name));
}, [ruleDialog, normalizedRules, templateVariables]);
const editingRule =
ruleDialog?.mode === 'edit' && ruleDialog.index !== undefined
? normalizedRules[ruleDialog.index]
: undefined;
const handleConfirmRule = (rule: VisibilityRule) => {
if (!ruleDialog) return;
if (ruleDialog.mode === 'edit' && ruleDialog.index !== undefined) {
onChangeRules(normalizedRules.map((item, index) => (index === ruleDialog.index ? rule : item)));
return;
}
onChangeRules([...normalizedRules, rule]);
};
return (
<>
<div className="space-y-2">
<p className="text-[9px] text-[#777] leading-relaxed">{description}</p>
{normalizedRules.length > 0 ? (
<div className="space-y-1.5">
{normalizedRules.map((rule, index) => (
<VisibilityRuleSummary
key={`${rule.variable}-${rule.operator}-${index}`}
rule={rule}
onEdit={() => setRuleDialog({ mode: 'edit', index })}
onDelete={() => onChangeRules(normalizedRules.filter((_, i) => i !== index))}
onToggleEnabled={() =>
onChangeRules(
normalizedRules.map((item, i) =>
i === index ? toggleVisibilityRuleEnabled(item) : item
)
)
}
/>
))}
</div>
) : (
<p className="text-[10px] text-[#888]">{emptyHint}</p>
)}
<button
type="button"
onClick={() => setRuleDialog({ mode: 'add' })}
disabled={disabled}
className="ps-btn w-full text-[10px] justify-center disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="w-3 h-3" />
</button>
</div>
<VisibilityRuleDialog
open={ruleDialog !== null}
mode={ruleDialog?.mode ?? 'add'}
initialRule={editingRule}
variableOptions={ruleDialogVariableOptions}
variableOnly
addTitle={addTitle}
editTitle={editTitle}
onClose={() => setRuleDialog(null)}
onConfirm={handleConfirmRule}
/>
</>
);
};

View File

@@ -16,6 +16,9 @@ interface VisibilityRuleDialogProps {
mode: 'add' | 'edit';
initialRule?: VisibilityRule;
variableOptions: string[];
variableOnly?: boolean;
addTitle?: string;
editTitle?: string;
onClose: () => void;
onConfirm: (rule: VisibilityRule) => void;
}
@@ -25,6 +28,9 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
mode,
initialRule,
variableOptions,
variableOnly = false,
addTitle = '添加显示条件',
editTitle = '编辑显示条件',
onClose,
onConfirm,
}) => {
@@ -42,10 +48,10 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
} else {
setDraft({
...EMPTY_DRAFT,
target: variableOptions.length > 0 ? 'variable' : 'content',
target: variableOnly || variableOptions.length > 0 ? 'variable' : 'content',
});
}
}, [open, mode, initialRule, variableOptions.length]);
}, [open, mode, initialRule, variableOptions.length, variableOnly]);
useEffect(() => {
if (!open) return;
@@ -62,9 +68,8 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
if (!open) return null;
const target = draft.target ?? 'variable';
const canConfirm =
target === 'content' || draft.variable.trim().length > 0;
const title = mode === 'add' ? '添加显示条件' : '编辑显示条件';
const canConfirm = target === 'content' || draft.variable.trim().length > 0;
const title = mode === 'add' ? addTitle : editTitle;
const confirmLabel = mode === 'add' ? '确定添加' : '保存';
const handleSubmit = (e: React.FormEvent) => {
@@ -109,6 +114,7 @@ export const VisibilityRuleDialog: React.FC<VisibilityRuleDialogProps> = ({
rule={draft}
onChange={(patch) => setDraft((d) => ({ ...d, ...patch }))}
variableOptions={variableOptions}
variableOnly={variableOnly}
/>
</div>
<div className="ps-modal-footer">

View File

@@ -20,37 +20,42 @@ interface VisibilityRuleFormProps {
rule: VisibilityRuleDraft;
onChange: (patch: Partial<VisibilityRule>) => void;
variableOptions: string[];
/** 为 true 时仅支持按变量判断(如批量导出过滤) */
variableOnly?: boolean;
}
export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
rule,
onChange,
variableOptions,
variableOnly = false,
}) => {
const target = rule.target ?? 'variable';
const target = variableOnly ? 'variable' : (rule.target ?? 'variable');
return (
<div className="space-y-3">
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={target}
onChange={(e) => {
const nextTarget = e.target.value as VisibilityRuleTarget;
onChange({
target: nextTarget,
variable: nextTarget === 'content' ? '' : rule.variable,
});
}}
className="ps-field text-[11px] w-full mt-0.5"
>
{(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => (
<option key={t} value={t}>
{VISIBILITY_RULE_TARGET_LABELS[t]}
</option>
))}
</FieldSelect>
</div>
{!variableOnly && (
<div>
<label className="ps-field-label"></label>
<FieldSelect
value={target}
onChange={(e) => {
const nextTarget = e.target.value as VisibilityRuleTarget;
onChange({
target: nextTarget,
variable: nextTarget === 'content' ? '' : rule.variable,
});
}}
className="ps-field text-[11px] w-full mt-0.5"
>
{(Object.keys(VISIBILITY_RULE_TARGET_LABELS) as VisibilityRuleTarget[]).map((t) => (
<option key={t} value={t}>
{VISIBILITY_RULE_TARGET_LABELS[t]}
</option>
))}
</FieldSelect>
</div>
)}
{target === 'variable' ? (
<div>
<label className="ps-field-label"></label>
@@ -68,14 +73,18 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
</FieldSelect>
{variableOptions.length === 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
{variableOnly
? '请先在模板中添加变量后再设置过滤条件'
: '请先在「变量管理」中添加变量,或改用「内容值」作为条件对象'}
</p>
)}
</div>
) : (
!variableOnly && (
<p className="text-[9px] text-[#666] leading-relaxed">
使/
</p>
)
)}
<div>
<label className="ps-field-label"></label>

View File

@@ -1,12 +1,13 @@
import React from 'react';
import { Pencil, Trash2 } from 'lucide-react';
import { VisibilityRule } from '../types';
import { formatVisibilityRuleDescription } from '../utils';
import { formatVisibilityRuleDescription, isVisibilityRuleEnabled } from '../utils';
interface VisibilityRuleSummaryProps {
rule: VisibilityRule;
onEdit: () => void;
onDelete: () => void;
onToggleEnabled?: () => void;
}
/** 将 {变量} 或「内容值」高亮展示 */
@@ -37,31 +38,46 @@ export const VisibilityRuleSummary: React.FC<VisibilityRuleSummaryProps> = ({
rule,
onEdit,
onDelete,
}) => (
<div className="visibility-rule-summary">
<button
type="button"
onClick={onEdit}
className="visibility-rule-summary-btn"
title="编辑条件"
>
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
</button>
<button
type="button"
onClick={onEdit}
title="编辑"
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={onDelete}
title="删除条件"
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
onToggleEnabled,
}) => {
const enabled = isVisibilityRuleEnabled(rule);
return (
<div className={`visibility-rule-summary${enabled ? '' : ' is-disabled'}`}>
{onToggleEnabled && (
<input
type="checkbox"
checked={enabled}
onChange={onToggleEnabled}
className="ps-checkbox shrink-0"
title={enabled ? '禁用条件' : '启用条件'}
aria-label={enabled ? '禁用条件' : '启用条件'}
/>
)}
<button
type="button"
onClick={onEdit}
className="visibility-rule-summary-btn"
title="编辑条件"
>
<p className="visibility-rule-summary-text">{renderRuleDescription(rule)}</p>
</button>
<button
type="button"
onClick={onEdit}
title="编辑"
className="shrink-0 p-1 rounded text-[#666] hover:text-[#31a8ff] hover:bg-[#333] transition-colors"
>
<Pencil className="w-3 h-3" />
</button>
<button
type="button"
onClick={onDelete}
title="删除条件"
className="shrink-0 p-1 rounded text-[#666] hover:text-red-400 hover:bg-[#3a2020] transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
};