fix(admin): 在功能设置类型和保存接口中增加 adminLanguage 和 widgetLanguage 字段

This commit is contained in:
anghunk
2026-02-10 20:48:14 +08:00
parent 8941c1cf9f
commit 3ce32c9ada
9 changed files with 562 additions and 417 deletions

View File

@@ -137,6 +137,8 @@ export type FeatureSettingsResponse = {
enableImageLightbox: boolean;
commentPlaceholder?: string;
visibleDomains?: string[];
adminLanguage?: string;
widgetLanguage?: string;
};
export type AdminDisplaySettingsResponse = {
@@ -351,7 +353,7 @@ export function fetchFeatureSettings(): Promise<FeatureSettingsResponse> {
return get<FeatureSettingsResponse>('/admin/settings/features');
}
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; enableImageLightbox?: boolean; commentPlaceholder?: string; visibleDomains?: string[] }): Promise<{ message: string }> {
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; enableImageLightbox?: boolean; commentPlaceholder?: string; visibleDomains?: string[]; adminLanguage?: string; widgetLanguage?: string }): Promise<{ message: string }> {
return put<{ message: string }>('/admin/settings/features', data);
}

View File

@@ -1,7 +1,7 @@
<template>
<div class="page">
<div style="display: flex; align-items: center; gap: 20px">
<h2 class="page-title">{{ t('analytics.title') }}</h2>
<h2 class="page-title">{{ t("analytics.title") }}</h2>
</div>
<div
v-if="toastVisible"
@@ -13,63 +13,73 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">{{ t('analytics.overview') }}</h3>
<h3 class="card-title">{{ t("analytics.overview") }}</h3>
</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</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">{{ t('analytics.totalPv') }}</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">{{ t('analytics.todayPv') }}</div>
<div class="stats-label">{{ t("analytics.todayPv") }}</div>
<div class="stats-value">
<CountTo :end-val="overview.todayPv" />
<span
v-if="overview.yesterdayPv !== undefined"
class="trend"
:class="percentageChange >= 0 ? 'up' : 'down'"
:title="`对比昨日 ${percentageChange >= 0 ? '增加' : '减少'} ${Math.abs(percentageChange).toFixed(1)}%`"
:title="`对比昨日 ${percentageChange >= 0 ? '增加' : '减少'} ${Math.abs(
percentageChange
).toFixed(1)}%`"
>
<span class="trend-arrow">{{ percentageChange >= 0 ? '↑' : '↓' }}</span>
<span class="trend-arrow">{{ percentageChange >= 0 ? "↑" : "↓" }}</span>
{{ Math.abs(percentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">{{ t('analytics.weekPv') }}</div>
<div class="stats-label">{{ t("analytics.weekPv") }}</div>
<div class="stats-value">
<CountTo :end-val="overview.weekPv" />
<span
v-if="overview.lastWeekPv !== undefined"
class="trend"
:class="weekPercentageChange >= 0 ? 'up' : 'down'"
:title="`对比上周 ${weekPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(weekPercentageChange).toFixed(1)}%`"
:title="`对比上周 ${
weekPercentageChange >= 0 ? '增加' : '减少'
} ${Math.abs(weekPercentageChange).toFixed(1)}%`"
>
<span class="trend-arrow">{{ weekPercentageChange >= 0 ? '↑' : '↓' }}</span>
<span class="trend-arrow">{{
weekPercentageChange >= 0 ? "↑" : "↓"
}}</span>
{{ Math.abs(weekPercentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">{{ t('analytics.monthPv') }}</div>
<div class="stats-label">{{ t("analytics.monthPv") }}</div>
<div class="stats-value">
<CountTo :end-val="overview.monthPv" />
<span
v-if="overview.lastMonthPv !== undefined"
class="trend"
:class="monthPercentageChange >= 0 ? 'up' : 'down'"
:title="`对比上月 ${monthPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(monthPercentageChange).toFixed(1)}%`"
:title="`对比上月 ${
monthPercentageChange >= 0 ? '增加' : '减少'
} ${Math.abs(monthPercentageChange).toFixed(1)}%`"
>
<span class="trend-arrow">{{ monthPercentageChange >= 0 ? '↑' : '↓' }}</span>
<span class="trend-arrow">{{
monthPercentageChange >= 0 ? "↑" : "↓"
}}</span>
{{ Math.abs(monthPercentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">{{ t('analytics.totalPages') }}</div>
<div class="stats-label">{{ t("analytics.totalPages") }}</div>
<div class="stats-value"><CountTo :end-val="overview.totalPages" /></div>
</div>
</div>
@@ -78,7 +88,7 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">{{ t('analytics.trend') }}</h3>
<h3 class="card-title">{{ t("analytics.trend") }}</h3>
<div class="visit-tabs">
<button
class="visit-tab"
@@ -86,7 +96,7 @@
type="button"
@click="changeChartRange('7')"
>
{{ t('analytics.last7Days') }}
{{ t("analytics.last7Days") }}
</button>
<button
class="visit-tab"
@@ -94,11 +104,11 @@
type="button"
@click="changeChartRange('30')"
>
{{ t('analytics.last30Days') }}
{{ t("analytics.last30Days") }}
</button>
</div>
</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</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 +117,7 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">{{ t('analytics.pageDetail') }}</h3>
<h3 class="card-title">{{ t("analytics.pageDetail") }}</h3>
<div class="visit-tabs">
<button
class="visit-tab"
@@ -115,7 +125,7 @@
type="button"
@click="changeVisitTab('pv')"
>
{{ t('analytics.sort.pv') }}
{{ t("analytics.sort.pv") }}
</button>
<button
class="visit-tab"
@@ -123,22 +133,28 @@
type="button"
@click="changeVisitTab('latest')"
>
{{ t('analytics.sort.latest') }}
{{ t("analytics.sort.latest") }}
</button>
</div>
</div>
<div v-if="listLoading" class="page-hint">{{ t('common.loading') }}</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">{{ t('analytics.noData') }}</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">{{ 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 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 v-for="(item, index) in items" :key="index" class="domain-table-row">
<div class="domain-cell domain-cell-title">
{{ item.postTitle || item.postSlug }}
</div>
@@ -166,22 +182,30 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">{{ t('analytics.likeRank') }}</h3>
<h3 class="card-title">{{ t("analytics.likeRank") }}</h3>
</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</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">{{ t('analytics.noLikeData') }}</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">{{ 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 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"
:key="item.pageSlug"
:key="index"
class="domain-table-row"
>
<div class="domain-cell domain-cell-rank">
@@ -212,8 +236,7 @@
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject, computed } from "vue";
import type { Ref } from "vue";
import { onMounted, onBeforeUnmount, ref, nextTick, watch, computed } from "vue";
import { useI18n } from "vue-i18n";
import * as echarts from "echarts";
import CountTo from "../../components/CountTo.vue";

View File

@@ -1,42 +1,42 @@
<template>
<div v-if="visible" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">{{ t('comments.editModal.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">{{ t('comments.editModal.name') }}</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">{{ t('comments.editModal.email') }}</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">{{ t('comments.editModal.url') }}</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">{{ t('comments.editModal.postSlug') }}</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">{{ t('comments.editModal.postUrl') }}</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">{{ t('comments.editModal.content') }}</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">{{ t('comments.editModal.status') }}</label>
<label class="form-label">{{ t("comments.editModal.status") }}</label>
<select v-model="form.status" class="form-input">
<option value="approved">{{ t('comments.statusFilter.approved') }}</option>
<option value="pending">{{ t('comments.statusFilter.pending') }}</option>
<option value="rejected">{{ t('comments.statusFilter.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">{{ t('comments.editModal.priority') }}</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') }}
{{ t("comments.editModal.cancel") }}
</button>
<button
class="modal-btn primary"
@@ -55,8 +55,8 @@
:disabled="saving"
@click="handleSubmit"
>
<span v-if="saving">{{ t('comments.editModal.saving') }}</span>
<span v-else>{{ t('comments.editModal.save') }}</span>
<span v-if="saving">{{ t("comments.editModal.saving") }}</span>
<span v-else>{{ t("comments.editModal.save") }}</span>
</button>
</div>
</div>

View File

@@ -1,29 +1,35 @@
<template>
<div class="page">
<h2 class="page-title">{{ t('comments.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="">{{ 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>
<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)">{{ t('comments.refresh') }}</button>
<button class="toolbar-button" @click="goPage(1)">
{{ t("comments.refresh") }}
</button>
</div>
</div>
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</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">{{ 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 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">
@@ -89,10 +95,9 @@
target="_blank"
class="cell-path"
:title="item.postSlug"
>
{{ item.postUrl || item.postSlug }}
</a
>
{{ item.postUrl || item.postSlug }}
</a>
</div>
<div class="table-cell table-cell-status">
<div class="cell-status-wrapper">
@@ -104,7 +109,7 @@
:title="String(item.priority)"
class="cell-status cell-pin-flag"
>
{{ t('comments.actions.pin') }}
{{ t("comments.actions.pin") }}
</span>
<span class="cell-status cell-likes-number" v-if="item.likes !== 0">
<PhThumbsUp :size="13" />
@@ -126,21 +131,25 @@
:value="item.status"
@change="handleStatusChange(item, $event)"
>
<option value="approved">{{ t('comments.actions.approve') }}</option>
<option value="pending">{{ t('comments.actions.pending') }}</option>
<option value="rejected">{{ t('comments.actions.reject') }}</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)">{{ t('comments.actions.edit') }}</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') }}
{{ t("comments.actions.delete") }}
</button>
</div>
</div>
</div>
<div v-if="filteredComments.length === 0" class="table-empty">{{ t('comments.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 +157,7 @@
:disabled="pagination.page <= 1"
@click="goPage(pagination.page - 1)"
>
{{ t('comments.pagination.prev') }}
{{ t("comments.pagination.prev") }}
</button>
<button
class="pagination-button"
@@ -199,10 +208,10 @@
:disabled="pagination.page >= pagination.total"
@click="goPage(pagination.page + 1)"
>
{{ t('comments.pagination.next') }}
{{ t("comments.pagination.next") }}
</button>
<div class="pagination-jump">
<span>{{ t('comments.pagination.jumpTo') }}</span>
<span>{{ t("comments.pagination.jumpTo") }}</span>
<input
v-model="jumpPageInput"
class="pagination-input"
@@ -211,8 +220,10 @@
:max="pagination.total"
@keyup.enter="handleJumpPage"
/>
<span>{{ t('comments.pagination.page') }}</span>
<button class="pagination-button" @click="handleJumpPage">{{ t('comments.pagination.confirm') }}</button>
<span>{{ t("comments.pagination.page") }}</span>
<button class="pagination-button" @click="handleJumpPage">
{{ t("comments.pagination.confirm") }}
</button>
</div>
</div>
</div>
@@ -228,16 +239,12 @@
<script setup lang="ts">
import "../../styles/markdown.css";
import { onMounted, ref, computed, watch, inject } from "vue";
import type { Ref } from "vue";
import { onMounted, ref, computed, watch } 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,
fetchComments,
deleteComment,
updateCommentStatus,
@@ -319,13 +326,13 @@ function formatDate(value: number) {
function formatStatus(status: string) {
if (status === "approved") {
return t('comments.statusFilter.approved');
return t("comments.statusFilter.approved");
}
if (status === "pending") {
return t('comments.statusFilter.pending');
return t("comments.statusFilter.pending");
}
if (status === "rejected") {
return t('comments.statusFilter.rejected');
return t("comments.statusFilter.rejected");
}
return status;
}
@@ -398,7 +405,7 @@ function handleStatusChange(item: CommentItem, event: Event) {
}
async function removeComment(item: CommentItem) {
if (!window.confirm(t('comments.confirmDelete', { id: item.id }))) {
if (!window.confirm(t("comments.confirmDelete", { id: item.id }))) {
return;
}
try {
@@ -413,12 +420,12 @@ async function handleBlockIp(item: CommentItem) {
if (!item.ipAddress) {
return;
}
if (!window.confirm(t('comments.confirmBlockIp', { ip: item.ipAddress }))) {
if (!window.confirm(t("comments.confirmBlockIp", { ip: item.ipAddress }))) {
return;
}
try {
const res = await blockIp(item.ipAddress);
window.alert(res.message || t('comments.successBlockIp'));
window.alert(res.message || t("comments.successBlockIp"));
} catch (e: any) {
error.value = e.message || "屏蔽 IP 失败";
}
@@ -428,12 +435,12 @@ async function handleBlockEmail(item: CommentItem) {
if (!item.email) {
return;
}
if (!window.confirm(t('comments.confirmBlockEmail', { email: item.email }))) {
if (!window.confirm(t("comments.confirmBlockEmail", { email: item.email }))) {
return;
}
try {
const res = await blockEmail(item.email);
window.alert(res.message || t('comments.successBlockEmail'));
window.alert(res.message || t("comments.successBlockEmail"));
} catch (e: any) {
error.value = e.message || "屏蔽邮箱失败";
}
@@ -545,7 +552,6 @@ 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">{{ t('data.title') }}</h2>
<h2 class="page-title">{{ t("data.title") }}</h2>
<div
v-if="toastVisible"
class="toast"
@@ -11,57 +11,101 @@
<!-- 1. 评论数据 -->
<div class="card">
<h3 class="card-title">{{ t('data.sections.comments.title') }}</h3>
<p class="card-desc">{{ t('data.sections.comments.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">{{ t('data.sections.comments.exportLabel') }}</span>
<button class="card-button secondary" :disabled="exporting" @click="handleExportComments">
<span v-if="exporting">{{ t('data.sections.comments.exporting') }}</span>
<span v-else>{{ t('data.sections.comments.exportJson') }}</span>
<span class="action-label">{{ t("data.sections.comments.exportLabel") }}</span>
<button
class="card-button secondary"
:disabled="exporting"
@click="handleExportComments"
>
<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">{{ t('data.sections.comments.importLabel') }}</span>
<select v-model="importSource" class="form-select" style="min-width:120px;">
<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>
<span class="action-label">{{ t("data.sections.comments.importLabel") }}</span>
<select v-model="importSource" class="form-select" style="min-width: 120px">
<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
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">{{ t('data.sections.config.title') }}</h3>
<p class="card-desc">{{ t('data.sections.config.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">{{ t('data.sections.config.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">{{ t('data.sections.config.import') }}</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">{{ t('data.sections.stats.title') }}</h3>
<p class="card-desc">{{ t('data.sections.stats.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">{{ t('data.sections.stats.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">{{ t('data.sections.stats.import') }}</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">{{ t('data.sections.backup.title') }}</h3>
<p class="card-desc">{{ t('data.sections.backup.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">{{ t('data.sections.backup.export') }}</button>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">{{ t('data.sections.backup.import') }}</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 +120,7 @@
<!-- 导入日志 -->
<div v-if="importLogs.length > 0" class="log-container">
<div class="log-title">{{ t('data.logs.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,15 +131,15 @@
<!-- 前缀确认弹窗 -->
<div v-if="showPrefixModal" class="modal-overlay">
<div class="modal">
<h3 class="modal-title">{{ t('data.prefixModal.title') }}</h3>
<h3 class="modal-title">{{ t("data.prefixModal.title") }}</h3>
<p class="modal-desc">
{{ t('data.prefixModal.descPart1') }}
{{ t("data.prefixModal.descPart1") }}
<strong>{{ missingPrefixCount }}</strong>
{{ t('data.prefixModal.descPart2') }}<br />
{{ t('data.prefixModal.descPart3') }}
{{ t("data.prefixModal.descPart2") }}<br />
{{ t("data.prefixModal.descPart3") }}
</p>
<div class="form-group">
<label class="form-label">{{ t('data.prefixModal.label') }}</label>
<label class="form-label">{{ t("data.prefixModal.label") }}</label>
<input
v-model="urlPrefix"
class="form-input"
@@ -103,11 +147,13 @@
@keyup.enter="confirmPrefix"
/>
</div>
<div class="modal-actions">
<div class="modal-actions">
<button class="modal-btn secondary" @click="cancelPrefix">
{{ t('data.prefixModal.skip') }}
{{ t("data.prefixModal.skip") }}
</button>
<button class="modal-btn primary" @click="confirmPrefix">
{{ t("data.prefixModal.confirm") }}
</button>
<button class="modal-btn primary" @click="confirmPrefix">{{ t('data.prefixModal.confirm') }}</button>
</div>
</div>
</div>
@@ -117,11 +163,15 @@
<script setup lang="ts">
import { ref } from "vue";
import { useI18n } from "vue-i18n";
import {
exportComments, importComments,
exportConfig, importConfig,
exportStats, importStats,
exportBackup, importBackup
import {
exportComments,
importComments,
exportConfig,
importConfig,
exportStats,
importStats,
exportBackup,
importBackup,
} from "../../api/admin";
import { useSite } from "../../composables/useSite";
@@ -138,7 +188,7 @@ const importLogs = ref<string[]>([]);
const { currentSiteId } = useSite();
// 当前导入模式: comments | config | stats | backup
const currentImportMode = ref<string>('comments');
const currentImportMode = ref<string>("comments");
// 前缀处理相关状态
const showPrefixModal = ref(false);
@@ -175,26 +225,27 @@ async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast(t('data.messages.exportSuccess'), "success");
showToast(t("data.messages.exportSuccess"), "success");
} catch (e: any) {
showToast(e.message || t('data.messages.exportFailed'), "error");
showToast(e.message || t("data.messages.exportFailed"), "error");
} finally {
exporting.value = false;
}
}
// 导出处理
const handleExportComments = () => executeExport(exportComments, 'comments-export');
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config');
const handleExportStats = () => executeExport(() => exportStats(currentSiteId.value), 'cwd-stats');
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
const handleExportComments = () => executeExport(exportComments, "comments-export");
const handleExportConfig = () => executeExport(exportConfig, "cwd-config");
const handleExportStats = () =>
executeExport(() => exportStats(currentSiteId.value), "cwd-stats");
const handleExportBackup = () => executeExport(exportBackup, "cwd-full-backup");
// 触发文件选择
function triggerFileInput(mode: string) {
currentImportMode.value = mode;
importLogs.value = [];
if (fileInput.value) {
fileInput.value.value = ''; // 重置 input
fileInput.value.value = ""; // 重置 input
fileInput.value.click();
}
}
@@ -206,8 +257,10 @@ async function handleFileChange(event: Event) {
if (!file) return;
importing.value = true;
addLog(t('data.messages.importStart', { name: file.name, mode: currentImportMode.value }));
addLog(
t("data.messages.importStart", { name: file.name, mode: currentImportMode.value })
);
const reader = new FileReader();
reader.onload = async (e) => {
try {
@@ -216,37 +269,36 @@ async function handleFileChange(event: Event) {
try {
json = JSON.parse(content);
} catch (parseError) {
throw new Error(t('data.messages.jsonParseFailed'));
throw new Error(t("data.messages.jsonParseFailed"));
}
addLog(t('data.messages.fileParseSuccess'));
addLog(t("data.messages.fileParseSuccess"));
switch (currentImportMode.value) {
case 'comments':
case "comments":
await processImportComments(json);
break;
case 'config':
case "config":
await processImportConfig(json);
break;
case 'stats':
case "stats":
await processImportStats(json);
break;
case 'backup':
case "backup":
await processImportBackup(json);
break;
}
} catch (err: any) {
console.error(err);
addLog(t('data.messages.errorWithMessage', { msg: err.message }));
addLog(t("data.messages.errorWithMessage", { msg: err.message }));
showToast(err.message, "error");
importing.value = false;
}
};
reader.onerror = () => {
addLog(t('data.messages.readFileFailedLog'));
showToast(t('data.messages.fileReadFailed'), "error");
addLog(t("data.messages.readFileFailedLog"));
showToast(t("data.messages.fileReadFailed"), "error");
importing.value = false;
};
@@ -257,27 +309,27 @@ async function handleFileChange(event: Event) {
async function processImportConfig(data: any) {
const res = await importConfig(data);
addLog(res.message);
showToast(t('data.messages.importConfigSuccess'));
showToast(t("data.messages.importConfigSuccess"));
importing.value = false;
}
async function processImportStats(data: any) {
const res = await importStats(data);
addLog(res.message);
showToast(t('data.messages.importStatsSuccess'));
showToast(t("data.messages.importStatsSuccess"));
importing.value = false;
}
async function processImportBackup(data: any) {
const res = await importBackup(data);
addLog(res.message);
showToast(t('data.messages.importBackupSuccess'));
showToast(t("data.messages.importBackupSuccess"));
importing.value = false;
}
async function processImportComments(json: any) {
const comments = Array.isArray(json) ? json : [json];
addLog(t('data.messages.parsedCommentsCount', { count: comments.length }));
addLog(t("data.messages.parsedCommentsCount", { count: comments.length }));
// 检查 URL 前缀 (仅针对评论导入)
let missingCount = 0;
@@ -291,7 +343,7 @@ async function processImportComments(json: any) {
}
if (missingCount > 0) {
addLog(t('data.messages.detectMissingPrefix', { count: missingCount }));
addLog(t("data.messages.detectMissingPrefix", { count: missingCount }));
missingPrefixCount.value = missingCount;
pendingJson.value = comments;
showPrefixModal.value = true;
@@ -304,8 +356,8 @@ async function processImportComments(json: any) {
async function executeImportComments(comments: any[]) {
try {
const res = await importComments(comments);
addLog(t('data.messages.importCommentsDone', { message: res.message }));
showToast(t('data.messages.importCommentsSuccess'));
addLog(t("data.messages.importCommentsDone", { message: res.message }));
showToast(t("data.messages.importCommentsSuccess"));
} catch (err: any) {
throw err;
} finally {
@@ -317,14 +369,14 @@ async function executeImportComments(comments: any[]) {
// 前缀确认逻辑
async function confirmPrefix() {
if (!urlPrefix.value) {
showToast(t('data.messages.prefixRequired'), "error");
showToast(t("data.messages.prefixRequired"), "error");
return;
}
let prefix = urlPrefix.value.trim();
const comments = pendingJson.value.map((item) => {
const newItem = { ...item };
// Twikoo
if (newItem.href && typeof newItem.href === "string") {
if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) {
@@ -333,13 +385,19 @@ async function confirmPrefix() {
}
// Artalk
if (newItem.page_key && typeof newItem.page_key === "string") {
if (!newItem.page_key.startsWith("http://") && !newItem.page_key.startsWith("https://")) {
if (
!newItem.page_key.startsWith("http://") &&
!newItem.page_key.startsWith("https://")
) {
newItem.page_key = joinUrl(prefix, newItem.page_key);
}
}
// CWD
if (newItem.post_slug && typeof newItem.post_slug === "string") {
if (!newItem.post_slug.startsWith("http://") && !newItem.post_slug.startsWith("https://")) {
if (
!newItem.post_slug.startsWith("http://") &&
!newItem.post_slug.startsWith("https://")
) {
newItem.post_slug = joinUrl(prefix, newItem.post_slug);
}
}
@@ -347,13 +405,13 @@ async function confirmPrefix() {
});
showPrefixModal.value = false;
addLog(t('data.messages.prefixAdded'));
addLog(t("data.messages.prefixAdded"));
await executeImportComments(comments);
}
function cancelPrefix() {
showPrefixModal.value = false;
addLog(t('data.messages.skipPrefix'));
addLog(t("data.messages.skipPrefix"));
executeImportComments(pendingJson.value);
}

View File

@@ -14,18 +14,14 @@
<div class="layout-domain-filter layout-domain-filter-header">
<select v-model="currentSiteId" class="layout-domain-select">
<option :value="defaultSiteId">{{ getSiteLabel(defaultSiteId) }}</option>
<option
v-for="item in siteOptions"
:key="item.value"
:value="item.value"
>
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
{{ getSiteLabel(item.value) }}
</option>
</select>
</div>
<div class="layout-actions">
<a class="layout-button" href="https://cwd.js.org" target="_blank">
{{ t('layout.docs') }}
{{ t("layout.docs") }}
</a>
<a class="layout-button" href="https://github.com/anghunk/cwd" target="_blank">
Github
@@ -40,7 +36,9 @@
<PhMoon v-else-if="theme === 'dark'" :size="16" />
<PhAirplay v-else :size="16" />
</button>
<button class="layout-button" @click="handleLogout">{{ t('layout.logout') }}</button>
<button class="layout-button" @click="handleLogout">
{{ t("layout.logout") }}
</button>
</div>
<button
@@ -53,7 +51,7 @@
</button>
<div v-if="isActionsOpen" class="layout-actions-dropdown">
<button class="layout-actions-item" type="button" @click="openDocs">
{{ t('layout.docs') }}
{{ t("layout.docs") }}
</button>
<button class="layout-actions-item" type="button" @click="openGithub">
Github
@@ -63,7 +61,7 @@
type="button"
@click="handleLogoutFromActions"
>
{{ t('layout.logout') }}
{{ t("layout.logout") }}
</button>
</div>
</div>
@@ -76,11 +74,7 @@
<div class="layout-sider-domain-filter">
<select v-model="currentSiteId" class="layout-domain-select">
<option :value="defaultSiteId">{{ getSiteLabel(defaultSiteId) }}</option>
<option
v-for="item in siteOptions"
:key="item.value"
:value="item.value"
>
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
{{ getSiteLabel(item.value) }}
</option>
</select>
@@ -92,7 +86,7 @@
@click="goComments"
>
<PhChatCircleDots class="menu-item-icon" :size="18" />
<span>{{ t('menu.comments') }}</span>
<span>{{ t("menu.comments") }}</span>
</li>
<li
class="menu-item"
@@ -100,7 +94,7 @@
@click="goStats"
>
<PhSquaresFour class="menu-item-icon" :size="18" />
<span>{{ t('menu.stats') }}</span>
<span>{{ t("menu.stats") }}</span>
</li>
<li
class="menu-item"
@@ -108,7 +102,7 @@
@click="goAnalytics"
>
<PhChartBar class="menu-item-icon" :size="18" />
<span>{{ t('menu.analytics') }}</span>
<span>{{ t("menu.analytics") }}</span>
</li>
<li
class="menu-item"
@@ -116,7 +110,7 @@
@click="goSettings"
>
<PhGear class="menu-item-icon" :size="18" />
<span>{{ t('menu.settings') }}</span>
<span>{{ t("menu.settings") }}</span>
</li>
<li
class="menu-item"
@@ -124,7 +118,7 @@
@click="goData"
>
<PhDatabase class="menu-item-icon" :size="18" />
<span>{{ t('menu.data') }}</span>
<span>{{ t("menu.data") }}</span>
</li>
</ul>
<div class="layout-sider-footer" @click="openVersionModal">
@@ -141,35 +135,42 @@
</div>
<div v-if="versionModalVisible" class="modal-overlay" @click.self="closeVersionModal">
<div class="modal">
<h3 class="modal-title">{{ t('layout.version.title') }}</h3>
<h3 class="modal-title">{{ t("layout.version.title") }}</h3>
<div class="modal-body">
<p class="modal-row">
<span class="modal-label">{{ t('layout.version.apiAddress') }}</span>
<span class="modal-value">{{ checkedApiBaseUrl || t('layout.version.notConfigured') }}</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">{{ t('layout.version.apiVersion') }}</span>
<span class="modal-label">{{ t("layout.version.apiVersion") }}</span>
<span class="modal-value">
{{ apiVersion || (apiVersionError ? t('layout.version.notFetched') : t('layout.version.loading')) }}
{{
apiVersion ||
(apiVersionError
? t("layout.version.notFetched")
: t("layout.version.loading"))
}}
</span>
</p>
<p class="modal-row">
<span class="modal-label">{{ t('layout.version.adminVersion') }}</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') }}
{{ t("layout.version.match") }}
</p>
<p v-else-if="apiVersion && apiVersion !== adminVersion" class="modal-status">
{{ t('layout.version.mismatch') }}
{{ t("layout.version.mismatch") }}
</p>
<p v-else-if="apiVersionError" class="modal-status">
{{ t('layout.version.fetchError') }} {{ apiVersionError }}
{{ t("layout.version.fetchError") }} {{ apiVersionError }}
</p>
</div>
<div class="modal-actions">
<button class="modal-btn" type="button" @click="closeVersionModal">
{{ t('layout.version.ok') }}
{{ t("layout.version.ok") }}
</button>
</div>
</div>
@@ -205,9 +206,9 @@ const versionModalVisible = ref(false);
const layoutTitle = ref(localStorage.getItem(SITE_TITLE_KEY) || "CWD 评论系统");
const themeTitle = computed(() => {
if (theme.value === "light") return t('layout.theme.light');
if (theme.value === "dark") return t('layout.theme.dark');
return t('layout.theme.system');
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() {
@@ -222,7 +223,7 @@ const defaultSiteId = "default";
function getSiteLabel(value: string) {
if (!value || value === "default") {
return t('layout.defaultSite');
return t("layout.defaultSite");
}
return value;
}
@@ -232,10 +233,12 @@ async function loadSites() {
const res = await fetchSiteList();
const sites = Array.isArray(res.sites) ? res.sites : [];
const unique = Array.from(new Set(sites));
siteOptions.value = unique.filter((s) => s !== "").map((s) => ({
label: s,
value: s,
}));
siteOptions.value = unique
.filter((s) => s !== "")
.map((s) => ({
label: s,
value: s,
}));
} catch {
siteOptions.value = [];
}

View File

@@ -7,18 +7,9 @@
<div class="domain-transfer">
<!-- Visible Domains (Left) -->
<div class="transfer-panel">
<div class="transfer-header">
后台显示站点 ({{ visibleList.length }})
</div>
<div
class="transfer-body"
@dragover.prevent
@drop="onDrop($event, 'visible')"
>
<div
v-if="visibleList.length === 0"
class="transfer-empty"
>
<div class="transfer-header">后台显示站点 ({{ visibleList.length }})</div>
<div class="transfer-body" @dragover.prevent @drop="onDrop($event, 'visible')">
<div v-if="visibleList.length === 0" class="transfer-empty">
无站点 (默认显示全部)
</div>
<div
@@ -43,26 +34,15 @@
<PhCaretDoubleLeft />
</button>
<button class="action-btn" @click="moveAllToHidden" title="全部右移">
<PhCaretDoubleRight />
<PhCaretDoubleRight />
</button>
</div>
<!-- Hidden/All Domains (Right) -->
<div class="transfer-panel">
<div class="transfer-header">
其他站点 ({{ hiddenList.length }})
</div>
<div
class="transfer-body"
@dragover.prevent
@drop="onDrop($event, 'hidden')"
>
<div
v-if="hiddenList.length === 0"
class="transfer-empty"
>
无更多站点
</div>
<div class="transfer-header">其他站点 ({{ hiddenList.length }})</div>
<div class="transfer-body" @dragover.prevent @drop="onDrop($event, 'hidden')">
<div v-if="hiddenList.length === 0" class="transfer-empty">无更多站点</div>
<div
v-for="domain in hiddenList"
:key="domain"
@@ -89,8 +69,12 @@
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { fetchSiteList, fetchFeatureSettings, saveFeatureSettings } from '../../../api/admin';
import { ref, onMounted, computed } from "vue";
import {
fetchSiteList,
fetchFeatureSettings,
saveFeatureSettings,
} from "../../../api/admin";
const loading = ref(false);
const allDomains = ref<string[]>([]);
@@ -98,12 +82,12 @@ const visibleList = ref<string[]>([]);
const hiddenList = computed(() => {
const visibleSet = new Set(visibleList.value);
return allDomains.value.filter(d => !visibleSet.has(d));
return allDomains.value.filter((d) => !visibleSet.has(d));
});
function getSiteLabel(value: string) {
if (!value) {
return '默认站点 (Default)';
return "默认站点 (Default)";
}
return value;
}
@@ -112,14 +96,14 @@ async function loadData() {
try {
const [siteRes, settingsRes] = await Promise.all([
fetchSiteList(),
fetchFeatureSettings()
fetchFeatureSettings(),
]);
allDomains.value = siteRes.sites || [];
if (settingsRes.visibleDomains) {
visibleList.value = settingsRes.visibleDomains;
} else {
visibleList.value = [];
visibleList.value = [];
}
} catch (e) {
console.error(e);
@@ -127,7 +111,7 @@ async function loadData() {
}
function moveToHidden(domain: string) {
visibleList.value = visibleList.value.filter(d => d !== domain);
visibleList.value = visibleList.value.filter((d) => d !== domain);
}
function moveToVisible(domain: string) {
@@ -138,7 +122,7 @@ function moveToVisible(domain: string) {
function moveAllToVisible() {
const current = new Set(visibleList.value);
hiddenList.value.forEach(d => current.add(d));
hiddenList.value.forEach((d) => current.add(d));
visibleList.value = Array.from(current);
}
@@ -146,20 +130,20 @@ function moveAllToHidden() {
visibleList.value = [];
}
function onDragStart(event: DragEvent, domain: string, source: 'visible' | 'hidden') {
function onDragStart(event: DragEvent, domain: string, source: "visible" | "hidden") {
if (event.dataTransfer) {
event.dataTransfer.setData('text/plain', JSON.stringify({ domain, source }));
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData("text/plain", JSON.stringify({ domain, source }));
event.dataTransfer.effectAllowed = "move";
}
}
function onDrop(event: DragEvent, target: 'visible' | 'hidden') {
const data = event.dataTransfer?.getData('text/plain');
function onDrop(event: DragEvent, target: "visible" | "hidden") {
const data = event.dataTransfer?.getData("text/plain");
if (data) {
try {
const { domain, source } = JSON.parse(data);
if (source !== target) {
if (target === 'visible') {
if (target === "visible") {
moveToVisible(domain);
} else {
moveToHidden(domain);
@@ -175,12 +159,12 @@ async function handleSave() {
loading.value = true;
try {
await saveFeatureSettings({
visibleDomains: visibleList.value
visibleDomains: visibleList.value,
});
// 保存成功后刷新页面以应用更改LayoutView 重新加载)
window.location.reload();
} catch (e) {
alert('保存失败');
alert("保存失败");
} finally {
loading.value = false;
}
@@ -208,7 +192,7 @@ onMounted(() => {
gap: 20px;
align-items: flex-start;
height: 400px;
@media (max-width: 768px) {
flex-direction: column;
height: auto;
@@ -267,15 +251,15 @@ onMounted(() => {
&:hover {
background: var(--bg-hover);
}
&:active {
cursor: grabbing;
cursor: grabbing;
}
.domain-text {
flex: 1;
margin: 0 10px;
word-break: break-all;
flex: 1;
margin: 0 10px;
word-break: break-all;
}
}
@@ -288,7 +272,7 @@ onMounted(() => {
border-radius: 4px;
display: flex;
align-items: center;
&:hover {
background: var(--bg-active);
color: var(--primary-color);
@@ -300,7 +284,7 @@ onMounted(() => {
flex-direction: column;
gap: 10px;
align-self: center;
@media (max-width: 768px) {
flex-direction: row;
}
@@ -318,7 +302,7 @@ onMounted(() => {
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--primary-color);
color: var(--primary-color);
@@ -339,7 +323,7 @@ onMounted(() => {
border-radius: 6px;
cursor: pointer;
font-size: 14px;
&:disabled {
opacity: 0.7;
cursor: not-allowed;

View File

@@ -1,6 +1,6 @@
<template>
<div class="page">
<h2 class="page-title">{{ t('settings.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">{{ t('common.loading') }}</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') }}
{{ t("settings.tabs.comment") }}
</button>
<button
type="button"
@@ -25,7 +25,7 @@
:class="{ 'settings-tab-active': activeTab === 'feature' }"
@click="activeTab = 'feature'"
>
{{ t('settings.tabs.feature') }}
{{ t("settings.tabs.feature") }}
</button>
<button
type="button"
@@ -33,7 +33,7 @@
:class="{ 'settings-tab-active': activeTab === 'emailNotify' }"
@click="activeTab = 'emailNotify'"
>
{{ t('settings.tabs.emailNotify') }}
{{ t("settings.tabs.emailNotify") }}
</button>
<button
type="button"
@@ -41,7 +41,7 @@
:class="{ 'settings-tab-active': activeTab === 'telegramNotify' }"
@click="activeTab = 'telegramNotify'"
>
{{ t('settings.tabs.telegramNotify') }}
{{ t("settings.tabs.telegramNotify") }}
</button>
<button
type="button"
@@ -49,7 +49,7 @@
:class="{ 'settings-tab-active': activeTab === 'display' }"
@click="activeTab = 'display'"
>
{{ t('settings.tabs.display') }}
{{ t("settings.tabs.display") }}
</button>
</div>
<transition name="tab-fade" mode="out-in">
@@ -57,33 +57,37 @@
<template v-if="activeTab === 'comment'">
<div class="card">
<div class="card-header">
<div class="card-title">{{ t('settings.comment.title') }}</div>
<div class="card-title">{{ t("settings.comment.title") }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">{{ t('settings.comment.adminEmail') }}</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">{{ t('settings.comment.adminBadge') }}</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">{{ t('settings.comment.adminEnabled') }}</label>
<label class="form-label">{{
t("settings.comment.adminEnabled")
}}</label>
<label class="switch">
<input v-model="commentAdminEnabled" type="checkbox" />
<span class="slider" />
</label>
</div>
<div class="form-item">
<label class="form-label"
>{{ t('settings.comment.avatarPrefix') }}</label
>
<label class="form-label">{{
t("settings.comment.avatarPrefix")
}}</label>
<input v-model="avatarPrefix" class="form-input" type="text" />
</div>
<h3 class="card-title">{{ t('settings.comment.securityTitle') }}</h3>
<h3 class="card-title">{{ t("settings.comment.securityTitle") }}</h3>
<div class="form-item">
<label class="form-label">{{ t('settings.comment.requireReview') }}</label>
<label class="form-label">{{
t("settings.comment.requireReview")
}}</label>
<label class="switch">
<input v-model="requireReview" type="checkbox" />
<span class="slider" />
@@ -91,7 +95,7 @@
</div>
<div class="form-item">
<label class="form-label">
{{ t('settings.comment.adminKey') }}
{{ t("settings.comment.adminKey") }}
</label>
<input
@@ -103,20 +107,20 @@
</div>
<div class="form-item">
<label class="form-label">
{{ t('settings.comment.allowedDomains') }}
{{ t("settings.comment.allowedDomains") }}
</label>
<TagInput v-model="allowedDomainTags" />
</div>
<div class="form-item">
<label class="form-label">
{{ t('settings.comment.blockedIps') }}
{{ t("settings.comment.blockedIps") }}
</label>
<TagInput v-model="blockedIpTags" />
</div>
<div class="form-item">
<label class="form-label"
>{{ t('settings.comment.blockedEmails') }}</label
>
<label class="form-label">{{
t("settings.comment.blockedEmails")
}}</label>
<TagInput v-model="blockedEmailTags" />
</div>
@@ -126,8 +130,8 @@
:disabled="savingComment"
@click="saveComment"
>
<span v-if="savingComment">{{ t('settings.comment.saving') }}</span>
<span v-else>{{ t('settings.comment.save') }}</span>
<span v-if="savingComment">{{ t("settings.comment.saving") }}</span>
<span v-else>{{ t("settings.comment.save") }}</span>
</button>
</div>
</div>
@@ -136,47 +140,57 @@
<template v-else-if="activeTab === 'feature'">
<div class="card feature">
<div class="card-header">
<div class="card-title">{{ t('settings.feature.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">{{ t('settings.feature.articleLike') }}</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') }}
{{ t("settings.feature.articleLikeHint") }}
</div>
</div>
<div class="form-item bg">
<div class="form-item-flex">
<label class="form-label">{{ t('settings.feature.commentLike') }}</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') }}
{{ t("settings.feature.commentLikeHint") }}
</div>
</div>
<div class="form-item bg">
<div class="form-item-flex">
<label class="form-label">{{ t('settings.feature.imageLightbox') }}</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">{{ t('settings.feature.imageLightboxHint') }}</div>
<div class="form-hint">
{{ t("settings.feature.imageLightboxHint") }}
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.feature.placeholder') }}</label>
<label class="form-label">{{
t("settings.feature.placeholder")
}}</label>
<textarea
v-model="commentPlaceholder"
class="form-input"
@@ -185,20 +199,26 @@
:placeholder="t('settings.feature.placeholderHint')"
></textarea>
<div class="form-hint">
{{ t('settings.feature.placeholderHint') }}
{{ t("settings.feature.placeholderHint") }}
</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.feature.widgetLanguage') }}</label>
<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 }}
<option
v-for="lang in languageOptions"
:key="lang.value"
:value="lang.value"
>
{{ lang.label }}
</option>
</select>
<div class="form-hint">
{{ t('settings.feature.widgetLanguageHint') }}
{{ t("settings.feature.widgetLanguageHint") }}
</div>
</div>
@@ -208,8 +228,8 @@
:disabled="savingFeature"
@click="saveFeature"
>
<span v-if="savingFeature">{{ t('settings.feature.saving') }}</span>
<span v-else>{{ t('settings.feature.save') }}</span>
<span v-if="savingFeature">{{ t("settings.feature.saving") }}</span>
<span v-else>{{ t("settings.feature.save") }}</span>
</button>
</div>
</div>
@@ -218,28 +238,38 @@
<template v-else-if="activeTab === 'display'">
<div class="card">
<div class="card-header">
<div class="card-title">{{ t('settings.display.title') }}</div>
<div class="card-title">{{ t("settings.display.title") }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">{{ t('settings.display.layoutTitle') }}</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">{{ t('settings.display.layoutTitleHint') }}</div>
<div class="form-hint">{{ t("settings.display.layoutTitleHint") }}</div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.display.adminLanguage') }}</label>
<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 }}
<option
v-for="lang in languageOptions"
:key="lang.value"
:value="lang.value"
>
{{ lang.label }}
</option>
</select>
<div class="form-hint">{{ t('settings.display.adminLanguageHint') }}</div>
<div class="form-hint">
{{ t("settings.display.adminLanguageHint") }}
</div>
</div>
<div class="card-actions">
@@ -248,8 +278,8 @@
:disabled="savingDisplay"
@click="saveDisplay"
>
<span v-if="savingDisplay">{{ t('settings.display.saving') }}</span>
<span v-else>{{ t('settings.display.save') }}</span>
<span v-if="savingDisplay">{{ t("settings.display.saving") }}</span>
<span v-else>{{ t("settings.display.save") }}</span>
</button>
</div>
</div>
@@ -258,11 +288,11 @@
<template v-else-if="activeTab === 'emailNotify'">
<div class="card">
<div class="card-header">
<div class="card-title">{{ t('settings.emailNotify.title') }}</div>
<div class="card-title">{{ t("settings.emailNotify.title") }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.enable') }}</label>
<label class="form-label">{{ t("settings.emailNotify.enable") }}</label>
<label class="switch">
<input v-model="emailGlobalEnabled" type="checkbox" />
<span class="slider" />
@@ -270,10 +300,12 @@
</div>
<div class="divider"></div>
<h4 class="card-subtitle">{{ t('settings.emailNotify.smtpTitle') }}</h4>
<h4 class="card-subtitle">{{ t("settings.emailNotify.smtpTitle") }}</h4>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.provider') }}</label>
<label class="form-label">{{
t("settings.emailNotify.provider")
}}</label>
<select
v-model="smtpProvider"
class="form-input"
@@ -287,7 +319,7 @@
<div v-if="smtpProvider === 'custom'">
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.host') }}</label>
<label class="form-label">{{ t("settings.emailNotify.host") }}</label>
<input
v-model="smtpHost"
class="form-input"
@@ -295,7 +327,7 @@
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.port') }}</label>
<label class="form-label">{{ t("settings.emailNotify.port") }}</label>
<input
v-model="smtpPort"
class="form-input"
@@ -304,7 +336,9 @@
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.secure') }}</label>
<label class="form-label">{{
t("settings.emailNotify.secure")
}}</label>
<label class="switch">
<input v-model="smtpSecure" type="checkbox" />
<span class="slider" />
@@ -313,7 +347,7 @@
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.user') }}</label>
<label class="form-label">{{ t("settings.emailNotify.user") }}</label>
<input
v-model="smtpUser"
class="form-input"
@@ -321,24 +355,36 @@
/>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.pass') }}</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" v-html="t('settings.emailNotify.qqHint')"></div>
<div v-else-if="smtpProvider === '163'" class="form-hint" v-html="t('settings.emailNotify.163Hint')"></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">{{ t('settings.emailNotify.templateTitle') }}</h4>
<h4 class="card-subtitle">
{{ t("settings.emailNotify.templateTitle") }}
</h4>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.adminTemplate') }}</label>
<label class="form-label">{{
t("settings.emailNotify.adminTemplate")
}}</label>
<div class="form-hint">
{{ t('settings.emailNotify.adminTemplateHint') }}
{{ t("settings.emailNotify.adminTemplateHint") }}
</div>
<textarea
v-model="templateAdmin"
@@ -349,9 +395,11 @@
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.replyTemplate') }}</label>
<label class="form-label">{{
t("settings.emailNotify.replyTemplate")
}}</label>
<div class="form-hint">
{{ t('settings.emailNotify.replyTemplateHint') }}
{{ t("settings.emailNotify.replyTemplateHint") }}
</div>
<textarea
v-model="templateReply"
@@ -373,19 +421,21 @@
:disabled="testingEmail"
@click="testEmail"
>
<span v-if="testingEmail">{{ t('settings.emailNotify.testingBtn') }}</span>
<span v-else>{{ t('settings.emailNotify.testBtn') }}</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') }}
{{ t("settings.emailNotify.resetBtn") }}
</button>
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
<span v-if="savingEmail">{{ t('settings.emailNotify.saving') }}</span>
<span v-else>{{ t('settings.emailNotify.save') }}</span>
<span v-if="savingEmail">{{ t("settings.emailNotify.saving") }}</span>
<span v-else>{{ t("settings.emailNotify.save") }}</span>
</button>
</div>
</div>
@@ -394,18 +444,22 @@
<template v-else-if="activeTab === 'telegramNotify'">
<div class="card">
<div class="card-header">
<div class="card-title">{{ t('settings.telegramNotify.title') }}</div>
<div class="card-title">{{ t("settings.telegramNotify.title") }}</div>
</div>
<div class="card-body">
<div class="form-item">
<label class="form-label">{{ t('settings.telegramNotify.enable') }}</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">{{ t('settings.telegramNotify.botToken') }}</label>
<label class="form-label">{{
t("settings.telegramNotify.botToken")
}}</label>
<input
v-model="telegramBotToken"
class="form-input"
@@ -413,17 +467,25 @@
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
autocomplete="new-password"
/>
<div class="form-hint" v-html="t('settings.telegramNotify.botTokenHint')"></div>
<div
class="form-hint"
v-html="t('settings.telegramNotify.botTokenHint')"
></div>
</div>
<div class="form-item">
<label class="form-label">{{ t('settings.telegramNotify.chatId') }}</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" v-html="t('settings.telegramNotify.chatIdHint')"></div>
<div
class="form-hint"
v-html="t('settings.telegramNotify.chatIdHint')"
></div>
</div>
<div class="card-actions" style="justify-content: space-between">
@@ -432,8 +494,10 @@
:disabled="settingUpWebhook"
@click="doSetupWebhook"
>
<span v-if="settingUpWebhook">{{ t('settings.telegramNotify.webhookSetting') }}</span>
<span v-else>{{ t('settings.telegramNotify.webhookBtn') }}</span>
<span v-if="settingUpWebhook">{{
t("settings.telegramNotify.webhookSetting")
}}</span>
<span v-else>{{ t("settings.telegramNotify.webhookBtn") }}</span>
</button>
<button
class="card-button secondary"
@@ -441,16 +505,20 @@
@click="testTelegram"
style="margin-right: auto"
>
<span v-if="testingTelegram">{{ t('settings.telegramNotify.testingBtn') }}</span>
<span v-else>{{ t('settings.telegramNotify.testBtn') }}</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">{{ t('settings.telegramNotify.saving') }}</span>
<span v-else>{{ t('settings.telegramNotify.save') }}</span>
<span v-if="savingTelegram">{{
t("settings.telegramNotify.saving")
}}</span>
<span v-else>{{ t("settings.telegramNotify.save") }}</span>
</button>
</div>
</div>
@@ -463,7 +531,7 @@
</template>
<script setup lang="ts">
import { onMounted, ref, type Ref, inject, watch } from "vue";
import { onMounted, ref, inject, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import {
@@ -582,21 +650,21 @@ 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' },
{ 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();
@@ -606,12 +674,7 @@ const widgetLanguage = ref("auto");
const route = useRoute();
const router = useRouter();
type TabKey =
| "comment"
| "feature"
| "display"
| "emailNotify"
| "telegramNotify";
type TabKey = "comment" | "feature" | "display" | "emailNotify" | "telegramNotify";
const validTabs: TabKey[] = [
"comment",
"feature",
@@ -621,9 +684,7 @@ const validTabs: TabKey[] = [
];
const activeTab = ref<TabKey>(
validTabs.includes(route.query.tab as TabKey)
? (route.query.tab as TabKey)
: "comment",
validTabs.includes(route.query.tab as TabKey) ? (route.query.tab as TabKey) : "comment"
);
watch(activeTab, (newTab) => {
@@ -636,7 +697,7 @@ watch(
if (newTab && validTabs.includes(newTab as TabKey)) {
activeTab.value = newTab as TabKey;
}
},
}
);
const savingEmail = ref(false);
@@ -667,9 +728,15 @@ function loadCardsExpanded() {
site: parsed.site ?? false,
};
}
} catch {
}
return { comment: true, display: false, feature: false, email: false, telegram: false, site: false };
} catch {}
return {
comment: true,
display: false,
feature: false,
email: false,
telegram: false,
site: false,
};
}
const cardsExpanded = ref(loadCardsExpanded());
@@ -725,7 +792,13 @@ function resetTemplatesToDefault() {
async function load() {
loading.value = true;
try {
const [commentRes, emailNotifyRes, featureRes, telegramRes, displayRes] = await Promise.all([
const [
commentRes,
emailNotifyRes,
featureRes,
telegramRes,
displayRes,
] = await Promise.all([
fetchCommentSettings(),
fetchEmailNotifySettings(),
fetchFeatureSettings(),
@@ -758,11 +831,11 @@ async function load() {
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);
localStorage.setItem("admin_language", featureRes.adminLanguage);
}
telegramBotToken.value = telegramRes.botToken || "";
@@ -834,7 +907,7 @@ async function testEmail() {
message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
messageType.value = "error";
return;
}
}
if (!smtpUser.value || !smtpPass.value) {
message.value = "请先填写 SMTP 账号和密码";
messageType.value = "error";
@@ -894,9 +967,9 @@ async function saveComment() {
}),
]);
showToast(commentRes.message || t('common.saveSuccess'), "success");
showToast(commentRes.message || t("common.saveSuccess"), "success");
} catch (e: any) {
showToast(e.message || t('common.saveFailed'), "error");
showToast(e.message || t("common.saveFailed"), "error");
} finally {
savingComment.value = false;
}
@@ -916,9 +989,9 @@ async function saveFeature() {
}),
]);
showToast(featureRes.message || t('common.saveSuccess'), "success");
showToast(featureRes.message || t("common.saveSuccess"), "success");
} catch (e: any) {
showToast(e.message || t('common.saveFailed'), "error");
showToast(e.message || t("common.saveFailed"), "error");
} finally {
savingFeature.value = false;
}
@@ -933,19 +1006,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
adminLanguage: adminLanguage.value,
});
if (updateSiteTitle) {
updateSiteTitle(adminLayoutTitle.value);
}
// Update local locale
locale.value = adminLanguage.value;
localStorage.setItem('admin_language', 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">{{ t('stats.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">{{ t('stats.overview') }}</h3>
<h3 class="card-title">{{ t("stats.overview") }}</h3>
</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</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">{{ t('stats.total') }}</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">{{ t('stats.approved') }}</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">{{ t('stats.pending') }}</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">{{ t('stats.rejected') }}</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">{{ t('stats.trend') }}</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')"
>
{{ t('stats.last7Days') }}
{{ t("stats.last7Days") }}
</button>
<button
class="chart-tab"
@@ -61,11 +61,11 @@
type="button"
@click="changeChartRange('30')"
>
{{ t('stats.last30Days') }}
{{ t("stats.last30Days") }}
</button>
</div>
</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</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,26 +74,26 @@
<div class="card">
<div class="card-title-row">
<h3 class="card-title">{{ t('stats.bySite') }}</h3>
<h3 class="card-title">{{ t("stats.bySite") }}</h3>
</div>
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</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">{{ t('stats.noData') }}</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">{{ 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 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"
:key="item.domain"
class="domain-table-row"
>
<div v-for="item in domainStats" :key="item.domain" class="domain-table-row">
<div class="domain-cell domain-cell-domain">{{ item.domain }}</div>
<div class="domain-cell">{{ item.total }}</div>
<div class="domain-cell">{{ item.approved }}</div>
@@ -111,8 +111,7 @@
</template>
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject } from "vue";
import type { Ref } from "vue";
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
import { useI18n } from "vue-i18n";
import * as echarts from "echarts";
import { fetchCommentStats } from "../../api/admin";
@@ -161,8 +160,7 @@ function loadChartRangeFromStorage() {
if (value === "7" || value === "30") {
chartRange.value = value;
}
} catch {
}
} catch {}
}
function saveChartRangeToStorage(value: "7" | "30") {
@@ -171,8 +169,7 @@ function saveChartRangeToStorage(value: "7" | "30") {
}
try {
window.localStorage.setItem(chartRangeStorageKey, value);
} catch {
}
} catch {}
}
function showToast(msg: string, type: "success" | "error" = "success") {
@@ -220,8 +217,7 @@ function renderChart() {
chartInstance = echarts.init(el);
}
const source = last7Days.value;
const seriesData =
chartRange.value === "7" ? source.slice(-7) : source;
const seriesData = chartRange.value === "7" ? source.slice(-7) : source;
const dates = seriesData.map((item) => item.date.slice(5));
const values = seriesData.map((item) => item.total);
const option: echarts.EChartsOption = {