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

View File

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

View File

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

View File

@@ -1,29 +1,35 @@
<template> <template>
<div class="page"> <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">
<div class="toolbar-left"> <div class="toolbar-left">
<select v-model="statusFilter" class="toolbar-select"> <select v-model="statusFilter" class="toolbar-select">
<option value="">{{ t('comments.statusFilter.all') }}</option> <option value="">{{ t("comments.statusFilter.all") }}</option>
<option value="approved">{{ t('comments.statusFilter.approved') }}</option> <option value="approved">{{ t("comments.statusFilter.approved") }}</option>
<option value="pending">{{ t('comments.statusFilter.pending') }}</option> <option value="pending">{{ t("comments.statusFilter.pending") }}</option>
<option value="rejected">{{ t('comments.statusFilter.rejected') }}</option> <option value="rejected">{{ t("comments.statusFilter.rejected") }}</option>
</select> </select>
</div> </div>
<div class="toolbar-right"> <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> </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="error" class="page-error">{{ error }}</div>
<div v-else> <div v-else>
<div class="comment-table"> <div class="comment-table">
<div class="table-header"> <div class="table-header">
<div class="table-cell table-cell-author">{{ t('comments.table.author') }}</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-content">
<div class="table-cell table-cell-path">{{ t('comments.table.path') }}</div> {{ t("comments.table.content") }}
<div class="table-cell table-cell-status">{{ t('comments.table.status') }}</div> </div>
<div class="table-cell table-cell-actions">{{ t('comments.table.actions') }}</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>
<div v-for="item in filteredComments" :key="item.id" class="table-row"> <div v-for="item in filteredComments" :key="item.id" class="table-row">
<div class="table-cell table-cell-author"> <div class="table-cell table-cell-author">
@@ -89,10 +95,9 @@
target="_blank" target="_blank"
class="cell-path" class="cell-path"
:title="item.postSlug" :title="item.postSlug"
>
{{ item.postUrl || item.postSlug }}
</a
> >
{{ item.postUrl || item.postSlug }}
</a>
</div> </div>
<div class="table-cell table-cell-status"> <div class="table-cell table-cell-status">
<div class="cell-status-wrapper"> <div class="cell-status-wrapper">
@@ -104,7 +109,7 @@
:title="String(item.priority)" :title="String(item.priority)"
class="cell-status cell-pin-flag" class="cell-status cell-pin-flag"
> >
{{ t('comments.actions.pin') }} {{ t("comments.actions.pin") }}
</span> </span>
<span class="cell-status cell-likes-number" v-if="item.likes !== 0"> <span class="cell-status cell-likes-number" v-if="item.likes !== 0">
<PhThumbsUp :size="13" /> <PhThumbsUp :size="13" />
@@ -126,21 +131,25 @@
:value="item.status" :value="item.status"
@change="handleStatusChange(item, $event)" @change="handleStatusChange(item, $event)"
> >
<option value="approved">{{ t('comments.actions.approve') }}</option> <option value="approved">{{ t("comments.actions.approve") }}</option>
<option value="pending">{{ t('comments.actions.pending') }}</option> <option value="pending">{{ t("comments.actions.pending") }}</option>
<option value="rejected">{{ t('comments.actions.reject') }}</option> <option value="rejected">{{ t("comments.actions.reject") }}</option>
</select> </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 <button
class="table-action table-action-danger" class="table-action table-action-danger"
@click="removeComment(item)" @click="removeComment(item)"
> >
{{ t('comments.actions.delete') }} {{ t("comments.actions.delete") }}
</button> </button>
</div> </div>
</div> </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>
<div v-if="pagination.total > 1" class="pagination"> <div v-if="pagination.total > 1" class="pagination">
<button <button
@@ -148,7 +157,7 @@
:disabled="pagination.page <= 1" :disabled="pagination.page <= 1"
@click="goPage(pagination.page - 1)" @click="goPage(pagination.page - 1)"
> >
{{ t('comments.pagination.prev') }} {{ t("comments.pagination.prev") }}
</button> </button>
<button <button
class="pagination-button" class="pagination-button"
@@ -199,10 +208,10 @@
:disabled="pagination.page >= pagination.total" :disabled="pagination.page >= pagination.total"
@click="goPage(pagination.page + 1)" @click="goPage(pagination.page + 1)"
> >
{{ t('comments.pagination.next') }} {{ t("comments.pagination.next") }}
</button> </button>
<div class="pagination-jump"> <div class="pagination-jump">
<span>{{ t('comments.pagination.jumpTo') }}</span> <span>{{ t("comments.pagination.jumpTo") }}</span>
<input <input
v-model="jumpPageInput" v-model="jumpPageInput"
class="pagination-input" class="pagination-input"
@@ -211,8 +220,10 @@
:max="pagination.total" :max="pagination.total"
@keyup.enter="handleJumpPage" @keyup.enter="handleJumpPage"
/> />
<span>{{ t('comments.pagination.page') }}</span> <span>{{ t("comments.pagination.page") }}</span>
<button class="pagination-button" @click="handleJumpPage">{{ t('comments.pagination.confirm') }}</button> <button class="pagination-button" @click="handleJumpPage">
{{ t("comments.pagination.confirm") }}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -228,16 +239,12 @@
<script setup lang="ts"> <script setup lang="ts">
import "../../styles/markdown.css"; import "../../styles/markdown.css";
import { onMounted, ref, computed, watch, inject } from "vue"; import { onMounted, ref, computed, watch } from "vue";
import type { Ref } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { marked } from "marked";
import DOMPurify from "dompurify";
import ModalEdit from "./components/ModalEdit.vue"; import ModalEdit from "./components/ModalEdit.vue";
import { import {
CommentItem, CommentItem,
CommentListResponse,
fetchComments, fetchComments,
deleteComment, deleteComment,
updateCommentStatus, updateCommentStatus,
@@ -319,13 +326,13 @@ function formatDate(value: number) {
function formatStatus(status: string) { function formatStatus(status: string) {
if (status === "approved") { if (status === "approved") {
return t('comments.statusFilter.approved'); return t("comments.statusFilter.approved");
} }
if (status === "pending") { if (status === "pending") {
return t('comments.statusFilter.pending'); return t("comments.statusFilter.pending");
} }
if (status === "rejected") { if (status === "rejected") {
return t('comments.statusFilter.rejected'); return t("comments.statusFilter.rejected");
} }
return status; return status;
} }
@@ -398,7 +405,7 @@ function handleStatusChange(item: CommentItem, event: Event) {
} }
async function removeComment(item: CommentItem) { async function removeComment(item: CommentItem) {
if (!window.confirm(t('comments.confirmDelete', { id: item.id }))) { if (!window.confirm(t("comments.confirmDelete", { id: item.id }))) {
return; return;
} }
try { try {
@@ -413,12 +420,12 @@ async function handleBlockIp(item: CommentItem) {
if (!item.ipAddress) { if (!item.ipAddress) {
return; return;
} }
if (!window.confirm(t('comments.confirmBlockIp', { ip: item.ipAddress }))) { if (!window.confirm(t("comments.confirmBlockIp", { ip: item.ipAddress }))) {
return; return;
} }
try { try {
const res = await blockIp(item.ipAddress); const res = await blockIp(item.ipAddress);
window.alert(res.message || t('comments.successBlockIp')); window.alert(res.message || t("comments.successBlockIp"));
} catch (e: any) { } catch (e: any) {
error.value = e.message || "屏蔽 IP 失败"; error.value = e.message || "屏蔽 IP 失败";
} }
@@ -428,12 +435,12 @@ async function handleBlockEmail(item: CommentItem) {
if (!item.email) { if (!item.email) {
return; return;
} }
if (!window.confirm(t('comments.confirmBlockEmail', { email: item.email }))) { if (!window.confirm(t("comments.confirmBlockEmail", { email: item.email }))) {
return; return;
} }
try { try {
const res = await blockEmail(item.email); const res = await blockEmail(item.email);
window.alert(res.message || t('comments.successBlockEmail')); window.alert(res.message || t("comments.successBlockEmail"));
} catch (e: any) { } catch (e: any) {
error.value = e.message || "屏蔽邮箱失败"; error.value = e.message || "屏蔽邮箱失败";
} }
@@ -545,7 +552,6 @@ watch(currentSiteId, () => {
updateRoutePage(1); updateRoutePage(1);
loadComments(1); loadComments(1);
}); });
</script> </script>
<style scoped lang="less"> <style scoped lang="less">

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="page"> <div class="page">
<h2 class="page-title">{{ t('data.title') }}</h2> <h2 class="page-title">{{ t("data.title") }}</h2>
<div <div
v-if="toastVisible" v-if="toastVisible"
class="toast" class="toast"
@@ -11,57 +11,101 @@
<!-- 1. 评论数据 --> <!-- 1. 评论数据 -->
<div class="card"> <div class="card">
<h3 class="card-title">{{ t('data.sections.comments.title') }}</h3> <h3 class="card-title">{{ t("data.sections.comments.title") }}</h3>
<p class="card-desc">{{ t('data.sections.comments.desc') }}</p> <p class="card-desc">{{ t("data.sections.comments.desc") }}</p>
<div class="action-row"> <div class="action-row">
<span class="action-label">{{ t('data.sections.comments.exportLabel') }}</span> <span class="action-label">{{ t("data.sections.comments.exportLabel") }}</span>
<button class="card-button secondary" :disabled="exporting" @click="handleExportComments"> <button
<span v-if="exporting">{{ t('data.sections.comments.exporting') }}</span> class="card-button secondary"
<span v-else>{{ t('data.sections.comments.exportJson') }}</span> :disabled="exporting"
@click="handleExportComments"
>
<span v-if="exporting">{{ t("data.sections.comments.exporting") }}</span>
<span v-else>{{ t("data.sections.comments.exportJson") }}</span>
</button> </button>
</div> </div>
<div class="action-row"> <div class="action-row">
<span class="action-label">{{ t('data.sections.comments.importLabel') }}</span> <span class="action-label">{{ t("data.sections.comments.importLabel") }}</span>
<select v-model="importSource" class="form-select" style="min-width:120px;"> <select v-model="importSource" class="form-select" style="min-width: 120px">
<option value="cwd">{{ t('data.sections.comments.source.cwd') }}</option> <option value="cwd">{{ t("data.sections.comments.source.cwd") }}</option>
<option value="twikoo">{{ t('data.sections.comments.source.twikoo') }}</option> <option value="twikoo">{{ t("data.sections.comments.source.twikoo") }}</option>
<option value="artalk">{{ t('data.sections.comments.source.artalk') }}</option> <option value="artalk">{{ t("data.sections.comments.source.artalk") }}</option>
</select> </select>
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('comments')"> <button
{{ t('data.sections.comments.importButton') }} class="card-button secondary"
:disabled="importing"
@click="triggerFileInput('comments')"
>
{{ t("data.sections.comments.importButton") }}
</button> </button>
</div> </div>
</div> </div>
<!-- 2. 系统配置 --> <!-- 2. 系统配置 -->
<div class="card"> <div class="card">
<h3 class="card-title">{{ t('data.sections.config.title') }}</h3> <h3 class="card-title">{{ t("data.sections.config.title") }}</h3>
<p class="card-desc">{{ t('data.sections.config.desc') }}</p> <p class="card-desc">{{ t("data.sections.config.desc") }}</p>
<div class="action-row"> <div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportConfig">{{ t('data.sections.config.export') }}</button> <button
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">{{ t('data.sections.config.import') }}</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>
</div> </div>
<!-- 3. 访问统计 --> <!-- 3. 访问统计 -->
<div class="card"> <div class="card">
<h3 class="card-title">{{ t('data.sections.stats.title') }}</h3> <h3 class="card-title">{{ t("data.sections.stats.title") }}</h3>
<p class="card-desc">{{ t('data.sections.stats.desc') }}</p> <p class="card-desc">{{ t("data.sections.stats.desc") }}</p>
<div class="action-row"> <div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportStats">{{ t('data.sections.stats.export') }}</button> <button
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">{{ t('data.sections.stats.import') }}</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>
</div> </div>
<!-- 4. 全量备份 --> <!-- 4. 全量备份 -->
<div class="card"> <div class="card">
<h3 class="card-title">{{ t('data.sections.backup.title') }}</h3> <h3 class="card-title">{{ t("data.sections.backup.title") }}</h3>
<p class="card-desc">{{ t('data.sections.backup.desc') }}</p> <p class="card-desc">{{ t("data.sections.backup.desc") }}</p>
<div class="action-row"> <div class="action-row">
<button class="card-button secondary" :disabled="exporting" @click="handleExportBackup">{{ t('data.sections.backup.export') }}</button> <button
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">{{ t('data.sections.backup.import') }}</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>
</div> </div>
@@ -76,7 +120,7 @@
<!-- 导入日志 --> <!-- 导入日志 -->
<div v-if="importLogs.length > 0" class="log-container"> <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 class="log-list">
<div v-for="(log, index) in importLogs" :key="index" class="log-item"> <div v-for="(log, index) in importLogs" :key="index" class="log-item">
{{ log }} {{ log }}
@@ -87,15 +131,15 @@
<!-- 前缀确认弹窗 --> <!-- 前缀确认弹窗 -->
<div v-if="showPrefixModal" class="modal-overlay"> <div v-if="showPrefixModal" class="modal-overlay">
<div class="modal"> <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"> <p class="modal-desc">
{{ t('data.prefixModal.descPart1') }} {{ t("data.prefixModal.descPart1") }}
<strong>{{ missingPrefixCount }}</strong> <strong>{{ missingPrefixCount }}</strong>
{{ t('data.prefixModal.descPart2') }}<br /> {{ t("data.prefixModal.descPart2") }}<br />
{{ t('data.prefixModal.descPart3') }} {{ t("data.prefixModal.descPart3") }}
</p> </p>
<div class="form-group"> <div class="form-group">
<label class="form-label">{{ t('data.prefixModal.label') }}</label> <label class="form-label">{{ t("data.prefixModal.label") }}</label>
<input <input
v-model="urlPrefix" v-model="urlPrefix"
class="form-input" class="form-input"
@@ -103,11 +147,13 @@
@keyup.enter="confirmPrefix" @keyup.enter="confirmPrefix"
/> />
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="modal-btn secondary" @click="cancelPrefix"> <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>
<button class="modal-btn primary" @click="confirmPrefix">{{ t('data.prefixModal.confirm') }}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -117,11 +163,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { import {
exportComments, importComments, exportComments,
exportConfig, importConfig, importComments,
exportStats, importStats, exportConfig,
exportBackup, importBackup importConfig,
exportStats,
importStats,
exportBackup,
importBackup,
} from "../../api/admin"; } from "../../api/admin";
import { useSite } from "../../composables/useSite"; import { useSite } from "../../composables/useSite";
@@ -138,7 +188,7 @@ const importLogs = ref<string[]>([]);
const { currentSiteId } = useSite(); const { currentSiteId } = useSite();
// 当前导入模式: comments | config | stats | backup // 当前导入模式: comments | config | stats | backup
const currentImportMode = ref<string>('comments'); const currentImportMode = ref<string>("comments");
// 前缀处理相关状态 // 前缀处理相关状态
const showPrefixModal = ref(false); const showPrefixModal = ref(false);
@@ -175,26 +225,27 @@ async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
document.body.removeChild(a); document.body.removeChild(a);
showToast(t('data.messages.exportSuccess'), "success"); showToast(t("data.messages.exportSuccess"), "success");
} catch (e: any) { } catch (e: any) {
showToast(e.message || t('data.messages.exportFailed'), "error"); showToast(e.message || t("data.messages.exportFailed"), "error");
} finally { } finally {
exporting.value = false; exporting.value = false;
} }
} }
// 导出处理 // 导出处理
const handleExportComments = () => executeExport(exportComments, 'comments-export'); const handleExportComments = () => executeExport(exportComments, "comments-export");
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config'); const handleExportConfig = () => executeExport(exportConfig, "cwd-config");
const handleExportStats = () => executeExport(() => exportStats(currentSiteId.value), 'cwd-stats'); const handleExportStats = () =>
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup'); executeExport(() => exportStats(currentSiteId.value), "cwd-stats");
const handleExportBackup = () => executeExport(exportBackup, "cwd-full-backup");
// 触发文件选择 // 触发文件选择
function triggerFileInput(mode: string) { function triggerFileInput(mode: string) {
currentImportMode.value = mode; currentImportMode.value = mode;
importLogs.value = []; importLogs.value = [];
if (fileInput.value) { if (fileInput.value) {
fileInput.value.value = ''; // 重置 input fileInput.value.value = ""; // 重置 input
fileInput.value.click(); fileInput.value.click();
} }
} }
@@ -206,8 +257,10 @@ async function handleFileChange(event: Event) {
if (!file) return; if (!file) return;
importing.value = true; 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(); const reader = new FileReader();
reader.onload = async (e) => { reader.onload = async (e) => {
try { try {
@@ -216,37 +269,36 @@ async function handleFileChange(event: Event) {
try { try {
json = JSON.parse(content); json = JSON.parse(content);
} catch (parseError) { } 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) { switch (currentImportMode.value) {
case 'comments': case "comments":
await processImportComments(json); await processImportComments(json);
break; break;
case 'config': case "config":
await processImportConfig(json); await processImportConfig(json);
break; break;
case 'stats': case "stats":
await processImportStats(json); await processImportStats(json);
break; break;
case 'backup': case "backup":
await processImportBackup(json); await processImportBackup(json);
break; break;
} }
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
addLog(t('data.messages.errorWithMessage', { msg: err.message })); addLog(t("data.messages.errorWithMessage", { msg: err.message }));
showToast(err.message, "error"); showToast(err.message, "error");
importing.value = false; importing.value = false;
} }
}; };
reader.onerror = () => { reader.onerror = () => {
addLog(t('data.messages.readFileFailedLog')); addLog(t("data.messages.readFileFailedLog"));
showToast(t('data.messages.fileReadFailed'), "error"); showToast(t("data.messages.fileReadFailed"), "error");
importing.value = false; importing.value = false;
}; };
@@ -257,27 +309,27 @@ async function handleFileChange(event: Event) {
async function processImportConfig(data: any) { async function processImportConfig(data: any) {
const res = await importConfig(data); const res = await importConfig(data);
addLog(res.message); addLog(res.message);
showToast(t('data.messages.importConfigSuccess')); showToast(t("data.messages.importConfigSuccess"));
importing.value = false; importing.value = false;
} }
async function processImportStats(data: any) { async function processImportStats(data: any) {
const res = await importStats(data); const res = await importStats(data);
addLog(res.message); addLog(res.message);
showToast(t('data.messages.importStatsSuccess')); showToast(t("data.messages.importStatsSuccess"));
importing.value = false; importing.value = false;
} }
async function processImportBackup(data: any) { async function processImportBackup(data: any) {
const res = await importBackup(data); const res = await importBackup(data);
addLog(res.message); addLog(res.message);
showToast(t('data.messages.importBackupSuccess')); showToast(t("data.messages.importBackupSuccess"));
importing.value = false; importing.value = false;
} }
async function processImportComments(json: any) { async function processImportComments(json: any) {
const comments = Array.isArray(json) ? json : [json]; const comments = Array.isArray(json) ? json : [json];
addLog(t('data.messages.parsedCommentsCount', { count: comments.length })); addLog(t("data.messages.parsedCommentsCount", { count: comments.length }));
// 检查 URL 前缀 (仅针对评论导入) // 检查 URL 前缀 (仅针对评论导入)
let missingCount = 0; let missingCount = 0;
@@ -291,7 +343,7 @@ async function processImportComments(json: any) {
} }
if (missingCount > 0) { if (missingCount > 0) {
addLog(t('data.messages.detectMissingPrefix', { count: missingCount })); addLog(t("data.messages.detectMissingPrefix", { count: missingCount }));
missingPrefixCount.value = missingCount; missingPrefixCount.value = missingCount;
pendingJson.value = comments; pendingJson.value = comments;
showPrefixModal.value = true; showPrefixModal.value = true;
@@ -304,8 +356,8 @@ async function processImportComments(json: any) {
async function executeImportComments(comments: any[]) { async function executeImportComments(comments: any[]) {
try { try {
const res = await importComments(comments); const res = await importComments(comments);
addLog(t('data.messages.importCommentsDone', { message: res.message })); addLog(t("data.messages.importCommentsDone", { message: res.message }));
showToast(t('data.messages.importCommentsSuccess')); showToast(t("data.messages.importCommentsSuccess"));
} catch (err: any) { } catch (err: any) {
throw err; throw err;
} finally { } finally {
@@ -317,14 +369,14 @@ async function executeImportComments(comments: any[]) {
// 前缀确认逻辑 // 前缀确认逻辑
async function confirmPrefix() { async function confirmPrefix() {
if (!urlPrefix.value) { if (!urlPrefix.value) {
showToast(t('data.messages.prefixRequired'), "error"); showToast(t("data.messages.prefixRequired"), "error");
return; return;
} }
let prefix = urlPrefix.value.trim(); let prefix = urlPrefix.value.trim();
const comments = pendingJson.value.map((item) => { const comments = pendingJson.value.map((item) => {
const newItem = { ...item }; const newItem = { ...item };
// Twikoo // Twikoo
if (newItem.href && typeof newItem.href === "string") { if (newItem.href && typeof newItem.href === "string") {
if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) { if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) {
@@ -333,13 +385,19 @@ async function confirmPrefix() {
} }
// Artalk // Artalk
if (newItem.page_key && typeof newItem.page_key === "string") { 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); newItem.page_key = joinUrl(prefix, newItem.page_key);
} }
} }
// CWD // CWD
if (newItem.post_slug && typeof newItem.post_slug === "string") { 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); newItem.post_slug = joinUrl(prefix, newItem.post_slug);
} }
} }
@@ -347,13 +405,13 @@ async function confirmPrefix() {
}); });
showPrefixModal.value = false; showPrefixModal.value = false;
addLog(t('data.messages.prefixAdded')); addLog(t("data.messages.prefixAdded"));
await executeImportComments(comments); await executeImportComments(comments);
} }
function cancelPrefix() { function cancelPrefix() {
showPrefixModal.value = false; showPrefixModal.value = false;
addLog(t('data.messages.skipPrefix')); addLog(t("data.messages.skipPrefix"));
executeImportComments(pendingJson.value); executeImportComments(pendingJson.value);
} }

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="page"> <div class="page">
<h2 class="page-title">{{ t('settings.title') }}</h2> <h2 class="page-title">{{ t("settings.title") }}</h2>
<div <div
v-if="toastVisible" v-if="toastVisible"
class="toast" class="toast"
@@ -8,7 +8,7 @@
> >
{{ toastMessage }} {{ toastMessage }}
</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> <div v-else>
<div class="settings-tabs"> <div class="settings-tabs">
<button <button
@@ -17,7 +17,7 @@
:class="{ 'settings-tab-active': activeTab === 'comment' }" :class="{ 'settings-tab-active': activeTab === 'comment' }"
@click="activeTab = 'comment'" @click="activeTab = 'comment'"
> >
{{ t('settings.tabs.comment') }} {{ t("settings.tabs.comment") }}
</button> </button>
<button <button
type="button" type="button"
@@ -25,7 +25,7 @@
:class="{ 'settings-tab-active': activeTab === 'feature' }" :class="{ 'settings-tab-active': activeTab === 'feature' }"
@click="activeTab = 'feature'" @click="activeTab = 'feature'"
> >
{{ t('settings.tabs.feature') }} {{ t("settings.tabs.feature") }}
</button> </button>
<button <button
type="button" type="button"
@@ -33,7 +33,7 @@
:class="{ 'settings-tab-active': activeTab === 'emailNotify' }" :class="{ 'settings-tab-active': activeTab === 'emailNotify' }"
@click="activeTab = 'emailNotify'" @click="activeTab = 'emailNotify'"
> >
{{ t('settings.tabs.emailNotify') }} {{ t("settings.tabs.emailNotify") }}
</button> </button>
<button <button
type="button" type="button"
@@ -41,7 +41,7 @@
:class="{ 'settings-tab-active': activeTab === 'telegramNotify' }" :class="{ 'settings-tab-active': activeTab === 'telegramNotify' }"
@click="activeTab = 'telegramNotify'" @click="activeTab = 'telegramNotify'"
> >
{{ t('settings.tabs.telegramNotify') }} {{ t("settings.tabs.telegramNotify") }}
</button> </button>
<button <button
type="button" type="button"
@@ -49,7 +49,7 @@
:class="{ 'settings-tab-active': activeTab === 'display' }" :class="{ 'settings-tab-active': activeTab === 'display' }"
@click="activeTab = 'display'" @click="activeTab = 'display'"
> >
{{ t('settings.tabs.display') }} {{ t("settings.tabs.display") }}
</button> </button>
</div> </div>
<transition name="tab-fade" mode="out-in"> <transition name="tab-fade" mode="out-in">
@@ -57,33 +57,37 @@
<template v-if="activeTab === 'comment'"> <template v-if="activeTab === 'comment'">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">{{ t('settings.comment.title') }}</div> <div class="card-title">{{ t("settings.comment.title") }}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-item"> <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" /> <input v-model="commentAdminEmail" class="form-input" type="email" />
</div> </div>
<div class="form-item"> <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" /> <input v-model="commentAdminBadge" class="form-input" type="text" />
</div> </div>
<div class="form-item"> <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"> <label class="switch">
<input v-model="commentAdminEnabled" type="checkbox" /> <input v-model="commentAdminEnabled" type="checkbox" />
<span class="slider" /> <span class="slider" />
</label> </label>
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label" <label class="form-label">{{
>{{ t('settings.comment.avatarPrefix') }}</label t("settings.comment.avatarPrefix")
> }}</label>
<input v-model="avatarPrefix" class="form-input" type="text" /> <input v-model="avatarPrefix" class="form-input" type="text" />
</div> </div>
<h3 class="card-title">{{ t('settings.comment.securityTitle') }}</h3> <h3 class="card-title">{{ t("settings.comment.securityTitle") }}</h3>
<div class="form-item"> <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"> <label class="switch">
<input v-model="requireReview" type="checkbox" /> <input v-model="requireReview" type="checkbox" />
<span class="slider" /> <span class="slider" />
@@ -91,7 +95,7 @@
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label"> <label class="form-label">
{{ t('settings.comment.adminKey') }} {{ t("settings.comment.adminKey") }}
</label> </label>
<input <input
@@ -103,20 +107,20 @@
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label"> <label class="form-label">
{{ t('settings.comment.allowedDomains') }} {{ t("settings.comment.allowedDomains") }}
</label> </label>
<TagInput v-model="allowedDomainTags" /> <TagInput v-model="allowedDomainTags" />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label"> <label class="form-label">
{{ t('settings.comment.blockedIps') }} {{ t("settings.comment.blockedIps") }}
</label> </label>
<TagInput v-model="blockedIpTags" /> <TagInput v-model="blockedIpTags" />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label" <label class="form-label">{{
>{{ t('settings.comment.blockedEmails') }}</label t("settings.comment.blockedEmails")
> }}</label>
<TagInput v-model="blockedEmailTags" /> <TagInput v-model="blockedEmailTags" />
</div> </div>
@@ -126,8 +130,8 @@
:disabled="savingComment" :disabled="savingComment"
@click="saveComment" @click="saveComment"
> >
<span v-if="savingComment">{{ t('settings.comment.saving') }}</span> <span v-if="savingComment">{{ t("settings.comment.saving") }}</span>
<span v-else>{{ t('settings.comment.save') }}</span> <span v-else>{{ t("settings.comment.save") }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -136,47 +140,57 @@
<template v-else-if="activeTab === 'feature'"> <template v-else-if="activeTab === 'feature'">
<div class="card feature"> <div class="card feature">
<div class="card-header"> <div class="card-header">
<div class="card-title">{{ t('settings.feature.title') }}</div> <div class="card-title">{{ t("settings.feature.title") }}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-item bg"> <div class="form-item bg">
<div class="form-item-flex"> <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"> <label class="switch">
<input v-model="enableArticleLike" type="checkbox" /> <input v-model="enableArticleLike" type="checkbox" />
<span class="slider" /> <span class="slider" />
</label> </label>
</div> </div>
<div class="form-hint"> <div class="form-hint">
{{ t('settings.feature.articleLikeHint') }} {{ t("settings.feature.articleLikeHint") }}
</div> </div>
</div> </div>
<div class="form-item bg"> <div class="form-item bg">
<div class="form-item-flex"> <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"> <label class="switch">
<input v-model="enableCommentLike" type="checkbox" /> <input v-model="enableCommentLike" type="checkbox" />
<span class="slider" /> <span class="slider" />
</label> </label>
</div> </div>
<div class="form-hint"> <div class="form-hint">
{{ t('settings.feature.commentLikeHint') }} {{ t("settings.feature.commentLikeHint") }}
</div> </div>
</div> </div>
<div class="form-item bg"> <div class="form-item bg">
<div class="form-item-flex"> <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"> <label class="switch">
<input v-model="enableImageLightbox" type="checkbox" /> <input v-model="enableImageLightbox" type="checkbox" />
<span class="slider" /> <span class="slider" />
</label> </label>
</div> </div>
<div class="form-hint">{{ t('settings.feature.imageLightboxHint') }}</div> <div class="form-hint">
{{ t("settings.feature.imageLightboxHint") }}
</div>
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.feature.placeholder') }}</label> <label class="form-label">{{
t("settings.feature.placeholder")
}}</label>
<textarea <textarea
v-model="commentPlaceholder" v-model="commentPlaceholder"
class="form-input" class="form-input"
@@ -185,20 +199,26 @@
:placeholder="t('settings.feature.placeholderHint')" :placeholder="t('settings.feature.placeholderHint')"
></textarea> ></textarea>
<div class="form-hint"> <div class="form-hint">
{{ t('settings.feature.placeholderHint') }} {{ t("settings.feature.placeholderHint") }}
</div> </div>
</div> </div>
<div class="form-item"> <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"> <select v-model="widgetLanguage" class="form-input">
<option value="auto">Auto (Browser Default)</option> <option value="auto">Auto (Browser Default)</option>
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value"> <option
{{ lang.label }} v-for="lang in languageOptions"
:key="lang.value"
:value="lang.value"
>
{{ lang.label }}
</option> </option>
</select> </select>
<div class="form-hint"> <div class="form-hint">
{{ t('settings.feature.widgetLanguageHint') }} {{ t("settings.feature.widgetLanguageHint") }}
</div> </div>
</div> </div>
@@ -208,8 +228,8 @@
:disabled="savingFeature" :disabled="savingFeature"
@click="saveFeature" @click="saveFeature"
> >
<span v-if="savingFeature">{{ t('settings.feature.saving') }}</span> <span v-if="savingFeature">{{ t("settings.feature.saving") }}</span>
<span v-else>{{ t('settings.feature.save') }}</span> <span v-else>{{ t("settings.feature.save") }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -218,28 +238,38 @@
<template v-else-if="activeTab === 'display'"> <template v-else-if="activeTab === 'display'">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">{{ t('settings.display.title') }}</div> <div class="card-title">{{ t("settings.display.title") }}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.display.layoutTitle') }}</label> <label class="form-label">{{
t("settings.display.layoutTitle")
}}</label>
<input <input
v-model="adminLayoutTitle" v-model="adminLayoutTitle"
class="form-input" class="form-input"
type="text" type="text"
placeholder="CWD 评论系统" placeholder="CWD 评论系统"
/> />
<div class="form-hint">{{ t('settings.display.layoutTitleHint') }}</div> <div class="form-hint">{{ t("settings.display.layoutTitleHint") }}</div>
</div> </div>
<div class="form-item"> <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"> <select v-model="adminLanguage" class="form-input">
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value"> <option
{{ lang.label }} v-for="lang in languageOptions"
:key="lang.value"
:value="lang.value"
>
{{ lang.label }}
</option> </option>
</select> </select>
<div class="form-hint">{{ t('settings.display.adminLanguageHint') }}</div> <div class="form-hint">
{{ t("settings.display.adminLanguageHint") }}
</div>
</div> </div>
<div class="card-actions"> <div class="card-actions">
@@ -248,8 +278,8 @@
:disabled="savingDisplay" :disabled="savingDisplay"
@click="saveDisplay" @click="saveDisplay"
> >
<span v-if="savingDisplay">{{ t('settings.display.saving') }}</span> <span v-if="savingDisplay">{{ t("settings.display.saving") }}</span>
<span v-else>{{ t('settings.display.save') }}</span> <span v-else>{{ t("settings.display.save") }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -258,11 +288,11 @@
<template v-else-if="activeTab === 'emailNotify'"> <template v-else-if="activeTab === 'emailNotify'">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">{{ t('settings.emailNotify.title') }}</div> <div class="card-title">{{ t("settings.emailNotify.title") }}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-item"> <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"> <label class="switch">
<input v-model="emailGlobalEnabled" type="checkbox" /> <input v-model="emailGlobalEnabled" type="checkbox" />
<span class="slider" /> <span class="slider" />
@@ -270,10 +300,12 @@
</div> </div>
<div class="divider"></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"> <div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.provider') }}</label> <label class="form-label">{{
t("settings.emailNotify.provider")
}}</label>
<select <select
v-model="smtpProvider" v-model="smtpProvider"
class="form-input" class="form-input"
@@ -287,7 +319,7 @@
<div v-if="smtpProvider === 'custom'"> <div v-if="smtpProvider === 'custom'">
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.host') }}</label> <label class="form-label">{{ t("settings.emailNotify.host") }}</label>
<input <input
v-model="smtpHost" v-model="smtpHost"
class="form-input" class="form-input"
@@ -295,7 +327,7 @@
/> />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.port') }}</label> <label class="form-label">{{ t("settings.emailNotify.port") }}</label>
<input <input
v-model="smtpPort" v-model="smtpPort"
class="form-input" class="form-input"
@@ -304,7 +336,9 @@
/> />
</div> </div>
<div class="form-item"> <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"> <label class="switch">
<input v-model="smtpSecure" type="checkbox" /> <input v-model="smtpSecure" type="checkbox" />
<span class="slider" /> <span class="slider" />
@@ -313,7 +347,7 @@
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.user') }}</label> <label class="form-label">{{ t("settings.emailNotify.user") }}</label>
<input <input
v-model="smtpUser" v-model="smtpUser"
class="form-input" class="form-input"
@@ -321,24 +355,36 @@
/> />
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.emailNotify.pass') }}</label> <label class="form-label">{{ t("settings.emailNotify.pass") }}</label>
<input <input
v-model="smtpPass" v-model="smtpPass"
class="form-input" class="form-input"
type="text" type="text"
placeholder="QQ邮箱请使用授权码" placeholder="QQ邮箱请使用授权码"
/> />
<div v-if="smtpProvider === 'qq'" class="form-hint" v-html="t('settings.emailNotify.qqHint')"></div> <div
<div v-else-if="smtpProvider === '163'" class="form-hint" v-html="t('settings.emailNotify.163Hint')"></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>
<div class="divider"></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"> <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"> <div class="form-hint">
{{ t('settings.emailNotify.adminTemplateHint') }} {{ t("settings.emailNotify.adminTemplateHint") }}
</div> </div>
<textarea <textarea
v-model="templateAdmin" v-model="templateAdmin"
@@ -349,9 +395,11 @@
</div> </div>
<div class="form-item"> <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"> <div class="form-hint">
{{ t('settings.emailNotify.replyTemplateHint') }} {{ t("settings.emailNotify.replyTemplateHint") }}
</div> </div>
<textarea <textarea
v-model="templateReply" v-model="templateReply"
@@ -373,19 +421,21 @@
:disabled="testingEmail" :disabled="testingEmail"
@click="testEmail" @click="testEmail"
> >
<span v-if="testingEmail">{{ t('settings.emailNotify.testingBtn') }}</span> <span v-if="testingEmail">{{
<span v-else>{{ t('settings.emailNotify.testBtn') }}</span> t("settings.emailNotify.testingBtn")
}}</span>
<span v-else>{{ t("settings.emailNotify.testBtn") }}</span>
</button> </button>
<button <button
class="card-button secondary" class="card-button secondary"
style="margin-left: auto" style="margin-left: auto"
@click="resetTemplatesToDefault" @click="resetTemplatesToDefault"
> >
{{ t('settings.emailNotify.resetBtn') }} {{ t("settings.emailNotify.resetBtn") }}
</button> </button>
<button class="card-button" :disabled="savingEmail" @click="saveEmail"> <button class="card-button" :disabled="savingEmail" @click="saveEmail">
<span v-if="savingEmail">{{ t('settings.emailNotify.saving') }}</span> <span v-if="savingEmail">{{ t("settings.emailNotify.saving") }}</span>
<span v-else>{{ t('settings.emailNotify.save') }}</span> <span v-else>{{ t("settings.emailNotify.save") }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -394,18 +444,22 @@
<template v-else-if="activeTab === 'telegramNotify'"> <template v-else-if="activeTab === 'telegramNotify'">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">{{ t('settings.telegramNotify.title') }}</div> <div class="card-title">{{ t("settings.telegramNotify.title") }}</div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="form-item"> <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"> <label class="switch">
<input v-model="telegramNotifyEnabled" type="checkbox" /> <input v-model="telegramNotifyEnabled" type="checkbox" />
<span class="slider" /> <span class="slider" />
</label> </label>
</div> </div>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.telegramNotify.botToken') }}</label> <label class="form-label">{{
t("settings.telegramNotify.botToken")
}}</label>
<input <input
v-model="telegramBotToken" v-model="telegramBotToken"
class="form-input" class="form-input"
@@ -413,17 +467,25 @@
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
autocomplete="new-password" 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>
<div class="form-item"> <div class="form-item">
<label class="form-label">{{ t('settings.telegramNotify.chatId') }}</label> <label class="form-label">{{
t("settings.telegramNotify.chatId")
}}</label>
<input <input
v-model="telegramChatId" v-model="telegramChatId"
class="form-input" class="form-input"
type="text" type="text"
placeholder="123456789" 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>
<div class="card-actions" style="justify-content: space-between"> <div class="card-actions" style="justify-content: space-between">
@@ -432,8 +494,10 @@
:disabled="settingUpWebhook" :disabled="settingUpWebhook"
@click="doSetupWebhook" @click="doSetupWebhook"
> >
<span v-if="settingUpWebhook">{{ t('settings.telegramNotify.webhookSetting') }}</span> <span v-if="settingUpWebhook">{{
<span v-else>{{ t('settings.telegramNotify.webhookBtn') }}</span> t("settings.telegramNotify.webhookSetting")
}}</span>
<span v-else>{{ t("settings.telegramNotify.webhookBtn") }}</span>
</button> </button>
<button <button
class="card-button secondary" class="card-button secondary"
@@ -441,16 +505,20 @@
@click="testTelegram" @click="testTelegram"
style="margin-right: auto" style="margin-right: auto"
> >
<span v-if="testingTelegram">{{ t('settings.telegramNotify.testingBtn') }}</span> <span v-if="testingTelegram">{{
<span v-else>{{ t('settings.telegramNotify.testBtn') }}</span> t("settings.telegramNotify.testingBtn")
}}</span>
<span v-else>{{ t("settings.telegramNotify.testBtn") }}</span>
</button> </button>
<button <button
class="card-button" class="card-button"
:disabled="savingTelegram" :disabled="savingTelegram"
@click="saveTelegram" @click="saveTelegram"
> >
<span v-if="savingTelegram">{{ t('settings.telegramNotify.saving') }}</span> <span v-if="savingTelegram">{{
<span v-else>{{ t('settings.telegramNotify.save') }}</span> t("settings.telegramNotify.saving")
}}</span>
<span v-else>{{ t("settings.telegramNotify.save") }}</span>
</button> </button>
</div> </div>
</div> </div>
@@ -463,7 +531,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { import {
@@ -582,21 +650,21 @@ const settingUpWebhook = ref(false);
const testingTelegram = ref(false); const testingTelegram = ref(false);
const languageOptions = [ const languageOptions = [
{ value: 'en-US', label: 'English' }, { value: "en-US", label: "English" },
{ value: 'zh-CN', label: '简体中文' }, { value: "zh-CN", label: "简体中文" },
{ value: 'zh-TW', label: '繁體中文' }, { value: "zh-TW", label: "繁體中文" },
{ value: 'es', label: 'Español' }, { value: "es", label: "Español" },
{ value: 'pt', label: 'Português' }, { value: "pt", label: "Português" },
{ value: 'fr', label: 'Français' }, { value: "fr", label: "Français" },
{ value: 'de', label: 'Deutsch' }, { value: "de", label: "Deutsch" },
{ value: 'ja', label: '日本語' }, { value: "ja", label: "日本語" },
{ value: 'ko', label: '한국어' }, { value: "ko", label: "한국어" },
{ value: 'ru', label: 'Русский' }, { value: "ru", label: "Русский" },
{ value: 'it', label: 'Italiano' }, { value: "it", label: "Italiano" },
{ value: 'nl', label: 'Nederlands' }, { value: "nl", label: "Nederlands" },
{ value: 'ar', label: 'العربية' }, { value: "ar", label: "العربية" },
{ value: 'hi', label: 'हिन्दी' }, { value: "hi", label: "हिन्दी" },
{ value: 'id', label: 'Bahasa Indonesia' }, { value: "id", label: "Bahasa Indonesia" },
]; ];
const { t, locale } = useI18n(); const { t, locale } = useI18n();
@@ -606,12 +674,7 @@ const widgetLanguage = ref("auto");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
type TabKey = type TabKey = "comment" | "feature" | "display" | "emailNotify" | "telegramNotify";
| "comment"
| "feature"
| "display"
| "emailNotify"
| "telegramNotify";
const validTabs: TabKey[] = [ const validTabs: TabKey[] = [
"comment", "comment",
"feature", "feature",
@@ -621,9 +684,7 @@ const validTabs: TabKey[] = [
]; ];
const activeTab = ref<TabKey>( const activeTab = ref<TabKey>(
validTabs.includes(route.query.tab as TabKey) validTabs.includes(route.query.tab as TabKey) ? (route.query.tab as TabKey) : "comment"
? (route.query.tab as TabKey)
: "comment",
); );
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
@@ -636,7 +697,7 @@ watch(
if (newTab && validTabs.includes(newTab as TabKey)) { if (newTab && validTabs.includes(newTab as TabKey)) {
activeTab.value = newTab as TabKey; activeTab.value = newTab as TabKey;
} }
}, }
); );
const savingEmail = ref(false); const savingEmail = ref(false);
@@ -667,9 +728,15 @@ function loadCardsExpanded() {
site: parsed.site ?? false, site: parsed.site ?? false,
}; };
} }
} catch { } catch {}
} return {
return { comment: true, display: false, feature: false, email: false, telegram: false, site: false }; comment: true,
display: false,
feature: false,
email: false,
telegram: false,
site: false,
};
} }
const cardsExpanded = ref(loadCardsExpanded()); const cardsExpanded = ref(loadCardsExpanded());
@@ -725,7 +792,13 @@ function resetTemplatesToDefault() {
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
const [commentRes, emailNotifyRes, featureRes, telegramRes, displayRes] = await Promise.all([ const [
commentRes,
emailNotifyRes,
featureRes,
telegramRes,
displayRes,
] = await Promise.all([
fetchCommentSettings(), fetchCommentSettings(),
fetchEmailNotifySettings(), fetchEmailNotifySettings(),
fetchFeatureSettings(), fetchFeatureSettings(),
@@ -758,11 +831,11 @@ async function load() {
commentPlaceholder.value = featureRes.commentPlaceholder || ""; commentPlaceholder.value = featureRes.commentPlaceholder || "";
adminLanguage.value = featureRes.adminLanguage || "zh-CN"; adminLanguage.value = featureRes.adminLanguage || "zh-CN";
widgetLanguage.value = featureRes.widgetLanguage || "auto"; widgetLanguage.value = featureRes.widgetLanguage || "auto";
// Sync locale // Sync locale
if (featureRes.adminLanguage) { if (featureRes.adminLanguage) {
locale.value = featureRes.adminLanguage; locale.value = featureRes.adminLanguage;
localStorage.setItem('admin_language', featureRes.adminLanguage); localStorage.setItem("admin_language", featureRes.adminLanguage);
} }
telegramBotToken.value = telegramRes.botToken || ""; telegramBotToken.value = telegramRes.botToken || "";
@@ -834,7 +907,7 @@ async function testEmail() {
message.value = "请先在上方“评论显示配置”中设置管理员邮箱"; message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
messageType.value = "error"; messageType.value = "error";
return; return;
} }
if (!smtpUser.value || !smtpPass.value) { if (!smtpUser.value || !smtpPass.value) {
message.value = "请先填写 SMTP 账号和密码"; message.value = "请先填写 SMTP 账号和密码";
messageType.value = "error"; 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) { } catch (e: any) {
showToast(e.message || t('common.saveFailed'), "error"); showToast(e.message || t("common.saveFailed"), "error");
} finally { } finally {
savingComment.value = false; 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) { } catch (e: any) {
showToast(e.message || t('common.saveFailed'), "error"); showToast(e.message || t("common.saveFailed"), "error");
} finally { } finally {
savingFeature.value = false; savingFeature.value = false;
} }
@@ -933,19 +1006,19 @@ async function saveDisplay() {
const res = await saveAdminDisplaySettings({ const res = await saveAdminDisplaySettings({
layoutTitle: adminLayoutTitle.value, layoutTitle: adminLayoutTitle.value,
}); });
// Also save admin language to feature settings as it's a global preference // Also save admin language to feature settings as it's a global preference
await saveFeatureSettings({ await saveFeatureSettings({
adminLanguage: adminLanguage.value adminLanguage: adminLanguage.value,
}); });
if (updateSiteTitle) { if (updateSiteTitle) {
updateSiteTitle(adminLayoutTitle.value); updateSiteTitle(adminLayoutTitle.value);
} }
// Update local locale // Update local locale
locale.value = adminLanguage.value; locale.value = adminLanguage.value;
localStorage.setItem('admin_language', adminLanguage.value); localStorage.setItem("admin_language", adminLanguage.value);
showToast(res.message || "保存成功", "success"); showToast(res.message || "保存成功", "success");
} catch (e: any) { } catch (e: any) {

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="page"> <div class="page">
<div style="display: flex; align-items: center; gap: 20px"> <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>
<div <div
v-if="toastVisible" v-if="toastVisible"
@@ -13,28 +13,28 @@
<div class="card"> <div class="card">
<div class="card-title-row"> <div class="card-title-row">
<h3 class="card-title">{{ t('stats.overview') }}</h3> <h3 class="card-title">{{ t("stats.overview") }}</h3>
</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 v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div v-else> <div v-else>
<div class="stats-grid"> <div class="stats-grid">
<div class="stats-item"> <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 class="stats-value">{{ statsSummary.total }}</div>
</div> </div>
<div class="stats-item"> <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"> <div class="stats-value stats-value-approved">
{{ statsSummary.approved }} {{ statsSummary.approved }}
</div> </div>
</div> </div>
<div class="stats-item"> <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 class="stats-value stats-value-pending">{{ statsSummary.pending }}</div>
</div> </div>
<div class="stats-item"> <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"> <div class="stats-value stats-value-rejected">
{{ statsSummary.rejected }} {{ statsSummary.rejected }}
</div> </div>
@@ -45,7 +45,7 @@
<div class="card"> <div class="card">
<div class="card-title-row"> <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"> <div class="chart-tabs">
<button <button
class="chart-tab" class="chart-tab"
@@ -53,7 +53,7 @@
type="button" type="button"
@click="changeChartRange('7')" @click="changeChartRange('7')"
> >
{{ t('stats.last7Days') }} {{ t("stats.last7Days") }}
</button> </button>
<button <button
class="chart-tab" class="chart-tab"
@@ -61,11 +61,11 @@
type="button" type="button"
@click="changeChartRange('30')" @click="changeChartRange('30')"
> >
{{ t('stats.last30Days') }} {{ t("stats.last30Days") }}
</button> </button>
</div> </div>
</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 v-else-if="statsError" class="page-error">{{ statsError }}</div>
<div class="chart-wrapper"> <div class="chart-wrapper">
<div ref="chartEl" class="chart"></div> <div ref="chartEl" class="chart"></div>
@@ -74,26 +74,26 @@
<div class="card"> <div class="card">
<div class="card-title-row"> <div class="card-title-row">
<h3 class="card-title">{{ t('stats.bySite') }}</h3> <h3 class="card-title">{{ t("stats.bySite") }}</h3>
</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 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 v-else class="domain-stats-layout">
<div class="domain-table-wrapper"> <div class="domain-table-wrapper">
<div class="domain-table"> <div class="domain-table">
<div class="domain-table-header"> <div class="domain-table-header">
<div class="domain-cell domain-cell-domain">{{ t('stats.table.domain') }}</div> <div class="domain-cell domain-cell-domain">
<div class="domain-cell">{{ t('stats.table.total') }}</div> {{ t("stats.table.domain") }}
<div class="domain-cell">{{ t('stats.table.approved') }}</div> </div>
<div class="domain-cell">{{ t('stats.table.pending') }}</div> <div class="domain-cell">{{ t("stats.table.total") }}</div>
<div class="domain-cell">{{ t('stats.table.rejected') }}</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>
<div <div v-for="item in domainStats" :key="item.domain" class="domain-table-row">
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 domain-cell-domain">{{ item.domain }}</div>
<div class="domain-cell">{{ item.total }}</div> <div class="domain-cell">{{ item.total }}</div>
<div class="domain-cell">{{ item.approved }}</div> <div class="domain-cell">{{ item.approved }}</div>
@@ -111,8 +111,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject } from "vue"; import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
import type { Ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import * as echarts from "echarts"; import * as echarts from "echarts";
import { fetchCommentStats } from "../../api/admin"; import { fetchCommentStats } from "../../api/admin";
@@ -161,8 +160,7 @@ function loadChartRangeFromStorage() {
if (value === "7" || value === "30") { if (value === "7" || value === "30") {
chartRange.value = value; chartRange.value = value;
} }
} catch { } catch {}
}
} }
function saveChartRangeToStorage(value: "7" | "30") { function saveChartRangeToStorage(value: "7" | "30") {
@@ -171,8 +169,7 @@ function saveChartRangeToStorage(value: "7" | "30") {
} }
try { try {
window.localStorage.setItem(chartRangeStorageKey, value); window.localStorage.setItem(chartRangeStorageKey, value);
} catch { } catch {}
}
} }
function showToast(msg: string, type: "success" | "error" = "success") { function showToast(msg: string, type: "success" | "error" = "success") {
@@ -220,8 +217,7 @@ function renderChart() {
chartInstance = echarts.init(el); chartInstance = echarts.init(el);
} }
const source = last7Days.value; const source = last7Days.value;
const seriesData = const seriesData = chartRange.value === "7" ? source.slice(-7) : source;
chartRange.value === "7" ? source.slice(-7) : source;
const dates = seriesData.map((item) => item.date.slice(5)); const dates = seriesData.map((item) => item.date.slice(5));
const values = seriesData.map((item) => item.total); const values = seriesData.map((item) => item.total);
const option: echarts.EChartsOption = { const option: echarts.EChartsOption = {