feat(admin): 添加后台域名可见性管理功能

在后台设置页面新增域名管理选项卡,允许管理员配置后台下拉框中显示的域名列表。通过拖拽或点击按钮可以移动域名,实现灵活的可视域名控制。当配置了可见域名时,LayoutView 将只显示指定的域名,否则显示全部域名。
This commit is contained in:
anghunk
2026-02-05 17:25:09 +08:00
parent f2e9160638
commit c580aec52d
6 changed files with 409 additions and 9 deletions

View File

@@ -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);
}

View File

@@ -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);

View 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>

View File

@@ -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",
);

View File

@@ -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: '保存成功!' });

View File

@@ -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
}
];