新增字典映射功能,支持有限枚举值转换;更新相关组件和文档以提升用户体验

This commit is contained in:
lirui
2026-02-09 22:38:17 +08:00
parent 04e5f7f705
commit cd6ca590b4
7 changed files with 721 additions and 43 deletions

View File

@@ -1,7 +1,16 @@
<script lang="ts">
import type { MappingConfig, DataType, DateFormat } from '$lib/types.js';
import type { MappingConfig, DataType, DateFormat, RowData } from '$lib/types.js';
import DictionaryMapper from './DictionaryMapper.svelte';
let { config = $bindable(), onclose }: { config: MappingConfig; onclose: () => void } = $props();
let {
config = $bindable(),
rows,
onclose
}: {
config: MappingConfig;
rows?: RowData[];
onclose: () => void;
} = $props();
const dataTypes: { value: DataType; label: string }[] = [
{ value: 'string', label: '字符串 (String)' },
@@ -19,6 +28,19 @@
{ value: 'timestamp', label: 'Unix Timestamp' }
];
// Initialize dictionary mapping config if not present
$effect(() => {
if (config.useDictionary === undefined) {
config.useDictionary = false;
}
if (!config.valueMapping) {
config.valueMapping = [];
}
if (!config.mappingFallback) {
config.mappingFallback = 'keep';
}
});
function onTypeChange(e: Event) {
const select = e.target as HTMLSelectElement;
config.type = select.value as DataType;
@@ -51,10 +73,17 @@
});
</script>
<div bind:this={panelEl} class="absolute top-full left-0 z-50 mt-1 w-72 rounded-lg border border-gray-200 bg-white p-4 shadow-xl">
<div
bind:this={panelEl}
class="absolute top-full left-0 z-50 mt-1 w-80 max-h-[80vh] overflow-y-auto rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
>
<div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">列配置</h4>
<button onclick={onclose} aria-label="关闭配置" class="text-gray-400 hover:text-gray-600 cursor-pointer">
<button
onclick={onclose}
aria-label="关闭配置"
class="text-gray-400 hover:text-gray-600 cursor-pointer"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
@@ -64,23 +93,42 @@
<div class="space-y-3">
<!-- Source header (read-only) -->
<div>
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">源字段</label>
<input id="source-{config.source}" type="text" value={config.source} disabled
class="w-full rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-500" />
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
>源字段</label
>
<input
id="source-{config.source}"
type="text"
value={config.source}
disabled
class="w-full rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-500"
/>
</div>
<!-- Target key -->
<div>
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">目标字段 (JSON Key)</label>
<input id="target-{config.source}" type="text" bind:value={config.target}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500" />
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
>目标字段 (JSON Key)</label
>
<input
id="target-{config.source}"
type="text"
bind:value={config.target}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<!-- Data type -->
<div>
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">数据类型</label>
<select id="type-{config.source}" value={config.type} onchange={onTypeChange}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
>数据类型</label
>
<select
id="type-{config.source}"
value={config.type}
onchange={onTypeChange}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
{#each dataTypes as dt (dt.value)}
<option value={dt.value}>{dt.label}</option>
{/each}
@@ -90,9 +138,14 @@
<!-- Date format (only for date type) -->
{#if config.type === 'date'}
<div>
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">日期格式</label>
<select id="format-{config.source}" bind:value={config.format}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500">
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
>日期格式</label
>
<select
id="format-{config.source}"
bind:value={config.format}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
{#each dateFormats as df (df.value)}
<option value={df.value}>{df.label}</option>
{/each}
@@ -100,27 +153,77 @@
</div>
{/if}
<!-- Dictionary Mapping -->
{#if rows}
<DictionaryMapper
config={{
get useDictionary() {
return config.useDictionary ?? false;
},
set useDictionary(v) {
config.useDictionary = v;
},
get valueMapping() {
return config.valueMapping ?? [];
},
set valueMapping(v) {
config.valueMapping = v;
},
get mappingFallback() {
return config.mappingFallback ?? 'keep';
},
set mappingFallback(v) {
config.mappingFallback = v;
},
get mappingCustomValue() {
return config.mappingCustomValue;
},
set mappingCustomValue(v) {
config.mappingCustomValue = v;
}
}}
{rows}
sourceColumn={config.source}
/>
{/if}
<!-- Exclude if empty -->
<div class="flex items-center gap-2">
<input type="checkbox" id="exclude-empty-{config.source}" bind:checked={config.excludeIfEmpty}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<label for="exclude-empty-{config.source}" class="text-sm text-gray-600">空值时移除该字段</label>
<input
type="checkbox"
id="exclude-empty-{config.source}"
bind:checked={config.excludeIfEmpty}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="exclude-empty-{config.source}" class="text-sm text-gray-600"
>空值时移除该字段</label
>
</div>
<!-- Default value -->
{#if !config.excludeIfEmpty}
<div>
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">默认值 (空值时)</label>
<input id="default-{config.source}" type="text" bind:value={config.defaultValue}
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
>默认值 (空值时)</label
>
<input
id="default-{config.source}"
type="text"
bind:value={config.defaultValue}
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="留空则为 null" />
placeholder="留空则为 null"
/>
</div>
{/if}
<!-- Enable/disable column -->
<div class="flex items-center gap-2 border-t border-gray-100 pt-3">
<input type="checkbox" id="enabled-{config.source}" bind:checked={config.enabled}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<input
type="checkbox"
id="enabled-{config.source}"
bind:checked={config.enabled}
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label for="enabled-{config.source}" class="text-sm text-gray-600">包含此列到输出</label>
</div>
</div>

View File

@@ -0,0 +1,222 @@
<script lang="ts">
import type { ValueMapItem, MappingFallback, RowData } from '$lib/types.js';
import { scanUniqueValues } from '$lib/converter.js';
let {
config = $bindable(),
rows,
sourceColumn
}: {
config: {
useDictionary: boolean;
valueMapping: ValueMapItem[];
mappingFallback: MappingFallback;
mappingCustomValue?: string;
};
rows: RowData[];
sourceColumn: string;
} = $props();
const fallbackOptions: { value: MappingFallback; label: string }[] = [
{ value: 'keep', label: '保留原值' },
{ value: 'null', label: '设为 null' },
{ value: 'custom', label: '自定义值' }
];
let newSourceValue = $state('');
let newTargetValue = $state('');
function enableDictionary() {
config.useDictionary = true;
if (config.valueMapping.length === 0) {
scanColumnValues();
}
}
function disableDictionary() {
config.useDictionary = false;
}
function scanColumnValues() {
const uniqueValues = scanUniqueValues(sourceColumn, rows);
config.valueMapping = uniqueValues.map((v) => ({
source: v,
target: v
}));
}
function addMappingItem() {
if (!newSourceValue) return;
config.valueMapping = [
...config.valueMapping,
{ source: newSourceValue, target: newTargetValue || newSourceValue }
];
newSourceValue = '';
newTargetValue = '';
}
function removeMappingItem(index: number) {
config.valueMapping = config.valueMapping.filter((_, i) => i !== index);
}
function updateTargetValue(index: number, value: string) {
// Create a new array to trigger Svelte 5 reactivity
config.valueMapping = config.valueMapping.map((item, i) =>
i === index
? { ...item, target: parseTargetValue(value) }
: item
);
}
function parseTargetValue(value: string): unknown {
if (value === 'null' || value === '') return null;
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'undefined') return undefined;
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
try {
return JSON.parse(value);
} catch {
return value;
}
}
return value;
}
function formatTargetValue(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
</script>
<div class="space-y-2">
<!-- Toggle -->
<div class="flex items-center justify-between border-b border-gray-100 pb-1.5">
<span class="text-xs font-semibold text-gray-700">字典映射</span>
{#if !config.useDictionary}
<button
onclick={enableDictionary}
class="rounded bg-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700"
>
启用
</button>
{:else}
<button
onclick={disableDictionary}
class="rounded border border-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
>
禁用
</button>
{/if}
</div>
{#if config.useDictionary}
<!-- Auto Scan Button -->
<button
onclick={scanColumnValues}
class="w-full rounded border border-blue-600 bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
>
🔄 自动扫描列值
</button>
<!-- Mapping Table -->
{#if config.valueMapping.length > 0}
<div class="max-h-32 overflow-y-auto rounded border border-gray-200">
<table class="w-full text-xs">
<thead class="bg-gray-50 sticky top-0">
<tr>
<th class="px-1.5 py-1 text-left font-medium text-gray-600 text-[10px]"></th>
<th class="px-1.5 py-1 text-left font-medium text-gray-600 text-[10px]">目标</th>
<th class="w-6"></th>
</tr>
</thead>
<tbody>
{#each config.valueMapping as item, index (index)}
<tr class="border-t border-gray-100">
<td class="px-1.5 py-1 text-gray-700 truncate max-w-24" title={String(item.source)}>{String(item.source)}</td>
<td class="px-1.5 py-1">
<input
type="text"
value={formatTargetValue(item.target)}
oninput={(e) => updateTargetValue(index, e.currentTarget.value)}
class="w-full rounded border border-gray-300 px-1 py-0.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</td>
<td class="px-0.5 py-1">
<button
onclick={() => removeMappingItem(index)}
class="text-red-500 hover:text-red-700 p-0.5"
title="删除"
>
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-[10px] text-gray-400 text-center">暂无映射项</p>
{/if}
<!-- Manual Add (compact) -->
<div class="flex gap-1">
<input
bind:value={newSourceValue}
type="text"
placeholder="源值"
class="w-20 rounded border border-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<input
bind:value={newTargetValue}
type="text"
placeholder="目标"
class="flex-1 rounded border border-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<button
onclick={addMappingItem}
disabled={!newSourceValue}
class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
>
+
</button>
</div>
<!-- Fallback Strategy (compact) -->
<div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500 whitespace-nowrap">未匹配:</span>
<select
bind:value={config.mappingFallback}
class="flex-1 rounded border border-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
{#each fallbackOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
</div>
<!-- Custom Fallback Value (compact) -->
{#if config.mappingFallback === 'custom'}
<input
bind:value={config.mappingCustomValue}
type="text"
placeholder="自定义默认值 (如: null, true, 0)"
class="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
{/if}
{/if}
</div>

View File

@@ -131,7 +131,7 @@
{/if}
</div>
{#if activeConfigIndex === i && mappings[i]}
<ColumnConfig bind:config={mappings[i]} onclose={() => (activeConfigIndex = null)} />
<ColumnConfig bind:config={mappings[i]} {rows} onclose={() => (activeConfigIndex = null)} />
{/if}
</th>
{/each}

View File

@@ -7,7 +7,9 @@ import type {
ApiEnrichmentRule,
SubmissionConfig,
StaticRule,
JobBundle
JobBundle,
ValueMapItem,
MappingFallback
} from './types.js';
/**
@@ -17,31 +19,107 @@ function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === '';
}
/**
* Parse a target value string into the appropriate type.
* Intelligently detects boolean, number, null, and JSON.
*/
function parseTargetValue(value: string): unknown {
if (value === 'null' || value === '') return null;
if (value === 'true') return true;
if (value === 'false') return false;
if (value === 'undefined') return undefined;
// Try parsing as number
const num = Number(value);
if (!isNaN(num) && value !== '') return num;
// Try parsing as JSON object/array
if ((value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'))) {
try {
return JSON.parse(value);
} catch {
// Not valid JSON, return as string
}
}
return value;
}
/**
* Apply dictionary mapping to a value.
* Returns the mapped value or applies the fallback strategy.
*/
function applyDictionaryMapping(
value: unknown,
valueMapping: ValueMapItem[],
fallback: MappingFallback,
customValue?: string
): unknown {
if (value === undefined || value === null) {
return fallback === 'null' ? null : value;
}
const normalizedValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
// Try to find exact match in mapping
const mappedItem = valueMapping.find(
(item) => String(item.source) === normalizedValue
);
if (mappedItem) {
return mappedItem.target;
}
// Apply fallback strategy
switch (fallback) {
case 'keep':
return value;
case 'null':
return null;
case 'custom':
return customValue !== undefined ? parseTargetValue(customValue) : null;
default:
return value;
}
}
/**
* Convert a raw cell value to the specified data type.
*/
function convertValue(value: unknown, type: DataType, format?: string): unknown {
function convertValue(
value: unknown,
type: DataType,
format?: string,
mapping?: { enabled: boolean; items: ValueMapItem[]; fallback: MappingFallback; customValue?: string }
): unknown {
if (isEmpty(value)) return undefined;
// Apply dictionary mapping first (before type conversion)
let processedValue = value;
if (mapping?.enabled && mapping.items.length > 0) {
processedValue = applyDictionaryMapping(value, mapping.items, mapping.fallback, mapping.customValue);
if (processedValue === null && mapping.fallback === 'null') return null;
}
switch (type) {
case 'number': {
const num = Number(value);
return isNaN(num) ? value : num;
const num = Number(processedValue);
return isNaN(num) ? processedValue : num;
}
case 'boolean': {
if (typeof value === 'boolean') return value;
const str = String(value).toLowerCase().trim();
if (typeof processedValue === 'boolean') return processedValue;
const str = String(processedValue).toLowerCase().trim();
if (['true', '1', 'yes', '是'].includes(str)) return true;
if (['false', '0', 'no', '否'].includes(str)) return false;
return Boolean(value);
return Boolean(processedValue);
}
case 'date': {
return formatDate(value, format);
return formatDate(processedValue, format);
}
case 'string':
default:
if (value instanceof Date) return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
return String(value);
if (processedValue instanceof Date) return dayjs(processedValue).format('YYYY-MM-DD HH:mm:ss');
return String(processedValue);
}
}
@@ -119,9 +197,29 @@ export function convertData(
const finalValue = isEmptyVal
? (mapping.defaultValue !== undefined && mapping.defaultValue !== ''
? convertValue(mapping.defaultValue, mapping.type, mapping.format)
? convertValue(
mapping.defaultValue,
mapping.type,
mapping.format,
mapping.useDictionary ? {
enabled: true,
items: mapping.valueMapping ?? [],
fallback: mapping.mappingFallback ?? 'keep',
customValue: mapping.mappingCustomValue
} : undefined
)
: null)
: convertValue(rawValue, mapping.type, mapping.format);
: convertValue(
rawValue,
mapping.type,
mapping.format,
mapping.useDictionary ? {
enabled: true,
items: mapping.valueMapping ?? [],
fallback: mapping.mappingFallback ?? 'keep',
customValue: mapping.mappingCustomValue
} : undefined
);
setNested(obj, mapping.target, finalValue);
}
@@ -152,6 +250,27 @@ export function createDefaultMappings(headers: string[], rows?: RowData[]): Mapp
/**
* Detect the data type of a column by sampling its values.
*/
/**
* Scan unique values from a column for auto-filling dictionary mapping.
* Limits to first 1000 rows for performance.
*/
export function scanUniqueValues(header: string, rows: RowData[]): (string | number)[] {
const MAX_ROWS = 1000;
const sample = rows.slice(0, MAX_ROWS);
const uniqueValues = new Set<string | number>();
for (const row of sample) {
const val = row[header];
if (val !== undefined && val !== null && val !== '') {
const normalized = typeof val === 'object' ? JSON.stringify(val) : (val as string | number);
uniqueValues.add(normalized);
}
}
return Array.from(uniqueValues).sort();
}
function detectColumnType(header: string, rows: RowData[]): DataType {
const sample = rows.slice(0, 20);
let dateCount = 0;
@@ -186,7 +305,12 @@ export function applyTemplate(
type: tmpl.type,
format: tmpl.format,
excludeIfEmpty: tmpl.excludeIfEmpty,
defaultValue: tmpl.defaultValue ?? ''
defaultValue: tmpl.defaultValue ?? '',
// v2.1: Apply dictionary mapping settings
useDictionary: tmpl.useDictionary,
valueMapping: tmpl.valueMapping,
mappingFallback: tmpl.mappingFallback,
mappingCustomValue: tmpl.mappingCustomValue
};
}
return mapping;
@@ -197,10 +321,15 @@ export function applyTemplate(
* Export current mappings as a template.
*/
export function exportTemplate(mappings: MappingConfig[]): MappingTemplate {
return mappings.map(({ source, target, type, format, excludeIfEmpty, defaultValue }) => {
return mappings.map(({ source, target, type, format, excludeIfEmpty, defaultValue, useDictionary, valueMapping, mappingFallback, mappingCustomValue }) => {
const entry: MappingTemplate[number] = { source, target, type, excludeIfEmpty };
if (type === 'date' && format) entry.format = format;
if (defaultValue) entry.defaultValue = defaultValue;
// v2.1: Export dictionary mapping settings
if (useDictionary) entry.useDictionary = true;
if (valueMapping && valueMapping.length > 0) entry.valueMapping = valueMapping;
if (mappingFallback) entry.mappingFallback = mappingFallback;
if (mappingCustomValue) entry.mappingCustomValue = mappingCustomValue;
return entry;
});
}
@@ -219,6 +348,15 @@ function toStaticRules(mappings: MappingConfig[]): StaticRule[] {
dataType: m.type
};
if (m.type === 'date' && m.format) rule.format = m.format;
// v2.1: Include dictionary mapping properties
if (m.useDictionary && m.valueMapping && m.valueMapping.length > 0) {
rule.useDictionary = true;
rule.valueMapping = m.valueMapping;
rule.mappingFallback = m.mappingFallback ?? 'keep';
if (m.mappingFallback === 'custom') {
rule.mappingCustomValue = m.mappingCustomValue;
}
}
return rule;
});
}

View File

@@ -1,5 +1,14 @@
export type DataType = 'string' | 'number' | 'boolean' | 'date';
/** Mapping fallback strategy when value is not found in dictionary */
export type MappingFallback = 'keep' | 'null' | 'custom';
/** Dictionary mapping item - maps source value to target value */
export interface ValueMapItem {
source: string | number;
target: unknown;
}
export type DateFormat =
| 'YYYY-MM-DD'
| 'YYYY/MM/DD'
@@ -23,6 +32,14 @@ export interface MappingConfig {
defaultValue?: string;
/** Whether this column is included in output */
enabled: boolean;
/** v2.1: Enable dictionary/value mapping for this column */
useDictionary?: boolean;
/** v2.1: Value mapping dictionary */
valueMapping?: ValueMapItem[];
/** v2.1: Fallback strategy when value not found in mapping */
mappingFallback?: MappingFallback;
/** v2.1: Custom fallback value (only used when mappingFallback is 'custom') */
mappingCustomValue?: string;
}
/** A single row of raw Excel data, keyed by original header */
@@ -31,7 +48,7 @@ export type RowData = Record<string, unknown>;
/** Template file structure for import/export */
export type MappingTemplate = Pick<
MappingConfig,
'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue'
'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue' | 'useDictionary' | 'valueMapping' | 'mappingFallback' | 'mappingCustomValue'
>[];
/** Static mapping rule in Job Bundle output */
@@ -41,6 +58,14 @@ export interface StaticRule {
target: string;
dataType: DataType;
format?: string;
/** v2.1: Enable dictionary/value mapping */
useDictionary?: boolean;
/** v2.1: Value mapping dictionary */
valueMapping?: ValueMapItem[];
/** v2.1: Fallback strategy when value not found in mapping */
mappingFallback?: MappingFallback;
/** v2.1: Custom fallback value */
mappingCustomValue?: string;
}
/** Dynamic API enrichment rule */