diff --git a/cwd-admin/src/locales/en-US.json b/cwd-admin/src/locales/en-US.json index 0cb6813..d4b54e4 100644 --- a/cwd-admin/src/locales/en-US.json +++ b/cwd-admin/src/locales/en-US.json @@ -95,7 +95,8 @@ "source": { "cwd": "CWD (.json)", "twikoo": "Twikoo (.json)", - "artalk": "Artalk (.json)" + "artalk": "Artalk (.json)", + "valine": "Valine (.jsonl)" } }, "config": { @@ -144,15 +145,14 @@ "logs": { "title": "Operation Logs" }, - "prefixModal": { - "title": "Missing URL Prefix Detected", - "descPart1": "Detected ", - "descPart2": " comments with URL missing protocol prefix (http/https).", - "descPart3": "Add prefix during import?", - "label": "Domain Prefix (e.g. https://example.me)", - "placeholder": "Enter domain prefix", - "skip": "Skip (Don't Add)", - "confirm": "Add & Import" + "siteIdModal": { + "title": "Select Target Site", + "desc": "Select the site ID for imported comments, or enter a custom site ID.", + "selectLabel": "Select Site", + "customLabel": "Custom Site ID (Leave empty to use selected site)", + "customPlaceholder": "Enter custom site ID", + "cancel": "Cancel", + "confirm": "Import" }, "messages": { "exportSuccess": "Export Success", @@ -163,14 +163,12 @@ "importStatsSuccess": "Stats Imported", "importBackupSuccess": "Full Restore Success", "importCommentsSuccess": "Comments Imported", - "prefixRequired": "Please enter domain prefix", "importStart": "Importing: {name} (Mode: {mode})", "fileParseSuccess": "File parsed, processing...", "errorWithMessage": "Error: {msg}", "parsedCommentsCount": "Parsed {count} comments", - "detectMissingPrefix": "Detected {count} URLs missing prefix, waiting for confirmation...", - "prefixAdded": "Prefix added, continuing...", - "skipPrefix": "Skipped prefix addition", + "siteIdSelected": "Site selected: {siteId}", + "importCancelled": "Import cancelled", "readFileFailedLog": "Read file failed", "importCommentsDone": "Import Done: {message}" } diff --git a/cwd-admin/src/locales/zh-CN.json b/cwd-admin/src/locales/zh-CN.json index 7bf01e2..2ffc63f 100644 --- a/cwd-admin/src/locales/zh-CN.json +++ b/cwd-admin/src/locales/zh-CN.json @@ -95,7 +95,8 @@ "source": { "cwd": "CWD (.json)", "twikoo": "Twikoo (.json)", - "artalk": "Artalk (.json)" + "artalk": "Artalk (.json)", + "valine": "Valine (.jsonl)" } }, "config": { @@ -144,15 +145,14 @@ "logs": { "title": "操作日志" }, - "prefixModal": { - "title": "检测到 URL 缺失前缀", - "descPart1": "检测到 ", - "descPart2": " 条评论的 URL 不存在域名前缀(http/https)。", - "descPart3": "是否在导入时统一添加?", - "label": "域名前缀 (例如 https://example.me)", - "placeholder": "请输入域名前缀", - "skip": "直接导入 (不添加)", - "confirm": "添加并导入" + "siteIdModal": { + "title": "选择目标站点", + "desc": "请选择导入评论所属的站点 ID,或输入自定义站点 ID。", + "selectLabel": "选择站点", + "customLabel": "自定义站点 ID(留空则使用上方选择的站点)", + "customPlaceholder": "输入自定义站点 ID", + "cancel": "取消", + "confirm": "确认导入" }, "messages": { "exportSuccess": "导出成功", @@ -163,14 +163,12 @@ "importStatsSuccess": "统计数据导入成功", "importBackupSuccess": "全量恢复成功", "importCommentsSuccess": "评论导入成功", - "prefixRequired": "请输入域名前缀", "importStart": "开始导入: {name} (模式: {mode})", "fileParseSuccess": "文件解析成功,开始处理...", "errorWithMessage": "错误: {msg}", "parsedCommentsCount": "解析到 {count} 条评论数据", - "detectMissingPrefix": "检测到 {count} 条 URL 缺失前缀,等待用户确认...", - "prefixAdded": "已添加前缀,继续导入...", - "skipPrefix": "用户跳过前缀添加", + "siteIdSelected": "已选择站点: {siteId}", + "importCancelled": "导入已取消", "readFileFailedLog": "读取文件失败", "importCommentsDone": "导入完成: {message}" } diff --git a/cwd-admin/src/styles/components/data.less b/cwd-admin/src/styles/components/data.less index 4c0e86e..f3af014 100644 --- a/cwd-admin/src/styles/components/data.less +++ b/cwd-admin/src/styles/components/data.less @@ -65,7 +65,6 @@ } .log-container { - margin-top: 16px; padding: 12px; background-color: var(--bg-sider); border-radius: 6px; diff --git a/cwd-admin/src/views/DataView/index.vue b/cwd-admin/src/views/DataView/index.vue index 275ab91..be51f95 100644 --- a/cwd-admin/src/views/DataView/index.vue +++ b/cwd-admin/src/views/DataView/index.vue @@ -32,6 +32,7 @@ + - @@ -245,6 +251,7 @@ import { saveS3Settings, triggerS3Backup, S3SettingsResponse, + fetchSiteList, } from "../../api/admin"; import S3BackupModal from "./components/S3BackupModal.vue"; import { useSite } from "../../composables/useSite"; @@ -264,10 +271,11 @@ const { currentSiteId } = useSite(); // 当前导入模式: comments | config | stats | backup const currentImportMode = ref("comments"); -// 前缀处理相关状态 -const showPrefixModal = ref(false); -const urlPrefix = ref(""); -const missingPrefixCount = ref(0); +// site_id 选择相关状态 +const showSiteIdModal = ref(false); +const siteOptions = ref<{ label: string; value: string }[]>([]); +const selectedSiteId = ref(""); +const customSiteId = ref(""); const pendingJson = ref([]); const s3Config = ref({ endpoint: "", @@ -323,8 +331,27 @@ function handleS3BackupModalClose() { onMounted(() => { loadS3Config(); + loadSiteOptions(); }); +async function loadSiteOptions() { + try { + const res = await fetchSiteList(); + const sites = Array.isArray(res.sites) ? res.sites : []; + const unique = Array.from(new Set(sites)); + siteOptions.value = unique + .filter((s) => s && s !== "default") + .map((s) => ({ + label: s, + value: s, + })); + // 默认选择当前站点 + selectedSiteId.value = currentSiteId.value || "default"; + } catch { + siteOptions.value = []; + } +} + function showToast(msg: string, type: "success" | "error" = "success") { toastMessage.value = msg; toastType.value = type; @@ -395,13 +422,19 @@ async function handleFileChange(event: Event) { try { const content = e.target?.result as string; let json; - try { - json = JSON.parse(content); - } catch (parseError) { - throw new Error(t("data.messages.jsonParseFailed")); - } - addLog(t("data.messages.fileParseSuccess")); + // Valine 使用 NDJSON 格式,需要特殊处理 + if (currentImportMode.value === "comments" && importSource.value === "valine") { + json = parseNDJSON(content); + addLog(t("data.messages.fileParseSuccess")); + } else { + try { + json = JSON.parse(content); + } catch (parseError) { + throw new Error(t("data.messages.jsonParseFailed")); + } + addLog(t("data.messages.fileParseSuccess")); + } switch (currentImportMode.value) { case "comments": @@ -434,6 +467,29 @@ async function handleFileChange(event: Event) { reader.readAsText(file); } +// 解析 NDJSON (JSON-streaming) 格式,用于 Valine/LeanCloud 导出数据 +function parseNDJSON(content: string): any[] { + const lines = content.split("\n"); + const results: any[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + // 跳过空行和文件类型声明行 + if (!line || line.startsWith("#filetype:")) { + continue; + } + try { + const obj = JSON.parse(line); + results.push(obj); + } catch (e) { + // 忽略解析失败的行,继续处理下一行 + console.warn(`Failed to parse line ${i + 1}: ${line.substring(0, 50)}...`); + } + } + + return results; +} + // 导入处理逻辑 async function processImportConfig(data: any) { const res = await importConfig(data); @@ -460,31 +516,21 @@ async function processImportComments(json: any) { const comments = Array.isArray(json) ? json : [json]; addLog(t("data.messages.parsedCommentsCount", { count: comments.length })); - // 检查 URL 前缀 (仅针对评论导入) - let missingCount = 0; - for (const item of comments) { - const url = item.href || item.page_key || item.post_slug; - if (url && typeof url === "string") { - if (!url.startsWith("http://") && !url.startsWith("https://")) { - missingCount++; - } - } - } - - if (missingCount > 0) { - addLog(t("data.messages.detectMissingPrefix", { count: missingCount })); - missingPrefixCount.value = missingCount; - pendingJson.value = comments; - showPrefixModal.value = true; - // 暂停,等待 Modal 操作 - } else { - await executeImportComments(comments); - } + // 弹出 site_id 选择弹窗 + pendingJson.value = comments; + selectedSiteId.value = currentSiteId.value || "default"; + customSiteId.value = ""; + showSiteIdModal.value = true; } -async function executeImportComments(comments: any[]) { +async function executeImportComments(comments: any[], siteId?: string) { try { - const res = await importComments(comments); + // 为所有评论设置 site_id + const commentsWithSiteId = comments.map((item) => ({ + ...item, + site_id: siteId || currentSiteId.value || "default", + })); + const res = await importComments(commentsWithSiteId); addLog(t("data.messages.importCommentsDone", { message: res.message })); showToast(t("data.messages.importCommentsSuccess")); } catch (err: any) { @@ -495,59 +541,19 @@ async function executeImportComments(comments: any[]) { } } -// 前缀确认逻辑 -async function confirmPrefix() { - if (!urlPrefix.value) { - showToast(t("data.messages.prefixRequired"), "error"); - return; - } - - let prefix = urlPrefix.value.trim(); - const comments = pendingJson.value.map((item) => { - const newItem = { ...item }; - - // Twikoo - if (newItem.href && typeof newItem.href === "string") { - if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) { - newItem.href = joinUrl(prefix, newItem.href); - } - } - // Artalk - if (newItem.page_key && typeof newItem.page_key === "string") { - if ( - !newItem.page_key.startsWith("http://") && - !newItem.page_key.startsWith("https://") - ) { - newItem.page_key = joinUrl(prefix, newItem.page_key); - } - } - // CWD - if (newItem.post_slug && typeof newItem.post_slug === "string") { - if ( - !newItem.post_slug.startsWith("http://") && - !newItem.post_slug.startsWith("https://") - ) { - newItem.post_slug = joinUrl(prefix, newItem.post_slug); - } - } - return newItem; - }); - - showPrefixModal.value = false; - addLog(t("data.messages.prefixAdded")); - await executeImportComments(comments); +// site_id 确认逻辑 +async function confirmSiteId() { + const siteId = customSiteId.value.trim() || selectedSiteId.value || "default"; + addLog(t("data.messages.siteIdSelected", { siteId })); + showSiteIdModal.value = false; + await executeImportComments(pendingJson.value, siteId); } -function cancelPrefix() { - showPrefixModal.value = false; - addLog(t("data.messages.skipPrefix")); - executeImportComments(pendingJson.value); -} - -function joinUrl(prefix: string, path: string): string { - if (prefix.endsWith("/") && path.startsWith("/")) return prefix + path.substring(1); - if (!prefix.endsWith("/") && !path.startsWith("/")) return prefix + "/" + path; - return prefix + path; +function cancelSiteId() { + showSiteIdModal.value = false; + addLog(t("data.messages.importCancelled")); + importing.value = false; + pendingJson.value = []; } diff --git a/cwd-api/src/api/admin/importComments.ts b/cwd-api/src/api/admin/importComments.ts index 07df044..1713331 100644 --- a/cwd-api/src/api/admin/importComments.ts +++ b/cwd-api/src/api/admin/importComments.ts @@ -10,14 +10,49 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => { return c.json({ message: '导入数据为空' }, 400); } - // 映射 Twikoo / Artalk 数据结构到 CWD 结构 + // 映射 Twikoo / Artalk / Valine 数据结构到 CWD 结构 const comments = rawComments.map((item: any) => { + // Valine/LeanCloud 特征检测 + const isValine = + item.objectId !== undefined && + item.insertedAt !== undefined && + item.nick !== undefined; // Twikoo 特征检测 const isTwikoo = - item.href !== undefined || item.nick !== undefined || item.comment !== undefined; + item.href !== undefined || item.comment !== undefined; // Artalk 特征检测 (page_key 是 Artalk 特有的) const isArtalk = item.page_key !== undefined && item.content !== undefined; + if (isValine) { + // Valine/LeanCloud 映射逻辑 + // 处理时间: insertedAt 是嵌套对象 { __type: "Date", iso: "ISO字符串" } + let created = Date.now(); + if (item.insertedAt && item.insertedAt.iso) { + created = new Date(item.insertedAt.iso).getTime(); + } else if (item.createdAt) { + created = new Date(item.createdAt).getTime(); + } + + return { + created, // >>> insertedAt.iso 转为时间戳 + post_slug: item.url || '', // >>> url + post_url: item.url || '', // >>> url + name: item.nick || 'Anonymous', // >>> nick + email: item.mail || '', // >>> mail + url: item.link || null, // >>> link + ip_address: item.ip || null, // >>> ip + device: null, // >>> 保持空 + os: null, // >>> 保持空 + browser: null, // >>> 保持空 + ua: item.ua || null, // >>> ua + content_text: item.comment || '', // >>> comment + content_html: item.comment || '', // >>> comment + parent_id: null, // >>> 保持 null + status: 'approved', // >>> 保持 "approved" + site_id: item.site_id + }; + } + if (isArtalk) { // Artalk 映射逻辑 // 处理 ID: Artalk ID 通常是数字 @@ -49,7 +84,8 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => { content_text: item.content || '', // >>> content content_html: item.content || '', // >>> content parent_id: null, // >>> 保持 null - status: 'approved' // >>> 保持 "approved" + status: 'approved', // >>> 保持 "approved" + site_id: item.site_id }; } @@ -86,7 +122,8 @@ export const importComments = async (c: Context<{ Bindings: Bindings }>) => { content_text: item.comment || "", // >>> comment content_html: item.comment || "", // >>> comment parent_id: null, // >>> 保持 null - status: "approved" // >>> approved + status: "approved", // >>> approved + site_id: item.site_id }; }