diff --git a/src/App.tsx b/src/App.tsx index 0fbd43d..94b65ab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -703,7 +703,7 @@ export default function App() { if (previewSnapshot !== null && appliedExportData !== null) { setPreviewLayoutStale(true); } - }, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]); + }, [workingTemplate.elements, workingTemplate.width, workingTemplate.height, workingTemplate.dataFilterRules]); useEffect(() => { if (workflowStep !== 'print-view') return; @@ -734,6 +734,7 @@ export default function App() { appliedMappedRows, workingTemplate.width, workingTemplate.height, + workingTemplate.dataFilterRules, ]); const pdfExportImage = useMemo(() => { @@ -770,6 +771,14 @@ export default function App() { }); }; + const handleDataFilterRulesChange = (rules: VisibilityRule[]) => { + if (!activeTemplate) return; + handleTemplateChange({ + ...activeTemplate, + dataFilterRules: rules.length > 0 ? rules : undefined, + }); + }; + const renderAppliedLabelToDataUrl = useCallback( (label: PrintableLabel) => renderLabelToDataUrl({ @@ -1440,6 +1449,7 @@ export default function App() { rows={mappedRows} draftExportRange={draftExportRange} onChangeDraftExportRange={setDraftExportRange} + onChangeDataFilterRules={handleDataFilterRulesChange} cutLine={cutLine} onChangeCutLine={handleCutLineChange} printablePages={previewSnapshot?.pages ?? []} @@ -1561,6 +1571,7 @@ export default function App() { rows={mappedRows} draftExportRange={draftExportRange} onChangeDraftExportRange={setDraftExportRange} + onChangeDataFilterRules={handleDataFilterRulesChange} cutLine={cutLine} onChangeCutLine={handleCutLineChange} previewReady={previewSnapshot !== null} diff --git a/src/components/DataRangeFields.tsx b/src/components/DataRangeFields.tsx index 1fb2821..04d753a 100644 --- a/src/components/DataRangeFields.tsx +++ b/src/components/DataRangeFields.tsx @@ -1,6 +1,10 @@ import React, { useMemo } from 'react'; -import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types'; -import { buildPrintablePages, extractTemplateVariables } from '../utils'; +import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow, VisibilityRule } from '../types'; +import { + buildPrintablePages, + extractTemplateVariables, + getDataFilteredIndices, +} from '../utils'; import { PageInput } from './PreviewGrid'; import { FieldSelect } from './PsSelect'; import { VariableFilterRulesEditor } from './VariableFilterRulesEditor'; @@ -9,82 +13,104 @@ interface DataRangeFieldsProps { variant?: 'ps' | 'mobile'; exportRange: ExportRangeConfig; onChangeExportRange: (range: ExportRangeConfig) => void; + onChangeDataFilterRules: (rules: VisibilityRule[]) => void; rows: RecordRow[]; paper: PaperConfig; template: LabelTemplate; - disabled?: boolean; + /** 排版过滤在无数据时不可用;数据过滤始终可编辑 */ + layoutDisabled?: boolean; } export const DataRangeFields: React.FC = ({ variant = 'ps', exportRange, onChangeExportRange, + onChangeDataFilterRules, rows, paper, template, - disabled = false, + layoutDisabled = false, }) => { const isMobile = variant === 'mobile'; const variableDefaults = template.variableDefaults ?? null; const templateVariables = useMemo(() => extractTemplateVariables(template), [template]); + const dataFilteredCount = useMemo( + () => getDataFilteredIndices(rows, template, variableDefaults).length, + [rows, template, variableDefaults] + ); const rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults); const patchRange = (patch: Partial) => { onChangeExportRange({ ...exportRange, ...patch }); }; + const sectionTitleClass = isMobile ? 'mobile-field-label mb-1 block' : 'ps-section-title'; + const hintClass = isMobile ? 'text-[10px] text-slate-500' : 'text-[10px] text-[#888]'; + return ( -
- {!isMobile &&
数据范围
} -
- {isMobile && } - patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} - className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'} - > - - - - - - -
- {exportRange.mode === 'custom' && ( -
- {isMobile ? ( - - ) : ( - - )} - patchRange({ custom: val })} - placeholder="如 1,3,5-10" - inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'} +
+
+ {rows.length > 0 && ( +
+
排版过滤
+

仅影响当前导出会话

+
+ {isMobile && } + patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} + className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'} + > + + + + + +
+ {exportRange.mode === 'custom' && ( +
+ {isMobile ? ( + + ) : ( + + )} + patchRange({ custom: val })} + placeholder="如 1,3,5-10" + inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'} + /> +
+ )} +

+ 最终范围:{rangeStats.selectedCount} / {rows.length} 行 +

+
+ )} + +
+
数据过滤
+

条件写入模板;全部满足的数据行才参与排版与导出。

+ + {rows.length > 0 && ( +

+ 数据过滤后:{dataFilteredCount} / {rows.length} 行 +

+ )}
- )} - {exportRange.mode === 'conditional' && ( - patchRange({ filterRules })} - /> - )} - {rows.length > 0 && ( -

- 当前范围:{rangeStats.selectedCount} / {rows.length} 行 -

- )} +
+
); }; diff --git a/src/components/ExportSidebar.tsx b/src/components/ExportSidebar.tsx index 15643af..dfb7a10 100644 --- a/src/components/ExportSidebar.tsx +++ b/src/components/ExportSidebar.tsx @@ -24,6 +24,7 @@ interface ExportSidebarProps { rows: RecordRow[]; draftExportRange: ExportRangeConfig; onChangeDraftExportRange: (range: ExportRangeConfig) => void; + onChangeDataFilterRules: (rules: VisibilityRule[]) => void; cutLine: CutLineConfig; onChangeCutLine: (cutLine: CutLineConfig) => void; previewReady: boolean; @@ -64,6 +65,7 @@ export const ExportSidebar: React.FC = ({ rows, draftExportRange, onChangeDraftExportRange, + onChangeDataFilterRules, cutLine, onChangeCutLine, previewReady, @@ -102,10 +104,11 @@ export const ExportSidebar: React.FC = ({ variant={compact ? 'ps' : 'ps'} exportRange={draftExportRange} onChangeExportRange={onChangeDraftExportRange} + onChangeDataFilterRules={onChangeDataFilterRules} rows={rows} paper={paper} template={template} - disabled={rows.length === 0} + layoutDisabled={rows.length === 0} /> ); @@ -155,6 +158,7 @@ export const ExportSidebar: React.FC = ({ templateVariables={templateVariables} />
+ {dataRangeSection} {hasData && ( <>
@@ -167,7 +171,6 @@ export const ExportSidebar: React.FC = ({ variableDefaults={template.variableDefaults} />
- {dataRangeSection} {applyDataSection} )} @@ -194,6 +197,7 @@ export const ExportSidebar: React.FC = ({ templateName={templateName} templateVariables={templateVariables} /> + {dataRangeSection} {hasData && ( <> = ({ onChangeMapping={onChangeMapping} variableDefaults={template.variableDefaults} /> - {dataRangeSection} {applyDataSection} )} diff --git a/src/components/MobileExportView.tsx b/src/components/MobileExportView.tsx index 7d4f241..205b5ca 100644 --- a/src/components/MobileExportView.tsx +++ b/src/components/MobileExportView.tsx @@ -53,6 +53,7 @@ interface MobileExportViewProps { rows: RecordRow[]; draftExportRange: ExportRangeConfig; onChangeDraftExportRange: (range: ExportRangeConfig) => void; + onChangeDataFilterRules: (rules: VisibilityRule[]) => void; cutLine: CutLineConfig; onChangeCutLine: (cutLine: CutLineConfig) => void; printablePages: (PrintableLabel | null)[][]; @@ -97,6 +98,7 @@ export const MobileExportView: React.FC = ({ rows, draftExportRange, onChangeDraftExportRange, + onChangeDataFilterRules, cutLine, onChangeCutLine, printablePages, @@ -247,28 +249,27 @@ export const MobileExportView: React.FC = ({ templateVariables={templateVariables} /> {hasData && ( - <> -
-

关联变量

- -
- +

关联变量

+ - +
)} + {/* {exportBlockedReason && !pdfGenerating && (

{exportBlockedReason}

)} */} diff --git a/src/types.ts b/src/types.ts index 9a912ed..36e89a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -222,14 +222,12 @@ export interface CutLineConfig { color: string; } -export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom' | 'conditional'; +export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom'; export interface ExportRangeConfig { mode: ExportRangeMode; - /** 自定义范围:1-based 序号,如 "1,3,5-10" */ + /** 自定义范围:1-based 序号,如 "1,3,5-10"(排版过滤,不写入模板) */ custom?: string; - /** mode=conditional 时,全部满足才纳入排版/导出范围 */ - filterRules?: VisibilityRule[]; } export interface LabelTemplate { @@ -242,6 +240,8 @@ export interface LabelTemplate { variableList?: string[]; /** 设计预览用:变量名 → 默认展示值 */ variableDefaults?: Record; + /** 数据过滤条件(写入模板);全部满足的数据行才参与排版/导出 */ + dataFilterRules?: VisibilityRule[]; /** 整页排版配置(每模板独立) */ paper?: PaperConfig; /** PDF 裁切参考线配置 */ diff --git a/src/utils.ts b/src/utils.ts index 52f1a68..2691c35 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -155,9 +155,63 @@ export function applyRowVariableMapping( export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' }; export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) => - a.mode === b.mode && - (a.custom ?? '') === (b.custom ?? '') && - JSON.stringify(a.filterRules ?? []) === JSON.stringify(b.filterRules ?? []); + a.mode === b.mode && (a.custom ?? '') === (b.custom ?? ''); + +/** 模板数据过滤规则(仅 variable 目标) */ +export function resolveDataFilterRules( + template: Pick +): VisibilityRule[] { + return resolveVariableFilterRules(template.dataFilterRules); +} + +/** 按模板数据过滤条件筛选 0-based 行索引 */ +export function getDataFilteredIndices( + rows: RecordRow[], + template: Pick, + variableDefaults?: Record | null +): number[] { + const total = rows.length; + if (total <= 0) return []; + const rules = getActiveVisibilityRules(resolveDataFilterRules(template)); + if (rules.length === 0) return Array.from({ length: total }, (_, i) => i); + return Array.from({ length: total }, (_, i) => i).filter((i) => + rowMatchesVariableFilterRules(rows[i], i, rules, variableDefaults) + ); +} + +/** 在候选行上应用排版过滤(奇偶/自定义序号,基于原始 1-based 行号) */ +export function getLayoutRangeIndices( + candidateIndices: number[], + totalRows: number, + range: ExportRangeConfig +): number[] { + if (candidateIndices.length === 0) return []; + switch (range.mode) { + case 'odd': + return candidateIndices.filter((i) => (i + 1) % 2 === 1); + case 'even': + return candidateIndices.filter((i) => (i + 1) % 2 === 0); + case 'custom': { + const parsed = parseCustomExportRange(range.custom ?? '', totalRows); + const candidateSet = new Set(candidateIndices); + return parsed.filter((i) => candidateSet.has(i)); + } + case 'all': + default: + return candidateIndices; + } +} + +/** 按数据过滤 + 排版过滤得到最终 0-based 行索引(保持升序) */ +export function getExportRangeIndices( + rows: RecordRow[], + range: ExportRangeConfig, + template: Pick, + variableDefaults?: Record | null +): number[] { + const dataFiltered = getDataFilteredIndices(rows, template, variableDefaults); + return getLayoutRangeIndices(dataFiltered, rows.length, range); +} export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = { enabled: true, @@ -247,34 +301,6 @@ export interface PrintableLabel { sourceIndex: number; } -/** 按导出范围筛选 0-based 行索引(保持升序) */ -export function getExportRangeIndices( - rows: RecordRow[], - range: ExportRangeConfig, - variableDefaults?: Record | null -): number[] { - const total = rows.length; - if (total <= 0) return []; - switch (range.mode) { - case 'odd': - return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 1); - case 'even': - return Array.from({ length: total }, (_, i) => i).filter((i) => (i + 1) % 2 === 0); - case 'custom': - return parseCustomExportRange(range.custom ?? '', total); - case 'conditional': { - const rules = resolveVariableFilterRules(range.filterRules); - if (rules.length === 0) return Array.from({ length: total }, (_, i) => i); - return Array.from({ length: total }, (_, i) => i).filter((i) => - rowMatchesVariableFilterRules(rows[i], i, rules, variableDefaults) - ); - } - case 'all': - default: - return Array.from({ length: total }, (_, i) => i); - } -} - /** 解析自定义范围字符串(1-based 序号) */ export function parseCustomExportRange(input: string, total: number): number[] { const trimmed = input.trim(); @@ -300,13 +326,13 @@ export function parseCustomExportRange(input: string, total: number): number[] { export function buildPrintablePages( rows: RecordRow[], paper: PaperConfig, - labelSize: { width: number; height: number }, + template: Pick, exportRange: ExportRangeConfig, variableDefaults?: Record | null ) { - const gridInfo = calculateGrid(paper, labelSize); + const gridInfo = calculateGrid(paper, template); const labelsPerPage = gridInfo.cols * gridInfo.rows; - const indices = getExportRangeIndices(rows, exportRange, variableDefaults); + const indices = getExportRangeIndices(rows, exportRange, template, variableDefaults); const selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i })); const totalPages = Math.ceil(selected.length / labelsPerPage) || 1; const pages: (PrintableLabel | null)[][] = [];