feat(i18n): 为评论系统添加国际化支持
- 在管理后台引入 vue-i18n 并添加多语言文件 - 为前端评论组件添加翻译功能,支持自动语言检测 - 在功能设置中新增管理后台和组件语言配置选项 - 更新 API 接口以支持语言设置存储 - 优化菜单项文本溢出显示样式
This commit is contained in:
@@ -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("");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user