1296 lines
42 KiB
TypeScript
1296 lines
42 KiB
TypeScript
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;
|
||
|
||
/** 深拷贝元素并生成新 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<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 ?? '');
|
||
|
||
/** 模板数据过滤规则(仅 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 = {
|
||
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 strokeDasharray,scale 为 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;
|
||
}
|
||
|
||
/** 解析自定义范围字符串(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,
|
||
template: Pick<LabelTemplate, 'width' | 'height' | 'dataFilterRules'>,
|
||
exportRange: ExportRangeConfig,
|
||
variableDefaults?: Record<string, string> | 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<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' ||
|
||
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<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');
|
||
}
|
||
case 'contains': {
|
||
const target = (rule.value ?? '').trim();
|
||
if (!target || isEmpty) return false;
|
||
return raw!.includes(target);
|
||
}
|
||
case 'not_contains': {
|
||
const target = (rule.value ?? '').trim();
|
||
if (!target) return true;
|
||
if (isEmpty) return true;
|
||
return !raw!.includes(target);
|
||
}
|
||
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 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<string, string> | 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<string, string> | 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<LabelTemplate, 'exportImageFilterMode'>
|
||
): boolean {
|
||
return template.exportImageFilterMode === 'conditional';
|
||
}
|
||
|
||
export function resolveExportImageFilterRules(
|
||
template: Pick<LabelTemplate, 'exportImageFilterRules'>
|
||
): VisibilityRule[] {
|
||
return resolveVariableFilterRules(template.exportImageFilterRules);
|
||
}
|
||
|
||
/** 批量导出图片时,当前数据行是否应导出 */
|
||
export function shouldExportLabelImage(
|
||
template: Pick<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
|
||
label: PrintableLabel,
|
||
variableDefaults?: Record<string, string> | 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<LabelTemplate, 'exportImageFilterMode' | 'exportImageFilterRules'>,
|
||
variableDefaults?: Record<string, string> | 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<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) {
|
||
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<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' }
|
||
];
|