优化风格

This commit is contained in:
iqudoo
2026-06-13 12:56:41 +08:00
parent 765d94b0b4
commit 8b09bcaa14
13 changed files with 400 additions and 42 deletions

View File

@@ -498,12 +498,13 @@ export default function App() {
const mappedRows = useMemo(
() =>
activeRows.map((row) =>
activeRows.map((row, rowIndex) =>
applyRowVariableMapping(
row,
variableColumnMapping,
templateVariables,
workingTemplate.variableDefaults
workingTemplate.variableDefaults,
rowIndex
)
),
[activeRows, variableColumnMapping, templateVariables, workingTemplate.variableDefaults]
@@ -530,12 +531,13 @@ export default function App() {
const appliedMappedRows = useMemo(() => {
if (!appliedExportData) return [];
return appliedExportData.rows.map((row) =>
return appliedExportData.rows.map((row, rowIndex) =>
applyRowVariableMapping(
row,
appliedExportData.mapping,
templateVariables,
workingTemplate.variableDefaults
workingTemplate.variableDefaults,
rowIndex
)
);
}, [appliedExportData, templateVariables, workingTemplate.variableDefaults]);

View File

@@ -3,6 +3,7 @@ import * as XLSX from 'xlsx';
import { ImportData, RecordRow } from '../types';
import { FileSpreadsheet, Upload, Download, Trash2 } from 'lucide-react';
import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect';
export function downloadDataTemplate(templateName: string, templateVariables: string[]) {
const columns = templateVariables.length > 0 ? templateVariables : ['数据'];
@@ -331,7 +332,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
>
</label>
<select
<FieldSelect
value={importData.currentSheet}
disabled={sheetLoading}
onChange={(e) => void handleSheetChange(e.target.value)}
@@ -342,7 +343,7 @@ export const DataImporter: React.FC<DataImporterProps> = ({
{sheet}
</option>
))}
</select>
</FieldSelect>
{sheetLoading && (
<p className={`${isPs ? 'text-[10px] text-[#777]' : 'text-[10px] text-gray-400'}`}>

View File

@@ -3,6 +3,7 @@ import { Filter } from 'lucide-react';
import { ExportRangeConfig, LabelTemplate, PaperConfig, RecordRow } from '../types';
import { buildPrintablePages } from '../utils';
import { PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect';
interface DataRangeFieldsProps {
variant?: 'ps' | 'mobile';
@@ -36,7 +37,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
<div>
{isMobile && <label className="mobile-field-label"></label>}
{/* {!isMobile && <label className="ps-field-label">范围</label>} */}
<select
<FieldSelect
value={exportRange.mode}
disabled={disabled}
onChange={(e) => patchRange({ mode: e.target.value as ExportRangeConfig['mode'] })}
@@ -46,7 +47,7 @@ export const DataRangeFields: React.FC<DataRangeFieldsProps> = ({
<option value="odd"></option>
<option value="even"></option>
<option value="custom"></option>
</select>
</FieldSelect>
</div>
{exportRange.mode === 'custom' && (
<div>

View File

@@ -6,6 +6,7 @@ import { VariableMappingPanel } from './VariableMappingPanel';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { DataRangeFields } from './DataRangeFields';
import { FieldSelect } from './PsSelect';
interface ExportSidebarProps {
importData: ImportData | null;
@@ -220,7 +221,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
>
<div>
<label className="ps-field-label">线</label>
<select
<FieldSelect
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
@@ -232,7 +233,7 @@ export const ExportSidebar: React.FC<ExportSidebarProps> = ({
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">线 (mm)</label>

View File

@@ -8,6 +8,7 @@ import {
RecordRow,
} from '../types';
import { PaperSettingsPanel, PageInput } from './PreviewGrid';
import { FieldSelect } from './PsSelect';
interface MobileExportLayoutPanelProps {
template: LabelTemplate;
@@ -59,7 +60,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
>
<div>
<label className="ps-field-label">线</label>
<select
<FieldSelect
value={cutLine.style}
onChange={(e) =>
onChangeCutLine({
@@ -71,7 +72,7 @@ export const MobileExportLayoutPanel: React.FC<MobileExportLayoutPanelProps> = (
>
<option value="dashed">线</option>
<option value="solid">线</option>
</select>
</FieldSelect>
</div>
<div>
<label className="ps-field-label">线 (mm)</label>

View File

@@ -17,6 +17,7 @@ import {
import { CanvasLabelImage } from './CanvasLabelImage';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { FieldSelect } from './PsSelect';
import { FileDown, RefreshCcw, X } from 'lucide-react';
interface PageInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
@@ -316,7 +317,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
<h4 className={sectionTitleClass}></h4>
<div>
<label className={labelClass}></label>
<select
<FieldSelect
value={draftPaper.type}
onChange={(e) => handlePaperTypeChange(e.target.value as PaperType)}
className={selectClass}
@@ -325,7 +326,7 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
<option value="A5">A5 (148×210 mm)</option>
<option value="A6">A6 (105×148 mm)</option>
<option value="custom"></option>
</select>
</FieldSelect>
</div>
{draftPaper.type === 'custom' && (
@@ -499,14 +500,14 @@ export const PaperSettingsPanel: React.FC<PaperSettingsPanelProps> = ({
</div>
<div>
<label className={labelClass}></label>
<select
<FieldSelect
value={draftPaper.flow}
onChange={(e) => patchDraftPaper({ flow: e.target.value as PaperConfig['flow'] })}
className={selectClass}
>
<option value="left-right-top-bottom"> </option>
<option value="top-bottom-left-right"> </option>
</select>
</FieldSelect>
</div>
</div>

View File

@@ -20,6 +20,7 @@ import {
type AlignMode,
} from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
@@ -682,7 +683,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={isConditionalVisibility(selectedElem) ? 'conditional' : 'always'}
onChange={(e) => {
const conditional = e.target.value === 'conditional';
@@ -710,7 +711,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
>
<option value="always"></option>
<option value="conditional"></option>
</select>
</FieldSelect>
</div>
{isConditionalVisibility(selectedElem) && (() => {
const rules = resolveVisibilityRules(selectedElem);
@@ -962,7 +963,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="grid grid-cols-2 gap-2">
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={selectedElem.textFormat || 'text'}
onChange={(e) =>
handleElemChange({
@@ -980,7 +981,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="number"></option>
<option value="percent"></option>
<option value="currency"> (¥)</option>
</select>
</FieldSelect>
</div>
{(selectedElem.textFormat === 'number' ||
selectedElem.textFormat === 'percent' ||
@@ -1004,7 +1005,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<div className="col-span-2">
<label className="ps-field-label"></label>
<select
<FieldSelect
value={selectedElem.numberPartDisplay ?? 'full'}
onChange={(e) =>
handleElemChange({
@@ -1020,7 +1021,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="full"></option>
<option value="integer"> 19.9 19</option>
<option value="fraction"> 19.9 .9</option>
</select>
</FieldSelect>
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
19.9
</p>
@@ -1045,7 +1046,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={selectedElem.fontFamily}
onChange={(e) =>
handleElemChange({
@@ -1058,7 +1059,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="sans"> (Sans)</option>
<option value="serif"> (Serif)</option>
<option value="mono"> (Mono)</option>
</select>
</FieldSelect>
</div>
</div>
@@ -1303,7 +1304,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</h5>
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={selectedElem.barcodeFormat}
onChange={(e) =>
handleElemChange({
@@ -1317,7 +1318,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="CODE39">CODE 39 ()</option>
<option value="EAN13">EAN 13 ( 13)</option>
<option value="ITF">ITF ()</option>
</select>
</FieldSelect>
</div>
<label className="flex items-center gap-2 cursor-pointer mt-1">
@@ -1341,7 +1342,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</h5>
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={selectedElem.imageFit ?? 'contain'}
onChange={(e) =>
handleElemChange({
@@ -1354,7 +1355,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<option value="contain"> (contain)</option>
<option value="cover"> (cover)</option>
<option value="fill"> (fill)</option>
</select>
</FieldSelect>
</div>
</div>
)}

237
src/components/PsSelect.tsx Normal file
View File

@@ -0,0 +1,237 @@
import React, {
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
const PANEL_GAP = 4;
const VIEWPORT_PAD = 8;
type ParsedOption = {
value: string;
label: string;
disabled?: boolean;
};
function optionLabel(children: React.ReactNode): string {
if (children === null || children === undefined) return '';
if (typeof children === 'string' || typeof children === 'number') return String(children);
return React.Children.toArray(children).join('');
}
function parseSelectOptions(children: React.ReactNode): ParsedOption[] {
const out: ParsedOption[] = [];
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child) || child.type !== 'option') return;
const props = child.props as {
value?: string | number;
disabled?: boolean;
children?: React.ReactNode;
};
out.push({
value: String(props.value ?? ''),
label: optionLabel(props.children),
disabled: props.disabled,
});
});
return out;
}
function useSelectPanelPosition(
open: boolean,
anchorRef: React.RefObject<HTMLElement | null>,
panelRef: React.RefObject<HTMLElement | null>
) {
const [coords, setCoords] = useState<{ top: number; left: number; width: number } | null>(
null
);
useLayoutEffect(() => {
if (!open) {
setCoords(null);
return;
}
const update = () => {
const anchor = anchorRef.current;
const panel = panelRef.current;
if (!anchor || !panel) return;
const rect = anchor.getBoundingClientRect();
const panelHeight = panel.offsetHeight;
let top = rect.bottom + PANEL_GAP;
if (top + panelHeight > window.innerHeight - VIEWPORT_PAD) {
top = rect.top - panelHeight - PANEL_GAP;
}
top = Math.max(
VIEWPORT_PAD,
Math.min(top, window.innerHeight - panelHeight - VIEWPORT_PAD)
);
const width = rect.width;
let left = rect.left;
left = Math.max(VIEWPORT_PAD, Math.min(left, window.innerWidth - width - VIEWPORT_PAD));
setCoords({ top, left, width });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open, anchorRef, panelRef]);
return coords;
}
export interface PsSelectProps
extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, 'onChange' | 'value'> {
value: string;
onChange: React.ChangeEventHandler<HTMLSelectElement>;
}
export const PsSelect: React.FC<PsSelectProps> = ({
value,
onChange,
className = 'ps-field',
disabled,
id,
children,
...rest
}) => {
const options = useMemo(() => parseSelectOptions(children), [children]);
const selected = options.find((opt) => opt.value === value);
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const coords = useSelectPanelPosition(open, anchorRef, panelRef);
const pick = (next: string) => {
onChange({ target: { value: next } } as React.ChangeEvent<HTMLSelectElement>);
setOpen(false);
};
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
setOpen(false);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [open]);
useEffect(() => {
if (!open) return;
const onPointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
setOpen(false);
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
return (
<>
<button
{...rest}
ref={anchorRef}
type="button"
id={id}
disabled={disabled}
className={`ps-select-trigger ${className}`.trim()}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => {
if (disabled) return;
setOpen((v) => !v);
}}
>
<span className="ps-select-trigger-label truncate">
{selected?.label ?? (value ? value : '\u00A0')}
</span>
</button>
{open &&
typeof document !== 'undefined' &&
createPortal(
<div
ref={panelRef}
className="ps-select-panel"
role="listbox"
aria-labelledby={id}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
width: coords?.width,
visibility: coords ? 'visible' : 'hidden',
zIndex: 10000,
}}
>
{options.map((opt, index) => (
<button
key={`${opt.value}-${index}`}
type="button"
role="option"
aria-selected={opt.value === value}
disabled={opt.disabled}
className={`ps-select-option${opt.value === value ? ' selected' : ''}`}
onClick={() => {
if (opt.disabled) return;
pick(opt.value);
}}
>
{opt.label}
</button>
))}
</div>,
document.body
)}
</>
);
};
/** ps-field 时用 PsSelect否则保留原生 select */
export const FieldSelect: React.FC<React.SelectHTMLAttributes<HTMLSelectElement>> = (props) => {
const { className = '', children, onChange, value, disabled, id, ...rest } = props;
const usePsSelect = /\bps-field\b/.test(className);
if (usePsSelect && onChange) {
return (
<PsSelect
className={className}
value={value === undefined || value === null ? '' : String(value)}
onChange={onChange}
disabled={disabled}
id={id}
{...rest}
>
{children}
</PsSelect>
);
}
return (
<select
className={className}
value={value}
onChange={onChange}
disabled={disabled}
id={id}
{...rest}
>
{children}
</select>
);
};

View File

@@ -1,6 +1,7 @@
import React from 'react';
import { TemplateElement } from '../types';
import { CommitInput } from './CommitInput';
import { FieldSelect } from './PsSelect';
interface ValueExtractFieldsProps {
elem: TemplateElement;
@@ -21,7 +22,7 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
<div className="space-y-2">
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={extractMode}
onChange={(e) => {
const mode = e.target.value as 'none' | 'split' | 'prefix' | 'suffix';
@@ -51,7 +52,7 @@ export const ValueExtractFields: React.FC<ValueExtractFieldsProps> = ({ elem, on
<option value="split"></option>
<option value="prefix"> N </option>
<option value="suffix"> N </option>
</select>
</FieldSelect>
</div>
{extractMode === 'split' && (
<div className="grid grid-cols-2 gap-2">

View File

@@ -1,5 +1,10 @@
import React from 'react';
import { VARIABLE_MAPPING_USE_DEFAULT } from '../utils';
import {
VARIABLE_MAPPING_USE_DEFAULT,
VARIABLE_MAPPING_USE_ROW_INDEX,
VARIABLE_MAPPING_ROW_INDEX_LABEL,
} from '../utils';
import { FieldSelect } from './PsSelect';
interface VariableMappingPanelProps {
variables: string[];
@@ -110,19 +115,22 @@ export const VariableMappingPanel: React.FC<VariableMappingPanelProps> = ({
isMobile ? 'text-sm text-indigo-600' : isPs ? 'text-[#31a8ff]' : 'text-indigo-700'
}`}
>{`{${varName}}`}</span>
<select
<FieldSelect
value={mappedCol}
onChange={(e) => onChangeMapping({ ...mapping, [varName]: e.target.value })}
className={selectClass}
>
<option value=""></option>
<option value=""></option>
<option value={VARIABLE_MAPPING_USE_DEFAULT}>{useDefaultLabel}</option>
<option value={VARIABLE_MAPPING_USE_ROW_INDEX}>
{VARIABLE_MAPPING_ROW_INDEX_LABEL}
</option>
{columns.map((col) => (
<option key={col} value={col}>
{col}
</option>
))}
</select>
</FieldSelect>
{mappedCol === VARIABLE_MAPPING_USE_DEFAULT && !defaultVal && (
<p
className={

View File

@@ -7,6 +7,7 @@ import {
VISIBILITY_RULE_TARGET_LABELS,
} from '../types';
import { visibilityOperatorNeedsValue } from '../utils';
import { FieldSelect } from './PsSelect';
export interface VisibilityRuleDraft {
target: VisibilityRuleTarget;
@@ -32,7 +33,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
<div className="space-y-3">
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={target}
onChange={(e) => {
const nextTarget = e.target.value as VisibilityRuleTarget;
@@ -48,12 +49,12 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{VISIBILITY_RULE_TARGET_LABELS[t]}
</option>
))}
</select>
</FieldSelect>
</div>
{target === 'variable' ? (
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={rule.variable}
onChange={(e) => onChange({ variable: e.target.value })}
className="ps-field text-[11px] w-full mt-0.5"
@@ -64,7 +65,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{v}
</option>
))}
</select>
</FieldSelect>
{variableOptions.length === 0 && (
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
@@ -78,7 +79,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
)}
<div>
<label className="ps-field-label"></label>
<select
<FieldSelect
value={rule.operator}
onChange={(e) => {
const operator = e.target.value as VisibilityOperator;
@@ -94,7 +95,7 @@ export const VisibilityRuleForm: React.FC<VisibilityRuleFormProps> = ({
{VISIBILITY_OPERATOR_LABELS[op]}
</option>
))}
</select>
</FieldSelect>
</div>
{visibilityOperatorNeedsValue(rule.operator) && (
<div>

View File

@@ -340,6 +340,89 @@ body {
font-family: var(--font-mono);
}
.ps-field:is(select),
.ps-select-trigger.ps-field,
.ps-field.ps-select-trigger,
.ps-panel-body select:not(.mobile-field),
.ps-modal select {
-webkit-appearance: none;
appearance: none;
color-scheme: dark;
background-color: var(--color-ps-input);
padding-right: calc(0.625rem + 0.75rem + 0.625rem);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.625rem center;
background-size: 0.65rem;
cursor: pointer;
}
.ps-select-trigger {
display: flex;
align-items: center;
width: 100%;
text-align: left;
box-sizing: border-box;
}
.ps-select-trigger-label {
flex: 1;
min-width: 0;
}
.ps-select-panel {
box-sizing: border-box;
background: var(--color-ps-input);
border: 1px solid var(--color-ps-input-border);
border-radius: 0.25rem;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
max-height: min(240px, 50vh);
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.25rem 0;
}
.ps-select-option {
display: block;
width: 100%;
padding: 0.375rem 0.625rem;
text-align: left;
font-size: 0.75rem;
line-height: 1.4;
color: var(--color-ps-text);
background: transparent;
border: none;
cursor: pointer;
}
.ps-select-option:hover:not(:disabled) {
background: #2d2d2d;
}
.ps-select-option.selected {
color: var(--color-ps-accent);
background: #2d2d2d;
}
.ps-select-option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.ps-field:is(select) option,
.ps-panel-body select:not(.mobile-field) option,
.ps-modal select option {
background-color: var(--color-ps-input);
color: var(--color-ps-text);
}
.ps-field:is(select) option:checked,
.ps-panel-body select:not(.mobile-field) option:checked,
.ps-modal select option:checked {
background-color: #2d2d2d;
color: var(--color-ps-accent);
}
.ps-field:focus {
border-color: var(--color-ps-accent);
outline: none;
@@ -1093,6 +1176,15 @@ body {
appearance: none;
}
select.mobile-field {
padding-right: calc(12px + 0.75rem + 12px);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 0.75rem;
cursor: pointer;
}
.mobile-field:focus {
outline: none;
border-color: #818cf8;

View File

@@ -22,6 +22,11 @@ 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,
@@ -111,19 +116,25 @@ export function isVariableMappingConfigured(
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);
}
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」时写入 variableDefaults */
/** 按映射将数据行中的列值填入模板变量键;「使用默认值」/「数据序号」时写入对应值 */
export function applyRowVariableMapping(
row: RecordRow,
mapping: Record<string, string>,
variables: string[],
variableDefaults?: Record<string, string> | null
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() !== '') {