Compare commits

...

7 Commits

Author SHA1 Message Date
lirui
5e20e37cda feat: add theme toggle component and theme store for light/dark mode support
- Implemented ThemeToggle component for switching between light, dark, and system themes.
- Created themeStore for managing theme state and persisting user preferences in localStorage.
- Added utility functions for error message sanitization to prevent sensitive data leakage.
- Developed proxy utility functions for API requests, including template variable replacement.
- Enhanced layout with dark mode styles and smooth transitions for theme changes.
- Updated main layout and page components to integrate theme toggle and improve accessibility.
- Added server-side proxy handling with validation and error sanitization for API requests.
2026-02-09 23:13:18 +08:00
lirui
cd6ca590b4 新增字典映射功能,支持有限枚举值转换;更新相关组件和文档以提升用户体验 2026-02-09 22:38:17 +08:00
lirui
04e5f7f705 重构测试配置,新增 vitest.config.ts 文件以支持客户端和服务器端测试 2026-02-09 22:14:34 +08:00
lirui
c3716d7a6c 新增数据补全和提交脚本,支持从 job_bundle.json 读取数据并执行 API 请求;实现批量提交功能,记录成功与失败的日志 2026-02-09 22:06:11 +08:00
lirui
a2d8a774ca 新增 API 配置和提交设置组件;更新 Excel 表格以支持 API 字段管理和任务包导出功能 2026-02-09 21:54:19 +08:00
lirui
b6eaa2a1b1 在页面中添加 GitHub 链接,便于用户访问项目仓库 2026-02-09 20:53:45 +08:00
lirui
28ba7da64a 更新 README 文档,修正截图标题并添加第二张截图;在 JsonTreeNode 组件中添加注释以提高代码可读性 2026-02-09 20:52:45 +08:00
31 changed files with 3827 additions and 171 deletions

View File

@@ -6,7 +6,16 @@
"Bash(npm run check:*)", "Bash(npm run check:*)",
"Bash(npx vitest:*)", "Bash(npx vitest:*)",
"Bash(node -e:*)", "Bash(node -e:*)",
"Bash(npm run build:*)" "Bash(npm run build:*)",
"Bash(npm run test:*)",
"Bash(python -m py_compile:*)",
"Bash(node --check:*)",
"Bash(ls:*)",
"Bash(findstr:*)",
"mcp__zai-mcp-server__analyze_image"
],
"additionalDirectories": [
"c:\\Users\\meowr\\projects\\excel2json\\src\\lib"
] ]
}, },
"enableAllProjectMcpServers": true, "enableAllProjectMcpServers": true,

View File

