init commit

This commit is contained in:
iqudoo
2026-06-12 19:34:27 +08:00
commit 89b59ecc1b
38 changed files with 17719 additions and 0 deletions

691
src/utils.ts Normal file
View File

@@ -0,0 +1,691 @@
import {
LabelTemplate,
PaperConfig,
RecordRow,
ImportData,
DataSourceMeta,
TemplateElement,
TextFormat,
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__';
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;
return columns.includes(mapped);
}
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」时写入 variableDefaults */
export function applyRowVariableMapping(
row: RecordRow,
mapping: Record<string, string>,
variables: string[],
variableDefaults?: Record<string, string> | null
): RecordRow {
const result = { ...row };
for (const varName of variables) {
const mapped = mapping[varName];
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',
};
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,
};
}
export const DEFAULT_PAPER_CONFIG: PaperConfig = {
type: 'A4',
width: 210,
height: 297,
orientation: 'portrait',
marginTop: 10,
marginBottom: 10,
marginLeft: 10,
marginRight: 10,
columnGap: 2,
rowGap: 2,
flow: 'left-right-top-bottom',
};
/** 补全模板默认排版字段,剥离已废弃的数据源字段 */
export function normalizeTemplate(tmpl: LabelTemplate): LabelTemplate {
const { dataSource, dataSourceMeta, showPdfCutLines, exportRange: _exportRange, ...rest } = tmpl;
const cutLine: CutLineConfig = {
...DEFAULT_CUT_LINE_CONFIG,
...rest.cutLine,
enabled: rest.cutLine?.enabled ?? showPdfCutLines !== false,
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 extractTemplateVariables(template: {
variableList?: string[];
elements: TemplateElement[];
}): string[] {
const vars = new Set<string>(template.variableList ?? []);
for (const elem of template.elements) {
for (const v of extractVariablesFromContent(elem.content)) {
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 extractVariablesFromContent(elem.content)) {
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 varLabel = `{${rule.variable}}`;
switch (rule.operator) {
case 'empty':
return `${varLabel} 为空`;
case 'not_empty':
return `${varLabel} 不为空`;
case 'gt':
return `${varLabel} 大于 ${rule.value ?? '—'}`;
case 'lt':
return `${varLabel} 小于 ${rule.value ?? '—'}`;
case 'eq':
return `${varLabel} 等于 ${rule.value ?? '—'}`;
case 'neq':
return `${varLabel} 不等于 ${rule.value ?? '—'}`;
default:
return `${varLabel} ${VISIBILITY_OPERATOR_LABELS[rule.operator]}`;
}
}
/** 解析显示控制规则(兼容旧 visibilityVariables 字段) */
export function resolveVisibilityRules(elem: TemplateElement): VisibilityRule[] {
if (elem.visibilityRules !== undefined) {
return elem.visibilityRules
.map((r) => ({
variable: r.variable.trim(),
operator: r.operator,
value: r.value,
}))
.filter((r) => 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;
}
/** 单条显示条件是否满足 */
export function evaluateVisibilityRule(
rule: VisibilityRule,
row: RecordRow | null,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'output'
): boolean {
const raw = getVariableRawValue(rule.variable, row, 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;
}
}
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
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));
}
if (elem.visibilityMode === 'conditional' || elem.visibilityRules !== undefined) {
return true;
}
const value = resolveElementValue(elem, row, rowIndex, defaults, mode);
return !!value && String(value).trim() !== '';
}
/** 解析元素最终显示内容(含文本格式) */
export function resolveElementValue(
elem: TemplateElement,
row: RecordRow | null,
rowIndex: number,
defaults?: Record<string, string> | null,
mode: ContentResolveMode = 'design'
): string {
const formatSubst =
elem.type === 'text' && elem.textFormat && elem.textFormat !== 'text'
? (raw: string) =>
formatDisplayValue(
raw,
elem.textFormat as Exclude<TextFormat, 'text'>,
elem.decimalPlaces
)
: undefined;
return resolveContent(elem.content, row, rowIndex, defaults, { mode, formatSubst });
}
/** 输出模式下条码/二维码/图片内容为空时不渲染 */
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;
return !value || !String(value).trim();
}
// 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' }
];