feat: 新增指定格式导入

This commit is contained in:
digua
2026-04-10 20:58:00 +08:00
parent 96e21f7bd1
commit b8a0e9e8da
10 changed files with 293 additions and 3 deletions
+9
View File
@@ -73,6 +73,15 @@ export function getSupportedFormats(): FormatFeature[] {
return sniffer.getSupportedFormats()
}
/**
* 根据格式 ID 获取格式特征
* 用于手动指定格式时跳过嗅探
*/
export function getFormatFeatureById(formatId: string): FormatFeature | null {
const all = sniffer.getSupportedFormats()
return all.find((f) => f.id === formatId) ?? null
}
/**
* 获取格式的预处理器(如果有)
*/
@@ -10,6 +10,7 @@ import {
streamParseFile,
detectFormat,
detectAllFormats,
getFormatFeatureById,
getPreprocessor,
needsPreprocess,
type ParsedMeta,
@@ -144,6 +145,16 @@ export async function streamImport(
requestId: string,
formatOptions?: Record<string, unknown>
): Promise<StreamImportResult> {
// 用户手动指定格式时,跳过自动检测直接使用指定的 Parser
if (formatOptions?.formatId) {
const formatId = formatOptions.formatId as string
const feature = getFormatFeatureById(formatId)
if (!feature) {
return { success: false, error: 'error.unknown_format_id' }
}
return streamImportSingle(filePath, requestId, feature, formatOptions)
}
// 检测所有匹配的格式(按优先级排序)
const candidates = detectAllFormats(filePath)
if (candidates.length === 0) {
+3 -1
View File
@@ -226,7 +226,9 @@ export const chatApi = {
/**
* 获取支持的格式列表
*/
getSupportedFormats: (): Promise<Array<{ name: string; platform: string }>> => {
getSupportedFormats: (): Promise<
Array<{ id: string; name: string; platform: string; extensions: string[] }>
> => {
return ipcRenderer.invoke('chat:getSupportedFormats')
},
+1 -1
View File
@@ -135,7 +135,7 @@ interface ChatApi {
) => Promise<Array<{ type: MessageType; count: number }>>
getTimeRange: (sessionId: string) => Promise<{ start: number; end: number } | null>
getDbDirectory: () => Promise<string | null>
getSupportedFormats: () => Promise<Array<{ name: string; platform: string }>>
getSupportedFormats: () => Promise<Array<{ id: string; name: string; platform: string; extensions: string[] }>>
onImportProgress: (callback: (progress: ImportProgress) => void) => () => void
getCatchphraseAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<CatchphraseAnalysis>
getMentionAnalysis: (sessionId: string, filter?: TimeFilter) => Promise<MentionAnalysis>
+6
View File
@@ -100,6 +100,12 @@
"import": "Import ({count})",
"noChats": "No chats found"
},
"formatSelector": {
"title": "Select Chat Format",
"hint": "Unable to auto-detect the file format. Please manually select the corresponding chat format.",
"manualSelect": "Select format manually",
"confirm": "Import with this format"
},
"footer": {
"website": "Website",
"terms": "Terms of Use"
+6
View File
@@ -100,6 +100,12 @@
"import": "インポート ({count})",
"noChats": "チャットが見つかりません"
},
"formatSelector": {
"title": "チャット形式を選択",
"hint": "ファイル形式を自動検出できませんでした。対応するチャット形式を手動で選択してください。",
"manualSelect": "形式を手動で選択",
"confirm": "この形式でインポート"
},
"footer": {
"website": "公式サイト",
"terms": "利用規約"
+6
View File
@@ -100,6 +100,12 @@
"import": "导入 ({count})",
"noChats": "未发现任何聊天"
},
"formatSelector": {
"title": "选择聊天记录格式",
"hint": "无法自动识别此文件的格式,请手动选择对应的聊天记录格式。",
"manualSelect": "手动选择格式",
"confirm": "使用此格式导入"
},
"footer": {
"website": "官网",
"terms": "使用条款"
+6
View File
@@ -100,6 +100,12 @@
"import": "匯入 ({count})",
"noChats": "未發現任何聊天"
},
"formatSelector": {
"title": "選擇聊天記錄格式",
"hint": "無法自動識別此檔案的格式,請手動選擇對應的聊天記錄格式。",
"manualSelect": "手動選擇格式",
"confirm": "使用此格式匯入"
},
"footer": {
"website": "官網",
"terms": "使用條款"
@@ -0,0 +1,174 @@
<script setup lang="ts">
/**
* 格式选择器弹窗
* 当自动检测无法识别文件格式时,让用户手动选择对应的聊天记录格式。
*/
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
interface FormatInfo {
id: string
name: string
platform: string
extensions: string[]
}
const props = defineProps<{
open: boolean
/** 导入失败的文件路径(用于显示) */
filePath?: string
}>()
const emit = defineEmits<{
'update:open': [value: boolean]
select: [formatId: string]
}>()
const { t } = useI18n()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
const formats = ref<FormatInfo[]>([])
const loading = ref(false)
const selectedFormatId = ref<string | null>(null)
/** 只展示可手动选择的平台(避免用户面对过多无关格式) */
const MANUAL_SELECTABLE_PLATFORMS = new Set(['whatsapp', 'line', 'unknown'])
/** 按平台分组(仅展示可手动选择的格式) */
const groupedFormats = computed(() => {
const map = new Map<string, FormatInfo[]>()
for (const f of formats.value) {
if (!MANUAL_SELECTABLE_PLATFORMS.has(f.platform)) continue
const group = map.get(f.platform) ?? []
group.push(f)
map.set(f.platform, group)
}
return map
})
/** 平台图标映射 */
function getPlatformIcon(platform: string): string {
const p = platform.toLowerCase()
if (p.includes('whatsapp')) return 'i-simple-icons-whatsapp'
if (p.includes('telegram')) return 'i-simple-icons-telegram'
if (p.includes('line')) return 'i-simple-icons-line'
if (p.includes('qq')) return 'i-simple-icons-tencentqq'
if (p.includes('wechat') || p.includes('weixin')) return 'i-simple-icons-wechat'
if (p.includes('discord')) return 'i-simple-icons-discord'
if (p.includes('instagram')) return 'i-simple-icons-instagram'
if (p.includes('chatlab')) return 'i-heroicons-chat-bubble-left-right'
return 'i-heroicons-chat-bubble-left-right'
}
async function loadFormats() {
loading.value = true
try {
const result = await window.chatApi.getSupportedFormats()
formats.value = result
} catch {
formats.value = []
} finally {
loading.value = false
}
}
watch(
() => props.open,
(val) => {
if (val) {
selectedFormatId.value = null
loadFormats()
}
}
)
function confirmSelection() {
if (selectedFormatId.value) {
isOpen.value = false
emit('select', selectedFormatId.value)
}
}
function handleClose() {
isOpen.value = false
}
</script>
<template>
<UModal v-model:open="isOpen" :title="t('home.formatSelector.title')">
<template #body>
<div class="min-h-[200px]">
<!-- 提示 -->
<p class="mb-3 text-sm text-gray-500 dark:text-gray-400">
{{ t('home.formatSelector.hint') }}
</p>
<!-- 加载中 -->
<div v-if="loading" class="flex items-center justify-center py-12">
<UIcon name="i-heroicons-arrow-path" class="h-6 w-6 animate-spin text-pink-500" />
</div>
<!-- 格式列表按平台分组 -->
<div v-else class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<div v-for="[platform, items] in groupedFormats" :key="platform">
<div class="mb-1 flex items-center gap-1.5 px-1">
<UIcon :name="getPlatformIcon(platform)" class="h-3.5 w-3.5 text-gray-400" />
<span class="text-xs font-medium text-gray-400 uppercase">{{ platform }}</span>
</div>
<div class="space-y-0.5">
<div
v-for="format in items"
:key="format.id"
class="flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 transition-colors"
:class="
selectedFormatId === format.id
? 'bg-pink-50 ring-1 ring-pink-200 dark:bg-pink-500/10 dark:ring-pink-500/30'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
"
@click="selectedFormatId = format.id"
>
<div
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors"
:class="
selectedFormatId === format.id
? 'border-pink-500 bg-pink-500'
: 'border-gray-300 dark:border-gray-600'
"
>
<UIcon
v-if="selectedFormatId === format.id"
name="i-heroicons-check-20-solid"
class="h-3 w-3 text-white"
/>
</div>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ format.name }}
</p>
<p class="text-xs text-gray-400">
{{ format.extensions.join(', ') }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<div class="flex w-full justify-end gap-2">
<UButton variant="ghost" color="neutral" @click="handleClose">
{{ t('common.cancel') }}
</UButton>
<UButton :disabled="!selectedFormatId" @click="confirmSelection">
{{ t('home.formatSelector.confirm') }}
</UButton>
</div>
</template>
</UModal>
</template>
+71 -1
View File
@@ -2,6 +2,7 @@
import { FileDropZone } from '@/components/UI'
import FileListItem from './FileListItem.vue'
import ChatSelector, { type ChatInfo } from './ChatSelector.vue'
import FormatSelectorModal from './FormatSelectorModal.vue'
import { storeToRefs } from 'pinia'
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -29,6 +30,10 @@ const {
const showChatSelector = ref(false)
const chatSelectorFilePath = ref('')
// 格式选择器状态(自动检测失败时的手动兜底)
const showFormatSelector = ref(false)
const formatSelectorFilePath = ref('')
// 自动生成会话索引(与 importFileFromPath 保持一致)
async function autoGenerateSessionIndex(sessionId: string) {
try {
@@ -163,6 +168,10 @@ async function processFilePaths(paths: string[]) {
const result = await sessionStore.importFileFromPath(paths[0])
if (!result.success && result.error) {
importError.value = translateError(result.error)
// 格式无法识别时,记住文件路径以便手动选择格式
if (result.error === 'error.unrecognized_format') {
formatSelectorFilePath.value = paths[0]
}
if (result.diagnosisSuggestion) {
diagnosisSuggestion.value = result.diagnosisSuggestion
}
@@ -194,6 +203,49 @@ async function processFilePaths(paths: string[]) {
await sessionStore.mergeImportFiles(paths)
}
// 手动格式选择后的导入处理
async function handleFormatSelect(formatId: string) {
const filePath = formatSelectorFilePath.value
if (!filePath) return
importError.value = null
diagnosisSuggestion.value = null
importDiagnostics.value = null
isImporting.value = true
importProgress.value = { stage: 'detecting', progress: 0, message: '' }
const unsubscribe = window.chatApi.onImportProgress((progress) => {
if (progress.stage === 'done') return
importProgress.value = progress
})
try {
const result = await window.chatApi.importWithOptions(filePath, { formatId })
unsubscribe()
if (importProgress.value) {
importProgress.value.progress = 100
}
await new Promise((resolve) => setTimeout(resolve, 300))
if (result.success && result.sessionId) {
await sessionStore.loadSessions()
sessionStore.selectSession(result.sessionId)
await autoGenerateSessionIndex(result.sessionId)
await navigateToSession(result.sessionId)
} else {
importError.value = translateError(result.error || 'error.import_failed')
}
} catch (error) {
importError.value = String(error)
} finally {
isImporting.value = false
setTimeout(() => {
importProgress.value = null
}, 500)
}
}
// 聊天选择后的导入处理(通用,适用于 Telegram 等多聊天格式)
async function handleChatSelect(selectedChats: ChatInfo[]) {
if (selectedChats.length === 0) return
@@ -743,7 +795,18 @@ const getMergeFileProgressText = (file: MergeFileInfo) =>
<span>{{ diagnosisSuggestion }}</span>
</div>
</div>
<UButton v-if="hasImportLog" size="xs" @click="openLatestImportLog">{{ t('home.import.viewLog') }}</UButton>
<div class="flex gap-2">
<UButton v-if="hasImportLog" size="xs" @click="openLatestImportLog">{{ t('home.import.viewLog') }}</UButton>
<UButton
v-if="formatSelectorFilePath"
size="xs"
variant="soft"
icon="i-heroicons-list-bullet"
@click="showFormatSelector = true"
>
{{ t('home.formatSelector.manualSelect') }}
</UButton>
</div>
</div>
<UButton :to="tutorialUrl" target="_blank" trailing-icon="i-heroicons-chevron-right-20-solid">
@@ -752,5 +815,12 @@ const getMergeFileProgressText = (file: MergeFileInfo) =>
<!-- 聊天选择器多聊天格式通用 -->
<ChatSelector v-model:open="showChatSelector" :file-path="chatSelectorFilePath" @select="handleChatSelect" />
<!-- 格式选择器自动检测失败时的手动兜底 -->
<FormatSelectorModal
v-model:open="showFormatSelector"
:file-path="formatSelectorFilePath"
@select="handleFormatSelect"
/>
</div>
</template>