fix(admin): 在功能设置类型和保存接口中增加 adminLanguage 和 widgetLanguage 字段
This commit is contained in:
@@ -137,6 +137,8 @@ export type FeatureSettingsResponse = {
|
||||
enableImageLightbox: boolean;
|
||||
commentPlaceholder?: string;
|
||||
visibleDomains?: string[];
|
||||
adminLanguage?: string;
|
||||
widgetLanguage?: string;
|
||||
};
|
||||
|
||||
export type AdminDisplaySettingsResponse = {
|
||||
@@ -351,7 +353,7 @@ export function fetchFeatureSettings(): Promise<FeatureSettingsResponse> {
|
||||
return get<FeatureSettingsResponse>('/admin/settings/features');
|
||||
}
|
||||
|
||||
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; enableImageLightbox?: boolean; commentPlaceholder?: string; visibleDomains?: string[] }): Promise<{ message: string }> {
|
||||
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; enableImageLightbox?: boolean; commentPlaceholder?: string; visibleDomains?: string[]; adminLanguage?: string; widgetLanguage?: string }): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/features', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div style="display: flex; align-items: center; gap: 20px">
|
||||
<h2 class="page-title">{{ t('analytics.title') }}</h2>
|
||||
<h2 class="page-title">{{ t("analytics.title") }}</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
@@ -13,63 +13,73 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('analytics.overview') }}</h3>
|
||||
<h3 class="card-title">{{ t("analytics.overview") }}</h3>
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="loading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="stats-grid">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('analytics.totalPv') }}</div>
|
||||
<div class="stats-label">{{ t("analytics.totalPv") }}</div>
|
||||
<div class="stats-value"><CountTo :end-val="overview.totalPv" /></div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('analytics.todayPv') }}</div>
|
||||
<div class="stats-label">{{ t("analytics.todayPv") }}</div>
|
||||
<div class="stats-value">
|
||||
<CountTo :end-val="overview.todayPv" />
|
||||
<span
|
||||
v-if="overview.yesterdayPv !== undefined"
|
||||
class="trend"
|
||||
:class="percentageChange >= 0 ? 'up' : 'down'"
|
||||
:title="`对比昨日 ${percentageChange >= 0 ? '增加' : '减少'} ${Math.abs(percentageChange).toFixed(1)}%`"
|
||||
:title="`对比昨日 ${percentageChange >= 0 ? '增加' : '减少'} ${Math.abs(
|
||||
percentageChange
|
||||
).toFixed(1)}%`"
|
||||
>
|
||||
<span class="trend-arrow">{{ percentageChange >= 0 ? '↑' : '↓' }}</span>
|
||||
<span class="trend-arrow">{{ percentageChange >= 0 ? "↑" : "↓" }}</span>
|
||||
{{ Math.abs(percentageChange).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('analytics.weekPv') }}</div>
|
||||
<div class="stats-label">{{ t("analytics.weekPv") }}</div>
|
||||
<div class="stats-value">
|
||||
<CountTo :end-val="overview.weekPv" />
|
||||
<span
|
||||
v-if="overview.lastWeekPv !== undefined"
|
||||
class="trend"
|
||||
:class="weekPercentageChange >= 0 ? 'up' : 'down'"
|
||||
:title="`对比上周 ${weekPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(weekPercentageChange).toFixed(1)}%`"
|
||||
:title="`对比上周 ${
|
||||
weekPercentageChange >= 0 ? '增加' : '减少'
|
||||
} ${Math.abs(weekPercentageChange).toFixed(1)}%`"
|
||||
>
|
||||
<span class="trend-arrow">{{ weekPercentageChange >= 0 ? '↑' : '↓' }}</span>
|
||||
<span class="trend-arrow">{{
|
||||
weekPercentageChange >= 0 ? "↑" : "↓"
|
||||
}}</span>
|
||||
{{ Math.abs(weekPercentageChange).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('analytics.monthPv') }}</div>
|
||||
<div class="stats-label">{{ t("analytics.monthPv") }}</div>
|
||||
<div class="stats-value">
|
||||
<CountTo :end-val="overview.monthPv" />
|
||||
<span
|
||||
v-if="overview.lastMonthPv !== undefined"
|
||||
class="trend"
|
||||
:class="monthPercentageChange >= 0 ? 'up' : 'down'"
|
||||
:title="`对比上月 ${monthPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(monthPercentageChange).toFixed(1)}%`"
|
||||
:title="`对比上月 ${
|
||||
monthPercentageChange >= 0 ? '增加' : '减少'
|
||||
} ${Math.abs(monthPercentageChange).toFixed(1)}%`"
|
||||
>
|
||||
<span class="trend-arrow">{{ monthPercentageChange >= 0 ? '↑' : '↓' }}</span>
|
||||
<span class="trend-arrow">{{
|
||||
monthPercentageChange >= 0 ? "↑" : "↓"
|
||||
}}</span>
|
||||
{{ Math.abs(monthPercentageChange).toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('analytics.totalPages') }}</div>
|
||||
<div class="stats-label">{{ t("analytics.totalPages") }}</div>
|
||||
<div class="stats-value"><CountTo :end-val="overview.totalPages" /></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -78,7 +88,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('analytics.trend') }}</h3>
|
||||
<h3 class="card-title">{{ t("analytics.trend") }}</h3>
|
||||
<div class="visit-tabs">
|
||||
<button
|
||||
class="visit-tab"
|
||||
@@ -86,7 +96,7 @@
|
||||
type="button"
|
||||
@click="changeChartRange('7')"
|
||||
>
|
||||
{{ t('analytics.last7Days') }}
|
||||
{{ t("analytics.last7Days") }}
|
||||
</button>
|
||||
<button
|
||||
class="visit-tab"
|
||||
@@ -94,11 +104,11 @@
|
||||
type="button"
|
||||
@click="changeChartRange('30')"
|
||||
>
|
||||
{{ t('analytics.last30Days') }}
|
||||
{{ t("analytics.last30Days") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="loading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div class="chart-wrapper">
|
||||
<div ref="chartEl" class="chart"></div>
|
||||
@@ -107,7 +117,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('analytics.pageDetail') }}</h3>
|
||||
<h3 class="card-title">{{ t("analytics.pageDetail") }}</h3>
|
||||
<div class="visit-tabs">
|
||||
<button
|
||||
class="visit-tab"
|
||||
@@ -115,7 +125,7 @@
|
||||
type="button"
|
||||
@click="changeVisitTab('pv')"
|
||||
>
|
||||
{{ t('analytics.sort.pv') }}
|
||||
{{ t("analytics.sort.pv") }}
|
||||
</button>
|
||||
<button
|
||||
class="visit-tab"
|
||||
@@ -123,22 +133,28 @@
|
||||
type="button"
|
||||
@click="changeVisitTab('latest')"
|
||||
>
|
||||
{{ t('analytics.sort.latest') }}
|
||||
{{ t("analytics.sort.latest") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="listLoading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="listLoading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div v-else-if="items.length === 0" class="page-hint">{{ t('analytics.noData') }}</div>
|
||||
<div v-else-if="items.length === 0" class="page-hint">
|
||||
{{ t("analytics.noData") }}
|
||||
</div>
|
||||
<div v-else class="domain-table-wrapper">
|
||||
<div class="domain-table">
|
||||
<div class="domain-table-header">
|
||||
<div class="domain-cell domain-cell-title">{{ t('analytics.table.title') }}</div>
|
||||
<div class="domain-cell domain-cell-pv">{{ t('analytics.table.pv') }}</div>
|
||||
<div class="domain-cell domain-cell-time">{{ t('analytics.table.time') }}</div>
|
||||
<div class="domain-cell domain-cell-url">{{ t('analytics.table.url') }}</div>
|
||||
<div class="domain-cell domain-cell-title">
|
||||
{{ t("analytics.table.title") }}
|
||||
</div>
|
||||
<div class="domain-cell domain-cell-pv">{{ t("analytics.table.pv") }}</div>
|
||||
<div class="domain-cell domain-cell-time">
|
||||
{{ t("analytics.table.time") }}
|
||||
</div>
|
||||
<div class="domain-cell domain-cell-url">{{ t("analytics.table.url") }}</div>
|
||||
</div>
|
||||
<div v-for="item in items" :key="item.postSlug" class="domain-table-row">
|
||||
<div v-for="(item, index) in items" :key="index" class="domain-table-row">
|
||||
<div class="domain-cell domain-cell-title">
|
||||
{{ item.postTitle || item.postSlug }}
|
||||
</div>
|
||||
@@ -166,22 +182,30 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('analytics.likeRank') }}</h3>
|
||||
<h3 class="card-title">{{ t("analytics.likeRank") }}</h3>
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="loading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div v-else-if="likeStatsItems.length === 0" class="page-hint">{{ t('analytics.noLikeData') }}</div>
|
||||
<div v-else-if="likeStatsItems.length === 0" class="page-hint">
|
||||
{{ t("analytics.noLikeData") }}
|
||||
</div>
|
||||
<div v-else class="domain-table-wrapper">
|
||||
<div class="domain-table">
|
||||
<div class="domain-table-header">
|
||||
<div class="domain-cell domain-cell-rank">{{ t('analytics.table.rank') }}</div>
|
||||
<div class="domain-cell domain-cell-title">{{ t('analytics.table.title') }}</div>
|
||||
<div class="domain-cell domain-cell-like">{{ t('analytics.table.like') }}</div>
|
||||
<div class="domain-cell domain-cell-url">{{ t('analytics.table.url') }}</div>
|
||||
<div class="domain-cell domain-cell-rank">
|
||||
{{ t("analytics.table.rank") }}
|
||||
</div>
|
||||
<div class="domain-cell domain-cell-title">
|
||||
{{ t("analytics.table.title") }}
|
||||
</div>
|
||||
<div class="domain-cell domain-cell-like">
|
||||
{{ t("analytics.table.like") }}
|
||||
</div>
|
||||
<div class="domain-cell domain-cell-url">{{ t("analytics.table.url") }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, index) in likeStatsItems"
|
||||
:key="item.pageSlug"
|
||||
:key="index"
|
||||
class="domain-table-row"
|
||||
>
|
||||
<div class="domain-cell domain-cell-rank">
|
||||
@@ -212,8 +236,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject, computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as echarts from "echarts";
|
||||
import CountTo from "../../components/CountTo.vue";
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
<template>
|
||||
<div v-if="visible" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 class="modal-title">{{ t('comments.editModal.title') }}</h3>
|
||||
<h3 class="modal-title">{{ t("comments.editModal.title") }}</h3>
|
||||
<div v-if="form" class="modal-body">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.name') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.name") }}</label>
|
||||
<input v-model="form.name" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.email') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.email") }}</label>
|
||||
<input v-model="form.email" class="form-input" type="email" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.url') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.url") }}</label>
|
||||
<input v-model="form.url" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.postSlug') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.postSlug") }}</label>
|
||||
<input v-model="form.postSlug" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.postUrl') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.postUrl") }}</label>
|
||||
<input v-model="form.postUrl" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.content') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.content") }}</label>
|
||||
<textarea v-model="form.contentText" class="form-input" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.status') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.status") }}</label>
|
||||
<select v-model="form.status" class="form-input">
|
||||
<option value="approved">{{ t('comments.statusFilter.approved') }}</option>
|
||||
<option value="pending">{{ t('comments.statusFilter.pending') }}</option>
|
||||
<option value="rejected">{{ t('comments.statusFilter.rejected') }}</option>
|
||||
<option value="approved">{{ t("comments.statusFilter.approved") }}</option>
|
||||
<option value="pending">{{ t("comments.statusFilter.pending") }}</option>
|
||||
<option value="rejected">{{ t("comments.statusFilter.rejected") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('comments.editModal.priority') }}</label>
|
||||
<label class="form-label">{{ t("comments.editModal.priority") }}</label>
|
||||
<input
|
||||
v-model.number="form.priority"
|
||||
class="form-input"
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn secondary" type="button" @click="handleClose">
|
||||
{{ t('comments.editModal.cancel') }}
|
||||
{{ t("comments.editModal.cancel") }}
|
||||
</button>
|
||||
<button
|
||||
class="modal-btn primary"
|
||||
@@ -55,8 +55,8 @@
|
||||
:disabled="saving"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<span v-if="saving">{{ t('comments.editModal.saving') }}</span>
|
||||
<span v-else>{{ t('comments.editModal.save') }}</span>
|
||||
<span v-if="saving">{{ t("comments.editModal.saving") }}</span>
|
||||
<span v-else>{{ t("comments.editModal.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">{{ t('comments.title') }}</h2>
|
||||
<h2 class="page-title">{{ t("comments.title") }}</h2>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<select v-model="statusFilter" class="toolbar-select">
|
||||
<option value="">{{ t('comments.statusFilter.all') }}</option>
|
||||
<option value="approved">{{ t('comments.statusFilter.approved') }}</option>
|
||||
<option value="pending">{{ t('comments.statusFilter.pending') }}</option>
|
||||
<option value="rejected">{{ t('comments.statusFilter.rejected') }}</option>
|
||||
<option value="">{{ t("comments.statusFilter.all") }}</option>
|
||||
<option value="approved">{{ t("comments.statusFilter.approved") }}</option>
|
||||
<option value="pending">{{ t("comments.statusFilter.pending") }}</option>
|
||||
<option value="rejected">{{ t("comments.statusFilter.rejected") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<button class="toolbar-button" @click="goPage(1)">{{ t('comments.refresh') }}</button>
|
||||
<button class="toolbar-button" @click="goPage(1)">
|
||||
{{ t("comments.refresh") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="loading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="error" class="page-error">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="comment-table">
|
||||
<div class="table-header">
|
||||
<div class="table-cell table-cell-author">{{ t('comments.table.author') }}</div>
|
||||
<div class="table-cell table-cell-content">{{ t('comments.table.content') }}</div>
|
||||
<div class="table-cell table-cell-path">{{ t('comments.table.path') }}</div>
|
||||
<div class="table-cell table-cell-status">{{ t('comments.table.status') }}</div>
|
||||
<div class="table-cell table-cell-actions">{{ t('comments.table.actions') }}</div>
|
||||
<div class="table-cell table-cell-author">{{ t("comments.table.author") }}</div>
|
||||
<div class="table-cell table-cell-content">
|
||||
{{ t("comments.table.content") }}
|
||||
</div>
|
||||
<div class="table-cell table-cell-path">{{ t("comments.table.path") }}</div>
|
||||
<div class="table-cell table-cell-status">{{ t("comments.table.status") }}</div>
|
||||
<div class="table-cell table-cell-actions">
|
||||
{{ t("comments.table.actions") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="item in filteredComments" :key="item.id" class="table-row">
|
||||
<div class="table-cell table-cell-author">
|
||||
@@ -89,10 +95,9 @@
|
||||
target="_blank"
|
||||
class="cell-path"
|
||||
:title="item.postSlug"
|
||||
>
|
||||
{{ item.postUrl || item.postSlug }}
|
||||
</a
|
||||
>
|
||||
{{ item.postUrl || item.postSlug }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="table-cell table-cell-status">
|
||||
<div class="cell-status-wrapper">
|
||||
@@ -104,7 +109,7 @@
|
||||
:title="String(item.priority)"
|
||||
class="cell-status cell-pin-flag"
|
||||
>
|
||||
{{ t('comments.actions.pin') }}
|
||||
{{ t("comments.actions.pin") }}
|
||||
</span>
|
||||
<span class="cell-status cell-likes-number" v-if="item.likes !== 0">
|
||||
<PhThumbsUp :size="13" />
|
||||
@@ -126,21 +131,25 @@
|
||||
:value="item.status"
|
||||
@change="handleStatusChange(item, $event)"
|
||||
>
|
||||
<option value="approved">{{ t('comments.actions.approve') }}</option>
|
||||
<option value="pending">{{ t('comments.actions.pending') }}</option>
|
||||
<option value="rejected">{{ t('comments.actions.reject') }}</option>
|
||||
<option value="approved">{{ t("comments.actions.approve") }}</option>
|
||||
<option value="pending">{{ t("comments.actions.pending") }}</option>
|
||||
<option value="rejected">{{ t("comments.actions.reject") }}</option>
|
||||
</select>
|
||||
<button class="table-action" @click="openEdit(item)">{{ t('comments.actions.edit') }}</button>
|
||||
<button class="table-action" @click="openEdit(item)">
|
||||
{{ t("comments.actions.edit") }}
|
||||
</button>
|
||||
<button
|
||||
class="table-action table-action-danger"
|
||||
@click="removeComment(item)"
|
||||
>
|
||||
{{ t('comments.actions.delete') }}
|
||||
{{ t("comments.actions.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="filteredComments.length === 0" class="table-empty">{{ t('comments.empty') }}</div>
|
||||
<div v-if="filteredComments.length === 0" class="table-empty">
|
||||
{{ t("comments.empty") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 1" class="pagination">
|
||||
<button
|
||||
@@ -148,7 +157,7 @@
|
||||
:disabled="pagination.page <= 1"
|
||||
@click="goPage(pagination.page - 1)"
|
||||
>
|
||||
{{ t('comments.pagination.prev') }}
|
||||
{{ t("comments.pagination.prev") }}
|
||||
</button>
|
||||
<button
|
||||
class="pagination-button"
|
||||
@@ -199,10 +208,10 @@
|
||||
:disabled="pagination.page >= pagination.total"
|
||||
@click="goPage(pagination.page + 1)"
|
||||
>
|
||||
{{ t('comments.pagination.next') }}
|
||||
{{ t("comments.pagination.next") }}
|
||||
</button>
|
||||
<div class="pagination-jump">
|
||||
<span>{{ t('comments.pagination.jumpTo') }}</span>
|
||||
<span>{{ t("comments.pagination.jumpTo") }}</span>
|
||||
<input
|
||||
v-model="jumpPageInput"
|
||||
class="pagination-input"
|
||||
@@ -211,8 +220,10 @@
|
||||
:max="pagination.total"
|
||||
@keyup.enter="handleJumpPage"
|
||||
/>
|
||||
<span>{{ t('comments.pagination.page') }}</span>
|
||||
<button class="pagination-button" @click="handleJumpPage">{{ t('comments.pagination.confirm') }}</button>
|
||||
<span>{{ t("comments.pagination.page") }}</span>
|
||||
<button class="pagination-button" @click="handleJumpPage">
|
||||
{{ t("comments.pagination.confirm") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,16 +239,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import "../../styles/markdown.css";
|
||||
import { onMounted, ref, computed, watch, inject } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { onMounted, ref, computed, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import ModalEdit from "./components/ModalEdit.vue";
|
||||
import {
|
||||
CommentItem,
|
||||
CommentListResponse,
|
||||
fetchComments,
|
||||
deleteComment,
|
||||
updateCommentStatus,
|
||||
@@ -319,13 +326,13 @@ function formatDate(value: number) {
|
||||
|
||||
function formatStatus(status: string) {
|
||||
if (status === "approved") {
|
||||
return t('comments.statusFilter.approved');
|
||||
return t("comments.statusFilter.approved");
|
||||
}
|
||||
if (status === "pending") {
|
||||
return t('comments.statusFilter.pending');
|
||||
return t("comments.statusFilter.pending");
|
||||
}
|
||||
if (status === "rejected") {
|
||||
return t('comments.statusFilter.rejected');
|
||||
return t("comments.statusFilter.rejected");
|
||||
}
|
||||
return status;
|
||||
}
|
||||
@@ -398,7 +405,7 @@ function handleStatusChange(item: CommentItem, event: Event) {
|
||||
}
|
||||
|
||||
async function removeComment(item: CommentItem) {
|
||||
if (!window.confirm(t('comments.confirmDelete', { id: item.id }))) {
|
||||
if (!window.confirm(t("comments.confirmDelete", { id: item.id }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -413,12 +420,12 @@ async function handleBlockIp(item: CommentItem) {
|
||||
if (!item.ipAddress) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('comments.confirmBlockIp', { ip: item.ipAddress }))) {
|
||||
if (!window.confirm(t("comments.confirmBlockIp", { ip: item.ipAddress }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await blockIp(item.ipAddress);
|
||||
window.alert(res.message || t('comments.successBlockIp'));
|
||||
window.alert(res.message || t("comments.successBlockIp"));
|
||||
} catch (e: any) {
|
||||
error.value = e.message || "屏蔽 IP 失败";
|
||||
}
|
||||
@@ -428,12 +435,12 @@ async function handleBlockEmail(item: CommentItem) {
|
||||
if (!item.email) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('comments.confirmBlockEmail', { email: item.email }))) {
|
||||
if (!window.confirm(t("comments.confirmBlockEmail", { email: item.email }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await blockEmail(item.email);
|
||||
window.alert(res.message || t('comments.successBlockEmail'));
|
||||
window.alert(res.message || t("comments.successBlockEmail"));
|
||||
} catch (e: any) {
|
||||
error.value = e.message || "屏蔽邮箱失败";
|
||||
}
|
||||
@@ -545,7 +552,6 @@ watch(currentSiteId, () => {
|
||||
updateRoutePage(1);
|
||||
loadComments(1);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">{{ t('data.title') }}</h2>
|
||||
<h2 class="page-title">{{ t("data.title") }}</h2>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
@@ -11,57 +11,101 @@
|
||||
|
||||
<!-- 1. 评论数据 -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ t('data.sections.comments.title') }}</h3>
|
||||
<p class="card-desc">{{ t('data.sections.comments.desc') }}</p>
|
||||
|
||||
<h3 class="card-title">{{ t("data.sections.comments.title") }}</h3>
|
||||
<p class="card-desc">{{ t("data.sections.comments.desc") }}</p>
|
||||
|
||||
<div class="action-row">
|
||||
<span class="action-label">{{ t('data.sections.comments.exportLabel') }}</span>
|
||||
<button class="card-button secondary" :disabled="exporting" @click="handleExportComments">
|
||||
<span v-if="exporting">{{ t('data.sections.comments.exporting') }}</span>
|
||||
<span v-else>{{ t('data.sections.comments.exportJson') }}</span>
|
||||
<span class="action-label">{{ t("data.sections.comments.exportLabel") }}</span>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="exporting"
|
||||
@click="handleExportComments"
|
||||
>
|
||||
<span v-if="exporting">{{ t("data.sections.comments.exporting") }}</span>
|
||||
<span v-else>{{ t("data.sections.comments.exportJson") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<span class="action-label">{{ t('data.sections.comments.importLabel') }}</span>
|
||||
<select v-model="importSource" class="form-select" style="min-width:120px;">
|
||||
<option value="cwd">{{ t('data.sections.comments.source.cwd') }}</option>
|
||||
<option value="twikoo">{{ t('data.sections.comments.source.twikoo') }}</option>
|
||||
<option value="artalk">{{ t('data.sections.comments.source.artalk') }}</option>
|
||||
<span class="action-label">{{ t("data.sections.comments.importLabel") }}</span>
|
||||
<select v-model="importSource" class="form-select" style="min-width: 120px">
|
||||
<option value="cwd">{{ t("data.sections.comments.source.cwd") }}</option>
|
||||
<option value="twikoo">{{ t("data.sections.comments.source.twikoo") }}</option>
|
||||
<option value="artalk">{{ t("data.sections.comments.source.artalk") }}</option>
|
||||
</select>
|
||||
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('comments')">
|
||||
{{ t('data.sections.comments.importButton') }}
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="importing"
|
||||
@click="triggerFileInput('comments')"
|
||||
>
|
||||
{{ t("data.sections.comments.importButton") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 系统配置 -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ t('data.sections.config.title') }}</h3>
|
||||
<p class="card-desc">{{ t('data.sections.config.desc') }}</p>
|
||||
<h3 class="card-title">{{ t("data.sections.config.title") }}</h3>
|
||||
<p class="card-desc">{{ t("data.sections.config.desc") }}</p>
|
||||
<div class="action-row">
|
||||
<button class="card-button secondary" :disabled="exporting" @click="handleExportConfig">{{ t('data.sections.config.export') }}</button>
|
||||
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('config')">{{ t('data.sections.config.import') }}</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="exporting"
|
||||
@click="handleExportConfig"
|
||||
>
|
||||
{{ t("data.sections.config.export") }}
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="importing"
|
||||
@click="triggerFileInput('config')"
|
||||
>
|
||||
{{ t("data.sections.config.import") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 访问统计 -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ t('data.sections.stats.title') }}</h3>
|
||||
<p class="card-desc">{{ t('data.sections.stats.desc') }}</p>
|
||||
<h3 class="card-title">{{ t("data.sections.stats.title") }}</h3>
|
||||
<p class="card-desc">{{ t("data.sections.stats.desc") }}</p>
|
||||
<div class="action-row">
|
||||
<button class="card-button secondary" :disabled="exporting" @click="handleExportStats">{{ t('data.sections.stats.export') }}</button>
|
||||
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('stats')">{{ t('data.sections.stats.import') }}</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="exporting"
|
||||
@click="handleExportStats"
|
||||
>
|
||||
{{ t("data.sections.stats.export") }}
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="importing"
|
||||
@click="triggerFileInput('stats')"
|
||||
>
|
||||
{{ t("data.sections.stats.import") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 全量备份 -->
|
||||
<div class="card">
|
||||
<h3 class="card-title">{{ t('data.sections.backup.title') }}</h3>
|
||||
<p class="card-desc">{{ t('data.sections.backup.desc') }}</p>
|
||||
<h3 class="card-title">{{ t("data.sections.backup.title") }}</h3>
|
||||
<p class="card-desc">{{ t("data.sections.backup.desc") }}</p>
|
||||
<div class="action-row">
|
||||
<button class="card-button secondary" :disabled="exporting" @click="handleExportBackup">{{ t('data.sections.backup.export') }}</button>
|
||||
<button class="card-button secondary" :disabled="importing" @click="triggerFileInput('backup')">{{ t('data.sections.backup.import') }}</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="exporting"
|
||||
@click="handleExportBackup"
|
||||
>
|
||||
{{ t("data.sections.backup.export") }}
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
:disabled="importing"
|
||||
@click="triggerFileInput('backup')"
|
||||
>
|
||||
{{ t("data.sections.backup.import") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -76,7 +120,7 @@
|
||||
|
||||
<!-- 导入日志 -->
|
||||
<div v-if="importLogs.length > 0" class="log-container">
|
||||
<div class="log-title">{{ t('data.logs.title') }}</div>
|
||||
<div class="log-title">{{ t("data.logs.title") }}</div>
|
||||
<div class="log-list">
|
||||
<div v-for="(log, index) in importLogs" :key="index" class="log-item">
|
||||
{{ log }}
|
||||
@@ -87,15 +131,15 @@
|
||||
<!-- 前缀确认弹窗 -->
|
||||
<div v-if="showPrefixModal" class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h3 class="modal-title">{{ t('data.prefixModal.title') }}</h3>
|
||||
<h3 class="modal-title">{{ t("data.prefixModal.title") }}</h3>
|
||||
<p class="modal-desc">
|
||||
{{ t('data.prefixModal.descPart1') }}
|
||||
{{ t("data.prefixModal.descPart1") }}
|
||||
<strong>{{ missingPrefixCount }}</strong>
|
||||
{{ t('data.prefixModal.descPart2') }}<br />
|
||||
{{ t('data.prefixModal.descPart3') }}
|
||||
{{ t("data.prefixModal.descPart2") }}<br />
|
||||
{{ t("data.prefixModal.descPart3") }}
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('data.prefixModal.label') }}</label>
|
||||
<label class="form-label">{{ t("data.prefixModal.label") }}</label>
|
||||
<input
|
||||
v-model="urlPrefix"
|
||||
class="form-input"
|
||||
@@ -103,11 +147,13 @@
|
||||
@keyup.enter="confirmPrefix"
|
||||
/>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn secondary" @click="cancelPrefix">
|
||||
{{ t('data.prefixModal.skip') }}
|
||||
{{ t("data.prefixModal.skip") }}
|
||||
</button>
|
||||
<button class="modal-btn primary" @click="confirmPrefix">
|
||||
{{ t("data.prefixModal.confirm") }}
|
||||
</button>
|
||||
<button class="modal-btn primary" @click="confirmPrefix">{{ t('data.prefixModal.confirm') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,11 +163,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
exportComments, importComments,
|
||||
exportConfig, importConfig,
|
||||
exportStats, importStats,
|
||||
exportBackup, importBackup
|
||||
import {
|
||||
exportComments,
|
||||
importComments,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
exportStats,
|
||||
importStats,
|
||||
exportBackup,
|
||||
importBackup,
|
||||
} from "../../api/admin";
|
||||
import { useSite } from "../../composables/useSite";
|
||||
|
||||
@@ -138,7 +188,7 @@ const importLogs = ref<string[]>([]);
|
||||
const { currentSiteId } = useSite();
|
||||
|
||||
// 当前导入模式: comments | config | stats | backup
|
||||
const currentImportMode = ref<string>('comments');
|
||||
const currentImportMode = ref<string>("comments");
|
||||
|
||||
// 前缀处理相关状态
|
||||
const showPrefixModal = ref(false);
|
||||
@@ -175,26 +225,27 @@ async function executeExport(apiFunc: () => Promise<any>, fileNamePrefix: string
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
showToast(t('data.messages.exportSuccess'), "success");
|
||||
showToast(t("data.messages.exportSuccess"), "success");
|
||||
} catch (e: any) {
|
||||
showToast(e.message || t('data.messages.exportFailed'), "error");
|
||||
showToast(e.message || t("data.messages.exportFailed"), "error");
|
||||
} finally {
|
||||
exporting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出处理
|
||||
const handleExportComments = () => executeExport(exportComments, 'comments-export');
|
||||
const handleExportConfig = () => executeExport(exportConfig, 'cwd-config');
|
||||
const handleExportStats = () => executeExport(() => exportStats(currentSiteId.value), 'cwd-stats');
|
||||
const handleExportBackup = () => executeExport(exportBackup, 'cwd-full-backup');
|
||||
const handleExportComments = () => executeExport(exportComments, "comments-export");
|
||||
const handleExportConfig = () => executeExport(exportConfig, "cwd-config");
|
||||
const handleExportStats = () =>
|
||||
executeExport(() => exportStats(currentSiteId.value), "cwd-stats");
|
||||
const handleExportBackup = () => executeExport(exportBackup, "cwd-full-backup");
|
||||
|
||||
// 触发文件选择
|
||||
function triggerFileInput(mode: string) {
|
||||
currentImportMode.value = mode;
|
||||
importLogs.value = [];
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''; // 重置 input
|
||||
fileInput.value.value = ""; // 重置 input
|
||||
fileInput.value.click();
|
||||
}
|
||||
}
|
||||
@@ -206,8 +257,10 @@ async function handleFileChange(event: Event) {
|
||||
if (!file) return;
|
||||
|
||||
importing.value = true;
|
||||
addLog(t('data.messages.importStart', { name: file.name, mode: currentImportMode.value }));
|
||||
|
||||
addLog(
|
||||
t("data.messages.importStart", { name: file.name, mode: currentImportMode.value })
|
||||
);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
@@ -216,37 +269,36 @@ async function handleFileChange(event: Event) {
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (parseError) {
|
||||
throw new Error(t('data.messages.jsonParseFailed'));
|
||||
throw new Error(t("data.messages.jsonParseFailed"));
|
||||
}
|
||||
|
||||
addLog(t('data.messages.fileParseSuccess'));
|
||||
|
||||
addLog(t("data.messages.fileParseSuccess"));
|
||||
|
||||
switch (currentImportMode.value) {
|
||||
case 'comments':
|
||||
case "comments":
|
||||
await processImportComments(json);
|
||||
break;
|
||||
case 'config':
|
||||
case "config":
|
||||
await processImportConfig(json);
|
||||
break;
|
||||
case 'stats':
|
||||
case "stats":
|
||||
await processImportStats(json);
|
||||
break;
|
||||
case 'backup':
|
||||
case "backup":
|
||||
await processImportBackup(json);
|
||||
break;
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
addLog(t('data.messages.errorWithMessage', { msg: err.message }));
|
||||
addLog(t("data.messages.errorWithMessage", { msg: err.message }));
|
||||
showToast(err.message, "error");
|
||||
importing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
reader.onerror = () => {
|
||||
addLog(t('data.messages.readFileFailedLog'));
|
||||
showToast(t('data.messages.fileReadFailed'), "error");
|
||||
addLog(t("data.messages.readFileFailedLog"));
|
||||
showToast(t("data.messages.fileReadFailed"), "error");
|
||||
importing.value = false;
|
||||
};
|
||||
|
||||
@@ -257,27 +309,27 @@ async function handleFileChange(event: Event) {
|
||||
async function processImportConfig(data: any) {
|
||||
const res = await importConfig(data);
|
||||
addLog(res.message);
|
||||
showToast(t('data.messages.importConfigSuccess'));
|
||||
showToast(t("data.messages.importConfigSuccess"));
|
||||
importing.value = false;
|
||||
}
|
||||
|
||||
async function processImportStats(data: any) {
|
||||
const res = await importStats(data);
|
||||
addLog(res.message);
|
||||
showToast(t('data.messages.importStatsSuccess'));
|
||||
showToast(t("data.messages.importStatsSuccess"));
|
||||
importing.value = false;
|
||||
}
|
||||
|
||||
async function processImportBackup(data: any) {
|
||||
const res = await importBackup(data);
|
||||
addLog(res.message);
|
||||
showToast(t('data.messages.importBackupSuccess'));
|
||||
showToast(t("data.messages.importBackupSuccess"));
|
||||
importing.value = false;
|
||||
}
|
||||
|
||||
async function processImportComments(json: any) {
|
||||
const comments = Array.isArray(json) ? json : [json];
|
||||
addLog(t('data.messages.parsedCommentsCount', { count: comments.length }));
|
||||
addLog(t("data.messages.parsedCommentsCount", { count: comments.length }));
|
||||
|
||||
// 检查 URL 前缀 (仅针对评论导入)
|
||||
let missingCount = 0;
|
||||
@@ -291,7 +343,7 @@ async function processImportComments(json: any) {
|
||||
}
|
||||
|
||||
if (missingCount > 0) {
|
||||
addLog(t('data.messages.detectMissingPrefix', { count: missingCount }));
|
||||
addLog(t("data.messages.detectMissingPrefix", { count: missingCount }));
|
||||
missingPrefixCount.value = missingCount;
|
||||
pendingJson.value = comments;
|
||||
showPrefixModal.value = true;
|
||||
@@ -304,8 +356,8 @@ async function processImportComments(json: any) {
|
||||
async function executeImportComments(comments: any[]) {
|
||||
try {
|
||||
const res = await importComments(comments);
|
||||
addLog(t('data.messages.importCommentsDone', { message: res.message }));
|
||||
showToast(t('data.messages.importCommentsSuccess'));
|
||||
addLog(t("data.messages.importCommentsDone", { message: res.message }));
|
||||
showToast(t("data.messages.importCommentsSuccess"));
|
||||
} catch (err: any) {
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -317,14 +369,14 @@ async function executeImportComments(comments: any[]) {
|
||||
// 前缀确认逻辑
|
||||
async function confirmPrefix() {
|
||||
if (!urlPrefix.value) {
|
||||
showToast(t('data.messages.prefixRequired'), "error");
|
||||
showToast(t("data.messages.prefixRequired"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
let prefix = urlPrefix.value.trim();
|
||||
const comments = pendingJson.value.map((item) => {
|
||||
const newItem = { ...item };
|
||||
|
||||
|
||||
// Twikoo
|
||||
if (newItem.href && typeof newItem.href === "string") {
|
||||
if (!newItem.href.startsWith("http://") && !newItem.href.startsWith("https://")) {
|
||||
@@ -333,13 +385,19 @@ async function confirmPrefix() {
|
||||
}
|
||||
// Artalk
|
||||
if (newItem.page_key && typeof newItem.page_key === "string") {
|
||||
if (!newItem.page_key.startsWith("http://") && !newItem.page_key.startsWith("https://")) {
|
||||
if (
|
||||
!newItem.page_key.startsWith("http://") &&
|
||||
!newItem.page_key.startsWith("https://")
|
||||
) {
|
||||
newItem.page_key = joinUrl(prefix, newItem.page_key);
|
||||
}
|
||||
}
|
||||
// CWD
|
||||
if (newItem.post_slug && typeof newItem.post_slug === "string") {
|
||||
if (!newItem.post_slug.startsWith("http://") && !newItem.post_slug.startsWith("https://")) {
|
||||
if (
|
||||
!newItem.post_slug.startsWith("http://") &&
|
||||
!newItem.post_slug.startsWith("https://")
|
||||
) {
|
||||
newItem.post_slug = joinUrl(prefix, newItem.post_slug);
|
||||
}
|
||||
}
|
||||
@@ -347,13 +405,13 @@ async function confirmPrefix() {
|
||||
});
|
||||
|
||||
showPrefixModal.value = false;
|
||||
addLog(t('data.messages.prefixAdded'));
|
||||
addLog(t("data.messages.prefixAdded"));
|
||||
await executeImportComments(comments);
|
||||
}
|
||||
|
||||
function cancelPrefix() {
|
||||
showPrefixModal.value = false;
|
||||
addLog(t('data.messages.skipPrefix'));
|
||||
addLog(t("data.messages.skipPrefix"));
|
||||
executeImportComments(pendingJson.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,18 +14,14 @@
|
||||
<div class="layout-domain-filter layout-domain-filter-header">
|
||||
<select v-model="currentSiteId" class="layout-domain-select">
|
||||
<option :value="defaultSiteId">{{ getSiteLabel(defaultSiteId) }}</option>
|
||||
<option
|
||||
v-for="item in siteOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
|
||||
{{ getSiteLabel(item.value) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="layout-actions">
|
||||
<a class="layout-button" href="https://cwd.js.org" target="_blank">
|
||||
{{ t('layout.docs') }}
|
||||
{{ t("layout.docs") }}
|
||||
</a>
|
||||
<a class="layout-button" href="https://github.com/anghunk/cwd" target="_blank">
|
||||
Github
|
||||
@@ -40,7 +36,9 @@
|
||||
<PhMoon v-else-if="theme === 'dark'" :size="16" />
|
||||
<PhAirplay v-else :size="16" />
|
||||
</button>
|
||||
<button class="layout-button" @click="handleLogout">{{ t('layout.logout') }}</button>
|
||||
<button class="layout-button" @click="handleLogout">
|
||||
{{ t("layout.logout") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -53,7 +51,7 @@
|
||||
</button>
|
||||
<div v-if="isActionsOpen" class="layout-actions-dropdown">
|
||||
<button class="layout-actions-item" type="button" @click="openDocs">
|
||||
{{ t('layout.docs') }}
|
||||
{{ t("layout.docs") }}
|
||||
</button>
|
||||
<button class="layout-actions-item" type="button" @click="openGithub">
|
||||
Github
|
||||
@@ -63,7 +61,7 @@
|
||||
type="button"
|
||||
@click="handleLogoutFromActions"
|
||||
>
|
||||
{{ t('layout.logout') }}
|
||||
{{ t("layout.logout") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,11 +74,7 @@
|
||||
<div class="layout-sider-domain-filter">
|
||||
<select v-model="currentSiteId" class="layout-domain-select">
|
||||
<option :value="defaultSiteId">{{ getSiteLabel(defaultSiteId) }}</option>
|
||||
<option
|
||||
v-for="item in siteOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
|
||||
{{ getSiteLabel(item.value) }}
|
||||
</option>
|
||||
</select>
|
||||
@@ -92,7 +86,7 @@
|
||||
@click="goComments"
|
||||
>
|
||||
<PhChatCircleDots class="menu-item-icon" :size="18" />
|
||||
<span>{{ t('menu.comments') }}</span>
|
||||
<span>{{ t("menu.comments") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
@@ -100,7 +94,7 @@
|
||||
@click="goStats"
|
||||
>
|
||||
<PhSquaresFour class="menu-item-icon" :size="18" />
|
||||
<span>{{ t('menu.stats') }}</span>
|
||||
<span>{{ t("menu.stats") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
@@ -108,7 +102,7 @@
|
||||
@click="goAnalytics"
|
||||
>
|
||||
<PhChartBar class="menu-item-icon" :size="18" />
|
||||
<span>{{ t('menu.analytics') }}</span>
|
||||
<span>{{ t("menu.analytics") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
@@ -116,7 +110,7 @@
|
||||
@click="goSettings"
|
||||
>
|
||||
<PhGear class="menu-item-icon" :size="18" />
|
||||
<span>{{ t('menu.settings') }}</span>
|
||||
<span>{{ t("menu.settings") }}</span>
|
||||
</li>
|
||||
<li
|
||||
class="menu-item"
|
||||
@@ -124,7 +118,7 @@
|
||||
@click="goData"
|
||||
>
|
||||
<PhDatabase class="menu-item-icon" :size="18" />
|
||||
<span>{{ t('menu.data') }}</span>
|
||||
<span>{{ t("menu.data") }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="layout-sider-footer" @click="openVersionModal">
|
||||
@@ -141,35 +135,42 @@
|
||||
</div>
|
||||
<div v-if="versionModalVisible" class="modal-overlay" @click.self="closeVersionModal">
|
||||
<div class="modal">
|
||||
<h3 class="modal-title">{{ t('layout.version.title') }}</h3>
|
||||
<h3 class="modal-title">{{ t("layout.version.title") }}</h3>
|
||||
<div class="modal-body">
|
||||
<p class="modal-row">
|
||||
<span class="modal-label">{{ t('layout.version.apiAddress') }}</span>
|
||||
<span class="modal-value">{{ checkedApiBaseUrl || t('layout.version.notConfigured') }}</span>
|
||||
<span class="modal-label">{{ t("layout.version.apiAddress") }}</span>
|
||||
<span class="modal-value">{{
|
||||
checkedApiBaseUrl || t("layout.version.notConfigured")
|
||||
}}</span>
|
||||
</p>
|
||||
<p class="modal-row">
|
||||
<span class="modal-label">{{ t('layout.version.apiVersion') }}</span>
|
||||
<span class="modal-label">{{ t("layout.version.apiVersion") }}</span>
|
||||
<span class="modal-value">
|
||||
{{ apiVersion || (apiVersionError ? t('layout.version.notFetched') : t('layout.version.loading')) }}
|
||||
{{
|
||||
apiVersion ||
|
||||
(apiVersionError
|
||||
? t("layout.version.notFetched")
|
||||
: t("layout.version.loading"))
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<p class="modal-row">
|
||||
<span class="modal-label">{{ t('layout.version.adminVersion') }}</span>
|
||||
<span class="modal-label">{{ t("layout.version.adminVersion") }}</span>
|
||||
<span class="modal-value">{{ adminVersion }}</span>
|
||||
</p>
|
||||
<p v-if="apiVersion && apiVersion === adminVersion" class="modal-status">
|
||||
{{ t('layout.version.match') }}
|
||||
{{ t("layout.version.match") }}
|
||||
</p>
|
||||
<p v-else-if="apiVersion && apiVersion !== adminVersion" class="modal-status">
|
||||
{{ t('layout.version.mismatch') }}
|
||||
{{ t("layout.version.mismatch") }}
|
||||
</p>
|
||||
<p v-else-if="apiVersionError" class="modal-status">
|
||||
{{ t('layout.version.fetchError') }} {{ apiVersionError }}
|
||||
{{ t("layout.version.fetchError") }} {{ apiVersionError }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn" type="button" @click="closeVersionModal">
|
||||
{{ t('layout.version.ok') }}
|
||||
{{ t("layout.version.ok") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -205,9 +206,9 @@ const versionModalVisible = ref(false);
|
||||
const layoutTitle = ref(localStorage.getItem(SITE_TITLE_KEY) || "CWD 评论系统");
|
||||
|
||||
const themeTitle = computed(() => {
|
||||
if (theme.value === "light") return t('layout.theme.light');
|
||||
if (theme.value === "dark") return t('layout.theme.dark');
|
||||
return t('layout.theme.system');
|
||||
if (theme.value === "light") return t("layout.theme.light");
|
||||
if (theme.value === "dark") return t("layout.theme.dark");
|
||||
return t("layout.theme.system");
|
||||
});
|
||||
|
||||
function cycleTheme() {
|
||||
@@ -222,7 +223,7 @@ const defaultSiteId = "default";
|
||||
|
||||
function getSiteLabel(value: string) {
|
||||
if (!value || value === "default") {
|
||||
return t('layout.defaultSite');
|
||||
return t("layout.defaultSite");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -232,10 +233,12 @@ async function loadSites() {
|
||||
const res = await fetchSiteList();
|
||||
const sites = Array.isArray(res.sites) ? res.sites : [];
|
||||
const unique = Array.from(new Set(sites));
|
||||
siteOptions.value = unique.filter((s) => s !== "").map((s) => ({
|
||||
label: s,
|
||||
value: s,
|
||||
}));
|
||||
siteOptions.value = unique
|
||||
.filter((s) => s !== "")
|
||||
.map((s) => ({
|
||||
label: s,
|
||||
value: s,
|
||||
}));
|
||||
} catch {
|
||||
siteOptions.value = [];
|
||||
}
|
||||
|
||||
@@ -7,18 +7,9 @@
|
||||
<div class="domain-transfer">
|
||||
<!-- Visible Domains (Left) -->
|
||||
<div class="transfer-panel">
|
||||
<div class="transfer-header">
|
||||
后台显示站点 ({{ visibleList.length }})
|
||||
</div>
|
||||
<div
|
||||
class="transfer-body"
|
||||
@dragover.prevent
|
||||
@drop="onDrop($event, 'visible')"
|
||||
>
|
||||
<div
|
||||
v-if="visibleList.length === 0"
|
||||
class="transfer-empty"
|
||||
>
|
||||
<div class="transfer-header">后台显示站点 ({{ visibleList.length }})</div>
|
||||
<div class="transfer-body" @dragover.prevent @drop="onDrop($event, 'visible')">
|
||||
<div v-if="visibleList.length === 0" class="transfer-empty">
|
||||
无站点 (默认显示全部)
|
||||
</div>
|
||||
<div
|
||||
@@ -43,26 +34,15 @@
|
||||
<PhCaretDoubleLeft />
|
||||
</button>
|
||||
<button class="action-btn" @click="moveAllToHidden" title="全部右移">
|
||||
<PhCaretDoubleRight />
|
||||
<PhCaretDoubleRight />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Hidden/All Domains (Right) -->
|
||||
<div class="transfer-panel">
|
||||
<div class="transfer-header">
|
||||
其他站点 ({{ hiddenList.length }})
|
||||
</div>
|
||||
<div
|
||||
class="transfer-body"
|
||||
@dragover.prevent
|
||||
@drop="onDrop($event, 'hidden')"
|
||||
>
|
||||
<div
|
||||
v-if="hiddenList.length === 0"
|
||||
class="transfer-empty"
|
||||
>
|
||||
无更多站点
|
||||
</div>
|
||||
<div class="transfer-header">其他站点 ({{ hiddenList.length }})</div>
|
||||
<div class="transfer-body" @dragover.prevent @drop="onDrop($event, 'hidden')">
|
||||
<div v-if="hiddenList.length === 0" class="transfer-empty">无更多站点</div>
|
||||
<div
|
||||
v-for="domain in hiddenList"
|
||||
:key="domain"
|
||||
@@ -89,8 +69,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { fetchSiteList, fetchFeatureSettings, saveFeatureSettings } from '../../../api/admin';
|
||||
import { ref, onMounted, computed } from "vue";
|
||||
import {
|
||||
fetchSiteList,
|
||||
fetchFeatureSettings,
|
||||
saveFeatureSettings,
|
||||
} from "../../../api/admin";
|
||||
|
||||
const loading = ref(false);
|
||||
const allDomains = ref<string[]>([]);
|
||||
@@ -98,12 +82,12 @@ const visibleList = ref<string[]>([]);
|
||||
|
||||
const hiddenList = computed(() => {
|
||||
const visibleSet = new Set(visibleList.value);
|
||||
return allDomains.value.filter(d => !visibleSet.has(d));
|
||||
return allDomains.value.filter((d) => !visibleSet.has(d));
|
||||
});
|
||||
|
||||
function getSiteLabel(value: string) {
|
||||
if (!value) {
|
||||
return '默认站点 (Default)';
|
||||
return "默认站点 (Default)";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -112,14 +96,14 @@ async function loadData() {
|
||||
try {
|
||||
const [siteRes, settingsRes] = await Promise.all([
|
||||
fetchSiteList(),
|
||||
fetchFeatureSettings()
|
||||
fetchFeatureSettings(),
|
||||
]);
|
||||
|
||||
|
||||
allDomains.value = siteRes.sites || [];
|
||||
if (settingsRes.visibleDomains) {
|
||||
visibleList.value = settingsRes.visibleDomains;
|
||||
} else {
|
||||
visibleList.value = [];
|
||||
visibleList.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -127,7 +111,7 @@ async function loadData() {
|
||||
}
|
||||
|
||||
function moveToHidden(domain: string) {
|
||||
visibleList.value = visibleList.value.filter(d => d !== domain);
|
||||
visibleList.value = visibleList.value.filter((d) => d !== domain);
|
||||
}
|
||||
|
||||
function moveToVisible(domain: string) {
|
||||
@@ -138,7 +122,7 @@ function moveToVisible(domain: string) {
|
||||
|
||||
function moveAllToVisible() {
|
||||
const current = new Set(visibleList.value);
|
||||
hiddenList.value.forEach(d => current.add(d));
|
||||
hiddenList.value.forEach((d) => current.add(d));
|
||||
visibleList.value = Array.from(current);
|
||||
}
|
||||
|
||||
@@ -146,20 +130,20 @@ function moveAllToHidden() {
|
||||
visibleList.value = [];
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, domain: string, source: 'visible' | 'hidden') {
|
||||
function onDragStart(event: DragEvent, domain: string, source: "visible" | "hidden") {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.setData('text/plain', JSON.stringify({ domain, source }));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData("text/plain", JSON.stringify({ domain, source }));
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent, target: 'visible' | 'hidden') {
|
||||
const data = event.dataTransfer?.getData('text/plain');
|
||||
function onDrop(event: DragEvent, target: "visible" | "hidden") {
|
||||
const data = event.dataTransfer?.getData("text/plain");
|
||||
if (data) {
|
||||
try {
|
||||
const { domain, source } = JSON.parse(data);
|
||||
if (source !== target) {
|
||||
if (target === 'visible') {
|
||||
if (target === "visible") {
|
||||
moveToVisible(domain);
|
||||
} else {
|
||||
moveToHidden(domain);
|
||||
@@ -175,12 +159,12 @@ async function handleSave() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await saveFeatureSettings({
|
||||
visibleDomains: visibleList.value
|
||||
visibleDomains: visibleList.value,
|
||||
});
|
||||
// 保存成功后刷新页面以应用更改(LayoutView 重新加载)
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
alert('保存失败');
|
||||
alert("保存失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -208,7 +192,7 @@ onMounted(() => {
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
height: 400px;
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
@@ -267,15 +251,15 @@ onMounted(() => {
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
.domain-text {
|
||||
flex: 1;
|
||||
margin: 0 10px;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
margin: 0 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,7 +272,7 @@ onMounted(() => {
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--primary-color);
|
||||
@@ -300,7 +284,7 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-self: center;
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
@@ -318,7 +302,7 @@ onMounted(() => {
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
@@ -339,7 +323,7 @@ onMounted(() => {
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-title">{{ t('settings.title') }}</h2>
|
||||
<h2 class="page-title">{{ t("settings.title") }}</h2>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
class="toast"
|
||||
@@ -8,7 +8,7 @@
|
||||
>
|
||||
{{ toastMessage }}
|
||||
</div>
|
||||
<div v-if="loading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="loading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else>
|
||||
<div class="settings-tabs">
|
||||
<button
|
||||
@@ -17,7 +17,7 @@
|
||||
:class="{ 'settings-tab-active': activeTab === 'comment' }"
|
||||
@click="activeTab = 'comment'"
|
||||
>
|
||||
{{ t('settings.tabs.comment') }}
|
||||
{{ t("settings.tabs.comment") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -25,7 +25,7 @@
|
||||
:class="{ 'settings-tab-active': activeTab === 'feature' }"
|
||||
@click="activeTab = 'feature'"
|
||||
>
|
||||
{{ t('settings.tabs.feature') }}
|
||||
{{ t("settings.tabs.feature") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -33,7 +33,7 @@
|
||||
:class="{ 'settings-tab-active': activeTab === 'emailNotify' }"
|
||||
@click="activeTab = 'emailNotify'"
|
||||
>
|
||||
{{ t('settings.tabs.emailNotify') }}
|
||||
{{ t("settings.tabs.emailNotify") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,7 +41,7 @@
|
||||
:class="{ 'settings-tab-active': activeTab === 'telegramNotify' }"
|
||||
@click="activeTab = 'telegramNotify'"
|
||||
>
|
||||
{{ t('settings.tabs.telegramNotify') }}
|
||||
{{ t("settings.tabs.telegramNotify") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -49,7 +49,7 @@
|
||||
:class="{ 'settings-tab-active': activeTab === 'display' }"
|
||||
@click="activeTab = 'display'"
|
||||
>
|
||||
{{ t('settings.tabs.display') }}
|
||||
{{ t("settings.tabs.display") }}
|
||||
</button>
|
||||
</div>
|
||||
<transition name="tab-fade" mode="out-in">
|
||||
@@ -57,33 +57,37 @@
|
||||
<template v-if="activeTab === 'comment'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ t('settings.comment.title') }}</div>
|
||||
<div class="card-title">{{ t("settings.comment.title") }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.comment.adminEmail') }}</label>
|
||||
<label class="form-label">{{ t("settings.comment.adminEmail") }}</label>
|
||||
<input v-model="commentAdminEmail" class="form-input" type="email" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.comment.adminBadge') }}</label>
|
||||
<label class="form-label">{{ t("settings.comment.adminBadge") }}</label>
|
||||
<input v-model="commentAdminBadge" class="form-input" type="text" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.comment.adminEnabled') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.comment.adminEnabled")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="commentAdminEnabled" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>{{ t('settings.comment.avatarPrefix') }}</label
|
||||
>
|
||||
<label class="form-label">{{
|
||||
t("settings.comment.avatarPrefix")
|
||||
}}</label>
|
||||
<input v-model="avatarPrefix" class="form-input" type="text" />
|
||||
</div>
|
||||
<h3 class="card-title">{{ t('settings.comment.securityTitle') }}</h3>
|
||||
<h3 class="card-title">{{ t("settings.comment.securityTitle") }}</h3>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.comment.requireReview') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.comment.requireReview")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="requireReview" type="checkbox" />
|
||||
<span class="slider" />
|
||||
@@ -91,7 +95,7 @@
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">
|
||||
{{ t('settings.comment.adminKey') }}
|
||||
{{ t("settings.comment.adminKey") }}
|
||||
</label>
|
||||
|
||||
<input
|
||||
@@ -103,20 +107,20 @@
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">
|
||||
{{ t('settings.comment.allowedDomains') }}
|
||||
{{ t("settings.comment.allowedDomains") }}
|
||||
</label>
|
||||
<TagInput v-model="allowedDomainTags" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">
|
||||
{{ t('settings.comment.blockedIps') }}
|
||||
{{ t("settings.comment.blockedIps") }}
|
||||
</label>
|
||||
<TagInput v-model="blockedIpTags" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label"
|
||||
>{{ t('settings.comment.blockedEmails') }}</label
|
||||
>
|
||||
<label class="form-label">{{
|
||||
t("settings.comment.blockedEmails")
|
||||
}}</label>
|
||||
<TagInput v-model="blockedEmailTags" />
|
||||
</div>
|
||||
|
||||
@@ -126,8 +130,8 @@
|
||||
:disabled="savingComment"
|
||||
@click="saveComment"
|
||||
>
|
||||
<span v-if="savingComment">{{ t('settings.comment.saving') }}</span>
|
||||
<span v-else>{{ t('settings.comment.save') }}</span>
|
||||
<span v-if="savingComment">{{ t("settings.comment.saving") }}</span>
|
||||
<span v-else>{{ t("settings.comment.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,47 +140,57 @@
|
||||
<template v-else-if="activeTab === 'feature'">
|
||||
<div class="card feature">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ t('settings.feature.title') }}</div>
|
||||
<div class="card-title">{{ t("settings.feature.title") }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-item bg">
|
||||
<div class="form-item-flex">
|
||||
<label class="form-label">{{ t('settings.feature.articleLike') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.feature.articleLike")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="enableArticleLike" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.feature.articleLikeHint') }}
|
||||
{{ t("settings.feature.articleLikeHint") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-item bg">
|
||||
<div class="form-item-flex">
|
||||
<label class="form-label">{{ t('settings.feature.commentLike') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.feature.commentLike")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="enableCommentLike" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.feature.commentLikeHint') }}
|
||||
{{ t("settings.feature.commentLikeHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item bg">
|
||||
<div class="form-item-flex">
|
||||
<label class="form-label">{{ t('settings.feature.imageLightbox') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.feature.imageLightbox")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="enableImageLightbox" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-hint">{{ t('settings.feature.imageLightboxHint') }}</div>
|
||||
<div class="form-hint">
|
||||
{{ t("settings.feature.imageLightboxHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.feature.placeholder') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.feature.placeholder")
|
||||
}}</label>
|
||||
<textarea
|
||||
v-model="commentPlaceholder"
|
||||
class="form-input"
|
||||
@@ -185,20 +199,26 @@
|
||||
:placeholder="t('settings.feature.placeholderHint')"
|
||||
></textarea>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.feature.placeholderHint') }}
|
||||
{{ t("settings.feature.placeholderHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.feature.widgetLanguage') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.feature.widgetLanguage")
|
||||
}}</label>
|
||||
<select v-model="widgetLanguage" class="form-input">
|
||||
<option value="auto">Auto (Browser Default)</option>
|
||||
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
<option
|
||||
v-for="lang in languageOptions"
|
||||
:key="lang.value"
|
||||
:value="lang.value"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.feature.widgetLanguageHint') }}
|
||||
{{ t("settings.feature.widgetLanguageHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -208,8 +228,8 @@
|
||||
:disabled="savingFeature"
|
||||
@click="saveFeature"
|
||||
>
|
||||
<span v-if="savingFeature">{{ t('settings.feature.saving') }}</span>
|
||||
<span v-else>{{ t('settings.feature.save') }}</span>
|
||||
<span v-if="savingFeature">{{ t("settings.feature.saving") }}</span>
|
||||
<span v-else>{{ t("settings.feature.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,28 +238,38 @@
|
||||
<template v-else-if="activeTab === 'display'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ t('settings.display.title') }}</div>
|
||||
<div class="card-title">{{ t("settings.display.title") }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.display.layoutTitle') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.display.layoutTitle")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="adminLayoutTitle"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="CWD 评论系统"
|
||||
/>
|
||||
<div class="form-hint">{{ t('settings.display.layoutTitleHint') }}</div>
|
||||
<div class="form-hint">{{ t("settings.display.layoutTitleHint") }}</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.display.adminLanguage') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.display.adminLanguage")
|
||||
}}</label>
|
||||
<select v-model="adminLanguage" class="form-input">
|
||||
<option v-for="lang in languageOptions" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
<option
|
||||
v-for="lang in languageOptions"
|
||||
:key="lang.value"
|
||||
:value="lang.value"
|
||||
>
|
||||
{{ lang.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="form-hint">{{ t('settings.display.adminLanguageHint') }}</div>
|
||||
<div class="form-hint">
|
||||
{{ t("settings.display.adminLanguageHint") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
@@ -248,8 +278,8 @@
|
||||
:disabled="savingDisplay"
|
||||
@click="saveDisplay"
|
||||
>
|
||||
<span v-if="savingDisplay">{{ t('settings.display.saving') }}</span>
|
||||
<span v-else>{{ t('settings.display.save') }}</span>
|
||||
<span v-if="savingDisplay">{{ t("settings.display.saving") }}</span>
|
||||
<span v-else>{{ t("settings.display.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,11 +288,11 @@
|
||||
<template v-else-if="activeTab === 'emailNotify'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ t('settings.emailNotify.title') }}</div>
|
||||
<div class="card-title">{{ t("settings.emailNotify.title") }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.enable') }}</label>
|
||||
<label class="form-label">{{ t("settings.emailNotify.enable") }}</label>
|
||||
<label class="switch">
|
||||
<input v-model="emailGlobalEnabled" type="checkbox" />
|
||||
<span class="slider" />
|
||||
@@ -270,10 +300,12 @@
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h4 class="card-subtitle">{{ t('settings.emailNotify.smtpTitle') }}</h4>
|
||||
<h4 class="card-subtitle">{{ t("settings.emailNotify.smtpTitle") }}</h4>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.provider') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.emailNotify.provider")
|
||||
}}</label>
|
||||
<select
|
||||
v-model="smtpProvider"
|
||||
class="form-input"
|
||||
@@ -287,7 +319,7 @@
|
||||
|
||||
<div v-if="smtpProvider === 'custom'">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.host') }}</label>
|
||||
<label class="form-label">{{ t("settings.emailNotify.host") }}</label>
|
||||
<input
|
||||
v-model="smtpHost"
|
||||
class="form-input"
|
||||
@@ -295,7 +327,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.port') }}</label>
|
||||
<label class="form-label">{{ t("settings.emailNotify.port") }}</label>
|
||||
<input
|
||||
v-model="smtpPort"
|
||||
class="form-input"
|
||||
@@ -304,7 +336,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.secure') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.emailNotify.secure")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="smtpSecure" type="checkbox" />
|
||||
<span class="slider" />
|
||||
@@ -313,7 +347,7 @@
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.user') }}</label>
|
||||
<label class="form-label">{{ t("settings.emailNotify.user") }}</label>
|
||||
<input
|
||||
v-model="smtpUser"
|
||||
class="form-input"
|
||||
@@ -321,24 +355,36 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.pass') }}</label>
|
||||
<label class="form-label">{{ t("settings.emailNotify.pass") }}</label>
|
||||
<input
|
||||
v-model="smtpPass"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="QQ邮箱请使用授权码"
|
||||
/>
|
||||
<div v-if="smtpProvider === 'qq'" class="form-hint" v-html="t('settings.emailNotify.qqHint')"></div>
|
||||
<div v-else-if="smtpProvider === '163'" class="form-hint" v-html="t('settings.emailNotify.163Hint')"></div>
|
||||
<div
|
||||
v-if="smtpProvider === 'qq'"
|
||||
class="form-hint"
|
||||
v-html="t('settings.emailNotify.qqHint')"
|
||||
></div>
|
||||
<div
|
||||
v-else-if="smtpProvider === '163'"
|
||||
class="form-hint"
|
||||
v-html="t('settings.emailNotify.163Hint')"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<h4 class="card-subtitle">{{ t('settings.emailNotify.templateTitle') }}</h4>
|
||||
<h4 class="card-subtitle">
|
||||
{{ t("settings.emailNotify.templateTitle") }}
|
||||
</h4>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.adminTemplate') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.emailNotify.adminTemplate")
|
||||
}}</label>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.emailNotify.adminTemplateHint') }}
|
||||
{{ t("settings.emailNotify.adminTemplateHint") }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="templateAdmin"
|
||||
@@ -349,9 +395,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.emailNotify.replyTemplate') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.emailNotify.replyTemplate")
|
||||
}}</label>
|
||||
<div class="form-hint">
|
||||
{{ t('settings.emailNotify.replyTemplateHint') }}
|
||||
{{ t("settings.emailNotify.replyTemplateHint") }}
|
||||
</div>
|
||||
<textarea
|
||||
v-model="templateReply"
|
||||
@@ -373,19 +421,21 @@
|
||||
:disabled="testingEmail"
|
||||
@click="testEmail"
|
||||
>
|
||||
<span v-if="testingEmail">{{ t('settings.emailNotify.testingBtn') }}</span>
|
||||
<span v-else>{{ t('settings.emailNotify.testBtn') }}</span>
|
||||
<span v-if="testingEmail">{{
|
||||
t("settings.emailNotify.testingBtn")
|
||||
}}</span>
|
||||
<span v-else>{{ t("settings.emailNotify.testBtn") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
style="margin-left: auto"
|
||||
@click="resetTemplatesToDefault"
|
||||
>
|
||||
{{ t('settings.emailNotify.resetBtn') }}
|
||||
{{ t("settings.emailNotify.resetBtn") }}
|
||||
</button>
|
||||
<button class="card-button" :disabled="savingEmail" @click="saveEmail">
|
||||
<span v-if="savingEmail">{{ t('settings.emailNotify.saving') }}</span>
|
||||
<span v-else>{{ t('settings.emailNotify.save') }}</span>
|
||||
<span v-if="savingEmail">{{ t("settings.emailNotify.saving") }}</span>
|
||||
<span v-else>{{ t("settings.emailNotify.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,18 +444,22 @@
|
||||
<template v-else-if="activeTab === 'telegramNotify'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">{{ t('settings.telegramNotify.title') }}</div>
|
||||
<div class="card-title">{{ t("settings.telegramNotify.title") }}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.telegramNotify.enable') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.telegramNotify.enable")
|
||||
}}</label>
|
||||
<label class="switch">
|
||||
<input v-model="telegramNotifyEnabled" type="checkbox" />
|
||||
<span class="slider" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.telegramNotify.botToken') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.telegramNotify.botToken")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="telegramBotToken"
|
||||
class="form-input"
|
||||
@@ -413,17 +467,25 @@
|
||||
placeholder="123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<div class="form-hint" v-html="t('settings.telegramNotify.botTokenHint')"></div>
|
||||
<div
|
||||
class="form-hint"
|
||||
v-html="t('settings.telegramNotify.botTokenHint')"
|
||||
></div>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label class="form-label">{{ t('settings.telegramNotify.chatId') }}</label>
|
||||
<label class="form-label">{{
|
||||
t("settings.telegramNotify.chatId")
|
||||
}}</label>
|
||||
<input
|
||||
v-model="telegramChatId"
|
||||
class="form-input"
|
||||
type="text"
|
||||
placeholder="123456789"
|
||||
/>
|
||||
<div class="form-hint" v-html="t('settings.telegramNotify.chatIdHint')"></div>
|
||||
<div
|
||||
class="form-hint"
|
||||
v-html="t('settings.telegramNotify.chatIdHint')"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions" style="justify-content: space-between">
|
||||
@@ -432,8 +494,10 @@
|
||||
:disabled="settingUpWebhook"
|
||||
@click="doSetupWebhook"
|
||||
>
|
||||
<span v-if="settingUpWebhook">{{ t('settings.telegramNotify.webhookSetting') }}</span>
|
||||
<span v-else>{{ t('settings.telegramNotify.webhookBtn') }}</span>
|
||||
<span v-if="settingUpWebhook">{{
|
||||
t("settings.telegramNotify.webhookSetting")
|
||||
}}</span>
|
||||
<span v-else>{{ t("settings.telegramNotify.webhookBtn") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="card-button secondary"
|
||||
@@ -441,16 +505,20 @@
|
||||
@click="testTelegram"
|
||||
style="margin-right: auto"
|
||||
>
|
||||
<span v-if="testingTelegram">{{ t('settings.telegramNotify.testingBtn') }}</span>
|
||||
<span v-else>{{ t('settings.telegramNotify.testBtn') }}</span>
|
||||
<span v-if="testingTelegram">{{
|
||||
t("settings.telegramNotify.testingBtn")
|
||||
}}</span>
|
||||
<span v-else>{{ t("settings.telegramNotify.testBtn") }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="card-button"
|
||||
:disabled="savingTelegram"
|
||||
@click="saveTelegram"
|
||||
>
|
||||
<span v-if="savingTelegram">{{ t('settings.telegramNotify.saving') }}</span>
|
||||
<span v-else>{{ t('settings.telegramNotify.save') }}</span>
|
||||
<span v-if="savingTelegram">{{
|
||||
t("settings.telegramNotify.saving")
|
||||
}}</span>
|
||||
<span v-else>{{ t("settings.telegramNotify.save") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -463,7 +531,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, type Ref, inject, watch } from "vue";
|
||||
import { onMounted, ref, inject, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
@@ -582,21 +650,21 @@ const settingUpWebhook = ref(false);
|
||||
const testingTelegram = ref(false);
|
||||
|
||||
const languageOptions = [
|
||||
{ value: 'en-US', label: 'English' },
|
||||
{ value: 'zh-CN', label: '简体中文' },
|
||||
{ value: 'zh-TW', label: '繁體中文' },
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'ja', label: '日本語' },
|
||||
{ value: 'ko', label: '한국어' },
|
||||
{ value: 'ru', label: 'Русский' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
{ value: 'nl', label: 'Nederlands' },
|
||||
{ value: 'ar', label: 'العربية' },
|
||||
{ value: 'hi', label: 'हिन्दी' },
|
||||
{ value: 'id', label: 'Bahasa Indonesia' },
|
||||
{ value: "en-US", label: "English" },
|
||||
{ value: "zh-CN", label: "简体中文" },
|
||||
{ value: "zh-TW", label: "繁體中文" },
|
||||
{ value: "es", label: "Español" },
|
||||
{ value: "pt", label: "Português" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "ja", label: "日本語" },
|
||||
{ value: "ko", label: "한국어" },
|
||||
{ value: "ru", label: "Русский" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
{ value: "nl", label: "Nederlands" },
|
||||
{ value: "ar", label: "العربية" },
|
||||
{ value: "hi", label: "हिन्दी" },
|
||||
{ value: "id", label: "Bahasa Indonesia" },
|
||||
];
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -606,12 +674,7 @@ const widgetLanguage = ref("auto");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
type TabKey =
|
||||
| "comment"
|
||||
| "feature"
|
||||
| "display"
|
||||
| "emailNotify"
|
||||
| "telegramNotify";
|
||||
type TabKey = "comment" | "feature" | "display" | "emailNotify" | "telegramNotify";
|
||||
const validTabs: TabKey[] = [
|
||||
"comment",
|
||||
"feature",
|
||||
@@ -621,9 +684,7 @@ const validTabs: TabKey[] = [
|
||||
];
|
||||
|
||||
const activeTab = ref<TabKey>(
|
||||
validTabs.includes(route.query.tab as TabKey)
|
||||
? (route.query.tab as TabKey)
|
||||
: "comment",
|
||||
validTabs.includes(route.query.tab as TabKey) ? (route.query.tab as TabKey) : "comment"
|
||||
);
|
||||
|
||||
watch(activeTab, (newTab) => {
|
||||
@@ -636,7 +697,7 @@ watch(
|
||||
if (newTab && validTabs.includes(newTab as TabKey)) {
|
||||
activeTab.value = newTab as TabKey;
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const savingEmail = ref(false);
|
||||
@@ -667,9 +728,15 @@ function loadCardsExpanded() {
|
||||
site: parsed.site ?? false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return { comment: true, display: false, feature: false, email: false, telegram: false, site: false };
|
||||
} catch {}
|
||||
return {
|
||||
comment: true,
|
||||
display: false,
|
||||
feature: false,
|
||||
email: false,
|
||||
telegram: false,
|
||||
site: false,
|
||||
};
|
||||
}
|
||||
|
||||
const cardsExpanded = ref(loadCardsExpanded());
|
||||
@@ -725,7 +792,13 @@ function resetTemplatesToDefault() {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [commentRes, emailNotifyRes, featureRes, telegramRes, displayRes] = await Promise.all([
|
||||
const [
|
||||
commentRes,
|
||||
emailNotifyRes,
|
||||
featureRes,
|
||||
telegramRes,
|
||||
displayRes,
|
||||
] = await Promise.all([
|
||||
fetchCommentSettings(),
|
||||
fetchEmailNotifySettings(),
|
||||
fetchFeatureSettings(),
|
||||
@@ -758,11 +831,11 @@ async function load() {
|
||||
commentPlaceholder.value = featureRes.commentPlaceholder || "";
|
||||
adminLanguage.value = featureRes.adminLanguage || "zh-CN";
|
||||
widgetLanguage.value = featureRes.widgetLanguage || "auto";
|
||||
|
||||
|
||||
// Sync locale
|
||||
if (featureRes.adminLanguage) {
|
||||
locale.value = featureRes.adminLanguage;
|
||||
localStorage.setItem('admin_language', featureRes.adminLanguage);
|
||||
localStorage.setItem("admin_language", featureRes.adminLanguage);
|
||||
}
|
||||
|
||||
telegramBotToken.value = telegramRes.botToken || "";
|
||||
@@ -834,7 +907,7 @@ async function testEmail() {
|
||||
message.value = "请先在上方“评论显示配置”中设置管理员邮箱";
|
||||
messageType.value = "error";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!smtpUser.value || !smtpPass.value) {
|
||||
message.value = "请先填写 SMTP 账号和密码";
|
||||
messageType.value = "error";
|
||||
@@ -894,9 +967,9 @@ async function saveComment() {
|
||||
}),
|
||||
]);
|
||||
|
||||
showToast(commentRes.message || t('common.saveSuccess'), "success");
|
||||
showToast(commentRes.message || t("common.saveSuccess"), "success");
|
||||
} catch (e: any) {
|
||||
showToast(e.message || t('common.saveFailed'), "error");
|
||||
showToast(e.message || t("common.saveFailed"), "error");
|
||||
} finally {
|
||||
savingComment.value = false;
|
||||
}
|
||||
@@ -916,9 +989,9 @@ async function saveFeature() {
|
||||
}),
|
||||
]);
|
||||
|
||||
showToast(featureRes.message || t('common.saveSuccess'), "success");
|
||||
showToast(featureRes.message || t("common.saveSuccess"), "success");
|
||||
} catch (e: any) {
|
||||
showToast(e.message || t('common.saveFailed'), "error");
|
||||
showToast(e.message || t("common.saveFailed"), "error");
|
||||
} finally {
|
||||
savingFeature.value = false;
|
||||
}
|
||||
@@ -933,19 +1006,19 @@ async function saveDisplay() {
|
||||
const res = await saveAdminDisplaySettings({
|
||||
layoutTitle: adminLayoutTitle.value,
|
||||
});
|
||||
|
||||
|
||||
// Also save admin language to feature settings as it's a global preference
|
||||
await saveFeatureSettings({
|
||||
adminLanguage: adminLanguage.value
|
||||
adminLanguage: adminLanguage.value,
|
||||
});
|
||||
|
||||
if (updateSiteTitle) {
|
||||
updateSiteTitle(adminLayoutTitle.value);
|
||||
}
|
||||
|
||||
|
||||
// Update local locale
|
||||
locale.value = adminLanguage.value;
|
||||
localStorage.setItem('admin_language', adminLanguage.value);
|
||||
localStorage.setItem("admin_language", adminLanguage.value);
|
||||
|
||||
showToast(res.message || "保存成功", "success");
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div style="display: flex; align-items: center; gap: 20px">
|
||||
<h2 class="page-title">{{ t('stats.title') }}</h2>
|
||||
<h2 class="page-title">{{ t("stats.title") }}</h2>
|
||||
</div>
|
||||
<div
|
||||
v-if="toastVisible"
|
||||
@@ -13,28 +13,28 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('stats.overview') }}</h3>
|
||||
<h3 class="card-title">{{ t("stats.overview") }}</h3>
|
||||
</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div v-else>
|
||||
<div class="stats-grid">
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('stats.total') }}</div>
|
||||
<div class="stats-label">{{ t("stats.total") }}</div>
|
||||
<div class="stats-value">{{ statsSummary.total }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('stats.approved') }}</div>
|
||||
<div class="stats-label">{{ t("stats.approved") }}</div>
|
||||
<div class="stats-value stats-value-approved">
|
||||
{{ statsSummary.approved }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('stats.pending') }}</div>
|
||||
<div class="stats-label">{{ t("stats.pending") }}</div>
|
||||
<div class="stats-value stats-value-pending">{{ statsSummary.pending }}</div>
|
||||
</div>
|
||||
<div class="stats-item">
|
||||
<div class="stats-label">{{ t('stats.rejected') }}</div>
|
||||
<div class="stats-label">{{ t("stats.rejected") }}</div>
|
||||
<div class="stats-value stats-value-rejected">
|
||||
{{ statsSummary.rejected }}
|
||||
</div>
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('stats.trend') }}</h3>
|
||||
<h3 class="card-title">{{ t("stats.trend") }}</h3>
|
||||
<div class="chart-tabs">
|
||||
<button
|
||||
class="chart-tab"
|
||||
@@ -53,7 +53,7 @@
|
||||
type="button"
|
||||
@click="changeChartRange('7')"
|
||||
>
|
||||
{{ t('stats.last7Days') }}
|
||||
{{ t("stats.last7Days") }}
|
||||
</button>
|
||||
<button
|
||||
class="chart-tab"
|
||||
@@ -61,11 +61,11 @@
|
||||
type="button"
|
||||
@click="changeChartRange('30')"
|
||||
>
|
||||
{{ t('stats.last30Days') }}
|
||||
{{ t("stats.last30Days") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div class="chart-wrapper">
|
||||
<div ref="chartEl" class="chart"></div>
|
||||
@@ -74,26 +74,26 @@
|
||||
|
||||
<div class="card">
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{{ t('stats.bySite') }}</h3>
|
||||
<h3 class="card-title">{{ t("stats.bySite") }}</h3>
|
||||
</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t('common.loading') }}</div>
|
||||
<div v-if="statsLoading" class="page-hint">{{ t("common.loading") }}</div>
|
||||
<div v-else-if="statsError" class="page-error">{{ statsError }}</div>
|
||||
<div v-else-if="domainStats.length === 0" class="page-hint">{{ t('stats.noData') }}</div>
|
||||
<div v-else-if="domainStats.length === 0" class="page-hint">
|
||||
{{ t("stats.noData") }}
|
||||
</div>
|
||||
<div v-else class="domain-stats-layout">
|
||||
<div class="domain-table-wrapper">
|
||||
<div class="domain-table">
|
||||
<div class="domain-table-header">
|
||||
<div class="domain-cell domain-cell-domain">{{ t('stats.table.domain') }}</div>
|
||||
<div class="domain-cell">{{ t('stats.table.total') }}</div>
|
||||
<div class="domain-cell">{{ t('stats.table.approved') }}</div>
|
||||
<div class="domain-cell">{{ t('stats.table.pending') }}</div>
|
||||
<div class="domain-cell">{{ t('stats.table.rejected') }}</div>
|
||||
<div class="domain-cell domain-cell-domain">
|
||||
{{ t("stats.table.domain") }}
|
||||
</div>
|
||||
<div class="domain-cell">{{ t("stats.table.total") }}</div>
|
||||
<div class="domain-cell">{{ t("stats.table.approved") }}</div>
|
||||
<div class="domain-cell">{{ t("stats.table.pending") }}</div>
|
||||
<div class="domain-cell">{{ t("stats.table.rejected") }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-for="item in domainStats"
|
||||
:key="item.domain"
|
||||
class="domain-table-row"
|
||||
>
|
||||
<div v-for="item in domainStats" :key="item.domain" class="domain-table-row">
|
||||
<div class="domain-cell domain-cell-domain">{{ item.domain }}</div>
|
||||
<div class="domain-cell">{{ item.total }}</div>
|
||||
<div class="domain-cell">{{ item.approved }}</div>
|
||||
@@ -111,8 +111,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch, inject } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { onMounted, onBeforeUnmount, ref, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import * as echarts from "echarts";
|
||||
import { fetchCommentStats } from "../../api/admin";
|
||||
@@ -161,8 +160,7 @@ function loadChartRangeFromStorage() {
|
||||
if (value === "7" || value === "30") {
|
||||
chartRange.value = value;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function saveChartRangeToStorage(value: "7" | "30") {
|
||||
@@ -171,8 +169,7 @@ function saveChartRangeToStorage(value: "7" | "30") {
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(chartRangeStorageKey, value);
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function showToast(msg: string, type: "success" | "error" = "success") {
|
||||
@@ -220,8 +217,7 @@ function renderChart() {
|
||||
chartInstance = echarts.init(el);
|
||||
}
|
||||
const source = last7Days.value;
|
||||
const seriesData =
|
||||
chartRange.value === "7" ? source.slice(-7) : source;
|
||||
const seriesData = chartRange.value === "7" ? source.slice(-7) : source;
|
||||
const dates = seriesData.map((item) => item.date.slice(5));
|
||||
const values = seriesData.map((item) => item.total);
|
||||
const option: echarts.EChartsOption = {
|
||||
|
||||
Reference in New Issue
Block a user