import { LabelTemplate, PaperConfig, RecordRow, ImportData, DataSourceMeta, TemplateElement, TextFormat, TextExtractMode, NumberPartDisplay, ExportRangeConfig, CutLineConfig, VisibilityRule, VisibilityOperator, VISIBILITY_OPERATOR_LABELS, } from './types'; const IMPORT_DATA_STORAGE_PREFIX = 'label_import_data_v2_'; const GLOBAL_IMPORT_DATA_KEY = 'label_import_data_global_v2'; const VARIABLE_MAPPING_KEY = 'label_var_column_mapping_v2'; /** 变量映射:使用模板中配置的默认值(非数据列) */ export const VARIABLE_MAPPING_USE_DEFAULT = '__use_default__'; /** 变量映射:使用当前数据行序号(1-based,与 {#INDEX} 一致) */ export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__'; export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号'; /** 粘贴副本时相对原位置的偏移 (mm) */ export const PASTE_ELEMENT_OFFSET_MM = 2; /** 在标签画布上居中放置元素(mm,保留 1 位小数) */ export function centerElementOnLabel( labelWidth: number, labelHeight: number, elementWidth: number, elementHeight: number ): { x: number; y: number } { const x = Math.round(((labelWidth - elementWidth) / 2) * 10) / 10; const y = Math.round(((labelHeight - elementHeight) / 2) * 10) / 10; return { x: Math.max(0, x), y: Math.max(0, y), }; } const AUTO_ELEMENT_NAME_RE = /^(文本|条形码|二维码|图片|表格) \d+$/; const MAX_LAYER_NAME_FROM_CONTENT = 40; /** 是否为新建时的默认图层名(含粘贴副本后缀) */ export function isAutoGeneratedElementName(name: string): boolean { const trimmed = name.trim(); if (!trimmed || trimmed === '未命名元素') return true; if (AUTO_ELEMENT_NAME_RE.test(trimmed)) return true; if (trimmed.endsWith(' 副本')) { const base = trimmed.slice(0, -3).trim(); if (AUTO_ELEMENT_NAME_RE.test(base) || base === '未命名元素') return true; } return false; } /** 是否应根据内容自动同步图层名称 */ export function shouldSyncElementNameFromContent(elem: TemplateElement): boolean { if (elem.nameCustomized) return false; return isAutoGeneratedElementName(elem.name); } /** 从元素内容推导图层显示名称 */ export function deriveElementNameFromContent( content: string, type: TemplateElement['type'] ): string { const trimmed = content.trim(); if (!trimmed) return '未命名元素'; if (type === 'image') { if (trimmed.startsWith('data:')) return '图片'; const withoutVars = trimmed.replace(/\{[^}]+\}/g, '').trim(); if (withoutVars && /^https?:\/\//i.test(withoutVars)) { try { const path = withoutVars.split(/[?#]/)[0]; const segment = path.split('/').filter(Boolean).pop(); if (segment) { return decodeURIComponent(segment).slice(0, MAX_LAYER_NAME_FROM_CONTENT); } } catch { /* ignore */ } } } const singleLine = trimmed.split(/\r?\n/)[0].trim(); if (!singleLine) return '未命名元素'; if (singleLine.startsWith('data:')) return type === 'image' ? '图片' : '未命名元素'; return singleLine.slice(0, MAX_LAYER_NAME_FROM_CONTENT); } /** 更新内容并在需要时同步图层名称 */ export function withContentAndSyncedName( elem: TemplateElement, content: string ): TemplateElement { const updated = { ...elem, content }; if (!shouldSyncElementNameFromContent(elem)) return updated; return { ...updated, name: deriveElementNameFromContent(content, elem.type), }; } /** 深拷贝元素并生成新 id,用于复制/粘贴 */ export function cloneTemplateElementsForPaste( elements: TemplateElement[], pasteRepeat: number ): TemplateElement[] { const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat); const stamp = Date.now(); return elements.map((element, index) => { const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement; cloned.id = `${element.type}_${stamp}_${index}`; cloned.name = `${element.name} 副本`; cloned.x = Math.round((element.x + offset) * 10) / 10; cloned.y = Math.round((element.y + offset) * 10) / 10; return cloned; }); } export function toDataSourceMeta(data: ImportData): DataSourceMeta { return { fileName: data.fileName, sheets: data.sheets, currentSheet: data.currentSheet, columns: data.columns, rowCount: data.rows.length, }; } export function saveImportDataForTemplate(templateId: string, data: ImportData): void { try { localStorage.setItem(IMPORT_DATA_STORAGE_PREFIX + templateId, JSON.stringify(data)); } catch (e) { console.warn('Failed to save import data', e); } } export function loadImportDataForTemplate(templateId: string): ImportData | null { try { const raw = localStorage.getItem(IMPORT_DATA_STORAGE_PREFIX + templateId); if (raw) return JSON.parse(raw) as ImportData; } catch (e) {} return null; } export function clearImportDataForTemplate(templateId: string): void { try { localStorage.removeItem(IMPORT_DATA_STORAGE_PREFIX + templateId); } catch (e) {} } export function saveGlobalImportData(data: ImportData): void { try { localStorage.setItem(GLOBAL_IMPORT_DATA_KEY, JSON.stringify(data)); } catch (e) { console.warn('Failed to save global import data', e); } } export function loadGlobalImportData(): ImportData | null { try { const raw = localStorage.getItem(GLOBAL_IMPORT_DATA_KEY); if (raw) return JSON.parse(raw) as ImportData; } catch (e) {} return null; } export function clearGlobalImportData(): void { try { localStorage.removeItem(GLOBAL_IMPORT_DATA_KEY); } catch (e) {} } export function saveVariableColumnMapping(mapping: Record): void { try { localStorage.setItem(VARIABLE_MAPPING_KEY, JSON.stringify(mapping)); } catch (e) {} } export function loadVariableColumnMapping(): Record { try { const raw = localStorage.getItem(VARIABLE_MAPPING_KEY); if (raw) return JSON.parse(raw) as Record; } catch (e) {} return {}; } /** 变量名与数据列同名时自动建立映射 */ export function buildAutoVariableMapping( variables: string[], columns: string[] ): Record { const mapping: Record = {}; for (const v of variables) { if (columns.includes(v)) mapping[v] = v; } return mapping; } /** 变量是否已完成映射配置(含「使用默认值」) */ export function isVariableMappingConfigured( mapping: Record, varName: string, columns: string[] ): boolean { const mapped = mapping[varName]; if (!mapped) return false; if (mapped === VARIABLE_MAPPING_USE_DEFAULT) return true; if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) return true; return columns.includes(mapped); } /** 按映射将数据行中的列值填入模板变量键;「使用默认值」/「数据序号」时写入对应值 */ export function applyRowVariableMapping( row: RecordRow, mapping: Record, variables: string[], variableDefaults?: Record | null, rowIndex: number = 0 ): RecordRow { const result = { ...row }; for (const varName of variables) { const mapped = mapping[varName]; if (mapped === VARIABLE_MAPPING_USE_ROW_INDEX) { result[varName] = String(rowIndex + 1); continue; } if (mapped === VARIABLE_MAPPING_USE_DEFAULT) { const def = variableDefaults?.[varName]; if (def !== undefined && String(def).trim() !== '') { result[varName] = String(def).trim(); } else { delete result[varName]; } continue; } const col = mapped ?? varName; if (row[col] !== undefined) { result[varName] = row[col]; } } return result; } export const DEFAULT_EXPORT_RANGE: ExportRangeConfig = { mode: 'all' }; export const exportRangeEquals = (a: ExportRangeConfig, b: ExportRangeConfig) => 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, style: 'dashed', width: 0.2, color: '#333333', }; /** 裁切线有效线宽 (mm),预览与 PDF 共用 */ export function getCutLineStrokeWidthMm(cutLine: Pick): number { return Math.max(0.02, cutLine.width); } /** 虚线单段长度 (mm),预览与 PDF 共用 */ export function getCutLineDashSegmentMm(cutLine: Pick): number { if (cutLine.style !== 'dashed') return 0; return Math.max(0.5, cutLine.width * 4); } /** 预览 SVG strokeDasharray,scale 为 mm→px 比例 */ export function formatCutLineSvgDashArray( cutLine: Pick, scale: number ): string | undefined { if (cutLine.style !== 'dashed') return undefined; const seg = getCutLineDashSegmentMm(cutLine) * scale; return `${seg} ${seg}`; } export interface GridCutLinePositions { xs: number[]; ys: number[]; gridW: number; gridH: number; } /** 收集网格裁切线坐标(去重),每条线只绘制一次,避免转角/外缘叠线变粗 */ export function collectGridCutLinePositions( originX: number, originY: number, labelW: number, labelH: number, gridCols: number, gridRows: number, columnGap: number, rowGap: number ): GridCutLinePositions { const gridW = gridCols * labelW + Math.max(0, gridCols - 1) * columnGap; const gridH = gridRows * labelH + Math.max(0, gridRows - 1) * rowGap; const xs = new Set(); const ys = new Set(); for (let c = 0; c < gridCols; c++) { xs.add(originX + c * (labelW + columnGap)); xs.add(originX + c * (labelW + columnGap) + labelW); } for (let r = 0; r < gridRows; r++) { ys.add(originY + r * (labelH + rowGap)); ys.add(originY + r * (labelH + rowGap) + labelH); } return { xs: [...xs].sort((a, b) => a - b), ys: [...ys].sort((a, b) => a - b), gridW, gridH, }; } /** 间距为 0 且启用裁切线时,为裁切线预留半线宽通道,避免与标签图重叠导致内外粗细不一 */ export function getCutLineChannel( columnGap: number, rowGap: number, cutLine: { enabled: boolean; width: number } ): { x: number; y: number } { if (!cutLine.enabled) return { x: 0, y: 0 }; const half = cutLine.width / 2; return { x: columnGap === 0 ? half : 0, y: rowGap === 0 ? half : 0, }; } export interface PrintableLabel { row: RecordRow; /** 原始数据行索引(0-based),用于变量解析 */ sourceIndex: number; } /** 解析自定义范围字符串(1-based 序号) */ export function parseCustomExportRange(input: string, total: number): number[] { const trimmed = input.trim(); if (!trimmed || total <= 0) return []; const result = new Set(); const parts = trimmed.split(/[,,、\s]+/).filter(Boolean); for (const part of parts) { const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/); if (rangeMatch) { const start = Math.max(1, parseInt(rangeMatch[1], 10)); const end = Math.min(total, parseInt(rangeMatch[2], 10)); if (!Number.isNaN(start) && !Number.isNaN(end)) { for (let n = start; n <= end; n++) result.add(n - 1); } } else { const n = parseInt(part, 10); if (!Number.isNaN(n) && n >= 1 && n <= total) result.add(n - 1); } } return Array.from(result).sort((a, b) => a - b); } export function buildPrintablePages( rows: RecordRow[], paper: PaperConfig, template: Pick, exportRange: ExportRangeConfig, variableDefaults?: Record | null ) { const gridInfo = calculateGrid(paper, template); const labelsPerPage = gridInfo.cols * gridInfo.rows; 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)[][] = []; for (let p = 0; p < totalPages; p++) { const pageCells: (PrintableLabel | null)[] = []; const startIdx = p * labelsPerPage; for (let cell = 0; cell < labelsPerPage; cell++) { pageCells.push(selected[startIdx + cell] ?? null); } if (paper.flow === 'top-bottom-left-right') { const reordered: (PrintableLabel | null)[] = Array(labelsPerPage).fill(null); let ptr = 0; for (let c = 0; c < gridInfo.cols; c++) { for (let r = 0; r < gridInfo.rows; r++) { const cellIndexInGrid = r * gridInfo.cols + c; if (ptr < pageCells.length) { reordered[cellIndexInGrid] = pageCells[ptr++]; } } } pages.push(reordered); } else { pages.push(pageCells); } } return { pages, gridCols: gridInfo.cols, gridRows: gridInfo.rows, labelsPerPage, totalPages, selectedCount: selected.length, }; } /** 自动排版时允许的最小页边距 (mm) */ export const AUTO_LAYOUT_MIN_PAGE_MARGIN_MM = 10; const AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY = 'label_designer_auto_layout_min_margin_mm'; /** 读取上次自动排版使用的最小页边距 (mm) */ export function loadAutoLayoutMinPageMarginMm(): number { try { const raw = localStorage.getItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY); if (raw === null) return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM; const n = Number(raw); if (Number.isFinite(n) && n >= 0) return n; } catch { /* ignore */ } return AUTO_LAYOUT_MIN_PAGE_MARGIN_MM; } /** 保存自动排版使用的最小页边距 (mm) */ export function saveAutoLayoutMinPageMarginMm(value: number): void { try { localStorage.setItem(AUTO_LAYOUT_MIN_MARGIN_STORAGE_KEY, String(value)); } catch { /* ignore */ } } /** 按最小页边距居中计算四边页边距 */ export function computeAutoLayoutPaper( paper: PaperConfig, template: Pick, minMargin: number ): PaperConfig { const paperW = paper.orientation === 'portrait' ? paper.width : paper.height; const paperH = paper.orientation === 'portrait' ? paper.height : paper.width; const lw = template.width; const lh = template.height; const usableWInit = paperW - minMargin * 2; const usableHInit = paperH - minMargin * 2; const maxCols = Math.floor((usableWInit + paper.columnGap) / (lw + paper.columnGap)) || 1; const maxRows = Math.floor((usableHInit + paper.rowGap) / (lh + paper.rowGap)) || 1; const gridWidth = maxCols * lw + (maxCols - 1) * paper.columnGap; const gridHeight = maxRows * lh + (maxRows - 1) * paper.rowGap; const balancedLR = Math.max(minMargin, Math.round(((paperW - gridWidth) / 2) * 10) / 10); const balancedTB = Math.max(minMargin, Math.round(((paperH - gridHeight) / 2) * 10) / 10); return { ...paper, marginLeft: balancedLR, marginRight: balancedLR, marginTop: balancedTB, marginBottom: balancedTB, }; } export const DEFAULT_PAPER_CONFIG: PaperConfig = { type: 'A4', width: 210, height: 297, orientation: 'portrait', marginTop: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginBottom: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginLeft: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, marginRight: AUTO_LAYOUT_MIN_PAGE_MARGIN_MM, columnGap: 0, rowGap: 0, flow: 'left-right-top-bottom', }; /** 补全模板默认排版字段,剥离已废弃的数据源字段 */ export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate { const { ...rest } = tmpl; const cutLine: CutLineConfig = { ...DEFAULT_CUT_LINE_CONFIG, ...rest.cutLine, enabled: rest.cutLine?.enabled ?? true, width: typeof rest.cutLine?.width === 'number' && rest.cutLine.width > 0 ? rest.cutLine.width : DEFAULT_CUT_LINE_CONFIG.width, }; return { ...rest, paper: rest.paper ?? DEFAULT_PAPER_CONFIG, cutLine, }; } /** 从模板对象中剥离数据相关字段,仅保留设计信息 */ export function stripTemplateForStorage(tmpl: LabelTemplate): LabelTemplate { return normalizeTemplate(tmpl); } export const MM_TO_PX = 3.78; export function mmToPx(mm: number, zoom: number = 1): number { return imgRound(mm * MM_TO_PX * zoom); } function imgRound(val: number): number { return Math.round(val * 100) / 100; } /** 从文本中提取 {变量名} 占位符(排除 #INDEX) */ export function extractVariablesFromContent(content: string): string[] { const vars = new Set(); const matches = content.match(/\{([^}]+)\}/g); if (matches) { for (const m of matches) { const name = m.slice(1, -1).trim(); if (name && name !== '#INDEX') vars.add(name); } } return Array.from(vars); } /** 将元素背景色与不透明度解析为 canvas 可用的 rgba 字符串;无需绘制时返回 null */ export function resolveElementBackgroundFill( backgroundColor?: string, backgroundOpacity?: number ): string | null { if (!backgroundColor || backgroundColor === 'transparent') return null; const opacity = backgroundOpacity !== undefined ? backgroundOpacity : 100; if (opacity <= 0) return null; const alpha = Math.min(1, Math.max(0, opacity / 100)); const color = backgroundColor.trim(); if (color.startsWith('rgba(')) { const m = color.match(/rgba\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*\)/); if (m) { const a = Math.min(1, Math.max(0, parseFloat(m[4]) * alpha)); return `rgba(${m[1]},${m[2]},${m[3]},${a})`; } return color; } if (color.startsWith('rgb(')) { const m = color.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/); if (m) return `rgba(${m[1]},${m[2]},${m[3]},${alpha})`; } if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) { hex = hex.split('').map((c) => c + c).join(''); } if (hex.length === 8) { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); const a = (parseInt(hex.slice(6, 8), 16) / 255) * alpha; return `rgba(${r},${g},${b},${a})`; } if (hex.length === 6) { const r = parseInt(hex.slice(0, 2), 16); const g = parseInt(hex.slice(2, 4), 16); const b = parseInt(hex.slice(4, 6), 16); return `rgba(${r},${g},${b},${alpha})`; } } return color; } /** 从显示/过滤条件中收集引用的变量名 */ function collectVariablesFromVisibilityRule(rule: VisibilityRule, vars: Set) { const name = (rule.variable ?? '').trim(); if (name) vars.add(name); if (rule.value) { for (const v of extractVariablesFromContent(rule.value)) { vars.add(v); } } } function collectVariablesFromVisibilityRules(rules: VisibilityRule[] | undefined, vars: Set) { if (!rules) return; for (const rule of rules) { collectVariablesFromVisibilityRule(rule, vars); } } /** 从元素内容(含表格单元格)提取变量名 */ export function extractVariablesFromElement(elem: TemplateElement): string[] { const vars = new Set(); for (const v of extractVariablesFromContent(elem.content)) { vars.add(v); } if (elem.type === 'table' && elem.tableCells) { for (const cell of Object.values(elem.tableCells)) { if (cell.content) { for (const v of extractVariablesFromContent(cell.content)) { vars.add(v); } } } } return Array.from(vars); } /** 从模板汇总全部变量(声明列表 + 内容 + 显示控制) */ export function extractTemplateVariables(template: { variableList?: string[]; elements: TemplateElement[]; dataFilterRules?: VisibilityRule[]; exportImageFilterRules?: VisibilityRule[]; }): string[] { const vars = new Set(template.variableList ?? []); for (const elem of template.elements) { for (const v of extractVariablesFromElement(elem)) { vars.add(v); } collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars); } collectVariablesFromVisibilityRules(template.dataFilterRules, vars); collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars); return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN')); } /** 被元素内容或显示条件引用的变量 */ export function getUsedTemplateVariables(template: { elements: TemplateElement[]; dataFilterRules?: VisibilityRule[]; exportImageFilterRules?: VisibilityRule[]; }): string[] { const vars = new Set(); for (const elem of template.elements) { for (const v of extractVariablesFromElement(elem)) { vars.add(v); } collectVariablesFromVisibilityRules(resolveVisibilityRules(elem), vars); } collectVariablesFromVisibilityRules(template.dataFilterRules, vars); collectVariablesFromVisibilityRules(template.exportImageFilterRules, vars); return Array.from(vars); } export function isTemplateVariableInUse( template: { elements: TemplateElement[] }, varName: string ): boolean { return getUsedTemplateVariables(template).includes(varName); } /** 从模板声明列表移除未使用的变量(内容及显示条件未引用) */ export function removeUnusedTemplateVariable( template: LabelTemplate, varName: string ): LabelTemplate { if (isTemplateVariableInUse(template, varName)) return template; const variableList = (template.variableList ?? []).filter((v) => v !== varName); const defaults = { ...(template.variableDefaults || {}) }; delete defaults[varName]; return { ...template, variableList: variableList.length > 0 ? variableList : undefined, variableDefaults: Object.keys(defaults).length > 0 ? defaults : undefined, }; } export function isConditionalVisibility(elem: TemplateElement): boolean { if (elem.visibilityMode === 'conditional') return true; if (elem.visibilityMode === 'always') return false; return !!elem.hideWhenNoData; } export function visibilityOperatorNeedsValue(operator: VisibilityOperator): boolean { return ( operator === 'gt' || operator === 'lt' || operator === 'eq' || operator === 'neq' || operator === 'contains' || operator === 'not_contains' ); } export function isVisibilityRuleEnabled(rule: VisibilityRule): boolean { return rule.enabled !== false; } export function getActiveVisibilityRules(rules: VisibilityRule[]): VisibilityRule[] { return rules.filter(isVisibilityRuleEnabled); } export function toggleVisibilityRuleEnabled(rule: VisibilityRule): VisibilityRule { return { ...rule, enabled: isVisibilityRuleEnabled(rule) ? false : undefined }; } /** 将显示条件格式化为可读说明 */ export function formatVisibilityRuleDescription(rule: VisibilityRule): string { const subjectLabel = rule.target === 'content' ? '内容值' : `{${rule.variable}}`; switch (rule.operator) { case 'empty': return `${subjectLabel} 为空`; case 'not_empty': return `${subjectLabel} 不为空`; case 'gt': return `${subjectLabel} 大于 ${rule.value ?? '—'}`; case 'lt': return `${subjectLabel} 小于 ${rule.value ?? '—'}`; case 'eq': return `${subjectLabel} 等于 ${rule.value ?? '—'}`; case 'neq': return `${subjectLabel} 不等于 ${rule.value ?? '—'}`; case 'contains': return `${subjectLabel} 包含 ${rule.value ?? '—'}`; case 'not_contains': return `${subjectLabel} 不包含 ${rule.value ?? '—'}`; default: return `${subjectLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`; } } export function isVisibilityContentTarget(rule: VisibilityRule): boolean { return rule.target === 'content'; } /** 解析显示控制规则(兼容旧 visibilityVariables 字段) */ export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[] { if (elem.visibilityRules !== undefined) { return elem.visibilityRules .map((r) => ({ target: r.target ?? 'variable', variable: (r.variable ?? '').trim(), operator: r.operator, value: r.value, enabled: r.enabled, })) .filter((r) => r.target === 'content' || r.variable); } const legacyVars = (elem.visibilityVariables ?? []).map((v) => v.trim()).filter(Boolean); if (legacyVars.length > 0) { return legacyVars.map((variable) => ({ variable, operator: 'not_empty' as const })); } const legacy = elem.visibilityVariable?.trim(); if (legacy) return [{ variable: legacy, operator: 'not_empty' }]; if (elem.hideWhenNoData) { return extractVariablesFromContent(elem.content).map((variable) => ({ variable, operator: 'not_empty' as const, })); } return []; } function parseComparableNumber(value: string): number | null { const num = Number(value.replace(/,/g, '').trim()); return Number.isNaN(num) ? null : num; } function compareVariableToTarget( raw: string, target: string, operator: 'gt' | 'lt' | 'eq' | 'neq' ): boolean { const numRaw = parseComparableNumber(raw); const numTarget = parseComparableNumber(target); if (numRaw !== null && numTarget !== null) { if (operator === 'gt') return numRaw > numTarget; if (operator === 'lt') return numRaw < numTarget; if (operator === 'eq') return numRaw === numTarget; return numRaw !== numTarget; } if (operator === 'eq') return raw === target; if (operator === 'neq') return raw !== target; return false; } /** 读取显示条件所判断的原始值 */ function getVisibilityRuleRawValue( rule: VisibilityRule, elem: TemplateElement | undefined, row: RecordRow | null, rowIndex: number, defaults?: Record | null, mode: ContentResolveMode = 'output' ): string | undefined { if (isVisibilityContentTarget(rule)) { if (!elem) return undefined; const effectiveRow = row ?? (mode === 'design' && defaults ? (defaults as RecordRow) : null); const resolved = resolveElementCoreValue(elem, effectiveRow, rowIndex, defaults, 'output'); const trimmed = resolved.trim(); return trimmed || undefined; } return getVariableRawValue(rule.variable, row, defaults, mode); } /** 解析条件目标值(支持 {变量名}) */ export function resolveVisibilityRuleCompareTarget( targetTemplate: string | undefined, row: RecordRow | null, defaults: Record | null | undefined, mode: ContentResolveMode, rowIndex: number = 0 ): string { const trimmed = (targetTemplate ?? '').trim(); if (!trimmed) return ''; if (!trimmed.includes('{')) return trimmed; return resolveContent(trimmed, row, rowIndex, defaults, { mode }).trim(); } /** 单条显示条件是否满足 */ export function evaluateVisibilityRule( rule: VisibilityRule, row: RecordRow | null, defaults?: Record | null, mode: ContentResolveMode = 'output', elem?: TemplateElement, rowIndex: number = 0 ): boolean { const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode); const isEmpty = raw === undefined; const compareTarget = resolveVisibilityRuleCompareTarget( rule.value, row, defaults, mode, rowIndex ); switch (rule.operator) { case 'empty': return isEmpty; case 'not_empty': return !isEmpty; case 'gt': case 'lt': case 'eq': { if (isEmpty) return false; return compareVariableToTarget(raw!, compareTarget, rule.operator); } case 'neq': { if (isEmpty) return compareTarget !== ''; return compareVariableToTarget(raw!, compareTarget, 'neq'); } case 'contains': { if (!compareTarget || isEmpty) return false; return raw!.includes(compareTarget); } case 'not_contains': { if (!compareTarget) return true; if (isEmpty) return true; return !raw!.includes(compareTarget); } default: return true; } } export type ContentResolveMode = 'design' | 'output'; export interface ResolveContentOptions { mode?: ContentResolveMode; /** 对单个变量替换值做格式化(如数值/百分比) */ formatSubst?: (raw: string) => string; } /** 将 {变量} 替换为行数据或设计默认值 */ export function resolveContent( content: string, row: RecordRow | null, fallbackIndex: number = 0, defaults?: Record | null, options?: ResolveContentOptions ): string { if (!content) return ''; const mode = options?.mode ?? 'design'; return content.replace(/\{([^}]+)\}/g, (_, key) => { if (key === '#INDEX') return String(fallbackIndex + 1); let val: string | undefined; if (row && row[key] !== undefined && String(row[key]).trim() !== '') { val = String(row[key]); } else if (defaults && defaults[key] !== undefined && String(defaults[key]).trim() !== '') { val = String(defaults[key]); } if (val === undefined) { return mode === 'design' ? `[${key}]` : ''; } return options?.formatSubst ? options.formatSubst(val) : val; }); } /** 清理为安全的文件名(不含扩展名) */ export function sanitizeFileName(name: string, fallback = 'label'): string { const cleaned = name .replace(/[\\/:*?"<>|]/g, '_') .replace(/[\u0000-\u001f]/g, '') .replace(/\s+/g, ' ') .trim() .replace(/[. ]+$/g, '') .slice(0, 120); return cleaned || fallback; } /** * 解析批量导出图片的文件名模板。 * - {#SEQ}:导出顺序(1-based);{#SEQ:4} 可补零 * - {#INDEX}:数据行序号(1-based);{#INDEX:4} 可补零 * - {变量名}:当前行数据或 variableDefaults */ export function resolveExportImageFileName( pattern: string | undefined, label: PrintableLabel, seq: number, padWidth: number, variableDefaults?: Record | null ): string { if (!pattern?.trim()) { return String(seq).padStart(padWidth, '0'); } const normalizedPattern = pattern.replace(/\r?\n/g, '').trim(); if (!normalizedPattern) { return String(seq).padStart(padWidth, '0'); } const resolved = normalizedPattern.replace(/\{([^}]+)\}/g, (_, key: string) => { const special = key.match(/^(#SEQ|#INDEX)(?::(\d+))?$/); if (special) { const width = special[2] ? parseInt(special[2], 10) : 0; const raw = special[1] === '#SEQ' ? String(seq) : String(label.sourceIndex + 1); return width > 0 ? raw.padStart(width, '0') : raw; } if (key === '#SEQ') return String(seq); if (key === '#INDEX') return String(label.sourceIndex + 1); let val: string | undefined; if (label.row && label.row[key] !== undefined && String(label.row[key]).trim() !== '') { val = String(label.row[key]); } else if ( variableDefaults && variableDefaults[key] !== undefined && String(variableDefaults[key]).trim() !== '' ) { val = String(variableDefaults[key]); } return val ?? ''; }); return sanitizeFileName(resolved); } /** 解析变量过滤规则(仅 variable 目标) */ export function resolveVariableFilterRules(rules?: VisibilityRule[]): VisibilityRule[] { return (rules ?? []) .map((rule) => ({ target: rule.target ?? 'variable', variable: (rule.variable ?? '').trim(), operator: rule.operator, value: rule.value, enabled: rule.enabled, })) .filter((rule) => rule.target === 'variable' && rule.variable); } /** 数据行是否满足全部变量过滤条件 */ export function rowMatchesVariableFilterRules( row: RecordRow, rowIndex: number, rules: VisibilityRule[], variableDefaults?: Record | null ): boolean { const activeRules = getActiveVisibilityRules(rules); if (activeRules.length === 0) return true; return activeRules.every((rule) => evaluateVisibilityRule(rule, row, variableDefaults, 'output', undefined, rowIndex) ); } export function isExportImageFilterConditional( template: Pick ): boolean { return template.exportImageFilterMode === 'conditional'; } export function resolveExportImageFilterRules( template: Pick ): VisibilityRule[] { return resolveVariableFilterRules(template.exportImageFilterRules); } /** 批量导出图片时,当前数据行是否应导出 */ export function shouldExportLabelImage( template: Pick, label: PrintableLabel, variableDefaults?: Record | null ): boolean { if (!isExportImageFilterConditional(template)) return true; const rules = resolveExportImageFilterRules(template); return rowMatchesVariableFilterRules(label.row, label.sourceIndex, rules, variableDefaults); } export function filterLabelsForImageExport( printablePages: (PrintableLabel | null)[][], template: Pick, variableDefaults?: Record | null ): PrintableLabel[] { return flattenPrintableLabelsForExport(printablePages).filter((label) => shouldExportLabelImage(template, label, variableDefaults) ); } function flattenPrintableLabelsForExport( printablePages: (PrintableLabel | null)[][] ): PrintableLabel[] { const labels: PrintableLabel[] = []; for (const page of printablePages) { for (const item of page) { if (item) labels.push(item); } } return labels; } /** 格式化单个数值用于文本显示 */ export function formatDisplayValue( value: string, format: Exclude, decimalPlaces?: number ): string { const trimmed = value.trim(); if (!trimmed) return ''; const num = Number(trimmed.replace(/,/g, '')); if (Number.isNaN(num)) return value; const places = decimalPlaces ?? 2; const formatted = num.toLocaleString('zh-CN', { minimumFractionDigits: places, maximumFractionDigits: places, }); switch (format) { case 'number': return formatted; case 'percent': return `${formatted}%`; case 'currency': return `¥${formatted}`; default: return value; } } /** 按整数/小数部分拆分数值显示(如价格 19.9 →「19」与「.9」分元素展示) */ export function applyNumberPartDisplay( value: string, format: Exclude, decimalPlaces: number | undefined, part: NumberPartDisplay ): string { if (part === 'full') { return formatDisplayValue(value, format, decimalPlaces); } const trimmed = value.trim().replace(/,/g, ''); if (!trimmed) return ''; const num = Number(trimmed); if (Number.isNaN(num)) { return formatDisplayValue(value, format, decimalPlaces); } const places = decimalPlaces ?? 2; const sign = num < 0 ? '-' : ''; const abs = Math.abs(num); const intVal = Math.trunc(abs); const intStr = intVal.toLocaleString('zh-CN'); if (part === 'integer') { switch (format) { case 'number': return `${sign}${intStr}`; case 'percent': return `${sign}${intStr}%`; case 'currency': return `${sign}¥${intStr}`; default: return `${sign}${intStr}`; } } const fracNum = abs - intVal; let fracStr = fracNum.toFixed(places).slice(1); if (!fracStr || fracStr === '.' || fracStr === '.' + '0'.repeat(places)) { fracStr = places > 0 ? '.' + '0'.repeat(places) : ''; } return fracStr; } /** 按分隔符取值中的第 N 段(1-based);段数不足时返回空字符串 */ export function applyTextSplitSegment( value: string, delimiter: string, index: number ): string { if (!delimiter || index < 1) return value; const parts = value.split(delimiter); if (index > parts.length) return ''; return parts[index - 1]; } /** 解析元素有效的文本提取方式(兼容旧版仅配置分隔符) */ export function getTextExtractMode(elem: Pick< TemplateElement, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' >): TextExtractMode { if (elem.textExtractMode !== undefined) return elem.textExtractMode; const delimiter = elem.textSplitDelimiter?.trim(); const splitIndex = elem.textSplitIndex ?? 0; if (delimiter && splitIndex >= 1) return 'split'; return 'none'; } /** 按配置提取变量值片段 */ export function applyTextExtract( value: string, elem: Pick< TemplateElement, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength' > ): string { const trimmed = value.trim(); const mode = getTextExtractMode(elem); switch (mode) { case 'split': { const delimiter = elem.textSplitDelimiter?.trim(); const index = elem.textSplitIndex ?? 0; if (!delimiter || index < 1) return trimmed; return applyTextSplitSegment(trimmed, delimiter, index); } case 'prefix': { const n = elem.textExtractLength ?? 0; if (n < 1) return trimmed; return trimmed.slice(0, n); } case 'suffix': { const n = elem.textExtractLength ?? 0; if (n < 1) return trimmed; return trimmed.slice(-n); } default: return trimmed; } } /** 元素是否启用了变量值提取(文本/条码/二维码/图片内容均有效) */ export function elementHasValueExtract( elem: Pick< TemplateElement, 'textExtractMode' | 'textSplitDelimiter' | 'textSplitIndex' | 'textExtractLength' > ): boolean { const mode = getTextExtractMode(elem); if (mode === 'split') { const delimiter = elem.textSplitDelimiter?.trim(); const index = elem.textSplitIndex ?? 0; return !!(delimiter && index >= 1); } if (mode === 'prefix' || mode === 'suffix') { return (elem.textExtractLength ?? 0) >= 1; } return false; } /** @deprecated 使用 elementHasValueExtract */ export const textElementHasExtract = elementHasValueExtract; /** 提取后为文本追加显示用前后缀(不参与内容值条件) */ export function applyTextDisplayAffix( value: string, elem: Pick ): string { const prefix = elem.textDisplayPrefix ?? ''; const suffix = elem.textDisplaySuffix ?? ''; if (!prefix && !suffix) return value; return `${prefix}${value}${suffix}`; } /** 文本元素单个变量值的数值格式化(不含内容提取) */ export function formatTextNumberSubstValue(elem: TemplateElement, raw: string): string { if (elem.type !== 'text' || !elem.textFormat || elem.textFormat === 'text') { return raw; } return applyNumberPartDisplay( raw, elem.textFormat as Exclude, elem.decimalPlaces, elem.numberPartDisplay ?? 'full' ); } /** @deprecated 提取已改为对完整内容生效,请使用 formatTextNumberSubstValue */ export function formatElementSubstValue(elem: TemplateElement, raw: string): string { return formatTextNumberSubstValue(elem, raw); } /** @deprecated 使用 formatTextNumberSubstValue */ export const formatTextSubstValue = formatElementSubstValue; /** 文本元素是否需要对变量替换值做数值格式化 */ export function textElementNeedsNumberFormatSubst(elem: TemplateElement): boolean { return elem.type === 'text' && !!(elem.textFormat && elem.textFormat !== 'text'); } /** @deprecated 使用 textElementNeedsNumberFormatSubst 与 elementHasValueExtract */ export function elementNeedsFormatSubst(elem: TemplateElement): boolean { return elementHasValueExtract(elem) || textElementNeedsNumberFormatSubst(elem); } /** @deprecated 使用 textElementNeedsNumberFormatSubst */ export const textElementNeedsFormatSubst = elementNeedsFormatSubst; /** 读取变量原始值(仅行数据;设计模式可选用默认值) */ export function getVariableRawValue( varName: string, row: RecordRow | null, defaults?: Record | null, mode: ContentResolveMode = 'output' ): string | undefined { if (row && row[varName] !== undefined && String(row[varName]).trim() !== '') { return String(row[varName]).trim(); } if (mode === 'design' && defaults && defaults[varName] !== undefined && String(defaults[varName]).trim() !== '') { return String(defaults[varName]).trim(); } return undefined; } /** 是否应渲染元素(显示控制为 conditional 时按关联变量判断;设计模式使用变量默认值) */ export function shouldRenderElement( elem: TemplateElement, row: RecordRow | null, rowIndex: number, defaults?: Record | null, mode: ContentResolveMode = 'output' ): boolean { if (!isConditionalVisibility(elem)) return true; const rules = resolveVisibilityRules(elem); if (rules.length > 0) { const activeRules = getActiveVisibilityRules(rules); if (activeRules.length === 0) return true; return activeRules.every((rule) => evaluateVisibilityRule(rule, row, defaults, mode, elem, rowIndex) ); } if (elem.visibilityMode === 'conditional' || elem.visibilityRules !== undefined) { return true; } const value = resolveElementCoreValue(elem, row, rowIndex, defaults, mode); return !!value && String(value).trim() !== ''; } /** 解析元素内容值:变量替换 + 数值格式 + 提取(不含显示前后缀) */ export function resolveElementCoreValue( elem: TemplateElement, row: RecordRow | null, rowIndex: number, defaults?: Record | null, mode: ContentResolveMode = 'design' ): string { const formatSubst = textElementNeedsNumberFormatSubst(elem) ? (raw: string) => formatTextNumberSubstValue(elem, raw) : undefined; let resolved = resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst }); if (elementHasValueExtract(elem)) { resolved = applyTextExtract(resolved, elem); } return resolved; } /** 解析元素最终显示内容(含显示前后缀) */ export function resolveElementValue( elem: TemplateElement, row: RecordRow | null, rowIndex: number, defaults?: Record | null, mode: ContentResolveMode = 'design' ): string { const core = resolveElementCoreValue(elem, row, rowIndex, defaults, mode); if (elem.type !== 'text') return core; return applyTextDisplayAffix(core, elem); } /** 输出模式下条码/二维码/图片内容为空时不渲染(除非 showWhenContentEmpty 为 true) */ export function isGraphicContentEmpty( elem: TemplateElement, value: string, mode: ContentResolveMode ): boolean { if (mode === 'design') return false; if (elem.type !== 'barcode' && elem.type !== 'qrcode' && elem.type !== 'image') return false; const empty = !value || !String(value).trim(); if (!empty) return false; return !elem.showWhenContentEmpty; } // Generate columns and rows based on paper dimensions, label dimensions, gaps, and margins export function calculateGrid(paper: PaperConfig, label: { width: number; height: number }) { const paperW = paper.orientation === 'portrait' ? paper.width : paper.height; const paperH = paper.orientation === 'portrait' ? paper.height : paper.width; const usableW = paperW - paper.marginLeft - paper.marginRight; const usableH = paperH - paper.marginTop - paper.marginBottom; // Let's solve: cols * label.width + (cols - 1) * columnGap <= usableW // cols * (label.width + columnGap) - columnGap <= usableW // cols <= (usableW + columnGap) / (label.width + columnGap) const cols = Math.max(1, Math.floor((usableW + paper.columnGap) / (label.width + paper.columnGap))); const rows = Math.max(1, Math.floor((usableH + paper.rowGap) / (label.height + paper.rowGap))); return { cols, rows }; } export const SAMPLE_COLUMNS = ['货位编号', '物料名称', '规格型号', '批次号', '条形码', '二维码']; export const SAMPLE_ROWS: RecordRow[] = [ { '货位编号': 'A-01-05', '物料名称': '轴承支撑座', '规格型号': 'BF12-M8', '批次号': '2026061101', '条形码': 'LOC-A0105', '二维码': 'https://ais-dev.run.apps/cargo/A0105' }, { '货位编号': 'A-01-06', '物料名称': '高精滚珠丝杠', '规格型号': '1605-300mm', '批次号': '2026061102', '条形码': 'LOC-A0106', '二维码': 'https://ais-dev.run.apps/cargo/A0106' }, { '货位编号': 'B-02-11', '物料名称': '同步带轮', '规格型号': 'XL20-6.35B', '批次号': '2026061103', '条形码': 'LOC-B0211', '二维码': 'https://ais-dev.run.apps/cargo/B0211' }, { '货位编号': 'B-02-12', '物料名称': '联轴器弹性体', '规格型号': 'D20L25-8x8', '批次号': '2026061104', '条形码': 'LOC-B0212', '二维码': 'https://ais-dev.run.apps/cargo/B0212' }, { '货位编号': 'C-03-01', '物料名称': '防尘阻水风扇', '规格型号': '12038-220V', '批次号': '2026061105', '条形码': 'LOC-C0301', '二维码': 'https://ais-dev.run.apps/cargo/C0301' }, { '货位编号': 'C-03-02', '物料名称': '直流开关电源', '规格型号': 'LRS-350-24', '批次号': '2026061106', '条形码': 'LOC-C0302', '二维码': 'https://ais-dev.run.apps/cargo/C0302' }, { '货位编号': 'D-04-15', '物料名称': '工业继电器组', '规格型号': 'MY2NJ-DC24V', '批次号': '2026061107', '条形码': 'LOC-D0415', '二维码': 'https://ais-dev.run.apps/cargo/D0415' }, { '货位编号': 'D-04-16', '物料名称': '可编程控制器', '规格型号': 'FX3U-14MT', '批次号': '2026061108', '条形码': 'LOC-D0416', '二维码': 'https://ais-dev.run.apps/cargo/D0416' } ];