Files
cwd/cwd-admin/src/views/DataView.vue
anghunk c95ca722bc fix(ui): 修复按钮最小宽度和分页路由同步问题
- 为按钮添加最小宽度100px防止内容截断
- 修改模态框背景透明度提升视觉层次
- 统一错误日志中的冒号为中文全角
- 重构分页组件,同步当前页码到路由参数
- 初始化时从路由参数读取页码
2026-01-21 11:19:35 +08:00

538 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page">
<h2 class="page-title">数据管理</h2>
<div
v-if="toastVisible"
class="toast"
:class="toastType === 'error' ? 'toast-error' : 'toast-success'"
>
{{ toastMessage }}
</div>
<div class="card">
<h3 class="card-title">数据导出</h3>
<p class="card-desc">将所有评论数据导出为 JSON 格式</p>
<div class="card-actions">
<button class="card-button" :disabled="exporting" @click="handleExport">
<span v-if="exporting">导出中...</span>
<span v-else>导出 JSON</span>
</button>
</div>
</div>
<div class="card">
<h3 class="card-title">数据导入</h3>
<p class="card-desc">从其他评论系统导入数据请选择来源并上传 JSON 文件</p>
<div class="form-group">
<label class="form-label">来源系统</label>
<select v-model="importSource" class="form-select">
<option value="twikoo">Twikoo (.json)</option>
<option value="artalk">Artalk (.json)</option>
</select>
</div>
<div class="card-actions">
<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">
{{ log }}
</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>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { exportComments, importComments } from "../api/admin";
const exporting = ref(false);
const importing = ref(false);
const importSource = ref("twikoo");
const fileInput = ref<HTMLInputElement | null>(null);
const toastMessage = ref("");
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;
toastVisible.value = true;
window.setTimeout(() => {
toastVisible.value = false;
}, 2000);
}
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 timeStr = `${y}.${m}.${d} ${h}:${min}:${s}`;
importLogs.value.push(`${timeStr} ${msg}`);
}
async function handleExport() {
exporting.value = true;
try {
const data = await exportComments();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `comments-export-${new Date().toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("导出成功", "success");
} catch (e: any) {
showToast(e.message || "导出失败", "error");
} finally {
exporting.value = false;
}
}
function triggerFileInput() {
fileInput.value?.click();
}
async function handleFileChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
// 重置 input允许重复选择同一文件
target.value = "";
// 清空之前的日志
importLogs.value = [];
showPrefixModal.value = false;
urlPrefix.value = "";
pendingJson.value = [];
importing.value = true;
addLog(`开始导入文件 ${file.name}`);
addLog("正在读取文件...");
const reader = new FileReader();
reader.onload = async (e) => {
try {
const content = e.target?.result as string;
addLog("文件读取完成,正在解析数据...");
let json;
try {
json = JSON.parse(content);
} catch (parseError) {
throw new Error("JSON 解析失败,请检查文件格式");
}
const comments = Array.isArray(json) ? json : [json];
const count = comments.length;
addLog(`解析成功,共 ${count} 条数据`);
// 检测前缀逻辑
let missingCount = 0;
for (const item of comments) {
// 优先检查 Twikoo 字段 href其次 Artalk 字段 page_key最后 CWD 字段 post_slug
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(`检测到 ${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 || "未知错误"}`);
showToast(err.message || "导入失败,文件格式错误", "error");
importing.value = false;
}
};
reader.onerror = () => {
addLog("读取文件失败");
showToast("读取文件失败", "error");
importing.value = false;
};
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);
}
}
// 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(`已为 ${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;
gap: 12px;
max-width: 520px;
}
.page-title {
margin: 0;
font-size: 18px;
color: #24292f;
}
.card {
background-color: #ffffff;
border-radius: 6px;
border: 1px solid #d0d7de;
padding: 16px 18px;
margin-bottom: 1em;
}
.card-title {
margin: 0 0 12px;
font-size: 15px;
}
.card-desc {
font-size: 14px;
color: #57606a;
margin: 0 0 16px;
}
.card-actions {
display: flex;
justify-content: flex-end;
}
.card-button {
padding: 8px 14px;
border-radius: 4px;
border: none;
background-color: #0969da;
color: #ffffff;
font-size: 14px;
cursor: pointer;
min-width: 100px;
}
.card-button:disabled {
opacity: 0.7;
cursor: default;
}
.form-group {
margin-bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
color: #24292f;
font-weight: 500;
}
.form-select {
padding: 6px 8px;
border: 1px solid #d0d7de;
border-radius: 4px;
font-size: 14px;
color: #24292f;
background-color: #f6f8fa;
outline: none;
}
.form-select:focus {
border-color: #0969da;
box-shadow: 0 0 0 2px rgba(9, 105, 218, 0.3);
}
.toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
min-width: 220px;
max-width: 320px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
box-shadow: 0 8px 24px rgba(140, 149, 159, 0.2);
z-index: 1000;
}
.toast-success {
background-color: #1a7f37;
color: #ffffff;
}
.toast-error {
background-color: #d1242f;
color: #ffffff;
}
.log-container {
margin-top: 16px;
padding: 12px;
background-color: #f6f8fa;
border-radius: 6px;
border: 1px solid #d0d7de;
}
.log-title {
font-size: 13px;
font-weight: 600;
color: #24292f;
margin-bottom: 8px;
}
.log-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 200px;
overflow-y: auto;
}
.log-item {
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono",
monospace;
color: #57606a;
line-height: 1.5;
}
</style>