feat(admin): 添加后台域名可见性管理功能
在后台设置页面新增域名管理选项卡,允许管理员配置后台下拉框中显示的域名列表。通过拖拽或点击按钮可以移动域名,实现灵活的可视域名控制。当配置了可见域名时,LayoutView 将只显示指定的域名,否则显示全部域名。
This commit is contained in:
@@ -131,6 +131,7 @@ export type FeatureSettingsResponse = {
|
||||
enableCommentLike: boolean;
|
||||
enableArticleLike: boolean;
|
||||
commentPlaceholder?: string;
|
||||
visibleDomains?: string[];
|
||||
};
|
||||
|
||||
export type AdminDisplaySettingsResponse = {
|
||||
@@ -332,7 +333,7 @@ export function fetchFeatureSettings(): Promise<FeatureSettingsResponse> {
|
||||
return get<FeatureSettingsResponse>('/admin/settings/features');
|
||||
}
|
||||
|
||||
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; commentPlaceholder?: string }): Promise<{ message: string }> {
|
||||
export function saveFeatureSettings(data: { enableCommentLike?: boolean; enableArticleLike?: boolean; commentPlaceholder?: string; visibleDomains?: string[] }): Promise<{ message: string }> {
|
||||
return put<{ message: string }>('/admin/settings/features', data);
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, provide, computed } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import { logoutAdmin, fetchDomainList, fetchAdminDisplaySettings } from "../../api/admin";
|
||||
import { logoutAdmin, fetchDomainList, fetchAdminDisplaySettings, fetchFeatureSettings } from "../../api/admin";
|
||||
import { useTheme } from "../../composables/useTheme";
|
||||
import packageJson from "../../../package.json";
|
||||
|
||||
@@ -215,8 +215,18 @@ const domainOptions = ref<string[]>([]);
|
||||
|
||||
async function loadDomains() {
|
||||
try {
|
||||
const res = await fetchDomainList();
|
||||
const domains = Array.isArray(res.domains) ? res.domains : [];
|
||||
const [domainRes, settingsRes] = await Promise.all([
|
||||
fetchDomainList(),
|
||||
fetchFeatureSettings().catch(() => ({ visibleDomains: undefined }))
|
||||
]);
|
||||
|
||||
let domains = Array.isArray(domainRes.domains) ? domainRes.domains : [];
|
||||
|
||||
// 如果配置了显示域名,则仅显示配置的域名
|
||||
if (settingsRes.visibleDomains && Array.isArray(settingsRes.visibleDomains) && settingsRes.visibleDomains.length > 0) {
|
||||
domains = settingsRes.visibleDomains;
|
||||
}
|
||||
|
||||
const set = new Set(domains);
|
||||
if (domainFilter.value && !set.has(domainFilter.value)) {
|
||||
set.add(domainFilter.value);
|
||||
|
||||
342
cwd-admin/src/views/SettingsView/components/DomainSettings.vue
Normal file
342
cwd-admin/src/views/SettingsView/components/DomainSettings.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<div class="domain-settings">
|
||||
<div class="domain-settings-desc">
|
||||
配置后台可见的域名。左侧为后台下拉框中显示的域名,右侧为数据库中发现的所有域名。支持拖拽或点击按钮移动。
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div
|
||||
v-for="domain in visibleList"
|
||||
:key="domain"
|
||||
class="transfer-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, domain, 'visible')"
|
||||
@dblclick="moveToHidden(domain)"
|
||||
>
|
||||
<span class="domain-text">{{ domain }}</span>
|
||||
<button class="move-btn" @click="moveToHidden(domain)" title="移出">
|
||||
<PhArrowRight :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="transfer-actions">
|
||||
<button class="action-btn" @click="moveAllToVisible" title="全部左移">
|
||||
<PhChecks :size="20" />
|
||||
</button>
|
||||
<button class="action-btn" @click="moveAllToHidden" title="全部右移">
|
||||
<PhTrash :size="20" />
|
||||
</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
|
||||
v-for="domain in hiddenList"
|
||||
:key="domain"
|
||||
class="transfer-item"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, domain, 'hidden')"
|
||||
@dblclick="moveToVisible(domain)"
|
||||
>
|
||||
<button class="move-btn" @click="moveToVisible(domain)" title="移入">
|
||||
<PhArrowLeft :size="16" />
|
||||
</button>
|
||||
<span class="domain-text">{{ domain }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" @click="handleSave" :disabled="loading">
|
||||
{{ loading ? "保存中..." : "保存" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { fetchDomainList, fetchFeatureSettings, saveFeatureSettings } from '../../../api/admin';
|
||||
import { PhArrowRight, PhArrowLeft, PhChecks, PhTrash } from '@phosphor-icons/vue';
|
||||
|
||||
const loading = ref(false);
|
||||
const allDomains = ref<string[]>([]);
|
||||
const visibleList = ref<string[]>([]);
|
||||
|
||||
const hiddenList = computed(() => {
|
||||
const visibleSet = new Set(visibleList.value);
|
||||
return allDomains.value.filter(d => !visibleSet.has(d));
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const [domainRes, settingsRes] = await Promise.all([
|
||||
fetchDomainList(),
|
||||
fetchFeatureSettings()
|
||||
]);
|
||||
|
||||
allDomains.value = domainRes.domains || [];
|
||||
if (settingsRes.visibleDomains) {
|
||||
visibleList.value = settingsRes.visibleDomains;
|
||||
} else {
|
||||
visibleList.value = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function moveToHidden(domain: string) {
|
||||
visibleList.value = visibleList.value.filter(d => d !== domain);
|
||||
}
|
||||
|
||||
function moveToVisible(domain: string) {
|
||||
if (!visibleList.value.includes(domain)) {
|
||||
visibleList.value.push(domain);
|
||||
}
|
||||
}
|
||||
|
||||
function moveAllToVisible() {
|
||||
const current = new Set(visibleList.value);
|
||||
hiddenList.value.forEach(d => current.add(d));
|
||||
visibleList.value = Array.from(current);
|
||||
}
|
||||
|
||||
function moveAllToHidden() {
|
||||
visibleList.value = [];
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
moveToVisible(domain);
|
||||
} else {
|
||||
moveToHidden(domain);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await saveFeatureSettings({
|
||||
visibleDomains: visibleList.value
|
||||
});
|
||||
// 保存成功后刷新页面以应用更改(LayoutView 重新加载)
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
alert('保存失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.domain-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.domain-settings-desc {
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.domain-transfer {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
height: 400px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: column;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-panel {
|
||||
flex: 1;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--bg-card);
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.transfer-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-weight: 600;
|
||||
background: var(--bg-body);
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.transfer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.transfer-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 40px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.transfer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-body);
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
color: var(--text-primary);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.domain-text {
|
||||
flex: 1;
|
||||
margin: 0 10px;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.move-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-active);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.transfer-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-self: center;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 8px 24px;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -35,6 +35,14 @@
|
||||
>
|
||||
后台显示
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-tab"
|
||||
:class="{ 'settings-tab-active': activeTab === 'domain' }"
|
||||
@click="activeTab = 'domain'"
|
||||
>
|
||||
域名管理
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-tab"
|
||||
@@ -280,6 +288,16 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab === 'domain'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">域名选择管理</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<DomainSettings />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="activeTab === 'emailNotify'">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
@@ -527,6 +545,8 @@ import {
|
||||
sendTelegramTestMessage,
|
||||
} from "../../api/admin";
|
||||
|
||||
import DomainSettings from "./components/DomainSettings.vue";
|
||||
|
||||
const DEFAULT_REPLY_TEMPLATE = `<div style="background-color:#f4f4f5;padding:24px 0;">
|
||||
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:12px;border:1px solid #e5e7eb;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;color:#111827;">
|
||||
<div style="padding:20px 28px;border-bottom:1px solid #e5e7eb;background:linear-gradient(135deg,#2563eb,#4f46e5);">
|
||||
@@ -627,7 +647,7 @@ const savingTelegram = ref(false);
|
||||
const settingUpWebhook = ref(false);
|
||||
const testingTelegram = ref(false);
|
||||
|
||||
const activeTab = ref<"comment" | "feature" | "display" | "emailNotify" | "telegramNotify">(
|
||||
const activeTab = ref<"comment" | "feature" | "display" | "emailNotify" | "telegramNotify" | "domain">(
|
||||
"comment",
|
||||
);
|
||||
|
||||
|
||||
@@ -30,10 +30,15 @@ export const updateFeatureSettings = async (c: Context<{ Bindings: Bindings }>)
|
||||
const commentPlaceholder =
|
||||
rawCommentPlaceholder !== undefined ? rawCommentPlaceholder.trim() : undefined;
|
||||
|
||||
const visibleDomains = Array.isArray(body.visibleDomains)
|
||||
? (body.visibleDomains as string[])
|
||||
: undefined;
|
||||
|
||||
await saveFeatureSettings(c.env, {
|
||||
enableCommentLike,
|
||||
enableArticleLike,
|
||||
commentPlaceholder
|
||||
commentPlaceholder,
|
||||
visibleDomains
|
||||
});
|
||||
|
||||
return c.json({ message: '保存成功!' });
|
||||
|
||||
@@ -3,11 +3,13 @@ import { Bindings } from '../bindings';
|
||||
export const FEATURE_COMMENT_LIKE_KEY = 'comment_feature_comment_like';
|
||||
export const FEATURE_ARTICLE_LIKE_KEY = 'comment_feature_article_like';
|
||||
export const FEATURE_COMMENT_PLACEHOLDER_KEY = 'comment_feature_placeholder';
|
||||
export const FEATURE_VISIBLE_DOMAINS_KEY = 'admin_visible_domains';
|
||||
|
||||
export type FeatureSettings = {
|
||||
enableCommentLike: boolean;
|
||||
enableArticleLike: boolean;
|
||||
commentPlaceholder?: string;
|
||||
visibleDomains?: string[];
|
||||
};
|
||||
|
||||
export async function loadFeatureSettings(env: Bindings): Promise<FeatureSettings> {
|
||||
@@ -15,9 +17,14 @@ export async function loadFeatureSettings(env: Bindings): Promise<FeatureSetting
|
||||
'CREATE TABLE IF NOT EXISTS Settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const keys = [FEATURE_COMMENT_LIKE_KEY, FEATURE_ARTICLE_LIKE_KEY, FEATURE_COMMENT_PLACEHOLDER_KEY];
|
||||
const keys = [
|
||||
FEATURE_COMMENT_LIKE_KEY,
|
||||
FEATURE_ARTICLE_LIKE_KEY,
|
||||
FEATURE_COMMENT_PLACEHOLDER_KEY,
|
||||
FEATURE_VISIBLE_DOMAINS_KEY
|
||||
];
|
||||
const { results } = await env.CWD_DB.prepare(
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?)'
|
||||
'SELECT key, value FROM Settings WHERE key IN (?, ?, ?, ?)'
|
||||
)
|
||||
.bind(...keys)
|
||||
.all<{ key: string; value: string }>();
|
||||
@@ -51,10 +58,21 @@ export async function loadFeatureSettings(env: Bindings): Promise<FeatureSetting
|
||||
|
||||
const commentPlaceholder = map.get(FEATURE_COMMENT_PLACEHOLDER_KEY);
|
||||
|
||||
let visibleDomains: string[] | undefined;
|
||||
const visibleDomainsRaw = map.get(FEATURE_VISIBLE_DOMAINS_KEY);
|
||||
if (visibleDomainsRaw) {
|
||||
try {
|
||||
visibleDomains = JSON.parse(visibleDomainsRaw);
|
||||
} catch (e) {
|
||||
// ignore error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enableCommentLike,
|
||||
enableArticleLike,
|
||||
commentPlaceholder
|
||||
commentPlaceholder,
|
||||
visibleDomains
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,6 +106,10 @@ export async function saveFeatureSettings(
|
||||
{
|
||||
key: FEATURE_COMMENT_PLACEHOLDER_KEY,
|
||||
value: settings.commentPlaceholder
|
||||
},
|
||||
{
|
||||
key: FEATURE_VISIBLE_DOMAINS_KEY,
|
||||
value: settings.visibleDomains ? JSON.stringify(settings.visibleDomains) : undefined
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user