Files
cwd/cwd-admin/src/views/LayoutView/index.vue
anghunk 098d668a15 feat: 实现多站点数据隔离和管理功能
- 新增站点管理功能,替换原有的域名管理
- 添加数据库迁移工具,为现有表增加 site_id 字段
- 创建 useSite 组合式 API 统一管理站点状态
- 修改前后端 API 以支持站点筛选参数
- 更新管理界面,将域名筛选改为站点筛选
- 优化数据导出功能,支持按站点筛选导出
- 更新文档,调整站点 ID 默认值为空字符串
- 升级 wrangler 依赖至最新版本
2026-02-09 15:31:47 +08:00

445 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="layout">
<header class="layout-header">
<button
class="layout-menu-toggle"
@click="toggleSider"
aria-label="切换菜单"
type="button"
>
<PhTextIndent :size="20" />
</button>
<div class="layout-title">{{ layoutTitle }}</div>
<div class="layout-actions-wrapper">
<div class="layout-domain-filter layout-domain-filter-header">
<select v-model="currentSiteId" class="layout-domain-select">
<option value="default">所有站点</option>
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</div>
<div class="layout-actions">
<a class="layout-button" href="https://cwd.js.org" target="_blank">
使用文档
</a>
<a class="layout-button" href="https://github.com/anghunk/cwd" target="_blank">
Github
</a>
<button
class="layout-button"
@click="cycleTheme"
:title="themeTitle"
type="button"
>
<PhSun v-if="theme === 'light'" :size="16" />
<PhMoon v-else-if="theme === 'dark'" :size="16" />
<PhAirplay v-else :size="16" />
</button>
<button class="layout-button" @click="handleLogout">退出</button>
</div>
<button
class="layout-actions-toggle"
@click="toggleActions"
aria-label="更多操作"
type="button"
>
<PhDotsThreeVertical :size="20" bold />
</button>
<div v-if="isActionsOpen" class="layout-actions-dropdown">
<button class="layout-actions-item" type="button" @click="openDocs">
使用文档
</button>
<button class="layout-actions-item" type="button" @click="openGithub">
Github
</button>
<button
class="layout-actions-item layout-actions-item-danger"
type="button"
@click="handleLogoutFromActions"
>
退出
</button>
</div>
</div>
</header>
<div class="layout-body">
<nav
class="layout-sider"
:class="{ 'layout-sider-mobile-open': isMobileSiderOpen }"
>
<div class="layout-sider-domain-filter">
<select v-model="currentSiteId" class="layout-domain-select">
<option value="default">所有站点</option>
<option v-for="item in siteOptions" :key="item.value" :value="item.value">
{{ item.label }}
</option>
</select>
</div>
<ul class="menu">
<li
class="menu-item"
:class="{ active: isRouteActive('comments') }"
@click="goComments"
>
<PhChatCircleDots class="menu-item-icon" :size="18" />
<span>评论管理</span>
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('stats') }"
@click="goStats"
>
<PhSquaresFour class="menu-item-icon" :size="18" />
<span>数据看板</span>
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('analytics') }"
@click="goAnalytics"
>
<PhChartBar class="menu-item-icon" :size="18" />
<span>访问统计</span>
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('settings') }"
@click="goSettings"
>
<PhGear class="menu-item-icon" :size="18" />
<span>网站设置</span>
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('data') }"
@click="goData"
>
<PhDatabase class="menu-item-icon" :size="18" />
<span>数据管理</span>
</li>
</ul>
<div class="layout-sider-footer" @click="openVersionModal">
<div class="layout-sider-footer-line">
<span>API {{ apiVersion }}</span>
</div>
<div class="layout-sider-footer-line">Admin {{ adminVersion }}</div>
</div>
</nav>
<div v-if="isMobileSiderOpen" class="layout-sider-mask" @click="closeSider" />
<main class="layout-content">
<router-view />
</main>
</div>
<div v-if="versionModalVisible" class="modal-overlay" @click.self="closeVersionModal">
<div class="modal">
<h3 class="modal-title">版本信息</h3>
<div class="modal-body">
<p class="modal-row">
<span class="modal-label">API 地址</span>
<span class="modal-value">{{ checkedApiBaseUrl || "未配置" }}</span>
</p>
<p class="modal-row">
<span class="modal-label">接口版本</span>
<span class="modal-value">
{{ apiVersion || (apiVersionError ? "未获取到" : "加载中...") }}
</span>
</p>
<p class="modal-row">
<span class="modal-label">后台版本</span>
<span class="modal-value">{{ adminVersion }}</span>
</p>
<p v-if="apiVersion && apiVersion === adminVersion" class="modal-status">
当前后台与接口版本一致可以正常使用
</p>
<p v-else-if="apiVersion && apiVersion !== adminVersion" class="modal-status">
当前后台与接口版本不一致推荐将 API 服务更新到与后台版本一致
以避免潜在的兼容性问题
</p>
<p v-else-if="apiVersionError" class="modal-status">
无法获取接口版本{{ apiVersionError }}
</p>
</div>
<div class="modal-actions">
<button class="modal-btn" type="button" @click="closeVersionModal">
我知道了
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, provide, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { logoutAdmin, fetchSiteList, fetchAdminDisplaySettings } from "../../api/admin";
import { useTheme } from "../../composables/useTheme";
import { useSite } from "../../composables/useSite";
import packageJson from "../../../package.json";
const API_BASE_URL_KEY = "cwd_admin_api_base_url";
const SITE_TITLE_KEY = "cwd_admin_site_title";
const router = useRouter();
const route = useRoute();
const { theme, setTheme } = useTheme();
const { currentSiteId } = useSite();
const isMobileSiderOpen = ref(false);
const isActionsOpen = ref(false);
const adminVersion = ref(packageJson.version || "0.0.0");
const apiVersion = ref("");
const checkedApiBaseUrl = ref("");
const apiVersionError = ref("");
const versionModalVisible = ref(false);
const layoutTitle = ref(localStorage.getItem(SITE_TITLE_KEY) || "CWD 评论系统");
const themeTitle = computed(() => {
if (theme.value === "light") return "明亮模式";
if (theme.value === "dark") return "暗黑模式";
return "跟随系统";
});
function cycleTheme() {
if (theme.value === "system") setTheme("light");
else if (theme.value === "light") setTheme("dark");
else setTheme("system");
}
type SiteOption = { label: string; value: string };
const siteOptions = ref<SiteOption[]>([]);
async function loadSites() {
try {
const res = await fetchSiteList();
const sites = Array.isArray(res.sites) ? res.sites : [];
siteOptions.value = sites.map(s => ({
label: s === '' ? '默认站点 (Default)' : s,
value: s
}));
} catch {
siteOptions.value = [];
}
}
async function loadVersion() {
const baseUrl = (localStorage.getItem(API_BASE_URL_KEY) || "").trim();
if (!baseUrl) {
checkedApiBaseUrl.value = "";
apiVersionError.value = "";
return;
}
checkedApiBaseUrl.value = baseUrl;
apiVersionError.value = "";
try {
const res = await fetch(baseUrl);
const contentType = res.headers.get("content-type") || "";
if (!res.ok || !contentType.includes("application/json")) {
apiVersionError.value =
"当前 API 版本较旧,未提供版本信息接口。推荐后续升级到最新版本以获得完整的版本检测能力(不影响当前使用)。";
return;
}
const data = await res.json().catch(() => null);
if (data && typeof data.version === "string") {
apiVersion.value = data.version;
} else {
apiVersionError.value =
"当前 API 版本较旧,未提供版本信息接口。推荐后续升级到最新版本以获得完整的版本检测能力(不影响当前使用)。";
}
} catch (e) {
apiVersionError.value = (e as Error).message || "获取接口版本失败";
}
}
function updateTitle(newTitle: string) {
layoutTitle.value = newTitle;
localStorage.setItem(SITE_TITLE_KEY, newTitle);
const pageTitle = route.meta.title;
if (pageTitle) {
document.title = `${pageTitle} - ${newTitle}`;
} else {
document.title = newTitle;
}
}
async function loadDisplaySettings() {
try {
const res = await fetchAdminDisplaySettings();
const title = res.layoutTitle || "CWD 评论系统";
updateTitle(title);
} catch {
// 忽略错误,保持当前值或默认值
}
}
provide("updateSiteTitle", updateTitle);
onMounted(() => {
loadSites();
loadVersion();
loadDisplaySettings();
});
function isRouteActive(name: string) {
return route.name === name;
}
function closeSider() {
isMobileSiderOpen.value = false;
}
function toggleSider() {
isMobileSiderOpen.value = !isMobileSiderOpen.value;
}
function toggleActions() {
isActionsOpen.value = !isActionsOpen.value;
}
function closeActions() {
isActionsOpen.value = false;
}
function goComments() {
router.push({ name: "comments" });
closeSider();
}
function goStats() {
router.push({ name: "stats" });
closeSider();
}
function goAnalytics() {
router.push({ name: "analytics" });
closeSider();
}
function goData() {
router.push({ name: "data" });
closeSider();
}
function goSettings() {
router.push({ name: "settings" });
closeSider();
}
function openDocs() {
window.open("https://cwd.js.org", "_blank");
closeActions();
}
function openGithub() {
window.open("https://github.com/anghunk/cwd", "_blank");
closeActions();
}
function handleLogout() {
logoutAdmin();
router.push({ name: "login" });
closeSider();
}
function handleLogoutFromActions() {
closeActions();
handleLogout();
}
function openVersionModal() {
loadVersion();
versionModalVisible.value = true;
}
function closeVersionModal() {
versionModalVisible.value = false;
}
</script>
<style lang="less">
@import "../../styles/layout.less";
.modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal {
background-color: var(--bg-card);
border-radius: 10px;
max-width: 420px;
width: 100%;
margin: 10px;
padding: 20px 20px 18px;
box-shadow: var(--shadow-card);
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.modal-body {
display: flex;
flex-direction: column;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
}
.modal-row {
margin: 0;
display: flex;
justify-content: space-between;
gap: 10px;
}
.modal-label {
flex: 0 0 auto;
}
.modal-value {
flex: 1 1 auto;
text-align: right;
word-break: break-all;
}
.modal-status {
margin: 4px 0 0;
font-size: 13px;
color: var(--text-primary);
}
.modal-actions {
display: flex;
justify-content: flex-end;
margin-top: 6px;
}
.modal-btn {
padding: 8px 16px;
border-radius: 999px;
border: none;
font-size: 14px;
cursor: pointer;
background-color: var(--primary-color);
color: var(--text-inverse);
}
.modal-btn:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
</style>