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:
liumangmang
2026-05-15 15:43:58 +08:00
co-authored by Claude Opus 4.7
parent a13a0070a5
commit 7adc7c00ab
43 changed files with 6611 additions and 637 deletions
+624 -189
View File
@@ -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>