新增 API 配置和提交设置组件;更新 Excel 表格以支持 API 字段管理和任务包导出功能
This commit is contained in:
298
src/lib/components/ApiConfigModal.svelte
Normal file
298
src/lib/components/ApiConfigModal.svelte
Normal 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="插入变量"
|
||||||
|
>
|
||||||
|
{{x}}
|
||||||
|
</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">支持 {{列名}} 模板变量</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>
|
||||||
@@ -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,16 +45,67 @@
|
|||||||
|
|
||||||
<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">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-sm font-semibold text-gray-700">
|
<h3 class="text-sm font-semibold text-gray-700">
|
||||||
Excel 数据
|
Excel 数据
|
||||||
<span class="ml-2 text-xs font-normal text-gray-400">
|
<span class="ml-2 text-xs font-normal text-gray-400">
|
||||||
{rows.length} 行 × {headers.length} 列
|
{rows.length} 行 × {headers.length} 列
|
||||||
|
{#if enrichmentRules.length > 0}
|
||||||
|
+ {enrichmentRules.length} API 字段
|
||||||
|
{/if}
|
||||||
{#if rows.length > maxPreviewRows}
|
{#if rows.length > maxPreviewRows}
|
||||||
(显示前 {maxPreviewRows} 行)
|
(显示前 {maxPreviewRows} 行)
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</h3>
|
</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">
|
||||||
@@ -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>
|
||||||
|
|||||||
88
src/lib/components/SubmissionSettings.svelte
Normal file
88
src/lib/components/SubmissionSettings.svelte
Normal 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>
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user