Compare commits
1 Commits
main
...
feature/th
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e20e37cda |
@@ -9,7 +9,13 @@
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(npm run test:*)",
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(node --check:*)"
|
||||
"Bash(node --check:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(findstr:*)",
|
||||
"mcp__zai-mcp-server__analyze_image"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"c:\\Users\\meowr\\projects\\excel2json\\src\\lib"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
|
||||
163
demands/DEMAND-proxy.md
Normal file
163
demands/DEMAND-proxy.md
Normal 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` 的测试功能。**
|
||||
145
demands/DEMAND-样式.md
Normal file
145
demands/DEMAND-样式.md
Normal 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)。
|
||||
@@ -1,18 +1,22 @@
|
||||
<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
|
||||
@@ -30,12 +34,53 @@
|
||||
// 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') {
|
||||
@@ -65,6 +110,81 @@
|
||||
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;
|
||||
|
||||
@@ -90,7 +210,12 @@
|
||||
onsave(newRule);
|
||||
}
|
||||
|
||||
const isValid = $derived(targetKey.trim() !== '' && urlTemplate.trim() !== '' && responsePath.trim() !== '');
|
||||
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 -->
|
||||
@@ -98,141 +223,59 @@
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick={onclose} role="presentation">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-lg bg-white p-6 shadow-xl" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-800">
|
||||
<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-gray-400 hover:text-gray-600">
|
||||
<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">
|
||||
<!-- Target Key -->
|
||||
<div>
|
||||
<label for="api-target-key" class="mb-1 block text-sm font-medium text-gray-700">Target Key</label>
|
||||
<input
|
||||
id="api-target-key"
|
||||
type="text"
|
||||
bind:value={targetKey}
|
||||
placeholder="user_balance"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">最终 JSON 中的字段名</p>
|
||||
</div>
|
||||
|
||||
<!-- Request URL -->
|
||||
<div>
|
||||
<label for="api-url" class="mb-1 block text-sm font-medium text-gray-700">Request URL</label>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
id="api-url"
|
||||
type="text"
|
||||
bind:this={urlInput}
|
||||
bind:value={urlTemplate}
|
||||
placeholder={"https://api.example.com/users/{{用户ID}}/detail"}
|
||||
class="flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={() => { showUrlVars = !showUrlVars; showBodyVars = false; }}
|
||||
class="cursor-pointer whitespace-nowrap rounded-md border border-gray-300 px-2 py-2 text-xs text-gray-600 hover:bg-gray-50"
|
||||
title="插入变量"
|
||||
>
|
||||
{{x}}
|
||||
</button>
|
||||
{#if showUrlVars}
|
||||
<div class="absolute right-0 z-10 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{#each headers as h (h)}
|
||||
<button
|
||||
onclick={() => insertVariable('url', h)}
|
||||
class="w-full cursor-pointer px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-blue-50"
|
||||
>
|
||||
{h}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-400">支持 {{列名}} 模板变量</p>
|
||||
</div>
|
||||
|
||||
<!-- Request Method -->
|
||||
<div>
|
||||
<label for="api-method" class="mb-1 block text-sm font-medium text-gray-700">Request Method</label>
|
||||
<select
|
||||
id="api-method"
|
||||
bind:value={method}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Headers -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700">Headers</span>
|
||||
<button onclick={addHeader} class="cursor-pointer text-xs text-blue-600 hover:text-blue-700">
|
||||
+ 添加
|
||||
</button>
|
||||
</div>
|
||||
{#if headerEntries.length > 0}
|
||||
<div class="space-y-2">
|
||||
{#each headerEntries as entry, i (i)}
|
||||
<div class="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={entry.key}
|
||||
placeholder="Key"
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={entry.value}
|
||||
placeholder="Value"
|
||||
class="flex-1 rounded border border-gray-300 px-2 py-1.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
onclick={() => removeHeader(i)}
|
||||
class="cursor-pointer flex-shrink-0 text-gray-400 hover:text-red-500"
|
||||
aria-label="删除此 Header"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-xs text-gray-400">暂无 Header</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Body (POST only) -->
|
||||
{#if method === 'POST'}
|
||||
<div class="p-6">
|
||||
<div class="space-y-4">
|
||||
<!-- Target Key -->
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<label for="api-body" class="text-sm font-medium text-gray-700">Request Body</label>
|
||||
<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={() => { showBodyVars = !showBodyVars; showUrlVars = false; }}
|
||||
class="cursor-pointer text-xs text-blue-600 hover:text-blue-700"
|
||||
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="插入变量"
|
||||
>
|
||||
插入变量
|
||||
{{x}}
|
||||
</button>
|
||||
{#if showBodyVars}
|
||||
<div class="absolute right-0 z-10 mt-1 max-h-48 w-40 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{#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('body', h)}
|
||||
class="w-full cursor-pointer px-3 py-1.5 text-left text-xs text-gray-700 hover:bg-blue-50"
|
||||
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>
|
||||
@@ -241,55 +284,297 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="api-body"
|
||||
bind:this={bodyTextarea}
|
||||
bind:value={bodyTemplate}
|
||||
rows="4"
|
||||
placeholder={'{"user_id": "{{用户ID}}"}'}
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-slate-400 dark:text-slate-500">支持 {{列名}} 模板变量</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">
|
||||
{{{v}}}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Response Path -->
|
||||
<div>
|
||||
<label for="api-response-path" class="mb-1 block text-sm font-medium text-gray-700">Response Extractor</label>
|
||||
<input
|
||||
id="api-response-path"
|
||||
type="text"
|
||||
bind:value={responsePath}
|
||||
placeholder="data.balance"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">从接口返回 JSON 中提取值的路径(如 data.result.value)</p>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- Fallback Value -->
|
||||
<div>
|
||||
<label for="api-fallback" class="mb-1 block text-sm font-medium text-gray-700">Fallback Value</label>
|
||||
<input
|
||||
id="api-fallback"
|
||||
type="text"
|
||||
bind:value={fallbackValue}
|
||||
placeholder="null"
|
||||
class="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-400">API 请求失败时的默认值</p>
|
||||
<!-- 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">
|
||||
{{{v}}}
|
||||
</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="mt-6 flex justify-end gap-2">
|
||||
<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-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
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-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
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>
|
||||
|
||||
@@ -75,14 +75,14 @@
|
||||
|
||||
<div
|
||||
bind:this={panelEl}
|
||||
class="absolute top-full left-0 z-50 mt-1 w-80 max-h-[80vh] overflow-y-auto rounded-lg border border-gray-200 bg-white p-4 shadow-xl"
|
||||
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">
|
||||
<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"
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="space-y-3">
|
||||
<!-- Source header (read-only) -->
|
||||
<div>
|
||||
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
<label for="source-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||
>源字段</label
|
||||
>
|
||||
<input
|
||||
@@ -101,33 +101,33 @@
|
||||
type="text"
|
||||
value={config.source}
|
||||
disabled
|
||||
class="w-full rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-sm text-gray-500"
|
||||
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>
|
||||
|
||||
<!-- Target key -->
|
||||
<div>
|
||||
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
<label for="target-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||
>目标字段 (JSON Key)</label
|
||||
>
|
||||
<input
|
||||
id="target-{config.source}"
|
||||
type="text"
|
||||
bind:value={config.target}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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>
|
||||
|
||||
<!-- Data type -->
|
||||
<div>
|
||||
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
<label for="type-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||
>数据类型</label
|
||||
>
|
||||
<select
|
||||
id="type-{config.source}"
|
||||
value={config.type}
|
||||
onchange={onTypeChange}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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)}
|
||||
<option value={dt.value}>{dt.label}</option>
|
||||
@@ -138,13 +138,13 @@
|
||||
<!-- Date format (only for date type) -->
|
||||
{#if config.type === 'date'}
|
||||
<div>
|
||||
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
<label for="format-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||
>日期格式</label
|
||||
>
|
||||
<select
|
||||
id="format-{config.source}"
|
||||
bind:value={config.format}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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)}
|
||||
<option value={df.value}>{df.label}</option>
|
||||
@@ -193,9 +193,9 @@
|
||||
type="checkbox"
|
||||
id="exclude-empty-{config.source}"
|
||||
bind:checked={config.excludeIfEmpty}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
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-gray-600"
|
||||
<label for="exclude-empty-{config.source}" class="text-sm text-slate-600 dark:text-slate-400"
|
||||
>空值时移除该字段</label
|
||||
>
|
||||
</div>
|
||||
@@ -203,28 +203,28 @@
|
||||
<!-- Default value -->
|
||||
{#if !config.excludeIfEmpty}
|
||||
<div>
|
||||
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-gray-500"
|
||||
<label for="default-{config.source}" class="mb-1 block text-xs font-medium text-slate-500 dark:text-slate-400"
|
||||
>默认值 (空值时)</label
|
||||
>
|
||||
<input
|
||||
id="default-{config.source}"
|
||||
type="text"
|
||||
bind:value={config.defaultValue}
|
||||
class="w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<!-- 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}
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
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-gray-600">包含此列到输出</label>
|
||||
<label for="enabled-{config.source}" class="text-sm text-slate-600 dark:text-slate-400">包含此列到输出</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -97,19 +97,19 @@
|
||||
|
||||
<div class="space-y-2">
|
||||
<!-- Toggle -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 pb-1.5">
|
||||
<span class="text-xs font-semibold text-gray-700">字典映射</span>
|
||||
<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-blue-600 px-2 py-0.5 text-xs text-white hover:bg-blue-700"
|
||||
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-gray-300 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50"
|
||||
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>
|
||||
@@ -120,38 +120,38 @@
|
||||
<!-- Auto Scan Button -->
|
||||
<button
|
||||
onclick={scanColumnValues}
|
||||
class="w-full rounded border border-blue-600 bg-blue-50 px-2 py-1 text-xs text-blue-600 hover:bg-blue-100"
|
||||
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-gray-200">
|
||||
<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-gray-50 sticky top-0">
|
||||
<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-gray-600 text-[10px]">源</th>
|
||||
<th class="px-1.5 py-1 text-left font-medium text-gray-600 text-[10px]">目标</th>
|
||||
<th class="px-1.5 py-1 text-left font-medium text-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-gray-100">
|
||||
<td class="px-1.5 py-1 text-gray-700 truncate max-w-24" title={String(item.source)}>{String(item.source)}</td>
|
||||
<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-gray-300 px-1 py-0.5 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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 p-0.5"
|
||||
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">
|
||||
@@ -170,7 +170,7 @@
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-[10px] text-gray-400 text-center">暂无映射项</p>
|
||||
<p class="text-[10px] text-slate-400 text-center dark:text-slate-500">暂无映射项</p>
|
||||
{/if}
|
||||
|
||||
<!-- Manual Add (compact) -->
|
||||
@@ -179,18 +179,18 @@
|
||||
bind:value={newSourceValue}
|
||||
type="text"
|
||||
placeholder="源值"
|
||||
class="w-20 rounded border border-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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-gray-300 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 disabled:opacity-50 whitespace-nowrap"
|
||||
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>
|
||||
@@ -198,10 +198,10 @@
|
||||
|
||||
<!-- Fallback Strategy (compact) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-gray-500 whitespace-nowrap">未匹配:</span>
|
||||
<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-gray-300 px-1.5 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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>
|
||||
@@ -215,7 +215,7 @@
|
||||
bind:value={config.mappingCustomValue}
|
||||
type="text"
|
||||
placeholder="自定义默认值 (如: null, true, 0)"
|
||||
class="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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}
|
||||
|
||||
@@ -44,11 +44,11 @@
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700">
|
||||
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
Excel 数据
|
||||
<span class="ml-2 text-xs font-normal text-gray-400">
|
||||
<span class="ml-2 text-xs font-normal text-slate-400 dark:text-slate-500">
|
||||
{rows.length} 行 × {headers.length} 列
|
||||
{#if enrichmentRules.length > 0}
|
||||
+ {enrichmentRules.length} API 字段
|
||||
@@ -61,7 +61,7 @@
|
||||
{#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"
|
||||
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" />
|
||||
@@ -74,10 +74,10 @@
|
||||
|
||||
<!-- Enrichment rules bar -->
|
||||
{#if enrichmentRules.length > 0}
|
||||
<div class="flex flex-shrink-0 flex-wrap items-center gap-2 border-b border-gray-200 bg-purple-50/50 px-4 py-2">
|
||||
<span class="text-xs font-medium text-purple-600">API 字段:</span>
|
||||
<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">
|
||||
<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}
|
||||
@@ -109,15 +109,15 @@
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<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>
|
||||
{#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">
|
||||
<span class="truncate" title={header}>{header}</span>
|
||||
<button
|
||||
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="配置此列"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -127,7 +127,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{#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}
|
||||
</div>
|
||||
{#if activeConfigIndex === i && mappings[i]}
|
||||
@@ -137,7 +137,7 @@
|
||||
{/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">
|
||||
<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" />
|
||||
@@ -150,15 +150,15 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{#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)}
|
||||
<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>
|
||||
</td>
|
||||
{/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">
|
||||
<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}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
<script lang="ts">
|
||||
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>[];
|
||||
enrichmentRules?: ApiEnrichmentRule[];
|
||||
sourceData?: Record<string, unknown>[];
|
||||
onupdate?: (edited: string) => void;
|
||||
} = $props();
|
||||
|
||||
const MAX_PREVIEW_CHARS = 2000;
|
||||
const MAX_PREVIEW_LINES = 50;
|
||||
const MAX_TREE_ITEMS = 50;
|
||||
const ENRICH_PREVIEW_LIMIT = 5;
|
||||
|
||||
type ViewMode = 'tree' | 'raw' | 'edit';
|
||||
let viewMode = $state<ViewMode>('tree');
|
||||
let editText = $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
|
||||
const truncated = $derived.by(() => {
|
||||
@@ -31,8 +45,11 @@
|
||||
});
|
||||
|
||||
// Tree view shows limited items for performance
|
||||
const treeData = $derived(json.length > MAX_TREE_ITEMS ? json.slice(0, MAX_TREE_ITEMS) : json);
|
||||
const treeTruncated = $derived(json.length > MAX_TREE_ITEMS);
|
||||
const treeData = $derived(displayJson.length > MAX_TREE_ITEMS ? displayJson.slice(0, MAX_TREE_ITEMS) : displayJson);
|
||||
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
|
||||
$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 {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
@@ -49,7 +165,7 @@
|
||||
.replace(/"([^"]*)"(?=\s*:)/g, '<span class="json-key">"$1"</span>')
|
||||
.replace(/:\s*"([^"]*)"/g, ': <span class="json-string">"$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>');
|
||||
}
|
||||
|
||||
@@ -79,38 +195,66 @@
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-shrink-0 items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<h3 class="text-sm font-semibold text-gray-700">
|
||||
JSON
|
||||
<span class="ml-1 text-xs font-normal text-gray-400">{json.length} 条</span>
|
||||
</h3>
|
||||
<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">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="text-sm font-semibold text-slate-700 dark:text-slate-200">
|
||||
JSON
|
||||
<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">
|
||||
{#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 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>
|
||||
{:else}
|
||||
<!-- 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
|
||||
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"
|
||||
>
|
||||
树形
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
源码
|
||||
</button>
|
||||
</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>
|
||||
{/if}
|
||||
@@ -118,7 +262,14 @@
|
||||
</div>
|
||||
|
||||
{#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}
|
||||
|
||||
<!-- Content -->
|
||||
@@ -128,22 +279,22 @@
|
||||
bind:value={editText}
|
||||
oninput={() => { parseError = ''; }}
|
||||
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>
|
||||
{:else if viewMode === 'tree'}
|
||||
<div class="h-full overflow-auto px-4 py-3">
|
||||
<JsonTreeNode value={treeData} defaultOpen={true} />
|
||||
{#if treeTruncated}
|
||||
<div class="mt-2 border-t border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
⚠️ 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {json.length.toLocaleString()} 条)。请下载或复制查看完整数据。
|
||||
<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">
|
||||
⚠️ 树形视图仅展示前 {MAX_TREE_ITEMS} 条记录(共 {displayJson.length.toLocaleString()} 条)。请下载或复制查看完整数据。
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<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}
|
||||
<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">
|
||||
⚠️ 预览已截断(显示 {Math.min(MAX_PREVIEW_CHARS, truncated.text.length).toLocaleString()}/{truncated.totalChars.toLocaleString()} 字符,{Math.min(MAX_PREVIEW_LINES, truncated.totalLines)}/{truncated.totalLines.toLocaleString()} 行)。请下载或复制查看完整数据。
|
||||
</div>
|
||||
{/if}
|
||||
@@ -157,6 +308,9 @@
|
||||
background: #fafbfc;
|
||||
content-visibility: auto;
|
||||
}
|
||||
:global(.dark) .json-preview {
|
||||
background: #0d1117;
|
||||
}
|
||||
.json-content {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
@@ -166,21 +320,4 @@
|
||||
padding: 16px;
|
||||
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>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
|
||||
<!-- 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">
|
||||
<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 bg-white p-6 shadow-xl" onclick={(e) => e.stopPropagation()}>
|
||||
<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-gray-800">提交设置</h3>
|
||||
<button onclick={onclose} aria-label="关闭" class="cursor-pointer text-gray-400 hover:text-gray-600">
|
||||
<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>
|
||||
@@ -36,22 +36,22 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="target-url" class="mb-1 block text-sm font-medium text-gray-700">Target URL</label>
|
||||
<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-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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-gray-700">Method</label>
|
||||
<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-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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>
|
||||
@@ -59,13 +59,13 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="batch-size" class="mb-1 block text-sm font-medium text-gray-700">Batch Size</label>
|
||||
<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-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
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>
|
||||
@@ -73,13 +73,13 @@
|
||||
<div class="mt-6 flex justify-end gap-2">
|
||||
<button
|
||||
onclick={onclose}
|
||||
class="cursor-pointer rounded-md border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
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-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
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>
|
||||
|
||||
69
src/lib/components/ThemeToggle.svelte
Normal file
69
src/lib/components/ThemeToggle.svelte
Normal 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>
|
||||
129
src/lib/stores/themeStore.svelte.ts
Normal file
129
src/lib/stores/themeStore.svelte.ts
Normal 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();
|
||||
80
src/lib/utils/error-sanitizer.ts
Normal file
80
src/lib/utils/error-sanitizer.ts
Normal 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
117
src/lib/utils/proxy.ts
Normal 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, '')))];
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { onMount } from 'svelte';
|
||||
import { themeStore } from '$lib/stores/themeStore.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
onMount(() => {
|
||||
themeStore.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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
|
||||
let headers = $state<string[]>([]);
|
||||
@@ -26,6 +27,8 @@
|
||||
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
|
||||
let splitPercent = $state(50);
|
||||
@@ -58,6 +61,15 @@
|
||||
// Derived
|
||||
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
|
||||
let convertedJson = $state<Record<string, unknown>[]>([]);
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
@@ -245,20 +257,20 @@
|
||||
</script>
|
||||
|
||||
<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}
|
||||
ondragover={onDragOver}
|
||||
ondragleave={onDragLeave}
|
||||
role="application"
|
||||
>
|
||||
<!-- 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">
|
||||
<h1 class="text-lg font-bold text-gray-800">Excel → JSON</h1>
|
||||
<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-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 -->
|
||||
<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">
|
||||
<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>
|
||||
@@ -267,16 +279,16 @@
|
||||
</label>
|
||||
|
||||
{#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}
|
||||
|
||||
<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 -->
|
||||
<button
|
||||
onclick={handleImportTemplate}
|
||||
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">
|
||||
<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" />
|
||||
@@ -286,7 +298,7 @@
|
||||
<button
|
||||
onclick={handleExportTemplate}
|
||||
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">
|
||||
<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" />
|
||||
@@ -294,13 +306,13 @@
|
||||
导出配置
|
||||
</button>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Submission Settings -->
|
||||
<button
|
||||
onclick={() => (showSubmissionSettings = true)}
|
||||
disabled={!hasData}
|
||||
class="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||
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" />
|
||||
@@ -315,7 +327,7 @@
|
||||
<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"
|
||||
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" />
|
||||
@@ -323,13 +335,13 @@
|
||||
导出任务包
|
||||
</button>
|
||||
|
||||
<div class="mx-1 h-6 w-px bg-gray-200"></div>
|
||||
<div class="mx-1 h-6 w-px bg-slate-200 dark:bg-slate-700"></div>
|
||||
|
||||
<!-- JSON operations -->
|
||||
<button
|
||||
onclick={handleDownloadJson}
|
||||
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">
|
||||
<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" />
|
||||
@@ -339,10 +351,10 @@
|
||||
<button
|
||||
onclick={handleCopyJson}
|
||||
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}
|
||||
<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" />
|
||||
</svg>
|
||||
已复制
|
||||
@@ -359,23 +371,26 @@
|
||||
href="https://github.com/meowrain/Excel2JSON"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-1 inline-flex items-center rounded-md border border-gray-300 bg-white p-1.5 text-gray-600 hover:bg-gray-50 hover:text-gray-900"
|
||||
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>
|
||||
|
||||
<!-- Error message -->
|
||||
{#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">
|
||||
<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>
|
||||
{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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
@@ -409,10 +424,10 @@
|
||||
<!-- Drag handle -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
|
||||
<!-- Right: JSON Preview -->
|
||||
@@ -420,27 +435,32 @@
|
||||
<button
|
||||
onclick={toggleJsonPanel}
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
<JsonPreview json={convertedJson} onupdate={onJsonEdited} />
|
||||
<JsonPreview
|
||||
json={convertedJson}
|
||||
{enrichmentRules}
|
||||
sourceData={rows}
|
||||
onupdate={onJsonEdited}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- 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
|
||||
onclick={toggleJsonPanel}
|
||||
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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7M19 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -449,8 +469,8 @@
|
||||
<div class="flex flex-1 items-center justify-center p-8">
|
||||
<div
|
||||
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-gray-300 bg-white'}"
|
||||
? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
|
||||
: '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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
@@ -475,6 +495,7 @@
|
||||
<ApiConfigModal
|
||||
rule={editingRuleIndex !== null ? enrichmentRules[editingRuleIndex] : undefined}
|
||||
{headers}
|
||||
testSample={testSampleData}
|
||||
onsave={handleSaveApiRule}
|
||||
onclose={() => { showApiConfig = false; editingRuleIndex = null; }}
|
||||
/>
|
||||
|
||||
193
src/routes/api/proxy/+server.ts
Normal file
193
src/routes/api/proxy/+server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,73 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user