Files
cwd/cwd-admin/src/views/LayoutView.vue
anghunk e89aa7419d docs: 将“数据迁移”统一更名为“数据管理”
更新路由、侧边栏、页面标题及文档中的相关术语,以更准确地反映该模块的实际功能。数据导出和导入功能现在明确标注为“评论数据导出”和“评论数据导入”。
2026-01-29 17:53:16 +08:00

559 lines
13 KiB
Vue

<template>
<div class="layout">
<header class="layout-header">
<button
class="layout-menu-toggle"
@click="toggleSider"
aria-label="切换菜单"
type="button"
>
<svg class="layout-menu-icon" viewBox="0 0 24 24" width="18" height="18">
<path
d="M4 7h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2zm0 6h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2zm0 6h16a1 1 0 0 0 0-2H4a1 1 0 0 0 0 2z"
fill="currentColor"
/>
</svg>
</button>
<div class="layout-title">CWD 评论系统</div>
<div class="layout-actions-wrapper">
<div class="layout-domain-filter layout-domain-filter-header">
<select v-model="domainFilter" class="layout-domain-select">
<option value="">全部域名</option>
<option v-for="item in domainOptions" :key="item" :value="item">
{{ item }}
</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"
>
<svg v-if="theme === 'light'" viewBox="0 0 24 24" width="16" height="16">
<path
d="M12 18a6 6 0 1 0 0-12 6 6 0 0 0 0 12zM11 1h2v3h-2V1zm0 19h2v3h-2v-3zM3.515 4.929l1.414-1.414L7.05 5.636 5.636 7.05 3.515 4.93zM16.95 18.364l1.414-1.414 2.121 2.121-1.414 1.414-2.121-2.121zm2.121-14.85l1.414 1.415-2.121 2.121-1.414-1.414 2.121-2.121zM5.636 16.95l1.414 1.414-2.121 2.121-1.414-1.414 2.121-2.121zM23 11v2h-3v-2h3zM4 11v2H1v-2h3z"
fill="currentColor"
/>
</svg>
<svg v-else-if="theme === 'dark'" viewBox="0 0 24 24" width="16" height="16">
<path
d="M20 12.986c-.52.095-1.056.15-1.6.15-5.238 0-9.486-4.248-9.486-9.486 0-.544.055-1.08.15-1.6-5.275.986-9.264 5.615-9.264 11.123 0 6.255 5.07 11.325 11.325 11.325 5.508 0 10.137-3.989 11.123-9.264a9.66 9.66 0 0 1-2.248.752z"
fill="currentColor"
/>
</svg>
<svg v-else viewBox="0 0 24 24" width="16" height="16">
<path
d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6zM6 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H6zm-3 14a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v1a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-1z"
fill="currentColor"
/>
</svg>
</button>
<button class="layout-button" @click="handleLogout">退出</button>
</div>
<button
class="layout-actions-toggle"
@click="toggleActions"
aria-label="更多操作"
type="button"
>
<svg class="layout-actions-icon" viewBox="0 0 24 24" width="18" height="18">
<path
d="M12 5.5a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zm0 8a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5zm0 8a1.75 1.75 0 1 1 0-3.5 1.75 1.75 0 0 1 0 3.5z"
fill="currentColor"
/>
</svg>
</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="domainFilter" class="layout-domain-select">
<option value="">全部域名</option>
<option v-for="item in domainOptions" :key="item" :value="item">
{{ item }}
</option>
</select>
</div>
<ul class="menu">
<li
class="menu-item"
:class="{ active: isRouteActive('comments') }"
@click="goComments"
>
评论管理
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('stats') }"
@click="goStats"
>
数据看板
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('analytics') }"
@click="goAnalytics"
>
访问统计
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('settings') }"
@click="goSettings"
>
网站设置
</li>
<li
class="menu-item"
:class="{ active: isRouteActive('data') }"
@click="goData"
>
数据管理
</li>
</ul>
</nav>
<div v-if="isMobileSiderOpen" class="layout-sider-mask" @click="closeSider" />
<main class="layout-content">
<router-view />
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, provide, computed } from "vue";
import { useRouter, useRoute } from "vue-router";
import { logoutAdmin, fetchDomainList } from "../api/admin";
import { useTheme } from "../composables/useTheme";
const DOMAIN_STORAGE_KEY = "cwd_admin_domain_filter";
const router = useRouter();
const route = useRoute();
const { theme, setTheme } = useTheme();
const isMobileSiderOpen = ref(false);
const isActionsOpen = ref(false);
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");
}
const storedDomain =
typeof window !== "undefined"
? window.localStorage.getItem(DOMAIN_STORAGE_KEY) || ""
: "";
const domainFilter = ref(storedDomain);
const domainOptions = ref<string[]>([]);
async function loadDomains() {
try {
const res = await fetchDomainList();
const domains = Array.isArray(res.domains) ? res.domains : [];
const set = new Set(domains);
if (domainFilter.value && !set.has(domainFilter.value)) {
set.add(domainFilter.value);
}
domainOptions.value = Array.from(set);
} catch {
domainOptions.value = [];
}
}
provide("domainFilter", domainFilter);
onMounted(() => {
loadDomains();
});
watch(domainFilter, (value) => {
if (typeof window !== "undefined") {
window.localStorage.setItem(DOMAIN_STORAGE_KEY, value || "");
}
});
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 goFeatureSettings() {
router.push({ name: "feature-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();
}
</script>
<style scoped>
* {
box-sizing: border-box;
}
::-webkit-scrollbar {
width: 8px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-body);
border-radius: 0;
}
::-webkit-scrollbar-thumb {
background: var(--text-secondary);
border-radius: 10px;
transition: all 0.1s linear;
}
.layout {
display: flex;
flex-direction: column;
height: 100%;
}
.layout-header {
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
background-color: var(--bg-header);
color: var(--text-inverse);
border-bottom: 1px solid var(--border-header);
}
.layout-title {
font-size: 18px;
font-weight: 600;
}
.layout-actions-wrapper {
display: flex;
align-items: center;
gap: 12px;
position: relative;
}
.layout-domain-filter {
display: flex;
align-items: center;
}
.layout-domain-select {
padding: 6px 8px;
border-radius: 4px;
border: 1px solid var(--border-header);
background-color: var(--bg-select);
color: var(--text-inverse);
font-size: 13px;
}
.layout-sider-domain-filter {
display: none;
}
.layout-actions {
display: flex;
gap: 8px;
}
.layout-button {
text-decoration: none;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--border-header);
background-color: var(--bg-select);
color: var(--text-inverse);
cursor: pointer;
font-size: 13px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.layout-button:hover {
background-color: var(--border-header);
}
.layout-actions-toggle {
display: none;
align-items: center;
justify-content: center;
padding: 6px;
border-radius: 4px;
border: 1px solid var(--border-header);
background-color: var(--bg-select);
color: var(--text-inverse);
cursor: pointer;
}
.layout-actions-dropdown {
display: none;
}
.layout-body {
display: flex;
flex: 1;
min-height: 0;
}
.layout-sider {
width: 180px;
background-color: var(--bg-sider);
border-right: 1px solid var(--border-color);
}
.menu {
list-style: none;
margin: 0;
padding: 12px 0;
}
.menu-item {
padding: 10px 16px;
cursor: pointer;
font-size: 15px;
color: var(--text-primary);
}
.menu-item:hover {
background-color: var(--bg-hover);
}
.menu-item.active {
background-color: var(--bg-active);
font-weight: 600;
color: var(--primary-color);
}
.layout-content {
flex: 1;
padding: 16px 20px 40px;
overflow: auto;
}
.layout-menu-toggle {
display: none;
align-items: center;
justify-content: center;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--border-header);
background-color: var(--bg-select);
color: var(--text-inverse);
cursor: pointer;
font-size: 13px;
}
.layout-sider-mask {
display: none;
}
@media (max-width: 768px) {
.layout {
height: 100vh;
}
.layout-header {
padding: 0 12px;
gap: 8px;
}
.layout-title {
font-size: 16px;
}
.layout-body {
position: relative;
}
.layout-menu-toggle {
display: inline-flex;
}
.layout-sider {
position: fixed;
top: 56px;
left: 0;
bottom: 0;
width: 220px;
max-width: 80%;
border-right: 1px solid var(--border-color);
transform: translateX(-100%);
transition: transform 0.2s ease-out;
z-index: 1000;
}
.layout-sider-mobile-open {
transform: translateX(0);
}
.layout-sider-mask {
position: fixed;
top: 56px;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
z-index: 900;
display: block;
}
.layout-content {
padding: 12px;
}
.layout-actions {
display: none;
}
.layout-domain-filter-header {
display: none;
}
.layout-sider-domain-filter {
display: block;
padding: 12px 16px 0;
}
.layout-sider-domain-filter .layout-domain-select {
width: 100%;
box-sizing: border-box;
}
.layout-actions-toggle {
display: inline-flex;
}
.layout-actions-dropdown {
position: fixed;
top: 56px;
right: 12px;
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: var(--shadow-card);
padding: 6px 0;
min-width: 160px;
z-index: 1100;
display: flex;
flex-direction: column;
}
.layout-actions-item {
padding: 8px 14px;
font-size: 13px;
background: none;
border: none;
text-align: left;
cursor: pointer;
color: var(--text-primary);
width: 100%;
}
.layout-actions-item:hover {
background-color: var(--bg-hover);
}
.layout-actions-item-danger {
color: var(--color-danger);
}
.layout-actions-item-danger:hover {
background-color: rgba(209, 36, 47, 0.1);
}
}
</style>