feat(admin-frontend): 重做登录回跳与仪表盘样式

重构管理端登录、主布局和仪表盘,统一为 Apple 风格
并移除高成本装饰层以提升页面流畅度。

补充仪表盘统计、趋势、排行和系统状态接口封装,
同时完善受保护路由的 redirect 回跳逻辑。
This commit is contained in:
yinjianm
2026-04-21 04:23:23 +08:00
parent 4cfda0fbf1
commit f68ba190a8
27 changed files with 2886 additions and 148 deletions
+50 -3
View File
@@ -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')
}
+13 -1
View File
@@ -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 || '请求失败')
+164 -44
View File
@@ -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>
+12 -4
View File
@@ -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 },
}
}
}
+3 -1
View File
@@ -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' },
},
],
},
+127 -7
View File
@@ -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;
}
+99 -3
View File
@@ -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
View File
@@ -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']
}
+239
View File
@@ -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'
}
+23
View File
@@ -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'
}
+2 -2
View File
@@ -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
+173 -42
View File
@@ -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>