This commit is contained in:
iqudoo
2026-06-14 04:12:21 +08:00
parent 1e03b7750a
commit ccb5b119de
6 changed files with 181 additions and 114 deletions

View File

@@ -703,7 +703,7 @@ export default function App() {
if (previewSnapshot !== null && appliedExportData !== null) { if (previewSnapshot !== null && appliedExportData !== null) {
setPreviewLayoutStale(true); setPreviewLayoutStale(true);
} }
}, [workingTemplate.elements, workingTemplate.width, workingTemplate.height]); }, [workingTemplate.elements, workingTemplate.width, workingTemplate.height, workingTemplate.dataFilterRules]);
useEffect(() => { useEffect(() => {
if (workflowStep !== 'print-view') return; if (workflowStep !== 'print-view') return;
@@ -734,6 +734,7 @@ export default function App() {
appliedMappedRows, appliedMappedRows,
workingTemplate.width, workingTemplate.width,
workingTemplate.height, workingTemplate.height,
workingTemplate.dataFilterRules,
]); ]);
const pdfExportImage = useMemo(() => { 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( const renderAppliedLabelToDataUrl = useCallback(
(label: PrintableLabel) => (label: PrintableLabel) =>
renderLabelToDataUrl({ renderLabelToDataUrl({
@@ -1440,6 +1449,7 @@ export default function App() {
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
onChangeDataFilterRules={handleDataFilterRulesChange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
printablePages={previewSnapshot?.pages ?? []} printablePages={previewSnapshot?.pages ?? []}
@@ -1561,6 +1571,7 @@ export default function App() {
rows={mappedRows} rows={mappedRows}
draftExportRange={draftExportRange} draftExportRange={draftExportRange}
onChangeDraftExportRange={setDraftExportRange} onChangeDraftExportRange={setDraftExportRange}
onChangeDataFilterRules={handleDataFilterRulesChange}
cutLine={cutLine} cutLine={cutLine}
onChangeCutLine={handleCutLineChange} onChangeCutLine={handleCutLineChange}
previewReady={previewSnapshot !== null} previewReady={previewSnapshot !== null}

View File

@@ -1,6 +1,10 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types'; import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow, VisibilityRule } from '../types';
import { buildPrintablePages, extractTemplateVariables } from '../utils'; import {
buildPrintablePages,
extractTemplateVariables,
getDataFilteredIndices,
} from '../utils';
import { PageInput } from './PreviewGrid'; import { PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect'; import { FieldSelect } from './PsSelect';
import { VariableFilterRulesEditor } from './VariableFilterRulesEditor'; import { VariableFilterRulesEditor } from './VariableFilterRulesEditor';
@@ -9,82 +13,104 @@ interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile'; variant?: 'ps' | 'mobile';
exportRange: ExportRangeConfig; exportRange: ExportRangeConfig;
onChangeExportRange: (range: ExportRangeConfig) => void; onChangeExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
rows: RecordRow[]; rows: RecordRow[];
paper: PaperConfig; paper: PaperConfig;
template: LabelTemplate; template: LabelTemplate;
disabled?: boolean; /** 排版过滤在无数据时不可用;数据过滤始终可编辑 */
layoutDisabled?: boolean;
} }
export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
variant = 'ps', variant = 'ps',
exportRange, exportRange,
onChangeExportRange, onChangeExportRange,
onChangeDataFilterRules,
rows, rows,
paper, paper,
template, template,
disabled = false, layoutDisabled = false,
}) => { }) => {
const isMobile = variant === 'mobile'; const isMobile = variant === 'mobile';
const variableDefaults = template.variableDefaults ?? null; const variableDefaults = template.variableDefaults ?? null;
const templateVariables = useMemo(() => extractTemplateVariables(template), [template]); 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 rangeStats = buildPrintablePages(rows, paper, template, exportRange, variableDefaults);
const patchRange = (patch: Partial<ExportRangeConfig>) => { const patchRange = (patch: Partial<ExportRangeConfig>) => {
onChangeExportRange({ ...exportRange, ...patch }); 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 ( return (
<div className={isMobile ? 'space-y-4' : 'space-y-2'}> <div className={isMobile ? 'space-y-4' : 'space-y-3'}>
{!isMobile && <div className="ps-section-title"></div>} <div className="space-y-2">
<div> {rows.length > 0 && (
{isMobile && <label className="mobile-field-label"></label>} <div className="space-y-2">
<FieldSelect <div className={sectionTitleClass}></div>
value={exportRange.mode} <p className={hintClass}></p>
disabled={disabled} <div>
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })} {isMobile && <label className="mobile-field-label"></label>}
className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'} <FieldSelect
> value={exportRange.mode}
<option value="all"></option> disabled={layoutDisabled}
<option value="odd"></option> onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
<option value="even"></option> className={isMobile ? 'mobile-field' : 'ps-field text-[11px]'}
<option value="custom"></option> >
<option value="conditional"></option> <option value="all"></option>
</FieldSelect> <option value="odd"></option>
</div> <option value="even"></option>
{exportRange.mode === 'custom' && ( <option value="custom"></option>
<div> </FieldSelect>
{isMobile ? ( </div>
<label className="mobile-field-label"></label> {exportRange.mode === 'custom' && (
) : ( <div>
<label className="ps-field-label"></label> {isMobile ? (
)} <label className="mobile-field-label"></label>
<PageInput ) : (
type="text" <label className="ps-field-label"></label>
value={exportRange.custom ?? ''} )}
disabled={disabled} <PageInput
onCommit={(val) => patchRange({ custom: val })} type="text"
placeholder="如 1,3,5-10" value={exportRange.custom ?? ''}
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'} disabled={layoutDisabled}
onCommit={(val) => patchRange({ custom: val })}
placeholder="如 1,3,5-10"
inputClassName={isMobile ? 'mobile-field font-mono' : 'ps-field font-mono text-[11px]'}
/>
</div>
)}
<p className={hintClass}>
{rangeStats.selectedCount} / {rows.length}
</p>
</div>
)}
<div className="space-y-3">
<div className={sectionTitleClass}></div>
<p className={hintClass}></p>
<VariableFilterRulesEditor
rules={template.dataFilterRules ?? []}
templateVariables={templateVariables}
emptyHint="尚未添加过滤条件,将使用全部数据行"
description="按数据行与变量默认值判断;上传数据后可预览过滤结果。"
addTitle="添加数据过滤条件"
editTitle="编辑数据过滤条件"
onChangeRules={onChangeDataFilterRules}
/> />
{rows.length > 0 && (
<p className={hintClass}>
{dataFilteredCount} / {rows.length}
</p>
)}
</div> </div>
)} </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> </div>
); );
}; };

View File

@@ -24,6 +24,7 @@ interface ExportSidebarProps {
rows: RecordRow[]; rows: RecordRow[];
draftExportRange: ExportRangeConfig; draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
previewReady: boolean; previewReady: boolean;
@@ -64,6 +65,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
rows, rows,
draftExportRange, draftExportRange,
onChangeDraftExportRange, onChangeDraftExportRange,
onChangeDataFilterRules,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
previewReady, previewReady,
@@ -102,10 +104,11 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
variant={compact ? 'ps' : 'ps'} variant={compact ? 'ps' : 'ps'}
exportRange={draftExportRange} exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange} onChangeExportRange={onChangeDraftExportRange}
onChangeDataFilterRules={onChangeDataFilterRules}
rows={rows} rows={rows}
paper={paper} paper={paper}
template={template} template={template}
disabled={rows.length === 0} layoutDisabled={rows.length === 0}
/> />
); );
@@ -155,6 +158,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
</div> </div>
{dataRangeSection}
{hasData && ( {hasData && (
<> <>
<div> <div>
@@ -167,7 +171,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
variableDefaults={template.variableDefaults} variableDefaults={template.variableDefaults}
/> />
</div> </div>
{dataRangeSection}
{applyDataSection} {applyDataSection}
</> </>
)} )}
@@ -194,6 +197,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
templateName={templateName} templateName={templateName}
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
{dataRangeSection}
{hasData && ( {hasData && (
<> <>
<VariableMappingPanel <VariableMappingPanel
@@ -204,7 +208,6 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
onChangeMapping={onChangeMapping} onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults} variableDefaults={template.variableDefaults}
/> />
{dataRangeSection}
{applyDataSection} {applyDataSection}
</> </>
)} )}

View File

@@ -53,6 +53,7 @@ interface MobileExportViewProps {
rows: RecordRow[]; rows: RecordRow[];
draftExportRange: ExportRangeConfig; draftExportRange: ExportRangeConfig;
onChangeDraftExportRange: (range: ExportRangeConfig) => void; onChangeDraftExportRange: (range: ExportRangeConfig) => void;
onChangeDataFilterRules: (rules: VisibilityRule[]) => void;
cutLine: CutLineConfig; cutLine: CutLineConfig;
onChangeCutLine: (cutLine: CutLineConfig) => void; onChangeCutLine: (cutLine: CutLineConfig) => void;
printablePages: (PrintableLabel | null)[][]; printablePages: (PrintableLabel | null)[][];
@@ -97,6 +98,7 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
rows, rows,
draftExportRange, draftExportRange,
onChangeDraftExportRange, onChangeDraftExportRange,
onChangeDataFilterRules,
cutLine, cutLine,
onChangeCutLine, onChangeCutLine,
printablePages, printablePages,
@@ -247,28 +249,27 @@ export const MobileExportView: React.FC<MobileExportViewProps> = ({
templateVariables={templateVariables} templateVariables={templateVariables}
/> />
{hasData && ( {hasData && (
<> <div>
<div> <p className="text-[11px] font-semibold text-[#e8e8e8] mb-2"></p>
<p className="text-[11px] font-semibold text-[#e8e8e8] mb-2"></p> <VariableMappingPanel
<VariableMappingPanel
variant="ps"
variables={templateVariables}
columns={dataColumns}
mapping={variableColumnMapping}
onChangeMapping={onChangeMapping}
variableDefaults={template.variableDefaults}
/>
</div>
<DataRangeFields
variant="ps" variant="ps"
exportRange={draftExportRange} variables={templateVariables}
onChangeExportRange={onChangeDraftExportRange} columns={dataColumns}
rows={rows} mapping={variableColumnMapping}
paper={paper} onChangeMapping={onChangeMapping}
template={template} variableDefaults={template.variableDefaults}
/> />
</> </div>
)} )}
<DataRangeFields
variant="ps"
exportRange={draftExportRange}
onChangeExportRange={onChangeDraftExportRange}
onChangeDataFilterRules={onChangeDataFilterRules}
rows={rows}
paper={paper}
template={template}
/>
{/* {exportBlockedReason && !pdfGenerating && ( {/* {exportBlockedReason && !pdfGenerating && (
<p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p> <p className="text-[10px] text-amber-500/90 text-center">{exportBlockedReason}</p>
)} */} )} */}

View File

@@ -222,14 +222,12 @@ export interface CutLineConfig {
color: string; color: string;
} }
export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom' | 'conditional'; export type ExportRangeMode = 'all' | 'odd' | 'even' | 'custom';
export interface ExportRangeConfig { export interface ExportRangeConfig {
mode: ExportRangeMode; mode: ExportRangeMode;
/** 自定义范围1-based 序号,如 "1,3,5-10" */ /** 自定义范围1-based 序号,如 "1,3,5-10"(排版过滤,不写入模板) */
custom?: string; custom?: string;
/** mode=conditional 时,全部满足才纳入排版/导出范围 */
filterRules?: VisibilityRule[];
} }
export interface LabelTemplate { export interface LabelTemplate {
@@ -242,6 +240,8 @@ export interface LabelTemplate {
variableList?: string[]; variableList?: string[];
/** 设计预览用:变量名 → 默认展示值 */ /** 设计预览用:变量名 → 默认展示值 */
variableDefaults?: Record<string, string>; variableDefaults?: Record<string, string>;
/** 数据过滤条件(写入模板);全部满足的数据行才参与排版/导出 */
dataFilterRules?: VisibilityRule[];
/** 整页排版配置(每模板独立) */ /** 整页排版配置(每模板独立) */
paper?: PaperConfig; paper?: PaperConfig;
/** PDF 裁切参考线配置 */ /** PDF 裁切参考线配置 */

View File

@@ -155,9 +155,63 @@ export function applyRowVariableMapping(
export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' }; export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' };
export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) => export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) =>
a.mode === b.mode && a.mode === b.mode && (a.custom ?? '') === (b.custom ?? '');
(a.custom ?? '') === (b.custom ?? '') &&
JSON.stringify(a.filterRules ?? []) === JSON.stringify(b.filterRules ?? []); /** 模板数据过滤规则(仅 variable 目标) */
export function resolveDataFilterRules(
template: Pick<LabelTemplate, 'dataFilterRules'>
): VisibilityRule[] {
return resolveVariableFilterRules(template.dataFilterRules);
}
/** 按模板数据过滤条件筛选 0-based 行索引 */
export function getDataFilteredIndices(
rows: RecordRow[],
template: Pick<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | 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<LabelTemplate, 'dataFilterRules'>,
variableDefaults?: Record<string, string> | null
): number[] {
const dataFiltered = getDataFilteredIndices(rows, template, variableDefaults);
return getLayoutRangeIndices(dataFiltered, rows.length, range);
}
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = { export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
enabled: true, enabled: true,
@@ -247,34 +301,6 @@ export interface PrintableLabel {
sourceIndex: number; sourceIndex: number;
} }
/** 按导出范围筛选 0-based 行索引(保持升序) */
export function getExportRangeIndices(
rows: RecordRow[],
range: ExportRangeConfig,
variableDefaults?: Record<string, string> | 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 序号) */ /** 解析自定义范围字符串1-based 序号) */
export function parseCustomExportRange(input: string, total: number): number[] { export function parseCustomExportRange(input: string, total: number): number[] {
const trimmed = input.trim(); const trimmed = input.trim();
@@ -300,13 +326,13 @@ export function parseCustomExportRange(input: string, total: number): number[] {
export function buildPrintablePages( export function buildPrintablePages(
rows: RecordRow[], rows: RecordRow[],
paper: PaperConfig, paper: PaperConfig,
labelSize: { width: number; height: number }, template: Pick<LabelTemplate, 'width' | 'height' | 'dataFilterRules'>,
exportRange: ExportRangeConfig, exportRange: ExportRangeConfig,
variableDefaults?: Record<string, string> | null variableDefaults?: Record<string, string> | null
) { ) {
const gridInfo = calculateGrid(paper, labelSize); const gridInfo = calculateGrid(paper, template);
const labelsPerPage = gridInfo.cols * gridInfo.rows; 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 selected: PrintableLabel[] = indices.map((i) => ({ row: rows[i], sourceIndex: i }));
const totalPages = Math.ceil(selected.length / labelsPerPage) || 1; const totalPages = Math.ceil(selected.length / labelsPerPage) || 1;
const pages: (PrintableLabel | null)[][] = []; const pages: (PrintableLabel | null)[][] = [];