mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-15 13:55:19 +00:00
聊天记录导入部分初步实现
This commit is contained in:
parent
f591a2788b
commit
40b7ac7dc9
3 changed files with 781 additions and 43 deletions
329
src/lib/components/layout/ImportChatsModal.svelte
Normal file
329
src/lib/components/layout/ImportChatsModal.svelte
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import Modal from '../common/Modal.svelte';
|
||||||
|
import Spinner from '../common/Spinner.svelte';
|
||||||
|
import { extractChatsFromFile } from '$lib/utils/chatImport';
|
||||||
|
import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
|
||||||
|
|
||||||
|
export let show = false;
|
||||||
|
export let onImport: (chats: any[]) => Promise<void>;
|
||||||
|
|
||||||
|
let dropActive = false;
|
||||||
|
let loading = false;
|
||||||
|
let importing = false;
|
||||||
|
let filterOpen = true;
|
||||||
|
let errorMsg = '';
|
||||||
|
let fileName = '';
|
||||||
|
let rawChats: any[] = [];
|
||||||
|
let selectedIndices: Set<number> = new Set();
|
||||||
|
|
||||||
|
const resetState = () => {
|
||||||
|
errorMsg = '';
|
||||||
|
fileName = '';
|
||||||
|
rawChats = [];
|
||||||
|
selectedIndices = new Set();
|
||||||
|
filterOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$: if (!show) {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTxtAsJson = async (file: File) => {
|
||||||
|
const text = await file.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('纯文本文件需包含有效的 JSON 内容');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFiles = async (files: FileList | File[]) => {
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
const file = files[0];
|
||||||
|
loading = true;
|
||||||
|
errorMsg = '';
|
||||||
|
fileName = file.name;
|
||||||
|
try {
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
let chats = null;
|
||||||
|
|
||||||
|
if (ext === 'txt') {
|
||||||
|
chats = await parseTxtAsJson(file);
|
||||||
|
} else {
|
||||||
|
chats = await extractChatsFromFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getImportOrigin(chats) === 'openai') {
|
||||||
|
try {
|
||||||
|
chats = convertOpenAIChats(chats);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to convert OpenAI chats');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(chats)) {
|
||||||
|
throw new Error('文件内容需为 JSON 数组');
|
||||||
|
}
|
||||||
|
|
||||||
|
rawChats = chats;
|
||||||
|
selectedIndices = new Set(rawChats.map((_, idx) => idx));
|
||||||
|
filterOpen = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
errorMsg = error instanceof Error ? error.message : `${error}`;
|
||||||
|
rawChats = [];
|
||||||
|
selectedIndices = new Set();
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRow = (idx: number) => {
|
||||||
|
const next = new Set(selectedIndices);
|
||||||
|
if (next.has(idx)) {
|
||||||
|
next.delete(idx);
|
||||||
|
} else {
|
||||||
|
next.add(idx);
|
||||||
|
}
|
||||||
|
selectedIndices = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSelectAll = (checked: boolean) => {
|
||||||
|
selectedIndices = checked ? new Set(rawChats.map((_, idx) => idx)) : new Set();
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmImport = async () => {
|
||||||
|
if (!rawChats.length) {
|
||||||
|
toast.error('请先上传对话记录文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const chatsToImport =
|
||||||
|
selectedIndices.size > 0 ? rawChats.filter((_, idx) => selectedIndices.has(idx)) : rawChats;
|
||||||
|
|
||||||
|
if (!chatsToImport.length) {
|
||||||
|
toast.error('未选择任何记录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
importing = true;
|
||||||
|
await onImport(chatsToImport);
|
||||||
|
show = false;
|
||||||
|
toast.success('开始导入筛选后的对话记录');
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.error(error instanceof Error ? error.message : `${error}`);
|
||||||
|
} finally {
|
||||||
|
importing = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayRows = () =>
|
||||||
|
rawChats.map((chat, idx) => {
|
||||||
|
const meta = chat?.meta ?? chat?.chat?.meta ?? chat;
|
||||||
|
const title =
|
||||||
|
meta?.title ??
|
||||||
|
chat?.title ??
|
||||||
|
chat?.chat?.title ??
|
||||||
|
meta?.subject ??
|
||||||
|
'未命名对话';
|
||||||
|
const date =
|
||||||
|
meta?.inserted_at ??
|
||||||
|
meta?.created_at ??
|
||||||
|
meta?.updated_at ??
|
||||||
|
chat?.inserted_at ??
|
||||||
|
chat?.created_at ??
|
||||||
|
chat?.updated_at ??
|
||||||
|
'-';
|
||||||
|
|
||||||
|
return { idx, title, date };
|
||||||
|
});
|
||||||
|
|
||||||
|
let fileInputEl: HTMLInputElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Modal bind:show size="xl" className="bg-white/95 dark:bg-gray-900/95 rounded-4xl p-1">
|
||||||
|
<div class="p-6 space-y-6 font-primary">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900 dark:text-white">对话记录导入中心</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
完成准备、筛选后再执行导入,提升成功率与速度。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white"
|
||||||
|
on:click={() => (show = false)}
|
||||||
|
aria-label="关闭导入中心"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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. 准备您的对话记录
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed space-y-1">
|
||||||
|
<p>请确保导出文件格式为 <strong>JSON (.json)</strong> 或 <strong>纯文本 (.txt)</strong>。</p>
|
||||||
|
<p>
|
||||||
|
请前往原平台导出历史对话,如 DeepSeek — 系统设置 — 数据管理 — 导出所有历史对话,
|
||||||
|
ChatGPT — 设置 — 数据管理 — 导出数据。
|
||||||
|
</p>
|
||||||
|
<p>如果导出的文件内容过多或过大,建议使用下方的筛选功能生成新的导入文件,以加快导入速度。</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="flex items-center justify-between">
|
||||||
|
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">2. 筛选记录</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)}
|
||||||
|
>
|
||||||
|
{filterOpen ? '收起高级筛选器' : '高级筛选器'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class={`border-2 border-dashed rounded-xl p-4 transition ${
|
||||||
|
dropActive
|
||||||
|
? 'border-blue-400 bg-blue-50/70 dark:border-blue-400/80 dark:bg-blue-950/40'
|
||||||
|
: 'border-gray-200 dark:border-gray-800 bg-gray-50/40 dark:bg-gray-900'
|
||||||
|
}`}
|
||||||
|
on:dragover|preventDefault={() => (dropActive = true)}
|
||||||
|
on:dragleave|preventDefault={() => (dropActive = false)}
|
||||||
|
on:drop|preventDefault={(e) => {
|
||||||
|
dropActive = false;
|
||||||
|
handleFiles(e.dataTransfer?.files ?? []);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
{fileName ? `已选择:${fileName}` : '拖拽文件到此处,或点击上传'}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<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"
|
||||||
|
on:click={() => fileInputEl.click()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
选择文件
|
||||||
|
</button>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
支持 .json / .txt,OpenAI 导出支持自动转换
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center gap-2 text-blue-600 dark:text-blue-300">
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
<span>正在解析文件...</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if errorMsg}
|
||||||
|
<div class="text-xs text-red-500">{errorMsg}</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInputEl}
|
||||||
|
type="file"
|
||||||
|
accept=".json,.zip,.txt,application/json"
|
||||||
|
hidden
|
||||||
|
on:change={(e) => handleFiles((e.currentTarget as HTMLInputElement).files)}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
总数:{rawChats.length} | 已选:{selectedIndices.size}
|
||||||
|
{selectedIndices.size === 0 && rawChats.length > 0 ? '(未选则默认导入全部)' : ''}
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="accent-blue-600"
|
||||||
|
checked={rawChats.length > 0 && selectedIndices.size === rawChats.length}
|
||||||
|
indeterminate={selectedIndices.size > 0 && selectedIndices.size < rawChats.length}
|
||||||
|
on:change={(e) => toggleSelectAll((e.currentTarget as HTMLInputElement).checked)}
|
||||||
|
/>
|
||||||
|
<span>全选 / 取消全选</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-64 overflow-auto rounded-xl border border-gray-100 dark:border-gray-800">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="text-left bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-300">
|
||||||
|
<tr>
|
||||||
|
<th class="w-14 py-2 px-3">选择</th>
|
||||||
|
<th class="py-2 px-3">标题 / 摘要</th>
|
||||||
|
<th class="w-48 py-2 px-3">时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#if !rawChats.length}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="py-4 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
请先上传文件以查看可筛选的记录
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
{#each displayRows() as row}
|
||||||
|
<tr class="border-t border-gray-100 dark:border-gray-850 hover:bg-gray-50 dark:hover:bg-gray-900/60">
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIndices.has(row.idx)}
|
||||||
|
on:change={() => toggleRow(row.idx)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3">
|
||||||
|
<div class="text-sm text-gray-900 dark:text-white line-clamp-2">
|
||||||
|
{row.title}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="py-2 px-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{row.date}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</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="text-sm font-semibold text-gray-800 dark:text-gray-100">3. 导入记录</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
确认后将按筛选结果导入到当前账户的对话列表中。
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<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"
|
||||||
|
on:click={() => (show = false)}
|
||||||
|
type="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"
|
||||||
|
on:click={confirmImport}
|
||||||
|
disabled={loading || importing || (!rawChats.length && !loading)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{#if importing}
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
{/if}
|
||||||
|
<span>{importing ? '正在导入...' : '确认导入'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
@ -52,6 +52,7 @@
|
||||||
import Folder from '../common/Folder.svelte';
|
import Folder from '../common/Folder.svelte';
|
||||||
import Tooltip from '../common/Tooltip.svelte';
|
import Tooltip from '../common/Tooltip.svelte';
|
||||||
import Folders from './Sidebar/Folders.svelte';
|
import Folders from './Sidebar/Folders.svelte';
|
||||||
|
import ImportChatsModal from './ImportChatsModal.svelte';
|
||||||
import { getChannels, createNewChannel } from '$lib/apis/channels';
|
import { getChannels, createNewChannel } from '$lib/apis/channels';
|
||||||
import ChannelModal from './Sidebar/ChannelModal.svelte';
|
import ChannelModal from './Sidebar/ChannelModal.svelte';
|
||||||
import ChannelItem from './Sidebar/ChannelItem.svelte';
|
import ChannelItem from './Sidebar/ChannelItem.svelte';
|
||||||
|
|
@ -64,8 +65,6 @@
|
||||||
import PinnedModelList from './Sidebar/PinnedModelList.svelte';
|
import PinnedModelList from './Sidebar/PinnedModelList.svelte';
|
||||||
import Note from '../icons/Note.svelte';
|
import Note from '../icons/Note.svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import { getImportOrigin, convertOpenAIChats } from '$lib/utils';
|
|
||||||
import { extractChatsFromFile } from '$lib/utils/chatImport';
|
|
||||||
|
|
||||||
const BREAKPOINT = 768;
|
const BREAKPOINT = 768;
|
||||||
|
|
||||||
|
|
@ -79,6 +78,8 @@
|
||||||
|
|
||||||
let showCreateChannel = false;
|
let showCreateChannel = false;
|
||||||
|
|
||||||
|
let showImportChatsModal = false;
|
||||||
|
|
||||||
// Pagination variables
|
// Pagination variables
|
||||||
let chatListLoading = false;
|
let chatListLoading = false;
|
||||||
let allChatsLoaded = false;
|
let allChatsLoaded = false;
|
||||||
|
|
@ -90,10 +91,6 @@
|
||||||
|
|
||||||
let newFolderId = null;
|
let newFolderId = null;
|
||||||
|
|
||||||
// Import Chats state
|
|
||||||
let importFiles;
|
|
||||||
let chatImportInputElement: HTMLInputElement;
|
|
||||||
|
|
||||||
const initFolders = async () => {
|
const initFolders = async () => {
|
||||||
const folderList = await getFolders(localStorage.token).catch((error) => {
|
const folderList = await getFolders(localStorage.token).catch((error) => {
|
||||||
toast.error(`${error}`);
|
toast.error(`${error}`);
|
||||||
|
|
@ -357,32 +354,6 @@
|
||||||
selectedChatId = null;
|
selectedChatId = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Import Chats handler
|
|
||||||
$: if (importFiles) {
|
|
||||||
console.log(importFiles);
|
|
||||||
|
|
||||||
if (importFiles.length > 0) {
|
|
||||||
extractChatsFromFile(importFiles[0])
|
|
||||||
.then((chats) => {
|
|
||||||
console.log(chats);
|
|
||||||
if (getImportOrigin(chats) == 'openai') {
|
|
||||||
try {
|
|
||||||
chats = convertOpenAIChats(chats);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Unable to import chats:', error);
|
|
||||||
toast.error($i18n.t('Failed to convert OpenAI chats'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
importChatsHandler(chats);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Import error:', error);
|
|
||||||
toast.error($i18n.t(error.message));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const importChatsHandler = async (_chats) => {
|
const importChatsHandler = async (_chats) => {
|
||||||
for (const chat of _chats) {
|
for (const chat of _chats) {
|
||||||
console.log(chat);
|
console.log(chat);
|
||||||
|
|
@ -1313,19 +1284,10 @@
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Import Chats Button -->
|
<!-- Import Chats Button -->
|
||||||
<input
|
|
||||||
id="sidebar-chat-import-input"
|
|
||||||
bind:this={chatImportInputElement}
|
|
||||||
bind:files={importFiles}
|
|
||||||
type="file"
|
|
||||||
accept=".json,.zip"
|
|
||||||
hidden
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100/50 dark:hover:bg-gray-900 transition outline-none"
|
class="flex w-full items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100/50 dark:hover:bg-gray-900 transition outline-none"
|
||||||
on:click={() => {
|
on:click={() => {
|
||||||
chatImportInputElement.click();
|
showImportChatsModal = true;
|
||||||
}}
|
}}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
aria-label={$i18n.t('Import Chats')}
|
aria-label={$i18n.t('Import Chats')}
|
||||||
|
|
@ -1349,7 +1311,6 @@
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex flex-col font-primary">
|
<div class="flex flex-col font-primary">
|
||||||
{#if $user !== undefined && $user !== null}
|
{#if $user !== undefined && $user !== null}
|
||||||
<UserMenu
|
<UserMenu
|
||||||
|
|
@ -1383,3 +1344,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<ImportChatsModal
|
||||||
|
bind:show={showImportChatsModal}
|
||||||
|
onImport={async (chats) => {
|
||||||
|
await importChatsHandler(chats);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
|
||||||
441
unitest/chat_history_filter.html
Normal file
441
unitest/chat_history_filter.html
Normal file
|
|
@ -0,0 +1,441 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>聊天记录筛选器 (JSON)</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #0f1115;
|
||||||
|
--card-bg: #161b22;
|
||||||
|
--text-main: #e6edf3;
|
||||||
|
--text-dim: #8b949e;
|
||||||
|
--accent-color: #2f81f7;
|
||||||
|
--accent-hover: #58a6ff;
|
||||||
|
--border-color: #30363d;
|
||||||
|
--success-color: #238636;
|
||||||
|
--font-mono: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航 */
|
||||||
|
header {
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-radius: 2px;
|
||||||
|
box-shadow: 0 0 8px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.version-tag {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: var(--border-color);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 控制区域 */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-upload-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-main);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: var(--accent-hover);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: #2ea043;
|
||||||
|
border-color: #2ea043;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 10px rgba(46, 160, 67, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
border-color: var(--border-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主体列表区域 */
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th, .data-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tr:hover td {
|
||||||
|
background-color: rgba(47, 129, 247, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.col-check { width: 50px; text-align: center; }
|
||||||
|
.col-title { width: auto; font-weight: 500; }
|
||||||
|
.col-date { width: 250px; font-family: var(--font-mono); color: var(--accent-color); }
|
||||||
|
|
||||||
|
/* 自定义复选框 */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"]:checked::after {
|
||||||
|
content: '✔';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条 */
|
||||||
|
::-webkit-scrollbar { width: 8px; }
|
||||||
|
::-webkit-scrollbar-track { background: var(--bg-color); }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>CHAT_FILTER <span class="version-tag">ARRAY_MODE</span></h1>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<span class="stats" id="stats-display">等待导入文件...</span>
|
||||||
|
|
||||||
|
<div class="file-upload-wrapper">
|
||||||
|
<label for="file-input" class="btn">📂 导入 JSON</label>
|
||||||
|
<input type="file" id="file-input" accept=".json,application/json">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="download-btn" class="btn btn-primary" disabled>⬇ 导出 JSON</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main-container">
|
||||||
|
<div class="empty-state" id="empty-state">
|
||||||
|
<p>请点击右上角导入 JSON 文件</p>
|
||||||
|
<p style="font-size: 0.8rem; margin-top: 10px; opacity: 0.6;">支持格式: Standard JSON Array `[{},{}]`</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="data-table" id="result-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="col-check">
|
||||||
|
<input type="checkbox" id="select-all" title="全选/取消全选">
|
||||||
|
</th>
|
||||||
|
<th class="col-title">TITLE</th>
|
||||||
|
<th class="col-date">INSERTED_AT</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 状态管理
|
||||||
|
let rawData = [];
|
||||||
|
let selectedIndices = new Set();
|
||||||
|
|
||||||
|
// DOM 元素
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const downloadBtn = document.getElementById('download-btn');
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const resultTable = document.getElementById('result-table');
|
||||||
|
const emptyState = document.getElementById('empty-state');
|
||||||
|
const statsDisplay = document.getElementById('stats-display');
|
||||||
|
const selectAllCheckbox = document.getElementById('select-all');
|
||||||
|
|
||||||
|
// 监听文件上传
|
||||||
|
fileInput.addEventListener('change', handleFileUpload);
|
||||||
|
|
||||||
|
// 监听全选
|
||||||
|
selectAllCheckbox.addEventListener('change', (e) => {
|
||||||
|
const checkboxes = document.querySelectorAll('.item-checkbox');
|
||||||
|
const isChecked = e.target.checked;
|
||||||
|
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = isChecked;
|
||||||
|
const index = parseInt(cb.dataset.index);
|
||||||
|
if (isChecked) {
|
||||||
|
selectedIndices.add(index);
|
||||||
|
} else {
|
||||||
|
selectedIndices.delete(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
updateStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听导出按钮
|
||||||
|
downloadBtn.addEventListener('click', exportData);
|
||||||
|
|
||||||
|
function handleFileUpload(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
// 重置界面
|
||||||
|
statsDisplay.textContent = "正在解析...";
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
rawData = [];
|
||||||
|
selectedIndices.clear();
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
downloadBtn.disabled = true;
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = function(e) {
|
||||||
|
try {
|
||||||
|
const content = e.target.result;
|
||||||
|
// 解析整个 JSON
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
|
||||||
|
// 校验格式:必须是数组
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
throw new Error("文件格式错误:JSON 根节点必须是数组 [...]");
|
||||||
|
}
|
||||||
|
|
||||||
|
rawData = parsed;
|
||||||
|
|
||||||
|
if (rawData.length > 0) {
|
||||||
|
renderTable();
|
||||||
|
emptyState.style.display = 'none';
|
||||||
|
resultTable.style.display = 'table';
|
||||||
|
downloadBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
statsDisplay.textContent = "JSON 数组为空";
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert(`解析失败: ${err.message}`);
|
||||||
|
statsDisplay.textContent = "错误";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsText(file, 'UTF-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
rawData.forEach((item, index) => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
|
||||||
|
// 宽容处理:防止某些对象缺少字段
|
||||||
|
const title = item.title || '<无标题>';
|
||||||
|
const date = item.inserted_at || '-';
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="col-check">
|
||||||
|
<input type="checkbox" class="item-checkbox" data-index="${index}">
|
||||||
|
</td>
|
||||||
|
<td class="col-title">${escapeHtml(title)}</td>
|
||||||
|
<td class="col-date">${escapeHtml(String(date))}</td>
|
||||||
|
`;
|
||||||
|
fragment.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
tableBody.appendChild(fragment);
|
||||||
|
|
||||||
|
// 更新统计
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用事件委托处理表格内的 Checkbox 点击
|
||||||
|
tableBody.addEventListener('change', (e) => {
|
||||||
|
if (e.target.classList.contains('item-checkbox')) {
|
||||||
|
const index = parseInt(e.target.dataset.index);
|
||||||
|
if (e.target.checked) {
|
||||||
|
selectedIndices.add(index);
|
||||||
|
} else {
|
||||||
|
selectedIndices.delete(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联动全选框状态
|
||||||
|
if (selectedIndices.size === rawData.length && rawData.length > 0) {
|
||||||
|
selectAllCheckbox.checked = true;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else if (selectedIndices.size === 0) {
|
||||||
|
selectAllCheckbox.checked = false;
|
||||||
|
selectAllCheckbox.indeterminate = false;
|
||||||
|
} else {
|
||||||
|
selectAllCheckbox.indeterminate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
const total = rawData.length;
|
||||||
|
const selected = selectedIndices.size;
|
||||||
|
statsDisplay.textContent = `总数: ${total} | 已选: ${selected}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportData() {
|
||||||
|
if (selectedIndices.size === 0) {
|
||||||
|
if(!confirm('未选择任何记录,确定要导出一个空数组吗?')) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选数据
|
||||||
|
const filteredData = rawData.filter((_, index) => selectedIndices.has(index));
|
||||||
|
|
||||||
|
// 转换为 JSON 字符串 (美化输出,缩进2空格)
|
||||||
|
const outputString = JSON.stringify(filteredData, null, 2);
|
||||||
|
|
||||||
|
// 创建 Blob
|
||||||
|
const blob = new Blob([outputString], { type: 'application/json;charset=utf-8' });
|
||||||
|
|
||||||
|
// 创建下载链接
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `filtered_chat_${new Date().toISOString().slice(0,10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防 XSS 简单转义
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (typeof text !== 'string') return text;
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue