导出
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
66
src/components/ExportImageFilterFields.tsx
Normal file
66
src/components/ExportImageFilterFields.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
109
src/components/VariableFilterRulesEditor.tsx
Normal file
109
src/components/VariableFilterRulesEditor.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user