@@ -4,14 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**excel2json** — a SvelteKit web application for converting Excel files to JSON. Built with Svelte 5, SvelteKit 2, TypeScript, and Tailwind CSS 4. **excel2json** — a SvelteKit web application for converting Excel files to JSON with visual mapping configuration. Built with Svelte 5, SvelteKit 2, TypeScript, and Tailwind CSS 4.
## Commands ## Commands
- `npm run dev` — start dev server - `npm run dev` — start dev server (typically http://localhost:5173)
- `npm run build` — production build - `npm run build` — production build
- `npm run preview` — preview production build - `npm run preview` — preview production build
- `npm run check` — type-check with svelte-check - `npm run check` — type-check with svelte-check
- `npm run check:watch` — type-check in watch mode
- `npm run test` — run all tests once - `npm run test` — run all tests once
- `npm run test:unit` — run tests in watch mode - `npm run test:unit` — run tests in watch mode
- `npm run test:unit -- --run --testNamePattern="pattern"` — run a single test by name - `npm run test:unit -- --run --testNamePattern="pattern"` — run a single test by name
@@ -22,10 +23,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Styling**: Tailwind CSS 4 with `@tailwindcss/forms` and `@tailwindcss/typography` plugins, configured via `src/routes/layout.css` - **Styling**: Tailwind CSS 4 with `@tailwindcss/forms` and `@tailwindcss/typography` plugins, configured via `src/routes/layout.css`
- **Adapter**: `@sveltejs/adapter-auto` - **Adapter**: `@sveltejs/adapter-auto`
- **TypeScript**: strict mode enabled - **TypeScript**: strict mode enabled
- **Dependencies**: `xlsx` (SheetJS) for Excel parsing, `dayjs` for date handling
### Testing ### Testing
Two Vitest project configurations in `vite.config.ts`: Two Vitest project configurations in `vitest.config.ts`:
- **`client`** — browser tests using Playwright (headless Chromium). Files matching `src/**/*.svelte.{test,spec}.{js,ts}`. Uses `vitest-browser-svelte` for component rendering. - **`client`** — browser tests using Playwright (headless Chromium). Files matching `src/**/*.svelte.{test,spec}.{js,ts}`. Uses `vitest-browser-svelte` for component rendering.
- **`server`** — Node.js unit tests. Files matching `src/**/*.{test,spec}.{js,ts}` (excluding `.svelte.` test files). - **`server`** — Node.js unit tests. Files matching `src/**/*.{test,spec}.{js,ts}` (excluding `.svelte.` test files).
@@ -38,6 +40,32 @@ All tests require assertions (`expect.requireAssertions: true`).
- Shared library code goes in `src/lib/` (aliased as `$lib`) - Shared library code goes in `src/lib/` (aliased as `$lib`)
- Use Svelte 5 runes syntax, not legacy Svelte 4 patterns - Use Svelte 5 runes syntax, not legacy Svelte 4 patterns
### Core Application Structure
**Single-page application** with split-pane layout:
- `src/routes/+page.svelte` — Main page containing all application logic
- `src/lib/components/ExcelTable.svelte` — Left panel showing Excel data with column configuration
- `src/lib/components/JsonPreview.svelte` — Right panel showing JSON output with syntax highlighting
- `src/lib/components/ColumnConfig.svelte` — Modal for column mapping configuration
- `src/lib/components/ApiConfigModal.svelte` — API enrichment rules configuration
- `src/lib/components/SubmissionSettings.svelte` — Data submission settings
**Core libraries:**
- `src/lib/excel.ts` — Excel/CSV reading and parsing
- `src/lib/converter.ts` — Mapping conversion core logic
- `src/lib/types.ts` — TypeScript type definitions (`MappingConfig`, `RowData`, etc.)
**Data flow:** File upload → Excel parsing → Mapping configuration → JSON conversion → Preview/Export
**Key features:**
- Nested object support via dot notation (e.g., `user.address.city`)
- Date formatting including Excel serial date compatibility
- Empty value handling (exclude field or use default value)
- Template import/export for mapping configurations
- API enrichment for dynamic data fetching
## Svelte MCP Server ## Svelte MCP Server
A Svelte MCP server is available for Svelte 5 / SvelteKit documentation lookup and code validation. When writing Svelte code: A Svelte MCP server is available for Svelte 5 / SvelteKit documentation lookup and code validation. When writing Svelte code:

View File

@@ -106,9 +106,10 @@ npm run preview
-`excludeIfEmpty = true` 时,空值字段不会出现在输出 JSON 中 -`excludeIfEmpty = true` 时,空值字段不会出现在输出 JSON 中
- 若目标字段包含 `.`,会按路径写入嵌套对象 - 若目标字段包含 `.`,会按路径写入嵌套对象
## <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼ
![<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼ 1](doc/images/image1.png) ## Screenshots
![<EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>ͼ 2](doc/images/image2.png) ![Screenshot 1](doc/images/image1.png)
![Screenshot 2](doc/images/image2.png)

163
demands/DEMAND-proxy.md Normal file
View File

@@ -0,0 +1,163 @@
这是一个为您准备的 **v3.0 版本需求文档**
这份文档的核心目标是**“收口”**:将之前零散开发的“前端代理 (Proxy)”功能正式集成到主流程中,确立 **“混合模式 (Hybrid Mode)”** 的架构策略。
即:**前端负责实时测试和预览(利用 Proxy后端/脚本负责全量执行(利用 Job Bundle。**
---
### 复制下面的内容发送给 Claude
---
**Role:** 资深全栈架构师 (SvelteKit + TypeScript)
**Project Context:**
我们正在开发 "Excel2JSON ETL Blueprint Generator"。
目前我们已经具备了:
1. **前端:** Excel 解析、映射配置、JSON 预览。
2. **后端能力:** 一个 `/api/proxy` 端点 (SvelteKit Endpoint),可以绕过 CORS 转发请求。
3. **输出:** `job_bundle.json` 用于给 Python 脚本跑全量数据。
**Current Goal (Phase 3 Integration):**
我们需要正式集成 `/api/proxy`,实现 **“所见即所得”** 的 API 调试体验。
用户在配置 API 字段时,可以直接点击“测试”,前端调用 Proxy 立即拿回数据并展示,确保配置无误后再导出。
### Phase 3: 在线调试与混合执行架构需求文档
#### 1. 核心架构策略:混合模式 (Hybrid Execution)
为了平衡**用户体验**与**系统性能**,我们采用以下策略:
* **调试/预览阶段 (Online Mode):**
* 使用 SvelteKit 后端代理 (`/api/proxy`)。
* **作用:** 让用户在配置界面就能实时验证 "URL 填得对不对"、"JSON Path 提取得对不对"。
* **限制:** 仅用于**单条数据**测试或**小批量 (前10条)** 预览。
* **生产/执行阶段 (Offline Mode):**
* 使用 `job_bundle.json` + Python 脚本。
* **作用:** 处理成千上万行数据的全量抓取和入库。
* **优势:** 无超时限制,无浏览器崩溃风险。
#### 2. UI/UX 交互升级
##### 2.1 API 配置面板 (Enrichment Config Modal)
`ApiConfigModal.svelte` 中增加 **"Test Connection" (测试连接)** 功能区。
* **输入区:** (已有的 URL, Method, Headers, Body 配置)
* **测试上下文 (Test Context):**
* 显示当前 Excel 的 **第一行数据** 作为测试样本。
* *示例:* `User ID: 101`, `Name: Alice`
* 用户可以手动修改这些样本值来测试不同情况。
* **操作:** 点击 **[Test Request]** 按钮。
* **逻辑:**
1. 前端将 URL 模板中的 `{{Variables}}` 替换为测试样本值。
2. 发送 POST 请求给本站的 `/api/proxy`
3. 等待响应。
* **反馈区:**
* **Status:** 显示 HTTP 状态码 (e.g., `200 OK`, `404 Not Found`)。
* **Response Preview:** 显示原始返回的 JSON (带语法高亮)。
* **Extracted Result:** 根据用户配置的 `Response Path` (e.g., `data.balance`),显示最终提取到的值。
* *交互:* 如果提取结果为 `undefined`,高亮提示用户检查 Path 配置。
##### 2.2 主界面实时预览 (Enriched Preview)
在主界面的右侧 JSON 预览区,增加一个 **"Preview Enrichment" (预览增强数据)** 开关。
* **默认状态 (Off):** 仅展示静态映射后的数据API 字段显示为 `null` 或占位符)。
* **开启状态 (On):**
* **限制:** 仅对前 **5 行** 数据生效。
* **加载:** 显示 Loading 骨架屏。
* **并发:** 并发调用 `/api/proxy` (限制并发数为 3)。
* **展示:** 成功获取后JSON 预览中的相关字段会被真实数据填充并高亮显示。
* **警告:** 在开关旁显示小字提示 *"Live preview limited to first 5 rows to prevent API abuse."*
#### 3. 数据流与接口定义 (Data Flow)
##### 3.1 前端代理调用函数
封装一个通用的 `proxyFetch` 工具函数,用于前端组件调用:
```typescript
// src/lib/utils/proxy.ts
interface ProxyOptions {
url: string;
method: string;
headers: Record<string, string>;
body?: any;
}
export async function proxyFetch(options: ProxyOptions): Promise<any> {
// 1. 调用我们自己的 SvelteKit 后端
const response = await fetch('/api/proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options)
});
if (!response.ok) {
throw new Error(`Proxy Error: ${response.statusText}`);
}
return response.json();
}
```
##### 3.2 变量替换逻辑 (Template Interpolation)
确保前端和后端(Python)使用一致的变量替换逻辑。建议实现一个简单的 `renderTemplate` 函数:
```typescript
/**
* 将 "https://api.com/users/{{id}}" 使用 { id: 123 } 替换为 "https://api.com/users/123"
*/
export function renderTemplate(template: string, context: Record<string, any>): string {
return template.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => {
return context[key] !== undefined ? String(context[key]) : '';
});
}
```
#### 4. 安全性与限流 (Security & Constraints)
为了防止 `/api/proxy` 被滥用或导致服务器卡死,请在服务端 (`src/routes/api/proxy/+server.ts`) 增加以下保护:
1. **超时控制:** 设置 `AbortController`,如果目标接口 10 秒未响应,强制中断并返回 504。
2. **错误屏蔽:** 如果目标接口返回敏感信息(如 Stack Trace后端应进行脱敏处理后再返回给前端。
#### 5. 开发任务清单
1. **工具库:** 实现 `src/lib/utils/proxy.ts``renderTemplate`
2. **组件升级:**
* 改造 `ApiConfigModal`:加入测试按钮和结果展示面板。
* 改造 `JsonPreview`:集成“实时预览”开关和并发请求逻辑。
3. **流程集成:** 确保在导出 `job_bundle.json` 时,不需要改动任何逻辑(导出依然是纯配置)。
---
### 给 AI 的提示 (Prompt Tip)
* **Focus on State:** 提醒 Claude 注意 Svelte 的状态管理。在测试 API 时,不要阻塞主 UI 的渲染。建议使用 `async/await` 配合局部 loading 状态变量。
* **Error Handling:** 强调错误处理。如果用户填的 API URL 是错的(比如 404前端不应该报错崩溃而是应该优雅地在“测试结果”面板里显示红色错误信息。
---
**请先实现 `src/lib/utils/proxy.ts``ApiConfigModal` 的测试功能。**

View File

@@ -0,0 +1,137 @@
**Role:** 资深前端架构师 (Svelte 5 + TypeScript)
**Project Pivot (重大架构调整):**
我们将 "Excel2JSON Mapper" 升级为一个 **"ETL 配置生成器 (ETL Blueprint Generator)"**。
前端的任务是生成一个包含 **[源数据 + 处理逻辑]** 的 **Job Bundle (JSON 文件)**,用户将使用该文件配合 Python 脚本在后端执行实际的数据抓取和入库。
### Phase 2: ETL 配置生成器需求文档
#### 1. 项目概述
这是一个基于 Svelte 5 的单页应用。用户上传 Excel配置字段映射规则包含静态重命名和动态 API 获取规则),最后导出为一个标准化的 JSON 任务包 (`job_bundle.json`)。
#### 2. 用户界面与交互 (UI/UX)
##### 2.1 顶部工具栏
* **导入/导出配置:** 支持保存当前所有的映射规则。
* **导出任务包 (Export Job Bundle):** 這是核心操作。点击后下载 `job_bundle.json` 文件(包含数据+配置)。
* **提交设置 (Submission Settings):** 一个模态框,配置最终数据推送到哪里。
* `Target URL`: 最终数据接收接口 (e.g., `https://api.db.com/bulk-insert`).
* `Method`: POST / PUT.
* `Batch Size`: 批次大小 (默认 50).
##### 2.2 主体区域 (左右分栏)
* **左侧 (Source):** Excel 表格预览。
* **右侧 (Preview):** 静态映射后的 JSON 预览(仅展示前 20 条以保证性能)。
##### 2.3 核心功能:列配置 (Column Configuration)
在左侧表格区域,除了点击现有表头修改映射外,新增 **"添加计算列 (Add Computed Column)"** 功能。
**新增类型:`API_FETCH` (动态 API 字段)**
当用户选择此类型时,弹出一个详细配置面板:
1. **Target Key:** 最终生成的 JSON 字段名 (例如 `user_balance`)。
2. **Request URL (支持模板变量):**
* 允许使用 `{{ColumnName}}` 语法引用当前行的 Excel 数据。
* *示例:* `https://api.example.com/users/{{用户ID}}/detail`
* *UI 交互:* 输入框旁应有“插入变量”按钮,点击列出所有可用 Excel 表头。
3. **Request Method:** 下拉选择 `GET` (默认) 或 `POST`
4. **Headers:** Key-Value 编辑器 (用于传 `Authorization`, `Content-Type` 等)。
5. **Request Body (仅 POST):**
* 多行文本域,支持 JSON 格式。
* 同样支持 `{{ColumnName}}` 模板变量替换。
6. **Response Extractor (取值路径):**
* 指定从接口返回的 JSON 中提取哪个字段。
* 支持 `lodash.get` 风格的点号路径。
* *示例:* 接口返回 `{ "data": { "balance": 100 } }`,用户填 `data.balance`
#### 3. 核心输出Job Bundle 数据结构
请严格按照以下 TypeScript 接口定义生成导出的 JSON 文件:
```typescript
// 1. 静态映射规则
interface StaticRule {
type: 'static';
source: string; // Excel 原表头
target: string; // JSON 目标 Key
dataType: 'string' | 'number' | 'boolean' | 'date';
format?: string; // 日期格式化字符串
}
// 2. 动态 API 获取规则 (本次新增核心)
interface ApiEnrichmentRule {
type: 'api_fetch';
target_key: string; // JSON 目标 Key
url_template: string; // "https://api.com/{{id}}"
method: 'GET' | 'POST';
headers?: Record<string, string>;
body_template?: string; // POST body 模板
response_path: string; // "data.result.value"
fallback_value?: any; // 默认值 (null/0)
}
// 3. 提交配置
interface SubmissionConfig {
target_url: string;
method: 'POST' | 'PUT';
batch_size: number;
}
// 4. 最终导出的 Job Bundle 结构
interface JobBundle {
meta: {
version: string;
generated_at: string;
};
config: {
static_rules: StaticRule[];
enrichment_rules: ApiEnrichmentRule[];
submission: SubmissionConfig;
};
source_data: Record<string, any>[]; // 经过静态映射后的基础数据列表
}
```
#### 4. 开发任务清单
1. **Store 设计:** 更新 Svelte Store 以存储 `enrichmentRules``submissionConfig`
2. **UI 组件:**
* 开发 `ApiConfigModal.svelte`: 用于录入 URL、Headers、Body 等复杂信息。
* 实现变量插入辅助功能 (点击列名自动插入 `{{...}}`)。
3. **导出逻辑:** 编写 `generateJobBundle` 函数。
* **步骤 1:** 根据 `static_rules` 转换 Excel 原始数据,生成基础 JSON 数组。
* **步骤 2:** 将基础数据、API 规则、提交配置组装成 `JobBundle` 格式。
* **步骤 3:** 触发浏览器下载 `job_bundle.json`
#### 5. 特别说明 (给 AI 的提示)
* **No Runtime Fetch:** 前端代码 **不需要** 执行 `fetch` 去调用用户配置的 API避免 CORS。前端只负责把 URL 字符串保存到 JSON 里。
* **Template Validation:** 在 UI 上简单校验 URL 模板格式(检查是否包含 `{}`),但不做逻辑校验。
* **Preview Limitations:** 右侧预览仅展示静态映射的结果。对于 API 字段,可以在预览中显示一个占位符(如 `[Pending API Fetch]`)。
---
### 后续步骤
Claude 完成这个前端代码后,你可以再发一条指令让它写对应的 Python 执行脚本:
> "前端已经完成了。现在请帮我写一个 Python 脚本 (`executor.py`)。它读取上面定义的 `job_bundle.json`,使用 http请求 执行 `enrichment_rules` 里的请求(注意替换 URL 中的 {{变量}}),最后把结果推送到 `submission` 定义的接口。"

View File

@@ -0,0 +1,162 @@
这次更新加入了 **“字典映射 (Dictionary Mapping)”** 功能,允许用户对有限的枚举值进行转换(如“是/否”转“Y/N”
我特别在文档中强调了**“自动扫描”**功能——让 UI 自动读取 Excel 列中的前几十行去重数据,自动列出所有可能的选项,用户只需要填右边的目标值即可,极大提升体验。
---
### 复制下面的内容发送给 Claude
---
**Role:** 资深前端架构师 (Svelte 5 + TypeScript)
**Project Context:**
我们正在开发 "Excel2JSON ETL Blueprint Generator"。
前端负责解析 Excel、配置映射规则含静态重命名、字典转换、动态 API、生成预览并最终导出包含 **[处理后的基础数据 + API 执行逻辑]** 的 `job_bundle.json`
**Current Task:**
我们需要在 **v2.0 (API Enrichment)** 的基础上,新增 **v2.1 (Dictionary/Enum Mapping)** 功能。
### Phase 2.1: 字典映射与高级 ETL 配置需求文档
#### 1. 核心功能变更
在静态字段映射Static Mapping阶段新增 **"值映射 (Value Map)"** 功能。
* **场景:** Excel 中某列是状态词(如 "开/关", "男/女"),目标 JSON 需要代码(如 `1/0`, `M/F`)。
* **逻辑:** 解析 Excel -> **字典替换** -> 类型转换 -> 生成基础 JSON -> (后续由 Python 处理 API).
#### 2. 用户界面与交互 (UI/UX)
##### 2.1 列配置面板升级
点击 Excel 表头配置时,除了修改 `Target Key``Data Type`,新增一个 **"Value Mapping" (值映射)** 开关/折叠面板。
**面板内容:**
1. **自动扫描 (Auto-Scan):**
* UI 自动读取该列的前 50 行数据,提取所有**唯一值 (Unique Values)**。
* 显示一个“映射表”:左侧是 `Source Value` (Excel 原值),右侧是 `Target Value` (输入框)。
2. **手动添加:** 允许用户手动增加新的映射对(防止前 50 行没覆盖到所有情况)。
3. **默认值 (Fallback):**
* 如果单元格的值不在映射表中,怎么处理?
* 选项: `Keep Original` (保留原值) / `Set to Null` / `Custom Value` (自定义默认值)。
##### 2.2 预览逻辑 (Preview Logic)
* 右侧 JSON 预览必须**实时反映**字典映射的结果。
* *示例:* 用户在左侧把 "是" 映射为 `true` (Boolean),右侧预览中原本的 "是" 应立即变为 `true`
#### 3. 核心数据结构 (Updated Interfaces)
请更新 TypeScript 接口以支持新的映射逻辑:
```typescript
// 字典映射项
interface ValueMapItem {
source: string | number; // Excel 里的原始值 (e.g., "是")
target: any; // JSON 里的目标值 (e.g., true, "Y", 1)
}
// 静态映射规则 (升级版)
interface StaticRule {
type: 'static';
source_column: string; // Excel 原表头
target_key: string; // JSON 目标 Key
data_type: 'string' | 'number' | 'boolean' | 'date' | 'array'; // 目标类型
// v2.1 新增: 字典映射配置
use_dictionary: boolean; // 是否启用字典映射
value_mapping?: ValueMapItem[];
mapping_fallback?: 'keep' | 'null' | any; // 没匹配到时的默认值
// v2.0 已有
format?: string; // 日期格式
separator?: string; // 数组分隔符
}
// 动态 API 规则 (保持 v2.0 不变)
interface ApiEnrichmentRule {
type: 'api_fetch';
target_key: string;
url_template: string; // "https://api.com/{{id}}"
method: 'GET' | 'POST';
headers?: Record<string, string>;
body_template?: string;
response_path: string; // "data.result"
}
// 提交配置 (保持 v2.0 不变)
interface SubmissionConfig {
target_url: string;
method: 'POST' | 'PUT';
batch_size: number;
}
// 最终导出的 Job Bundle
interface JobBundle {
meta: { version: string; generated_at: string };
config: {
// static_rules 仅用于前端回显Python 脚本其实只需要 enrichment 和 submission
// 但为了以后能在前端重新导入编辑,建议保留完整配置
static_rules: StaticRule[];
enrichment_rules: ApiEnrichmentRule[];
submission: SubmissionConfig;
};
// 注意: source_data 是前端已经应用了 "StaticRule" (包括字典映射) 后的干净数据
source_data: Record<string, any>[];
}
```
#### 4. 处理流程 (Processing Pipeline)
前端在生成 `source_data` 时,必须严格按照以下顺序处理每一单元格:
1. **Extract:** 读取 Excel 单元格原始值。
2. **Map (字典映射):**
* 如果启用了 `use_dictionary`:查找映射表。
* 找到 -> 替换为 Target Value。
* 没找到 -> 应用 `mapping_fallback` 策略。
3. **Cast (类型转换):**
* 将上一步的结果转换为 `data_type` 指定的类型 (e.g., String -> Boolean, String -> Number)。
* *注意:* 如果字典映射的目标值已经是正确的类型(如 `true`),则跳过此步或确保不会再次转为字符串 "true"。
4. **Format:** (如果是日期或数组) 应用格式化规则。
#### 5. 开发任务清单
1. **组件开发:**
* 修改 `ColumnConfigPanel.svelte` (或类似组件)。
* 新增 `DictionaryMapper` 子组件:包含“自动扫描”按钮和“键值对”编辑表格。
2. **逻辑核心:**
* 更新 `processRow` 函数,在类型转换前插入字典查找逻辑。
* 实现 `scanUniqueValues(columnData)` 函数,用于快速提取 Excel 列的去重值。
3. **预览同步:** 确保右侧 JSON 预览能实时响应字典配置的变更。
4. **导出验证:** 导出 `job_bundle.json`,检查 `source_data` 中的值是否已成功转换为映射后的值(例如 "Y" 而不是 "是")。
---
### 给 AI 的提示 (Prompt Tip)
* **性能注意:** 自动扫描 `scanUniqueValues` 时,如果 Excel 数据量极大(>10万行不要全量扫描。只扫描前 1000 行即可,并提示用户“仅扫描了前 1000 行,如有遗漏请手动添加”。
* **交互细节:** 字典映射的 Target Value 输入框,应该能智能识别类型。如果用户输入 `true`,应该被识别为 Boolean 而不是字符串 "true"。
---
### 执行步骤
请先基于上述文档,更新 **数据类型定义 (Interfaces)****核心处理逻辑 (`processRow` 函数)**

145
demands/DEMAND-样式.md Normal file
View File

@@ -0,0 +1,145 @@
**Role:** 资深 UI/UX 工程师 & Svelte 专家
**Current Context:**
我们已经完成了 "Excel2JSON ETL Blueprint Generator" 的核心功能Excel 解析、API 配置、JSON 导出)。
目前的界面比较原始。现在需要进行 **Phase 3: UI/UX Overhaul & Theming**
**Goal:**
全面优化应用样式,引入 **Dark Mode (夜间模式)** 支持,提升视觉层级和交互体验。目标风格是 **"Modern SaaS"** (类似 Vercel/Linear/Shadcn 的风格)。
### Phase 3: UI/UX 优化与多主题需求文档
#### 1. 技术方案 (Technical Approach)
* **Tailwind CSS Dark Mode:** 使用 `class` 策略(通过在 `<html>` 标签添加 `class="dark"` 来切换)。
* **State Management:** 创建一个 `themeStore.ts` (Svelte Store),用于管理 `light` | `dark` | `system` 状态,并持久化到 `localStorage`
* **CSS Variables:** 建议在 `app.css` 中定义语义化的 CSS 变量 (如 `--bg-primary`, `--text-secondary`),或者直接使用 Tailwind 的 `slate` 色系作为主轴。
#### 2. 设计规范 (Design System Specs)
请严格遵循以下配色逻辑,确保深色模式下的对比度和可读性。
##### 2.1 基础色盘 (Color Palette)
* **Primary Brand:** Indigo-600 (Light) / Indigo-500 (Dark)
* **Background (Canvas):**
* Light: `bg-white` (Main), `bg-slate-50` (Sidebar/Header)
* Dark: `bg-slate-950` (Main), `bg-slate-900` (Sidebar/Header)
* **Surface (Cards/Modals):**
* Light: `bg-white` + `shadow-sm` + `border-slate-200`
* Dark: `bg-slate-900` + `shadow-none` + `border-slate-800`
* **Text (Typography):**
* Primary: `text-slate-900` (Light) / `text-slate-50` (Dark)
* Secondary: `text-slate-500` (Light) / `text-slate-400` (Dark)
* Muted: `text-slate-400` (Light) / `text-slate-500` (Dark)
* **Borders:** `border-slate-200` (Light) / `border-slate-800` (Dark)
##### 2.2 交互反馈 (Interactive States)
* **Buttons:**
* Primary: Solid Indigo background. Hover: `hover:bg-indigo-700` (Light) / `hover:bg-indigo-400` (Dark).
* Ghost/Secondary: Transparent background. Hover: `hover:bg-slate-100` (Light) / `hover:bg-slate-800` (Dark).
* **Inputs:**
* Default: `bg-transparent border border-slate-300 dark:border-slate-700`.
* Focus: `ring-2 ring-indigo-500/20 border-indigo-500`.
#### 3. 组件级优化详情
##### 3.1 顶部导航栏 (Header)
* **布局:** Flexbox高度 `h-14` or `h-16`
* **功能区:**
* 左侧: Logo + Title (Bold, Tracking-tight)。
* 右侧: [Export Button] [Settings Icon] [Theme Toggle]。
* **Theme Toggle:** 实现一个图标按钮,点击在 🌞 (Sun) / 🌙 (Moon) / 💻 (System) 之间切换。切换时添加平滑的 `transition-colors` 动画。
##### 3.2 Excel 表格区域 (Left Panel)
* **容器:** 卡片式设计,圆角 `rounded-lg`,带边框。
* **表头 (Thead):**
* Light: `bg-slate-50`
* Dark: `bg-slate-900`
* 文字: `text-xs font-semibold uppercase tracking-wider text-slate-500`.
* **单元格 (Td):**
* 必须有边框:`border-r border-b border-slate-200 dark:border-slate-800`
* 斑马纹 (Zebra Striping): 偶数行在 Dark mode 下给予微弱的背景色 `dark:even:bg-slate-900/50` 增加可读性。
* **列配置按钮:** 表头上的“设置图标”在 Hover 时才显示,保持界面整洁。
##### 3.3 JSON 预览区域 (Right Panel)
* **容器:** 模拟 IDE/终端外观。
* **背景:**
* Light: `bg-slate-50` (或者纯白)
* Dark: `bg-[#0d1117]` (GitHub Dark Dimmed 风格) 或 `bg-slate-950`.
* **代码高亮:**
* **关键点:** 语法高亮需要根据主题动态切换。
* 如果没有引入重的 highlighter 库,请手动为 Key/String/Number/Boolean 定义两套颜色。
* *Example:* Keys (Blue-600/Blue-400), Strings (Green-600/Green-400), Numbers (Orange-600/Orange-400).
* **Copy 按钮:** 悬浮在右上角的绝对定位按钮,点击后显示 "Copied!" 提示。
##### 3.4 模态框 (Modals - API & Dictionary Config)
* **背景遮罩 (Backdrop):** `bg-black/50` (Light) / `bg-black/80` (Dark) with `backdrop-blur-sm`.
* **弹窗本体:** `bg-white dark:bg-slate-900`,边框 `dark:border-slate-700`
* **表单元素:** 输入框在 Dark Mode 下背景应为 `bg-slate-950` 或深灰色,避免过亮。
##### 3.5 滚动条 (Scrollbars)
* 请自定义 Webkit 滚动条样式,使其不再是默认的丑陋灰色条。
* Track: Transparent.
* Thumb: `bg-slate-300 dark:bg-slate-700`,圆角 `rounded-full`
#### 4. 开发任务清单 (Action Plan)
1. **基础建设:**
*`app.css` 中配置 Tailwind 的 `@apply` 基础样式。
* 实现 `themeStore.ts` 并处理 `onMount` 时的系统偏好检测。
*`App.svelte` 根节点绑定 `class:dark={$themeStore === 'dark'}`
2. **组件重构:**
* 重写 `Header.svelte`,加入主题切换器。
* 重构 `ExcelTable.svelte` 的 class全面加入 `dark:` 修饰符。
* 重构 `JsonPreview.svelte`,优化配色和字体 (使用 Monospace 字体)。
* 优化 `Modal``Drawer` 组件的阴影和边框。
3. **细节打磨:**
* 为所有可点击元素添加 `transition-all duration-200`
* 确保 Loading 状态(骨架屏或 Spinner在深色模式下不可见度正常。
---
### 给 AI 的提示 (Prompt Tip)
* **Syntax Highlighting:** 告诉 Claude如果目前的 JSON 预览只是纯文本 `pre` 标签,请帮我写一个简单的 `syntaxHighlight(json)` 函数,通过正则把 HTML 标签包进去,并用 Tailwind 的颜色类(如 `text-blue-600 dark:text-blue-400`)来控制颜色,从而实现轻量级的双模式高亮。
---
### 你可以期待的效果
有了这份文档Claude 会帮你把界面做得像 **VS Code****GitHub** 一样专业。
左边是清爽的表格,右边是极客风的代码预览,切换开关时,整个页面会平滑过渡(如果不做 transition 就是瞬间切换,做了就是渐变,建议做 transition

201
scripts/node/enricher.mjs Normal file
View File

@@ -0,0 +1,201 @@
/**
* enricher.mjs — API 数据补全脚本 (Node.js)
*
* 读取前端生成的 job_bundle.json执行 enrichment_rules 中的 API 请求,
* 将结果合并到 source_data 中,输出最终完整的 JSON 文件。
*
* 用法:
* node enricher.mjs job_bundle.json
* node enricher.mjs job_bundle.json -o enriched_data.json
* node enricher.mjs job_bundle.json --concurrency 10
*/
import { readFileSync, writeFileSync } from "fs";
import { basename, join, dirname } from "path";
// ── helpers ──
function resolvePath(obj, path) {
const keys = path.split(".");
let current = obj;
for (const key of keys) {
if (current == null) return null;
if (Array.isArray(current)) {
const idx = Number(key);
if (Number.isNaN(idx) || idx < 0 || idx >= current.length) return null;
current = current[idx];
} else if (typeof current === "object") {
current = current[key];
} else {
return null;
}
}
return current ?? null;
}
function renderTemplate(template, row) {
return template.replace(/\{\{(.+?)\}\}/g, (_, key) => {
const val = row[key];
return val != null ? String(val) : "";
});
}
// ── fetch one ──
async function fetchOne(rule, row, rowIndex, semaphore) {
const targetKey = rule.target_key;
const fallback = rule.fallback_value ?? null;
const url = renderTemplate(rule.url_template, row);
const method = (rule.method || "GET").toUpperCase();
const headers = {};
if (rule.headers) {
for (const [k, v] of Object.entries(rule.headers)) {
headers[k] = renderTemplate(v, row);
}
}
/** @type {RequestInit} */
const opts = { method, headers };
if (method === "POST" && rule.body_template) {
const body = renderTemplate(rule.body_template, row);
try {
JSON.parse(body);
headers["Content-Type"] = headers["Content-Type"] || "application/json";
opts.body = body;
} catch {
opts.body = body;
}
}
await semaphore.acquire();
try {
const resp = await fetch(url, opts);
if (!resp.ok) {
console.log(` [WARN] Row ${rowIndex} | ${targetKey} | HTTP ${resp.status} <- ${url}`);
return { rowIndex, targetKey, value: fallback };
}
const data = await resp.json();
const value = resolvePath(data, rule.response_path);
return { rowIndex, targetKey, value: value ?? fallback };
} catch (e) {
console.log(` [ERROR] Row ${rowIndex} | ${targetKey} | ${e.message}`);
return { rowIndex, targetKey, value: fallback };
} finally {
semaphore.release();
}
}
// ── semaphore ──
function createSemaphore(max) {
let current = 0;
/** @type {(() => void)[]} */
const queue = [];
return {
acquire() {
if (current < max) {
current++;
return Promise.resolve();
}
return new Promise((resolve) => queue.push(resolve));
},
release() {
current--;
if (queue.length > 0) {
current++;
queue.shift()();
}
},
};
}
// ── main ──
function parseArgs() {
const args = process.argv.slice(2);
const opts = { bundle: "", output: "", concurrency: 5 };
for (let i = 0; i < args.length; i++) {
if (args[i] === "-o" || args[i] === "--output") {
opts.output = args[++i];
} else if (args[i] === "--concurrency") {
opts.concurrency = Number(args[++i]) || 5;
} else if (args[i] === "--help" || args[i] === "-h") {
console.log("Usage: node enricher.mjs <job_bundle.json> [-o output.json] [--concurrency N]");
process.exit(0);
} else if (!opts.bundle) {
opts.bundle = args[i];
}
}
if (!opts.bundle) {
console.error("Error: Please provide a job_bundle.json path.");
console.error("Usage: node enricher.mjs <job_bundle.json>");
process.exit(1);
}
return opts;
}
async function main() {
const opts = parseArgs();
const raw = readFileSync(opts.bundle, "utf-8");
const bundle = JSON.parse(raw);
const meta = bundle.meta || {};
const config = bundle.config || {};
const sourceData = bundle.source_data || [];
const rules = config.enrichment_rules || [];
console.log(`=== Job Bundle v${meta.version || "?"} ===`);
console.log(`Generated: ${meta.generated_at || "?"}`);
console.log(`Rows: ${sourceData.length}`);
console.log(`Static rules: ${(config.static_rules || []).length}`);
console.log(`Enrichment rules: ${rules.length}`);
console.log();
if (rules.length === 0) {
console.log("No enrichment rules configured. Outputting source data as-is.");
} else {
const totalCalls = sourceData.length * rules.length;
console.log(`Enriching ${sourceData.length} rows x ${rules.length} rule(s) = ${totalCalls} API calls`);
console.log(`Concurrency: ${opts.concurrency}`);
console.log();
const semaphore = createSemaphore(opts.concurrency);
const tasks = [];
for (let rowIdx = 0; rowIdx < sourceData.length; rowIdx++) {
for (const rule of rules) {
tasks.push(fetchOne(rule, sourceData[rowIdx], rowIdx, semaphore));
}
}
const results = await Promise.all(tasks);
let errorCount = 0;
for (const r of results) {
if (r.value === undefined) errorCount++;
sourceData[r.rowIndex][r.targetKey] = r.value;
}
console.log();
console.log(`Done. ${totalCalls - errorCount}/${totalCalls} calls succeeded.`);
}
const output = {
meta,
submission: config.submission || {},
data: sourceData,
};
const outName = opts.output || opts.bundle.replace(/\.json$/i, "_enriched.json");
writeFileSync(outName, JSON.stringify(output, null, 2), "utf-8");
console.log(`\nEnriched data saved to: ${outName}`);
console.log(`Next step: node submitter.mjs ${outName}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

177
scripts/node/submitter.mjs Normal file
View File

@@ -0,0 +1,177 @@
/**
* submitter.mjs — 数据提交脚本 (Node.js)
*
* 读取 enricher.mjs 输出的 JSON 文件,按批次提交到目标接口。
* 记录提交成功和失败的记录到单独的日志文件。
*
* 用法:
* node submitter.mjs enriched_data.json
* node submitter.mjs enriched_data.json --batch-size 100
* node submitter.mjs enriched_data.json --url https://api.example.com/import
* node submitter.mjs enriched_data.json --dry-run
*/
import { readFileSync, writeFileSync } from "fs";
// ── args ──
function parseArgs() {
const args = process.argv.slice(2);
const opts = { input: "", url: "", method: "", batchSize: 0, dryRun: false };
for (let i = 0; i < args.length; i++) {
if (args[i] === "--url") {
opts.url = args[++i];
} else if (args[i] === "--method") {
opts.method = args[++i];
} else if (args[i] === "--batch-size") {
opts.batchSize = Number(args[++i]) || 0;
} else if (args[i] === "--dry-run") {
opts.dryRun = true;
} else if (args[i] === "--help" || args[i] === "-h") {
console.log("Usage: node submitter.mjs <enriched.json> [--url URL] [--method POST|PUT] [--batch-size N] [--dry-run]");
process.exit(0);
} else if (!opts.input) {
opts.input = args[i];
}
}
if (!opts.input) {
console.error("Error: Please provide an enriched JSON file path.");
console.error("Usage: node submitter.mjs <enriched.json>");
process.exit(1);
}
return opts;
}
// ── submit ──
async function submitBatch(url, method, batch, batchIndex, totalBatches) {
try {
const resp = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(batch),
});
const body = await resp.text();
const ok = resp.status < 400;
const tag = ok ? "OK" : "FAIL";
console.log(` Batch ${batchIndex}/${totalBatches}: HTTP ${resp.status} ${tag} (${batch.length} records)`);
return { ok, status: resp.status, body, data: batch };
} catch (e) {
const msg = e.message;
console.log(` Batch ${batchIndex}/${totalBatches}: ERROR - ${msg} (${batch.length} records)`);
return { ok: false, status: 0, body: msg, data: batch };
}
}
// ── main ──
async function main() {
const opts = parseArgs();
const raw = readFileSync(opts.input, "utf-8");
const payload = JSON.parse(raw);
const data = payload.data || [];
const submission = payload.submission || {};
const targetUrl = opts.url || submission.target_url || "";
const method = (opts.method || submission.method || "POST").toUpperCase();
const batchSize = opts.batchSize || submission.batch_size || 50;
if (!targetUrl) {
console.error("Error: No target URL configured. Use --url or set in job bundle.");
process.exit(1);
}
console.log("=== Submitter ===");
console.log(`Input: ${opts.input} (${data.length} records)`);
console.log(`Target: ${method} ${targetUrl}`);
console.log(`Batch size: ${batchSize}`);
console.log();
// 分批
const batches = [];
for (let i = 0; i < data.length; i += batchSize) {
batches.push(data.slice(i, i + batchSize));
}
const totalBatches = batches.length;
if (opts.dryRun) {
console.log(`[DRY RUN] Would submit ${data.length} records in ${totalBatches} batch(es)`);
for (let i = 0; i < batches.length; i++) {
console.log(` Batch ${i + 1}/${totalBatches}: ${batches[i].length} records`);
}
console.log("\nDry run complete. No data was sent.");
return;
}
/** @type {object[]} */
const successRecords = [];
/** @type {object[]} */
const failedRecords = [];
/** @type {object[]} */
const failedDetails = [];
console.log(`Submitting ${data.length} records in ${totalBatches} batch(es)...\n`);
for (let i = 0; i < batches.length; i++) {
const result = await submitBatch(targetUrl, method, batches[i], i + 1, totalBatches);
if (result.ok) {
successRecords.push(...result.data);
} else {
failedRecords.push(...result.data);
failedDetails.push({
batch_index: i + 1,
status: result.status,
response: result.body.slice(0, 500),
record_count: result.data.length,
});
}
}
// 结果统计
console.log();
console.log("=== Result ===");
console.log(`Success: ${successRecords.length} records`);
console.log(`Failed: ${failedRecords.length} records`);
const timestamp = new Date().toISOString().replace(/[-:T]/g, "").slice(0, 15);
const baseName = opts.input.replace(/\.json$/i, "");
if (successRecords.length > 0) {
const successFile = `${baseName}_success_${timestamp}.json`;
writeFileSync(successFile, JSON.stringify(successRecords, null, 2), "utf-8");
console.log(`\nSuccess log: ${successFile}`);
}
if (failedRecords.length > 0) {
const failedFile = `${baseName}_failed_${timestamp}.json`;
const report = {
summary: {
total_failed: failedRecords.length,
failed_batches: failedDetails.length,
target_url: targetUrl,
timestamp,
},
batch_errors: failedDetails,
failed_records: failedRecords,
};
writeFileSync(failedFile, JSON.stringify(report, null, 2), "utf-8");
console.log(`Failed log: ${failedFile}`);
const retryFile = `${baseName}_retry_${timestamp}.json`;
const retryPayload = { submission, data: failedRecords };
writeFileSync(retryFile, JSON.stringify(retryPayload, null, 2), "utf-8");
console.log(`\nTo retry failed records:`);
console.log(` node submitter.mjs ${retryFile}`);
}
if (successRecords.length === 0 && failedRecords.length === 0) {
console.log("\nNo records to submit.");
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

186
scripts/py/enricher.py Normal file
View File

@@ -0,0 +1,186 @@
"""
enricher.py — API 数据补全脚本
读取前端生成的 job_bundle.json执行 enrichment_rules 中的 API 请求,
将结果合并到 source_data 中,输出最终完整的 JSON 文件。
用法:
python enricher.py job_bundle.json
python enricher.py job_bundle.json -o enriched_data.json
python enricher.py job_bundle.json --concurrency 10
"""
import argparse
import asyncio
import json
import re
import sys
from pathlib import Path
import aiohttp
def resolve_path(obj, path: str):
"""按点号路径从 dict/list 中取值,类似 lodash.get"""
keys = path.split(".")
current = obj
for key in keys:
if isinstance(current, dict) and key in current:
current = current[key]
elif isinstance(current, list):
try:
current = current[int(key)]
except (ValueError, IndexError):
return None
else:
return None
return current
def render_template(template: str, row: dict) -> str:
"""{{变量名}} 替换为行数据中的对应值"""
def replacer(match: re.Match) -> str:
key = match.group(1)
value = row.get(key, "")
return str(value) if value is not None else ""
return re.sub(r"\{\{(.+?)\}\}", replacer, template)
async def fetch_one(
session: aiohttp.ClientSession,
rule: dict,
row: dict,
row_index: int,
semaphore: asyncio.Semaphore,
) -> tuple[int, str, object]:
"""对单行数据执行一条 enrichment rule 的 API 请求"""
target_key = rule["target_key"]
fallback = rule.get("fallback_value")
url = render_template(rule["url_template"], row)
headers = {}
if rule.get("headers"):
for k, v in rule["headers"].items():
headers[k] = render_template(v, row)
method = rule.get("method", "GET").upper()
body = None
if method == "POST" and rule.get("body_template"):
body = render_template(rule["body_template"], row)
async with semaphore:
try:
kwargs: dict = {"headers": headers}
if body is not None:
try:
kwargs["json"] = json.loads(body)
except json.JSONDecodeError:
kwargs["data"] = body
async with session.request(method, url, **kwargs) as resp:
if resp.status >= 400:
print(f" [WARN] Row {row_index} | {target_key} | HTTP {resp.status} <- {url}")
return row_index, target_key, fallback
data = await resp.json(content_type=None)
value = resolve_path(data, rule["response_path"])
if value is None:
value = fallback
return row_index, target_key, value
except Exception as e:
print(f" [ERROR] Row {row_index} | {target_key} | {type(e).__name__}: {e}")
return row_index, target_key, fallback
async def run_enrichments(
source_data: list[dict],
rules: list[dict],
concurrency: int,
) -> list[dict]:
"""对所有行执行所有 enrichment rules"""
if not rules:
print("No enrichment rules configured. Outputting source data as-is.")
return source_data
semaphore = asyncio.Semaphore(concurrency)
tasks = []
total_calls = len(source_data) * len(rules)
print(f"Enriching {len(source_data)} rows x {len(rules)} rule(s) = {total_calls} API calls")
print(f"Concurrency: {concurrency}")
print()
async with aiohttp.ClientSession() as session:
for row_idx, row in enumerate(source_data):
for rule in rules:
tasks.append(fetch_one(session, rule, row, row_idx, semaphore))
results = await asyncio.gather(*tasks, return_exceptions=True)
error_count = 0
for result in results:
if isinstance(result, Exception):
print(f" [ERROR] Unexpected: {result}")
error_count += 1
continue
row_idx, target_key, value = result
source_data[row_idx][target_key] = value
print()
print(f"Done. {total_calls - error_count}/{total_calls} calls succeeded.")
return source_data
async def main():
parser = argparse.ArgumentParser(description="Enrich job_bundle.json with API data and output final JSON.")
parser.add_argument("bundle", help="Path to job_bundle.json")
parser.add_argument("-o", "--output", type=str, default=None, help="Output file path (default: <bundle>_enriched.json)")
parser.add_argument("--concurrency", type=int, default=5, help="Max concurrent API requests (default: 5)")
args = parser.parse_args()
bundle_path = Path(args.bundle)
if not bundle_path.exists():
print(f"Error: File not found: {bundle_path}")
sys.exit(1)
with open(bundle_path, "r", encoding="utf-8") as f:
bundle = json.load(f)
meta = bundle.get("meta", {})
config = bundle.get("config", {})
source_data = bundle.get("source_data", [])
print(f"=== Job Bundle v{meta.get('version', '?')} ===")
print(f"Generated: {meta.get('generated_at', '?')}")
print(f"Rows: {len(source_data)}")
print(f"Static rules: {len(config.get('static_rules', []))}")
print(f"Enrichment rules: {len(config.get('enrichment_rules', []))}")
print()
enriched = await run_enrichments(
source_data,
config.get("enrichment_rules", []),
args.concurrency,
)
# 构建输出,保留 submission 配置
output = {
"meta": meta,
"submission": config.get("submission", {}),
"data": enriched,
}
output_path = Path(args.output) if args.output else bundle_path.with_name(bundle_path.stem + "_enriched.json")
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output, f, ensure_ascii=False, indent=2)
print(f"\nEnriched data saved to: {output_path}")
print(f"Next step: python submitter.py {output_path}")
if __name__ == "__main__":
asyncio.run(main())

161
scripts/py/submitter.py Normal file
View File

@@ -0,0 +1,161 @@
"""
submitter.py — 数据提交脚本
读取 enricher.py 输出的 JSON 文件,按批次提交到目标接口。
记录提交成功和失败的记录到单独的日志文件。
用法:
python submitter.py enriched_data.json
python submitter.py enriched_data.json --batch-size 100
python submitter.py enriched_data.json --url https://api.example.com/import # 覆盖 URL
python submitter.py enriched_data.json --dry-run # 只输出不提交
"""
import argparse
import asyncio
import json
import sys
from datetime import datetime
from pathlib import Path
import aiohttp
async def submit_batch(
session: aiohttp.ClientSession,
url: str,
method: str,
batch: list[dict],
batch_index: int,
total_batches: int,
) -> tuple[bool, int, str, list[dict]]:
"""提交一个批次,返回 (success, status_code, response_text, batch_data)"""
try:
async with session.request(method, url, json=batch) as resp:
status = resp.status
body = await resp.text()
ok = status < 400
tag = "OK" if ok else "FAIL"
print(f" Batch {batch_index}/{total_batches}: HTTP {status} {tag} ({len(batch)} records)")
return ok, status, body, batch
except Exception as e:
msg = f"{type(e).__name__}: {e}"
print(f" Batch {batch_index}/{total_batches}: ERROR - {msg} ({len(batch)} records)")
return False, 0, msg, batch
async def main():
parser = argparse.ArgumentParser(description="Submit enriched data to target API in batches.")
parser.add_argument("input", help="Path to enriched JSON file (from enricher.py)")
parser.add_argument("--url", type=str, default=None, help="Override target URL from config")
parser.add_argument("--method", type=str, default=None, choices=["POST", "PUT"], help="Override HTTP method")
parser.add_argument("--batch-size", type=int, default=None, help="Override batch size")
parser.add_argument("--dry-run", action="store_true", help="Print what would be submitted without sending")
args = parser.parse_args()
input_path = Path(args.input)
if not input_path.exists():
print(f"Error: File not found: {input_path}")
sys.exit(1)
with open(input_path, "r", encoding="utf-8") as f:
payload = json.load(f)
data = payload.get("data", [])
submission = payload.get("submission", {})
target_url = args.url or submission.get("target_url", "")
method = args.method or submission.get("method", "POST")
batch_size = args.batch_size or submission.get("batch_size", 50)
if not target_url:
print("Error: No target URL configured. Use --url or set in job bundle.")
sys.exit(1)
print(f"=== Submitter ===")
print(f"Input: {input_path} ({len(data)} records)")
print(f"Target: {method} {target_url}")
print(f"Batch size: {batch_size}")
print()
batches = [data[i : i + batch_size] for i in range(0, len(data), batch_size)]
total_batches = len(batches)
if args.dry_run:
print(f"[DRY RUN] Would submit {len(data)} records in {total_batches} batch(es)")
for i, batch in enumerate(batches, 1):
print(f" Batch {i}/{total_batches}: {len(batch)} records")
print("\nDry run complete. No data was sent.")
return
success_records: list[dict] = []
failed_records: list[dict] = []
failed_details: list[dict] = []
print(f"Submitting {len(data)} records in {total_batches} batch(es)...\n")
async with aiohttp.ClientSession() as session:
for i, batch in enumerate(batches, 1):
ok, status, body, batch_data = await submit_batch(
session, target_url, method, batch, i, total_batches
)
if ok:
success_records.extend(batch_data)
else:
failed_records.extend(batch_data)
failed_details.append({
"batch_index": i,
"status": status,
"response": body[:500],
"record_count": len(batch_data),
})
# 结果统计
print()
print(f"=== Result ===")
print(f"Success: {len(success_records)} records")
print(f"Failed: {len(failed_records)} records")
# 写入日志文件
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
base_name = input_path.stem
if success_records:
success_file = input_path.with_name(f"{base_name}_success_{timestamp}.json")
with open(success_file, "w", encoding="utf-8") as f:
json.dump(success_records, f, ensure_ascii=False, indent=2)
print(f"\nSuccess log: {success_file}")
if failed_records:
failed_file = input_path.with_name(f"{base_name}_failed_{timestamp}.json")
report = {
"summary": {
"total_failed": len(failed_records),
"failed_batches": len(failed_details),
"target_url": target_url,
"timestamp": timestamp,
},
"batch_errors": failed_details,
"failed_records": failed_records,
}
with open(failed_file, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"Failed log: {failed_file}")
print(f"\nTo retry failed records:")
print(f' python submitter.py {failed_file} --url "{target_url}"')
# 生成一个可直接重试的文件
retry_file = input_path.with_name(f"{base_name}_retry_{timestamp}.json")
retry_payload = {
"submission": submission,
"data": failed_records,
}
with open(retry_file, "w", encoding="utf-8") as f:
json.dump(retry_payload, f, ensure_ascii=False, indent=2)
print(f" or: python submitter.py {retry_file}")
if not success_records and not failed_records:
print("\nNo records to submit.")
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,583 @@
<script lang="ts">
import type { ApiEnrichmentRule } from '$lib/types.js';
import { proxyFetch, renderTemplate, extractByPath, extractVariableNames } from '$lib/utils/proxy.js';
let {
rule,
headers,
testSample,
onsave,
onclose
}: {
rule?: ApiEnrichmentRule;
headers: string[];
testSample: Record<string, unknown>;
onsave: (rule: ApiEnrichmentRule) => void;
onclose: () => void;
} = $props();
// Form state
// 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) : '');
// UI state
let showUrlVars = $state(false);
let showBodyVars = $state(false);
let urlInput: HTMLInputElement | undefined = $state();
let bodyTextarea: HTMLTextAreaElement | undefined = $state();
// Test connection state
let testContext = $state<Record<string, string>>({});
let isTestRunning = $state(false);
let testResult = $state<{
status: number;
statusText: string;
response: unknown;
extractedValue: unknown;
timestamp: string;
debug?: {
proxiedUrl: string;
proxiedMethod: string;
proxiedHeaders: Record<string, string>;
};
} | null>(null);
let testError = $state<string | null>(null);
let showTestSection = $state(false);
// Initialize test context from sample data
$effect(() => {
// Use the raw sample data directly as context
// This ensures the variable names in templates match the actual data keys
testContext = Object.fromEntries(
Object.entries(testSample).map(([key, value]) => [
key,
value != null ? String(value) : ''
])
);
});
const isValid = $derived(targetKey.trim() !== '' && urlTemplate.trim() !== '' && responsePath.trim() !== '');
const canTest = $derived(
urlTemplate.trim() !== '' &&
responsePath.trim() !== '' &&
Object.keys(testContext).length > 0
);
// Variable names in templates
const urlVariables = $derived(extractVariableNames(urlTemplate));
const bodyVariables = $derived(extractVariableNames(bodyTemplate));
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);
}
async function testConnection() {
isTestRunning = true;
testResult = null;
testError = null;
try {
// Prepare URL
const renderedUrl = renderTemplate(urlTemplate, testContext);
if (!renderedUrl) {
throw new Error('URL 模板渲染后为空,请检查测试样本值');
}
// Prepare headers with template variable replacement
const headersObj: Record<string, string> = {};
for (const entry of headerEntries) {
if (entry.key.trim()) {
headersObj[entry.key.trim()] = renderTemplate(entry.value, testContext);
}
}
// Prepare body for POST
let requestBody: unknown = undefined;
if (method === 'POST' && bodyTemplate.trim()) {
const renderedBody = renderTemplate(bodyTemplate, testContext);
try {
requestBody = JSON.parse(renderedBody);
} catch {
requestBody = renderedBody;
}
}
// Store actual request details for debug display
const actualRequest = {
url: renderedUrl,
method,
headers: headersObj,
body: requestBody
};
// Make proxy request
const response = await proxyFetch(actualRequest);
// Extract value using response path
const extractedValue = extractByPath(response.data, responsePath);
testResult = {
status: response.status,
statusText: getStatusText(response.status),
response: response.data,
extractedValue,
timestamp: new Date().toLocaleTimeString(),
debug: {
proxiedUrl: actualRequest.url,
proxiedMethod: actualRequest.method,
proxiedHeaders: actualRequest.headers
}
};
} catch (e) {
testError = e instanceof Error ? e.message : '测试请求失败';
} finally {
isTestRunning = false;
}
}
function getStatusText(status: number): string {
if (status >= 200 && status < 300) return 'OK';
if (status >= 300 && status < 400) return 'Redirect';
if (status === 400) return 'Bad Request';
if (status === 401) return 'Unauthorized';
if (status === 403) return 'Forbidden';
if (status === 404) return 'Not Found';
if (status >= 500) return 'Server Error';
return 'Unknown';
}
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);
}
function formatTestValue(value: unknown): string {
if (value === undefined) return 'undefined';
if (value === null) return 'null';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}
</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-2xl overflow-y-auto rounded-lg bg-white shadow-xl dark:bg-slate-900" onclick={(e) => e.stopPropagation()}>
<div class="sticky top-0 z-10 flex items-center justify-between border-b border-slate-200 bg-white px-6 py-4 dark:border-slate-800 dark:bg-slate-900">
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-200">
{rule ? '编辑' : '添加'} API 字段
</h3>
<button onclick={onclose} aria-label="关闭" class="cursor-pointer text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300">
<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="p-6">
<div class="space-y-4">
<!-- Target Key -->
<div>
<label for="api-target-key" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Target Key</label>
<input
id="api-target-key"
type="text"
bind:value={targetKey}
placeholder="user_balance"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">最终 JSON 中的字段名</p>
</div>
<!-- Request URL -->
<div>
<label for="api-url" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">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-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<div class="relative">
<button
onclick={() => { showUrlVars = !showUrlVars; showBodyVars = false; }}
class="cursor-pointer whitespace-nowrap rounded-md border border-slate-300 px-2 py-2 text-xs text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
title="插入变量"
>
&#123;&#123;x&#125;&#125;
</button>
{#if showUrlVars}
<div class="absolute right-0 z-20 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{#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-slate-700 hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
{h}
</button>
{/each}
</div>
{/if}
</div>
</div>
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">支持 &#123;&#123;列名&#125;&#125; 模板变量</p>
{#if urlVariables.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each urlVariables as v}
<span class="inline-flex items-center rounded bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
&#123;&#123;{v}&#125;&#125;
</span>
{/each}
</div>
{/if}
</div>
<!-- Request Method -->
<div>
<label for="api-method" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Request Method</label>
<select
id="api-method"
bind:value={method}
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
>
<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-slate-700 dark:text-slate-300">Headers</span>
<button onclick={addHeader} class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300">
+ 添加
</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-slate-300 px-2 py-1.5 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<input
type="text"
bind:value={entry.value}
placeholder="Value"
class="flex-1 rounded border border-slate-300 px-2 py-1.5 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<button
onclick={() => removeHeader(i)}
class="cursor-pointer flex-shrink-0 text-slate-400 hover:text-red-500 dark:text-slate-500 dark:hover:text-red-400"
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-slate-400 dark:text-slate-500">暂无 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-slate-700 dark:text-slate-300">Request Body</label>
<div class="relative">
<button
onclick={() => { showBodyVars = !showBodyVars; showUrlVars = false; }}
class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>
插入变量
</button>
{#if showBodyVars}
<div class="absolute right-0 z-20 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{#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-slate-700 hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-slate-700"
>
{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-slate-300 px-3 py-2 font-mono text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
></textarea>
{#if bodyVariables.length > 0}
<div class="mt-1 flex flex-wrap gap-1">
{#each bodyVariables as v}
<span class="inline-flex items-center rounded bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
&#123;&#123;{v}&#125;&#125;
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Response Path -->
<div>
<label for="api-response-path" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Response Extractor</label>
<input
id="api-response-path"
type="text"
bind:value={responsePath}
placeholder="data.balance"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">从接口返回 JSON 中提取值的路径(如 data.result.value</p>
</div>
<!-- Fallback Value -->
<div>
<label for="api-fallback" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Fallback Value</label>
<input
id="api-fallback"
type="text"
bind:value={fallbackValue}
placeholder="null"
class="w-full rounded-md border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">API 请求失败时的默认值</p>
</div>
<!-- Test Connection Section -->
<div class="rounded-md border border-slate-200 dark:border-slate-700">
<button
onclick={() => (showTestSection = !showTestSection)}
class="flex w-full items-center justify-between rounded-t-md bg-slate-50 px-4 py-3 text-sm font-medium text-slate-700 hover:bg-slate-100 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700 cursor-pointer"
>
<span class="flex items-center gap-2">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
测试连接
</span>
<svg
class="h-4 w-4 transition-transform {showTestSection ? 'rotate-180' : ''}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if showTestSection}
<div class="border-t border-slate-200 p-4 dark:border-slate-700">
<!-- Test Context -->
<div class="mb-4">
<label class="mb-2 block text-xs font-medium text-slate-600 dark:text-slate-400">
测试上下文(可编辑)
</label>
<div class="grid max-h-32 grid-cols-2 gap-2 overflow-y-auto">
{#each headers as header (header)}
<div class="flex items-center gap-2">
<label class="min-w-20 truncate text-xs text-slate-500 dark:text-slate-400">{header}:</label>
<input
type="text"
bind:value={testContext[header]}
class="flex-1 rounded border border-slate-200 px-2 py-1 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
</div>
{/each}
</div>
</div>
<!-- Test Button -->
<button
onclick={testConnection}
disabled={!canTest || isTestRunning}
class="mb-3 inline-flex w-full items-center justify-center gap-2 rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400 cursor-pointer"
>
{#if isTestRunning}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
测试中...
{:else}
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
发送测试请求
{/if}
</button>
<!-- Test Error -->
{#if testError}
<div class="mb-3 rounded-md bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
<div class="flex items-start gap-2">
<svg class="h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{testError}</span>
</div>
</div>
{/if}
<!-- Test Result -->
{#if testResult}
<div class="space-y-3">
<!-- Status -->
<div class="flex items-center justify-between rounded-md bg-slate-50 px-3 py-2 dark:bg-slate-800">
<span class="text-sm text-slate-600 dark:text-slate-400">HTTP 状态</span>
<span class="flex items-center gap-2 text-sm font-medium">
<span class="{testResult.status >= 200 && testResult.status < 300 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}">
{testResult.status} {testResult.statusText}
</span>
<span class="text-xs text-slate-400">{testResult.timestamp}</span>
</span>
</div>
<!-- Extracted Result -->
<div class="rounded-md bg-slate-50 p-3 dark:bg-slate-800">
<div class="mb-1 text-xs font-medium text-slate-600 dark:text-slate-400">提取结果</div>
{#if testResult.extractedValue === undefined}
<div class="flex items-center gap-2 text-amber-600 dark:text-amber-400">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span class="text-sm">未找到路径 "{responsePath}" 对应的值</span>
</div>
{:else}
<pre class="overflow-x-auto bg-slate-100 p-2 text-xs text-slate-700 dark:bg-slate-900 dark:text-slate-300">{formatTestValue(testResult.extractedValue)}</pre>
{/if}
</div>
<!-- Response Preview (collapsible) -->
<details class="group">
<summary class="cursor-pointer text-sm font-medium text-slate-600 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200">
原始响应 JSON
</summary>
<pre class="mt-2 max-h-48 overflow-auto bg-slate-100 p-3 text-xs text-slate-700 dark:bg-slate-900 dark:text-slate-300">{JSON.stringify(testResult.response, null, 2)}</pre>
</details>
<!-- Debug Info (collapsible) -->
{#if testResult.debug}
<details class="group" open>
<summary class="cursor-pointer text-sm font-medium text-indigo-600 hover:text-indigo-800 dark:text-indigo-400 dark:hover:text-indigo-300">
实际请求详情 (调试)
</summary>
<div class="mt-2 space-y-2 rounded-md bg-indigo-50 p-3 dark:bg-indigo-900/20">
<div>
<div class="text-xs font-medium text-indigo-700 dark:text-indigo-300">请求 URL</div>
<pre class="mt-1 break-all bg-indigo-100 p-2 text-xs text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200">{testResult.debug.proxiedUrl}</pre>
</div>
<div>
<div class="text-xs font-medium text-indigo-700 dark:text-indigo-300">请求方法</div>
<div class="mt-1 bg-indigo-100 p-2 text-xs text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200">{testResult.debug.proxiedMethod}</div>
</div>
<div>
<div class="text-xs font-medium text-indigo-700 dark:text-indigo-300">请求 Headers</div>
<pre class="mt-1 bg-indigo-100 p-2 text-xs text-indigo-800 dark:bg-indigo-900/40 dark:text-indigo-200">{JSON.stringify(testResult.debug.proxiedHeaders, null, 2)}</pre>
</div>
</div>
</details>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
</div>
<div class="flex justify-end gap-2 border-t border-slate-200 px-6 py-4 dark:border-slate-800">
<button
onclick={onclose}
class="cursor-pointer rounded-md border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
取消
</button>
<button
onclick={save}
disabled={!isValid}
class="cursor-pointer rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
保存
</button>
</div>
</div>
</div>

View File

@@ -1,7 +1,16 @@
<script lang="ts"> <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 }[] = [ const dataTypes: { value: DataType; label: string }[] = [
{ value: 'string', label: '字符串 (String)' }, { value: 'string', label: '字符串 (String)' },
@@ -19,6 +28,19 @@
{ value: 'timestamp', label: 'Unix Timestamp' } { 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) { function onTypeChange(e: Event) {
const select = e.target as HTMLSelectElement; const select = e.target as HTMLSelectElement;
config.type = select.value as DataType; config.type = select.value as DataType;
@@ -51,10 +73,17 @@
}); });
</script> </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-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-900"
>
<div class="mb-3 flex items-center justify-between"> <div class="mb-3 flex items-center justify-between">
<h4 class="text-sm font-semibold text-gray-700">列配置</h4> <h4 class="text-sm font-semibold text-slate-700 dark:text-slate-200">列配置</h4>
<button onclick={onclose} aria-label="关闭配置" class="text-gray-400 hover:text-gray-600 cursor-pointer"> <button
onclick={onclose}
aria-label="关闭配置"
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 cursor-pointer"
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@@ -64,23 +93,42 @@
<div class="space-y-3"> <div class="space-y-3">
<!-- Source header (read-only) --> <!-- Source header (read-only) -->
<div> <div>
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">源字段</label> <label for="source-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
<input id="source-{config.source}" type="text" value={config.source} disabled >源字段</label
class="w-full rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-500" /> >
<input
id="source-{config.source}"
type="text"
value={config.source}
disabled
class="w-full rounded border border-slate-200 bg-slate-50 px-2 py-1.5 text-sm text-slate-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-400"
/>
</div> </div>
<!-- Target key --> <!-- Target key -->
<div> <div>
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">目标字段 (JSON Key)</label> <label for="target-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
<input id="target-{config.source}" type="text" bind:value={config.target} >目标字段 (JSON Key)</label
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" /> >
<input
id="target-{config.source}"
type="text"
bind:value={config.target}
class="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
/>
</div> </div>
<!-- Data type --> <!-- Data type -->
<div> <div>
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">数据类型</label> <label for="type-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
<select id="type-{config.source}" value={config.type} onchange={onTypeChange} >数据类型</label
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"> >
<select
id="type-{config.source}"
value={config.type}
onchange={onTypeChange}
class="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
>
{#each dataTypes as dt (dt.value)} {#each dataTypes as dt (dt.value)}
<option value={dt.value}>{dt.label}</option> <option value={dt.value}>{dt.label}</option>
{/each} {/each}
@@ -90,9 +138,14 @@
<!-- Date format (only for date type) --> <!-- Date format (only for date type) -->
{#if config.type === 'date'} {#if config.type === 'date'}
<div> <div>
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">日期格式</label> <label for="format-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
<select id="format-{config.source}" bind:value={config.format} >日期格式</label
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"> >
<select
id="format-{config.source}"
bind:value={config.format}
class="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
>
{#each dateFormats as df (df.value)} {#each dateFormats as df (df.value)}
<option value={df.value}>{df.label}</option> <option value={df.value}>{df.label}</option>
{/each} {/each}
@@ -100,28 +153,78 @@
</div> </div>
{/if} {/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 --> <!-- Exclude if empty -->
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input type="checkbox" id="exclude-empty-{config.source}" bind:checked={config.excludeIfEmpty} <input
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> type="checkbox"
<label for="exclude-empty-{config.source}" class="text-sm text-gray-600">空值时移除该字段</label> id="exclude-empty-{config.source}"
bind:checked={config.excludeIfEmpty}
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-700 dark:text-indigo-500 dark:focus:ring-indigo-400"
/>
<label for="exclude-empty-{config.source}" class="text-sm text-slate-600 dark:text-slate-400"
>空值时移除该字段</label
>
</div> </div>
<!-- Default value --> <!-- Default value -->
{#if !config.excludeIfEmpty} {#if !config.excludeIfEmpty}
<div> <div>
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-gray-500">默认值 (空值时)</label> <label for="default-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
<input id="default-{config.source}" type="text" bind:value={config.defaultValue} >默认值 (空值时)</label
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" /> <input
id="default-{config.source}"
type="text"
bind:value={config.defaultValue}
class="w-full rounded border border-slate-300 bg-white px-2 py-1.5 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
placeholder="留空则为 null"
/>
</div> </div>
{/if} {/if}
<!-- Enable/disable column --> <!-- Enable/disable column -->
<div class="flex items-center gap-2 border-t border-gray-100 pt-3"> <div class="flex items-center gap-2 border-t border-slate-100 pt-3 dark:border-slate-800">
<input type="checkbox" id="enabled-{config.source}" bind:checked={config.enabled} <input
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" /> type="checkbox"
<label for="enabled-{config.source}" class="text-sm text-gray-600">包含此列到输出</label> id="enabled-{config.source}"
bind:checked={config.enabled}
class="rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-700 dark:text-indigo-500 dark:focus:ring-indigo-400"
/>
<label for="enabled-{config.source}" class="text-sm text-slate-600 dark:text-slate-400">包含此列到输出</label>
</div> </div>
</div> </div>
</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-slate-100 pb-1.5 dark:border-slate-800">
<span class="text-xs font-semibold text-slate-700 dark:text-slate-300">字典映射</span>
{#if !config.useDictionary}
<button
onclick={enableDictionary}
class="rounded bg-indigo-600 px-2 py-0.5 text-xs text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
启用
</button>
{:else}
<button
onclick={disableDictionary}
class="rounded border border-slate-300 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
>
禁用
</button>
{/if}
</div>
{#if config.useDictionary}
<!-- Auto Scan Button -->
<button
onclick={scanColumnValues}
class="w-full rounded border border-indigo-600 bg-indigo-50 px-2 py-1 text-xs text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-900/30 dark:text-indigo-400 dark:hover:bg-indigo-900/50"
>
🔄 自动扫描列值
</button>
<!-- Mapping Table -->
{#if config.valueMapping.length > 0}
<div class="max-h-32 overflow-y-auto rounded border border-slate-200 dark:border-slate-700">
<table class="w-full text-xs">
<thead class="bg-slate-50 sticky top-0 dark:bg-slate-900">
<tr>
<th class="px-1.5 py-1 text-left font-medium text-slate-600 text-[10px] dark:text-slate-400"></th>
<th class="px-1.5 py-1 text-left font-medium text-slate-600 text-[10px] dark:text-slate-400">目标</th>
<th class="w-6"></th>
</tr>
</thead>
<tbody>
{#each config.valueMapping as item, index (index)}
<tr class="border-t border-slate-100 dark:border-slate-800">
<td class="px-1.5 py-1 text-slate-700 truncate max-w-24 dark:text-slate-300" 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-slate-300 bg-white px-1 py-0.5 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
/>
</td>
<td class="px-0.5 py-1">
<button
onclick={() => removeMappingItem(index)}
class="text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 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-slate-400 text-center dark:text-slate-500">暂无映射项</p>
{/if}
<!-- Manual Add (compact) -->
<div class="flex gap-1">
<input
bind:value={newSourceValue}
type="text"
placeholder="源值"
class="w-20 rounded border border-slate-300 bg-white px-1.5 py-1 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
/>
<input
bind:value={newTargetValue}
type="text"
placeholder="目标"
class="flex-1 rounded border border-slate-300 bg-white px-1.5 py-1 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
/>
<button
onclick={addMappingItem}
disabled={!newSourceValue}
class="rounded border border-slate-300 px-2 py-1 text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-50 whitespace-nowrap dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
>
+
</button>
</div>
<!-- Fallback Strategy (compact) -->
<div class="flex items-center gap-2">
<span class="text-[10px] text-slate-500 whitespace-nowrap dark:text-slate-400">未匹配:</span>
<select
bind:value={config.mappingFallback}
class="flex-1 rounded border border-slate-300 bg-white px-1.5 py-1 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
>
{#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-slate-300 bg-white px-2 py-1 text-xs focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200"
/>
{/if}
{/if}
</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);
@@ -36,29 +44,80 @@
</script> </script>
<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-slate-200 bg-slate-50 px-4 py-2 dark:border-slate-800 dark:bg-slate-900">
<h3 class="text-sm font-semibold text-gray-700"> <div class="flex items-center justify-between">
Excel 数据 <h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">
<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-slate-400 dark:text-slate-500">
{#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 dark:bg-purple-900/30 dark:text-purple-400 dark:hover:bg-purple-900/50"
>
<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-slate-200 bg-purple-50/50 px-4 py-2 dark:border-slate-800 dark:bg-purple-900/10">
<span class="text-xs font-medium text-purple-600 dark:text-purple-400">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 dark:bg-purple-900/30 dark:text-purple-300">
{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-slate-50 dark:bg-slate-900">
<tr> <tr>
{#each headers as header, i (header)} {#each headers as header, i (header)}
<th class="relative border-b border-r border-gray-200 px-3 py-2 text-left font-medium text-gray-600"> <th class="relative border-b border-r border-slate-200 px-3 py-2 text-left font-medium text-slate-600 dark:border-slate-800 dark:text-slate-400">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="truncate" title={header}>{header}</span> <span class="truncate" title={header}>{header}</span>
<button <button
onclick={() => toggleConfig(i)} onclick={() => toggleConfig(i)}
class="ml-auto flex-shrink-0 rounded p-0.5 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer" class="ml-auto flex-shrink-0 rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 cursor-pointer dark:hover:bg-slate-700 dark:hover:text-slate-300"
title="配置此列" title="配置此列"
> >
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -68,24 +127,41 @@
</svg> </svg>
</button> </button>
{#if mappings[i] && mappings[i].target !== mappings[i].source} {#if mappings[i] && mappings[i].target !== mappings[i].source}
<span class="text-xs text-blue-500" title="映射为: {mappings[i].target}">{mappings[i].target}</span> <span class="text-xs text-indigo-600 dark:text-indigo-400" title="映射为: {mappings[i].target}">{mappings[i].target}</span>
{/if} {/if}
</div> </div>
{#if activeConfigIndex === i && mappings[i]} {#if activeConfigIndex === i && mappings[i]}
<ColumnConfig bind:config={mappings[i]} onclose={() => (activeConfigIndex = null)} /> <ColumnConfig bind:config={mappings[i]} {rows} onclose={() => (activeConfigIndex = null)} />
{/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 dark:border-purple-800/50 dark:bg-purple-900/20 dark:text-purple-400">
<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>
{#each rows.slice(0, maxPreviewRows) as row, rowIdx (rowIdx)} {#each rows.slice(0, maxPreviewRows) as row, rowIdx (rowIdx)}
<tr class={rowIdx % 2 === 0 ? 'bg-white' : 'bg-gray-50/50'}> <tr class="bg-white even:bg-slate-50/50 dark:bg-slate-950 dark:even:bg-slate-900/50">
{#each headers as header (header)} {#each headers as header (header)}
<td class="border-b border-r border-gray-100 px-3 py-1.5 text-gray-700" title={displayValue(row[header])}> <td class="border-b border-r border-slate-200 px-3 py-1.5 text-slate-700 dark:border-slate-800 dark:text-slate-300" title={displayValue(row[header])}>
<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 dark:border-purple-900/30 dark:bg-purple-900/10 dark:text-purple-500">
[Pending API Fetch]
</td>
{/each}
</tr> </tr>
{/each} {/each}
</tbody> </tbody>

View File

@@ -1,21 +1,35 @@
<script lang="ts"> <script lang="ts">
import JsonTreeNode from './JsonTreeNode.svelte'; import JsonTreeNode from './JsonTreeNode.svelte';
import { proxyFetch, renderTemplate, extractByPath } from '$lib/utils/proxy.js';
import type { ApiEnrichmentRule } from '$lib/types.js';
let { json, onupdate }: { let { json, enrichmentRules = [], sourceData = [], onupdate }: {
json: Record<string, unknown>[]; json: Record<string, unknown>[];
enrichmentRules?: ApiEnrichmentRule[];
sourceData?: Record<string, unknown>[];
onupdate?: (edited: string) => void; onupdate?: (edited: string) => void;
} = $props(); } = $props();
const MAX_PREVIEW_CHARS = 2000; const MAX_PREVIEW_CHARS = 2000;
const MAX_PREVIEW_LINES = 50; const MAX_PREVIEW_LINES = 50;
const MAX_TREE_ITEMS = 50; const MAX_TREE_ITEMS = 50;
const ENRICH_PREVIEW_LIMIT = 5;
type ViewMode = 'tree' | 'raw' | 'edit'; type ViewMode = 'tree' | 'raw' | 'edit';
let viewMode = $state<ViewMode>('tree'); let viewMode = $state<ViewMode>('tree');
let editText = $state(''); let editText = $state('');
let parseError = $state(''); let parseError = $state('');
const jsonString = $derived(JSON.stringify(json, null, 2)); // Enrichment preview state
let enrichPreviewEnabled = $state(false);
let isEnriching = $state(false);
let enrichedJson = $state<Record<string, unknown>[]>([]);
let enrichErrors = $state<Set<number>>(new Set());
// Use enriched data when preview is enabled
const displayJson = $derived(enrichPreviewEnabled ? enrichedJson : json);
const jsonString = $derived(JSON.stringify(displayJson, null, 2));
// Truncation for raw preview // Truncation for raw preview
const truncated = $derived.by(() => { const truncated = $derived.by(() => {
@@ -31,8 +45,11 @@
}); });
// Tree view shows limited items for performance // Tree view shows limited items for performance
const treeData = $derived(json.length > MAX_TREE_ITEMS ? json.slice(0, MAX_TREE_ITEMS) : json); const treeData = $derived(displayJson.length > MAX_TREE_ITEMS ? displayJson.slice(0, MAX_TREE_ITEMS) : displayJson);
const treeTruncated = $derived(json.length > MAX_TREE_ITEMS); const treeTruncated = $derived(displayJson.length > MAX_TREE_ITEMS);
// Check if enrichment preview is available
const canEnrich = $derived(enrichmentRules.length > 0 && sourceData.length > 0);
// Sync from upstream when not editing // Sync from upstream when not editing
$effect(() => { $effect(() => {
@@ -41,6 +58,105 @@
} }
}); });
// Reset enriched data when base json changes
$effect(() => {
if (!enrichPreviewEnabled) {
enrichedJson = [];
enrichErrors = new Set();
}
});
async function fetchEnrichmentForRow(rowData: Record<string, unknown>, rules: ApiEnrichmentRule[]): Promise<Record<string, unknown>> {
const enriched: Record<string, unknown> = { ...rowData };
for (const rule of rules) {
try {
// Render URL template
const renderedUrl = renderTemplate(rule.url_template, rowData);
if (!renderedUrl) {
enriched[rule.target_key] = rule.fallback_value ?? null;
continue;
}
// Prepare headers with template variable replacement
const headers: Record<string, string> = {};
if (rule.headers) {
for (const [key, value] of Object.entries(rule.headers)) {
headers[key] = renderTemplate(value, rowData);
}
}
// Prepare body for POST
let body: unknown = undefined;
if (rule.method === 'POST' && rule.body_template) {
const renderedBody = renderTemplate(rule.body_template, rowData);
try {
body = JSON.parse(renderedBody);
} catch {
body = renderedBody;
}
}
// Make proxy request
const response = await proxyFetch({
url: renderedUrl,
method: rule.method,
headers,
body
});
// Extract value using response path
const extractedValue = extractByPath(response.data, rule.response_path);
enriched[rule.target_key] = extractedValue ?? rule.fallback_value ?? null;
} catch {
enriched[rule.target_key] = rule.fallback_value ?? null;
}
}
return enriched;
}
async function runEnrichmentPreview() {
if (!canEnrich || isEnriching) return;
isEnriching = true;
enrichErrors = new Set();
const limit = Math.min(ENRICH_PREVIEW_LIMIT, sourceData.length);
const results: Record<string, unknown>[] = [];
// Process with concurrency limit of 3
const CONCURRENCY = 3;
for (let i = 0; i < limit; i += CONCURRENCY) {
const batch = sourceData.slice(i, i + CONCURRENCY);
const promises = batch.map(async (rowData, idx) => {
try {
return await fetchEnrichmentForRow(rowData, enrichmentRules);
} catch {
enrichErrors.add(i + idx);
return { ...rowData };
}
});
const batchResults = await Promise.all(promises);
results.push(...batchResults);
}
enrichedJson = results;
isEnriching = false;
}
function toggleEnrichPreview() {
if (enrichPreviewEnabled) {
enrichPreviewEnabled = false;
enrichedJson = [];
enrichErrors = new Set();
} else {
enrichPreviewEnabled = true;
runEnrichmentPreview();
}
}
function highlight(str: string): string { function highlight(str: string): string {
return str return str
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@@ -49,7 +165,7 @@
.replace(/"([^"]*)"(?=\s*:)/g, '<span class="json-key">"$1"</span>') .replace(/"([^"]*)"(?=\s*:)/g, '<span class="json-key">"$1"</span>')
.replace(/:\s*"([^"]*)"/g, ': <span class="json-string">"$1"</span>') .replace(/:\s*"([^"]*)"/g, ': <span class="json-string">"$1"</span>')
.replace(/:\s*(\d+\.?\d*)/g, ': <span class="json-number">$1</span>') .replace(/:\s*(\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
.replace(/:\s*(true|false)/g, ': <span class="json-bool">$1</span>') .replace(/:\s*(true|false)/g, ': <span class="json-boolean">$1</span>')
.replace(/:\s*(null)/g, ': <span class="json-null">$1</span>'); .replace(/:\s*(null)/g, ': <span class="json-null">$1</span>');
} }
@@ -79,38 +195,66 @@
<div class="flex h-full flex-col overflow-hidden"> <div class="flex h-full flex-col overflow-hidden">
<!-- Header --> <!-- Header -->
<div class="flex flex-shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2"> <div class="shrink-0 flex items-center justify-between border-b border-slate-200 bg-slate-50 px-4 py-2 dark:border-slate-800 dark:bg-slate-900">
<h3 class="text-sm font-semibold text-gray-700"> <div class="flex items-center gap-3">
JSON <h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">
<span class="ml-1 text-xs font-normal text-gray-400">{json.length}</span> JSON
</h3> <span class="ml-1 text-xs font-normal text-slate-400 dark:text-slate-500">{displayJson.length}</span>
</h3>
<!-- Enrichment Preview Toggle -->
{#if canEnrich}
<button
onclick={toggleEnrichPreview}
disabled={isEnriching}
class="flex items-center gap-1.5 rounded px-2 py-1 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 {enrichPreviewEnabled
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-slate-200 text-slate-600 hover:bg-slate-300 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700'}"
title="仅预览前 {ENRICH_PREVIEW_LIMIT} 条数据的 API 增强结果"
>
{#if isEnriching}
<svg class="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
加载中...
{:else}
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>实时预览</span>
{/if}
</button>
{/if}
</div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
{#if viewMode === 'edit'} {#if viewMode === 'edit'}
<button onclick={applyEdit} class="rounded bg-blue-600 px-2 py-1 text-xs text-white hover:bg-blue-700 cursor-pointer"> <button onclick={applyEdit} class="rounded bg-indigo-600 px-2 py-1 text-xs text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400 cursor-pointer">
应用 应用
</button> </button>
<button onclick={cancelEdit} class="rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer"> <button onclick={cancelEdit} class="rounded border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-200 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 cursor-pointer">
取消 取消
</button> </button>
{:else} {:else}
<!-- View mode tabs --> <!-- View mode tabs -->
<div class="flex rounded border border-gray-300 text-xs"> <div class="flex rounded border border-slate-300 text-xs dark:border-slate-700">
<button <button
onclick={() => (viewMode = 'tree')} onclick={() => (viewMode = 'tree')}
class="px-2 py-1 cursor-pointer {viewMode === 'tree' ? 'bg-gray-200 text-gray-800 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'}" class="px-2 py-1 cursor-pointer {viewMode === 'tree' ? 'bg-slate-200 text-slate-800 font-medium dark:bg-slate-700 dark:text-slate-200' : 'bg-white text-slate-500 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800'}"
style="border-radius: 3px 0 0 3px" style="border-radius: 3px 0 0 3px"
> >
树形 树形
</button> </button>
<button <button
onclick={() => (viewMode = 'raw')} onclick={() => (viewMode = 'raw')}
class="border-l border-gray-300 px-2 py-1 cursor-pointer {viewMode === 'raw' ? 'bg-gray-200 text-gray-800 font-medium' : 'bg-white text-gray-500 hover:bg-gray-50'}" class="border-l border-slate-300 px-2 py-1 cursor-pointer {viewMode === 'raw' ? 'bg-slate-200 text-slate-800 font-medium dark:bg-slate-700 dark:text-slate-200' : 'bg-white text-slate-500 hover:bg-slate-50 dark:bg-slate-900 dark:text-slate-400 dark:hover:bg-slate-800'}"
style="border-radius: 0 3px 3px 0" style="border-radius: 0 3px 3px 0"
> >
源码 源码
</button> </button>
</div> </div>
<button onclick={startEdit} class="ml-1 rounded border border-gray-300 px-2 py-1 text-xs text-gray-500 hover:bg-gray-200 cursor-pointer"> <button onclick={startEdit} class="ml-1 rounded border border-slate-300 px-2 py-1 text-xs text-slate-500 hover:bg-slate-200 cursor-pointer dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800">
编辑 编辑
</button> </button>
{/if} {/if}
@@ -118,7 +262,14 @@
</div> </div>
{#if parseError} {#if parseError}
<div class="flex-shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600">{parseError}</div> <div class="shrink-0 bg-red-50 px-4 py-1.5 text-xs text-red-600 dark:bg-red-900/20 dark:text-red-400">{parseError}</div>
{/if}
<!-- Enrichment warning -->
{#if enrichPreviewEnabled}
<div class="shrink-0 border-b border-amber-200 bg-amber-50 px-4 py-1.5 text-xs text-amber-700 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-400">
<span class="font-medium">实时预览模式</span> — 仅展示前 {ENRICH_PREVIEW_LIMIT} 条数据的 API 增强结果,用于测试配置。导出时将处理全部数据。
</div>
{/if} {/if}
<!-- Content --> <!-- Content -->
@@ -128,22 +279,22 @@
bind:value={editText} bind:value={editText}
oninput={() => { parseError = ''; }} oninput={() => { parseError = ''; }}
spellcheck="false" spellcheck="false"
class="json-textarea h-full w-full resize-none overflow-auto border-0 bg-transparent font-mono text-xs leading-relaxed text-gray-700 outline-none" class="json-textarea h-full w-full resize-none overflow-auto border-0 bg-transparent font-mono text-xs leading-relaxed text-slate-700 outline-none dark:text-slate-300"
></textarea> ></textarea>
{:else if viewMode === 'tree'} {:else if viewMode === 'tree'}
<div class="h-full overflow-auto px-4 py-3"> <div class="h-full overflow-auto px-4 py-3">
<JsonTreeNode value={treeData} defaultOpen={true} /> <JsonTreeNode value={treeData} defaultOpen={true} />
{#if treeTruncated} {#if treeTruncated}
<div class="mt-2 border-t border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700"> <div class="mt-2 border-t border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-400">
&#x26A0;&#xFE0F; 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {json.length.toLocaleString()} 条)。请下载或复制查看完整数据。 &#x26A0;&#xFE0F; 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {displayJson.length.toLocaleString()} 条)。请下载或复制查看完整数据。
</div> </div>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="h-full overflow-auto"> <div class="h-full overflow-auto">
<pre class="json-content whitespace-pre font-mono text-xs leading-relaxed text-gray-500">{@html highlight(truncated.text)}</pre> <pre class="json-content whitespace-pre font-mono text-xs leading-relaxed text-slate-500 dark:text-slate-400">{@html highlight(truncated.text)}</pre>
{#if truncated.isTruncated} {#if truncated.isTruncated}
<div class="sticky bottom-0 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700"> <div class="sticky bottom-0 border-t border-amber-200 bg-amber-50 px-4 py-2 text-xs text-amber-700 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-400">
&#x26A0;&#xFE0F; 预览已截断(显示 {Math.min(MAX_PREVIEW_CHARS, truncated.text.length).toLocaleString()}/{truncated.totalChars.toLocaleString()} 字符,{Math.min(MAX_PREVIEW_LINES, truncated.totalLines)}/{truncated.totalLines.toLocaleString()} 行)。请下载或复制查看完整数据。 &#x26A0;&#xFE0F; 预览已截断(显示 {Math.min(MAX_PREVIEW_CHARS, truncated.text.length).toLocaleString()}/{truncated.totalChars.toLocaleString()} 字符,{Math.min(MAX_PREVIEW_LINES, truncated.totalLines)}/{truncated.totalLines.toLocaleString()} 行)。请下载或复制查看完整数据。
</div> </div>
{/if} {/if}
@@ -157,6 +308,9 @@
background: #fafbfc; background: #fafbfc;
content-visibility: auto; content-visibility: auto;
} }
:global(.dark) .json-preview {
background: #0d1117;
}
.json-content { .json-content {
padding: 16px; padding: 16px;
margin: 0; margin: 0;
@@ -166,21 +320,4 @@
padding: 16px; padding: 16px;
tab-size: 2; tab-size: 2;
} }
:global(.json-key) {
color: #24292e;
font-weight: 500;
}
:global(.json-string) {
color: #22863a;
}
:global(.json-number) {
color: #005cc5;
}
:global(.json-bool) {
color: #d73a49;
}
:global(.json-null) {
color: #9ca3af;
font-style: italic;
}
</style> </style>

View File

@@ -13,6 +13,7 @@
defaultOpen?: boolean; defaultOpen?: boolean;
} = $props(); } = $props();
// svelte-ignore state_referenced_locally — intentionally using initial value only
let open = $state(defaultOpen); let open = $state(defaultOpen);
const valueType = $derived( const valueType = $derived(

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 dark:bg-black/60" 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 border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-700 dark:bg-slate-800" onclick={(e) => e.stopPropagation()}>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-slate-800 dark:text-slate-200">提交设置</h3>
<button onclick={onclose} aria-label="关闭" class="cursor-pointer text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300">
<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-slate-700 dark:text-slate-300">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-slate-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
</div>
<div>
<label for="method" class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Method</label>
<select
id="method"
bind:value={localConfig.method}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
>
<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-slate-700 dark:text-slate-300">Batch Size</label>
<input
id="batch-size"
type="number"
bind:value={localConfig.batch_size}
min="1"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 dark:border-slate-700 dark:bg-slate-950 dark:text-slate-200 dark:focus:border-indigo-400 dark:focus:ring-indigo-400"
/>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
onclick={onclose}
class="cursor-pointer rounded-md border border-slate-300 px-4 py-2 text-sm text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
>
取消
</button>
<button
onclick={save}
class="cursor-pointer rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400"
>
保存
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import { themeStore, type Theme } from '$lib/stores/themeStore.svelte';
let isOpen = $state(false);
let selectedTheme = $derived(themeStore.theme);
const themes: { value: Theme; icon: string; label: string }[] = [
{ value: 'light', icon: '☀️', label: '浅色' },
{ value: 'dark', icon: '🌙', label: '深色' },
{ value: 'system', icon: '💻', label: '跟随系统' }
];
function setTheme(theme: Theme) {
themeStore.setTheme(theme);
isOpen = false;
}
function toggleDropdown(e: Event) {
e.stopPropagation();
isOpen = !isOpen;
}
// Close dropdown when clicking outside
$effect(() => {
if (isOpen) {
const handler = () => (isOpen = false);
document.addEventListener('click', handler);
return () => document.removeEventListener('click', handler);
}
});
</script>
<div class="relative">
<!-- Toggle Button -->
<button
onclick={toggleDropdown}
class="flex h-9 w-9 items-center justify-center rounded-lg border border-slate-200 bg-white text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100 transition-colors"
title="切换主题"
aria-label="切换主题"
>
<span class="text-lg">{themes.find((t) => t.value === selectedTheme)?.icon}</span>
</button>
<!-- Theme Dropdown -->
{#if isOpen}
<div class="absolute right-0 top-full z-50 mt-1 w-36 rounded-lg border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800">
{#each themes as theme}
<button
onclick={() => setTheme(theme.value)}
class="flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors"
class:text-slate-700={selectedTheme !== theme.value}
class:text-slate-900={selectedTheme === theme.value}
class:hover:bg-slate-50={selectedTheme !== theme.value}
class:bg-slate-50={selectedTheme === theme.value}
class:dark:text-slate-300={selectedTheme !== theme.value}
class:dark:text-slate-100={selectedTheme === theme.value}
class:dark:hover:bg-slate-700={selectedTheme !== theme.value}
class:dark:bg-slate-700={selectedTheme === theme.value}
>
<span class="text-base">{theme.icon}</span>
<span>{theme.label}</span>
{#if selectedTheme === theme.value}
<span class="ml-auto text-indigo-600 dark:text-indigo-400"></span>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View File

@@ -1,5 +1,16 @@
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,
ValueMapItem,
MappingFallback
} from './types.js';
/** /**
* Check if a value is considered empty. * Check if a value is considered empty.
@@ -8,31 +19,107 @@ function isEmpty(value: unknown): boolean {
return value === undefined || value === null || value === ''; 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. * 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; 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) { switch (type) {
case 'number': { case 'number': {
const num = Number(value); const num = Number(processedValue);
return isNaN(num) ? value : num; return isNaN(num) ? processedValue : num;
} }
case 'boolean': { case 'boolean': {
if (typeof value === 'boolean') return value; if (typeof processedValue === 'boolean') return processedValue;
const str = String(value).toLowerCase().trim(); const str = String(processedValue).toLowerCase().trim();
if (['true', '1', 'yes', '是'].includes(str)) return true; if (['true', '1', 'yes', '是'].includes(str)) return true;
if (['false', '0', 'no', '否'].includes(str)) return false; if (['false', '0', 'no', '否'].includes(str)) return false;
return Boolean(value); return Boolean(processedValue);
} }
case 'date': { case 'date': {
return formatDate(value, format); return formatDate(processedValue, format);
} }
case 'string': case 'string':
default: default:
if (value instanceof Date) return dayjs(value).format('YYYY-MM-DD HH:mm:ss'); if (processedValue instanceof Date) return dayjs(processedValue).format('YYYY-MM-DD HH:mm:ss');
return String(value); return String(processedValue);
} }
} }
@@ -110,9 +197,29 @@ export function convertData(
const finalValue = isEmptyVal const finalValue = isEmptyVal
? (mapping.defaultValue !== undefined && mapping.defaultValue !== '' ? (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) : 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); setNested(obj, mapping.target, finalValue);
} }
@@ -143,6 +250,27 @@ export function createDefaultMappings(headers: string[], rows?: RowData[]): Mapp
/** /**
* Detect the data type of a column by sampling its values. * 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 { function detectColumnType(header: string, rows: RowData[]): DataType {
const sample = rows.slice(0, 20); const sample = rows.slice(0, 20);
let dateCount = 0; let dateCount = 0;
@@ -177,7 +305,12 @@ export function applyTemplate(
type: tmpl.type, type: tmpl.type,
format: tmpl.format, format: tmpl.format,
excludeIfEmpty: tmpl.excludeIfEmpty, 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; return mapping;
@@ -188,10 +321,68 @@ export function applyTemplate(
* Export current mappings as a template. * Export current mappings as a template.
*/ */
export function exportTemplate(mappings: MappingConfig[]): MappingTemplate { 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 }; const entry: MappingTemplate[number] = { source, target, type, excludeIfEmpty };
if (type === 'date' && format) entry.format = format; if (type === 'date' && format) entry.format = format;
if (defaultValue) entry.defaultValue = defaultValue; 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; 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;
// 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;
});
}
/**
* 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

@@ -0,0 +1,129 @@
/**
* Theme type definition
*/
export type Theme = 'light' | 'dark' | 'system';
/**
* Get the system theme preference
*/
function getSystemTheme(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Get the effective theme (resolving 'system' to actual theme)
*/
function getEffectiveTheme(theme: Theme): 'light' | 'dark' {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
}
/**
* Theme state management
*/
class ThemeStore {
theme = $state<Theme>('system');
private listeners: Set<(theme: 'light' | 'dark') => void> = new Set();
private mediaQuery: MediaQueryList | null = null;
constructor() {
// Load from localStorage
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme') as Theme | null;
if (stored && (stored === 'light' || stored === 'dark' || stored === 'system')) {
this.theme = stored;
}
// Listen for system theme changes
this.mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.mediaQuery.addEventListener('change', () => {
if (this.theme === 'system') {
this.notify();
}
});
}
}
/**
* Get the current effective theme (light or dark)
*/
get currentTheme(): 'light' | 'dark' {
return getEffectiveTheme(this.theme);
}
/**
* Set the theme
*/
setTheme(theme: Theme) {
this.theme = theme;
if (typeof window !== 'undefined') {
localStorage.setItem('theme', theme);
}
this.notify();
this.applyTheme();
}
/**
* Toggle between light and dark
*/
toggle() {
const effective = this.currentTheme;
this.setTheme(effective === 'light' ? 'dark' : 'light');
}
/**
* Subscribe to theme changes
*/
subscribe(callback: (theme: 'light' | 'dark') => void): () => void {
this.listeners.add(callback);
callback(this.currentTheme);
return () => {
this.listeners.delete(callback);
};
}
private notify() {
const effective = this.currentTheme;
this.listeners.forEach((callback) => callback(effective));
}
/**
* Apply theme to document
*/
applyTheme() {
if (typeof document === 'undefined') return;
const effective = this.currentTheme;
const root = document.documentElement;
if (effective === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}
/**
* Initialize theme on app load
*/
init() {
// Apply theme immediately without transition
if (typeof document !== 'undefined') {
document.documentElement.classList.add('no-transition');
this.applyTheme();
// Force reflow
document.documentElement.offsetHeight;
// Remove no-transition after a frame
requestAnimationFrame(() => {
document.documentElement.classList.remove('no-transition');
});
}
}
}
// Export singleton instance
export const themeStore = new ThemeStore();

View File

@@ -1,5 +1,14 @@
export type DataType = 'string' | 'number' | 'boolean' | 'date'; 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 = export type DateFormat =
| 'YYYY-MM-DD' | 'YYYY-MM-DD'
| 'YYYY/MM/DD' | 'YYYY/MM/DD'
@@ -23,6 +32,14 @@ export interface MappingConfig {
defaultValue?: string; defaultValue?: string;
/** Whether this column is included in output */ /** Whether this column is included in output */
enabled: boolean; 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 */ /** A single row of raw Excel data, keyed by original header */
@@ -31,5 +48,55 @@ export type RowData = Record<string, unknown>;
/** Template file structure for import/export */ /** Template file structure for import/export */
export type MappingTemplate = Pick< export type MappingTemplate = Pick<
MappingConfig, MappingConfig,
'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue' 'source' | 'target' | 'type' | 'format' | 'excludeIfEmpty' | 'defaultValue' | 'useDictionary' | 'valueMapping' | 'mappingFallback' | 'mappingCustomValue'
>[]; >[];
/** Static mapping rule in Job Bundle output */
export interface StaticRule {
type: 'static';
source: string;
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 */
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

@@ -0,0 +1,80 @@
/**
* Error message sanitization utility
* Prevents sensitive information leakage in error messages
*/
// Patterns that might indicate sensitive information
const SENSITIVE_PATTERNS = [
// Password/secret related
/password|passwd|pwd|secret|token|api[_-]?key|private[_-]?key|auth/i,
// File paths
/[a-z]:[\\/][^\s]*/i, // Windows paths
/\/(?:home|usr|var|etc|root)[\\/][^\s]*/i, // Unix paths
// Stack trace patterns
/\s+at\s+.*\s+\(\s*[^)]+\s*\)/,
/from\s+[^\/\s]+\/[^\/\s]+\.js/,
// Internal server details
/localhost|127\.0\.0\.1|0\.0\.0\.0|::1/i,
// Database connection strings
/mongodb|mysql|postgres|redis|sqlite[:+]/i,
/aws_access_key_id|aws_secret_access_key/i
];
/**
* Sanitizes an error message to prevent sensitive information leakage
*
* @param message - The raw error message
* @returns A sanitized error message safe to show to users
*/
export function sanitizeErrorMessage(message: string): string {
if (!message || typeof message !== 'string') {
return 'An error occurred';
}
let sanitized = message;
// Remove or mask sensitive patterns
for (const pattern of SENSITIVE_PATTERNS) {
sanitized = sanitized.replace(pattern, '[REDACTED]');
}
// Limit length to prevent excessive error messages
const MAX_LENGTH = 200;
if (sanitized.length > MAX_LENGTH) {
sanitized = sanitized.slice(0, MAX_LENGTH) + '...';
}
// Ensure we have a non-empty message
if (!sanitized.trim()) {
return 'An error occurred while processing your request';
}
return sanitized;
}
/**
* Creates a safe error object for API responses
*
* @param error - The original error
* @returns A sanitized error object
*/
export function createSafeError(error: unknown): { error: string; code?: string } {
if (error instanceof Error) {
return {
error: sanitizeErrorMessage(error.message),
code: error.name || 'ERROR'
};
}
if (typeof error === 'string') {
return {
error: sanitizeErrorMessage(error),
code: 'ERROR'
};
}
return {
error: 'An unexpected error occurred',
code: 'UNKNOWN_ERROR'
};
}

117
src/lib/utils/proxy.ts Normal file
View File

@@ -0,0 +1,117 @@
/**
* Proxy utility functions for API enrichment testing
* Handles template variable replacement and proxied fetch requests
*/
interface ProxyOptions {
url: string;
method: string;
headers: Record<string, string>;
body?: unknown;
}
interface ProxyResponse {
data: unknown;
status: number;
headers: Record<string, string>;
}
/**
* Makes a proxied API request through the SvelteKit backend
* This bypasses CORS restrictions and provides consistent error handling
*
* @param options - Request configuration
* @returns Promise with response data, status, and headers
* @throws Error if proxy request fails
*/
export async function proxyFetch(options: ProxyOptions): Promise<ProxyResponse> {
const response = await fetch('/api/proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(errorData.error || `Proxy Error: ${response.statusText}`);
}
return response.json();
}
/**
* Replaces template variables in a string with actual values
* Supports {{variable}} syntax for substitution
*
* @example
* ```ts
* renderTemplate("https://api.com/users/{{id}}", { id: 123 })
* // Returns: "https://api.com/users/123"
* renderTemplate("https://api.com/?name={{服务商}}", { 服务商: "Baidu" })
* // Returns: "https://api.com/?name=Baidu"
* ```
*
* @param template - String containing {{variable}} placeholders
* @param context - Object mapping variable names to values
* @returns String with all variables replaced
*/
export function renderTemplate(template: string, context: Record<string, unknown>): string {
return template.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => {
const value = context[key.trim()];
return value !== undefined && value !== null ? String(value) : '';
});
}
/**
* Safely extracts a value from a nested object using dot notation
* Returns undefined if the path cannot be resolved
*
* @example
* ```ts
* extractByPath({ data: { user: { name: "Alice" } } }, "data.user.name")
* // Returns: "Alice"
* ```
*
* @param obj - Object to extract from
* @param path - Dot-notation path (e.g., "data.user.name")
* @returns Extracted value or undefined
*/
export function extractByPath(obj: unknown, path: string): unknown {
if (!path) return obj;
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) {
return undefined;
}
if (typeof current !== 'object') {
return undefined;
}
current = (current as Record<string, unknown>)[key];
}
return current;
}
/**
* Validates if a string contains template variables
* @param str - String to check
* @returns true if string contains {{variable}} patterns
*/
export function hasTemplateVariables(str: string): boolean {
return /\{\{\s*\w+\s*\}\}/.test(str);
}
/**
* Extracts all variable names from a template string
* @param template - String containing {{variable}} placeholders
* @returns Array of unique variable names
*/
export function extractVariableNames(template: string): string[] {
const matches = template.match(/\{\{\s*(\w+)\s*\}\}/g);
if (!matches) return [];
return [...new Set(matches.map((m) => m.replace(/\{\{\s*|\s*\}\}/g, '')))];
}

View File

@@ -1,8 +1,14 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { themeStore } from '$lib/stores/themeStore.svelte';
let { children } = $props(); let { children } = $props();
onMount(() => {
themeStore.init();
});
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>

View File

@@ -1,9 +1,12 @@
<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';
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
// State // State
let headers = $state<string[]>([]); let headers = $state<string[]>([]);
@@ -14,6 +17,19 @@
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);
// First row data for API testing
let testSampleData = $state<Record<string, unknown>>({});
// Split panel // Split panel
let splitPercent = $state(50); let splitPercent = $state(50);
let jsonCollapsed = $state(false); let jsonCollapsed = $state(false);
@@ -45,33 +61,55 @@
// Derived // Derived
const hasData = $derived(headers.length > 0 && rows.length > 0); const hasData = $derived(headers.length > 0 && rows.length > 0);
// Update test sample data when rows change
$effect(() => {
if (rows.length > 0) {
testSampleData = { ...rows[0] };
} else {
testSampleData = {};
}
});
// Debounced conversion to avoid lag while editing mappings // Debounced conversion to avoid lag while editing mappings
let convertedJson = $state<Record<string, unknown>[]>([]); let convertedJson = $state<Record<string, unknown>[]>([]);
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,23 +217,60 @@
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
class="flex h-screen flex-col bg-gray-100" class="flex h-screen flex-col bg-slate-100 dark:bg-slate-950"
ondrop={onDrop} ondrop={onDrop}
ondragover={onDragOver} ondragover={onDragOver}
ondragleave={onDragLeave} ondragleave={onDragLeave}
role="application" role="application"
> >
<!-- Header Toolbar --> <!-- Header Toolbar -->
<header class="flex flex-shrink-0 items-center gap-3 border-b border-gray-200 bg-white px-4 py-3 shadow-sm"> <header class="flex flex-shrink-0 items-center gap-3 border-b border-slate-200 bg-white px-4 py-3 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<h1 class="text-lg font-bold text-gray-800">Excel → JSON</h1> <h1 class="text-lg font-bold text-slate-900 dark:text-slate-50">Excel → JSON</h1>
<div class="mx-2 h-6 w-px bg-gray-200"></div> <div class="mx-2 h-6 w-px bg-slate-200 dark:bg-slate-700"></div>
<!-- File upload --> <!-- File upload -->
<label class="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"> <label class="inline-flex cursor-pointer items-center gap-1.5 rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-400">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg> </svg>
@@ -204,16 +279,16 @@
</label> </label>
{#if fileName} {#if fileName}
<span class="text-sm text-gray-500">{fileName}</span> <span class="text-sm text-slate-500 dark:text-slate-400">{fileName}</span>
{/if} {/if}
<div class="mx-2 h-6 w-px bg-gray-200"></div> <div class="mx-2 h-6 w-px bg-slate-200 dark:bg-slate-700"></div>
<!-- Template operations --> <!-- Template operations -->
<button <button
onclick={handleImportTemplate} onclick={handleImportTemplate}
disabled={!hasData} 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" class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
@@ -223,7 +298,7 @@
<button <button
onclick={handleExportTemplate} onclick={handleExportTemplate}
disabled={!hasData} 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" class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
@@ -231,13 +306,42 @@
导出配置 导出配置
</button> </button>
<div class="mx-2 h-6 w-px bg-slate-200 dark:bg-slate-700"></div>
<!-- Submission Settings -->
<button
onclick={() => (showSubmissionSettings = true)}
disabled={!hasData}
class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
<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 dark:bg-emerald-500 dark:hover:bg-emerald-400"
>
<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-slate-200 dark:bg-slate-700"></div>
<!-- JSON operations --> <!-- JSON operations -->
<button <button
onclick={handleDownloadJson} onclick={handleDownloadJson}
disabled={!hasData} disabled={!hasData}
class="inline-flex items-center gap-1 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer" class="inline-flex items-center gap-1 rounded-md bg-green-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer dark:bg-green-500 dark:hover:bg-green-400"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
@@ -247,10 +351,10 @@
<button <button
onclick={handleCopyJson} onclick={handleCopyJson}
disabled={!hasData} 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" class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-white px-3 py-1.5 text-sm text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
> >
{#if copySuccess} {#if copySuccess}
<svg class="h-4 w-4 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg> </svg>
已复制 已复制
@@ -261,16 +365,32 @@
复制 JSON 复制 JSON
{/if} {/if}
</button> </button>
<!-- GitHub -->
<a
href="https://github.com/meowrain/Excel2JSON"
target="_blank"
rel="noopener noreferrer"
class="ml-1 inline-flex items-center rounded-md border border-slate-300 bg-white p-1.5 text-slate-600 hover:bg-slate-50 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-100"
title="GitHub"
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
</a>
<!-- Theme Toggle -->
<ThemeToggle />
</header> </header>
<!-- Error message --> <!-- Error message -->
{#if errorMessage} {#if errorMessage}
<div class="flex items-center gap-2 bg-red-50 px-4 py-2 text-sm text-red-700"> <div class="flex items-center gap-2 bg-red-50 px-4 py-2 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
<svg class="h-4 w-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="h-4 w-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
{errorMessage} {errorMessage}
<button onclick={() => (errorMessage = '')} aria-label="关闭错误" class="ml-auto text-red-500 hover:text-red-700 cursor-pointer"> <button onclick={() => (errorMessage = '')} aria-label="关闭错误" class="ml-auto text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 cursor-pointer">
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
@@ -289,17 +409,25 @@
> >
<!-- 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}
<!-- Drag handle --> <!-- Drag handle -->
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="group flex w-2 flex-shrink-0 cursor-col-resize items-center justify-center bg-gray-200 hover:bg-blue-300 active:bg-blue-400 transition-colors" class="group flex w-2 flex-shrink-0 cursor-col-resize items-center justify-center bg-slate-200 hover:bg-indigo-300 active:bg-indigo-400 transition-colors dark:bg-slate-800 dark:hover:bg-indigo-600 dark:active:bg-indigo-500"
onpointerdown={onSplitPointerDown} onpointerdown={onSplitPointerDown}
> >
<div class="h-8 w-0.5 rounded-full bg-gray-400 group-hover:bg-blue-500"></div> <div class="h-8 w-0.5 rounded-full bg-slate-400 group-hover:bg-indigo-500 dark:bg-slate-600 dark:group-hover:bg-indigo-400"></div>
</div> </div>
<!-- Right: JSON Preview --> <!-- Right: JSON Preview -->
@@ -307,27 +435,32 @@
<button <button
onclick={toggleJsonPanel} onclick={toggleJsonPanel}
aria-label="收起 JSON 面板" aria-label="收起 JSON 面板"
class="absolute top-2 right-2 z-10 rounded bg-white/80 p-1 text-gray-400 shadow hover:bg-white hover:text-gray-600 cursor-pointer" class="absolute top-2 right-2 z-10 rounded bg-white/80 p-1 text-slate-400 shadow hover:bg-white hover:text-slate-600 cursor-pointer dark:bg-slate-800/80 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M13 5l7 7-7 7M5 5l7 7-7 7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg> </svg>
</button> </button>
<JsonPreview json={convertedJson} onupdate={onJsonEdited} /> <JsonPreview
json={convertedJson}
{enrichmentRules}
sourceData={rows}
onupdate={onJsonEdited}
/>
</div> </div>
{:else} {:else}
<!-- Collapsed JSON mini-panel --> <!-- Collapsed JSON mini-panel -->
<div class="flex w-10 flex-shrink-0 flex-col items-center border-l border-gray-200 bg-gray-50 py-3"> <div class="flex w-10 flex-shrink-0 flex-col items-center border-l border-slate-200 bg-slate-50 py-3 dark:border-slate-800 dark:bg-slate-900">
<button <button
onclick={toggleJsonPanel} onclick={toggleJsonPanel}
aria-label="展开 JSON 面板" aria-label="展开 JSON 面板"
class="rounded p-1 text-gray-400 hover:bg-gray-200 hover:text-gray-600 cursor-pointer" class="rounded p-1 text-slate-400 hover:bg-slate-200 hover:text-slate-600 cursor-pointer dark:hover:bg-slate-800 dark:hover:text-slate-300"
> >
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M11 19l-7-7 7-7M19 19l-7-7 7-7" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7M19 19l-7-7 7-7" />
</svg> </svg>
</button> </button>
<span class="mt-2 text-xs text-gray-400" style="writing-mode: vertical-rl">JSON</span> <span class="mt-2 text-xs text-slate-400 dark:text-slate-500" style="writing-mode: vertical-rl">JSON</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -336,8 +469,8 @@
<div class="flex flex-1 items-center justify-center p-8"> <div class="flex flex-1 items-center justify-center p-8">
<div <div
class="flex max-w-md flex-col items-center rounded-2xl border-2 border-dashed p-12 text-center transition-colors {isDragOver class="flex max-w-md flex-col items-center rounded-2xl border-2 border-dashed p-12 text-center transition-colors {isDragOver
? 'border-blue-500 bg-blue-50' ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
: 'border-gray-300 bg-white'}" : 'border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-900'}"
> >
<svg class="mb-4 h-16 w-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="mb-4 h-16 w-16 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
@@ -356,3 +489,21 @@
</div> </div>
{/if} {/if}
</div> </div>
<!-- Modals -->
{#if showApiConfig}
<ApiConfigModal
rule={editingRuleIndex !== null ? enrichmentRules[editingRuleIndex] : undefined}
{headers}
testSample={testSampleData}
onsave={handleSaveApiRule}
onclose={() => { showApiConfig = false; editingRuleIndex = null; }}
/>
{/if}
{#if showSubmissionSettings}
<SubmissionSettings
bind:config={submissionConfig}
onclose={() => (showSubmissionSettings = false)}
/>
{/if}

View File

@@ -0,0 +1,193 @@
import { json, error } from '@sveltejs/kit';
import { sanitizeErrorMessage } from '$lib/utils/error-sanitizer.js';
interface ProxyRequest {
url: string;
method?: string;
headers?: Record<string, string>;
body?: unknown;
}
const PROXY_TIMEOUT_MS = 10000; // 10 seconds timeout
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB max response size
/**
* Sanitizes headers to prevent sensitive data leakage
*/
function sanitizeHeaders(headers: Headers): Record<string, string> {
const sanitized: Record<string, string> = {};
const safeHeaders = ['content-type', 'content-length', 'etag', 'last-modified', 'cache-control'];
for (const [key, value] of headers.entries()) {
const lowerKey = key.toLowerCase();
// Only include safe headers
if (safeHeaders.includes(lowerKey)) {
sanitized[key] = value;
}
}
return sanitized;
}
/**
* Validates URL to prevent SSRF attacks
*/
function validateUrl(url: string): { valid: boolean; error?: string } {
try {
const parsed = new URL(url);
// Only allow HTTP/HTTPS
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return { valid: false, error: 'Only HTTP/HTTPS protocols are allowed' };
}
// Block private/local network addresses (basic SSRF protection)
const hostname = parsed.hostname.toLowerCase();
const blockedHosts = [
'localhost',
'127.0.0.1',
'0.0.0.0',
'[::1]',
'169.254.169.254', // AWS metadata
'metadata.google.internal' // GCP metadata
];
if (blockedHosts.some((h) => hostname === h || hostname.endsWith('.' + h))) {
return { valid: false, error: 'Access to local/private addresses is not allowed' };
}
// Block private IP ranges
const privateIpPatterns = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[01])\./,
/^192\.168\./,
/^fc00:/i, // IPv6 private
/^fe80:/i // IPv6 link-local
];
if (privateIpPatterns.some((pattern) => pattern.test(hostname) || pattern.test(parsed.hostname))) {
return { valid: false, error: 'Access to private IP addresses is not allowed' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format' };
}
}
export async function POST({ request }) {
// Create abort controller for timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), PROXY_TIMEOUT_MS);
try {
const { url, method = 'GET', headers = {}, body }: ProxyRequest = await request.json();
// Debug logging
console.log('=== Proxy Request ===');
console.log(`URL: ${url}`);
console.log(`Method: ${method}`);
console.log('Headers:', JSON.stringify(headers, null, 2));
if (body !== undefined) {
console.log('Body:', JSON.stringify(body, null, 2));
}
console.log('====================');
// Basic validation
if (!url || typeof url !== 'string') {
return json({ error: 'Invalid URL' }, { status: 400 });
}
// URL validation with SSRF protection
const urlValidation = validateUrl(url);
if (!urlValidation.valid) {
return json({ error: urlValidation.error }, { status: 400 });
}
// Prepare fetch options
const fetchOptions: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
...headers
},
signal: controller.signal
};
// Add body for non-GET requests
if (method !== 'GET' && body !== undefined) {
fetchOptions.body = JSON.stringify(body);
}
// Make the proxied request
const response = await fetch(url, fetchOptions);
// Log response
console.log('=== Proxy Response ===');
console.log(`Status: ${response.status} ${response.statusText}`);
console.log('=====================');
// Check response size
const contentLength = response.headers.get('content-length');
if (contentLength && parseInt(contentLength) > MAX_RESPONSE_SIZE) {
return json({ error: 'Response too large' }, { status: 413 });
}
// Get response data with size limit
const contentType = response.headers.get('content-type') || '';
let responseData: unknown;
if (contentType.includes('application/json')) {
const text = await response.text();
if (text.length > MAX_RESPONSE_SIZE) {
return json({ error: 'Response too large' }, { status: 413 });
}
try {
responseData = JSON.parse(text);
} catch {
responseData = text;
}
} else {
const text = await response.text();
if (text.length > MAX_RESPONSE_SIZE) {
return json({ error: 'Response too large' }, { status: 413 });
}
responseData = text;
}
// Return response with same status, but sanitized headers
// Include debug info for development
return json(
{
data: responseData,
status: response.status,
headers: sanitizeHeaders(response.headers),
_debug: {
proxiedUrl: url,
proxiedMethod: method,
proxiedHeaders: headers
}
},
{ status: response.status }
);
} catch (err) {
// Handle timeout
if (err instanceof Error && err.name === 'AbortError') {
console.error('Proxy timeout');
return json(
{ error: 'Request timeout - the server took too long to respond (max 10 seconds)' },
{ status: 504 }
);
}
// Log error
console.error('Proxy error:', err);
// Sanitize error messages to prevent information leakage
const sanitizedError = err instanceof Error ? sanitizeErrorMessage(err.message) : 'Proxy request failed';
return json({ error: sanitizedError }, { status: 500 });
} finally {
clearTimeout(timeoutId);
}
}

View File

@@ -1,3 +1,73 @@
@import 'tailwindcss'; @import 'tailwindcss';
@plugin '@tailwindcss/forms'; @plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography'; @plugin '@tailwindcss/typography';
/* Dark mode configuration using class strategy */
@variant dark (&:where(.dark, .dark *));
/* Custom scrollbar styles */
@layer base {
/* Light mode scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: theme('colors.slate.300');
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: theme('colors.slate.400');
}
/* Dark mode scrollbar */
.dark ::-webkit-scrollbar-thumb {
background: theme('colors.slate.700');
}
.dark ::-webkit-scrollbar-thumb:hover {
background: theme('colors.slate.600');
}
/* Base transition for smooth theme switching */
* {
transition-property: color, background-color, border-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
/* Prevent transition on page load */
.no-transition,
.no-transition * {
transition: none !important;
}
}
/* Syntax highlighting for JSON preview */
@layer components {
.json-key {
@apply text-indigo-600 dark:text-indigo-400;
}
.json-string {
@apply text-emerald-600 dark:text-emerald-400;
}
.json-number {
@apply text-amber-600 dark:text-amber-400;
}
.json-boolean {
@apply text-violet-600 dark:text-violet-400;
}
.json-null {
@apply text-slate-500 dark:text-slate-500;
}
}

View File

@@ -1,37 +1,8 @@
import devtoolsJson from 'vite-plugin-devtools-json'; import devtoolsJson from 'vite-plugin-devtools-json';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vite';
import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit(), devtoolsJson()], plugins: [tailwindcss(), sveltekit(), devtoolsJson()]
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
}); });

32
vitest.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
export default defineConfig({
test: {
expect: { requireAssertions: true },
projects: [
{
extends: './vite.config.ts',
test: {
name: 'client',
browser: {
enabled: true,
provider: playwright(),
instances: [{ browser: 'chromium', headless: true }]
},
include: ['src/**/*.svelte.{test,spec}.{js,ts}'],
exclude: ['src/lib/server/**']
}
},
{
extends: './vite.config.ts',
test: {
name: 'server',
environment: 'node',
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['src/**/*.svelte.{test,spec}.{js,ts}']
}
}
]
}
});