feat(数据导入): 添加URL前缀确认弹窗及处理逻辑

检测到导入数据中URL缺少http/https前缀时,显示弹窗让用户确认是否添加前缀。包含以下改进:
- 添加前缀确认弹窗组件及样式
- 实现前缀拼接逻辑
- 优化导入流程,支持中断等待用户确认
- 添加相关状态管理和样式
This commit is contained in:
anghunk
2026-01-20 14:17:45 +08:00
parent d4054f4d47
commit 78e263faa4

View File

@@ -8,12 +8,10 @@
>
{{ toastMessage }}
</div>
<div class="card">
<h3 class="card-title">数据导出</h3>
<p class="card-desc">
将所有评论数据导出为 JSON 格式
</p>
<p class="card-desc">将所有评论数据导出为 JSON 格式</p>
<div class="card-actions">
<button class="card-button" :disabled="exporting" @click="handleExport">
<span v-if="exporting">导出中...</span>
@@ -24,10 +22,8 @@
<div class="card">
<h3 class="card-title">数据导入</h3>
<p class="card-desc">
从其他评论系统导入数据请选择来源并上传 JSON 文件
</p>
<p class="card-desc">从其他评论系统导入数据请选择来源并上传 JSON 文件</p>
<div class="form-group">
<label class="form-label">来源系统</label>
<select v-model="importSource" class="form-select">
@@ -36,25 +32,52 @@
</div>
<div class="card-actions">
<input
type="file"
ref="fileInput"
accept=".json"
style="display: none"
<input
type="file"
ref="fileInput"
accept=".json"
style="display: none"
@change="handleFileChange"
>
/>
<button class="card-button" :disabled="importing" @click="triggerFileInput">
<span v-if="importing">导入中...</span>
<span v-else>选择文件并导入</span>
</button>
</div>
<div v-if="importLogs.length > 0" class="log-container">
<div class="log-title">导入日志</div>
<div class="log-list">
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
{{ log }}
</div>
</div>
</div>
</div>
</div>
<!-- 前缀确认弹窗 -->
<div v-if="showPrefixModal" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">检测到 URL 缺失前缀</h3>
<p class="modal-desc">
检测到 <strong>{{ missingPrefixCount }}</strong> 条评论的 URL
不存在域名前缀http/https<br />
是否在导入时统一添加
</p>
<div class="form-group">
<label class="form-label">域名前缀 (例如 https://example.me)</label>
<input
v-model="urlPrefix"
class="form-input"
placeholder="请输入域名前缀"
@keyup.enter="confirmPrefix"
/>
</div>
<div class="modal-actions">
<button class="modal-btn secondary" @click="cancelPrefix">
直接导入(不添加)
</button>
<button class="modal-btn primary" @click="confirmPrefix">添加并导入</button>
</div>
</div>
</div>
@@ -74,6 +97,12 @@ const toastType = ref<"success" | "error">("success");
const toastVisible = ref(false);
const importLogs = ref<string[]>([]);
// 前缀处理相关状态
const showPrefixModal = ref(false);
const urlPrefix = ref("");
const missingPrefixCount = ref(0);
const pendingJson = ref<any[]>([]);
function showToast(msg: string, type: "success" | "error" = "success") {
toastMessage.value = msg;
toastType.value = type;
@@ -86,11 +115,11 @@ function showToast(msg: string, type: "success" | "error" = "success") {
function addLog(msg: string) {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const h = String(now.getHours()).padStart(2, '0');
const min = String(now.getMinutes()).padStart(2, '0');
const s = String(now.getSeconds()).padStart(2, '0');
const m = String(now.getMonth() + 1).padStart(2, "0");
const d = String(now.getDate()).padStart(2, "0");
const h = String(now.getHours()).padStart(2, "0");
const min = String(now.getMinutes()).padStart(2, "0");
const s = String(now.getSeconds()).padStart(2, "0");
const timeStr = `${y}.${m}.${d} ${h}:${min}:${s}`;
importLogs.value.push(`${timeStr} ${msg}`);
}
@@ -99,11 +128,11 @@ async function handleExport() {
exporting.value = true;
try {
const data = await exportComments();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `comments-export-${new Date().toISOString().split('T')[0]}.json`;
a.download = `comments-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
@@ -126,10 +155,13 @@ async function handleFileChange(event: Event) {
if (!file) return;
// 重置 input允许重复选择同一文件
target.value = '';
target.value = "";
// 清空之前的日志
importLogs.value = [];
showPrefixModal.value = false;
urlPrefix.value = "";
pendingJson.value = [];
importing.value = true;
addLog(`开始导入文件 ${file.name}`);
@@ -141,29 +173,44 @@ async function handleFileChange(event: Event) {
try {
const content = e.target?.result as string;
addLog("文件读取完成,正在解析数据...");
let json;
try {
json = JSON.parse(content);
} catch (parseError) {
throw new Error("JSON 解析失败,请检查文件格式");
}
const count = Array.isArray(json) ? json.length : 1;
const comments = Array.isArray(json) ? json : [json];
const count = comments.length;
addLog(`解析成功,共 ${count} 条数据`);
addLog("正在上传并导入数据库...");
// 这里可以根据 importSource 做一些预处理,目前只有 twikoo/通用
// 假设 json 已经是我们要的格式
const res = await importComments(json);
addLog(`导入完成: ${res.message || '成功'}`);
showToast(res.message || "导入成功", "success");
// 检测前缀逻辑
let missingCount = 0;
for (const item of comments) {
// 优先检查 Twikoo 字段 href其次检查 CWD 字段 post_slug
const url = item.href || item.post_slug;
if (url && typeof url === "string") {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
missingCount++;
}
}
}
if (missingCount > 0) {
addLog(`检测到 ${missingCount} 条评论 URL 缺少前缀,等待确认...`);
missingPrefixCount.value = missingCount;
pendingJson.value = comments;
showPrefixModal.value = true;
// 暂停在这里,等待用户操作 modal
} else {
// 无需处理,直接导入
await executeImport(comments);
}
} catch (err: any) {
console.error(err);
addLog(`导入失败: ${err.message || '未知错误'}`);
addLog(`导入失败: ${err.message || "未知错误"}`);
showToast(err.message || "导入失败,文件格式错误", "error");
} finally {
importing.value = false;
}
};
@@ -176,9 +223,171 @@ async function handleFileChange(event: Event) {
reader.readAsText(file);
}
async function confirmPrefix() {
if (!urlPrefix.value) {
showToast("请输入域名前缀", "error");
return;
}
// 处理前缀,确保以 / 结尾或拼接正确
let prefix = urlPrefix.value.trim();
// 简单处理:如果 prefix 不以 / 结尾,且 url 不以 / 开头,可能需要补 /。
// 但通常用户输入的域名可能带也可能不带。
// 这里假设用户输入的 prefix 是 "https://example.com",而 href 是 "/posts/1" 或 "posts/1"
// 为了安全,如果 prefix 结尾没有 /,且 target 不以 / 开头,中间加个 /。
// 但更简单的策略是直接拼,让用户自己负责输入正确的。
// 考虑到用户习惯,我们做个简单的优化:如果 prefix 没 / 且 url 没 /,加一个。
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);
}
}
// 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(`已为 ${missingPrefixCount.value} 条数据添加前缀`);
await executeImport(comments);
}
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 cancelPrefix() {
showPrefixModal.value = false;
addLog("用户选择跳过添加前缀,直接导入");
executeImport(pendingJson.value);
}
async function executeImport(comments: any[]) {
addLog("正在上传并导入数据库...");
try {
const res = await importComments(comments);
addLog(`导入完成: ${res.message || "成功"}`);
showToast(res.message || "导入成功", "success");
} catch (err: any) {
console.error(err);
addLog(`导入失败: ${err.message || "未知错误"}`);
showToast(err.message || "导入失败", "error");
} finally {
importing.value = false;
pendingJson.value = [];
}
}
</script>
<style scoped>
/* ... (existing styles) ... */
.form-input {
padding: 8px;
border: 1px solid #d0d7de;
border-radius: 4px;
font-size: 14px;
width: 100%;
box-sizing: border-box;
}
.form-input:focus {
border-color: #0969da;
outline: none;
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal {
background-color: white;
border-radius: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
width: 400px;
max-width: 90%;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #24292f;
}
.modal-desc {
margin: 0;
font-size: 14px;
color: #57606a;
line-height: 1.5;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 8px;
}
.modal-btn {
padding: 8px 16px;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
border: 1px solid transparent;
}
.modal-btn.primary {
background-color: #0969da;
color: white;
}
.modal-btn.secondary {
background-color: #f6f8fa;
border-color: #d0d7de;
color: #24292f;
}
.modal-btn:hover {
opacity: 0.9;
}
.page {
display: flex;
flex-direction: column;
@@ -308,7 +517,8 @@ async function handleFileChange(event: Event) {
.log-item {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
color: #57606a;
line-height: 1.5;
}