feat(i18n): 为评论系统添加国际化支持

- 在管理后台引入 vue-i18n 并添加多语言文件
- 为前端评论组件添加翻译功能,支持自动语言检测
- 在功能设置中新增管理后台和组件语言配置选项
- 更新 API 接口以支持语言设置存储
- 优化菜单项文本溢出显示样式
This commit is contained in:
anghunk
2026-02-10 14:26:38 +08:00
parent 860830cee2
commit 4d9e2d8d31
39 changed files with 6023 additions and 332 deletions

View File

@@ -1,7 +1,7 @@
<template>
<div class="page">
<div style="display: flex; align-items: center; gap: 20px">
<h2 class="page-title">访问统计</h2>
<h2 class="page-title">{{ t('analytics.title') }}</h2>
</div>
<div
v-if="toastVisible"
@@ -13,18 +13,18 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">整体概览</h3>
<h3 class="card-title">{{ t('analytics.overview') }}</h3>
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else>
<div class="stats-grid">
<div class="stats-item">
<div class="stats-label">全站总访问量</div>
<div class="stats-label">{{ t('analytics.totalPv') }}</div>
<div class="stats-value"><CountTo :end-val="overview.totalPv" /></div>
</div>
<div class="stats-item">
<div class="stats-label">今日访问量</div>
<div class="stats-label">{{ t('analytics.todayPv') }}</div>
<div class="stats-value">
<CountTo :end-val="overview.todayPv" />
<span
@@ -39,7 +39,7 @@
</div>
</div>
<div class="stats-item">
<div class="stats-label">本周访问量</div>
<div class="stats-label">{{ t('analytics.weekPv') }}</div>
<div class="stats-value">
<CountTo :end-val="overview.weekPv" />
<span
@@ -54,7 +54,7 @@
</div>
</div>
<div class="stats-item">
<div class="stats-label">本月访问量</div>
<div class="stats-label">{{ t('analytics.monthPv') }}</div>
<div class="stats-value">
<CountTo :end-val="overview.monthPv" />
<span
@@ -69,7 +69,7 @@
</div>
</div>
<div class="stats-item">
<div class="stats-label">有访问记录的页面数</div>
<div class="stats-label">{{ t('analytics.totalPages') }}</div>
<div class="stats-value"><CountTo :end-val="overview.totalPages" /></div>
</div>
</div>
@@ -78,7 +78,7 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">访问趋势</h3>
<h3 class="card-title">{{ t('analytics.trend') }}</h3>
<div class="visit-tabs">
<button
class="visit-tab"
@@ -86,7 +86,7 @@
type="button"
@click="changeChartRange('7')"
>
最近 7
{{ t('analytics.last7Days') }}
</button>
<button
class="visit-tab"
@@ -94,11 +94,11 @@
type="button"
@click="changeChartRange('30')"
>
最近 30
{{ t('analytics.last30Days') }}
</button>
</div>
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div class="chart-wrapper">
<div ref="chartEl" class="chart"></div>
@@ -107,7 +107,7 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">页面访问明细</h3>
<h3 class="card-title">{{ t('analytics.pageDetail') }}</h3>
<div class="visit-tabs">
<button
class="visit-tab"
@@ -115,7 +115,7 @@
type="button"
@click="changeVisitTab('pv')"
>
PV 排序
{{ t('analytics.sort.pv') }}
</button>
<button
class="visit-tab"
@@ -123,20 +123,20 @@
type="button"
@click="changeVisitTab('latest')"
>
最新访问
{{ t('analytics.sort.latest') }}
</button>
</div>
</div>
<div v-if="listLoading" class="page-hint">加载中...</div>
<div v-if="listLoading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="items.length === 0" class="page-hint">暂无访问数据</div>
<div v-else-if="items.length === 0" class="page-hint">{{ t('analytics.noData') }}</div>
<div v-else class="domain-table-wrapper">
<div class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-title">页面标题</div>
<div class="domain-cell domain-cell-pv">访问量</div>
<div class="domain-cell domain-cell-time">最后访问时间</div>
<div class="domain-cell domain-cell-url">页面地址</div>
<div class="domain-cell domain-cell-title">{{ t('analytics.table.title') }}</div>
<div class="domain-cell domain-cell-pv">{{ t('analytics.table.pv') }}</div>
<div class="domain-cell domain-cell-time">{{ t('analytics.table.time') }}</div>
<div class="domain-cell domain-cell-url">{{ t('analytics.table.url') }}</div>
</div>
<div v-for="item in items" :key="item.postSlug" class="domain-table-row">
<div class="domain-cell domain-cell-title">
@@ -166,18 +166,18 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">点赞页面排行榜</h3>
<h3 class="card-title">{{ t('analytics.likeRank') }}</h3>
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else-if="likeStatsItems.length === 0" class="page-hint">暂无点赞数据</div>
<div v-else-if="likeStatsItems.length === 0" class="page-hint">{{ t('analytics.noLikeData') }}</div>
<div v-else class="domain-table-wrapper">
<div class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-rank">排名</div>
<div class="domain-cell domain-cell-title">页面标题</div>
<div class="domain-cell domain-cell-like">点赞数</div>
<div class="domain-cell domain-cell-url">页面地址</div>
<div class="domain-cell domain-cell-rank">{{ t('analytics.table.rank') }}</div>
<div class="domain-cell domain-cell-title">{{ t('analytics.table.title') }}</div>
<div class="domain-cell domain-cell-like">{{ t('analytics.table.like') }}</div>
<div class="domain-cell domain-cell-url">{{ t('analytics.table.url') }}</div>
</div>
<div
v-for="(item, index) in likeStatsItems"
@@ -214,6 +214,7 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject, computed } from "vue";
import type { Ref } from "vue";
import { useI18n } from "vue-i18n";
import * as echarts from "echarts";
import CountTo from "../../components/CountTo.vue";
import {
@@ -226,6 +227,8 @@ import {
} from "../../api/admin";
import { useSite } from "../../composables/useSite";
const { t } = useI18n();
const loading = ref(false);
const listLoading = ref(false);
const error = ref("");

View File

@@ -1,42 +1,42 @@
<template>
<div v-if="visible" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">编辑评论</h3>
<h3 class="modal-title">{{ t('comments.editModal.title') }}</h3>
<div v-if="form" class="modal-body">
<div class="form-item">
<label class="form-label">访客昵称</label>
<label class="form-label">{{ t('comments.editModal.name') }}</label>
<input v-model="form.name" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">访客邮箱</label>
<label class="form-label">{{ t('comments.editModal.email') }}</label>
<input v-model="form.email" class="form-input" type="email" />
</div>
<div class="form-item">
<label class="form-label">访客网址</label>
<label class="form-label">{{ t('comments.editModal.url') }}</label>
<input v-model="form.url" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">页面标识</label>
<label class="form-label">{{ t('comments.editModal.postSlug') }}</label>
<input v-model="form.postSlug" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">评论地址</label>
<label class="form-label">{{ t('comments.editModal.postUrl') }}</label>
<input v-model="form.postUrl" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">评论内容</label>
<label class="form-label">{{ t('comments.editModal.content') }}</label>
<textarea v-model="form.contentText" class="form-input" rows="4"></textarea>
</div>
<div class="form-item">
<label class="form-label">评论状态</label>
<label class="form-label">{{ t('comments.editModal.status') }}</label>
<select v-model="form.status" class="form-input">
<option value="approved">已通过</option>
<option value="pending">待审核</option>
<option value="rejected">已拒绝</option>
<option value="approved">{{ t('comments.statusFilter.approved') }}</option>
<option value="pending">{{ t('comments.statusFilter.pending') }}</option>
<option value="rejected">{{ t('comments.statusFilter.rejected') }}</option>
</select>
</div>
<div class="form-item">
<label class="form-label">置顶权重1 为不置顶数值越大越靠前</label>
<label class="form-label">{{ t('comments.editModal.priority') }}</label>
<input
v-model.number="form.priority"
class="form-input"
@@ -47,7 +47,7 @@
</div>
<div class="modal-actions">
<button class="modal-btn secondary" type="button" @click="handleClose">
取消
{{ t('comments.editModal.cancel') }}
</button>
<button
class="modal-btn primary"
@@ -55,8 +55,8 @@
:disabled="saving"
@click="handleSubmit"
>
<span v-if="saving">保存中...</span>
<span v-else>保存</span>
<span v-if="saving">{{ t('comments.editModal.saving') }}</span>
<span v-else>{{ t('comments.editModal.save') }}</span>
</button>
</div>
</div>
@@ -64,6 +64,10 @@
</template>
<script setup lang="ts">
import { useI18n } from "vue-i18n";
const { t } = useI18n();
interface EditForm {
id: number;
name: string;

View File

@@ -1,29 +1,29 @@
<template>
<div class="page">
<h2 class="page-title">评论管理</h2>
<h2 class="page-title">{{ t('comments.title') }}</h2>
<div class="toolbar">
<div class="toolbar-left">
<select v-model="statusFilter" class="toolbar-select">
<option value="">全部状态</option>
<option value="approved">已通过</option>
<option value="pending">待审核</option>
<option value="rejected">已拒绝</option>
<option value="">{{ t('comments.statusFilter.all') }}</option>
<option value="approved">{{ t('comments.statusFilter.approved') }}</option>
<option value="pending">{{ t('comments.statusFilter.pending') }}</option>
<option value="rejected">{{ t('comments.statusFilter.rejected') }}</option>
</select>
</div>
<div class="toolbar-right">
<button class="toolbar-button" @click="goPage(1)">刷新</button>
<button class="toolbar-button" @click="goPage(1)">{{ t('comments.refresh') }}</button>
</div>
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="error" class="page-error">{{ error }}</div>
<div v-else>
<div class="comment-table">
<div class="table-header">
<div class="table-cell table-cell-author">用户</div>
<div class="table-cell table-cell-content">评论信息</div>
<div class="table-cell table-cell-path">评论地址</div>
<div class="table-cell table-cell-status">状态</div>
<div class="table-cell table-cell-actions">操作</div>
<div class="table-cell table-cell-author">{{ t('comments.table.author') }}</div>
<div class="table-cell table-cell-content">{{ t('comments.table.content') }}</div>
<div class="table-cell table-cell-path">{{ t('comments.table.path') }}</div>
<div class="table-cell table-cell-status">{{ t('comments.table.status') }}</div>
<div class="table-cell table-cell-actions">{{ t('comments.table.actions') }}</div>
</div>
<div v-for="item in filteredComments" :key="item.id" class="table-row">
<div class="table-cell table-cell-author">
@@ -40,7 +40,7 @@
<span v-if="item.isAdmin" class="cell-admin-tag">
<svg
viewBox="0 0 22 22"
aria-label="网站管理员"
:aria-label="t('comments.table.admin')"
role="img"
class="cwd-admin-icon"
style="
@@ -63,7 +63,7 @@
<span
class="cell-email-text"
@click="handleBlockEmail(item)"
title="屏蔽该邮箱"
:title="t('comments.actions.blockEmail')"
>
{{ item.email }}
</span>
@@ -73,7 +73,7 @@
<span
class="cell-ip-text"
@click="handleBlockIp(item)"
title="屏蔽该 IP"
:title="t('comments.actions.blockIp')"
>{{ item.ipAddress }}</span
>
</div>
@@ -104,7 +104,7 @@
:title="String(item.priority)"
class="cell-status cell-pin-flag"
>
置顶
{{ t('comments.actions.pin') }}
</span>
<span class="cell-status cell-likes-number" v-if="item.likes !== 0">
<PhThumbsUp :size="13" />
@@ -126,21 +126,21 @@
:value="item.status"
@change="handleStatusChange(item, $event)"
>
<option value="approved">通过</option>
<option value="pending">待审</option>
<option value="rejected">拒绝</option>
<option value="approved">{{ t('comments.actions.approve') }}</option>
<option value="pending">{{ t('comments.actions.pending') }}</option>
<option value="rejected">{{ t('comments.actions.reject') }}</option>
</select>
<button class="table-action" @click="openEdit(item)">编辑</button>
<button class="table-action" @click="openEdit(item)">{{ t('comments.actions.edit') }}</button>
<button
class="table-action table-action-danger"
@click="removeComment(item)"
>
删除
{{ t('comments.actions.delete') }}
</button>
</div>
</div>
</div>
<div v-if="filteredComments.length === 0" class="table-empty">暂无数据</div>
<div v-if="filteredComments.length === 0" class="table-empty">{{ t('comments.empty') }}</div>
</div>
<div v-if="pagination.total > 1" class="pagination">
<button
@@ -148,7 +148,7 @@
:disabled="pagination.page <= 1"
@click="goPage(pagination.page - 1)"
>
上一页
{{ t('comments.pagination.prev') }}
</button>
<button
class="pagination-button"
@@ -199,10 +199,10 @@
:disabled="pagination.page >= pagination.total"
@click="goPage(pagination.page + 1)"
>
下一页
{{ t('comments.pagination.next') }}
</button>
<div class="pagination-jump">
<span>跳转到</span>
<span>{{ t('comments.pagination.jumpTo') }}</span>
<input
v-model="jumpPageInput"
class="pagination-input"
@@ -211,8 +211,8 @@
:max="pagination.total"
@keyup.enter="handleJumpPage"
/>
<span></span>
<button class="pagination-button" @click="handleJumpPage">确定</button>
<span>{{ t('comments.pagination.page') }}</span>
<button class="pagination-button" @click="handleJumpPage">{{ t('comments.pagination.confirm') }}</button>
</div>
</div>
</div>
@@ -231,6 +231,10 @@ import "../../styles/markdown.css";
import { onMounted, ref, computed, watch, inject } from "vue";
import type { Ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { marked } from "marked";
import DOMPurify from "dompurify";
import ModalEdit from "./components/ModalEdit.vue";
import {
CommentItem,
CommentListResponse,
@@ -241,12 +245,13 @@ import {
blockIp,
blockEmail,
} from "../../api/admin";
import ModalEdit from "./components/ModalEdit.vue";
import { useSite } from "../../composables/useSite";
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
// ... existing refs ...
const comments = ref<CommentItem[]>([]);
const pagination = ref<{ page: number; total: number }>({ page: 1, total: 1 });
const loading = ref(false);
@@ -268,6 +273,11 @@ const editForm = ref<{
priority: number;
} | null>(null);
const searchKeyword = ref(""); // Missing ref in original code snippet but used in template?
// Wait, I saw searchKeyword in original code snippet. I must ensure I don't lose it.
// I will just use the replace string for the specific parts if possible, but I am replacing whole template.
// I need to be careful with script content.
const filteredComments = computed(() => {
if (!statusFilter.value) {
return comments.value;
@@ -275,6 +285,7 @@ const filteredComments = computed(() => {
return comments.value.filter((item) => item.status === statusFilter.value);
});
// ... visiblePages computed ...
const visiblePages = computed(() => {
const total = pagination.value.total;
const current = pagination.value.page;
@@ -308,17 +319,18 @@ function formatDate(value: number) {
function formatStatus(status: string) {
if (status === "approved") {
return "已通过";
return t('comments.statusFilter.approved');
}
if (status === "pending") {
return "待审核";
return t('comments.statusFilter.pending');
}
if (status === "rejected") {
return "已拒绝";
return t('comments.statusFilter.rejected');
}
return status;
}
// ... other functions ...
async function loadComments(page?: number) {
const targetPage = typeof page === "number" ? page : 1;
loading.value = true;
@@ -341,9 +353,7 @@ function updateRoutePage(page: number) {
} else {
query.p = String(page);
}
// Removed domain from query as it's now global state
delete query.domain;
router.push({ query });
}
@@ -388,7 +398,7 @@ function handleStatusChange(item: CommentItem, event: Event) {
}
async function removeComment(item: CommentItem) {
if (!window.confirm(`确认删除评论 ${item.id} 吗`)) {
if (!window.confirm(t('comments.confirmDelete', { id: item.id }))) {
return;
}
try {
@@ -403,12 +413,12 @@ async function handleBlockIp(item: CommentItem) {
if (!item.ipAddress) {
return;
}
if (!window.confirm(`确认将 IP ${item.ipAddress} 加入黑名单吗?`)) {
if (!window.confirm(t('comments.confirmBlockIp', { ip: item.ipAddress }))) {
return;
}
try {
const res = await blockIp(item.ipAddress);
window.alert(res.message || "已加入 IP 黑名单");
window.alert(res.message || t('comments.successBlockIp'));
} catch (e: any) {
error.value = e.message || "屏蔽 IP 失败";
}
@@ -418,12 +428,12 @@ async function handleBlockEmail(item: CommentItem) {
if (!item.email) {
return;
}
if (!window.confirm(`确认将邮箱 ${item.email} 加入黑名单吗?`)) {
if (!window.confirm(t('comments.confirmBlockEmail', { email: item.email }))) {
return;
}
try {
const res = await blockEmail(item.email);
window.alert(res.message || "已加入邮箱黑名单");
window.alert(res.message || t('comments.successBlockEmail'));
} catch (e: any) {
error.value = e.message || "屏蔽邮箱失败";
}
@@ -535,6 +545,7 @@ watch(currentSiteId, () => {
updateRoutePage(1);
loadComments(1);
});
</script>
<style scoped lang="less">

View File

@@ -1,6 +1,6 @@
<template>
<div class="page">
<h2 class="page-title">数据管理</h2>
<h2 class="page-title">{{ t('data.title') }}</h2>
<div
v-if="toastVisible"
class="toast"
@@ -11,57 +11,57 @@
<!-- 1. 评论数据 -->
<div class="card">
<h3 class="card-title">评论数据</h3>
<p class="card-desc">管理评论内容支持从其他评论框架迁移数据</p>
<h3 class="card-title">{{ t('data.sections.comments.title') }}</h3>
<p class="card-desc">{{ t('data.sections.comments.desc') }}</p>
<div class="action-row">
<span class="action-label">导出:</span>
<span class="action-label">{{ t('data.sections.comments.exportLabel') }}</span>
<button class="card-button secondary" :disabled="exporting" @click="handleExportComments">
<span v-if="exporting">导出中...</span>
<span v-else>导出 JSON</span>
<span v-if="exporting">{{ t('data.sections.comments.exporting') }}</span>
<span v-else>{{ t('data.sections.comments.exportJson') }}</span>
</button>
</div>
<div class="action-row">
<span class="action-label">导入:</span>
<span class="action-label">{{ t('data.sections.comments.importLabel') }}</span>
<select v-model="importSource" class="form-select" style="min-width:120px;">
<option value="cwd">CWD (.json)</option>
<option value="twikoo">Twikoo (.json)</option>
<option value="artalk">Artalk (.json)</option>
<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>
</select>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('comments')">
导入评论
{{ t('data.sections.comments.importButton') }}
</button>
</div>
</div>
<!-- 2. 系统配置 -->
<div class="card">
<h3 class="card-title">系统配置</h3>
<p class="card-desc">管理后台设置邮件配置黑名单等</p>
<h3 class="card-title">{{ t('data.sections.config.title') }}</h3>
<p class="card-desc">{{ t('data.sections.config.desc') }}</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportConfig">导出配置</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">导入配置</button>
<button class="card-button secondary" :disabled="exporting" @click="handleExportConfig">{{ t('data.sections.config.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">{{ t('data.sections.config.import') }}</button>
</div>
</div>
<!-- 3. 访问统计 -->
<div class="card">
<h3 class="card-title">访问统计</h3>
<p class="card-desc">管理文章访问量点赞数及每日访问趋势</p>
<h3 class="card-title">{{ t('data.sections.stats.title') }}</h3>
<p class="card-desc">{{ t('data.sections.stats.desc') }}</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportStats">导出统计</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">导入统计</button>
<button class="card-button secondary" :disabled="exporting" @click="handleExportStats">{{ t('data.sections.stats.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">{{ t('data.sections.stats.import') }}</button>
</div>
</div>
<!-- 4. 全量备份 -->
<div class="card">
<h3 class="card-title">全量备份</h3>
<p class="card-desc">一键备份或恢复系统所有数据评论 + 配置 + 统计</p>
<h3 class="card-title">{{ t('data.sections.backup.title') }}</h3>
<p class="card-desc">{{ t('data.sections.backup.desc') }}</p>
<div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportBackup">全量导出</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">全量恢复</button>
<button class="card-button secondary" :disabled="exporting" @click="handleExportBackup">{{ t('data.sections.backup.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">{{ t('data.sections.backup.import') }}</button>
</div>
</div>
@@ -76,7 +76,7 @@
<!-- 导入日志 -->
<div v-if="importLogs.length > 0" class="log-container">
<div class="log-title">操作日志</div>
<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 }}
@@ -87,26 +87,27 @@
<!-- 前缀确认弹窗 -->
<div v-if="showPrefixModal" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">检测到 URL 缺失前缀</h3>
<h3 class="modal-title">{{ t('data.prefixModal.title') }}</h3>
<p class="modal-desc">
检测到 <strong>{{ missingPrefixCount }}</strong> 条评论的 URL
不存在域名前缀http/https<br />
是否在导入时统一添加
{{ t('data.prefixModal.descPart1') }}
<strong>{{ missingPrefixCount }}</strong>
{{ t('data.prefixModal.descPart2') }}<br />
{{ t('data.prefixModal.descPart3') }}
</p>
<div class="form-group">
<label class="form-label">域名前缀 (例如 https://example.me)</label>
<label class="form-label">{{ t('data.prefixModal.label') }}</label>
<input
v-model="urlPrefix"
class="form-input"
placeholder="请输入域名前缀"
:placeholder="t('data.prefixModal.placeholder')"
@keyup.enter="confirmPrefix"
/>
</div>
<div class="modal-actions">
<div class="modal-actions">
<button class="modal-btn secondary" @click="cancelPrefix">
直接导入 (不添加)
{{ t('data.prefixModal.skip') }}
</button>
<button class="modal-btn primary" @click="confirmPrefix">添加并导入</button>
<button class="modal-btn primary" @click="confirmPrefix">{{ t('data.prefixModal.confirm') }}</button>
</div>
</div>
</div>
@@ -115,6 +116,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import {
exportComments, importComments,
exportConfig, importConfig,
@@ -123,6 +125,8 @@ import {
} from "../../api/admin";
import { useSite } from "../../composables/useSite";
const { t } = useI18n();
const exporting = ref(false);
const importing = ref(false);
const importSource = ref("cwd");
@@ -171,9 +175,9 @@ async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast("导出成功", "success");
showToast(t('data.messages.exportSuccess'), "success");
} catch (e: any) {
showToast(e.message || "导出失败", "error");
showToast(e.message || t('data.messages.exportFailed'), "error");
} finally {
exporting.value = false;
}
@@ -188,7 +192,7 @@ const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
// 触发文件选择
function triggerFileInput(mode: string) {
currentImportMode.value = mode;
importLogs.value = []; // 清空日志
importLogs.value = [];
if (fileInput.value) {
fileInput.value.value = ''; // 重置 input
fileInput.value.click();
@@ -202,7 +206,7 @@ async function handleFileChange(event: Event) {
if (!file) return;
importing.value = true;
addLog(`开始导入: ${file.name} (模式: ${currentImportMode.value})`);
addLog(t('data.messages.importStart', { name: file.name, mode: currentImportMode.value }));
const reader = new FileReader();
reader.onload = async (e) => {
@@ -212,10 +216,10 @@ async function handleFileChange(event: Event) {
try {
json = JSON.parse(content);
} catch (parseError) {
throw new Error("JSON 解析失败,请检查文件格式");
throw new Error(t('data.messages.jsonParseFailed'));
}
addLog("文件解析成功,开始处理...");
addLog(t('data.messages.fileParseSuccess'));
switch (currentImportMode.value) {
case 'comments':
@@ -234,15 +238,15 @@ async function handleFileChange(event: Event) {
} catch (err: any) {
console.error(err);
addLog(`错误: ${err.message}`);
addLog(t('data.messages.errorWithMessage', { msg: err.message }));
showToast(err.message, "error");
importing.value = false;
}
};
reader.onerror = () => {
addLog("读取文件失败");
showToast("读取文件失败", "error");
addLog(t('data.messages.readFileFailedLog'));
showToast(t('data.messages.fileReadFailed'), "error");
importing.value = false;
};
@@ -253,27 +257,27 @@ async function handleFileChange(event: Event) {
async function processImportConfig(data: any) {
const res = await importConfig(data);
addLog(res.message);
showToast("配置导入成功");
showToast(t('data.messages.importConfigSuccess'));
importing.value = false;
}
async function processImportStats(data: any) {
const res = await importStats(data);
addLog(res.message);
showToast("统计数据导入成功");
showToast(t('data.messages.importStatsSuccess'));
importing.value = false;
}
async function processImportBackup(data: any) {
const res = await importBackup(data);
addLog(res.message);
showToast("全量恢复成功");
showToast(t('data.messages.importBackupSuccess'));
importing.value = false;
}
async function processImportComments(json: any) {
const comments = Array.isArray(json) ? json : [json];
addLog(`解析到 ${comments.length} 条评论数据`);
addLog(t('data.messages.parsedCommentsCount', { count: comments.length }));
// 检查 URL 前缀 (仅针对评论导入)
let missingCount = 0;
@@ -287,7 +291,7 @@ async function processImportComments(json: any) {
}
if (missingCount > 0) {
addLog(`检测到 ${missingCount} 条 URL 缺失前缀,等待用户确认...`);
addLog(t('data.messages.detectMissingPrefix', { count: missingCount }));
missingPrefixCount.value = missingCount;
pendingJson.value = comments;
showPrefixModal.value = true;
@@ -300,8 +304,8 @@ async function processImportComments(json: any) {
async function executeImportComments(comments: any[]) {
try {
const res = await importComments(comments);
addLog(`导入完成: ${res.message}`);
showToast("评论导入成功");
addLog(t('data.messages.importCommentsDone', { message: res.message }));
showToast(t('data.messages.importCommentsSuccess'));
} catch (err: any) {
throw err;
} finally {
@@ -313,7 +317,7 @@ async function executeImportComments(comments: any[]) {
// 前缀确认逻辑
async function confirmPrefix() {
if (!urlPrefix.value) {
showToast("请输入域名前缀", "error");
showToast(t('data.messages.prefixRequired'), "error");
return;
}
@@ -343,13 +347,13 @@ async function confirmPrefix() {
});
showPrefixModal.value = false;
addLog(`已添加前缀,继续导入...`);
addLog(t('data.messages.prefixAdded'));
await executeImportComments(comments);
}
function cancelPrefix() {
showPrefixModal.value = false;
addLog("用户跳过前缀添加");
addLog(t('data.messages.skipPrefix'));
executeImportComments(pendingJson.value);
}

View File

@@ -4,7 +4,7 @@
<button
class="layout-menu-toggle"
@click="toggleSider"
aria-label="切换菜单"
:aria-label="t('layout.toggleMenu')"
type="button"
>
<PhTextIndent :size="20" />
@@ -25,7 +25,7 @@
</div>
<div class="layout-actions">
<a class="layout-button" href="https://cwd.js.org" target="_blank">
使用文档
{{ t('layout.docs') }}
</a>
<a class="layout-button" href="https://github.com/anghunk/cwd" target="_blank">
Github
@@ -40,20 +40,20 @@
<PhMoon v-else-if="theme === 'dark'" :size="16" />
<PhAirplay v-else :size="16" />
</button>
<button class="layout-button" @click="handleLogout">退出</button>
<button class="layout-button" @click="handleLogout">{{ t('layout.logout') }}</button>
</div>
<button
class="layout-actions-toggle"
@click="toggleActions"
aria-label="更多操作"
:aria-label="t('layout.moreActions')"
type="button"
>
<PhDotsThreeVertical :size="20" bold />
</button>
<div v-if="isActionsOpen" class="layout-actions-dropdown">
<button class="layout-actions-item" type="button" @click="openDocs">
使用文档
{{ t('layout.docs') }}
</button>
<button class="layout-actions-item" type="button" @click="openGithub">
Github
@@ -63,7 +63,7 @@
type="button"
@click="handleLogoutFromActions"
>
退出
{{ t('layout.logout') }}
</button>
</div>
</div>
@@ -92,7 +92,7 @@
@click="goComments"
>
<PhChatCircleDots class="menu-item-icon" :size="18" />
<span>评论管理</span>
<span>{{ t('menu.comments') }}</span>
</li>
<li
class="menu-item"
@@ -100,7 +100,7 @@
@click="goStats"
>
<PhSquaresFour class="menu-item-icon" :size="18" />
<span>数据看板</span>
<span>{{ t('menu.stats') }}</span>
</li>
<li
class="menu-item"
@@ -108,7 +108,7 @@
@click="goAnalytics"
>
<PhChartBar class="menu-item-icon" :size="18" />
<span>访问统计</span>
<span>{{ t('menu.analytics') }}</span>
</li>
<li
class="menu-item"
@@ -116,7 +116,7 @@
@click="goSettings"
>
<PhGear class="menu-item-icon" :size="18" />
<span>网站设置</span>
<span>{{ t('menu.settings') }}</span>
</li>
<li
class="menu-item"
@@ -124,7 +124,7 @@
@click="goData"
>
<PhDatabase class="menu-item-icon" :size="18" />
<span>数据管理</span>
<span>{{ t('menu.data') }}</span>
</li>
</ul>
<div class="layout-sider-footer" @click="openVersionModal">
@@ -141,36 +141,35 @@
</div>
<div v-if="versionModalVisible" class="modal-overlay" @click.self="closeVersionModal">
<div class="modal">
<h3 class="modal-title">版本信息</h3>
<h3 class="modal-title">{{ t('layout.version.title') }}</h3>
<div class="modal-body">
<p class="modal-row">
<span class="modal-label">API 地址</span>
<span class="modal-value">{{ checkedApiBaseUrl || "未配置" }}</span>
<span class="modal-label">{{ t('layout.version.apiAddress') }}</span>
<span class="modal-value">{{ checkedApiBaseUrl || t('layout.version.notConfigured') }}</span>
</p>
<p class="modal-row">
<span class="modal-label">接口版本</span>
<span class="modal-label">{{ t('layout.version.apiVersion') }}</span>
<span class="modal-value">
{{ apiVersion || (apiVersionError ? "未获取到" : "加载中...") }}
{{ apiVersion || (apiVersionError ? t('layout.version.notFetched') : t('layout.version.loading')) }}
</span>
</p>
<p class="modal-row">
<span class="modal-label">后台版本</span>
<span class="modal-label">{{ t('layout.version.adminVersion') }}</span>
<span class="modal-value">{{ adminVersion }}</span>
</p>
<p v-if="apiVersion && apiVersion === adminVersion" class="modal-status">
当前后台与接口版本一致可以正常使用
{{ t('layout.version.match') }}
</p>
<p v-else-if="apiVersion && apiVersion !== adminVersion" class="modal-status">
当前后台与接口版本不一致推荐将 API 服务更新到与后台版本一致
以避免潜在的兼容性问题
{{ t('layout.version.mismatch') }}
</p>
<p v-else-if="apiVersionError" class="modal-status">
无法获取接口版本{{ apiVersionError }}
{{ t('layout.version.fetchError') }} {{ apiVersionError }}
</p>
</div>
<div class="modal-actions">
<button class="modal-btn" type="button" @click="closeVersionModal">
我知道了
{{ t('layout.version.ok') }}
</button>
</div>
</div>
@@ -181,6 +180,7 @@
<script setup lang="ts">
import { ref, onMounted, provide, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { useI18n } from "vue-i18n";
import { logoutAdmin, fetchAdminDisplaySettings, fetchSiteList } from "../../api/admin";
import { useTheme } from "../../composables/useTheme";
import { useSite } from "../../composables/useSite";
@@ -191,6 +191,7 @@ const SITE_TITLE_KEY = "cwd_admin_site_title";
const router = useRouter();
const route = useRoute();
const { t } = useI18n();
const { theme, setTheme } = useTheme();
const { currentSiteId } = useSite();
@@ -204,9 +205,9 @@ const versionModalVisible = ref(false);
const layoutTitle = ref(localStorage.getItem(SITE_TITLE_KEY) || "CWD 评论系统");
const themeTitle = computed(() => {
if (theme.value === "light") return "明亮模式";
if (theme.value === "dark") return "暗黑模式";
return "跟随系统";
if (theme.value === "light") return t('layout.theme.light');
if (theme.value === "dark") return t('layout.theme.dark');
return t('layout.theme.system');
});
function cycleTheme() {
@@ -221,7 +222,7 @@ const defaultSiteId = "default";
function getSiteLabel(value: string) {
if (!value || value === "default") {
return "默认站点";
return t('layout.defaultSite');
}
return value;
}

View File

@@ -1,6 +1,6 @@
<template>
<div class="page">
<h2 class="page-title">网站设置</h2>
<h2 class="page-title">{{ t('settings.title') }}</h2>
<div
v-if="toastVisible"
class="toast"
@@ -8,7 +8,7 @@
>
{{ toastMessage }}
</div>
<div v-if="loading" class="page-hint">加载中...</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else>
<div class="settings-tabs">
<button
@@ -17,7 +17,7 @@
:class="{ 'settings-tab-active': activeTab === 'comment' }"
@click="activeTab = 'comment'"
>
评论与安全
{{ t('settings.tabs.comment') }}
</button>
<button
type="button"
@@ -25,15 +25,7 @@
:class="{ 'settings-tab-active': activeTab === 'feature' }"
@click="activeTab = 'feature'"
>
功能开关
</button>
<button
type="button"
class="settings-tab"
:class="{ 'settings-tab-active': activeTab === 'display' }"
@click="activeTab = 'display'"
>
后台显示
{{ t('settings.tabs.feature') }}
</button>
<button
type="button"
@@ -41,7 +33,7 @@
:class="{ 'settings-tab-active': activeTab === 'emailNotify' }"
@click="activeTab = 'emailNotify'"
>
邮箱提醒
{{ t('settings.tabs.emailNotify') }}
</button>
<button
type="button"
@@ -49,7 +41,15 @@
:class="{ 'settings-tab-active': activeTab === 'telegramNotify' }"
@click="activeTab = 'telegramNotify'"
>
Telegram 通知
{{ t('settings.tabs.telegramNotify') }}
</button>
<button
type="button"
class="settings-tab"
:class="{ 'settings-tab-active': activeTab === 'display' }"
@click="activeTab = 'display'"
>
{{ t('settings.tabs.display') }}
</button>
</div>
<transition name="tab-fade" mode="out-in">
@@ -57,19 +57,19 @@
<template v-if="activeTab === 'comment'">
<div class="card">
<div class="card-header">
<div class="card-title">评论显示配置</div>
<div class="card-title">{{ t('settings.comment.title') }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">管理员邮箱</label>
<label class="form-label">{{ t('settings.comment.adminEmail') }}</label>
<input v-model="commentAdminEmail" class="form-input" type="email" />
</div>
<div class="form-item">
<label class="form-label">博主标签文字留空使用默认图标</label>
<label class="form-label">{{ t('settings.comment.adminBadge') }}</label>
<input v-model="commentAdminBadge" class="form-input" type="text" />
</div>
<div class="form-item">
<label class="form-label">是否开启博主标签显示</label>
<label class="form-label">{{ t('settings.comment.adminEnabled') }}</label>
<label class="switch">
<input v-model="commentAdminEnabled" type="checkbox" />
<span class="slider" />
@@ -77,13 +77,13 @@
</div>
<div class="form-item">
<label class="form-label"
>头像前缀默认https://gravatar.com/avatar</label
>{{ t('settings.comment.avatarPrefix') }}</label
>
<input v-model="avatarPrefix" class="form-input" type="text" />
</div>
<h3 class="card-title">安全设置</h3>
<h3 class="card-title">{{ t('settings.comment.securityTitle') }}</h3>
<div class="form-item">
<label class="form-label">新评论是否审核后再显示</label>
<label class="form-label">{{ t('settings.comment.requireReview') }}</label>
<label class="switch">
<input v-model="requireReview" type="checkbox" />
<span class="slider" />
@@ -91,31 +91,31 @@
</div>
<div class="form-item">
<label class="form-label">
管理员评论密钥设置后前台使用管理员邮箱评论需输入此密钥
{{ t('settings.comment.adminKey') }}
</label>
<input
v-model="commentAdminKey"
class="form-input"
placeholder="输入密钥以设置或修改"
:placeholder="t('settings.comment.adminKeyPlaceholder')"
autocomplete="new-password"
/>
</div>
<div class="form-item">
<label class="form-label">
允许调用的域名设置后仅匹配域名可调用前台评论组件留空则不限制使用空格或者逗号进行分割
{{ t('settings.comment.allowedDomains') }}
</label>
<TagInput v-model="allowedDomainTags" />
</div>
<div class="form-item">
<label class="form-label">
IP 黑名单多个 IP 用逗号或换行分隔留空则不限制
{{ t('settings.comment.blockedIps') }}
</label>
<TagInput v-model="blockedIpTags" />
</div>
<div class="form-item">
<label class="form-label"
>邮箱黑名单多个邮箱用逗号或换行分隔留空则不限制</label
>{{ t('settings.comment.blockedEmails') }}</label
>
<TagInput v-model="blockedEmailTags" />
</div>
@@ -126,8 +126,8 @@
:disabled="savingComment"
@click="saveComment"
>
<span v-if="savingComment">保存中...</span>
<span v-else>保存</span>
<span v-if="savingComment">{{ t('settings.comment.saving') }}</span>
<span v-else>{{ t('settings.comment.save') }}</span>
</button>
</div>
</div>
@@ -136,66 +136,80 @@
<template v-else-if="activeTab === 'feature'">
<div class="card feature">
<div class="card-header">
<div class="card-title">功能开关</div>
<div class="card-title">{{ t('settings.feature.title') }}</div>
</div>
<div class="card-body">
<div class="form-item bg">
<div class="form-item-flex">
<label class="form-label">开启文章点赞功能</label>
<label class="form-label">{{ t('settings.feature.articleLike') }}</label>
<label class="switch">
<input v-model="enableArticleLike" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-hint">
开启后评论区顶部会显示的文章点赞喜欢按钮
{{ t('settings.feature.articleLikeHint') }}
</div>
</div>
<div class="form-item bg">
<div class="form-item-flex">
<label class="form-label">开启评论点赞功能</label>
<label class="form-label">{{ t('settings.feature.commentLike') }}</label>
<label class="switch">
<input v-model="enableCommentLike" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-hint">
开启后评论列表中的每条评论都会显示点赞按钮
{{ t('settings.feature.commentLikeHint') }}
</div>
</div>
<div class="form-item bg">
<div class="form-item-flex">
<label class="form-label">评论列表图片灯箱模式</label>
<label class="form-label">{{ t('settings.feature.imageLightbox') }}</label>
<label class="switch">
<input v-model="enableImageLightbox" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-hint">开启后点击评论中的图片时会全屏放大预览</div>
<div class="form-hint">{{ t('settings.feature.imageLightboxHint') }}</div>
</div>
<div class="form-item">
<label class="form-label">评论框提示文案 (Placeholder)</label>
<label class="form-label">{{ t('settings.feature.placeholder') }}</label>
<textarea
v-model="commentPlaceholder"
class="form-input"
rows="3"
style="height: 90px; resize: none"
placeholder="默认:写下你的评论,可换行书写提示"
:placeholder="t('settings.feature.placeholderHint')"
></textarea>
<div class="form-hint">
自定义评论输入框的提示文字支持换行留空则使用默认值
{{ t('settings.feature.placeholderHint') }}
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.feature.widgetLanguage') }}</label>
<select v-model="widgetLanguage" class="form-input">
<option value="auto">Auto (Browser Default)</option>
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value">
{{ lang.label }} ({{ lang.value }})
</option>
</select>
<div class="form-hint">
{{ t('settings.feature.widgetLanguageHint') }}
</div>
</div>
<div class="card-actions">
<button
class="card-button"
:disabled="savingFeature"
@click="saveFeature"
>
<span v-if="savingFeature">保存中...</span>
<span v-else>保存</span>
<span v-if="savingFeature">{{ t('settings.feature.saving') }}</span>
<span v-else>{{ t('settings.feature.save') }}</span>
</button>
</div>
</div>
@@ -204,27 +218,38 @@
<template v-else-if="activeTab === 'display'">
<div class="card">
<div class="card-header">
<div class="card-title">后台显示设置</div>
<div class="card-title">{{ t('settings.display.title') }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">后台标题</label>
<label class="form-label">{{ t('settings.display.layoutTitle') }}</label>
<input
v-model="adminLayoutTitle"
class="form-input"
type="text"
placeholder="CWD 评论系统"
/>
<div class="form-hint">显示在后台顶部导航栏左侧</div>
<div class="form-hint">{{ t('settings.display.layoutTitleHint') }}</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.display.adminLanguage') }}</label>
<select v-model="adminLanguage" class="form-input">
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value">
{{ lang.label }} ({{ lang.value }})
</option>
</select>
<div class="form-hint">{{ t('settings.display.adminLanguageHint') }}</div>
</div>
<div class="card-actions">
<button
class="card-button"
:disabled="savingDisplay"
@click="saveDisplay"
>
<span v-if="savingDisplay">保存中...</span>
<span v-else>保存</span>
<span v-if="savingDisplay">{{ t('settings.display.saving') }}</span>
<span v-else>{{ t('settings.display.save') }}</span>
</button>
</div>
</div>
@@ -233,11 +258,11 @@
<template v-else-if="activeTab === 'emailNotify'">
<div class="card">
<div class="card-header">
<div class="card-title">通知邮箱设置</div>
<div class="card-title">{{ t('settings.emailNotify.title') }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">开启邮件通知</label>
<label class="form-label">{{ t('settings.emailNotify.enable') }}</label>
<label class="switch">
<input v-model="emailGlobalEnabled" type="checkbox" />
<span class="slider" />
@@ -245,10 +270,10 @@
</div>
<div class="divider"></div>
<h4 class="card-subtitle">1. SMTP 发件配置</h4>
<h4 class="card-subtitle">{{ t('settings.emailNotify.smtpTitle') }}</h4>
<div class="form-item">
<label class="form-label">邮件服务商</label>
<label class="form-label">{{ t('settings.emailNotify.provider') }}</label>
<select
v-model="smtpProvider"
class="form-input"
@@ -262,7 +287,7 @@
<div v-if="smtpProvider === 'custom'">
<div class="form-item">
<label class="form-label">SMTP 服务器</label>
<label class="form-label">{{ t('settings.emailNotify.host') }}</label>
<input
v-model="smtpHost"
class="form-input"
@@ -270,7 +295,7 @@
/>
</div>
<div class="form-item">
<label class="form-label">SMTP 端口</label>
<label class="form-label">{{ t('settings.emailNotify.port') }}</label>
<input
v-model="smtpPort"
class="form-input"
@@ -279,7 +304,7 @@
/>
</div>
<div class="form-item">
<label class="form-label">SSL 安全连接</label>
<label class="form-label">{{ t('settings.emailNotify.secure') }}</label>
<label class="switch">
<input v-model="smtpSecure" type="checkbox" />
<span class="slider" />
@@ -288,7 +313,7 @@
</div>
<div class="form-item">
<label class="form-label">发件邮箱账号</label>
<label class="form-label">{{ t('settings.emailNotify.user') }}</label>
<input
v-model="smtpUser"
class="form-input"
@@ -296,37 +321,24 @@
/>
</div>
<div class="form-item">
<label class="form-label">授权码/密码</label>
<label class="form-label">{{ t('settings.emailNotify.pass') }}</label>
<input
v-model="smtpPass"
class="form-input"
type="text"
placeholder="QQ邮箱请使用授权码"
/>
<div v-if="smtpProvider === 'qq'" class="form-hint">
注意QQ 邮箱必须使用<a
href="https://service.mail.qq.com/detail/0/75"
target="_blank"
>授权码</a
>而非 QQ 密码<br />
请登录 QQ 邮箱网页版设置 - 账户中开启 POP3/SMTP
服务并生成授权码
</div>
<div v-else-if="smtpProvider === '163'" class="form-hint">
注意163 邮箱必须使用授权码而非登录密码<br />
请登录 163 邮箱网页版设置 -
POP3/SMTP/IMAP中开启服务并生成授权码
</div>
<div v-if="smtpProvider === 'qq'" class="form-hint" v-html="t('settings.emailNotify.qqHint')"></div>
<div v-else-if="smtpProvider === '163'" class="form-hint" v-html="t('settings.emailNotify.163Hint')"></div>
</div>
<div class="divider"></div>
<h4 class="card-subtitle">2. 邮件模板设置</h4>
<h4 class="card-subtitle">{{ t('settings.emailNotify.templateTitle') }}</h4>
<div class="form-item">
<label class="form-label">管理员通知模板 (HTML)</label>
<label class="form-label">{{ t('settings.emailNotify.adminTemplate') }}</label>
<div class="form-hint">
可用变量${commentAuthor} (评论人昵称), ${postTitle} (文章标题),
${postUrl} (文章链接), ${commentContent} (评论内容)
{{ t('settings.emailNotify.adminTemplateHint') }}
</div>
<textarea
v-model="templateAdmin"
@@ -337,11 +349,9 @@
</div>
<div class="form-item">
<label class="form-label">回复通知模板 (HTML)</label>
<label class="form-label">{{ t('settings.emailNotify.replyTemplate') }}</label>
<div class="form-hint">
可用变量${toName} (接收人昵称), ${replyAuthor} (回复人昵称),
${postTitle} (文章标题), ${postUrl} (文章链接), ${parentComment}
(原评论), ${replyContent} (回复内容)
{{ t('settings.emailNotify.replyTemplateHint') }}
</div>
<textarea
v-model="templateReply"
@@ -363,19 +373,19 @@
:disabled="testingEmail"
@click="testEmail"
>
<span v-if="testingEmail">发送中...</span>
<span v-else>发送测试邮件</span>
<span v-if="testingEmail">{{ t('settings.emailNotify.testingBtn') }}</span>
<span v-else>{{ t('settings.emailNotify.testBtn') }}</span>
</button>
<button
class="card-button secondary"
style="margin-left: auto"
@click="resetTemplatesToDefault"
>
恢复默认模板
{{ t('settings.emailNotify.resetBtn') }}
</button>
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
<span v-if="savingEmail">保存中...</span>
<span v-else>保存配置</span>
<span v-if="savingEmail">{{ t('settings.emailNotify.saving') }}</span>
<span v-else>{{ t('settings.emailNotify.save') }}</span>
</button>
</div>
</div>
@@ -384,44 +394,36 @@
<template v-else-if="activeTab === 'telegramNotify'">
<div class="card">
<div class="card-header">
<div class="card-title">Telegram 通知设置</div>
<div class="card-title">{{ t('settings.telegramNotify.title') }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">开启 Telegram 通知</label>
<label class="form-label">{{ t('settings.telegramNotify.enable') }}</label>
<label class="switch">
<input v-model="telegramNotifyEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label">Bot Token</label>
<label class="form-label">{{ t('settings.telegramNotify.botToken') }}</label>
<input
v-model="telegramBotToken"
class="form-input"
type="text"
type="password"
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
autocomplete="new-password"
/>
<div class="form-hint">
Telegram 中搜索
<a href="https://t.me/BotFather" target="_blank">@BotFather</a>
创建机器人获取 Token
</div>
<div class="form-hint" v-html="t('settings.telegramNotify.botTokenHint')"></div>
</div>
<div class="form-item">
<label class="form-label">Chat ID</label>
<label class="form-label">{{ t('settings.telegramNotify.chatId') }}</label>
<input
v-model="telegramChatId"
class="form-input"
type="text"
placeholder="123456789"
/>
<div class="form-hint">
这是接收通知的用户 ID 或群组 ID可以先给机器人发消息然后通过 API
获取 ID或者使用
<a href="https://t.me/userinfobot" target="_blank">@userinfobot</a>
查询
</div>
<div class="form-hint" v-html="t('settings.telegramNotify.chatIdHint')"></div>
</div>
<div class="card-actions" style="justify-content: space-between">
@@ -430,8 +432,8 @@
:disabled="settingUpWebhook"
@click="doSetupWebhook"
>
<span v-if="settingUpWebhook">设置中...</span>
<span v-else>一键设置 Webhook</span>
<span v-if="settingUpWebhook">{{ t('settings.telegramNotify.webhookSetting') }}</span>
<span v-else>{{ t('settings.telegramNotify.webhookBtn') }}</span>
</button>
<button
class="card-button secondary"
@@ -439,16 +441,16 @@
@click="testTelegram"
style="margin-right: auto"
>
<span v-if="testingTelegram">发送中...</span>
<span v-else>发送测试消息</span>
<span v-if="testingTelegram">{{ t('settings.telegramNotify.testingBtn') }}</span>
<span v-else>{{ t('settings.telegramNotify.testBtn') }}</span>
</button>
<button
class="card-button"
:disabled="savingTelegram"
@click="saveTelegram"
>
<span v-if="savingTelegram">保存中...</span>
<span v-else>保存</span>
<span v-if="savingTelegram">{{ t('settings.telegramNotify.saving') }}</span>
<span v-else>{{ t('settings.telegramNotify.save') }}</span>
</button>
</div>
</div>
@@ -463,6 +465,7 @@
<script setup lang="ts">
import { onMounted, ref, type Ref, inject, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import {
fetchCommentSettings,
saveCommentSettings,
@@ -578,6 +581,28 @@ const savingTelegram = ref(false);
const settingUpWebhook = ref(false);
const testingTelegram = ref(false);
const languageOptions = [
{ value: 'en-US', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
{ value: 'zh-TW', label: '繁體中文' },
{ value: 'es', label: 'Español' },
{ value: 'pt', label: 'Português' },
{ value: 'fr', label: 'Français' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ja', label: '日本語' },
{ value: 'ko', label: '한국어' },
{ value: 'ru', label: 'Русский' },
{ value: 'it', label: 'Italiano' },
{ value: 'nl', label: 'Nederlands' },
{ value: 'ar', label: 'العربية' },
{ value: 'hi', label: 'हिन्दी' },
{ value: 'id', label: 'Bahasa Indonesia' },
];
const { t, locale } = useI18n();
const adminLanguage = ref("zh-CN");
const widgetLanguage = ref("auto");
const route = useRoute();
const router = useRouter();
@@ -731,6 +756,14 @@ async function load() {
enableCommentLike.value = featureRes.enableCommentLike;
enableImageLightbox.value = featureRes.enableImageLightbox;
commentPlaceholder.value = featureRes.commentPlaceholder || "";
adminLanguage.value = featureRes.adminLanguage || "zh-CN";
widgetLanguage.value = featureRes.widgetLanguage || "auto";
// Sync locale
if (featureRes.adminLanguage) {
locale.value = featureRes.adminLanguage;
localStorage.setItem('admin_language', featureRes.adminLanguage);
}
telegramBotToken.value = telegramRes.botToken || "";
telegramChatId.value = telegramRes.chatId || "";
@@ -861,10 +894,9 @@ async function saveComment() {
}),
]);
showToast(commentRes.message || "保存成功", "success");
showToast(commentRes.message || t('common.saveSuccess'), "success");
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";
showToast(e.message || t('common.saveFailed'), "error");
} finally {
savingComment.value = false;
}
@@ -880,13 +912,13 @@ async function saveFeature() {
enableCommentLike: enableCommentLike.value,
enableImageLightbox: enableImageLightbox.value,
commentPlaceholder: commentPlaceholder.value,
widgetLanguage: widgetLanguage.value,
}),
]);
showToast(featureRes.message || "保存成功", "success");
showToast(featureRes.message || t('common.saveSuccess'), "success");
} catch (e: any) {
message.value = e.message || "保存失败";
messageType.value = "error";
showToast(e.message || t('common.saveFailed'), "error");
} finally {
savingFeature.value = false;
}
@@ -901,10 +933,19 @@ async function saveDisplay() {
const res = await saveAdminDisplaySettings({
layoutTitle: adminLayoutTitle.value,
});
// Also save admin language to feature settings as it's a global preference
await saveFeatureSettings({
adminLanguage: adminLanguage.value
});
if (updateSiteTitle) {
updateSiteTitle(adminLayoutTitle.value);
}
// Update local locale
locale.value = adminLanguage.value;
localStorage.setItem('admin_language', adminLanguage.value);
showToast(res.message || "保存成功", "success");
} catch (e: any) {

View File

@@ -1,7 +1,7 @@
<template>
<div class="page">
<div style="display: flex; align-items: center; gap: 20px">
<h2 class="page-title">数据看板</h2>
<h2 class="page-title">{{ t('stats.title') }}</h2>
</div>
<div
v-if="toastVisible"
@@ -13,28 +13,28 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">整体概览</h3>
<h3 class="card-title">{{ t('stats.overview') }}</h3>
</div>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else>
<div class="stats-grid">
<div class="stats-item">
<div class="stats-label">总评论数</div>
<div class="stats-label">{{ t('stats.total') }}</div>
<div class="stats-value">{{ statsSummary.total }}</div>
</div>
<div class="stats-item">
<div class="stats-label">已通过</div>
<div class="stats-label">{{ t('stats.approved') }}</div>
<div class="stats-value stats-value-approved">
{{ statsSummary.approved }}
</div>
</div>
<div class="stats-item">
<div class="stats-label">待审核</div>
<div class="stats-label">{{ t('stats.pending') }}</div>
<div class="stats-value stats-value-pending">{{ statsSummary.pending }}</div>
</div>
<div class="stats-item">
<div class="stats-label">已拒绝</div>
<div class="stats-label">{{ t('stats.rejected') }}</div>
<div class="stats-value stats-value-rejected">
{{ statsSummary.rejected }}
</div>
@@ -45,7 +45,7 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">评论数趋势</h3>
<h3 class="card-title">{{ t('stats.trend') }}</h3>
<div class="chart-tabs">
<button
class="chart-tab"
@@ -53,7 +53,7 @@
type="button"
@click="changeChartRange('7')"
>
最近 7
{{ t('stats.last7Days') }}
</button>
<button
class="chart-tab"
@@ -61,11 +61,11 @@
type="button"
@click="changeChartRange('30')"
>
最近 30
{{ t('stats.last30Days') }}
</button>
</div>
</div>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div class="chart-wrapper">
<div ref="chartEl" class="chart"></div>
@@ -74,20 +74,20 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">按站点统计</h3>
<h3 class="card-title">{{ t('stats.bySite') }}</h3>
</div>
<div v-if="statsLoading" class="page-hint">加载中...</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else-if="domainStats.length === 0" class="page-hint">暂无评论数据</div>
<div v-else-if="domainStats.length === 0" class="page-hint">{{ t('stats.noData') }}</div>
<div v-else class="domain-stats-layout">
<div class="domain-table-wrapper">
<div class="domain-table">
<div class="domain-table-header">
<div class="domain-cell domain-cell-domain">域名</div>
<div class="domain-cell">总数</div>
<div class="domain-cell">已通过</div>
<div class="domain-cell">待审核</div>
<div class="domain-cell">已拒绝</div>
<div class="domain-cell domain-cell-domain">{{ t('stats.table.domain') }}</div>
<div class="domain-cell">{{ t('stats.table.total') }}</div>
<div class="domain-cell">{{ t('stats.table.approved') }}</div>
<div class="domain-cell">{{ t('stats.table.pending') }}</div>
<div class="domain-cell">{{ t('stats.table.rejected') }}</div>
</div>
<div
v-for="item in domainStats"
@@ -113,10 +113,13 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject } from "vue";
import type { Ref } from "vue";
import { useI18n } from "vue-i18n";
import * as echarts from "echarts";
import { fetchCommentStats } from "../../api/admin";
import { useSite } from "../../composables/useSite";
const { t } = useI18n();
type DomainStat = {
domain: string;
total: number;