feat(admin): 添加 Valine 评论导入支持并重构导入流程

This commit is contained in:
anghunk
2026-03-16 16:12:24 +08:00
parent cd25211e31
commit 74e88e5fb4
5 changed files with 180 additions and 142 deletions

View File

@@ -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}"
}

View File

@@ -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}"
}

View File

@@ -65,7 +65,6 @@
}
.log-container {
margin-top: 16px;
padding: 12px;
background-color: var(--bg-sider);
border-radius: 6px;

View File

@@ -32,6 +32,7 @@
<option value="cwd">{{ t("data.sections.comments.source.cwd") }}</option>
<option value="twikoo">{{ t("data.sections.comments.source.twikoo") }}</option>
<option value="artalk">{{ t("data.sections.comments.source.artalk") }}</option>
<option value="valine">{{ t("data.sections.comments.source.valine") }}</option>
</select>
<button
class="card-button secondary"
@@ -43,6 +44,16 @@
</div>
</div>
<!-- 导入日志 -->
<div v-if="importLogs.length > 0" class="log-container">
<div class="log-title">{{ t("data.logs.title") }}</div>
<div class="log-list">
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
{{ log }}
</div>
</div>
</div>
<!-- 2. 系统配置 -->
<div class="card">
<h3 class="card-title">{{ t("data.sections.config.title") }}</h3>
@@ -176,46 +187,41 @@
<input
type="file"
ref="fileInput"
accept=".json"
accept=".json,.jsonl"
style="display: none"
@change="handleFileChange"
/>
<!-- 导入日志 -->
<div v-if="importLogs.length > 0" class="log-container">
<div class="log-title">{{ t("data.logs.title") }}</div>
<div class="log-list">
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
{{ log }}
</div>
</div>
</div>
<!-- 前缀确认弹窗 -->
<div v-if="showPrefixModal" class="modal-overlay">
<!-- site_id 选择弹窗 -->
<div v-if="showSiteIdModal" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">{{ t("data.prefixModal.title") }}</h3>
<h3 class="modal-title">{{ t("data.siteIdModal.title") }}</h3>
<p class="modal-desc">
{{ t("data.prefixModal.descPart1") }}
<strong>{{ missingPrefixCount }}</strong>
{{ t("data.prefixModal.descPart2") }}<br />
{{ t("data.prefixModal.descPart3") }}
{{ t("data.siteIdModal.desc") }}
</p>
<div class="form-group">
<label class="form-label">{{ t("data.prefixModal.label") }}</label>
<label class="form-label">{{ t("data.siteIdModal.selectLabel") }}</label>
<select v-model="selectedSiteId" class="form-select" style="width: 100%">
<option value="default">{{ t("layout.defaultSite") }}</option>
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</div>
<div class="form-group">
<label class="form-label">{{ t("data.siteIdModal.customLabel") }}</label>
<input
v-model="urlPrefix"
v-model="customSiteId"
class="form-input"
:placeholder="t('data.prefixModal.placeholder')"
@keyup.enter="confirmPrefix"
:placeholder="t('data.siteIdModal.customPlaceholder')"
/>
</div>
<div class="modal-actions">
<button class="modal-btn secondary" @click="cancelPrefix">
{{ t("data.prefixModal.skip") }}
<button class="modal-btn secondary" @click="cancelSiteId">
{{ t("data.siteIdModal.cancel") }}
</button>
<button class="modal-btn primary" @click="confirmPrefix">
{{ t("data.prefixModal.confirm") }}
<button class="modal-btn primary" @click="confirmSiteId">
{{ t("data.siteIdModal.confirm") }}
</button>
</div>
</div>
@@ -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<string>("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<any[]>([]);
const s3Config = ref<S3SettingsResponse>({
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 = [];
}
</script>

View File

@@ -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
};
}