feat(admin-frontend): 重做登录回跳与仪表盘样式
重构管理端登录、主布局和仪表盘,统一为 Apple 风格 并移除高成本装饰层以提升页面流畅度。 补充仪表盘统计、趋势、排行和系统状态接口封装, 同时完善受保护路由的 redirect 回跳逻辑。
This commit is contained in:
@@ -1,2 +1,2 @@
|
||||
VITE_API_BASE_URL=/api/v2
|
||||
VITE_ADMIN_PATH=
|
||||
VITE_ADMIN_PATH=adminadmin
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
import { adminClient } from './client'
|
||||
import type { ApiResponse, SystemStatus } from '@/types/api'
|
||||
import type {
|
||||
ApiResponse,
|
||||
DashboardStats,
|
||||
OrderTrendData,
|
||||
QueueStats,
|
||||
SystemStatus,
|
||||
TrafficRankResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
export function getSystemStatus(): Promise<ApiResponse<SystemStatus>> {
|
||||
function unwrap<T>(url: string, params?: Record<string, unknown>): Promise<ApiResponse<T>> {
|
||||
return adminClient
|
||||
.get<ApiResponse<SystemStatus>>('/system/getSystemStatus')
|
||||
.get<ApiResponse<T>>(url, { params })
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function getDashboardStats(): Promise<ApiResponse<DashboardStats>> {
|
||||
return unwrap<DashboardStats>('/stat/getStats')
|
||||
}
|
||||
|
||||
export function getOrderTrend(params: {
|
||||
startDate: string
|
||||
endDate: string
|
||||
type?: 'paid_total' | 'paid_count' | 'commission_total' | 'commission_count'
|
||||
}): Promise<ApiResponse<OrderTrendData>> {
|
||||
return unwrap<OrderTrendData>('/stat/getOrder', {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
type: params.type,
|
||||
})
|
||||
}
|
||||
|
||||
export function getTrafficRank(params: {
|
||||
type: 'node' | 'user'
|
||||
startTime: number
|
||||
endTime: number
|
||||
}): Promise<TrafficRankResponse> {
|
||||
return adminClient
|
||||
.get<TrafficRankResponse>('/stat/getTrafficRank', {
|
||||
params: {
|
||||
type: params.type,
|
||||
start_time: params.startTime,
|
||||
end_time: params.endTime,
|
||||
},
|
||||
})
|
||||
.then((res) => res.data)
|
||||
}
|
||||
|
||||
export function getSystemStatus(): Promise<ApiResponse<SystemStatus>> {
|
||||
return unwrap<SystemStatus>('/system/getSystemStatus')
|
||||
}
|
||||
|
||||
export function getQueueStats(): Promise<ApiResponse<QueueStats>> {
|
||||
return unwrap<QueueStats>('/system/getQueueStats')
|
||||
}
|
||||
|
||||
@@ -2,6 +2,18 @@ import axios from 'axios'
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import { getApiBaseUrl, getSecurePath } from '@/utils/runtime'
|
||||
import { getToken, removeToken } from '@/utils/token'
|
||||
import { buildLoginHash, DEFAULT_AFTER_LOGIN, normalizeRedirectTarget } from '@/utils/navigation'
|
||||
|
||||
function redirectToLogin(): void {
|
||||
const currentTarget = normalizeRedirectTarget(
|
||||
window.location.hash.replace(/^#/, ''),
|
||||
DEFAULT_AFTER_LOGIN,
|
||||
)
|
||||
|
||||
window.location.hash = currentTarget === DEFAULT_AFTER_LOGIN
|
||||
? '#/login'
|
||||
: buildLoginHash(currentTarget)
|
||||
}
|
||||
|
||||
function handleError(error: unknown): never {
|
||||
if (axios.isAxiosError(error)) {
|
||||
@@ -10,7 +22,7 @@ function handleError(error: unknown): never {
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
removeToken()
|
||||
window.location.hash = '#/login'
|
||||
redirectToLogin()
|
||||
}
|
||||
|
||||
throw new Error(data?.message || error.message || '请求失败')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -8,22 +8,33 @@ import {
|
||||
SwitchButton,
|
||||
Fold,
|
||||
Expand,
|
||||
Sunny,
|
||||
Moon,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
const isMobile = ref(false)
|
||||
|
||||
const sidebarWidth = computed(() => app.sidebarCollapsed ? '64px' : '220px')
|
||||
const sidebarWidth = computed(() => app.sidebarCollapsed ? '72px' : '220px')
|
||||
const currentTitle = computed(() => String(route.meta.title || '控制台'))
|
||||
const currentKicker = computed(() => String(route.meta.kicker || 'Xboard Admin'))
|
||||
|
||||
const menuItems = [
|
||||
{ index: '/dashboard', title: '仪表盘', icon: Odometer },
|
||||
]
|
||||
|
||||
function syncViewport() {
|
||||
isMobile.value = window.innerWidth < 960
|
||||
if (isMobile.value) {
|
||||
app.sidebarCollapsed = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleMenuSelect(index: string) {
|
||||
if (isMobile.value) {
|
||||
app.sidebarCollapsed = true
|
||||
}
|
||||
router.push(index)
|
||||
}
|
||||
|
||||
@@ -31,20 +42,34 @@ function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncViewport()
|
||||
window.addEventListener('resize', syncViewport)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncViewport)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElContainer class="admin-layout">
|
||||
<ElAside :width="sidebarWidth" class="admin-aside">
|
||||
<div class="aside-logo">
|
||||
<h1 v-if="!app.sidebarCollapsed">Xboard</h1>
|
||||
<span v-else>X</span>
|
||||
<div class="aside-mark">X</div>
|
||||
<div v-if="!app.sidebarCollapsed" class="aside-brand">
|
||||
<p>Xboard</p>
|
||||
<h1>{{ app.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElMenu
|
||||
:default-active="route.path"
|
||||
:collapse="app.sidebarCollapsed"
|
||||
:collapse-transition="false"
|
||||
router
|
||||
class="admin-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<ElMenuItem
|
||||
@@ -58,7 +83,7 @@ function handleLogout() {
|
||||
</ElMenu>
|
||||
</ElAside>
|
||||
|
||||
<ElContainer>
|
||||
<ElContainer class="admin-stage">
|
||||
<ElHeader class="admin-header">
|
||||
<div class="header-left">
|
||||
<ElIcon
|
||||
@@ -68,20 +93,19 @@ function handleLogout() {
|
||||
<Fold v-if="!app.sidebarCollapsed" />
|
||||
<Expand v-else />
|
||||
</ElIcon>
|
||||
<ElBreadcrumb separator="/">
|
||||
<ElBreadcrumbItem :to="{ path: '/dashboard' }">首页</ElBreadcrumbItem>
|
||||
<ElBreadcrumbItem v-if="route.name !== 'Dashboard'">
|
||||
{{ route.name }}
|
||||
</ElBreadcrumbItem>
|
||||
</ElBreadcrumb>
|
||||
|
||||
<div class="page-copy">
|
||||
<p>{{ currentKicker }}</p>
|
||||
<h2>{{ currentTitle }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<ElIcon class="theme-btn" @click="app.toggleTheme">
|
||||
<Sunny v-if="app.isDark" />
|
||||
<Moon v-else />
|
||||
</ElIcon>
|
||||
<ElButton text @click="handleLogout">
|
||||
<div class="header-info">
|
||||
<span class="header-info__label">secure_path</span>
|
||||
<strong>/{{ app.securePath || 'admin' }}</strong>
|
||||
</div>
|
||||
<ElButton text class="logout-btn" @click="handleLogout">
|
||||
<ElIcon><SwitchButton /></ElIcon>
|
||||
退出
|
||||
</ElButton>
|
||||
@@ -98,83 +122,179 @@ function handleLogout() {
|
||||
<style scoped>
|
||||
.admin-layout {
|
||||
height: 100vh;
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.admin-aside {
|
||||
background: var(--el-bg-color);
|
||||
border-right: 1px solid var(--el-border-color-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
transition: width 0.3s;
|
||||
padding: 18px 12px 12px;
|
||||
box-shadow: 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.aside-logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
gap: 12px;
|
||||
padding: 12px 8px 20px;
|
||||
}
|
||||
|
||||
.aside-logo h1 {
|
||||
.aside-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
background: #1d1d1f;
|
||||
color: #ffffff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.aside-brand {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.aside-brand p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.aside-brand h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.1;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.admin-menu {
|
||||
flex: 1;
|
||||
background: #ffffff;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.el-menu-item) {
|
||||
margin-bottom: 8px;
|
||||
border-radius: 12px;
|
||||
color: var(--xboard-text-secondary);
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.admin-menu :deep(.el-menu-item.is-active) {
|
||||
background: rgba(0, 113, 227, 0.08);
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.admin-stage {
|
||||
background: #f5f5f7;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color-light);
|
||||
padding: 0 20px;
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
backdrop-filter: saturate(180%) blur(20px);
|
||||
padding: 0 24px;
|
||||
height: 64px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-regular);
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.page-copy {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-copy p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-muted);
|
||||
}
|
||||
|
||||
.page-copy h2 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.1;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.theme-btn {
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-regular);
|
||||
.header-info {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.theme-btn:hover {
|
||||
color: var(--el-color-primary);
|
||||
.header-info__label {
|
||||
font-size: 12px;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.header-info strong {
|
||||
font-size: 14px;
|
||||
color: var(--xboard-text-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: #0071e3;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
background: var(--el-bg-color-page);
|
||||
background: #f5f5f7;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@media (max-width: 959px) {
|
||||
.admin-aside {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
z-index: 30;
|
||||
height: 100vh;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
padding: 0 18px;
|
||||
}
|
||||
|
||||
.page-copy h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
padding: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
import type { Router } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { hasToken } from '@/utils/token'
|
||||
import { normalizeRedirectTarget } from '@/utils/navigation'
|
||||
|
||||
export function setupGuards(router: Router) {
|
||||
router.beforeEach(async (to) => {
|
||||
const auth = useAuthStore()
|
||||
const redirectTarget = normalizeRedirectTarget(to.query.redirect)
|
||||
|
||||
if (to.meta.public) {
|
||||
if (hasToken() && !auth.validated) {
|
||||
const ok = await auth.validateAdmin()
|
||||
if (ok) return '/dashboard'
|
||||
if (ok) return redirectTarget
|
||||
}
|
||||
if (auth.validated) {
|
||||
return '/dashboard'
|
||||
return redirectTarget
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (!hasToken()) {
|
||||
return '/login'
|
||||
return {
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath },
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth.validated) {
|
||||
const ok = await auth.validateAdmin()
|
||||
if (!ok) {
|
||||
return '/login'
|
||||
return {
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@ const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/LoginView.vue'),
|
||||
meta: { public: true },
|
||||
meta: { public: true, title: '管理员登录', kicker: 'Xboard Admin' },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'AdminRoot',
|
||||
component: () => import('@/layouts/AdminLayout.vue'),
|
||||
children: [
|
||||
{
|
||||
@@ -20,6 +21,7 @@ const routes: RouteRecordRaw[] = [
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: '仪表盘', kicker: 'Overview' },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
Vendored
+127
-7
@@ -1,14 +1,46 @@
|
||||
@use 'element-plus/theme-chalk/src/mixins/config' as *;
|
||||
|
||||
:root {
|
||||
--xboard-primary: #409eff;
|
||||
--xboard-sidebar-bg: #ffffff;
|
||||
--xboard-header-bg: #ffffff;
|
||||
--xboard-font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
|
||||
'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
--xboard-font-mono: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', monospace;
|
||||
--xboard-bg: #f5f5f7;
|
||||
--xboard-surface: #ffffff;
|
||||
--xboard-surface-soft: #fbfbfd;
|
||||
--xboard-surface-dark: #000000;
|
||||
--xboard-text-strong: #1d1d1f;
|
||||
--xboard-text-secondary: rgba(29, 29, 31, 0.8);
|
||||
--xboard-text-muted: rgba(29, 29, 31, 0.56);
|
||||
--xboard-text-on-dark: #ffffff;
|
||||
--xboard-text-on-dark-muted: rgba(255, 255, 255, 0.72);
|
||||
--xboard-primary: #0071e3;
|
||||
--xboard-link: #0066cc;
|
||||
--xboard-link-dark: #2997ff;
|
||||
--xboard-border: rgba(0, 0, 0, 0.08);
|
||||
--xboard-border-strong: rgba(0, 0, 0, 0.12);
|
||||
--xboard-shadow: 0 6px 30px rgba(0, 0, 0, 0.08);
|
||||
--xboard-success: #23863f;
|
||||
--xboard-warning: #b05a00;
|
||||
--xboard-danger: #c93428;
|
||||
--el-color-primary: #0071e3;
|
||||
--el-color-primary-light-3: #2997ff;
|
||||
--el-color-primary-light-5: #4da1ff;
|
||||
--el-bg-color: #ffffff;
|
||||
--el-bg-color-page: #f5f5f7;
|
||||
--el-bg-color-overlay: #ffffff;
|
||||
--el-border-color: rgba(0, 0, 0, 0.1);
|
||||
--el-border-color-light: rgba(0, 0, 0, 0.08);
|
||||
--el-border-color-lighter: rgba(0, 0, 0, 0.06);
|
||||
--el-fill-color-blank: #ffffff;
|
||||
--el-fill-color-light: rgba(0, 0, 0, 0.03);
|
||||
--el-text-color-primary: #1d1d1f;
|
||||
--el-text-color-regular: rgba(29, 29, 31, 0.8);
|
||||
--el-text-color-secondary: rgba(29, 29, 31, 0.56);
|
||||
--el-mask-color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
--xboard-sidebar-bg: #1d1e1f;
|
||||
--xboard-header-bg: #1d1e1f;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -22,11 +54,99 @@ body,
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||
'Noto Sans', sans-serif;
|
||||
font-family: var(--xboard-font-sans);
|
||||
background: var(--xboard-bg);
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--xboard-font-mono);
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: var(--xboard-success);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--xboard-danger);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.el-card,
|
||||
.el-menu,
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-button,
|
||||
.el-alert {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
background: #ffffff;
|
||||
border-color: var(--xboard-border);
|
||||
box-shadow: var(--xboard-shadow);
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08) inset !important;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: #0071e3;
|
||||
border-color: #0071e3;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.el-button.is-text {
|
||||
color: var(--xboard-link);
|
||||
}
|
||||
|
||||
.el-checkbox__label,
|
||||
.el-form-item__label {
|
||||
color: var(--xboard-text-secondary) !important;
|
||||
}
|
||||
|
||||
.el-breadcrumb__item:last-child .el-breadcrumb__inner,
|
||||
.el-alert__title {
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
Vendored
+99
-3
@@ -1,8 +1,9 @@
|
||||
export interface ApiResponse<T = unknown> {
|
||||
status: 'success' | 'fail'
|
||||
message: string
|
||||
status?: 'success' | 'fail' | string
|
||||
message?: string
|
||||
data: T
|
||||
error?: string | null
|
||||
code?: number
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -16,8 +17,103 @@ export interface LoginResponse {
|
||||
is_admin: boolean
|
||||
}
|
||||
|
||||
export interface TrafficAmount {
|
||||
upload: number
|
||||
download: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
todayIncome: number
|
||||
dayIncomeGrowth: number
|
||||
currentMonthIncome: number
|
||||
lastMonthIncome: number
|
||||
monthIncomeGrowth: number
|
||||
lastMonthIncomeGrowth: number
|
||||
currentMonthCommissionPayout: number
|
||||
lastMonthCommissionPayout: number
|
||||
commissionGrowth: number
|
||||
commissionPendingTotal: number
|
||||
currentMonthNewUsers: number
|
||||
totalUsers: number
|
||||
activeUsers: number
|
||||
userGrowth: number
|
||||
onlineUsers: number
|
||||
onlineDevices: number
|
||||
ticketPendingTotal: number
|
||||
onlineNodes: number
|
||||
todayTraffic: TrafficAmount
|
||||
monthTraffic: TrafficAmount
|
||||
totalTraffic: TrafficAmount
|
||||
}
|
||||
|
||||
export interface OrderTrendPoint {
|
||||
date: string
|
||||
paid_total: number
|
||||
paid_count: number
|
||||
commission_total: number
|
||||
commission_count: number
|
||||
avg_order_amount: number
|
||||
avg_commission_amount: number
|
||||
value?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
export interface OrderTrendSummary {
|
||||
paid_total: number
|
||||
paid_count: number
|
||||
commission_total: number
|
||||
commission_count: number
|
||||
start_date: string
|
||||
end_date: string
|
||||
avg_paid_amount: number
|
||||
avg_commission_amount: number
|
||||
commission_rate: number
|
||||
}
|
||||
|
||||
export interface OrderTrendData {
|
||||
list: OrderTrendPoint[]
|
||||
summary: OrderTrendSummary
|
||||
}
|
||||
|
||||
export interface TrafficRankItem {
|
||||
id: string
|
||||
name: string
|
||||
value: number
|
||||
previousValue: number
|
||||
change: number
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface TrafficRankResponse {
|
||||
timestamp: string
|
||||
data: TrafficRankItem[]
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
[key: string]: unknown
|
||||
schedule: boolean
|
||||
horizon: boolean
|
||||
schedule_last_runtime: number | string | null
|
||||
}
|
||||
|
||||
export interface QueueWaitEntry {
|
||||
[key: string]: string | number | null | undefined
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
failedJobs: number
|
||||
jobsPerMinute: number
|
||||
pausedMasters: number
|
||||
periods: {
|
||||
failedJobs: number
|
||||
recentJobs: number
|
||||
}
|
||||
processes: number
|
||||
queueWithMaxRuntime?: string | null
|
||||
queueWithMaxThroughput?: string | null
|
||||
recentJobs: number
|
||||
status: boolean
|
||||
wait?: QueueWaitEntry[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+1
-6
@@ -11,15 +11,11 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElBreadcrumb: typeof import('element-plus/es')['ElBreadcrumb']
|
||||
ElBreadcrumbItem: typeof import('element-plus/es')['ElBreadcrumbItem']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDivider: typeof import('element-plus/es')['ElDivider']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
@@ -28,7 +24,6 @@ declare module 'vue' {
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import type {
|
||||
OrderTrendPoint,
|
||||
QueueStats,
|
||||
QueueWaitEntry,
|
||||
} from '@/types/api'
|
||||
|
||||
export type TimePreset = '1d' | '7d' | '30d' | '90d'
|
||||
|
||||
export interface DateRangePreset {
|
||||
startDate: string
|
||||
endDate: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
}
|
||||
|
||||
export interface ChartLabelPoint {
|
||||
index: number
|
||||
label: string
|
||||
xPercent: number
|
||||
}
|
||||
|
||||
export interface TrendChartPoint {
|
||||
index: number
|
||||
x: number
|
||||
y: number
|
||||
value: number
|
||||
date: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface TrendChartModel {
|
||||
path: string
|
||||
areaPath: string
|
||||
points: TrendChartPoint[]
|
||||
gridLines: Array<{ label: string; y: number }>
|
||||
labels: ChartLabelPoint[]
|
||||
}
|
||||
|
||||
const TREND_WIDTH = 760
|
||||
const TREND_HEIGHT = 260
|
||||
const PADDING_X = 24
|
||||
const PADDING_TOP = 18
|
||||
const PADDING_BOTTOM = 34
|
||||
|
||||
function formatDateToken(date: Date): string {
|
||||
return [
|
||||
date.getFullYear(),
|
||||
String(date.getMonth() + 1).padStart(2, '0'),
|
||||
String(date.getDate()).padStart(2, '0'),
|
||||
].join('-')
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number {
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric : 0
|
||||
}
|
||||
|
||||
export function getDateRangeFromPreset(preset: TimePreset): DateRangePreset {
|
||||
const days = { '1d': 1, '7d': 7, '30d': 30, '90d': 90 }[preset]
|
||||
const end = new Date()
|
||||
end.setHours(23, 59, 59, 999)
|
||||
|
||||
const start = new Date(end)
|
||||
start.setDate(start.getDate() - (days - 1))
|
||||
start.setHours(0, 0, 0, 0)
|
||||
|
||||
return {
|
||||
startDate: formatDateToken(start),
|
||||
endDate: formatDateToken(end),
|
||||
startTime: Math.floor(start.getTime() / 1000),
|
||||
endTime: Math.floor(end.getTime() / 1000),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
const amount = (value || 0) / 100
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
export function formatCompactCurrency(value: number): string {
|
||||
const amount = (value || 0) / 100
|
||||
const absolute = Math.abs(amount)
|
||||
if (absolute >= 1_000_000) return `¥${(amount / 1_000_000).toFixed(1)}m`
|
||||
if (absolute >= 1_000) return `¥${(amount / 1_000).toFixed(1)}k`
|
||||
return formatCurrency(value)
|
||||
}
|
||||
|
||||
export function formatCompactNumber(value: number): string {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
notation: value >= 10_000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: value >= 10_000 ? 1 : 0,
|
||||
}).format(value || 0)
|
||||
}
|
||||
|
||||
export function formatTraffic(bytes: number): string {
|
||||
const value = Math.max(0, bytes || 0)
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
let size = value
|
||||
let index = 0
|
||||
|
||||
while (size >= 1024 && index < units.length - 1) {
|
||||
size /= 1024
|
||||
index += 1
|
||||
}
|
||||
|
||||
const digits = size >= 100 || index === 0 ? 0 : size >= 10 ? 1 : 2
|
||||
return `${size.toFixed(digits)} ${units[index]}`
|
||||
}
|
||||
|
||||
export function formatPercent(value: number, signed: boolean = true): string {
|
||||
const numeric = Number.isFinite(value) ? value : 0
|
||||
const prefix = signed && numeric > 0 ? '+' : ''
|
||||
return `${prefix}${numeric.toFixed(1)}%`
|
||||
}
|
||||
|
||||
export function formatDateTime(value: number | string | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const numeric = Number(value)
|
||||
const timeValue = Number.isFinite(numeric)
|
||||
? (numeric > 1_000_000_000_000 ? numeric : numeric * 1000)
|
||||
: Date.parse(String(value))
|
||||
|
||||
if (!Number.isFinite(timeValue)) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(new Date(timeValue))
|
||||
}
|
||||
|
||||
function getVisibleLabels(points: TrendChartPoint[]): ChartLabelPoint[] {
|
||||
if (points.length <= 1) {
|
||||
return points.map((point) => ({
|
||||
index: point.index,
|
||||
label: point.label,
|
||||
xPercent: 50,
|
||||
}))
|
||||
}
|
||||
|
||||
const step = Math.max(1, Math.ceil(points.length / 6))
|
||||
return points
|
||||
.filter((_, index) => index === 0 || index === points.length - 1 || index % step === 0)
|
||||
.map((point) => ({
|
||||
index: point.index,
|
||||
label: point.label,
|
||||
xPercent: ((point.x - PADDING_X) / (TREND_WIDTH - PADDING_X * 2)) * 100,
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildTrendChart(points: OrderTrendPoint[]): TrendChartModel {
|
||||
if (!points.length) {
|
||||
return {
|
||||
path: '',
|
||||
areaPath: '',
|
||||
points: [],
|
||||
gridLines: [],
|
||||
labels: [],
|
||||
}
|
||||
}
|
||||
|
||||
const values = points.map((point) => Math.max(0, toNumber(point.paid_total)))
|
||||
const maxValue = Math.max(...values, 1)
|
||||
const innerWidth = TREND_WIDTH - PADDING_X * 2
|
||||
const innerHeight = TREND_HEIGHT - PADDING_TOP - PADDING_BOTTOM
|
||||
const stepX = values.length > 1 ? innerWidth / (values.length - 1) : innerWidth / 2
|
||||
|
||||
const chartPoints = points.map((point, index) => {
|
||||
const normalized = maxValue === 0 ? 0 : values[index] / maxValue
|
||||
return {
|
||||
index,
|
||||
value: values[index],
|
||||
date: point.date,
|
||||
label: point.date.slice(5),
|
||||
x: PADDING_X + stepX * index,
|
||||
y: PADDING_TOP + innerHeight - normalized * innerHeight,
|
||||
}
|
||||
})
|
||||
|
||||
const path = chartPoints
|
||||
.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(2)} ${point.y.toFixed(2)}`)
|
||||
.join(' ')
|
||||
|
||||
const areaPath = chartPoints.length
|
||||
? `${path} L ${chartPoints[chartPoints.length - 1].x.toFixed(2)} ${(TREND_HEIGHT - PADDING_BOTTOM).toFixed(2)} L ${chartPoints[0].x.toFixed(2)} ${(TREND_HEIGHT - PADDING_BOTTOM).toFixed(2)} Z`
|
||||
: ''
|
||||
|
||||
const gridLines = [1, 0.75, 0.5, 0.25, 0].map((ratio) => ({
|
||||
label: formatCompactCurrency(maxValue * ratio),
|
||||
y: PADDING_TOP + innerHeight - innerHeight * ratio,
|
||||
}))
|
||||
|
||||
return {
|
||||
path,
|
||||
areaPath,
|
||||
points: chartPoints,
|
||||
gridLines,
|
||||
labels: getVisibleLabels(chartPoints),
|
||||
}
|
||||
}
|
||||
|
||||
function extractQueueWaitEntry(wait?: QueueWaitEntry[]): QueueWaitEntry | null {
|
||||
if (!Array.isArray(wait) || !wait.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return wait[0]
|
||||
}
|
||||
|
||||
export function getQueueWaitSeconds(queueStats: QueueStats | null): number | null {
|
||||
const waitEntry = extractQueueWaitEntry(queueStats?.wait)
|
||||
if (!waitEntry) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = toNumber(waitEntry.wait ?? waitEntry.value ?? waitEntry.time)
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function getQueueWaitName(queueStats: QueueStats | null): string {
|
||||
const waitEntry = extractQueueWaitEntry(queueStats?.wait)
|
||||
if (!waitEntry) {
|
||||
return 'N/A'
|
||||
}
|
||||
|
||||
const queueName = waitEntry.name ?? waitEntry.queue ?? waitEntry.label
|
||||
return typeof queueName === 'string' && queueName ? queueName : 'N/A'
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const DEFAULT_AFTER_LOGIN = '/dashboard'
|
||||
|
||||
export function normalizeRedirectTarget(value: unknown, fallback: string = DEFAULT_AFTER_LOGIN): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const normalized = value.trim().replace(/^#/, '')
|
||||
if (!normalized || !normalized.startsWith('/') || normalized.startsWith('//')) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
if (normalized.startsWith('/login')) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function buildLoginHash(redirect?: string): string {
|
||||
const safeTarget = normalizeRedirectTarget(redirect, '')
|
||||
return safeTarget ? `#/login?redirect=${encodeURIComponent(safeTarget)}` : '#/login'
|
||||
}
|
||||
@@ -7,13 +7,13 @@ export function getApiBaseUrl(): string {
|
||||
export function initSecurePath(): string {
|
||||
if (window.settings?.secure_path) {
|
||||
cachedSecurePath = window.settings.secure_path
|
||||
return cachedSecurePath
|
||||
return window.settings.secure_path
|
||||
}
|
||||
|
||||
const envPath = import.meta.env.VITE_ADMIN_PATH
|
||||
if (envPath) {
|
||||
cachedSecurePath = envPath
|
||||
return cachedSecurePath
|
||||
return envPath
|
||||
}
|
||||
|
||||
console.error('[Xboard] secure_path 未配置。请设置 window.settings.secure_path 或环境变量 VITE_ADMIN_PATH')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Message, Right } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { DEFAULT_AFTER_LOGIN, normalizeRedirectTarget } from '@/utils/navigation'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const app = useAppStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
@@ -17,6 +23,13 @@ const form = reactive({
|
||||
remember: false,
|
||||
})
|
||||
|
||||
const redirectTarget = computed(() => normalizeRedirectTarget(route.query.redirect))
|
||||
const redirectHint = computed(() => (
|
||||
redirectTarget.value === DEFAULT_AFTER_LOGIN
|
||||
? '登录后将进入仪表盘总览'
|
||||
: `登录后将返回 ${redirectTarget.value}`
|
||||
))
|
||||
|
||||
const rules: FormRules = {
|
||||
email: [
|
||||
{ required: true, message: '请输入邮箱', trigger: 'blur' },
|
||||
@@ -33,12 +46,14 @@ async function onSubmit() {
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await auth.login(form.email, form.password, form.remember)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/dashboard')
|
||||
await router.replace(redirectTarget.value)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '登录失败'
|
||||
errorMessage.value = msg
|
||||
ElMessage.error(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -47,12 +62,34 @@ async function onSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">Xboard Admin</h1>
|
||||
<p class="login-subtitle">管理后台</p>
|
||||
<div class="login-page">
|
||||
<section class="login-hero">
|
||||
<p class="login-eyebrow">XBOARD ADMIN</p>
|
||||
<h1 class="login-headline">管理后台,像产品页一样安静。</h1>
|
||||
<p class="login-description">
|
||||
页面重新回到 Apple 式的信息编排方式。减少装饰层和不必要的视觉负担,让登录入口更直接。
|
||||
</p>
|
||||
<div class="login-meta">
|
||||
<span>secure_path /{{ app.securePath || 'admin' }}</span>
|
||||
<span>{{ redirectHint }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="login-panel">
|
||||
<div class="login-header">
|
||||
<p class="login-panel-kicker">Sign in</p>
|
||||
<h2 class="login-title">管理员登录</h2>
|
||||
<p class="login-subtitle">使用具备后台权限的账号进入 {{ app.title }}。</p>
|
||||
</div>
|
||||
|
||||
<ElAlert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="login-alert"
|
||||
/>
|
||||
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
@@ -66,7 +103,7 @@ async function onSubmit() {
|
||||
<ElInput
|
||||
v-model="form.email"
|
||||
placeholder="admin@example.com"
|
||||
prefix-icon="Message"
|
||||
:prefix-icon="Message"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -75,74 +112,168 @@ async function onSubmit() {
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
prefix-icon="Lock"
|
||||
:prefix-icon="Lock"
|
||||
show-password
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem>
|
||||
<div class="login-form-meta">
|
||||
<ElCheckbox v-model="form.remember">记住登录</ElCheckbox>
|
||||
</ElFormItem>
|
||||
<span class="login-meta-text">{{ redirectHint }}</span>
|
||||
</div>
|
||||
|
||||
<ElFormItem>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ loading ? '登录中...' : '登 录' }}
|
||||
</ElButton>
|
||||
</ElFormItem>
|
||||
<ElButton
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
class="login-btn"
|
||||
@click="onSubmit"
|
||||
>
|
||||
<span>{{ loading ? '登录中...' : '继续' }}</span>
|
||||
<ElIcon><Right /></ElIcon>
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-container {
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
padding: 20px;
|
||||
gap: 28px;
|
||||
padding: 40px 32px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
.login-hero,
|
||||
.login-panel {
|
||||
width: min(100%, 560px);
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
padding: 48px;
|
||||
border-radius: 28px;
|
||||
background: #000000;
|
||||
color: var(--xboard-text-on-dark);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.login-eyebrow,
|
||||
.login-panel-kicker {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.28em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.login-headline {
|
||||
margin: 0;
|
||||
font-size: clamp(40px, 5vw, 56px);
|
||||
line-height: 1.07;
|
||||
letter-spacing: -0.28px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.login-description {
|
||||
margin: 0;
|
||||
max-width: 420px;
|
||||
padding: 40px;
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1.47;
|
||||
color: var(--xboard-text-on-dark-muted);
|
||||
}
|
||||
|
||||
.login-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.login-meta span {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
border-radius: 28px;
|
||||
background: #ffffff;
|
||||
box-shadow: var(--xboard-shadow);
|
||||
padding: 40px 36px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 8px;
|
||||
margin: 0;
|
||||
font-size: 34px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: -0.32px;
|
||||
color: var(--xboard-text-strong);
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0;
|
||||
color: var(--xboard-text-secondary);
|
||||
}
|
||||
|
||||
.login-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.login-form-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.login-meta-text {
|
||||
color: var(--xboard-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border-radius: 999px;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
@media (max-width: 980px) {
|
||||
.login-page {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.login-hero,
|
||||
.login-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.login-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-hero,
|
||||
.login-panel {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-form-meta {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user