<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#6d5efc">
<meta name="description" content="Nova Admin — 企业级通用后台管理系统">
<title>Nova Admin · 用户管理</title>
<style>
/* =====================================================================
Nova Admin · 样式总纲
分层顺序 = 优先级顺序(低 → 高)
reset < tokens < base < layout < components < utilities < motion
===================================================================== */
@layer reset, tokens, base, layout, components, utilities, motion;
/* =====================================================================
1. RESET —— 现代化最小重置
===================================================================== */
@layer reset {
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
-webkit-text-size-adjust: 100%;
scrollbar-gutter: stable;
}
body {
min-height: 100dvh;
}
img,
picture,
svg,
video,
canvas {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
color: inherit;
letter-spacing: inherit;
}
button,
[role="button"] {
background: none;
border: 0;
cursor: pointer;
}
ul,
ol {
list-style: none;
padding: 0;
}
a {
color: inherit;
text-decoration: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
dialog {
padding: 0;
border: 0;
margin: auto;
background: transparent;
max-width: none;
max-height: none;
}
input[type="search"]::-webkit-search-cancel-button {
-webkit-appearance: none;
}
[hidden] {
display: none !important;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 6px;
}
}
/* =====================================================================
2. TOKENS —— 设计令牌
===================================================================== */
@layer tokens {
:root {
color-scheme: light dark;
/* 品牌 & 语义色 */
--accent: light-dark(oklch(0.55 0.20 268), oklch(0.74 0.16 268));
--accent-hover: light-dark(oklch(0.48 0.21 268), oklch(0.80 0.14 268));
--accent-soft: color-mix(in oklab, var(--accent) 14%, transparent);
--accent-softer: color-mix(in oklab, var(--accent) 7%, transparent);
--accent-ring: color-mix(in oklab, var(--accent) 34%, transparent);
--on-accent: light-dark(#fff, #0b0e14);
--success: light-dark(oklch(0.60 0.15 155), oklch(0.78 0.16 155));
--warn: light-dark(oklch(0.72 0.16 78), oklch(0.84 0.15 80));
--danger: light-dark(oklch(0.58 0.21 25), oklch(0.72 0.18 25));
/* ✅ 已修复:闭合 light-dark() 的右括号(上一版漏写导致 --info 及其派生变量失效) */
--info: light-dark(oklch(0.62 0.14 225), oklch(0.78 0.13 225));
--success-soft: color-mix(in oklab, var(--success) 16%, transparent);
--warn-soft: color-mix(in oklab, var(--warn) 20%, transparent);
--danger-soft: color-mix(in oklab, var(--danger) 15%, transparent);
--info-soft: color-mix(in oklab, var(--info) 16%, transparent);
/* 表面 / 背景 */
--bg-app: light-dark(#f2f4f9, #0a0d13);
--bg-surface: light-dark(#ffffff, #121721);
--bg-surface-2: light-dark(#f7f9fc, #171d29);
--bg-elevated: light-dark(#ffffff, #1c2331);
--bg-hover: light-dark(#eef2f8, #212a3a);
--bg-active: light-dark(#e6ecfa, #27324a);
--bg-sunken: light-dark(#eceff5, #0e131c);
/* 描边 */
--border: light-dark(#e3e8f0, #232b39);
--border-strong: light-dark(#ccd5e1, #35405480);
/* 文本 */
--text: light-dark(#0f172a, #e9eef7);
--text-muted: light-dark(#5c6b82, #94a3b8);
--text-faint: light-dark(#93a1b5, #64748b);
/* 形状 */
--r-xs: 6px;
--r-sm: 9px;
--r-md: 13px;
--r-lg: 18px;
--r-xl: 24px;
--r-full: 999px;
/* 阴影 */
--sh-xs: 0 1px 2px light-dark(oklch(0.2 0.02 260 / .06), oklch(0 0 0 / .5));
--sh-sm: 0 2px 8px light-dark(oklch(0.2 0.02 260 / .07), oklch(0 0 0 / .45));
--sh-md: 0 8px 26px light-dark(oklch(0.2 0.02 260 / .10), oklch(0 0 0 / .55));
--sh-lg: 0 24px 60px light-dark(oklch(0.2 0.02 260 / .16), oklch(0 0 0 / .68));
--sh-inner: inset 0 1px 0 light-dark(#fff, oklch(1 0 0 / .05));
/* 尺寸 */
--sidebar-w: 262px;
--sidebar-w-collapsed: 76px;
--topbar-h: 62px;
--content-max: 1560px;
/* 动效 */
--ease: cubic-bezier(.4, 0, .2, 1);
--ease-out-back: cubic-bezier(.34, 1.56, .64, 1);
--dur-fast: .14s;
--dur: .24s;
--dur-slow: .42s;
interpolate-size: allow-keywords;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
@property --ring-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@property --glow-x {
syntax: '<percentage>';
initial-value: 50%;
inherits: false;
}
}
/* =====================================================================
3. BASE
===================================================================== */
@layer base {
html {
font-size: 14px;
}
body {
font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
"PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
line-height: 1.55;
color: var(--text);
background: var(--bg-app);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-numeric: tabular-nums;
transition: background-color var(--dur) var(--ease), color var(--dur) var(--ease);
overflow-x: hidden;
}
h1,
h2,
h3,
h4 {
line-height: 1.28;
text-wrap: balance;
font-weight: 650;
}
p {
text-wrap: pretty;
}
small {
font-size: .82em;
}
code,
kbd,
.mono {
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
}
::selection {
background: var(--accent-soft);
color: var(--text);
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border-strong) transparent;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: var(--r-full);
border: 3px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-faint);
background-clip: content-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.sprite {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
svg.icon {
width: 1.15em;
height: 1.15em;
flex: none;
}
}
/* =====================================================================
4. LAYOUT —— 应用骨架
===================================================================== */
@layer layout {
/* 顶部滚动进度条(滚动驱动动画) */
.scroll-progress {
position: fixed;
inset: 0 0 auto 0;
height: 3px;
z-index: 9999;
background: linear-gradient(90deg, var(--accent), var(--info), var(--success));
transform-origin: 0 50%;
transform: scaleX(0);
pointer-events: none;
animation: grow-x linear both;
animation-timeline: scroll(root block);
}
@keyframes grow-x {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
/* 应用网格 */
.app {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr);
min-height: 100dvh;
transition: grid-template-columns var(--dur-slow) var(--ease);
}
/* ---------------- 侧边栏 ---------------- */
.sidebar {
position: sticky;
top: 0;
z-index: 40;
height: 100dvh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border-inline-end: 1px solid var(--border);
overflow: hidden auto;
overscroll-behavior: contain;
transition: width var(--dur-slow) var(--ease);
}
.sidebar__brand {
display: flex;
align-items: center;
gap: 12px;
padding: 0 18px;
height: var(--topbar-h);
flex: none;
border-block-end: 1px solid var(--border);
transition: padding var(--dur) var(--ease);
}
.brand__logo {
width: 36px;
height: 36px;
flex: none;
display: grid;
place-items: center;
border-radius: 11px;
background: linear-gradient(140deg, var(--accent),
color-mix(in oklab, var(--accent) 45%, var(--info)));
color: #fff;
box-shadow: var(--sh-sm), var(--sh-inner);
}
.brand__logo svg {
width: 20px;
height: 20px;
}
.brand__text {
display: flex;
flex-direction: column;
line-height: 1.15;
white-space: nowrap;
transition: opacity var(--dur) var(--ease);
}
.brand__text strong {
font-size: 1.02rem;
letter-spacing: .2px;
}
.brand__text small {
font-size: .72rem;
color: var(--text-muted);
}
.sidebar__nav {
flex: 1;
padding: 14px 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
transition: padding var(--dur) var(--ease);
}
.nav__label {
font-size: .68rem;
font-weight: 700;
letter-spacing: .14em;
text-transform: uppercase;
color: var(--text-faint);
padding: 14px 12px 6px;
transition: opacity var(--dur) var(--ease);
}
.nav__item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
border-radius: var(--r-sm);
color: var(--text-muted);
font-weight: 500;
white-space: nowrap;
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
padding var(--dur) var(--ease),
gap var(--dur) var(--ease);
}
.nav__item:hover {
background: var(--bg-hover);
color: var(--text);
}
.nav__item:active {
transform: scale(.985);
}
.nav__item[aria-current="page"],
.nav__item.is-active {
background: linear-gradient(90deg, var(--accent-soft), var(--accent-softer) 70%, transparent);
color: var(--accent);
font-weight: 620;
}
.nav__item[aria-current="page"]::before,
.nav__item.is-active::before {
content: "";
position: absolute;
inset-inline-start: -12px;
inset-block: 8px;
width: 3px;
border-radius: var(--r-full);
background: var(--accent);
box-shadow: 0 0 12px var(--accent-ring);
transition: inset-inline-start var(--dur) var(--ease);
}
.nav__icon {
width: 19px;
height: 19px;
flex: none;
}
.nav__text {
flex: 1;
transition: opacity var(--dur) var(--ease);
}
.nav__badge {
font-size: .68rem;
font-weight: 700;
padding: 1px 7px;
border-radius: var(--r-full);
background: var(--danger-soft);
color: var(--danger);
transition: opacity var(--dur) var(--ease);
}
.nav__chevron {
width: 15px;
height: 15px;
transition: rotate var(--dur) var(--ease), opacity var(--dur) var(--ease);
}
details[open]>summary .nav__chevron {
rotate: 180deg;
}
.nav__group>summary {
list-style: none;
cursor: pointer;
}
.nav__group>summary::-webkit-details-marker {
display: none;
}
.nav__sublist {
display: flex;
flex-direction: column;
gap: 2px;
padding-inline-start: 30px;
margin-block-start: 2px;
border-inline-start: 1px dashed var(--border);
margin-inline-start: 21px;
}
.nav__sublist .nav__item {
padding-block: 7px;
font-size: .92rem;
}
.nav__sublist .nav__item::before {
display: none;
}
.sidebar__footer {
flex: none;
padding: 12px;
border-block-start: 1px solid var(--border);
transition: padding var(--dur) var(--ease);
}
.sidebar__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: var(--r-md);
transition: background-color var(--dur-fast) var(--ease), padding var(--dur) var(--ease);
}
.sidebar__user:hover {
background: var(--bg-hover);
}
.sidebar__foot-text {
display: flex;
flex-direction: column;
min-width: 0;
transition: opacity var(--dur) var(--ease);
}
.sidebar__foot-text strong {
font-size: .88rem;
}
.sidebar__foot-text small {
color: var(--text-muted);
font-size: .74rem;
}
/* ----------------------------------------------------------------
⭐⭐⭐ 折叠状态(BUG 修复重点)
----------------------------------------------------------------
上一版的问题:隐藏元素用 opacity:0 + visibility:hidden,
仍然占据布局宽度,导致 76px 的列被内部 200px+ 的内容撑破,
出现左侧菜单"悬浮"、"错位"的现象。
修复方案:
1. 用 display:none 彻底从布局移除(不再占位)
2. 导航项改为居中对齐(只剩图标)
3. 品牌区、用户区居中
4. 用户区的退出按钮也隐藏(折叠状态下不显示)
---------------------------------------------------------------- */
.app[data-collapsed="true"] {
grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr);
}
.app[data-collapsed="true"] :is(.brand__text,
.nav__label,
.nav__text,
.nav__badge,
.nav__chevron,
.sidebar__foot-text,
.nav__sublist) {
/* ⭐ 关键:display:none 而非 opacity/visibility,
彻底移除布局占位,避免撑破侧边栏宽度 */
display: none;
}
.app[data-collapsed="true"] .sidebar__brand {
justify-content: center;
padding-inline: 0;
}
.app[data-collapsed="true"] .sidebar__nav {
padding-inline: 10px;
align-items: center;
}
.app[data-collapsed="true"] .nav__item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
/* 折叠时高亮条位置微调(避免贴到侧边栏边缘外) */
.app[data-collapsed="true"] .nav__item[aria-current="page"]::before,
.app[data-collapsed="true"] .nav__item.is-active::before {
inset-inline-start: -10px;
inset-block: 10px;
}
.app[data-collapsed="true"] .sidebar__footer {
padding-inline: 10px;
}
.app[data-collapsed="true"] .sidebar__user {
justify-content: center;
padding-inline: 0;
}
.app[data-collapsed="true"] .sidebar__user .icon-btn {
display: none;
}
/* ---------------- 主区域 ---------------- */
.main {
display: flex;
flex-direction: column;
min-width: 0;
}
.topbar {
position: sticky;
top: 0;
z-index: 30;
height: var(--topbar-h);
flex: none;
display: flex;
align-items: center;
gap: 10px;
padding-inline: 18px;
background: color-mix(in oklab, var(--bg-surface) 78%, transparent);
backdrop-filter: blur(16px) saturate(150%);
-webkit-backdrop-filter: blur(16px) saturate(150%);
border-block-end: 1px solid var(--border);
}
.topbar__spacer {
flex: 1;
}
.global-search {
position: relative;
display: flex;
align-items: center;
width: clamp(180px, 30vw, 380px);
}
.global-search input {
width: 100%;
padding: 8px 52px 8px 36px;
border: 1px solid var(--border);
border-radius: var(--r-full);
background: var(--bg-surface-2);
font-size: .9rem;
transition: border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease),
background-color var(--dur-fast) var(--ease);
}
.global-search input::placeholder {
color: var(--text-faint);
}
.global-search input:focus {
outline: none;
background: var(--bg-surface);
border-color: var(--accent);
box-shadow: 0 0 0 3.5px var(--accent-ring);
}
.global-search .icon {
position: absolute;
inset-inline-start: 12px;
color: var(--text-faint);
pointer-events: none;
}
.global-search kbd {
position: absolute;
inset-inline-end: 8px;
font-size: .68rem;
padding: 2px 7px;
border: 1px solid var(--border);
border-radius: var(--r-xs);
background: var(--bg-sunken);
color: var(--text-faint);
pointer-events: none;
}
.content {
flex: 1;
padding: 22px clamp(14px, 2vw, 28px) 48px;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
max-width: var(--content-max);
margin-inline: auto;
container-type: inline-size;
container-name: content;
}
}
/* =====================================================================
5. COMPONENTS
===================================================================== */
@layer components {
/* ---------- 5.1 头像 ---------- */
.avatar {
--size: 34px;
width: var(--size);
height: var(--size);
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-full);
font-size: calc(var(--size) * .4);
font-weight: 700;
color: light-dark(oklch(0.36 0.12 var(--av-h, 265)), oklch(0.92 0.06 var(--av-h, 265)));
background:
linear-gradient(140deg,
light-dark(oklch(0.90 0.07 var(--av-h, 265)), oklch(0.30 0.07 var(--av-h, 265))),
light-dark(oklch(0.95 0.04 var(--av-h, 265)), oklch(0.24 0.05 var(--av-h, 265))));
box-shadow: inset 0 0 0 1px light-dark(oklch(0.2 0.02 265 / .07), oklch(1 0 0 / .07));
user-select: none;
position: relative;
}
/* 光环头像:isolation 保证 z-index:-1 只在内部生效 */
.avatar--ring {
isolation: isolate;
}
.avatar--ring::after {
content: "";
position: absolute;
inset: -3px;
border-radius: inherit;
background: conic-gradient(from var(--ring-angle), var(--accent), var(--info), var(--success), var(--accent));
z-index: -1;
animation: spin-ring 4s linear infinite;
opacity: .85;
}
@keyframes spin-ring {
to {
--ring-angle: 360deg;
}
}
.avatar-stack {
display: flex;
}
.avatar-stack>.avatar {
margin-inline-start: -9px;
box-shadow: 0 0 0 2px var(--bg-surface);
}
.avatar-stack>.avatar:first-child {
margin-inline-start: 0;
}
/* ---------- 5.2 按钮 ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 8px 14px;
border-radius: var(--r-sm);
font-size: .88rem;
font-weight: 580;
white-space: nowrap;
border: 1px solid transparent;
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease);
user-select: none;
}
.btn:active {
transform: translateY(1px) scale(.985);
}
.btn:disabled {
opacity: .45;
pointer-events: none;
}
.btn--primary {
background: linear-gradient(180deg,
color-mix(in oklab, var(--accent) 88%, #fff), var(--accent));
color: var(--on-accent);
box-shadow: var(--sh-xs),
inset 0 1px 0 light-dark(oklch(1 0 0 / .35), oklch(1 0 0 / .12));
}
.btn--primary:hover {
background: var(--accent-hover);
box-shadow: 0 6px 20px var(--accent-ring);
}
.btn--ghost {
background: var(--bg-surface);
border-color: var(--border);
color: var(--text-muted);
}
.btn--ghost:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-strong);
}
.btn--danger {
background: var(--danger-soft);
color: var(--danger);
}
.btn--danger:hover {
background: color-mix(in oklab, var(--danger) 22%, transparent);
}
.btn--sm {
padding: 5px 10px;
font-size: .82rem;
border-radius: var(--r-xs);
}
.btn--icon {
padding: 0;
width: 34px;
height: 34px;
}
.icon-btn {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
border-radius: var(--r-sm);
color: var(--text-muted);
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease);
}
.icon-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
.icon-btn:active {
transform: scale(.92);
}
.icon-btn .icon {
width: 18px;
height: 18px;
}
.icon-btn__badge {
position: absolute;
top: 4px;
inset-inline-end: 4px;
min-width: 16px;
height: 16px;
padding-inline: 4px;
display: grid;
place-items: center;
font-size: .62rem;
font-weight: 700;
line-height: 1;
color: #fff;
background: var(--danger);
border-radius: var(--r-full);
box-shadow: 0 0 0 2px var(--bg-surface);
animation: pulse-badge 2.6s var(--ease) infinite;
}
@keyframes pulse-badge {
0%,
100% {
box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 0 var(--danger-soft);
}
50% {
box-shadow: 0 0 0 2px var(--bg-surface), 0 0 0 5px transparent;
}
}
/* ---------- 5.3 表单控件 ---------- */
.input,
.select,
.textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--bg-surface);
color: var(--text);
font-size: .9rem;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease),
background-color var(--dur-fast) var(--ease);
}
.input::placeholder,
.textarea::placeholder {
color: var(--text-faint);
}
.input:hover,
.select:hover,
.textarea:hover {
border-color: var(--border-strong);
}
.input:focus,
.select:focus,
.textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3.5px var(--accent-ring);
}
.input:disabled,
.select:disabled {
background: var(--bg-sunken);
color: var(--text-faint);
cursor: not-allowed;
}
.input:user-invalid,
.select:user-invalid,
.textarea:user-invalid {
border-color: var(--danger);
box-shadow: 0 0 0 3.5px var(--danger-soft);
}
.input:user-valid:not(:placeholder-shown) {
border-color: color-mix(in oklab, var(--success) 55%, var(--border));
}
.select {
appearance: none;
padding-inline-end: 32px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 9px center;
background-size: 14px;
cursor: pointer;
}
.select--sm,
.input--sm {
padding-block: 5px;
font-size: .83rem;
}
.textarea {
resize: vertical;
min-height: 76px;
field-sizing: content;
max-height: 200px;
}
.checkbox,
.radio {
--sz: 17px;
appearance: none;
width: var(--sz);
height: var(--sz);
flex: none;
border: 1.5px solid var(--border-strong);
border-radius: 5px;
background: var(--bg-surface);
cursor: pointer;
position: relative;
transition:
background-color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease);
}
.radio {
border-radius: var(--r-full);
}
.checkbox:hover,
.radio:hover {
border-color: var(--accent);
}
.checkbox:active,
.radio:active {
transform: scale(.9);
}
.checkbox:checked,
.radio:checked {
background: var(--accent);
border-color: var(--accent);
box-shadow: 0 2px 8px var(--accent-ring);
}
.checkbox:checked::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 13 4 4L19 7'/%3E%3C/svg%3E") center / 12px no-repeat;
animation: pop-in .18s var(--ease-out-back);
}
.checkbox:indeterminate {
background: var(--accent);
border-color: var(--accent);
}
.checkbox:indeterminate::after {
content: "";
position: absolute;
inset: 0;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.4' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E") center / 12px no-repeat;
}
.radio:checked::after {
content: "";
position: absolute;
inset: 4px;
border-radius: inherit;
background: #fff;
animation: pop-in .18s var(--ease-out-back);
}
@keyframes pop-in {
from {
scale: .3;
opacity: 0;
}
to {
scale: 1;
opacity: 1;
}
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 9px;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.switch__track {
width: 40px;
height: 22px;
flex: none;
border-radius: var(--r-full);
background: var(--border-strong);
position: relative;
transition: background-color var(--dur) var(--ease);
}
.switch__track::after {
content: "";
position: absolute;
top: 3px;
inset-inline-start: 3px;
width: 16px;
height: 16px;
border-radius: var(--r-full);
background: #fff;
box-shadow: var(--sh-xs);
transition: translate var(--dur) var(--ease-out-back);
}
.switch input:checked+.switch__track {
background: var(--accent);
}
.switch input:checked+.switch__track::after {
translate: 18px 0;
}
.switch input:focus-visible+.switch__track {
box-shadow: 0 0 0 3.5px var(--accent-ring);
}
.switch__label {
font-size: .87rem;
color: var(--text-muted);
}
.segmented {
display: inline-flex;
padding: 3px;
border-radius: var(--r-sm);
background: var(--bg-sunken);
border: 1px solid var(--border);
gap: 2px;
}
.segmented label {
position: relative;
}
.segmented input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.segmented span {
display: block;
padding: 4px 12px;
border-radius: var(--r-xs);
font-size: .84rem;
color: var(--text-muted);
cursor: pointer;
white-space: nowrap;
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
}
.segmented span:hover {
color: var(--text);
}
.segmented input:checked+span {
background: var(--bg-surface);
color: var(--accent);
font-weight: 620;
box-shadow: var(--sh-xs);
}
.segmented input:focus-visible+span {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
/* ---------- 5.4 卡片 ---------- */
.card {
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--r-lg);
box-shadow: var(--sh-xs);
overflow: hidden;
transition: box-shadow var(--dur) var(--ease), border-color var(--dur) var(--ease);
}
.card--hoverable:hover {
box-shadow: var(--sh-md);
border-color: var(--border-strong);
}
.card__head {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-block-end: 1px solid var(--border);
}
.card__title {
font-size: 1rem;
font-weight: 650;
display: flex;
align-items: center;
gap: 8px;
}
.card__subtitle {
font-size: .8rem;
color: var(--text-muted);
font-weight: 400;
}
.card__body {
padding: 20px;
}
/* ---------- 5.5 页头 ---------- */
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
}
.breadcrumb {
display: flex;
align-items: center;
gap: 7px;
font-size: .8rem;
color: var(--text-muted);
margin-block-end: 6px;
}
.breadcrumb a {
transition: color var(--dur-fast) var(--ease);
}
.breadcrumb a:hover {
color: var(--accent);
}
.breadcrumb .sep {
color: var(--text-faint);
}
.page-title {
font-size: clamp(1.28rem, 2.4cqi + .8rem, 1.7rem);
font-weight: 700;
letter-spacing: -.015em;
}
.page-desc {
font-size: .86rem;
color: var(--text-muted);
margin-block-start: 4px;
}
.page-actions {
display: flex;
gap: 9px;
flex-wrap: wrap;
}
/* ---------- 5.6 统计卡 ---------- */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.stat {
position: relative;
padding: 18px;
border-radius: var(--r-lg);
background: var(--bg-surface);
border: 1px solid var(--border);
overflow: hidden;
isolation: isolate;
transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.stat::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(420px circle at var(--glow-x) -30%,
color-mix(in oklab, var(--tone, var(--accent)) 22%, transparent), transparent 62%);
opacity: 0;
transition: opacity var(--dur-slow) var(--ease);
z-index: -1;
}
.stat:hover {
transform: translateY(-3px);
box-shadow: var(--sh-md);
}
.stat:hover::before {
opacity: 1;
}
.stat__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.stat__label {
font-size: .82rem;
color: var(--text-muted);
font-weight: 550;
}
.stat__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 11px;
background: color-mix(in oklab, var(--tone, var(--accent)) 14%, transparent);
color: var(--tone, var(--accent));
}
.stat__icon .icon {
width: 18px;
height: 18px;
}
.stat__value {
font-size: clamp(1.6rem, 3.2cqi + .6rem, 2.1rem);
font-weight: 720;
letter-spacing: -.03em;
margin-block: 10px 4px;
font-variant-numeric: tabular-nums;
}
.stat__foot {
display: flex;
align-items: center;
gap: 6px;
font-size: .78rem;
color: var(--text-muted);
}
.trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-weight: 650;
}
.trend--up {
color: var(--success);
}
.trend--down {
color: var(--danger);
}
.trend .icon {
width: 13px;
height: 13px;
}
.stat__spark {
display: flex;
align-items: flex-end;
gap: 3px;
height: 26px;
margin-block-start: 10px;
}
.stat__spark i {
flex: 1;
border-radius: 3px 3px 1px 1px;
background: color-mix(in oklab, var(--tone, var(--accent)) 40%, transparent);
height: calc(var(--h) * 1%);
transition: height var(--dur-slow) var(--ease);
}
.stat:hover .stat__spark i {
background: color-mix(in oklab, var(--tone, var(--accent)) 70%, transparent);
}
/* ---------- 5.7 图表 ---------- */
.chart {
display: flex;
align-items: flex-end;
gap: clamp(6px, 1.4cqi, 16px);
height: 232px;
padding-block-start: 14px;
}
.bar {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
gap: 9px;
position: relative;
min-width: 0;
}
.bar__fill {
width: 100%;
max-width: 46px;
height: calc(var(--v) * 1%);
min-height: 6px;
border-radius: 8px 8px 3px 3px;
background: linear-gradient(180deg,
color-mix(in oklab, var(--accent) 92%, #fff),
color-mix(in oklab, var(--accent) 42%, transparent));
box-shadow: inset 0 1px 0 light-dark(oklch(1 0 0 / .4), oklch(1 0 0 / .1));
transition: filter var(--dur) var(--ease), transform var(--dur) var(--ease);
transform-origin: bottom;
}
.bar:hover .bar__fill {
filter: brightness(1.12) saturate(1.15);
transform: scaleY(1.02);
}
.bar__label {
font-size: .7rem;
color: var(--text-faint);
white-space: nowrap;
}
.bar::after {
content: attr(data-val);
position: absolute;
bottom: calc(var(--v) * 1% + 34px);
padding: 3px 8px;
border-radius: var(--r-xs);
font-size: .72rem;
font-weight: 650;
background: var(--bg-elevated);
color: var(--text);
border: 1px solid var(--border);
box-shadow: var(--sh-sm);
opacity: 0;
translate: 0 5px;
pointer-events: none;
transition: opacity var(--dur-fast) var(--ease), translate var(--dur-fast) var(--ease);
white-space: nowrap;
}
.bar:hover::after {
opacity: 1;
translate: 0 0;
}
/* ---------- 5.8 徽章 ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: var(--r-full);
font-size: .76rem;
font-weight: 620;
white-space: nowrap;
border: 1px solid transparent;
}
.badge::before {
content: "";
width: 6px;
height: 6px;
border-radius: inherit;
flex: none;
background: currentColor;
box-shadow: 0 0 0 2.5px color-mix(in oklab, currentColor 22%, transparent);
}
.badge--success {
color: var(--success);
background: var(--success-soft);
}
.badge--warn {
color: var(--warn);
background: var(--warn-soft);
}
.badge--danger {
color: var(--danger);
background: var(--danger-soft);
}
.badge--info {
color: var(--info);
background: var(--info-soft);
}
.badge--muted {
color: var(--text-muted);
background: var(--bg-sunken);
}
.badge--plain::before {
display: none;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 11px;
border-radius: var(--r-full);
font-size: .8rem;
background: var(--bg-sunken);
border: 1px solid var(--border);
color: var(--text-muted);
transition: background-color var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease);
}
.chip:hover {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border-strong);
}
.chip[aria-pressed="true"],
.chip.is-active {
background: var(--accent-soft);
color: var(--accent);
border-color: color-mix(in oklab, var(--accent) 35%, transparent);
}
/* ---------- 5.9 表格 ---------- */
.table-card {
display: flex;
flex-direction: column;
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 14px 18px;
border-block-end: 1px solid var(--border);
}
.toolbar__group {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
}
.search-field {
position: relative;
display: flex;
align-items: center;
width: clamp(200px, 26cqi, 320px);
}
.search-field .icon {
position: absolute;
inset-inline-start: 11px;
color: var(--text-faint);
pointer-events: none;
width: 16px;
height: 16px;
}
.search-field input {
width: 100%;
padding: 8px 60px 8px 34px;
border: 1px solid var(--border);
border-radius: var(--r-sm);
background: var(--bg-surface-2);
font-size: .88rem;
transition:
border-color var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease),
background-color var(--dur-fast) var(--ease);
}
.search-field input:focus {
outline: none;
background: var(--bg-surface);
border-color: var(--accent);
box-shadow: 0 0 0 3.5px var(--accent-ring);
}
.search-field__hint {
position: absolute;
inset-inline-end: 8px;
font-size: .66rem;
padding: 2px 6px;
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text-faint);
background: var(--bg-sunken);
pointer-events: none;
}
.bulkbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding: 10px 18px;
background: linear-gradient(90deg, var(--accent-soft), transparent 70%);
border-block-end: 1px solid var(--border);
animation: slide-down var(--dur) var(--ease);
}
@keyframes slide-down {
from {
opacity: 0;
translate: 0 -8px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.bulkbar__count {
font-size: .86rem;
color: var(--text-muted);
}
.bulkbar__count strong {
color: var(--accent);
font-size: 1rem;
}
.table-wrap {
overflow: auto;
max-height: min(64vh, 720px);
overscroll-behavior: contain;
position: relative;
}
.data-table {
width: 100%;
min-width: 920px;
font-size: .875rem;
}
.data-table thead th {
position: sticky;
top: 0;
z-index: 5;
background: var(--bg-surface-2);
backdrop-filter: blur(8px);
padding: 11px 14px;
text-align: start;
font-size: .76rem;
font-weight: 700;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--text-muted);
white-space: nowrap;
border-block-end: 1px solid var(--border);
user-select: none;
}
.th-sortable {
cursor: pointer;
transition: color var(--dur-fast) var(--ease), background-color var(--dur-fast) var(--ease);
}
.th-sortable:hover {
color: var(--text);
background: var(--bg-hover);
}
.th-inner {
display: inline-flex;
align-items: center;
gap: 6px;
}
.th-sort-icon {
width: 13px;
height: 13px;
flex: none;
opacity: .3;
transition: opacity var(--dur-fast) var(--ease), rotate var(--dur) var(--ease), color var(--dur-fast) var(--ease);
}
.th-sortable:hover .th-sort-icon {
opacity: .65;
}
th[aria-sort="ascending"] .th-sort-icon {
opacity: 1;
color: var(--accent);
rotate: 0deg;
}
th[aria-sort="descending"] .th-sort-icon {
opacity: 1;
color: var(--accent);
rotate: 180deg;
}
th[aria-sort]:not([aria-sort="none"]) {
color: var(--accent);
}
.data-table tbody td {
padding: 11px 14px;
border-block-end: 1px solid var(--border);
vertical-align: middle;
transition: background-color var(--dur-fast) var(--ease);
}
.data-table[data-zebra="true"] tbody tr:nth-child(even) {
background: color-mix(in oklab, var(--bg-surface-2) 62%, transparent);
}
.data-table tbody tr {
transition: background-color var(--dur-fast) var(--ease), box-shadow var(--dur-fast) var(--ease);
}
.data-table tbody tr:hover {
background: var(--bg-hover);
}
.data-table tbody tr.is-selected {
background: var(--accent-softer);
box-shadow: inset 3px 0 0 var(--accent);
}
.data-table tbody tr.is-selected:hover {
background: var(--accent-soft);
}
.data-table[data-density="compact"] tbody td {
padding-block: 6px;
}
.data-table[data-density="compact"] thead th {
padding-block: 8px;
}
/* 列显隐:用 [data-hide~="列名"] 精确匹配空格分隔的属性值 */
.data-table[data-hide~="check"] [data-col="check"],
.data-table[data-hide~="id"] [data-col="id"],
.data-table[data-hide~="user"] [data-col="user"],
.data-table[data-hide~="email"] [data-col="email"],
.data-table[data-hide~="phone"] [data-col="phone"],
.data-table[data-hide~="dept"] [data-col="dept"],
.data-table[data-hide~="role"] [data-col="role"],
.data-table[data-hide~="status"] [data-col="status"],
.data-table[data-hide~="lastLogin"] [data-col="lastLogin"],
.data-table[data-hide~="createdAt"] [data-col="createdAt"] {
display: none;
}
.user-cell {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.user-cell__meta {
display: flex;
flex-direction: column;
line-height: 1.25;
min-width: 0;
}
.user-cell__name {
font-weight: 600;
}
.user-cell__sub {
font-size: .76rem;
color: var(--text-muted);
}
.cell-actions {
display: flex;
align-items: center;
gap: 2px;
justify-content: flex-end;
}
.table-empty {
padding: 60px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--text-muted);
animation: fade-in var(--dur) var(--ease);
}
.table-empty__art {
width: 76px;
height: 76px;
display: grid;
place-items: center;
border-radius: var(--r-full);
background: var(--bg-sunken);
color: var(--text-faint);
}
.table-empty__art .icon {
width: 32px;
height: 32px;
}
.skeleton {
height: 12px;
border-radius: var(--r-full);
background: linear-gradient(90deg,
var(--bg-sunken) 25%,
color-mix(in oklab, var(--bg-sunken) 60%, var(--bg-hover)) 50%,
var(--bg-sunken) 75%);
background-size: 220% 100%;
animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
from {
background-position: 220% 0;
}
to {
background-position: -20% 0;
}
}
/* ---------- 5.10 分页 ---------- */
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
flex-wrap: wrap;
padding: 14px 18px;
border-block-start: 1px solid var(--border);
background: var(--bg-surface);
}
.pagination__group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: .84rem;
color: var(--text-muted);
}
.pagination__group strong {
color: var(--text);
font-variant-numeric: tabular-nums;
}
.pager {
display: flex;
align-items: center;
gap: 4px;
}
.page-btn {
min-width: 34px;
height: 34px;
padding-inline: 9px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--r-sm);
font-size: .85rem;
font-weight: 560;
color: var(--text-muted);
border: 1px solid transparent;
font-variant-numeric: tabular-nums;
transition:
background-color var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease),
border-color var(--dur-fast) var(--ease),
transform var(--dur-fast) var(--ease);
}
.page-btn:hover:not(:disabled):not(.is-current) {
background: var(--bg-hover);
color: var(--text);
border-color: var(--border);
}
.page-btn:active:not(:disabled) {
transform: scale(.94);
}
.page-btn:disabled {
opacity: .35;
cursor: not-allowed;
pointer-events: none;
}
.page-btn.is-current {
background: var(--accent);
color: var(--on-accent);
border-color: var(--accent);
box-shadow: 0 3px 12px var(--accent-ring);
font-weight: 680;
}
.page-btn .icon {
width: 15px;
height: 15px;
}
.page-ellipsis {
min-width: 26px;
text-align: center;
color: var(--text-faint);
user-select: none;
}
.jump-input {
width: 58px;
padding: 5px 8px;
text-align: center;
border: 1px solid var(--border);
border-radius: var(--r-xs);
background: var(--bg-surface);
font-size: .84rem;
font-variant-numeric: tabular-nums;
}
.jump-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-ring);
}
.jump-input::-webkit-outer-spin-button,
.jump-input::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
/* ---------- 5.11 Popover ---------- */
.popover {
position: fixed;
inset: auto;
top: calc(var(--topbar-h) + 10px);
right: 20px;
margin: 0;
width: min(320px, calc(100vw - 32px));
padding: 0;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--bg-elevated);
box-shadow: var(--sh-lg);
color: var(--text);
opacity: 0;
transform: translateY(-8px) scale(.97);
transition:
opacity var(--dur) var(--ease),
transform var(--dur) var(--ease-out-back),
display var(--dur) allow-discrete,
overlay var(--dur) allow-discrete;
}
.popover:popover-open {
opacity: 1;
transform: none;
}
@starting-style {
.popover:popover-open {
opacity: 0;
transform: translateY(-8px) scale(.97);
}
}
.popover::backdrop {
background: transparent;
}
.popover__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-block-end: 1px solid var(--border);
font-size: .88rem;
font-weight: 650;
}
.popover__body {
padding: 10px;
max-height: 340px;
overflow-y: auto;
}
.col-list {
display: flex;
flex-direction: column;
gap: 1px;
}
.col-list label {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 9px;
border-radius: var(--r-xs);
font-size: .87rem;
cursor: pointer;
transition: background-color var(--dur-fast) var(--ease);
}
.col-list label:hover {
background: var(--bg-hover);
}
.notif-item {
display: flex;
gap: 11px;
padding: 10px;
border-radius: var(--r-sm);
transition: background-color var(--dur-fast) var(--ease);
cursor: pointer;
}
.notif-item:hover {
background: var(--bg-hover);
}
.notif-item__icon {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-sm);
background: var(--accent-soft);
color: var(--accent);
}
.notif-item__body {
min-width: 0;
}
.notif-item__title {
font-size: .86rem;
font-weight: 600;
}
.notif-item__desc {
font-size: .78rem;
color: var(--text-muted);
}
.notif-item__time {
font-size: .72rem;
color: var(--text-faint);
margin-block-start: 3px;
}
.notif-unread {
position: relative;
}
.notif-unread::after {
content: "";
position: absolute;
top: 16px;
left: 34px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 2px var(--bg-elevated);
}
/* ---------- 5.12 对话框 ---------- */
.modal {
width: min(680px, calc(100vw - 32px));
max-height: calc(100dvh - 60px);
border-radius: var(--r-lg);
background: var(--bg-elevated);
border: 1px solid var(--border);
box-shadow: var(--sh-lg);
color: var(--text);
opacity: 0;
transform: translateY(16px) scale(.97);
transition:
opacity var(--dur) var(--ease),
transform var(--dur) var(--ease-out-back),
display var(--dur) allow-discrete,
overlay var(--dur) allow-discrete;
}
.modal[open] {
opacity: 1;
transform: none;
}
@starting-style {
.modal[open] {
opacity: 0;
transform: translateY(16px) scale(.97);
}
}
.modal::backdrop {
background: color-mix(in oklab, #070a10 58%, transparent);
backdrop-filter: blur(6px);
opacity: 0;
transition:
opacity var(--dur) var(--ease),
display var(--dur) allow-discrete,
overlay var(--dur) allow-discrete;
}
.modal[open]::backdrop {
opacity: 1;
}
@starting-style {
.modal[open]::backdrop {
opacity: 0;
}
}
.modal--sm {
width: min(440px, calc(100vw - 32px));
}
.modal__head {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 18px 20px 14px;
border-block-end: 1px solid var(--border);
}
.modal__head h2 {
font-size: 1.06rem;
}
.modal__head p {
font-size: .82rem;
color: var(--text-muted);
margin-block-start: 3px;
}
.modal__head .icon-btn {
margin-inline-start: auto;
margin-block-start: -4px;
}
.modal__body {
padding: 20px;
overflow-y: auto;
max-height: calc(100dvh - 260px);
}
.modal__foot {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
padding: 14px 20px;
border-block-start: 1px solid var(--border);
background: color-mix(in oklab, var(--bg-surface-2) 60%, transparent);
}
.modal__icon {
width: 42px;
height: 42px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-md);
background: var(--danger-soft);
color: var(--danger);
}
.modal__icon .icon {
width: 21px;
height: 21px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.field--full {
grid-column: 1 / -1;
}
.field>label,
.field>legend {
font-size: .82rem;
font-weight: 600;
color: var(--text-muted);
padding: 0;
}
.field .req {
color: var(--danger);
}
.field__hint {
font-size: .74rem;
color: var(--text-faint);
}
.field__err {
font-size: .75rem;
color: var(--danger);
display: none;
align-items: center;
gap: 4px;
animation: fade-in var(--dur-fast) var(--ease);
}
/* :has() 父选择器:字段内有非法子元素时显示错误提示 */
.field:has(:user-invalid) .field__err {
display: flex;
}
.field:has(:user-invalid) .field__hint {
display: none;
}
/* ---------- 5.13 Toast ---------- */
.toast-stack {
position: fixed;
inset-block-end: 22px;
inset-inline-end: 22px;
z-index: 9998;
display: flex;
flex-direction: column;
gap: 10px;
width: min(360px, calc(100vw - 32px));
pointer-events: none;
}
.toast {
pointer-events: auto;
position: relative;
display: flex;
align-items: flex-start;
gap: 11px;
padding: 13px 14px;
border-radius: var(--r-md);
background: var(--bg-elevated);
border: 1px solid var(--border);
box-shadow: var(--sh-lg);
overflow: hidden;
animation: toast-in .38s var(--ease-out-back);
}
.toast.is-leaving {
animation: toast-out .24s var(--ease) forwards;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateX(28px) scale(.94);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes toast-out {
to {
opacity: 0;
transform: translateX(28px) scale(.94);
}
}
.toast__icon {
width: 30px;
height: 30px;
flex: none;
display: grid;
place-items: center;
border-radius: var(--r-sm);
}
.toast__icon .icon {
width: 16px;
height: 16px;
}
.toast--success .toast__icon {
background: var(--success-soft);
color: var(--success);
}
.toast--error .toast__icon {
background: var(--danger-soft);
color: var(--danger);
}
.toast--info .toast__icon {
background: var(--info-soft);
color: var(--info);
}
.toast--warn .toast__icon {
background: var(--warn-soft);
color: var(--warn);
}
.toast__content {
flex: 1;
min-width: 0;
}
.toast__title {
font-size: .88rem;
font-weight: 640;
}
.toast__desc {
font-size: .8rem;
color: var(--text-muted);
margin-block-start: 2px;
}
.toast__close {
color: var(--text-faint);
padding: 2px;
margin: -2px -2px 0 0;
border-radius: var(--r-xs);
}
.toast__close:hover {
color: var(--text);
background: var(--bg-hover);
}
.toast__bar {
position: absolute;
inset-block-end: 0;
inset-inline-start: 0;
height: 2.5px;
width: 100%;
background: currentColor;
opacity: .5;
transform-origin: 0 50%;
animation: toast-countdown linear forwards;
}
@keyframes toast-countdown {
from {
transform: scaleX(1);
}
to {
transform: scaleX(0);
}
}
/* ---------- 5.14 进度 / 分割线 / Tabs ---------- */
.progress {
height: 7px;
border-radius: var(--r-full);
background: var(--bg-sunken);
overflow: hidden;
}
.progress>i {
display: block;
height: 100%;
width: calc(var(--p, 0) * 1%);
border-radius: inherit;
background: linear-gradient(90deg, var(--accent),
color-mix(in oklab, var(--accent) 50%, var(--info)));
transition: width var(--dur-slow) var(--ease);
}
.divider {
height: 1px;
background: var(--border);
border: 0;
margin: 0;
}
.divider--v {
width: 1px;
height: 20px;
background: var(--border);
}
.tabs {
display: flex;
gap: 2px;
position: relative;
border-block-end: 1px solid var(--border);
padding-inline: 6px;
overflow-x: auto;
}
.tab {
position: relative;
padding: 10px 14px;
font-size: .87rem;
font-weight: 560;
color: var(--text-muted);
white-space: nowrap;
transition: color var(--dur-fast) var(--ease);
}
.tab:hover {
color: var(--text);
}
.tab[aria-selected="true"] {
color: var(--accent);
font-weight: 650;
}
.tab[aria-selected="true"]::after {
content: "";
position: absolute;
inset-inline: 10px;
inset-block-end: -1px;
height: 2px;
border-radius: var(--r-full);
background: var(--accent);
animation: tab-slide var(--dur) var(--ease);
}
@keyframes tab-slide {
from {
transform: scaleX(.3);
opacity: 0;
}
to {
transform: none;
opacity: 1;
}
}
}
/* =====================================================================
6. UTILITIES
===================================================================== */
@layer utilities {
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.items-center {
align-items: center;
}
.justify-between {
justify-content: space-between;
}
.gap-1 {
gap: 4px;
}
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
.gap-4 {
gap: 16px;
}
.wrap {
flex-wrap: wrap;
}
.ml-auto {
margin-inline-start: auto;
}
.grow {
flex: 1;
min-width: 0;
}
.text-muted {
color: var(--text-muted);
}
.text-faint {
color: var(--text-faint);
}
.text-sm {
font-size: .82rem;
}
.text-xs {
font-size: .74rem;
}
.fw-600 {
font-weight: 600;
}
.nowrap {
white-space: nowrap;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mono {
font-variant-numeric: tabular-nums;
}
.hidden {
display: none !important;
}
}
/* =====================================================================
7. MOTION —— 动画 / 容器查询 / 响应式
===================================================================== */
@layer motion {
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
.animate-rise {
animation: rise-in .46s var(--ease) backwards;
}
/* 滚动驱动动画:柱状图边滚边生长 */
@supports (animation-timeline: view()) {
.bar__fill {
animation: bar-grow linear both;
animation-timeline: view();
animation-range: entry 15% cover 38%;
}
@keyframes bar-grow {
from {
transform: scaleY(.04);
opacity: .35;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
}
/* 视图过渡:主题切换圆形揭示 */
@supports (view-transition-name: none) {
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: .46s;
animation-timing-function: var(--ease);
mix-blend-mode: normal;
}
::view-transition-new(root) {
animation-name: vtx-reveal;
clip-path: circle(0% at var(--vt-x, 50%) var(--vt-y, 50%));
}
@keyframes vtx-reveal {
to {
clip-path: circle(160% at var(--vt-x, 50%) var(--vt-y, 50%));
}
}
}
/* ---------- 容器查询 ---------- */
@container content (max-width: 1080px) {
.pagination {
justify-content: center;
}
}
@container content (max-width: 900px) {
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.chart {
height: 190px;
}
.bar__label {
font-size: 0;
}
.bar__label::first-letter {
font-size: .7rem;
}
}
@container content (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
}
.table-toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar__group {
width: 100%;
}
.search-field {
width: 100%;
}
.form-grid {
grid-template-columns: 1fr;
}
}
/* ---------- 响应式:平板尺寸自动折叠侧边栏 ---------- */
@media (min-width: 721px) and (max-width: 1024px) {
.app {
grid-template-columns: var(--sidebar-w-collapsed) minmax(0, 1fr);
}
.app :is(.brand__text, .nav__label, .nav__text, .nav__badge,
.nav__chevron, .sidebar__foot-text, .nav__sublist) {
display: none;
}
.app .sidebar__brand {
justify-content: center;
padding-inline: 0;
}
.app .sidebar__nav {
padding-inline: 10px;
align-items: center;
}
.app .nav__item {
justify-content: center;
gap: 0;
padding: 10px 0;
}
.app .nav__item[aria-current="page"]::before,
.app .nav__item.is-active::before {
inset-inline-start: -10px;
inset-block: 10px;
}
.app .sidebar__footer {
padding-inline: 10px;
}
.app .sidebar__user {
justify-content: center;
padding-inline: 0;
}
.app .sidebar__user .icon-btn {
display: none;
}
}
/* ---------- 响应式:移动端抽屉侧边栏 ---------- */
@media (max-width: 720px) {
.app,
.app[data-collapsed="true"] {
grid-template-columns: minmax(0, 1fr);
}
.sidebar {
position: fixed;
inset-block: 0;
inset-inline-start: 0;
width: var(--sidebar-w);
translate: -100% 0;
transition: translate var(--dur-slow) var(--ease);
box-shadow: var(--sh-lg);
z-index: 100;
}
.app[data-mobile-open="true"] .sidebar {
translate: 0 0;
}
/* 移动端抽屉打开时,强制恢复所有被折叠的元素 */
.app[data-mobile-open="true"] .brand__text {
display: flex;
}
.app[data-mobile-open="true"] .nav__label {
display: block;
}
.app[data-mobile-open="true"] .nav__text {
display: inline;
}
.app[data-mobile-open="true"] .nav__badge {
display: inline-block;
}
.app[data-mobile-open="true"] .nav__chevron {
display: block;
}
.app[data-mobile-open="true"] .sidebar__foot-text {
display: flex;
}
.app[data-mobile-open="true"] .nav__sublist {
display: flex;
}
.app[data-mobile-open="true"] .sidebar__user .icon-btn {
display: grid;
}
.app[data-mobile-open="true"] .sidebar__brand {
justify-content: flex-start;
padding-inline: 18px;
}
.app[data-mobile-open="true"] .sidebar__nav {
padding-inline: 12px;
align-items: stretch;
}
.app[data-mobile-open="true"] .nav__item {
justify-content: flex-start;
gap: 12px;
padding: 10px 12px;
}
.app[data-mobile-open="true"] .sidebar__user {
justify-content: flex-start;
padding-inline: 8px;
}
.global-search {
display: none;
}
}
/* 遮罩(移动端抽屉打开时) */
@media (max-width: 720px) {
.app[data-mobile-open="true"]::before {
content: "";
position: fixed;
inset: 0;
background: color-mix(in oklab, #070a10 55%, transparent);
backdrop-filter: blur(3px);
z-index: 90;
animation: fade-in var(--dur) var(--ease);
}
}
/* ---------- 降低动效 ---------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: .01ms !important;
animation-iteration-count: 1 !important;
transition-duration: .01ms !important;
scroll-behavior: auto !important;
}
}
/* ---------- 高对比度 ---------- */
@media (prefers-contrast: more) {
:root {
--border: light-dark(#8a94a6, #6b7688);
--border-strong: light-dark(#4b5563, #9aa5b8);
}
}
/* ---------- 打印 ---------- */
@media print {
.sidebar,
.topbar,
.pagination,
.table-toolbar,
.bulkbar,
.scroll-progress {
display: none !important;
}
.app {
grid-template-columns: 1fr;
}
.card {
box-shadow: none;
border-color: #ccc;
}
}
}
</style>
</head>
<body>
<!-- ================================================================
SVG 图标精灵
================================================================ -->
<svg class="sprite" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg">
<defs>
<symbol id="i-logo" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2 3 7v10l9 5 9-5V7z" />
<path d="m3 7 9 5 9-5" />
<path d="M12 12v10" />
</symbol>
<symbol id="i-dashboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="9" rx="1.6" />
<rect x="14" y="3" width="7" height="5" rx="1.6" />
<rect x="14" y="12" width="7" height="9" rx="1.6" />
<rect x="3" y="16" width="7" height="5" rx="1.6" />
</symbol>
<symbol id="i-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18" />
<path d="m7 15 3-4 3 3 5-7" />
</symbol>
<symbol id="i-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</symbol>
<symbol id="i-box" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8 12 3 3 8v8l9 5 9-5z" />
<path d="m3 8 9 5 9-5" />
<path d="M12 13v8" />
</symbol>
<symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
<path d="M9 15h6M9 18h4" />
</symbol>
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<path d="m9 12 2 2 4-4" />
</symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.7 21a2 2 0 0 1-3.4 0" />
</symbol>
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="7" />
<path d="m21 21-4.3-4.3" />
</symbol>
<symbol id="i-panel" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2.5" />
<path d="M9 3v18" />
</symbol>
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4" />
<path
d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
</symbol>
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</symbol>
<symbol id="i-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</symbol>
<symbol id="i-edit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4z" />
</symbol>
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18" />
<path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2" />
<path d="m19 6-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M5 21h14" />
</symbol>
<symbol id="i-columns" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2.5" />
<path d="M9 3v18M15 3v18" />
</symbol>
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</symbol>
<symbol id="i-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round">
<path d="M18 6 6 18M6 6l12 12" />
</symbol>
<symbol id="i-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<path d="m5 13 4 4L19 7" />
</symbol>
<symbol id="i-chevron-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</symbol>
<symbol id="i-chevron-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />
</symbol>
<symbol id="i-chevrons-left" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m11 17-5-5 5-5M18 17l-5-5 5-5" />
</symbol>
<symbol id="i-chevrons-right" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m13 17 5-5-5-5M6 17l5-5-5-5" />
</symbol>
<symbol id="i-chevron-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</symbol>
<symbol id="i-trend-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<path d="m3 17 6-6 4 4 8-8" />
<path d="M15 7h6v6" />
</symbol>
<symbol id="i-trend-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<path d="m3 7 6 6 4-4 8 8" />
<path d="M15 17h6v-6" />
</symbol>
<symbol id="i-clock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</symbol>
<symbol id="i-more" viewBox="0 0 24 24" fill="currentColor" stroke="none">
<circle cx="5" cy="12" r="1.7" />
<circle cx="12" cy="12" r="1.7" />
<circle cx="19" cy="12" r="1.7" />
</symbol>
<symbol id="i-logout" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="m16 17 5-5-5-5" />
<path d="M21 12H9" />
</symbol>
<symbol id="i-alert" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
stroke-linecap="round" stroke-linejoin="round">
<path d="M12 9v4" />
<path d="M12 17h.01" />
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
</symbol>
<symbol id="i-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9"
stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="9" />
<path d="M12 16v-4M12 8h.01" />
</symbol>
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M22 12h-6l-2 3h-4l-2-3H2" />
<path
d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</symbol>
<symbol id="i-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</symbol>
<symbol id="i-sort" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m8 9 4-4 4 4" />
<path d="m16 15-4 4-4-4" />
</symbol>
<symbol id="i-arrow-up" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m5 12 7-7 7 7M12 19V5" />
</symbol>
<symbol id="i-arrow-down" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" stroke-linejoin="round">
<path d="m19 12-7 7-7-7M12 5v14" />
</symbol>
</defs>
</svg>
<!-- 顶部滚动进度条 -->
<div class="scroll-progress" aria-hidden="true"></div>
<!-- ================================================================
应用主体
================================================================ -->
<div class="app" id="app" data-collapsed="false" data-mobile-open="false">
<!-- ============ 侧边栏 ============ -->
<aside class="sidebar" id="sidebar" aria-label="侧边导航">
<div class="sidebar__brand">
<span class="brand__logo"><svg class="icon">
<use href="#i-logo" />
</svg></span>
<span class="brand__text">
<strong>Nova Admin</strong>
<small>企业管理平台 v3.0</small>
</span>
</div>
<nav class="sidebar__nav" aria-label="主导航">
<p class="nav__label">概览</p>
<a class="nav__item" href="#" data-nav="dashboard">
<svg class="nav__icon">
<use href="#i-dashboard" />
</svg>
<span class="nav__text">仪表盘</span>
</a>
<a class="nav__item" href="#" data-nav="analytics">
<svg class="nav__icon">
<use href="#i-chart" />
</svg>
<span class="nav__text">数据分析</span>
<span class="nav__badge">NEW</span>
</a>
<p class="nav__label">业务管理</p>
<a class="nav__item is-active" href="#" data-nav="users" aria-current="page">
<svg class="nav__icon">
<use href="#i-users" />
</svg>
<span class="nav__text">用户管理</span>
<span class="nav__badge" id="navUserBadge">137</span>
</a>
<a class="nav__item" href="#" data-nav="org">
<svg class="nav__icon">
<use href="#i-shield" />
</svg>
<span class="nav__text">组织与权限</span>
</a>
<a class="nav__item" href="#" data-nav="products">
<svg class="nav__icon">
<use href="#i-box" />
</svg>
<span class="nav__text">商品中心</span>
</a>
<a class="nav__item" href="#" data-nav="orders">
<svg class="nav__icon">
<use href="#i-file" />
</svg>
<span class="nav__text">订单管理</span>
</a>
<p class="nav__label">系统</p>
<details class="nav__group" name="nav-acc">
<summary class="nav__item">
<svg class="nav__icon">
<use href="#i-settings" />
</svg>
<span class="nav__text">系统设置</span>
<svg class="nav__chevron">
<use href="#i-chevron-down" />
</svg>
</summary>
<ul class="nav__sublist">
<li><a class="nav__item" href="#" data-nav="sys-basic"><span class="nav__text">基础配置</span></a>
</li>
<li><a class="nav__item" href="#" data-nav="sys-log"><span class="nav__text">操作日志</span></a>
</li>
<li><a class="nav__item" href="#" data-nav="sys-api"><span class="nav__text">接口密钥</span></a>
</li>
</ul>
</details>
<a class="nav__item" href="#" data-nav="security">
<svg class="nav__icon">
<use href="#i-lock" />
</svg>
<span class="nav__text">安全中心</span>
</a>
</nav>
<div class="sidebar__footer">
<div class="sidebar__user">
<span class="avatar" style="--size:34px; --av-h:265">超</span>
<span class="sidebar__foot-text">
<strong>超级管理员</strong>
<small>admin@nova.com</small>
</span>
<button class="icon-btn" aria-label="退出登录" title="退出登录"
style="margin-inline-start:auto; width:30px;height:30px">
<svg class="icon">
<use href="#i-logout" />
</svg>
</button>
</div>
</div>
</aside>
<!-- ============ 主区域 ============ -->
<div class="main">
<header class="topbar">
<button class="icon-btn" id="sidebarToggle" aria-label="收起/展开侧边栏" aria-expanded="true">
<svg class="icon">
<use href="#i-panel" />
</svg>
</button>
<div class="global-search">
<svg class="icon">
<use href="#i-search" />
</svg>
<input type="search" id="globalSearch" placeholder="全局搜索:用户、订单、文档…" aria-label="全局搜索"
autocomplete="off">
<kbd>⌘K</kbd>
</div>
<div class="topbar__spacer"></div>
<div class="segmented" role="group" aria-label="主题">
<label><input type="radio" name="theme" value="light" checked><span><svg class="icon"
style="vertical-align:-3px">
<use href="#i-sun" />
</svg></span></label>
<label><input type="radio" name="theme" value="dark"><span><svg class="icon"
style="vertical-align:-3px">
<use href="#i-moon" />
</svg></span></label>
<label><input type="radio" name="theme" value="system"><span>自动</span></label>
</div>
<button class="icon-btn" id="refreshAll" aria-label="刷新数据" title="刷新数据">
<svg class="icon">
<use href="#i-refresh" />
</svg>
</button>
<button class="icon-btn" popovertarget="notifPop" aria-label="通知" title="通知">
<svg class="icon">
<use href="#i-bell" />
</svg>
<span class="icon-btn__badge">3</span>
</button>
<button class="icon-btn" id="fullscreenBtn" aria-label="全屏" title="全屏">
<svg class="icon">
<use href="#i-panel" />
</svg>
</button>
<span class="avatar avatar--ring" style="--size:34px; --av-h:280; cursor:pointer" title="超级管理员">超</span>
</header>
<!-- 通知 Popover -->
<div id="notifPop" popover class="popover">
<div class="popover__head">
<span>通知中心</span>
<button class="btn btn--ghost btn--sm" id="markAllRead">全部已读</button>
</div>
<div class="popover__body">
<div class="notif-item notif-unread">
<span class="notif-item__icon"><svg class="icon">
<use href="#i-users" />
</svg></span>
<div class="notif-item__body">
<p class="notif-item__title">新增 12 位注册用户</p>
<p class="notif-item__desc">来自「技术研发部」的批量导入任务已完成</p>
<p class="notif-item__time">3 分钟前</p>
</div>
</div>
<div class="notif-item notif-unread">
<span class="notif-item__icon" style="background:var(--warn-soft);color:var(--warn)"><svg
class="icon">
<use href="#i-alert" />
</svg></span>
<div class="notif-item__body">
<p class="notif-item__title">账号异常登录告警</p>
<p class="notif-item__desc">检测到异地登录行为,请及时核查</p>
<p class="notif-item__time">42 分钟前</p>
</div>
</div>
<div class="notif-item notif-unread">
<span class="notif-item__icon" style="background:var(--success-soft);color:var(--success)"><svg
class="icon">
<use href="#i-check" />
</svg></span>
<div class="notif-item__body">
<p class="notif-item__title">数据库备份成功</p>
<p class="notif-item__desc">自动备份任务已完成,耗时 2 分 18 秒</p>
<p class="notif-item__time">2 小时前</p>
</div>
</div>
</div>
</div>
<main class="content" id="content">
<!-- 页头 -->
<div class="page-head">
<div>
<nav class="breadcrumb" aria-label="面包屑">
<a href="#">首页</a>
<span class="sep">/</span>
<a href="#">业务管理</a>
<span class="sep">/</span>
<span aria-current="page">用户管理</span>
</nav>
<h1 class="page-title">用户管理</h1>
<p class="page-desc">管理平台全部账号的创建、权限、状态与生命周期。</p>
</div>
<div class="page-actions">
<button class="btn btn--ghost" id="importBtn"><svg class="icon">
<use href="#i-download" />
</svg>导入</button>
<button class="btn btn--ghost" id="exportBtn2"><svg class="icon">
<use href="#i-download" />
</svg>导出</button>
<button class="btn btn--primary" id="addUserBtn2"><svg class="icon">
<use href="#i-plus" />
</svg>新建用户</button>
</div>
</div>
<!-- 统计卡 -->
<section class="stats-grid" aria-label="数据概览">
<article class="stat" style="--tone: var(--accent)">
<div class="stat__top">
<span class="stat__label">总用户数</span>
<span class="stat__icon"><svg class="icon">
<use href="#i-users" />
</svg></span>
</div>
<div class="stat__value" data-count="137">0</div>
<div class="stat__foot">
<span class="trend trend--up"><svg class="icon">
<use href="#i-trend-up" />
</svg>12.4%</span>
<span>较上月</span>
</div>
<div class="stat__spark" aria-hidden="true">
<i style="--h:38"></i><i style="--h:52"></i><i style="--h:44"></i><i style="--h:66"></i>
<i style="--h:58"></i><i style="--h:80"></i><i style="--h:96"></i>
</div>
</article>
<article class="stat" style="--tone: var(--success)">
<div class="stat__top">
<span class="stat__label">活跃账号</span>
<span class="stat__icon"><svg class="icon">
<use href="#i-check" />
</svg></span>
</div>
<div class="stat__value" data-count="98">0</div>
<div class="stat__foot">
<span class="trend trend--up"><svg class="icon">
<use href="#i-trend-up" />
</svg>6.1%</span>
<span>活跃率 71.5%</span>
</div>
<div class="stat__spark" aria-hidden="true">
<i style="--h:60"></i><i style="--h:48"></i><i style="--h:70"></i><i style="--h:62"></i>
<i style="--h:84"></i><i style="--h:76"></i><i style="--h:90"></i>
</div>
</article>
<article class="stat" style="--tone: var(--warn)">
<div class="stat__top">
<span class="stat__label">待审核</span>
<span class="stat__icon"><svg class="icon">
<use href="#i-clock" />
</svg></span>
</div>
<div class="stat__value" data-count="14">0</div>
<div class="stat__foot">
<span class="trend trend--down"><svg class="icon">
<use href="#i-trend-down" />
</svg>3.2%</span>
<span>需 2 日内处理</span>
</div>
<div class="stat__spark" aria-hidden="true">
<i style="--h:88"></i><i style="--h:72"></i><i style="--h:64"></i><i style="--h:50"></i>
<i style="--h:44"></i><i style="--h:36"></i><i style="--h:28"></i>
</div>
</article>
<article class="stat" style="--tone: var(--danger)">
<div class="stat__top">
<span class="stat__label">异常锁定</span>
<span class="stat__icon"><svg class="icon">
<use href="#i-lock" />
</svg></span>
</div>
<div class="stat__value" data-count="5">0</div>
<div class="stat__foot">
<span class="trend trend--up"><svg class="icon">
<use href="#i-trend-up" />
</svg>1 例新增</span>
<span>安全策略已生效</span>
</div>
<div class="stat__spark" aria-hidden="true">
<i style="--h:20"></i><i style="--h:32"></i><i style="--h:26"></i><i style="--h:44"></i>
<i style="--h:38"></i><i style="--h:56"></i><i style="--h:62"></i>
</div>
</article>
</section>
<!-- 图表 -->
<section class="card">
<div class="card__head">
<div>
<h2 class="card__title">近 12 个月新增用户趋势</h2>
<p class="card__subtitle">数据每 10 分钟自动同步 · 最后更新:刚刚</p>
</div>
<div class="toolbar__group ml-auto">
<div class="segmented">
<label><input type="radio" name="range" value="12m" checked><span>12 个月</span></label>
<label><input type="radio" name="range" value="6m"><span>6 个月</span></label>
<label><input type="radio" name="range" value="30d"><span>30 天</span></label>
</div>
</div>
</div>
<div class="card__body">
<div class="chart" role="img" aria-label="近 12 个月新增用户柱状图">
<div class="bar" style="--v:42" data-val="1月 · 42 人"><span class="bar__fill"></span><span
class="bar__label">1月</span></div>
<div class="bar" style="--v:55" data-val="2月 · 55 人"><span class="bar__fill"></span><span
class="bar__label">2月</span></div>
<div class="bar" style="--v:38" data-val="3月 · 38 人"><span class="bar__fill"></span><span
class="bar__label">3月</span></div>
<div class="bar" style="--v:71" data-val="4月 · 71 人"><span class="bar__fill"></span><span
class="bar__label">4月</span></div>
<div class="bar" style="--v:64" data-val="5月 · 64 人"><span class="bar__fill"></span><span
class="bar__label">5月</span></div>
<div class="bar" style="--v:88" data-val="6月 · 88 人"><span class="bar__fill"></span><span
class="bar__label">6月</span></div>
<div class="bar" style="--v:76" data-val="7月 · 76 人"><span class="bar__fill"></span><span
class="bar__label">7月</span></div>
<div class="bar" style="--v:93" data-val="8月 · 93 人"><span class="bar__fill"></span><span
class="bar__label">8月</span></div>
<div class="bar" style="--v:82" data-val="9月 · 82 人"><span class="bar__fill"></span><span
class="bar__label">9月</span></div>
<div class="bar" style="--v:100" data-val="10月 · 100 人"><span class="bar__fill"></span><span
class="bar__label">10月</span></div>
<div class="bar" style="--v:91" data-val="11月 · 91 人"><span class="bar__fill"></span><span
class="bar__label">11月</span></div>
<div class="bar" style="--v:96" data-val="12月 · 96 人"><span class="bar__fill"></span><span
class="bar__label">12月</span></div>
</div>
</div>
</section>
<!-- ============ 用户表格 ============ -->
<section class="card table-card" aria-labelledby="tableTitle">
<h2 class="sr-only" id="tableTitle">用户列表</h2>
<div class="tabs" role="tablist" aria-label="用户分类">
<button class="tab" role="tab" aria-selected="true" data-tab="all">全部用户</button>
<button class="tab" role="tab" aria-selected="false" data-tab="active">正常账号</button>
<button class="tab" role="tab" aria-selected="false" data-tab="pending">待审核</button>
<button class="tab" role="tab" aria-selected="false" data-tab="inactive">已停用</button>
<button class="tab" role="tab" aria-selected="false" data-tab="locked">已锁定</button>
</div>
<div class="table-toolbar">
<div class="toolbar__group">
<div class="search-field">
<svg class="icon">
<use href="#i-search" />
</svg>
<input type="search" id="searchInput" placeholder="搜索姓名 / 邮箱 / 手机号…" aria-label="搜索用户"
autocomplete="off">
<span class="search-field__hint">/</span>
</div>
<select class="select select--sm" id="deptFilter" aria-label="按部门筛选"
style="width:auto;min-width:130px">
<option value="all">全部部门</option>
</select>
<select class="select select--sm" id="roleFilter" aria-label="按角色筛选"
style="width:auto;min-width:120px">
<option value="all">全部角色</option>
</select>
<select class="select select--sm" id="statusFilter" aria-label="按状态筛选"
style="width:auto;min-width:110px">
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="pending">待审核</option>
<option value="inactive">已停用</option>
<option value="locked">已锁定</option>
</select>
<button class="btn btn--ghost btn--sm" id="resetFilters"><svg class="icon">
<use href="#i-refresh" />
</svg>重置</button>
</div>
<div class="toolbar__group">
<label class="switch" title="隔行变色">
<input type="checkbox" id="zebraToggle" checked>
<span class="switch__track"></span>
<span class="switch__label">斑马纹</span>
</label>
<label class="switch" title="紧凑模式">
<input type="checkbox" id="densityToggle">
<span class="switch__track"></span>
<span class="switch__label">紧凑</span>
</label>
<button class="btn btn--ghost btn--sm" popovertarget="colPop"><svg class="icon">
<use href="#i-columns" />
</svg>列设置</button>
<button class="btn btn--ghost btn--sm" id="exportBtn"><svg class="icon">
<use href="#i-download" />
</svg>导出 CSV</button>
<button class="btn btn--primary btn--sm" id="addUserBtn"><svg class="icon">
<use href="#i-plus" />
</svg>新建用户</button>
</div>
</div>
<div id="colPop" popover class="popover" style="width:min(240px,calc(100vw - 32px))">
<div class="popover__head">
<span>显示列</span>
<button class="btn btn--ghost btn--sm" id="colReset">恢复默认</button>
</div>
<div class="popover__body">
<div class="col-list" id="colList">
<label><input type="checkbox" class="checkbox" data-col-toggle="id" checked> 用户
ID</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="user" checked>
用户信息</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="email" checked>
邮箱</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="phone" checked>
手机号</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="dept" checked>
部门</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="role" checked>
角色</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="status" checked>
状态</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="lastLogin" checked>
最后登录</label>
<label><input type="checkbox" class="checkbox" data-col-toggle="createdAt"> 创建时间</label>
</div>
</div>
</div>
<div class="bulkbar" id="bulkBar" hidden>
<span class="bulkbar__count">已选择 <strong id="bulkCount">0</strong> 项</span>
<div class="toolbar__group">
<button class="btn btn--ghost btn--sm" data-bulk="active"><svg class="icon">
<use href="#i-check" />
</svg>批量启用</button>
<button class="btn btn--ghost btn--sm" data-bulk="inactive"><svg class="icon">
<use href="#i-lock" />
</svg>批量停用</button>
<button class="btn btn--danger btn--sm" data-bulk="delete"><svg class="icon">
<use href="#i-trash" />
</svg>批量删除</button>
<button class="btn btn--ghost btn--sm" data-bulk="clear"><svg class="icon">
<use href="#i-close" />
</svg>取消选择</button>
</div>
</div>
<div class="table-wrap">
<table class="data-table" id="userTable" data-zebra="true" data-density="comfortable"
data-hide="">
<caption class="sr-only">用户列表,支持排序、筛选与批量操作</caption>
<thead>
<tr>
<th data-col="check" style="width:46px">
<input type="checkbox" class="checkbox" id="selectAll" aria-label="全选当前页">
</th>
<th data-col="id" class="th-sortable" data-sort="id" aria-sort="ascending"
style="width:90px">
<span class="th-inner">ID<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="user" class="th-sortable" data-sort="name" aria-sort="none"
style="min-width:200px">
<span class="th-inner">用户<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="email" class="th-sortable" data-sort="email" aria-sort="none"
style="min-width:190px">
<span class="th-inner">邮箱<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="phone" style="min-width:130px">手机号</th>
<th data-col="dept" class="th-sortable" data-sort="dept" aria-sort="none"
style="min-width:140px">
<span class="th-inner">部门<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="role" class="th-sortable" data-sort="role" aria-sort="none"
style="min-width:130px">
<span class="th-inner">角色<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="status" class="th-sortable" data-sort="status" aria-sort="none"
style="width:110px">
<span class="th-inner">状态<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="lastLogin" class="th-sortable" data-sort="lastLogin" aria-sort="none"
style="min-width:150px">
<span class="th-inner">最后登录<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th data-col="createdAt" class="th-sortable" data-sort="createdAt" aria-sort="none"
style="min-width:120px">
<span class="th-inner">创建时间<svg class="th-sort-icon">
<use href="#i-sort" />
</svg></span>
</th>
<th style="width:110px; text-align:end">操作</th>
</tr>
</thead>
<tbody id="userTbody"></tbody>
</table>
</div>
<div class="pagination" id="pagination">
<div class="pagination__group">
<span>共 <strong id="totalCount">0</strong> 条记录</span>
<span class="divider--v"></span>
<span>第 <strong id="pageInfo">1</strong> / <strong id="pageTotal">1</strong> 页</span>
<span class="divider--v"></span>
<span id="pageRange" class="text-faint"></span>
</div>
<div class="pagination__group">
<label for="pageSize" class="text-muted">每页</label>
<select class="select select--sm" id="pageSize" style="width:auto">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<span class="text-muted">条</span>
</div>
<nav class="pager" id="pager" aria-label="分页导航"></nav>
<div class="pagination__group">
<span>跳至</span>
<input type="number" class="jump-input" id="jumpInput" min="1" value="1"
aria-label="跳转到指定页">
<span>页</span>
<button class="btn btn--ghost btn--sm" id="jumpBtn">GO</button>
</div>
</div>
</section>
</main>
</div>
</div>
<!-- ============ 用户表单对话框 ============ -->
<dialog id="userDialog" class="modal" aria-labelledby="userDialogTitle">
<form id="userForm" method="dialog" novalidate>
<header class="modal__head">
<div>
<h2 id="userDialogTitle">新建用户</h2>
<p>填写账号基本信息,带 <span class="req">*</span> 为必填项。</p>
</div>
<button type="button" class="icon-btn" data-close-dialog aria-label="关闭">
<svg class="icon">
<use href="#i-close" />
</svg>
</button>
</header>
<div class="modal__body">
<input type="hidden" name="id">
<div class="form-grid">
<div class="field">
<label for="f-name">姓名 <span class="req">*</span></label>
<input class="input" id="f-name" name="name" required minlength="2" maxlength="20"
placeholder="请输入真实姓名" autocomplete="off">
<p class="field__err"><svg class="icon" style="width:13px;height:13px">
<use href="#i-alert" />
</svg>请输入 2–20 个字符的姓名</p>
</div>
<div class="field">
<label for="f-username">用户名 <span class="req">*</span></label>
<input class="input" id="f-username" name="username" required
pattern="[a-zA-Z][a-zA-Z0-9_]{2,19}" placeholder="字母开头,3–20 位" autocomplete="off">
<p class="field__hint">仅支持字母、数字与下划线</p>
<p class="field__err"><svg class="icon" style="width:13px;height:13px">
<use href="#i-alert" />
</svg>格式不正确,需字母开头且 3–20 位</p>
</div>
<div class="field">
<label for="f-email">邮箱 <span class="req">*</span></label>
<input class="input" id="f-email" name="email" type="email" required placeholder="name@nova.com"
autocomplete="off">
<p class="field__err"><svg class="icon" style="width:13px;height:13px">
<use href="#i-alert" />
</svg>请输入有效的邮箱地址</p>
</div>
<div class="field">
<label for="f-phone">手机号</label>
<input class="input" id="f-phone" name="phone" pattern="1[3-9]\d{9}" placeholder="11 位手机号"
inputmode="numeric" autocomplete="off">
<p class="field__err"><svg class="icon" style="width:13px;height:13px">
<use href="#i-alert" />
</svg>请输入有效的 11 位手机号</p>
</div>
<div class="field">
<label for="f-dept">所属部门 <span class="req">*</span></label>
<select class="select" id="f-dept" name="dept" required></select>
</div>
<div class="field">
<label for="f-role">角色 <span class="req">*</span></label>
<select class="select" id="f-role" name="role" required></select>
</div>
<div class="field field--full">
<fieldset style="border:0;padding:0;display:flex;flex-direction:column;gap:6px">
<legend>账号状态</legend>
<div class="segmented" style="align-self:flex-start">
<label><input type="radio" name="status" value="active" checked><span>正常</span></label>
<label><input type="radio" name="status" value="pending"><span>待审核</span></label>
<label><input type="radio" name="status" value="inactive"><span>停用</span></label>
<label><input type="radio" name="status" value="locked"><span>锁定</span></label>
</div>
</fieldset>
</div>
<div class="field field--full">
<label for="f-note">备注</label>
<textarea class="textarea" id="f-note" name="note" maxlength="200"
placeholder="可选,最多 200 字"></textarea>
</div>
</div>
</div>
<footer class="modal__foot">
<button type="button" class="btn btn--ghost" data-close-dialog>取消</button>
<button type="submit" class="btn btn--primary">
<svg class="icon">
<use href="#i-check" />
</svg>
<span id="submitLabel">创建用户</span>
</button>
</footer>
</form>
</dialog>
<!-- 确认对话框 -->
<dialog id="confirmDialog" class="modal modal--sm" aria-labelledby="confirmTitle">
<div class="modal__head">
<span class="modal__icon"><svg class="icon">
<use href="#i-alert" />
</svg></span>
<div>
<h2 id="confirmTitle">确认操作</h2>
<p id="confirmDesc">此操作不可撤销。</p>
</div>
</div>
<footer class="modal__foot">
<button class="btn btn--ghost" id="confirmCancel">取消</button>
<button class="btn btn--danger" id="confirmOk">确认删除</button>
</footer>
</dialog>
<!-- 详情对话框 -->
<dialog id="detailDialog" class="modal" aria-labelledby="detailTitle">
<header class="modal__head">
<div>
<h2 id="detailTitle">用户详情</h2>
<p>查看该账号的完整档案与活动信息。</p>
</div>
<button type="button" class="icon-btn" data-close-dialog aria-label="关闭">
<svg class="icon">
<use href="#i-close" />
</svg>
</button>
</header>
<div class="modal__body" id="detailBody"></div>
<footer class="modal__foot">
<button class="btn btn--ghost" data-close-dialog>关闭</button>
<button class="btn btn--primary" id="detailEditBtn"><svg class="icon">
<use href="#i-edit" />
</svg>编辑</button>
</footer>
</dialog>
<!-- Toast 容器 -->
<div class="toast-stack" id="toastStack" role="region" aria-live="polite" aria-label="系统通知"></div>
<script>
/* =====================================================================
Nova Admin · 应用逻辑
===================================================================== */
(() => {
'use strict';
/* ============ 0. 常量与工具 ============ */
const DEPTS = ['技术研发部', '产品设计部', '市场营销部', '人力资源部', '财务会计部', '客户成功部', '运维保障部', '法务合规部'];
const ROLES = ['超级管理员', '系统管理员', '部门主管', '普通用户', '只读访客'];
const STATUSES = ['active', 'inactive', 'pending', 'locked'];
const STATUS_META = {
active: { label: '正常', tone: 'success' },
inactive: { label: '已停用', tone: 'muted' },
pending: { label: '待审核', tone: 'warn' },
locked: { label: '已锁定', tone: 'danger' },
};
const SURNAMES = '王李张刘陈杨赵黄周吴徐孙马朱胡郭何高林罗郑梁谢宋唐许韩冯邓曹彭曾肖田董袁潘于蒋蔡余杜叶程苏魏吕丁任沈姚卢姜崔钟谭陆汪范金石廖贾夏韦付方白邹孟熊秦邱江尹薛闫段雷侯龙史陶黎贺顾毛郝龚邵万钱严覃武戴莫孔向汤'.split('');
const GIVEN = ['伟', '芳', '娜', '敏', '静', '丽', '强', '磊', '军', '洋', '勇', '艳', '杰', '娟', '涛', '明', '超', '秀英', '霞', '平', '刚', '桂英', '建国', '丹', '雪', '晨', '浩', '宇', '欣', '悦', '一鸣', '思远', '子涵', '雨萱', '梓豪', '嘉怡'];
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
function mulberry32(seed) {
let a = seed;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const debounce = (fn, wait = 260) => {
let t;
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), wait); };
};
const DTF_DATE = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
const DTF_FULL = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
const RTF = new Intl.RelativeTimeFormat('zh-CN', { numeric: 'auto' });
const NUM = new Intl.NumberFormat('zh-CN');
const fmtDate = (ts) => DTF_DATE.format(new Date(ts));
const fmtFull = (ts) => DTF_FULL.format(new Date(ts));
function fmtRelative(ts) {
const diff = Date.now() - ts;
const mins = Math.round(diff / 60000);
if (Math.abs(mins) < 60) return RTF.format(-mins, 'minute');
const hours = Math.round(mins / 60);
if (Math.abs(hours) < 24) return RTF.format(-hours, 'hour');
const days = Math.round(hours / 24);
if (Math.abs(days) < 30) return RTF.format(-days, 'day');
return fmtDate(ts);
}
/* ============ 1. 数据生成 ============ */
function generateUsers(count = 137) {
const rnd = mulberry32(20261016);
const now = Date.now();
const list = [];
for (let i = 1; i <= count; i++) {
const name = SURNAMES[Math.floor(rnd() * SURNAMES.length)] + GIVEN[Math.floor(rnd() * GIVEN.length)];
const dept = DEPTS[Math.floor(rnd() * DEPTS.length)];
const role = ROLES[Math.floor(rnd() * ROLES.length)];
const status = STATUSES[Math.floor(rnd() * STATUSES.length)];
const createdAt = now - Math.floor(rnd() * 1080 + 20) * 86400000;
const lastLogin = createdAt + Math.floor(rnd() * (now - createdAt) * 0.92);
const phoneTail = String(Math.floor(rnd() * 1e8)).padStart(8, '0');
const username = `user${String(i).padStart(4, '0')}`;
list.push({
id: i,
name,
username,
email: `${username}@nova.com`,
phone: `1${[3, 5, 6, 7, 8, 9][Math.floor(rnd() * 6)]}${phoneTail.slice(0, 1)}${String(Math.floor(rnd() * 1e8)).padStart(8, '0')}`,
dept,
role,
status,
lastLogin,
createdAt,
hue: Math.floor(rnd() * 360),
});
}
return list;
}
/* ============ 2. 状态 ============ */
const state = {
users: generateUsers(),
page: 1,
pageSize: 10,
sortKey: 'id',
sortDir: 'asc',
search: '',
dept: 'all',
role: 'all',
status: 'all',
tab: 'all',
selected: new Set(),
hideCols: [],
editingId: null,
pendingDelete: null,
};
let filteredCache = [];
/* ============ 3. 过滤 & 排序 ============ */
function computeFiltered() {
const q = state.search.trim().toLowerCase();
let rows = state.users.filter((u) => {
if (state.status !== 'all' && u.status !== state.status) return false;
if (state.dept !== 'all' && u.dept !== state.dept) return false;
if (state.role !== 'all' && u.role !== state.role) return false;
if (q) {
const hay = `${u.name} ${u.username} ${u.email} ${u.phone} ${u.dept} ${u.role} ${u.id}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
const dir = state.sortDir === 'asc' ? 1 : -1;
const key = state.sortKey;
rows.sort((a, b) => {
const va = a[key], vb = b[key];
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
return String(va).localeCompare(String(vb), 'zh-CN', { numeric: true }) * dir;
});
filteredCache = rows;
return rows;
}
/* ============ 4. 渲染 —— 表格 ============ */
const tbody = $('#userTbody');
const tableEl = $('#userTable');
function renderRows() {
const rows = filteredCache;
const total = rows.length;
const maxPage = Math.max(1, Math.ceil(total / state.pageSize));
if (state.page > maxPage) state.page = maxPage;
const start = (state.page - 1) * state.pageSize;
const pageRows = rows.slice(start, start + state.pageSize);
if (!pageRows.length) {
tbody.innerHTML = `
<tr>
<td colspan="11">
<div class="table-empty">
<span class="table-empty__art"><svg class="icon"><use href="#i-inbox"/></svg></span>
<p class="fw-600">没有找到匹配的用户</p>
<p class="text-sm text-muted">试试调整筛选条件或搜索关键词</p>
<button class="btn btn--ghost btn--sm" id="emptyReset">清除全部筛选</button>
</div>
</td>
</tr>`;
const btn = $('#emptyReset');
if (btn) btn.addEventListener('click', resetFilters);
syncSelectAll([]);
renderPagination(total);
updateBulkBar();
return;
}
tbody.innerHTML = pageRows.map((u) => {
const meta = STATUS_META[u.status];
const checked = state.selected.has(u.id);
return `
<tr data-id="${u.id}" class="${checked ? 'is-selected' : ''}">
<td data-col="check">
<input type="checkbox" class="checkbox row-check" data-id="${u.id}"
${checked ? 'checked' : ''} aria-label="选择用户 ${escapeHtml(u.name)}">
</td>
<td data-col="id"><span class="mono text-muted">#${String(u.id).padStart(4, '0')}</span></td>
<td data-col="user">
<div class="user-cell">
<span class="avatar" style="--size:34px; --av-h:${u.hue}">${escapeHtml(u.name[0])}</span>
<div class="user-cell__meta">
<span class="user-cell__name truncate">${escapeHtml(u.name)}</span>
<span class="user-cell__sub truncate">@${escapeHtml(u.username)}</span>
</div>
</div>
</td>
<td data-col="email"><span class="truncate" style="display:block;max-width:220px">${escapeHtml(u.email)}</span></td>
<td data-col="phone"><span class="mono text-muted">${escapeHtml(u.phone)}</span></td>
<td data-col="dept"><span class="chip">${escapeHtml(u.dept)}</span></td>
<td data-col="role">${escapeHtml(u.role)}</td>
<td data-col="status">
<span class="badge badge--${meta.tone}">${meta.label}</span>
</td>
<td data-col="lastLogin"><span title="${fmtFull(u.lastLogin)}">${fmtRelative(u.lastLogin)}</span></td>
<td data-col="createdAt"><span class="text-muted">${fmtDate(u.createdAt)}</span></td>
<td style="text-align:end">
<div class="cell-actions">
<button class="icon-btn" data-action="detail" data-id="${u.id}" title="查看详情" aria-label="查看详情">
<svg class="icon"><use href="#i-search"/></svg>
</button>
<button class="icon-btn" data-action="edit" data-id="${u.id}" title="编辑" aria-label="编辑">
<svg class="icon"><use href="#i-edit"/></svg>
</button>
<button class="icon-btn" data-action="delete" data-id="${u.id}" title="删除" aria-label="删除" style="color:var(--danger)">
<svg class="icon"><use href="#i-trash"/></svg>
</button>
</div>
</td>
</tr>`;
}).join('');
syncSelectAll(pageRows);
renderPagination(total);
updateBulkBar();
}
function syncSelectAll(pageRows) {
const all = $('#selectAll');
if (!all) return;
const selectable = pageRows.filter((u) => state.selected.has(u.id)).length;
all.checked = pageRows.length > 0 && selectable === pageRows.length;
all.indeterminate = selectable > 0 && selectable < pageRows.length;
}
/* ============ 5. 渲染 —— 分页 ============ */
function buildPageList(current, total) {
const out = [];
if (total <= 7) { for (let i = 1; i <= total; i++) out.push(i); return out; }
out.push(1);
let start = Math.max(2, current - 1);
let end = Math.min(total - 1, current + 1);
if (current <= 3) { start = 2; end = 4; }
if (current >= total - 2) { start = total - 3; end = total - 1; }
if (start > 2) out.push('…');
for (let i = start; i <= end; i++) out.push(i);
if (end < total - 1) out.push('…');
out.push(total);
return out;
}
function renderPagination(total) {
const maxPage = Math.max(1, Math.ceil(total / state.pageSize));
const pager = $('#pager');
const pages = buildPageList(state.page, maxPage);
const rangeStart = total === 0 ? 0 : (state.page - 1) * state.pageSize + 1;
const rangeEnd = Math.min(state.page * state.pageSize, total);
pager.innerHTML = `
<button class="page-btn" data-page="first" ${state.page === 1 ? 'disabled' : ''} title="首页" aria-label="首页">
<svg class="icon"><use href="#i-chevrons-left"/></svg>
</button>
<button class="page-btn" data-page="prev" ${state.page === 1 ? 'disabled' : ''} title="上一页" aria-label="上一页">
<svg class="icon"><use href="#i-chevron-left"/></svg>
</button>
${pages.map((p) => p === '…'
? `<span class="page-ellipsis">…</span>`
: `<button class="page-btn ${p === state.page ? 'is-current' : ''}" data-page="${p}"
${p === state.page ? 'aria-current="page"' : ''}>${p}</button>`
).join('')}
<button class="page-btn" data-page="next" ${state.page === maxPage ? 'disabled' : ''} title="下一页" aria-label="下一页">
<svg class="icon"><use href="#i-chevron-right"/></svg>
</button>
<button class="page-btn" data-page="last" ${state.page === maxPage ? 'disabled' : ''} title="尾页" aria-label="尾页">
<svg class="icon"><use href="#i-chevrons-right"/></svg>
</button>
`;
$('#totalCount').textContent = NUM.format(total);
$('#pageInfo').textContent = state.page;
$('#pageTotal').textContent = maxPage;
$('#pageRange').textContent = total ? `显示 ${rangeStart}–${rangeEnd}` : '无数据';
const jump = $('#jumpInput');
jump.max = maxPage;
jump.value = state.page;
const badge = $('#navUserBadge');
if (badge) badge.textContent = state.users.length;
}
/* ============ 6. 全量刷新 ============ */
function refresh({ resetPage = false } = {}) {
if (resetPage) state.page = 1;
computeFiltered();
renderRows();
}
/* ============ 7. 选择 ============ */
function updateBulkBar() {
const bar = $('#bulkBar');
const count = state.selected.size;
$('#bulkCount').textContent = count;
bar.hidden = count === 0;
}
function getPageRows() {
const start = (state.page - 1) * state.pageSize;
return filteredCache.slice(start, start + state.pageSize);
}
/* ============ 8. Toast ============ */
const ICONS = { success: '#i-check', error: '#i-alert', info: '#i-info', warn: '#i-alert' };
function toast(title, desc = '', type = 'success', duration = 3600) {
const stack = $('#toastStack');
const el = document.createElement('div');
el.className = `toast toast--${type}`;
el.innerHTML = `
<span class="toast__icon"><svg class="icon"><use href="${ICONS[type] || '#i-info'}"/></svg></span>
<div class="toast__content">
<p class="toast__title">${escapeHtml(title)}</p>
${desc ? `<p class="toast__desc">${escapeHtml(desc)}</p>` : ''}
</div>
<button class="toast__close" aria-label="关闭">
<svg class="icon" style="width:15px;height:15px"><use href="#i-close"/></svg>
</button>
<span class="toast__bar" style="animation-duration:${duration}ms"></span>
`;
stack.appendChild(el);
const remove = () => {
el.classList.add('is-leaving');
el.addEventListener('animationend', () => el.remove(), { once: true });
};
const timer = setTimeout(remove, duration);
el.querySelector('.toast__close').addEventListener('click', () => { clearTimeout(timer); remove(); });
}
/* ============ 9. 对话框 ============ */
const userDialog = $('#userDialog');
const confirmDialog = $('#confirmDialog');
const detailDialog = $('#detailDialog');
function openUserDialog(user = null) {
const form = $('#userForm');
form.reset();
state.editingId = user ? user.id : null;
$('#userDialogTitle').textContent = user ? '编辑用户' : '新建用户';
$('#submitLabel').textContent = user ? '保存修改' : '创建用户';
if (user) {
form.id.value = user.id;
form.name.value = user.name;
form.username.value = user.username;
form.email.value = user.email;
form.phone.value = user.phone;
form.dept.value = user.dept;
form.role.value = user.role;
const radio = form.querySelector(`input[name="status"][value="${user.status}"]`);
if (radio) radio.checked = true;
} else {
form.id.value = '';
form.dept.selectedIndex = 0;
form.role.selectedIndex = 0;
}
userDialog.showModal();
}
function confirmAction({ title = '确认操作', desc = '此操作不可撤销。', okText = '确认', danger = true } = {}) {
return new Promise((resolve) => {
$('#confirmTitle').textContent = title;
$('#confirmDesc').textContent = desc;
const ok = $('#confirmOk');
ok.textContent = okText;
ok.className = `btn ${danger ? 'btn--danger' : 'btn--primary'}`;
const cleanup = () => {
ok.removeEventListener('click', onOk);
$('#confirmCancel').removeEventListener('click', onCancel);
confirmDialog.removeEventListener('close', onCancel);
};
const onOk = () => { cleanup(); confirmDialog.close(); resolve(true); };
const onCancel = () => { cleanup(); if (confirmDialog.open) confirmDialog.close(); resolve(false); };
ok.addEventListener('click', onOk);
$('#confirmCancel').addEventListener('click', onCancel);
confirmDialog.addEventListener('close', onCancel);
confirmDialog.showModal();
});
}
function openDetail(user) {
const meta = STATUS_META[user.status];
$('#detailBody').innerHTML = `
<div class="flex items-center gap-4" style="margin-bottom:20px">
<span class="avatar" style="--size:64px;--av-h:${user.hue};font-size:1.5rem">${escapeHtml(user.name[0])}</span>
<div>
<h3 style="font-size:1.15rem">${escapeHtml(user.name)}</h3>
<p class="text-muted text-sm">@${escapeHtml(user.username)} · ${escapeHtml(user.role)}</p>
<span class="badge badge--${meta.tone}" style="margin-top:6px">${meta.label}</span>
</div>
</div>
<div class="form-grid">
<div class="field"><label>用户 ID</label><p class="mono">#${String(user.id).padStart(4, '0')}</p></div>
<div class="field"><label>所属部门</label><p>${escapeHtml(user.dept)}</p></div>
<div class="field"><label>邮箱</label><p class="truncate">${escapeHtml(user.email)}</p></div>
<div class="field"><label>手机号</label><p class="mono">${escapeHtml(user.phone)}</p></div>
<div class="field"><label>创建时间</label><p>${fmtFull(user.createdAt)}</p></div>
<div class="field"><label>最后登录</label><p>${fmtFull(user.lastLogin)}</p></div>
</div>
`;
$('#detailEditBtn').onclick = () => {
detailDialog.close();
openUserDialog(user);
};
detailDialog.showModal();
}
document.addEventListener('click', (e) => {
const btn = e.target.closest('[data-close-dialog]');
if (btn) btn.closest('dialog')?.close();
});
/* ============ 10. CRUD ============ */
function createUser(data) {
const id = Math.max(0, ...state.users.map((u) => u.id)) + 1;
const now = Date.now();
state.users.unshift({
id,
name: data.name,
username: data.username,
email: data.email,
phone: data.phone || '—',
dept: data.dept,
role: data.role,
status: data.status,
createdAt: now,
lastLogin: now,
hue: Math.floor(Math.random() * 360),
});
}
function updateUser(id, data) {
const u = state.users.find((x) => x.id === id);
if (!u) return;
Object.assign(u, {
name: data.name,
username: data.username,
email: data.email,
phone: data.phone || u.phone,
dept: data.dept,
role: data.role,
status: data.status,
});
}
function deleteUsers(ids) {
const set = new Set(ids);
state.users = state.users.filter((u) => !set.has(u.id));
ids.forEach((id) => state.selected.delete(id));
}
/* ============ 11. 事件绑定 ============ */
const app = $('#app');
/* 侧边栏折叠 */
$('#sidebarToggle').addEventListener('click', () => {
if (window.innerWidth <= 720) {
app.dataset.mobileOpen = app.dataset.mobileOpen === 'true' ? 'false' : 'true';
} else {
const next = app.dataset.collapsed !== 'true';
app.dataset.collapsed = String(next);
$('#sidebarToggle').setAttribute('aria-expanded', String(!next));
}
});
/* 搜索防抖 */
const onSearch = debounce((value) => {
state.search = value;
refresh({ resetPage: true });
}, 280);
$('#searchInput').addEventListener('input', (e) => onSearch(e.target.value));
/* 筛选器 */
$('#deptFilter').addEventListener('change', (e) => {
state.dept = e.target.value; refresh({ resetPage: true });
});
$('#roleFilter').addEventListener('change', (e) => {
state.role = e.target.value; refresh({ resetPage: true });
});
$('#statusFilter').addEventListener('change', (e) => {
state.status = e.target.value;
state.tab = e.target.value === 'all' ? 'all' : e.target.value;
syncTabs();
refresh({ resetPage: true });
});
function resetFilters() {
state.search = '';
state.dept = 'all';
state.role = 'all';
state.status = 'all';
state.tab = 'all';
$('#searchInput').value = '';
$('#deptFilter').value = 'all';
$('#roleFilter').value = 'all';
$('#statusFilter').value = 'all';
syncTabs();
refresh({ resetPage: true });
toast('筛选已重置', '已恢复全部用户视图', 'info', 2200);
}
$('#resetFilters').addEventListener('click', resetFilters);
/* Tab 切换 */
function syncTabs() {
$$('.tab').forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === state.tab)));
}
$$('.tab').forEach((tab) => {
tab.addEventListener('click', () => {
state.tab = tab.dataset.tab;
state.status = tab.dataset.tab === 'all' ? 'all' : tab.dataset.tab;
$('#statusFilter').value = state.status;
syncTabs();
refresh({ resetPage: true });
});
});
/* 排序 */
$$('.th-sortable').forEach((th) => {
th.addEventListener('click', () => {
const key = th.dataset.sort;
if (state.sortKey === key) {
state.sortDir = state.sortDir === 'asc' ? 'desc' : 'asc';
} else {
state.sortKey = key;
state.sortDir = 'asc';
}
$$('.th-sortable').forEach((x) => {
x.setAttribute('aria-sort', x === th ? (state.sortDir === 'asc' ? 'ascending' : 'descending') : 'none');
});
refresh();
});
});
/* 全选 / 单选 */
$('#selectAll').addEventListener('change', (e) => {
const rows = getPageRows();
if (e.target.checked) rows.forEach((u) => state.selected.add(u.id));
else rows.forEach((u) => state.selected.delete(u.id));
renderRows();
});
tbody.addEventListener('change', (e) => {
const cb = e.target.closest('.row-check');
if (!cb) return;
const id = Number(cb.dataset.id);
if (cb.checked) state.selected.add(id); else state.selected.delete(id);
cb.closest('tr').classList.toggle('is-selected', cb.checked);
syncSelectAll(getPageRows());
updateBulkBar();
});
/* 行操作 */
tbody.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const id = Number(btn.dataset.id);
const user = state.users.find((u) => u.id === id);
if (!user) return;
switch (btn.dataset.action) {
case 'edit': openUserDialog(user); break;
case 'detail': openDetail(user); break;
case 'delete': {
const ok = await confirmAction({
title: '删除用户',
desc: `确定要删除「${user.name}」吗?该操作不可恢复。`,
okText: '删除',
});
if (ok) {
deleteUsers([id]);
refresh();
toast('删除成功', `用户「${user.name}」已从系统中移除`, 'success');
}
break;
}
}
});
/* 批量操作 */
$('#bulkBar').addEventListener('click', async (e) => {
const btn = e.target.closest('[data-bulk]');
if (!btn) return;
const ids = [...state.selected];
switch (btn.dataset.bulk) {
case 'active':
case 'inactive':
case 'locked': {
state.users.forEach((u) => { if (state.selected.has(u.id)) u.status = btn.dataset.bulk; });
refresh();
toast('批量更新成功', `已将 ${ids.length} 个账号状态更新为「${STATUS_META[btn.dataset.bulk].label}」`, 'success');
break;
}
case 'delete': {
const ok = await confirmAction({
title: '批量删除',
desc: `即将删除 ${ids.length} 个用户账号,删除后无法恢复。`,
okText: `删除 ${ids.length} 项`,
});
if (ok) {
deleteUsers(ids);
state.selected.clear();
refresh();
toast('批量删除完成', `已删除 ${ids.length} 个用户`, 'success');
}
break;
}
case 'clear':
state.selected.clear();
renderRows();
break;
}
});
/* 分页 */
$('#pager').addEventListener('click', (e) => {
const btn = e.target.closest('.page-btn');
if (!btn || btn.disabled) return;
const maxPage = Math.max(1, Math.ceil(filteredCache.length / state.pageSize));
const p = btn.dataset.page;
if (p === 'first') state.page = 1;
else if (p === 'prev') state.page = Math.max(1, state.page - 1);
else if (p === 'next') state.page = Math.min(maxPage, state.page + 1);
else if (p === 'last') state.page = maxPage;
else state.page = Number(p);
renderRows();
$('#tableTitle')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
$('#pageSize').addEventListener('change', (e) => {
state.pageSize = Number(e.target.value);
state.page = 1;
refresh();
toast('每页条数已更新', `当前每页显示 ${state.pageSize} 条`, 'info', 2000);
});
$('#jumpBtn').addEventListener('click', jumpToPage);
$('#jumpInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') jumpToPage(); });
function jumpToPage() {
const maxPage = Math.max(1, Math.ceil(filteredCache.length / state.pageSize));
let v = Number($('#jumpInput').value);
if (!Number.isFinite(v)) v = 1;
state.page = Math.min(maxPage, Math.max(1, Math.round(v)));
renderRows();
}
/* 视图设置 */
$('#zebraToggle').addEventListener('change', (e) => {
tableEl.dataset.zebra = String(e.target.checked);
});
$('#densityToggle').addEventListener('change', (e) => {
tableEl.dataset.density = e.target.checked ? 'compact' : 'comfortable';
});
/* 列显隐 */
function syncColumnVisibility() {
tableEl.dataset.hide = state.hideCols.join(' ');
}
$('#colList').addEventListener('change', (e) => {
const cb = e.target.closest('[data-col-toggle]');
if (!cb) return;
const col = cb.dataset.colToggle;
if (cb.checked) state.hideCols = state.hideCols.filter((c) => c !== col);
else if (!state.hideCols.includes(col)) state.hideCols.push(col);
syncColumnVisibility();
});
$('#colReset').addEventListener('click', () => {
state.hideCols = [];
syncColumnVisibility();
$$('[data-col-toggle]').forEach((cb) => { cb.checked = cb.dataset.colToggle !== 'createdAt'; });
toast('列设置已恢复默认', '', 'info', 2000);
});
/* 表单提交 */
const userForm = $('#userForm');
userForm.addEventListener('submit', (e) => {
e.preventDefault();
if (!userForm.checkValidity()) {
userForm.reportValidity();
const firstInvalid = userForm.querySelector(':invalid');
firstInvalid?.focus();
toast('表单校验未通过', '请检查标红的输入项', 'error', 3000);
return;
}
const fd = new FormData(userForm);
const data = Object.fromEntries(fd.entries());
data.status = userForm.querySelector('input[name="status"]:checked')?.value || 'active';
if (state.editingId != null) {
updateUser(state.editingId, data);
toast('保存成功', `用户「${data.name}」信息已更新`, 'success');
} else {
createUser(data);
toast('创建成功', `用户「${data.name}」已加入系统`, 'success');
}
userDialog.close();
refresh();
});
$('#addUserBtn').addEventListener('click', () => openUserDialog());
$('#addUserBtn2').addEventListener('click', () => openUserDialog());
/* 导出 CSV */
function exportCSV() {
const rows = filteredCache;
if (!rows.length) { toast('没有可导出的数据', '', 'warn'); return; }
const headers = ['ID', '姓名', '用户名', '邮箱', '手机号', '部门', '角色', '状态', '最后登录', '创建时间'];
const lines = [headers.join(',')];
rows.forEach((u) => {
lines.push([
u.id,
`"${u.name}"`,
`"${u.username}"`,
`"${u.email}"`,
`"${u.phone}"`,
`"${u.dept}"`,
`"${u.role}"`,
`"${STATUS_META[u.status].label}"`,
`"${fmtFull(u.lastLogin)}"`,
`"${fmtFull(u.createdAt)}"`,
].join(','));
});
const blob = new Blob(['\uFEFF' + lines.join('\n')], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `用户数据_${fmtDate(Date.now()).replace(/\//g, '-')}.csv`;
a.click();
URL.revokeObjectURL(url);
toast('导出成功', `已导出 ${rows.length} 条记录`, 'success');
}
$('#exportBtn').addEventListener('click', exportCSV);
$('#exportBtn2').addEventListener('click', exportCSV);
$('#importBtn').addEventListener('click', () => {
toast('导入功能演示', '在实际项目中可接入文件选择与解析逻辑', 'info', 3200);
});
/* 刷新 */
function refreshAll() {
const skeletonRows = 6;
tbody.innerHTML = Array.from({ length: skeletonRows }, () => `
<tr>
<td data-col="check"><span class="skeleton" style="width:17px;height:17px;display:block"></span></td>
<td data-col="id"><span class="skeleton" style="width:44px;display:block"></span></td>
<td data-col="user"><span class="skeleton" style="width:150px;display:block"></span></td>
<td data-col="email"><span class="skeleton" style="width:170px;display:block"></span></td>
<td data-col="phone"><span class="skeleton" style="width:100px;display:block"></span></td>
<td data-col="dept"><span class="skeleton" style="width:90px;display:block"></span></td>
<td data-col="role"><span class="skeleton" style="width:80px;display:block"></span></td>
<td data-col="status"><span class="skeleton" style="width:60px;display:block"></span></td>
<td data-col="lastLogin"><span class="skeleton" style="width:90px;display:block"></span></td>
<td data-col="createdAt"><span class="skeleton" style="width:80px;display:block"></span></td>
<td></td>
</tr>`).join('');
setTimeout(() => {
refresh();
toast('数据已刷新', `共 ${state.users.length} 条用户记录`, 'info', 2200);
}, 520);
}
$('#refreshAll').addEventListener('click', refreshAll);
/* 通知已读 */
$('#markAllRead').addEventListener('click', () => {
$$('.notif-unread').forEach((n) => n.classList.remove('notif-unread'));
const badge = $('.icon-btn__badge');
if (badge) badge.remove();
toast('已全部标记为已读', '', 'info', 2000);
});
/* 全屏 */
$('#fullscreenBtn').addEventListener('click', async () => {
try {
if (!document.fullscreenElement) await document.documentElement.requestFullscreen();
else await document.exitFullscreen();
} catch {
toast('无法进入全屏', '当前环境不支持该操作', 'warn', 2400);
}
});
/* 主题切换 */
const themeRadios = $$('input[name="theme"]');
function applyTheme(mode, event) {
const root = document.documentElement;
if (mode === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', mode);
try { localStorage.setItem('nova-theme', mode); } catch { /* ignore */ }
if (event && document.startViewTransition) {
const x = event.clientX || window.innerWidth - 60;
const y = event.clientY || 40;
root.style.setProperty('--vt-x', x + 'px');
root.style.setProperty('--vt-y', y + 'px');
document.startViewTransition(() => { });
}
}
themeRadios.forEach((r) => {
r.addEventListener('change', (e) => { if (r.checked) applyTheme(r.value, e); });
});
(() => {
let saved = 'light';
try { saved = localStorage.getItem('nova-theme') || 'light'; } catch { /* ignore */ }
if (saved === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', saved);
const radio = themeRadios.find((r) => r.value === saved);
if (radio) radio.checked = true;
})();
/* 数字滚动 */
function animateCounters() {
$$('[data-count]').forEach((el) => {
const target = Number(el.dataset.count);
const start = performance.now();
const dur = 1100;
const tick = (now) => {
const p = Math.min(1, (now - start) / dur);
const eased = 1 - Math.pow(1 - p, 3);
el.textContent = NUM.format(Math.round(target * eased));
if (p < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
});
}
/* 卡片光晕跟随鼠标 */
$$('.stat').forEach((card) => {
card.addEventListener('pointermove', (e) => {
const r = card.getBoundingClientRect();
const pct = ((e.clientX - r.left) / r.width) * 100;
card.style.setProperty('--glow-x', pct + '%');
});
});
/* 键盘快捷键 */
document.addEventListener('keydown', (e) => {
const tag = e.target.tagName;
const typing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || e.target.isContentEditable;
if (e.key === '/' && !typing) {
e.preventDefault();
$('#searchInput').focus();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
$('#globalSearch').focus();
}
if (e.key === 'Escape' && !typing && state.selected.size && !document.querySelector('dialog[open]')) {
state.selected.clear();
renderRows();
toast('已取消选择', '', 'info', 1600);
}
if (!typing && !document.querySelector('dialog[open]')) {
if (e.key === 'PageDown' || (e.altKey && e.key === 'ArrowRight')) {
e.preventDefault();
$('#pager').querySelector('[data-page="next"]:not([disabled])')?.click();
}
if (e.key === 'PageUp' || (e.altKey && e.key === 'ArrowLeft')) {
e.preventDefault();
$('#pager').querySelector('[data-page="prev"]:not([disabled])')?.click();
}
}
});
/* 全局搜索联动 */
$('#globalSearch').addEventListener('input', debounce((e) => {
const v = e.target.value.trim();
if (!v) return;
$('#searchInput').value = v;
state.search = v;
refresh({ resetPage: true });
}, 420));
/* 导航项激活态 */
$$('.nav__item[data-nav]').forEach((item) => {
item.addEventListener('click', (e) => {
e.preventDefault();
$$('.nav__item').forEach((n) => { n.removeAttribute('aria-current'); n.classList.remove('is-active'); });
item.setAttribute('aria-current', 'page');
if (item.dataset.nav !== 'users') {
toast('模块演示', `「${item.textContent.trim()}」模块在完整版本中可用`, 'info', 2600);
}
});
});
/* 移动端点击遮罩关闭侧边栏 */
document.addEventListener('click', (e) => {
if (window.innerWidth > 720) return;
if (app.dataset.mobileOpen !== 'true') return;
if (!e.target.closest('.sidebar') && !e.target.closest('#sidebarToggle')) {
app.dataset.mobileOpen = 'false';
}
});
/* ============ 12. 初始化 ============ */
function init() {
/* 填充部门 / 角色下拉 */
const deptSelect = $('#deptFilter');
const roleSelect = $('#roleFilter');
const formDept = $('#f-dept');
const formRole = $('#f-role');
DEPTS.forEach((d) => {
deptSelect.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`);
formDept.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(d)}">${escapeHtml(d)}</option>`);
});
ROLES.forEach((r) => {
roleSelect.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`);
formRole.insertAdjacentHTML('beforeend', `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`);
});
syncColumnVisibility();
refresh();
requestAnimationFrame(animateCounters);
setTimeout(() => {
toast('欢迎使用 Nova Admin', `当前共有 ${state.users.length} 位用户,数据已就绪`, 'success', 4200);
}, 620);
window.addEventListener('resize', debounce(() => {
if (window.innerWidth > 720) app.dataset.mobileOpen = 'false';
}, 200));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* 调试接口 */
window.__nova = { state, refresh, toast };
})();
</script>
</body>
</html>