mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-14 13:25:20 +00:00
modified: src/lib/components/layout/ImportChatsModal.svelte
This commit is contained in:
parent
631f18b6c8
commit
5538cc1953
1 changed files with 158 additions and 199 deletions
|
|
@ -2,8 +2,6 @@
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import Modal from '../common/Modal.svelte';
|
import Modal from '../common/Modal.svelte';
|
||||||
import Spinner from '../common/Spinner.svelte';
|
import Spinner from '../common/Spinner.svelte';
|
||||||
import { extractChatsFromFile } from '$lib/utils/chatImport';
|
|
||||||
import { getImportOrigin, convertOpenAIChats, convertDeepseekChats } from '$lib/utils';
|
|
||||||
|
|
||||||
export let show = false;
|
export let show = false;
|
||||||
export let onImport: (chats: any[]) => Promise<void>;
|
export let onImport: (chats: any[]) => Promise<void>;
|
||||||
|
|
@ -15,9 +13,15 @@
|
||||||
let errorMsg = '';
|
let errorMsg = '';
|
||||||
let successMsg = '';
|
let successMsg = '';
|
||||||
let fileName = '';
|
let fileName = '';
|
||||||
|
|
||||||
|
// 对应 HTML 版本中的 rawData
|
||||||
let rawChats: any[] = [];
|
let rawChats: any[] = [];
|
||||||
|
// 对应 HTML 版本中的 selectedIndices
|
||||||
let selectedIndices: Set<number> = new Set();
|
let selectedIndices: Set<number> = new Set();
|
||||||
|
|
||||||
|
let fileInputEl: HTMLInputElement;
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
successMsg = '';
|
successMsg = '';
|
||||||
|
|
@ -25,93 +29,64 @@
|
||||||
rawChats = [];
|
rawChats = [];
|
||||||
selectedIndices = new Set();
|
selectedIndices = new Set();
|
||||||
filterOpen = true;
|
filterOpen = true;
|
||||||
|
if (fileInputEl) fileInputEl.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
$: if (!show) {
|
$: if (!show) {
|
||||||
resetState();
|
resetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseJsonOrJsonlText = async (file: File) => {
|
// 核心逻辑:读取并解析 JSON 数组
|
||||||
const text = await file.text();
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(text);
|
|
||||||
return Array.isArray(parsed) ? parsed : [parsed];
|
|
||||||
} catch (jsonError) {
|
|
||||||
const lines = text
|
|
||||||
.split('\n')
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
|
|
||||||
if (lines.length === 0) {
|
|
||||||
throw new Error('File is empty, nothing to parse');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return lines.map((line) => JSON.parse(line));
|
|
||||||
} catch (lineError) {
|
|
||||||
throw new Error('Plain text JSONL must contain one valid JSON object per line');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeChats = (chats: any) => {
|
|
||||||
if (Array.isArray(chats)) return chats;
|
|
||||||
|
|
||||||
if (chats && typeof chats === 'object') {
|
|
||||||
if (Array.isArray((chats as any).conversations)) {
|
|
||||||
return (chats as any).conversations;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('File content must be a JSON array of chats');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFiles = async (files: FileList | File[]) => {
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
errorMsg = '';
|
errorMsg = '';
|
||||||
successMsg = '';
|
successMsg = '';
|
||||||
fileName = file.name;
|
fileName = file.name;
|
||||||
|
rawChats = [];
|
||||||
|
selectedIndices = new Set();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
const text = await file.text();
|
||||||
let chats: any = null;
|
let parsed: any;
|
||||||
|
|
||||||
if (ext === 'txt' || ext === 'jsonl') {
|
try {
|
||||||
chats = await parseJsonOrJsonlText(file);
|
parsed = JSON.parse(text);
|
||||||
} else {
|
} catch (e) {
|
||||||
chats = await extractChatsFromFile(file);
|
throw new Error('JSON 解析失败,请检查文件格式');
|
||||||
}
|
}
|
||||||
|
|
||||||
chats = normalizeChats(chats);
|
// 校验格式:必须是数组 [{}, {}]
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
if (chats.length === 0) {
|
throw new Error('文件格式错误:JSON 根节点必须是数组 `[...]`');
|
||||||
throw new Error('File contained zero chat records');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const origin = getImportOrigin(chats);
|
if (parsed.length === 0) {
|
||||||
|
throw new Error('JSON 数组为空');
|
||||||
if (origin === 'openai') {
|
|
||||||
chats = convertOpenAIChats(chats);
|
|
||||||
} else if (origin === 'deepseek') {
|
|
||||||
chats = convertDeepseekChats(chats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rawChats = chats;
|
rawChats = parsed;
|
||||||
selectedIndices = new Set(rawChats.map((_, idx) => idx));
|
successMsg = `解析成功,共 ${rawChats.length} 条记录`;
|
||||||
|
|
||||||
|
// 默认不全选,或者全选,取决于你的偏好。这里保持原有逻辑(不选或全选)
|
||||||
|
// 这里改为:解析后默认显示列表,但不选中(等待用户操作),或者全选
|
||||||
|
// 之前的 HTML 逻辑是空的,这里为了方便用户,可以默认不选,或者全选。
|
||||||
|
// 让我们默认不选,让用户决定。
|
||||||
|
selectedIndices = new Set();
|
||||||
filterOpen = true;
|
filterOpen = true;
|
||||||
successMsg = '解析成功';
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
errorMsg = error instanceof Error ? error.message : `${error}`;
|
errorMsg = error instanceof Error ? error.message : `${error}`;
|
||||||
successMsg = '';
|
|
||||||
rawChats = [];
|
rawChats = [];
|
||||||
selectedIndices = new Set();
|
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 行选择切换
|
||||||
const toggleRow = (idx: number) => {
|
const toggleRow = (idx: number) => {
|
||||||
const next = new Set(selectedIndices);
|
const next = new Set(selectedIndices);
|
||||||
if (next.has(idx)) {
|
if (next.has(idx)) {
|
||||||
|
|
@ -122,39 +97,48 @@
|
||||||
selectedIndices = next;
|
selectedIndices = next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 全选/取消全选
|
||||||
const toggleSelectAll = (checked: boolean) => {
|
const toggleSelectAll = (checked: boolean) => {
|
||||||
selectedIndices = checked ? new Set(rawChats.map((_, idx) => idx)) : new Set();
|
selectedIndices = checked ? new Set(rawChats.map((_, idx) => idx)) : new Set();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 导出并导入 (Export & Import)
|
||||||
const confirmImport = async () => {
|
const confirmImport = async () => {
|
||||||
if (!rawChats.length) {
|
if (!rawChats.length) {
|
||||||
toast.error('Please upload a chat history file first');
|
toast.error('请先上传文件');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const chatsToImport =
|
|
||||||
selectedIndices.size > 0 ? rawChats.filter((_, idx) => selectedIndices.has(idx)) : rawChats;
|
|
||||||
|
|
||||||
if (!chatsToImport.length) {
|
if (selectedIndices.size === 0) {
|
||||||
toast.error('No records selected');
|
toast.error('未选择任何记录');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
const chatsToImport = rawChats.filter((_, idx) => selectedIndices.has(idx));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
importing = true;
|
importing = true;
|
||||||
const jsonlString = chatsToImport.map((item) => JSON.stringify(item)).join('\n');
|
|
||||||
const blob = new Blob([jsonlString], { type: 'application/json' });
|
// 1. 生成并下载 JSON 文件 (对应 HTML 工具的导出功能)
|
||||||
|
// 使用 null, 2 进行美化输出
|
||||||
|
const jsonString = JSON.stringify(chatsToImport, null, 2);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `filtered_chats_${new Date().toISOString().slice(0, 10)}.jsonl`;
|
a.download = `filtered_chats_${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
toast.success(`已导出 ${chatsToImport.length} 条记录`);
|
||||||
|
|
||||||
|
// 2. 执行回调,将数据导入应用
|
||||||
await onImport(chatsToImport);
|
await onImport(chatsToImport);
|
||||||
show = false;
|
show = false;
|
||||||
toast.success('Starting import for the filtered chats');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(error instanceof Error ? error.message : `${error}`);
|
toast.error(error instanceof Error ? error.message : `${error}`);
|
||||||
|
|
@ -163,29 +147,15 @@
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 辅助显示函数 (对应 HTML 中的 renderTable)
|
||||||
const displayRows = () =>
|
const displayRows = () =>
|
||||||
rawChats.map((chat, idx) => {
|
rawChats.map((chat, idx) => {
|
||||||
const meta = chat?.meta ?? chat?.chat?.meta ?? chat;
|
// 宽容处理字段
|
||||||
const title =
|
const title = chat.title || '<无标题>';
|
||||||
meta?.title ??
|
const date = chat.inserted_at || '-';
|
||||||
chat?.title ??
|
|
||||||
chat?.chat?.title ??
|
|
||||||
meta?.subject ??
|
|
||||||
'Untitled chat';
|
|
||||||
const date =
|
|
||||||
meta?.inserted_at ??
|
|
||||||
meta?.created_at ??
|
|
||||||
meta?.updated_at ??
|
|
||||||
chat?.inserted_at ??
|
|
||||||
chat?.created_at ??
|
|
||||||
chat?.updated_at ??
|
|
||||||
'-';
|
|
||||||
|
|
||||||
return { idx, title, date };
|
return { idx, title, date };
|
||||||
});
|
});
|
||||||
|
|
||||||
let fileInputEl: HTMLInputElement;
|
|
||||||
|
|
||||||
const handleFileInputChange = (event: Event) => {
|
const handleFileInputChange = (event: Event) => {
|
||||||
const input = event.currentTarget as HTMLInputElement;
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
handleFiles(input.files ?? []);
|
handleFiles(input.files ?? []);
|
||||||
|
|
@ -201,42 +171,27 @@
|
||||||
<div class="p-6 space-y-6 font-primary">
|
<div class="p-6 space-y-6 font-primary">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Chat Import Center</div>
|
<div class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||||
|
<span class="w-3 h-3 rounded bg-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.6)]"></span>
|
||||||
|
Chat Filter & Import
|
||||||
|
</div>
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Upload your exported history, filter the records you need, then import.
|
导入 JSON 数组,筛选所需的聊天记录,自动下载并导入。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||||
on:click={() => (show = false)}
|
on:click={() => (show = false)}
|
||||||
aria-label="Close import modal"
|
aria-label="Close"
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-gray-50/60 dark:bg-gray-900/60 p-4">
|
|
||||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100 mb-2">1. Prepare file</div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed space-y-1">
|
|
||||||
<p>
|
|
||||||
Supported formats: <strong>JSON (.json)</strong>, <strong>JSONL (.jsonl/.txt)</strong>, or
|
|
||||||
OpenAI ZIP export (auto-converted).
|
|
||||||
</p>
|
|
||||||
<p>Large exports can be filtered below to speed up the import.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 space-y-3">
|
<div class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 space-y-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">2. Filter records</div>
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">1. 读取文件 (JSON Array)</div>
|
||||||
<button
|
|
||||||
class="text-xs px-3 py-1.5 rounded-full border border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-850"
|
|
||||||
on:click={() => (filterOpen = !filterOpen)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{filterOpen ? 'Hide filters' : 'Show filters'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -251,98 +206,109 @@
|
||||||
dropActive = false;
|
dropActive = false;
|
||||||
handleFiles(e.dataTransfer?.files ?? []);
|
handleFiles(e.dataTransfer?.files ?? []);
|
||||||
}}
|
}}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
<div class="flex flex-col items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
<div class="font-medium text-gray-900 dark:text-white">
|
<div class="font-medium text-gray-900 dark:text-white font-mono">
|
||||||
{fileName ? `Selected: ${fileName}` : 'Drag a file here or click to upload'}
|
{fileName ? `Current: ${fileName}` : '拖入 .json 文件'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
class="px-3 py-1.5 rounded-lg bg-gray-900 text-white text-xs hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200"
|
class="px-3 py-1.5 rounded-lg bg-gray-900 text-white text-xs hover:bg-gray-800 dark:bg-white dark:text-gray-900 dark:hover:bg-gray-200 transition-colors"
|
||||||
on:click={() => fileInputEl.click()}
|
on:click={() => fileInputEl.click()}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Choose file
|
选择文件
|
||||||
</button>
|
</button>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Supports .json / .jsonl / .txt / .zip exports
|
格式要求: <code>[{...}, {...}]</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="flex items-center gap-2 text-blue-600 dark:text-blue-300">
|
<div class="flex items-center gap-2 text-blue-600 dark:text-blue-300 mt-2">
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
<span>Parsing file...</span>
|
<span>正在解析...</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if errorMsg}
|
{#if errorMsg}
|
||||||
<div class="text-xs text-red-500">{errorMsg}</div>
|
<div class="text-xs text-red-500 mt-1">{errorMsg}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if successMsg}
|
{#if successMsg}
|
||||||
<div class="text-xs text-green-600 dark:text-green-400">{successMsg}</div>
|
<div class="text-xs text-green-600 dark:text-green-400 mt-1">{successMsg}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
bind:this={fileInputEl}
|
bind:this={fileInputEl}
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json,.jsonl,.zip,.txt,application/json"
|
accept=".json,application/json"
|
||||||
hidden
|
hidden
|
||||||
on:change={handleFileInputChange}
|
on:change={handleFileInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if filterOpen}
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
|
|
||||||
<div>
|
|
||||||
Total: {rawChats.length} | Selected: {selectedIndices.size}
|
|
||||||
{selectedIndices.size === 0 && rawChats.length > 0
|
|
||||||
? ' (none selected will import all)'
|
|
||||||
: ''}
|
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 cursor-pointer select-none">
|
|
||||||
|
<div class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">
|
||||||
|
2. 筛选记录
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if rawChats.length > 0}
|
||||||
|
<div class="flex items-center gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="font-mono">Total: {rawChats.length} | Selected: {selectedIndices.size}</span>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="accent-blue-600"
|
class="accent-blue-600 rounded"
|
||||||
checked={rawChats.length > 0 && selectedIndices.size === rawChats.length}
|
checked={rawChats.length > 0 && selectedIndices.size === rawChats.length}
|
||||||
indeterminate={selectedIndices.size > 0 && selectedIndices.size < rawChats.length}
|
indeterminate={selectedIndices.size > 0 && selectedIndices.size < rawChats.length}
|
||||||
on:change={handleSelectAllChange}
|
on:change={handleSelectAllChange}
|
||||||
/>
|
/>
|
||||||
<span>Select / Deselect all</span>
|
<span>全选</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-h-64 overflow-auto rounded-xl border border-gray-100 dark:border-gray-800">
|
{#if filterOpen}
|
||||||
<table class="w-full text-sm">
|
<div class="max-h-64 overflow-auto rounded-xl border border-gray-100 dark:border-gray-800 bg-gray-50/30 dark:bg-black/20">
|
||||||
<thead class="text-left bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300">
|
<table class="w-full text-sm border-collapse">
|
||||||
|
<thead class="text-left bg-gray-50 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400 sticky top-0 backdrop-blur-md">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="w-14 py-2 px-3">Pick</th>
|
<th class="w-12 py-2 px-3 text-center">#</th>
|
||||||
<th class="py-2 px-3">Title / Summary</th>
|
<th class="py-2 px-3 font-medium text-xs uppercase tracking-wider">Title</th>
|
||||||
<th class="w-48 py-2 px-3">Timestamp</th>
|
<th class="w-48 py-2 px-3 font-medium text-xs uppercase tracking-wider">Inserted At</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#if !rawChats.length}
|
{#if !rawChats.length}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="py-4 text-center text-gray-500 dark:text-gray-400">
|
<td colspan="3" class="py-8 text-center text-gray-500 dark:text-gray-500 text-xs">
|
||||||
Upload a file to see filterable records
|
暂无数据,请先导入文件
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{:else}
|
{:else}
|
||||||
{#each displayRows() as row}
|
{#each displayRows() as row (row.idx)}
|
||||||
<tr class="border-t border-gray-100 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-900/60">
|
<tr
|
||||||
<td class="py-2 px-3">
|
class="border-b border-gray-100 dark:border-gray-800 last:border-0 hover:bg-blue-50/50 dark:hover:bg-blue-900/10 transition-colors group"
|
||||||
|
>
|
||||||
|
<td class="py-2 px-3 text-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
class="accent-blue-600 cursor-pointer"
|
||||||
checked={selectedIndices.has(row.idx)}
|
checked={selectedIndices.has(row.idx)}
|
||||||
on:change={() => toggleRow(row.idx)}
|
on:change={() => toggleRow(row.idx)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-3">
|
<td class="py-2 px-3">
|
||||||
<div class="text-sm text-gray-900 dark:text-white line-clamp-2">
|
<div class="text-gray-900 dark:text-gray-200 font-medium truncate max-w-[300px]" title={row.title}>
|
||||||
{row.title}
|
{row.title}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 px-3 text-xs text-gray-500 dark:text-gray-400">
|
<td class="py-2 px-3 text-xs font-mono text-blue-600 dark:text-blue-400">
|
||||||
{row.date}
|
{row.date}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -351,36 +317,29 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-900 p-4 space-y-2">
|
<div class="flex items-center justify-end gap-3 pt-2">
|
||||||
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">3. Import</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
Confirmed records will be imported into your current account.
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-end gap-3">
|
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm rounded-xl border border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-850"
|
class="px-4 py-2 text-sm rounded-xl border border-gray-200 dark:border-gray-800 hover:bg-gray-100 dark:hover:bg-gray-850 text-gray-700 dark:text-gray-300 transition-all"
|
||||||
on:click={() => (show = false)}
|
on:click={() => (show = false)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Cancel
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm rounded-xl bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60 disabled:cursor-not-allowed flex items-center gap-2"
|
class="px-4 py-2 text-sm rounded-xl bg-green-600 text-white hover:bg-green-700 hover:shadow-[0_0_12px_rgba(22,163,74,0.4)] disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none flex items-center gap-2 transition-all font-medium"
|
||||||
on:click={confirmImport}
|
on:click={confirmImport}
|
||||||
disabled={loading || importing || (!rawChats.length && !loading)}
|
disabled={loading || importing || selectedIndices.size === 0}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{#if importing}
|
{#if importing}
|
||||||
<Spinner className="size-4" />
|
<Spinner className="size-4" />
|
||||||
{/if}
|
{/if}
|
||||||
<span>{importing ? 'Importing...' : 'Confirm import'}</span>
|
<span>导出并导入 ({selectedIndices.size})</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
Loading…
Reference in a new issue