Add remote browser pages and website sync
Enable managed remote browser custom pages with login autofill and add website sync workflows so external admin surfaces can be handled inside SmartUp. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
a13a0070a5
commit
7adc7c00ab
@@ -1,132 +1,260 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<span class="logo-text">SmartUp</span>
|
||||
<div class="layout-shell">
|
||||
<div v-if="mobileNavOpen" class="sidebar-backdrop" @click="mobileNavOpen = false" />
|
||||
|
||||
<aside class="sidebar" :class="{ open: mobileNavOpen }">
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark-wrap">
|
||||
<img src="/favicon.svg" alt="SmartUp" class="brand-mark" />
|
||||
</div>
|
||||
<div class="brand-copy">
|
||||
<span class="brand-kicker">Control Console</span>
|
||||
<span class="brand-name brand-type">SmartUp</span>
|
||||
</div>
|
||||
<button type="button" class="sidebar-close" @click="mobileNavOpen = false" aria-label="关闭导航">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Fixed nav -->
|
||||
<div class="nav-group-label">监控</div>
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active">
|
||||
<el-icon><Connection /></el-icon>
|
||||
<span>上游管理</span>
|
||||
</router-link>
|
||||
<router-link to="/webhooks" class="nav-item" active-class="active">
|
||||
<el-icon><Bell /></el-icon>
|
||||
<span>Webhook 通知</span>
|
||||
</router-link>
|
||||
<router-link to="/logs" class="nav-item" active-class="active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>通知日志</span>
|
||||
</router-link>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="nav-divider" />
|
||||
<div class="sidebar-section">
|
||||
<div class="sidebar-section-title">监控中枢</div>
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/upstreams" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Connection /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>上游管理</strong>
|
||||
<small>轮询、健康度、倍率快照</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/websites" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><OfficeBuilding /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>网站管理</strong>
|
||||
<small>目标站点、分组映射、自动同步</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/webhooks" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Bell /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>Webhook 通知</strong>
|
||||
<small>投递器、订阅事件、测试回路</small>
|
||||
</span>
|
||||
</router-link>
|
||||
<router-link to="/logs" class="nav-item" active-class="active" @click="closeMobileNav">
|
||||
<span class="nav-icon"><el-icon><Document /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong>通知日志</strong>
|
||||
<small>响应记录、筛选、追踪异常</small>
|
||||
</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Custom pages section -->
|
||||
<div class="nav-group-label">
|
||||
自定义页面
|
||||
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面">
|
||||
<el-icon :size="12"><Setting /></el-icon>
|
||||
<div class="sidebar-divider" />
|
||||
|
||||
<div class="sidebar-section sidebar-section-pages">
|
||||
<div class="sidebar-section-head">
|
||||
<div>
|
||||
<div class="sidebar-section-title">自定义页面</div>
|
||||
<p class="sidebar-section-note">聚合外部控制台与嵌入页面</p>
|
||||
</div>
|
||||
<router-link to="/custom-pages" class="nav-manage-link" title="管理自定义页面" @click="closeMobileNav">
|
||||
<el-icon><Setting /></el-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template v-if="customPages.length > 0">
|
||||
<router-link
|
||||
v-for="page in customPages"
|
||||
:key="page.id"
|
||||
:to="`/page/${page.id}`"
|
||||
class="nav-item"
|
||||
active-class="active"
|
||||
>
|
||||
<el-icon><component :is="iconMap[page.icon] || LinkIcon" /></el-icon>
|
||||
<span class="nav-custom-label">{{ page.name }}</span>
|
||||
</router-link>
|
||||
</template>
|
||||
<div v-else class="nav-empty-pages">
|
||||
<router-link to="/custom-pages" class="add-page-link">
|
||||
<el-icon><Plus /></el-icon> 添加页面
|
||||
<div class="custom-page-stack">
|
||||
<template v-if="customPages.length > 0">
|
||||
<a
|
||||
v-for="page in customPages"
|
||||
:key="page.id"
|
||||
class="nav-item nav-item-custom"
|
||||
:class="{ active: isCustomPageActive(page.id) }"
|
||||
href="#"
|
||||
@click.prevent="openCustomPage(page)"
|
||||
>
|
||||
<span class="nav-icon"><el-icon><component :is="iconMap[page.icon] || LinkIcon" /></el-icon></span>
|
||||
<span class="nav-copy">
|
||||
<strong class="nav-custom-label">{{ page.name }}</strong>
|
||||
<small>{{ page.description || '外部控制台入口' }}</small>
|
||||
</span>
|
||||
</a>
|
||||
</template>
|
||||
<router-link v-else to="/custom-pages" class="add-page-link" @click="closeMobileNav">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>添加页面</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main-wrap">
|
||||
<!-- Topbar -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-title">{{ pageTitle }}</div>
|
||||
<header class="topbar" :class="{ compact: customPageTabs.length > 0 && isCustomPageRoute }">
|
||||
<div class="topbar-main">
|
||||
<button type="button" class="mobile-menu" @click="mobileNavOpen = true" aria-label="打开导航">
|
||||
<el-icon><Operation /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
<span class="user-email">
|
||||
<el-icon><User /></el-icon>
|
||||
{{ auth.email }}
|
||||
</span>
|
||||
<el-button size="small" text @click="handleLogout" style="color:var(--text-secondary)">
|
||||
<el-icon><SwitchButton /></el-icon> 退出
|
||||
<div class="topbar-status">
|
||||
<span class="status-badge enabled"><span class="dot" />在线控制台</span>
|
||||
<span class="user-email"><el-icon><User /></el-icon>{{ auth.email }}</span>
|
||||
</div>
|
||||
<el-button size="small" text @click="handleLogout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
退出
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Page content -->
|
||||
<main class="page-content">
|
||||
<router-view />
|
||||
|
||||
<main class="page-content" :class="{ 'has-custom-tabs': customPageTabs.length > 0 && isCustomPageRoute }">
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" v-if="!isCustomPageRoute" />
|
||||
</router-view>
|
||||
|
||||
<div v-show="isCustomPageRoute && customPageTabs.length > 0" class="custom-tabs-shell">
|
||||
<div class="custom-tabs-bar">
|
||||
<button
|
||||
v-for="tab in customPageTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
class="custom-tab"
|
||||
:class="{ active: tab.id === activeCustomPageId }"
|
||||
@click="activateCustomPage(tab.id)"
|
||||
>
|
||||
<el-icon><component :is="iconMap[tab.icon] || LinkIcon" /></el-icon>
|
||||
<span>{{ tab.name }}</span>
|
||||
<el-icon class="custom-tab-close" @click.stop="closeCustomPageTab(tab.id)"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="custom-tabs-content">
|
||||
<PageViewer
|
||||
v-for="tab in customPageTabs"
|
||||
:key="tab.id"
|
||||
:page-id="tab.id"
|
||||
:active="isCustomPageRoute && tab.id === activeCustomPageId"
|
||||
embedded
|
||||
v-show="tab.id === activeCustomPageId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, markRaw } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch, markRaw } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import {
|
||||
Link as LinkIcon, Plus, Setting,
|
||||
Monitor, SetUp, Reading, Cpu, DataLine,
|
||||
Grid, Ticket, Wallet, Key, Tools, Star, House,
|
||||
Link as LinkIcon,
|
||||
Plus,
|
||||
Setting,
|
||||
Close,
|
||||
Monitor,
|
||||
SetUp,
|
||||
Reading,
|
||||
Cpu,
|
||||
DataLine,
|
||||
Grid,
|
||||
Ticket,
|
||||
Wallet,
|
||||
Key,
|
||||
Tools,
|
||||
Star,
|
||||
House,
|
||||
OfficeBuilding,
|
||||
Connection,
|
||||
Bell,
|
||||
Document,
|
||||
User,
|
||||
SwitchButton,
|
||||
Operation,
|
||||
} from '@element-plus/icons-vue'
|
||||
import { customPagesApi, type CustomPageData } from '@/api'
|
||||
import PageViewer from '@/views/PageViewer.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const mobileNavOpen = ref(false)
|
||||
|
||||
// ---- icon map (must match CustomPages.vue) ----
|
||||
const iconMap: Record<string, any> = {
|
||||
Link: markRaw(LinkIcon), Monitor: markRaw(Monitor), SetUp: markRaw(SetUp),
|
||||
Reading: markRaw(Reading), Cpu: markRaw(Cpu), DataLine: markRaw(DataLine),
|
||||
Grid: markRaw(Grid), Ticket: markRaw(Ticket), Wallet: markRaw(Wallet),
|
||||
Key: markRaw(Key), Tools: markRaw(Tools), Star: markRaw(Star), House: markRaw(House),
|
||||
Link: markRaw(LinkIcon),
|
||||
Monitor: markRaw(Monitor),
|
||||
SetUp: markRaw(SetUp),
|
||||
Reading: markRaw(Reading),
|
||||
Cpu: markRaw(Cpu),
|
||||
DataLine: markRaw(DataLine),
|
||||
Grid: markRaw(Grid),
|
||||
Ticket: markRaw(Ticket),
|
||||
Wallet: markRaw(Wallet),
|
||||
Key: markRaw(Key),
|
||||
Tools: markRaw(Tools),
|
||||
Star: markRaw(Star),
|
||||
House: markRaw(House),
|
||||
}
|
||||
|
||||
// ---- custom pages nav ----
|
||||
const customPages = ref<CustomPageData[]>([])
|
||||
const customPageTabs = ref<CustomPageData[]>([])
|
||||
const activeCustomPageId = ref<number | null>(null)
|
||||
const isCustomPageRoute = computed(() => route.path.startsWith('/page/'))
|
||||
|
||||
async function loadCustomPages() {
|
||||
try {
|
||||
const res = await customPagesApi.list()
|
||||
customPages.value = res.data.filter(p => p.enabled)
|
||||
} catch { /* sidebar load failure is non-critical */ }
|
||||
}
|
||||
|
||||
// Refresh when management page emits update event
|
||||
function onPagesUpdated() { loadCustomPages() }
|
||||
|
||||
// ---- page title ----
|
||||
const staticTitles: Record<string, string> = {
|
||||
'/upstreams': '上游管理',
|
||||
'/webhooks': 'Webhook 通知',
|
||||
'/logs': '通知日志',
|
||||
'/custom-pages': '自定义页面',
|
||||
}
|
||||
const pageTitle = computed(() => {
|
||||
if (route.path.startsWith('/page/')) {
|
||||
const id = Number(route.params.id)
|
||||
const found = customPages.value.find(p => p.id === id)
|
||||
return found ? found.name : '嵌入页面'
|
||||
customPages.value = res.data.filter((page) => page.enabled)
|
||||
customPageTabs.value = customPageTabs.value
|
||||
.map((tab) => customPages.value.find((page) => page.id === tab.id))
|
||||
.filter((page): page is CustomPageData => Boolean(page))
|
||||
if (activeCustomPageId.value && !customPageTabs.value.some((tab) => tab.id === activeCustomPageId.value)) {
|
||||
activeCustomPageId.value = customPageTabs.value[0]?.id ?? null
|
||||
}
|
||||
} catch {
|
||||
// sidebar data is non-blocking
|
||||
}
|
||||
return staticTitles[route.path] || 'SmartUp'
|
||||
})
|
||||
}
|
||||
|
||||
function onPagesUpdated() {
|
||||
loadCustomPages()
|
||||
}
|
||||
|
||||
function closeMobileNav() {
|
||||
mobileNavOpen.value = false
|
||||
}
|
||||
|
||||
function openCustomPage(page: CustomPageData) {
|
||||
if (!customPageTabs.value.some((tab) => tab.id === page.id)) {
|
||||
customPageTabs.value.push(page)
|
||||
}
|
||||
closeMobileNav()
|
||||
activateCustomPage(page.id)
|
||||
}
|
||||
|
||||
function activateCustomPage(id: number) {
|
||||
activeCustomPageId.value = id
|
||||
if (route.path !== `/page/${id}`) router.push(`/page/${id}`)
|
||||
}
|
||||
|
||||
function closeCustomPageTab(id: number) {
|
||||
const index = customPageTabs.value.findIndex((tab) => tab.id === id)
|
||||
if (index === -1) return
|
||||
customPageTabs.value.splice(index, 1)
|
||||
if (activeCustomPageId.value !== id) return
|
||||
const next = customPageTabs.value[index] || customPageTabs.value[index - 1]
|
||||
if (next) {
|
||||
activateCustomPage(next.id)
|
||||
} else {
|
||||
activeCustomPageId.value = null
|
||||
router.push('/custom-pages')
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomPageActive(id: number) {
|
||||
return isCustomPageRoute.value && activeCustomPageId.value === id
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.clear()
|
||||
@@ -137,151 +265,458 @@ onMounted(() => {
|
||||
loadCustomPages()
|
||||
window.addEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('custom-pages-updated', onPagesUpdated)
|
||||
})
|
||||
|
||||
watch([() => route.path, customPages], () => {
|
||||
mobileNavOpen.value = false
|
||||
if (!isCustomPageRoute.value) return
|
||||
const id = Number(route.params.id)
|
||||
if (!id || activeCustomPageId.value === id) return
|
||||
const page = customPages.value.find((item) => item.id === id)
|
||||
if (page) openCustomPage(page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
.layout-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(217, 139, 66, 0.08), transparent 22%),
|
||||
radial-gradient(circle at bottom right, rgba(134, 183, 199, 0.05), transparent 18%);
|
||||
}
|
||||
|
||||
.sidebar-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(10, 8, 7, 0.56);
|
||||
backdrop-filter: blur(3px);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
width: min(86vw, 20rem);
|
||||
padding: 1rem;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 1rem;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 244, 232, 0.03), transparent 20%),
|
||||
rgba(22, 17, 14, 0.96);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 calc(var(--radius-shell) + 0.4rem) calc(var(--radius-shell) + 0.4rem) 0;
|
||||
box-shadow: 1.5rem 0 3rem rgba(0, 0, 0, 0.28);
|
||||
transform: translateX(-104%);
|
||||
transition: transform var(--motion-base) var(--ease-standard);
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.sidebar-logo {
|
||||
height: var(--topbar-height);
|
||||
display: flex;
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar-brand,
|
||||
.sidebar-section,
|
||||
.topbar,
|
||||
.custom-tabs-bar {
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-panel);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-surface);
|
||||
z-index: 1;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
}
|
||||
|
||||
.logo-icon { font-size: 22px; }
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
.brand-mark-wrap {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: linear-gradient(180deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 175, 99, 0.16);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
}
|
||||
|
||||
.brand-copy {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.brand-kicker,
|
||||
.topbar-kicker {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
padding: 12px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-group-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 8px 14px 4px;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
line-height: 0.95;
|
||||
}
|
||||
|
||||
.sidebar-close,
|
||||
.mobile-menu {
|
||||
min-width: 2.75rem;
|
||||
min-height: 2.75rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.95rem;
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-section {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
}
|
||||
|
||||
.sidebar-section-title {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sidebar-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.nav-manage-link {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
.sidebar-section-note {
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-soft);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.nav-manage-link:hover { color: var(--color-primary); background: var(--bg-elevated); }
|
||||
|
||||
.nav-divider {
|
||||
.sidebar-nav,
|
||||
.custom-page-stack {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.sidebar-divider {
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
margin: 8px 10px;
|
||||
margin: 0 0.4rem;
|
||||
background: linear-gradient(90deg, transparent, var(--border-color), transparent);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: flex-start;
|
||||
gap: 0.8rem;
|
||||
padding: 0.95rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 244, 232, 0.015);
|
||||
transition:
|
||||
transform var(--motion-fast) var(--ease-standard),
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
background-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(239, 175, 99, 0.18);
|
||||
background: rgba(255, 244, 232, 0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
border-color: rgba(239, 175, 99, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(217, 139, 66, 0.18), rgba(217, 139, 66, 0.05)),
|
||||
rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 2.2rem;
|
||||
height: 2.2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.05);
|
||||
color: var(--color-primary-strong);
|
||||
}
|
||||
|
||||
.nav-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.nav-copy strong,
|
||||
.nav-custom-label {
|
||||
font-size: 0.93rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.nav-copy small {
|
||||
color: var(--text-soft);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.nav-manage-link,
|
||||
.add-page-link {
|
||||
min-height: 2.75rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 14px;
|
||||
border-radius: 8px;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.7rem 0.95rem;
|
||||
border-radius: 0.95rem;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
}
|
||||
.nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); }
|
||||
.nav-item.active { background: rgba(99,102,241,0.15); color: var(--color-primary); }
|
||||
|
||||
.nav-custom-label {
|
||||
.main-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: var(--shell-padding);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: var(--topbar-height);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: var(--radius-shell);
|
||||
background: rgba(25, 19, 16, 0.7);
|
||||
}
|
||||
|
||||
.topbar:not(.compact) {
|
||||
width: min(100%, var(--content-max));
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.topbar.compact {
|
||||
min-height: 2.8rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: calc(var(--radius-shell) - 0.35rem);
|
||||
}
|
||||
|
||||
.topbar-main,
|
||||
.topbar-right,
|
||||
.topbar-status,
|
||||
.user-email {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.topbar-main {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
}
|
||||
|
||||
.topbar-right {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.topbar-status {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
min-height: 2rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.03);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.page-content.has-custom-tabs {
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.custom-tabs-shell {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.custom-tabs-bar {
|
||||
min-height: 2.4rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: calc(var(--radius-shell) - 0.45rem);
|
||||
background: rgba(25, 19, 16, 0.7);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.custom-tab {
|
||||
min-height: 1.9rem;
|
||||
min-width: 6rem;
|
||||
max-width: 12rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
background: rgba(255, 244, 232, 0.02);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-tab span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-empty-pages {
|
||||
padding: 4px 8px;
|
||||
.custom-tab.active {
|
||||
background: linear-gradient(135deg, rgba(217, 139, 66, 0.16), rgba(217, 139, 66, 0.04));
|
||||
border-color: rgba(239, 175, 99, 0.18);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.add-page-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
border: 1px dashed var(--border-color);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.add-page-link:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||
|
||||
/* ---- Main wrap ---- */
|
||||
.main-wrap {
|
||||
.custom-tab-close {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0.1rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.custom-tab-close:hover {
|
||||
background: rgba(221, 126, 114, 0.14);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.custom-tabs-content {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 0.45rem;
|
||||
border-radius: var(--radius-shell);
|
||||
overflow: hidden;
|
||||
background: rgba(10, 8, 7, 0.16);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.topbar-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.topbar-right { display: flex; align-items: center; gap: 12px; }
|
||||
.user-email { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-secondary); }
|
||||
@media (min-width: 1024px) {
|
||||
.layout-shell {
|
||||
gap: var(--shell-padding);
|
||||
padding: var(--shell-padding);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: var(--shell-padding);
|
||||
width: var(--sidebar-width);
|
||||
height: calc(100vh - (var(--shell-padding) * 2));
|
||||
border-radius: calc(var(--radius-shell) + 0.4rem);
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main-wrap {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-close,
|
||||
.mobile-menu,
|
||||
.sidebar-backdrop {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.topbar {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.topbar-main {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.topbar-right,
|
||||
.topbar-status {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.custom-tabs-bar {
|
||||
min-height: 3.7rem;
|
||||
}
|
||||
|
||||
.custom-tab {
|
||||
min-height: 2.9rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user