Files
label-designer/src/utils.ts
2026-06-14 02:52:51 +08:00

1066 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '数据序号';
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<string, string>): void {
try {
localStorage.setItem(VARIABLE_MAPPING_KEY, JSON.stringify(mapping));
} catch (e) {}
}
export function loadVariableColumnMapping(): Record<string, string> {
try {
const raw = localStorage.getItem(VARIABLE_MAPPING_KEY);
if (raw) return JSON.parse(raw) as Record<string, string>;
} catch (e) {}
return {};
}
/** 变量名与数据列同名时自动建立映射 */
export function buildAutoVariableMapping(
variables: string[],
columns: string[]
): Record<string, string> {
const mapping: Record<string, string> = {};
for (const v of variables) {
if (columns.includes(v)) mapping[v] = v;
}
return mapping;
}
/** 变量是否已完成映射配置(含「使用默认值」) */
export function isVariableMappingConfigured(
mapping: Record<string, string>,
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<string, string>,
variables: string[],
variableDefaults?: Record<string, string> | 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 ?? '');
export const DEFAULT_CUT_LINE_CONFIG: CutLineConfig = {
enabled: true,
style: 'dashed',
width: 0.1,
color: '#cccccc',
};
/** 裁切线有效线宽 (mm),预览与 PDF 共用 */
export function getCutLineStrokeWidthMm(cutLine: Pick<CutLineConfig, 'width'>): number {
return Math.max(0.02, cutLine.width);
}
/** 虚线单段长度 (mm),预览与 PDF 共用 */
export function getCutLineDashSegmentMm(cutLine: Pick<CutLineConfig, 'style' | 'width'>): number {
if (cutLine.style !== 'dashed') return 0;
return Math.max(0.5, cutLine.width * 4);
}
/** 预览 SVG strokeDasharrayscale 为 mm→px 比例 */
export function formatCutLineSvgDashArray(
cutLine: Pick<CutLineConfig, 'style' | 'width'>,
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<number>();
const ys = new Set<number>();
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;
}
/** 按导出范围筛选 0-based 行索引(保持升序) */
export function getExportRangeIndices(total: number, range: ExportRangeConfig): number[] {
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 'all':
default:
return Array.from({ length: total }, (_, i) => i);
}
}
/** 解析自定义范围字符串1-based 序号) */
export function parseCustomExportRange(input: string, total: number): number[] {
const trimmed = input.trim();
if (!trimmed || total <= 0) return [];
const result = new Set<number>();
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,
labelSize: { width: number; height: number },
exportRange: ExportRangeConfig
) {
const gridInfo = calculateGrid(paper, labelSize);
const labelsPerPage = gridInfo.cols * gridInfo.rows;
const indices = getExportRangeIndices(rows.length, exportRange);
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<LabelTemplate, 'width' | 'height'>,
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: 2,
rowGap: 2,
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<string>();
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;
}
/** 从元素内容(含表格单元格)提取变量名 */
export function extractVariablesFromElement(elem: TemplateElement): string[] {
const vars = new Set<string>();
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[];
}): string[] {
const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
}
return Array.from(vars).sort((a, b) => a.localeCompare(b, 'zh-CN'));
}
/** 被元素内容或显示条件引用的变量 */
export function getUsedTemplateVariables(template: { elements: TemplateElement[] }): string[] {
const vars = new Set<string>();
for (const elem of template.elements) {
for (const v of extractVariablesFromElement(elem)) {
vars.add(v);
}
for (const rule of resolveVisibilityRules(elem)) {
const name = (rule.variable ?? '').trim();
if (name) vars.add(name);
}
}
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';
}
/** 将显示条件格式化为可读说明 */
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 ?? '—'}`;
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,
}))
.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<string, string> | 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 evaluateVisibilityRule(
rule: VisibilityRule,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output',
elem?: TemplateElement,
rowIndex: number = 0
): boolean {
const raw = getVisibilityRuleRawValue(rule, elem, row, rowIndex, defaults, mode);
const isEmpty = raw === undefined;
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!, (rule.value ?? '').trim(), rule.operator);
}
case 'neq': {
const target = (rule.value ?? '').trim();
if (isEmpty) return target !== '';
return compareVariableToTarget(raw!, target, 'neq');
}
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<string, string> | 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 formatDisplayValue(
value: string,
format: Exclude<TextFormat, 'text'>,
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<TextFormat, 'text'>,
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<TemplateElement, 'textDisplayPrefix' | 'textDisplaySuffix'>
): 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<TextFormat, 'text'>,
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<string, string> | 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<string, string> | null,
mode: ContentResolveMode = 'output'
): boolean {
if (!isConditionalVisibility(elem)) return true;
const rules = resolveVisibilityRules(elem);
if (rules.length > 0) {
return rules.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<string, string> | 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<string, string> | 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' }
];