新增 API 配置和提交设置组件;更新 Excel 表格以支持 API 字段管理和任务包导出功能

This commit is contained in:
lirui
2026-02-09 21:54:19 +08:00
parent b6eaa2a1b1
commit a2d8a774ca
6 changed files with 695 additions and 21 deletions

View File

@@ -0,0 +1,298 @@
<script lang="ts">
import type { ApiEnrichmentRule } from '$lib/types.js';
let {
rule,
headers,
onsave,
onclose
}: {
rule?: ApiEnrichmentRule;
headers: string[];
onsave: (rule: ApiEnrichmentRule) => void;
onclose: () => void;
} = $props();
// svelte-ignore state_referenced_locally — intentionally using initial values only
let targetKey = $state(rule?.target_key ?? '');
// svelte-ignore state_referenced_locally
let urlTemplate = $state(rule?.url_template ?? '');
// svelte-ignore state_referenced_locally
let method = $state<'GET' | 'POST'>(rule?.method ?? 'GET');
// svelte-ignore state_referenced_locally
let headerEntries = $state<{ key: string; value: string }[]>(
rule?.headers ? Object.entries(rule.headers).map(([key, value]) => ({ key, value })) : []
);
// svelte-ignore state_referenced_locally
let bodyTemplate = $state(rule?.body_template ?? '');
// svelte-ignore state_referenced_locally
let responsePath = $state(rule?.response_path ?? '');
// svelte-ignore state_referenced_locally
let fallbackValue = $state(rule?.fallback_value != null ? String(rule.fallback_value) : '');
let showUrlVars = $state(false);
let showBodyVars = $state(false);
let urlInput: HTMLInputElement | undefined = $state();
let bodyTextarea: HTMLTextAreaElement | undefined = $state();
function insertVariable(field: 'url' | 'body', header: string) {
const variable = `{{${header}}}`;
if (field === 'url') {
if (urlInput) {
const start = urlInput.selectionStart ?? urlTemplate.length;
urlTemplate = urlTemplate.slice(0, start) + variable + urlTemplate.slice(urlInput.selectionEnd ?? start);
} else {
urlTemplate += variable;
}
showUrlVars = false;
} else {
if (bodyTextarea) {
const start = bodyTextarea.selectionStart ?? bodyTemplate.length;
bodyTemplate = bodyTemplate.slice(0, start) + variable + bodyTemplate.slice(bodyTextarea.selectionEnd ?? start);
} else {
bodyTemplate += variable;
}
showBodyVars = false;
}
}
function addHeader() {
headerEntries = [...headerEntries, { key: '', value: '' }];
}
function removeHeader(index: number) {
headerEntries = headerEntries.filter((_, i) => i !== index);
}
function save() {
if (!targetKey.trim() || !urlTemplate.trim() || !responsePath.trim()) return;
const headersObj: Record<string, string> = {};
for (const entry of headerEntries) {
if (entry.key.trim()) {
headersObj[entry.key.trim()] = entry.value;
}
}
const newRule: ApiEnrichmentRule = {
type: 'api_fetch',
target_key: targetKey.trim(),
url_template: urlTemplate.trim(),
method,
response_path: responsePath.trim()
};
if (Object.keys(headersObj).length > 0) newRule.headers = headersObj;
if (method === 'POST' && bodyTemplate.trim()) newRule.body_template = bodyTemplate.trim();
if (fallbackValue !== '') newRule.fallback_value = fallbackValue;
onsave(newRule);
}
const isValid = $derived(targetKey.trim() !== '' && urlTemplate.trim() !== '' && responsePath.trim() !== '');
</script>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={onclose} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg bg-white p-6 shadow-xl" onclick={(e) => e.stopPropagation()}>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-800">
{rule ? '编辑' : '添加'} API 字段
</h3>
<button onclick={onclose} aria-label="关闭" class="cursor-pointer text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" 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>
</div>
<div class="space-y-4">
<!-- Target Key -->
<div>
<label for="api-target-key" class="mb-1 block text-sm font-medium text-gray-700">Target Key</label>
<input
id="api-target-key"
type="text"
bind:value={targetKey}
placeholder="user_balance"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">最终 JSON 中的字段名</p>
</div>
<!-- Request URL -->
<div>
<label for="api-url" class="mb-1 block text-sm font-medium text-gray-700">Request URL</label>
<div class="flex gap-1">
<input
id="api-url"
type="text"
bind:this={urlInput}
bind:value={urlTemplate}
placeholder={"https://api.example.com/users/{{用户ID}}/detail"}
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<div class="relative">
<button
onclick={() => { showUrlVars = !showUrlVars; showBodyVars = false; }}
class="cursor-pointer whitespace-nowrap rounded-md border border-gray-300 px-2 py-2 text-xs text-gray-600 hover:bg-gray-50"
title="插入变量"
>
&#123;&#123;x&#125;&#125;
</button>
{#if showUrlVars}
<div class="absolute right-0 z-10 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg">
{#each headers as h (h)}
<button
onclick={() => insertVariable('url', h)}
class="w-full cursor-pointer px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-blue-50"
>
{h}
</button>
{/each}
</div>
{/if}
</div>
</div>
<p class="mt-1 text-xs text-gray-400">支持 &#123;&#123;列名&#125;&#125; 模板变量</p>
</div>
<!-- Request Method -->
<div>
<label for="api-method" class="mb-1 block text-sm font-medium text-gray-700">Request Method</label>
<select
id="api-method"
bind:value={method}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
</select>
</div>
<!-- Headers -->
<div>
<div class="mb-1 flex items-center justify-between">
<span class="text-sm font-medium text-gray-700">Headers</span>
<button onclick={addHeader} class="cursor-pointer text-xs text-blue-600 hover:text-blue-700">
+ 添加
</button>
</div>
{#if headerEntries.length > 0}
<div class="space-y-2">
{#each headerEntries as entry, i (i)}
<div class="flex items-center gap-1">
<input
type="text"
bind:value={entry.key}
placeholder="Key"
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<input
type="text"
bind:value={entry.value}
placeholder="Value"
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<button
onclick={() => removeHeader(i)}
class="cursor-pointer flex-shrink-0 text-gray-400 hover:text-red-500"
aria-label="删除此 Header"
>
<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>
</button>
</div>
{/each}
</div>
{:else}
<p class="text-xs text-gray-400">暂无 Header</p>
{/if}
</div>
<!-- Body (POST only) -->
{#if method === 'POST'}
<div>
<div class="mb-1 flex items-center justify-between">
<label for="api-body" class="text-sm font-medium text-gray-700">Request Body</label>
<div class="relative">
<button
onclick={() => { showBodyVars = !showBodyVars; showUrlVars = false; }}
class="cursor-pointer text-xs text-blue-600 hover:text-blue-700"
>
插入变量
</button>
{#if showBodyVars}
<div class="absolute right-0 z-10 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg">
{#each headers as h (h)}
<button
onclick={() => insertVariable('body', h)}
class="w-full cursor-pointer px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-blue-50"
>
{h}
</button>
{/each}
</div>
{/if}
</div>
</div>
<textarea
id="api-body"
bind:this={bodyTextarea}
bind:value={bodyTemplate}
rows="4"
placeholder={'{"user_id": "{{用户ID}}"}'}
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
></textarea>
</div>
{/if}
<!-- Response Path -->
<div>
<label for="api-response-path" class="mb-1 block text-sm font-medium text-gray-700">Response Extractor</label>
<input
id="api-response-path"
type="text"
bind:value={responsePath}
placeholder="data.balance"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">从接口返回 JSON 中提取值的路径(如 data.result.value</p>
</div>
<!-- Fallback Value -->
<div>
<label for="api-fallback" class="mb-1 block text-sm font-medium text-gray-700">Fallback Value</label>
<input
id="api-fallback"
type="text"
bind:value={fallbackValue}
placeholder="null"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
<p class="mt-1 text-xs text-gray-400">API 请求失败时的默认值</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={onclose}
class="cursor-pointer rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
取消
</button>
<button
onclick={save}
disabled={!isValid}
class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
保存
</button>
</div>
</div>
</div>

View File

@@ -1,15 +1,23 @@
<script lang="ts"> <script lang="ts">
import type { MappingConfig, RowData } from '$lib/types.js'; import type { MappingConfig, RowData, ApiEnrichmentRule } from '$lib/types.js';
import ColumnConfig from './ColumnConfig.svelte'; import ColumnConfig from './ColumnConfig.svelte';
let { let {
headers, headers,
rows, rows,
mappings = $bindable() mappings = $bindable(),
enrichmentRules = [],
onaddapi,
oneditapi,
ondeleteapi
}: { }: {
headers: string[]; headers: string[];
rows: RowData[]; rows: RowData[];
mappings: MappingConfig[]; mappings: MappingConfig[];
enrichmentRules?: ApiEnrichmentRule[];
onaddapi?: () => void;
oneditapi?: (index: number) => void;
ondeleteapi?: (index: number) => void;
} = $props(); } = $props();
let activeConfigIndex = $state<number | null>(null); let activeConfigIndex = $state<number | null>(null);
@@ -37,17 +45,68 @@
<div class="flex h-full flex-col overflow-hidden"> <div class="flex h-full flex-col overflow-hidden">
<div class="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-4 py-2"> <div class="flex-shrink-0 border-b border-gray-200 bg-gray-50 px-4 py-2">
<h3 class="text-sm font-semibold text-gray-700"> <div class="flex items-center justify-between">
Excel 数据 <h3 class="text-sm font-semibold text-gray-700">
<span class="ml-2 text-xs font-normal text-gray-400"> Excel 数据
{rows.length}× {headers.length} <span class="ml-2 text-xs font-normal text-gray-400">
{#if rows.length > maxPreviewRows} {rows.length}× {headers.length}
(显示前 {maxPreviewRows} 行) {#if enrichmentRules.length > 0}
{/if} + {enrichmentRules.length} API 字段
</span> {/if}
</h3> {#if rows.length > maxPreviewRows}
(显示前 {maxPreviewRows} 行)
{/if}
</span>
</h3>
{#if onaddapi}
<button
onclick={onaddapi}
class="inline-flex cursor-pointer items-center gap-1 rounded-md bg-purple-50 px-2 py-1 text-xs font-medium text-purple-700 hover:bg-purple-100"
>
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
添加 API 字段
</button>
{/if}
</div>
</div> </div>
<!-- Enrichment rules bar -->
{#if enrichmentRules.length > 0}
<div class="flex flex-shrink-0 flex-wrap items-center gap-2 border-b border-gray-200 bg-purple-50/50 px-4 py-2">
<span class="text-xs font-medium text-purple-600">API 字段:</span>
{#each enrichmentRules as rule, i (i)}
<span class="inline-flex items-center gap-1 rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-700">
{rule.target_key}
<span class="text-purple-400">({rule.method})</span>
{#if oneditapi}
<button
onclick={() => oneditapi?.(i)}
class="cursor-pointer text-purple-400 hover:text-purple-600"
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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
{/if}
{#if ondeleteapi}
<button
onclick={() => ondeleteapi?.(i)}
class="cursor-pointer text-purple-400 hover:text-red-500"
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>
{/if}
</span>
{/each}
</div>
{/if}
<div class="flex-1 overflow-auto"> <div class="flex-1 overflow-auto">
<table class="w-full text-sm"> <table class="w-full text-sm">
<thead class="sticky top-0 z-10 bg-gray-50"> <thead class="sticky top-0 z-10 bg-gray-50">
@@ -76,6 +135,17 @@
{/if} {/if}
</th> </th>
{/each} {/each}
<!-- API enrichment columns (virtual) -->
{#each enrichmentRules as rule (rule.target_key)}
<th class="border-b border-r border-purple-200 bg-purple-50 px-3 py-2 text-left font-medium text-purple-600">
<div class="flex items-center gap-1">
<svg class="h-3 w-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span class="truncate" title="{rule.target_key} (API)">{rule.target_key}</span>
</div>
</th>
{/each}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -86,6 +156,12 @@
<span class="block max-w-[200px] truncate">{displayValue(row[header])}</span> <span class="block max-w-[200px] truncate">{displayValue(row[header])}</span>
</td> </td>
{/each} {/each}
<!-- API placeholder cells -->
{#each enrichmentRules as _ (_.target_key)}
<td class="border-b border-r border-purple-100 bg-purple-50/30 px-3 py-1.5 text-xs italic text-purple-400">
[Pending API Fetch]
</td>
{/each}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import type { SubmissionConfig } from '$lib/types.js';
let {
config = $bindable(),
onclose
}: {
config: SubmissionConfig;
onclose: () => void;
} = $props();
let localConfig = $state<SubmissionConfig>({ ...config });
function save() {
config.target_url = localConfig.target_url;
config.method = localConfig.method;
config.batch_size = localConfig.batch_size;
onclose();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={onclose} role="presentation">
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl" onclick={(e) => e.stopPropagation()}>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-800">提交设置</h3>
<button onclick={onclose} aria-label="关闭" class="cursor-pointer text-gray-400 hover:text-gray-600">
<svg class="h-5 w-5" 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>
</div>
<div class="space-y-4">
<div>
<label for="target-url" class="mb-1 block text-sm font-medium text-gray-700">Target URL</label>
<input
id="target-url"
type="text"
bind:value={localConfig.target_url}
placeholder="https://api.db.com/bulk-insert"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="method" class="mb-1 block text-sm font-medium text-gray-700">Method</label>
<select
id="method"
bind:value={localConfig.method}
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
</select>
</div>
<div>
<label for="batch-size" class="mb-1 block text-sm font-medium text-gray-700">Batch Size</label>
<input
id="batch-size"
type="number"
bind:value={localConfig.batch_size}
min="1"
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={onclose}
class="cursor-pointer rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
取消
</button>
<button
onclick={save}
class="cursor-pointer rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
保存
</button>
</div>
</div>
</div>

View File

@@ -1,5 +1,14 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { MappingConfig, RowData, DataType, MappingTemplate } from './types.js'; import type {
MappingConfig,
RowData,
DataType,
MappingTemplate,
ApiEnrichmentRule,
SubmissionConfig,
StaticRule,
JobBundle
} from './types.js';
/** /**
* Check if a value is considered empty. * Check if a value is considered empty.
@@ -195,3 +204,47 @@ export function exportTemplate(mappings: MappingConfig[]): MappingTemplate {
return entry; return entry;
}); });
} }
/**
* Convert enabled MappingConfigs to StaticRule format for Job Bundle.
*/
function toStaticRules(mappings: MappingConfig[]): StaticRule[] {
return mappings
.filter((m) => m.enabled)
.map((m) => {
const rule: StaticRule = {
type: 'static',
source: m.source,
target: m.target,
dataType: m.type
};
if (m.type === 'date' && m.format) rule.format = m.format;
return rule;
});
}
/**
* Generate a complete Job Bundle for export.
*/
export function generateJobBundle(
rows: RowData[],
mappings: MappingConfig[],
enrichmentRules: ApiEnrichmentRule[],
submissionConfig: SubmissionConfig
): JobBundle {
const sourceData = convertData(rows, mappings);
const staticRules = toStaticRules(mappings);
return {
meta: {
version: '1.0.0',
generated_at: new Date().toISOString()
},
config: {
static_rules: staticRules,
enrichment_rules: enrichmentRules,
submission: submissionConfig
},
source_data: sourceData
};
}

View File

@@ -33,3 +33,45 @@ export type MappingTemplate = Pick<
MappingConfig, MappingConfig,
'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue' 'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue'
>[]; >[];
/** Static mapping rule in Job Bundle output */
export interface StaticRule {
type: 'static';
source: string;
target: string;
dataType: DataType;
format?: string;
}
/** Dynamic API enrichment rule */
export interface ApiEnrichmentRule {
type: 'api_fetch';
target_key: string;
url_template: string;
method: 'GET' | 'POST';
headers?: Record<string, string>;
body_template?: string;
response_path: string;
fallback_value?: unknown;
}
/** Submission configuration for final data push */
export interface SubmissionConfig {
target_url: string;
method: 'POST' | 'PUT';
batch_size: number;
}
/** Final exported Job Bundle structure */
export interface JobBundle {
meta: {
version: string;
generated_at: string;
};
config: {
static_rules: StaticRule[];
enrichment_rules: ApiEnrichmentRule[];
submission: SubmissionConfig;
};
source_data: Record<string, unknown>[];
}

View File

@@ -1,9 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { MappingConfig, RowData, MappingTemplate } from '$lib/types.js'; import type { MappingConfig, RowData, MappingTemplate, ApiEnrichmentRule, SubmissionConfig } from '$lib/types.js';
import { parseExcelFile } from '$lib/excel.js'; import { parseExcelFile } from '$lib/excel.js';
import { convertData, createDefaultMappings, applyTemplate, exportTemplate } from '$lib/converter.js'; import { convertData, createDefaultMappings, applyTemplate, exportTemplate, generateJobBundle } from '$lib/converter.js';
import ExcelTable from '$lib/components/ExcelTable.svelte'; import ExcelTable from '$lib/components/ExcelTable.svelte';
import JsonPreview from '$lib/components/JsonPreview.svelte'; import JsonPreview from '$lib/components/JsonPreview.svelte';
import ApiConfigModal from '$lib/components/ApiConfigModal.svelte';
import SubmissionSettings from '$lib/components/SubmissionSettings.svelte';
// State // State
let headers = $state<string[]>([]); let headers = $state<string[]>([]);
@@ -14,6 +16,17 @@
let isDragOver = $state(false); let isDragOver = $state(false);
let copySuccess = $state(false); let copySuccess = $state(false);
// ETL state
let enrichmentRules = $state<ApiEnrichmentRule[]>([]);
let submissionConfig = $state<SubmissionConfig>({
target_url: '',
method: 'POST',
batch_size: 50
});
let showApiConfig = $state(false);
let showSubmissionSettings = $state(false);
let editingRuleIndex = $state<number | null>(null);
// Split panel // Split panel
let splitPercent = $state(50); let splitPercent = $state(50);
let jsonCollapsed = $state(false); let jsonCollapsed = $state(false);
@@ -50,28 +63,41 @@
let debounceTimer: ReturnType<typeof setTimeout>; let debounceTimer: ReturnType<typeof setTimeout>;
$effect(() => { $effect(() => {
// $state.snapshot forces deep read of all nested properties,
// so changes to e.g. mappings[i].target will trigger this effect
const snapshotMappings = $state.snapshot(mappings); const snapshotMappings = $state.snapshot(mappings);
const snapshotRules = $state.snapshot(enrichmentRules);
const currentRows = rows; const currentRows = rows;
const dataReady = hasData; const dataReady = hasData;
clearTimeout(debounceTimer); clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => { debounceTimer = setTimeout(() => {
convertedJson = dataReady ? convertData(currentRows, snapshotMappings) : []; if (dataReady) {
const base = convertData(currentRows, snapshotMappings);
// Add API placeholders for preview
if (snapshotRules.length > 0) {
convertedJson = base.map((row) => {
const enriched = { ...row };
for (const rule of snapshotRules) {
enriched[rule.target_key] = '[Pending API Fetch]';
}
return enriched;
});
} else {
convertedJson = base;
}
} else {
convertedJson = [];
}
}, 150); }, 150);
}); });
// Manual JSON editing — overrides converted output until next mapping/data change // Manual JSON editing
let manualJson = $state<string | null>(null); let manualJson = $state<string | null>(null);
const jsonString = $derived(manualJson ?? JSON.stringify(convertedJson, null, 2)); const jsonString = $derived(manualJson ?? JSON.stringify(convertedJson, null, 2));
function onJsonEdited(edited: string) { function onJsonEdited(edited: string) {
manualJson = edited; manualJson = edited;
} }
// Clear manual override when conversion changes
$effect(() => { $effect(() => {
convertedJson; convertedJson;
manualJson = null; manualJson = null;
@@ -179,6 +205,43 @@
errorMessage = '复制失败,请手动复制'; errorMessage = '复制失败,请手动复制';
} }
} }
// Job Bundle export
function handleExportJobBundle() {
const bundle = generateJobBundle(rows, mappings, enrichmentRules, submissionConfig);
const blob = new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName ? fileName.replace(/\.[^.]+$/, '_job_bundle.json') : 'job_bundle.json';
a.click();
URL.revokeObjectURL(url);
}
// API enrichment rules management
function openAddApiConfig() {
editingRuleIndex = null;
showApiConfig = true;
}
function openEditApiConfig(index: number) {
editingRuleIndex = index;
showApiConfig = true;
}
function deleteApiRule(index: number) {
enrichmentRules = enrichmentRules.filter((_, i) => i !== index);
}
function handleSaveApiRule(rule: ApiEnrichmentRule) {
if (editingRuleIndex !== null) {
enrichmentRules = enrichmentRules.map((r, i) => (i === editingRuleIndex ? rule : r));
} else {
enrichmentRules = [...enrichmentRules, rule];
}
showApiConfig = false;
editingRuleIndex = null;
}
</script> </script>
<div <div
@@ -231,8 +294,37 @@
导出配置 导出配置
</button> </button>
<div class="mx-2 h-6 w-px bg-gray-200"></div>
<!-- Submission Settings -->
<button
onclick={() => (showSubmissionSettings = true)}
disabled={!hasData}
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
提交设置
</button>
<div class="flex-1"></div> <div class="flex-1"></div>
<!-- Export Job Bundle -->
<button
onclick={handleExportJobBundle}
disabled={!hasData}
class="inline-flex items-center gap-1 rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-700 disabled:cursor-not-allowed disabled:opacity-50 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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
导出任务包
</button>
<div class="mx-1 h-6 w-px bg-gray-200"></div>
<!-- JSON operations --> <!-- JSON operations -->
<button <button
onclick={handleDownloadJson} onclick={handleDownloadJson}
@@ -302,7 +394,15 @@
> >
<!-- Left: Excel Table --> <!-- Left: Excel Table -->
<div class="overflow-hidden" style="width: {jsonCollapsed ? '100%' : `${splitPercent}%`}"> <div class="overflow-hidden" style="width: {jsonCollapsed ? '100%' : `${splitPercent}%`}">
<ExcelTable {headers} {rows} bind:mappings /> <ExcelTable
{headers}
{rows}
bind:mappings
{enrichmentRules}
onaddapi={openAddApiConfig}
oneditapi={openEditApiConfig}
ondeleteapi={deleteApiRule}
/>
</div> </div>
{#if !jsonCollapsed} {#if !jsonCollapsed}
@@ -369,3 +469,20 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- Modals -->
{#if showApiConfig}
<ApiConfigModal
rule={editingRuleIndex !== null ? enrichmentRules[editingRuleIndex] : undefined}
{headers}
onsave={handleSaveApiRule}
onclose={() => { showApiConfig = false; editingRuleIndex = null; }}
/>
{/if}
{#if showSubmissionSettings}
<SubmissionSettings
bind:config={submissionConfig}
onclose={() => (showSubmissionSettings = false)}
/>
{/if}