init commit
This commit is contained in:
691
src/utils.ts
Normal file
691
src/utils.ts
Normal 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' }
|
||||
];
|
||||
Reference in New Issue
Block a user