Files
CrmSystem/public/admin.html
2026-01-23 21:56:02 +08:00

2407 lines
84 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>客户管理系统 - 总后台</title>
<style>
/* ========== 统一主题配色 ========== */
:root {
--primary: #5bbf93;
/* 主色 - 柔和的绿色 */
--primary-light: #8fd6b3;
/* 浅色 */
--primary-dark: #2f8f67;
/* 深色 */
--primary-bg: #f0f9f5;
/* 背景色 */
--primary-gradient: linear-gradient(135deg, #5bbf93 0%, #2f8f67 100%);
--text-primary: #2c3e50;
/* 主要文字 */
--text-secondary: #546e7a;
/* 次要文字 */
--text-muted: #90a4ae;
/* 弱化文字 */
--bg-body: #f8fafc;
/* 页面背景 */
--bg-card: #ffffff;
/* 卡片背景 */
--bg-hover: #f9fafc;
/* 悬停背景 */
--border: #e2e8f0;
/* 边框色 */
--border-light: #f1f5f9;
/* 浅边框 */
--success: #10b981;
/* 成功色 */
--warning: #f59e0b;
/* 警告色 */
--danger: #ef4444;
/* 危险色 */
--info: #3b82f6;
/* 信息色 */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--radius-sm: 6px;
--radius: 10px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-full: 9999px;
--transition: all 0.2s ease-in-out;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', 'Segoe UI', 'Microsoft YaHei', sans-serif;
background-color: var(--bg-body);
color: var(--text-primary);
line-height: 1.6;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.admin-container {
display: flex;
min-height: 100vh;
}
/* ========== 侧边栏 ========== */
.sidebar {
width: 280px;
background: linear-gradient(180deg, var(--primary-dark) 0%, #2a7f5f 100%);
color: white;
padding: 30px 0;
position: fixed;
height: 100vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
}
.logo {
padding: 0 25px 30px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 25px;
}
.logo h1 {
font-size: 1.5rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.logo .subtitle {
color: rgba(255, 255, 255, 0.6);
font-size: 0.9rem;
margin-top: 5px;
margin-left: 40px;
}
.user-info {
padding: 0 25px 25px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 25px;
}
.user-avatar {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.2rem;
backdrop-filter: blur(5px);
}
.nav-menu {
padding: 0 15px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 20px;
border-radius: var(--radius);
margin-bottom: 8px;
cursor: pointer;
transition: var(--transition);
color: rgba(255, 255, 255, 0.8);
border: 1px solid transparent;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
border-color: rgba(255, 255, 255, 0.2);
}
.nav-item.active {
background: rgba(255, 255, 255, 0.15);
color: white;
border-color: rgba(255, 255, 255, 0.3);
font-weight: 600;
}
/* ========== 主内容区 ========== */
.main-content {
flex: 1;
margin-left: 280px;
padding: 30px;
background-color: var(--bg-body);
min-height: 100vh;
}
.header {
background: var(--bg-card);
padding: 24px 30px;
border-radius: var(--radius-lg);
margin-bottom: 30px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.header h2 {
font-size: 1.8rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.header h2::before {
content: '';
display: block;
width: 4px;
height: 24px;
background: var(--primary);
border-radius: var(--radius-full);
}
.header .date {
color: var(--text-muted);
margin-top: 8px;
font-size: 0.95rem;
}
/* ========== 统计卡片 ========== */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 24px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-card);
padding: 28px;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
border: 1px solid var(--border);
transition: var(--transition);
cursor: pointer;
position: relative;
overflow: hidden;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
border-color: var(--primary-light);
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: var(--primary);
border-radius: var(--radius-full);
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 18px;
font-size: 1.6rem;
background: var(--primary-bg);
color: var(--primary);
}
.stat-value {
font-size: 2.2rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.stat-label {
color: var(--text-muted);
font-size: 0.95rem;
}
/* ========== 员工列表 ========== */
.staff-list {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 30px;
border: 1px solid var(--border);
}
.staff-header {
padding: 22px 28px;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--primary-bg) 0%, transparent 100%);
}
.staff-header h3 {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.staff-item {
display: flex;
align-items: center;
padding: 22px 28px;
border-bottom: 1px solid var(--border-light);
cursor: pointer;
transition: var(--transition);
position: relative;
}
.staff-item:hover {
background: var(--bg-hover);
}
.staff-item:last-child {
border-bottom: none;
}
.staff-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 1.3rem;
margin-right: 18px;
box-shadow: var(--shadow-sm);
}
.staff-info {
flex: 1;
}
.staff-name {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 4px;
color: var(--text-primary);
}
.staff-details {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.staff-stats {
display: flex;
gap: 16px;
margin-top: 10px;
}
.stat-badge {
padding: 6px 14px;
border-radius: var(--radius-full);
font-size: 0.85rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
box-shadow: var(--shadow-sm);
}
.stat-badge.total {
background: #e3f2fd;
color: var(--primary);
border: 1px solid rgba(91, 191, 147, 0.3);
}
.stat-badge.expired {
background: #ffebee;
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.stat-badge.expiring {
background: #fff3e0;
color: var(--warning);
border: 1px solid rgba(245, 158, 11, 0.3);
}
.staff-arrow {
color: var(--text-muted);
font-size: 1.2rem;
transition: transform 0.3s;
}
/* ========== 员工详细统计卡片 ========== */
.staff-stats-grid {
display: none;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin: 16px 28px 20px 28px;
padding: 20px;
background: var(--primary-bg);
border-radius: var(--radius);
animation: slideDown 0.3s ease;
border: 1px solid var(--border);
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.staff-stat-card {
background: white;
padding: 20px;
border-radius: var(--radius);
text-align: center;
cursor: pointer;
transition: var(--transition);
box-shadow: var(--shadow-sm);
border: 1px solid var(--border);
}
.staff-stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.staff-stat-card.total:hover {
background: #e3f2fd;
border-color: var(--primary);
}
.staff-stat-card.expired:hover {
background: #ffebee;
border-color: var(--danger);
}
.staff-stat-card.expiring:hover {
background: #fff3e0;
border-color: var(--warning);
}
.staff-stat-card.clients:hover {
background: #e8f5e9;
border-color: var(--success);
}
.staff-stat-value {
font-size: 1.8rem;
font-weight: 700;
margin-bottom: 8px;
color: var(--text-primary);
}
.staff-stat-label {
font-size: 0.9rem;
color: var(--text-muted);
font-weight: 500;
}
/* ========== 员工管理页面 ========== */
.staff-management {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
margin-bottom: 30px;
border: 1px solid var(--border);
}
.management-header {
padding: 22px 28px;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(90deg, var(--primary-bg) 0%, transparent 100%);
}
.management-header h3 {
font-size: 1.3rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 10px;
}
.action-buttons {
display: flex;
gap: 12px;
}
.btn {
padding: 10px 22px;
border: none;
border-radius: var(--radius);
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-primary {
background: var(--primary);
color: white;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: var(--primary-dark);
transform: translateY(-2px);
box-shadow: var(--shadow);
}
.btn-danger {
background: var(--danger);
color: white;
}
.btn-danger:hover {
background: #dc2626;
transform: translateY(-2px);
}
.btn-warning {
background: var(--warning);
color: white;
}
.btn-warning:hover {
background: #d97706;
transform: translateY(-2px);
}
/* ========== 员工管理列表 ========== */
.staff-management-list {
padding: 0;
}
.management-staff-item {
display: flex;
align-items: center;
padding: 20px 28px;
border-bottom: 1px solid var(--border-light);
transition: var(--transition);
}
.management-staff-item:hover {
background: var(--bg-hover);
}
.management-staff-item:last-child {
border-bottom: none;
}
.staff-checkbox {
margin-right: 16px;
width: 18px;
height: 18px;
cursor: pointer;
}
.staff-actions {
display: flex;
gap: 8px;
margin-left: auto;
}
.btn-small {
padding: 6px 14px;
font-size: 0.9rem;
}
/* ========== 分组样式 ========== */
.group-item {
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 16px;
overflow: hidden;
background: #fff;
}
.group-header {
display: flex;
align-items: center;
padding: 16px 20px;
background: #f8fafc;
cursor: pointer;
transition: var(--transition);
}
.group-header:hover {
background: #f1f5f9;
}
.group-info {
flex: 1;
display: flex;
align-items: center;
gap: 12px;
}
.group-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
}
.group-stats {
display: flex;
gap: 20px;
color: var(--text-muted);
font-size: 0.9rem;
margin-right: 20px;
}
.group-stat-item i {
margin-right: 6px;
}
.group-content {
display: none;
padding: 0;
border-top: 1px solid var(--border);
}
.group-arrow {
color: var(--text-muted);
transition: transform 0.3s;
}
/* ========== 数据表格 ========== */
.data-table {
background: var(--bg-card);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
overflow: hidden;
border: 1px solid var(--border);
position: relative;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: var(--bg-hover);
padding: 18px 20px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border);
font-size: 0.95rem;
}
td {
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
transition: var(--transition);
}
tr:hover td {
background: var(--bg-hover);
}
/* ========== 筛选栏 ========== */
.filter-bar {
background: var(--bg-card);
padding: 22px;
border-radius: var(--radius-lg);
margin-bottom: 24px;
display: flex;
gap: 20px;
flex-wrap: wrap;
align-items: center;
box-shadow: var(--shadow);
border: 1px solid var(--border);
}
.filter-group {
display: flex;
align-items: center;
gap: 12px;
}
select,
input {
padding: 10px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.95rem;
background: white;
color: var(--text-primary);
transition: var(--transition);
}
select:hover,
input:hover {
border-color: var(--primary-light);
}
select:focus,
input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(91, 191, 147, 0.15);
}
/* ========== 模态框 ========== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(3px);
}
.modal {
background: var(--bg-card);
border-radius: var(--radius-lg);
padding: 32px;
width: 90%;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
box-shadow: var(--shadow-lg);
animation: modalAppear 0.3s ease;
border: 1px solid var(--border);
}
@keyframes modalAppear {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-light);
}
.modal-header h3 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.modal-header h3::before {
content: '';
display: block;
width: 4px;
height: 24px;
background: var(--primary);
border-radius: var(--radius-full);
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: var(--text-muted);
cursor: pointer;
padding: 8px;
border-radius: var(--radius);
transition: var(--transition);
}
.modal-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ========== 表单 ========== */
.form-group {
margin-bottom: 24px;
}
.form-group label {
display: block;
margin-bottom: 10px;
font-weight: 600;
color: var(--text-secondary);
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.form-group input,
.form-group select {
width: 100%;
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 1rem;
background: white;
color: var(--text-primary);
transition: var(--transition);
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(91, 191, 147, 0.15);
}
.modal-actions {
display: flex;
gap: 16px;
justify-content: flex-end;
margin-top: 32px;
}
.btn-cancel {
background: var(--bg-hover);
color: var(--text-secondary);
}
.btn-cancel:hover {
background: #e2e8f0;
color: var(--text-primary);
}
/* ========== 员工数据详情表格 ========== */
.staff-client-table {
width: 100%;
border-collapse: collapse;
margin-top: 24px;
}
.staff-client-table th {
background: var(--bg-hover);
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 2px solid var(--border-light);
font-size: 0.9rem;
}
.staff-client-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-light);
}
.empty-data {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-data .icon {
font-size: 3rem;
margin-bottom: 20px;
opacity: 0.3;
}
@media (max-width: 768px) {
.sidebar {
width: 70px;
}
.main-content {
margin-left: 70px;
padding: 20px;
}
.logo h1,
.subtitle,
.user-name,
.nav-text {
display: none;
}
.staff-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.stats-grid {
grid-template-columns: 1fr;
}
.filter-bar {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.filter-group {
flex-direction: column;
align-items: flex-start;
}
}
/* ========== 滚动条美化 ========== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* ========== 消息提示 toast和登录页一致 ========== */
.message {
position: fixed;
top: 20px;
right: 20px;
padding: 16px 24px;
border-radius: var(--radius);
font-weight: 600;
z-index: 10000;
color: white;
box-shadow: var(--shadow-lg);
animation: slideIn 0.3s ease;
max-width: 400px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.message.success {
background: linear-gradient(135deg, var(--success) 0%, #059669 100%);
}
.message.error {
background: linear-gradient(135deg, var(--danger) 0%, #dc2626 100%);
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
/* ========== 按钮 loading ========== */
.btn[disabled] {
opacity: .7;
cursor: not-allowed;
transform: none !important;
}
.loader {
display: inline-block;
width: 18px;
height: 18px;
border: 3px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="admin-container">
<!-- 侧边栏 -->
<div class="sidebar">
<div class="logo">
<h1><i class="fas fa-crown"></i> 总后台</h1>
<div class="subtitle">客户管理系统</div>
</div>
<div class="user-info">
<div class="user-avatar"></div>
<div class="user-name">
<div style="font-weight:600;">管理员</div>
<div style="font-size:0.85rem; opacity:0.8;">超级管理员</div>
</div>
</div>
<div class="nav-menu">
<div class="nav-item active" data-page="dashboard">
<span><i class="fas fa-chart-bar"></i></span>
<span class="nav-text">数据总览</span>
</div>
<div class="nav-item" data-page="staff">
<span><i class="fas fa-users"></i></span>
<span class="nav-text">员工管理</span>
</div>
<div class="nav-item" data-page="clients">
<span><i class="fas fa-list"></i></span>
<span class="nav-text">所有客户</span>
</div>
<!-- <div class="nav-item" data-page="reminders">
<span><i class="fas fa-bell"></i></span>
<span class="nav-text">到期提醒</span>
</div> -->
<div class="nav-item" id="logoutBtn">
<span><i class="fas fa-sign-out-alt"></i></span>
<span class="nav-text">退出登录</span>
</div>
</div>
</div>
<!-- 主内容区 - 数据总览 -->
<div class="main-content" id="dashboardPage">
<div class="header">
<h2>数据总览</h2>
<div class="date" id="currentDate"></div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card" onclick="showAllClients()">
<div class="stat-icon"><i class="fas fa-money-bill-wave"></i></div>
<div class="stat-value" id="totalAmount">¥ 0</div>
<div class="stat-label">总金额</div>
</div>
<div class="stat-card" onclick="showAllClients()">
<div class="stat-icon"><i class="fas fa-users"></i></div>
<div class="stat-value" id="totalClients">0</div>
<div class="stat-label">客户总数</div>
</div>
<div class="stat-card" onclick="showExpiringClients()">
<div class="stat-icon"><i class="fas fa-exclamation-circle"></i></div>
<div class="stat-value" id="expiringCount">0</div>
<div class="stat-label">即将到期</div>
</div>
<div class="stat-card" onclick="showExpiredClients()">
<div class="stat-icon"><i class="fas fa-clock"></i></div>
<div class="stat-value" id="expiredCount">0</div>
<div class="stat-label">已过期</div>
</div>
</div>
<!-- 员工列表 -->
<div class="staff-list">
<div class="staff-header">
<h3><i class="fas fa-user-friends"></i> 员工数据</h3>
<div style="color:var(--text-muted); font-size:0.9rem;">点击员工查看详细统计</div>
</div>
<div id="staffList">
<!-- 员工列表会动态加载 -->
</div>
</div>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="filter-group">
<label><i class="fas fa-user"></i> 员工:</label>
<select id="staffSelect">
<option value="all">所有员工</option>
</select>
</div>
<div class="filter-group">
<label><i class="fas fa-info-circle"></i> 状态:</label>
<select id="statusSelect">
<option value="all">全部</option>
<option value="active">正常</option>
<option value="expiring">即将到期</option>
<option value="expired">已过期</option>
</select>
</div>
<div class="filter-group">
<label><i class="fas fa-calendar"></i> 时间:</label>
<input type="date" id="dateFrom">
<span></span>
<input type="date" id="dateTo">
</div>
<button class="btn btn-primary" onclick="filterData()"><i class="fas fa-filter"></i> 筛选</button>
<button class="btn btn-cancel" onclick="exportData()"><i class="fas fa-download"></i> 导出数据</button>
</div>
<!-- 数据表格 -->
<div class="data-table">
<table>
<thead>
<tr>
<th>员工</th>
<th>客户姓名</th>
<th>联系电话</th>
<th>服务类型</th>
<th>登记日期</th>
<th>到期时间</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
<th>备注</th>
</tr>
</thead>
<tbody id="dataTableBody">
<!-- 数据会动态加载 -->
</tbody>
</table>
</div>
</div>
<!-- 员工管理页面 -->
<div class="main-content" id="staffPage" style="display:none;">
<div class="header">
<h2>用户管理</h2>
<div class="date">管理组长和员工账号</div>
</div>
<!-- 组长列表 -->
<div class="staff-management" style="margin-bottom: 30px;">
<div class="management-header">
<h3><i class="fas fa-user-tie"></i> 组长列表</h3>
<div class="action-buttons">
<button class="btn btn-primary" onclick="openUserModal('leader')">
<i class="fas fa-plus"></i> 添加组长
</button>
</div>
</div>
<div class="staff-management-list" id="leaderManagementList">
<!-- 组长列表动态加载 -->
</div>
</div>
<!-- 员工列表 -->
<div class="staff-management">
<div class="management-header">
<h3><i class="fas fa-users"></i> 员工列表</h3>
<div class="action-buttons">
<button class="btn btn-primary" onclick="openUserModal('staff')">
<i class="fas fa-plus"></i> 添加员工
</button>
<button class="btn btn-danger" onclick="deleteSelectedStaff()">
<i class="fas fa-trash"></i> 删除选中
</button>
</div>
</div>
<div class="staff-management-list" id="staffManagementList">
<!-- 员工列表动态加载 -->
</div>
</div>
</div>
<!-- 所有客户页面 -->
<div class="main-content" id="clientsPage" style="display:none;">
<div class="header">
<h2>所有客户</h2>
<div class="date">查看和管理所有客户数据</div>
</div>
<div class="filter-bar">
<div class="filter-group">
<label><i class="fas fa-search"></i> 搜索:</label>
<input type="text" id="searchClient" placeholder="客户姓名、电话或备注">
</div>
<div class="filter-group">
<label><i class="fas fa-user"></i> 员工:</label>
<select id="clientStaffSelect">
<option value="all">所有员工</option>
</select>
</div>
<div class="filter-group">
<label><i class="fas fa-concierge-bell"></i> 服务类型:</label>
<select id="serviceTypeSelect">
<option value="all">全部</option>
<option value="TK会员">TK会员</option>
<option value="测试会员">测试会员</option>
</select>
</div>
<button class="btn btn-primary" onclick="searchClients()"><i class="fas fa-search"></i> 搜索</button>
<button class="btn btn-cancel" onclick="exportAllClients()"><i class="fas fa-file-excel"></i>
导出Excel</button>
</div>
<div class="data-table">
<table>
<thead>
<tr>
<th>员工</th>
<th>客户姓名</th>
<th>联系电话</th>
<th>服务类型</th>
<th>登记日期</th>
<th>到期时间</th>
<th>金额</th>
<th>状态</th>
<th>操作</th>
<th>备注</th>
</tr>
</thead>
<tbody id="allClientsTable">
<!-- 所有客户数据 -->
</tbody>
</table>
</div>
</div>
</div>
<!-- 员工详情模态框 -->
<div id="staffDetailModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3 id="staffDetailTitle">员工详情</h3>
<button class="modal-close" onclick="closeStaffDetailModal()">&times;</button>
</div>
<div id="staffDetailContent">
<!-- 员工详情内容会动态加载 -->
</div>
</div>
</div>
<!-- 添加员工模态框 -->
<div id="addStaffModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-user-plus"></i> 添加用户</h3>
<button class="modal-close" onclick="closeAddStaffModal()">&times;</button>
</div>
<div class="form-group">
<label><i class="fas fa-user"></i> 姓名:</label>
<input type="text" id="staffName" placeholder="请输入姓名">
</div>
<div class="form-group">
<label><i class="fas fa-user-tag"></i> 角色:</label>
<select id="staffRole">
<option value="staff">员工</option>
<option value="leader">组长</option>
</select>
</div>
<div class="form-group" id="staffLeaderGroup" style="display:none;">
<label><i class="fas fa-user-tie"></i> 所属组长:</label>
<select id="staffLeaderSelect">
<option value="">直属 (管理员)</option>
</select>
</div>
<div class="form-group">
<label><i class="fas fa-user-circle"></i> 登录账号:</label>
<input type="text" id="staffUsername" placeholder="设置登录用户名">
</div>
<div class="form-group">
<label><i class="fas fa-lock"></i> 登录密码:</label>
<input type="password" id="staffPassword" placeholder="设置登录密码">
</div>
<div class="form-group">
<label><i class="fas fa-lock"></i> 确认密码:</label>
<input type="password" id="staffConfirmPassword" placeholder="再次输入密码">
</div>
<div class="form-group">
<label><i class="fas fa-phone"></i> 手机号码:</label>
<input type="tel" id="staffPhone" placeholder="员工手机号码">
</div>
<div class="form-group">
<label><i class="fas fa-envelope"></i> 邮箱:</label>
<input type="email" id="staffEmail" placeholder="员工邮箱">
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeAddStaffModal()">取消</button>
<button class="btn btn-primary" onclick="saveStaff()">保存员工</button>
</div>
</div>
</div>
<!-- 重置员工密码模态框 -->
<div id="resetPasswordModal" class="modal-overlay" style="display:none;">
<div class="modal">
<div class="modal-header">
<h3><i class="fas fa-key"></i> 重置密码</h3>
<button class="modal-close" onclick="closeResetPasswordModal()">&times;</button>
</div>
<div class="form-group">
<label><i class="fas fa-user"></i> 员工:</label>
<div id="resetStaffName" style="font-weight:600;"></div>
</div>
<div class="form-group">
<label><i class="fas fa-lock"></i> 新密码:</label>
<input type="password" id="resetStaffPassword" placeholder="请输入新密码">
</div>
<div class="form-group">
<label><i class="fas fa-lock"></i> 确认密码:</label>
<input type="password" id="resetStaffConfirmPassword" placeholder="请再次输入新密码">
</div>
<div class="modal-actions">
<button class="btn btn-cancel" onclick="closeResetPasswordModal()">取消</button>
<button class="btn btn-primary" onclick="confirmResetPassword()">确认重置</button>
</div>
</div>
</div>
<script>
let currentResetStaffId = null;
function openResetPasswordModal(staffId, staffName) {
currentResetStaffId = staffId;
$('resetStaffName').textContent = staffName || '-';
$('resetStaffPassword').value = '';
$('resetStaffConfirmPassword').value = '';
$('resetPasswordModal').style.display = 'flex';
}
function closeResetPasswordModal() {
$('resetPasswordModal').style.display = 'none';
currentResetStaffId = null;
}
async function confirmResetPassword() {
if (!currentResetStaffId) return showMessage('请选择员工', 'error');
const btn = document.querySelector('#resetPasswordModal .btn-primary');
const password = $('resetStaffPassword').value;
const confirmPassword = $('resetStaffConfirmPassword').value;
if (!password) return showMessage('请输入新密码', 'error');
if (password !== confirmPassword) return showMessage('两次输入的密码不一致', 'error');
try {
setBtnLoading(btn, true, '重置中...');
await apiFetch(`/api/staff/${currentResetStaffId}/reset_password`, {
method: 'POST',
body: { password }
});
closeResetPasswordModal();
showMessage('密码已重置', 'success');
} catch (e) {
showMessage('重置失败:' + e.message, 'error');
} finally {
setBtnLoading(btn, false);
}
}
// =======================
// ✅ 与 login.html 统一的鉴权存储
// =======================
const API_BASE = window.location.origin; // 同域部署:前后端同一个 host/port
const TOKEN_KEY = 'auth_token';
const USER_KEY = 'auth_user';
function $(id) { return document.getElementById(id); }
function showMessage(text, type) {
const msg = $('message');
msg.textContent = text;
msg.className = 'message ' + type;
msg.style.display = 'block';
setTimeout(() => {
msg.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
msg.style.display = 'none';
msg.style.animation = '';
}, 300);
}, 3000);
}
function getToken() {
return localStorage.getItem(TOKEN_KEY) || '';
}
function clearAuth() {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
function getAuthUser() {
try { return JSON.parse(localStorage.getItem(USER_KEY) || 'null'); }
catch { return null; }
}
// 可选:给某个按钮做 loading传入按钮 DOM
function setBtnLoading(btn, loading, text = '处理中...') {
if (!btn) return;
if (loading) {
btn.dataset._oldHtml = btn.innerHTML;
btn.innerHTML = `<div class="loader"></div> <span>${text}</span>`;
btn.disabled = true;
} else {
btn.innerHTML = btn.dataset._oldHtml || btn.innerHTML;
btn.disabled = false;
delete btn.dataset._oldHtml;
}
}
async function apiFetch(path, { method = 'GET', body, headers = {} } = {}) {
const token = getToken();
const res = await fetch(API_BASE + path, {
method,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers
},
body: body ? JSON.stringify(body) : undefined
});
// 401token 失效/未登录
if (res.status === 401) {
clearAuth();
showMessage('登录已过期,请重新登录', 'error');
setTimeout(() => location.href = 'index.html', 800); // 你的登录页文件名如果不是 index.html 改一下
throw new Error('Unauthorized');
}
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
if (!res.ok) throw new Error(`请求失败(${res.status})`);
return res; // 文件流
}
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.message || `请求失败(${res.status})`);
return data;
}
// =======================
// ✅ 进入页面先校验登录
// =======================
async function ensureAuthedOrRedirect() {
const token = getToken();
if (!token) {
location.href = 'index.html'; // 登录页
return null;
}
// 用 /me 再确认一次 token
const me = await apiFetch('/api/auth/me');
// 同步刷新本地 user避免登录后信息变化
localStorage.setItem(USER_KEY, JSON.stringify(me.user));
return me.user;
}
// =======================
// ✅ 全局数据
// =======================
let adminData = {
stats: { totalAmount: 0, totalClients: 0, expiringCount: 0, expiredCount: 0 },
staff: [],
clients: [],
currentSelectedStaff: null,
staff: [],
clients: [],
currentSelectedStaff: null,
leaders: [], // Cache for leaders
me: null
};
// =======================
// ✅ 初始化
// =======================
async function initPage() {
// 当前日期
const currentDate = new Date();
const options = { year: 'numeric', month: 'long', day: 'numeric' };
$('currentDate').textContent = currentDate.toLocaleDateString('zh-CN', options);
// 校验登录
adminData.me = await ensureAuthedOrRedirect();
if (!adminData.me) return;
// 左侧用户信息
applySidebarUser(adminData.me);
// 导航
initNavigation();
// 加载数据
await reloadDashboard();
await reloadClientsTable();
// 员工管理admin 才加载)
await loadStaffManagementList();
// 绑定搜索回车
const searchInput = $('searchClient');
if (searchInput) {
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') searchClients();
});
}
}
function applySidebarUser(user) {
try {
const nameEl = document.querySelector('.user-name > div');
if (nameEl) nameEl.textContent = user.name || user.username || '用户';
const avatarEl = document.querySelector('.user-avatar');
if (avatarEl) avatarEl.textContent = (user.name || user.username || 'U').charAt(0);
const roleEl = document.querySelector('.user-name > div:nth-child(2)');
if (roleEl) roleEl.textContent = user.role === 'admin' ? '超级管理员' : '员工';
} catch { }
}
// =======================
// ✅ 后端数据加载
// =======================
async function reloadDashboard() {
const ret = await apiFetch('/api/dashboard/summary?days=7');
adminData.stats = {
totalAmount: ret.stats.totalAmount || 0,
totalClients: ret.stats.totalClients || 0,
expiringCount: ret.stats.expiringCount || 0,
expiredCount: ret.stats.expiredCount || 0
};
const staffStats = ret.staffStats || [];
adminData.staff = staffStats.map(s => ({
id: s.id,
name: s.name || s.username,
username: s.username,
phone: s.phone || '',
email: s.email || '',
total: s.total || 0,
expired: s.expired || 0,
expiring: s.expiring || 0,
clients: s.clients || 0,
joinDate: (s.created_at ? String(s.created_at).slice(0, 10) : ''),
creator_id: s.creator_id,
creator_name: s.creator_name
}));
$('totalAmount').textContent = '¥ ' + formatNumber(adminData.stats.totalAmount);
$('totalClients').textContent = adminData.stats.totalClients;
$('expiringCount').textContent = adminData.stats.expiringCount;
$('expiredCount').textContent = adminData.stats.expiredCount;
loadStaffList();
}
async function reloadClientsTable(params = {}) {
const url = new URL(API_BASE + '/api/clients');
const query = { page: 1, pageSize: 200, ...params };
Object.entries(query).forEach(([k, v]) => {
if (v === undefined || v === null || v === '' || v === 'all') return;
url.searchParams.set(k, v);
});
// 这里要传相对 path 给 apiFetch
const ret = await apiFetch('/api/clients' + url.search);
adminData.clients = (ret.clients || []).map(c => ({
id: c.id,
staffId: c.staff_id,
staff: c.staff_name || c.staff_username || '',
customer: c.customerName,
phone: c.phone || '',
service: c.serviceType || '',
regDate: c.regDate || '',
expireDate: c.expireDate || '',
amount: Number(c.amount || 0),
status: c.status || 'active',
remark: c.remark || ''
}));
renderClientTables(adminData.clients);
}
// =======================
// ✅ 导航
// =======================
function initNavigation() {
const navItems = document.querySelectorAll('.nav-item');
navItems.forEach(item => {
item.addEventListener('click', async function () {
if (this.id === 'logoutBtn') { logout(); return; }
navItems.forEach(nav => nav.classList.remove('active'));
this.classList.add('active');
const pageId = this.getAttribute('data-page') + 'Page';
switchPage(pageId);
if (pageId === 'dashboardPage') {
await reloadDashboard();
await reloadClientsTable();
}
if (pageId === 'staffPage') {
await loadStaffManagementList();
}
if (pageId === 'clientsPage') {
await reloadClientsTable();
}
});
});
}
function switchPage(pageId) {
const pages = ['dashboardPage', 'staffPage', 'clientsPage', 'remindersPage', 'reportsPage', 'settingsPage'];
pages.forEach(page => {
const el = document.getElementById(page);
if (el) el.style.display = 'none';
});
const target = document.getElementById(pageId);
if (target) target.style.display = 'block';
}
// =======================
// ✅ 员工列表
// =======================
function loadStaffList() {
const staffListEl = $('staffList');
const staffSelectEl = $('staffSelect');
const clientStaffSelectEl = $('clientStaffSelect');
if (!staffListEl) return;
staffListEl.innerHTML = '';
if (staffSelectEl) staffSelectEl.innerHTML = '<option value="all">所有员工</option>';
if (clientStaffSelectEl) clientStaffSelectEl.innerHTML = '<option value="all">所有员工</option>';
// 按 Creator 分组
const groups = {};
adminData.staff.forEach(staff => {
const cid = staff.creator_id || 'admin';
const cname = staff.creator_name || '直属员工';
if (!groups[cid]) {
groups[cid] = {
id: cid,
name: cname,
total: 0,
clients: 0,
expiring: 0,
expired: 0,
staff: []
};
}
const g = groups[cid];
g.total += staff.total;
g.clients += staff.clients;
g.expiring += staff.expiring;
g.expired += staff.expired;
g.staff.push(staff);
// Populate Selects
const opt = document.createElement('option');
opt.value = String(staff.id);
opt.textContent = staff.name;
if (staffSelectEl) staffSelectEl.appendChild(opt.cloneNode(true));
if (clientStaffSelectEl) clientStaffSelectEl.appendChild(opt);
});
// 渲染分组
Object.values(groups).forEach(group => {
const groupEl = document.createElement('div');
groupEl.className = 'group-item';
// 计算员工数量
const staffCount = group.staff.length;
groupEl.innerHTML = `
<div class="group-header" onclick="toggleGroup('group-${group.id}')">
<div class="group-info">
<div class="group-avatar">${group.name.charAt(0)}</div>
<div style="font-weight:600; font-size:1.1rem;">${group.name}</div>
</div>
<div class="group-stats">
<span class="group-stat-item" style="color:#6b7280;"><i class="fas fa-user-tie"></i> ${staffCount} 员工</span>
<span class="group-stat-item" style="color:#3b82f6;"><i class="fas fa-users"></i> ${group.clients} 客户</span>
<span class="group-stat-item" style="color:#059669;"><i class="fas fa-money-bill-wave"></i> ¥${formatNumber(group.total)}</span>
<span class="group-stat-item" style="color:#dc2626;"><i class="fas fa-exclamation-circle"></i> ${group.expiring + group.expired} 异常</span>
</div>
<div class="group-arrow" id="arrow-group-${group.id}">▶</div>
</div>
<div class="group-content" id="group-${group.id}"></div>
`;
staffListEl.appendChild(groupEl);
const groupContentEl = groupEl.querySelector('.group-content');
group.staff.forEach(staff => {
const staffItem = document.createElement('div');
staffItem.className = 'staff-item';
staffItem.onclick = () => toggleStaffStats(staff.id);
staffItem.innerHTML = `
<div class="staff-avatar">${(staff.name || '').charAt(0)}</div>
<div class="staff-info">
<div class="staff-name">${staff.name}</div>
<div class="staff-details">账号: ${staff.username} | 入职: ${staff.joinDate || '-'}</div>
<div class="staff-stats">
<span class="stat-badge total"><i class="fas fa-money-bill-wave"></i> ¥ ${formatNumber(staff.total)}</span>
<span class="stat-badge expired"><i class="fas fa-clock"></i> ${staff.expired}</span>
<span class="stat-badge expiring"><i class="fas fa-exclamation-circle"></i> ${staff.expiring}</span>
</div>
</div>
<div class="staff-arrow" id="arrow-${staff.id}">▶</div>
`;
groupContentEl.appendChild(staffItem);
const statsContainer = document.createElement('div');
statsContainer.className = 'staff-stats-grid';
statsContainer.id = `stats-${staff.id}`;
statsContainer.innerHTML = `
<div class="staff-stat-card total" onclick="showStaffDetail(${staff.id}, 'total')">
<div class="staff-stat-value">¥ ${formatNumber(staff.total)}</div>
<div class="staff-stat-label">总金额</div>
</div>
<div class="staff-stat-card expired" onclick="showStaffDetail(${staff.id}, 'expired')">
<div class="staff-stat-value">${staff.expired}</div>
<div class="staff-stat-label">已过期</div>
</div>
<div class="staff-stat-card expiring" onclick="showStaffDetail(${staff.id}, 'expiring')">
<div class="staff-stat-value">${staff.expiring}</div>
<div class="staff-stat-label">即将到期</div>
</div>
<div class="staff-stat-card clients" onclick="showStaffDetail(${staff.id}, 'clients')">
<div class="staff-stat-value">${staff.clients}</div>
<div class="staff-stat-label">客户总数</div>
</div>
`;
groupContentEl.appendChild(statsContainer);
});
});
// 如果没有任何分组(空数据),显示空状态
if (Object.keys(groups).length === 0) {
staffListEl.innerHTML = `
<div class="empty-data">
<div class="icon"><i class="fas fa-users"></i></div>
<p>暂无员工数据</p>
</div>
`;
}
}
function toggleGroup(groupId) {
const content = document.getElementById(groupId);
const arrow = document.getElementById(`arrow-${groupId}`);
if (!content || !arrow) return;
if (content.style.display === 'block') {
content.style.display = 'none';
arrow.style.transform = 'rotate(0deg)';
} else {
content.style.display = 'block';
arrow.style.transform = 'rotate(90deg)';
}
}
function toggleStaffStats(staffId) {
const statsContainer = document.getElementById(`stats-${staffId}`);
const arrow = document.getElementById(`arrow-${staffId}`);
if (!statsContainer || !arrow) return;
if (statsContainer.style.display === 'grid') {
statsContainer.style.display = 'none';
arrow.style.transform = 'rotate(0deg)';
} else {
adminData.staff.forEach(staff => {
if (staff.id !== staffId) {
const otherStats = document.getElementById(`stats-${staff.id}`);
const otherArrow = document.getElementById(`arrow-${staff.id}`);
if (otherStats) otherStats.style.display = 'none';
if (otherArrow) otherArrow.style.transform = 'rotate(0deg)';
}
});
statsContainer.style.display = 'grid';
arrow.style.transform = 'rotate(90deg)';
}
}
async function showStaffDetail(staffId, type) {
const staff = adminData.staff.find(s => s.id === staffId);
if (!staff) return;
adminData.currentSelectedStaff = staff;
let status = 'all';
if (type === 'expired') status = 'expired';
if (type === 'expiring') status = 'expiring';
await reloadClientsTable({ staffId, status });
const filteredClients = adminData.clients;
let title = '', description = '';
if (type === 'total' || type === 'clients') {
title = `${staff.name} - 所有客户`;
description = `${filteredClients.length} 个客户,总金额 ¥${formatNumber(staff.total)}`;
} else if (type === 'expired') {
title = `${staff.name} - 已过期客户`;
description = `${filteredClients.length} 个已过期客户`;
} else if (type === 'expiring') {
title = `${staff.name} - 即将到期客户`;
description = `${filteredClients.length} 个即将到期客户`;
}
$('staffDetailTitle').textContent = title;
let content = `
<div style="margin-bottom: 20px; color: var(--text-muted);">
<i class="fas fa-info-circle"></i> ${description}
</div>
`;
if (filteredClients.length === 0) {
content += `
<div class="empty-data">
<div class="icon"><i class="fas fa-database"></i></div>
<p>暂无相关客户数据</p>
</div>
`;
} else {
content += `
<table class="staff-client-table">
<thead>
<tr>
<th>客户姓名</th><th>联系电话</th><th>服务类型</th><th>登记日期</th>
<th>到期时间</th><th>金额</th><th>状态</th><th>备注</th>
</tr>
</thead>
<tbody>
`;
filteredClients.forEach(client => {
const statusInfo = getStatusInfo(client);
content += `
<tr>
<td>${client.customer}</td>
<td>${client.phone}</td>
<td>${client.service}</td>
<td>${client.regDate}</td>
<td>${client.expireDate}</td>
<td style="font-weight:600;">¥ ${formatNumber(client.amount)}</td>
<td style="${statusInfo.style} font-weight:600;">${statusInfo.text}</td>
<td>${client.remark}</td>
</tr>
`;
});
content += `</tbody></table>`;
}
$('staffDetailContent').innerHTML = content;
$('staffDetailModal').style.display = 'flex';
}
function closeStaffDetailModal() {
$('staffDetailModal').style.display = 'none';
reloadDashboard().then(() => reloadClientsTable()).catch(() => { });
}
// =======================
// ✅ 员工管理admin 才能用)
// =======================
async function loadStaffManagementList() {
const leaderEl = $('leaderManagementList');
const staffEl = $('staffManagementList');
if (!leaderEl || !staffEl) return;
if (!adminData.me || adminData.me.role !== 'admin') {
const msg = `
<div class="empty-data">
<div class="icon"><i class="fas fa-lock"></i></div>
<p>只有管理员可以管理用户</p>
</div>
`;
leaderEl.innerHTML = msg;
staffEl.innerHTML = msg;
return;
}
const ret = await apiFetch('/api/staff?includeInactive=true');
const allUsers = ret.staff || [];
const leaders = allUsers.filter(u => u.role === 'leader');
const staffs = allUsers.filter(u => u.role === 'staff');
// 渲染组长列表
leaderEl.innerHTML = '';
if (leaders.length === 0) {
leaderEl.innerHTML = `
<div class="empty-data">
<div class="icon"><i class="fas fa-user-tie"></i></div>
<p>暂无组长,点击"添加组长"创建</p>
</div>
`;
} else {
leaders.forEach(user => renderUserItem(leaderEl, user, '#3b82f6'));
}
// 渲染员工列表
staffEl.innerHTML = '';
if (staffs.length === 0) {
staffEl.innerHTML = `
<div class="empty-data">
<div class="icon"><i class="fas fa-users"></i></div>
<p>暂无员工,点击"添加员工"创建</p>
</div>
`;
} else {
staffs.forEach(user => renderUserItem(staffEl, user, '#10b981'));
}
}
function renderUserItem(container, user, avatarColor) {
const joinDate = user.created_at ? String(user.created_at).slice(0, 10) : '-';
const safeName = String(user.name || user.username || '')
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'");
const isInactive = user.status === 'inactive';
const statusStyle = isInactive ? 'color:#ef4444;' : 'color:#10b981;';
const statusText = isInactive ? '已禁用' : '正常';
const itemStyle = isInactive ? 'opacity:0.6;background:#fef2f2;' : '';
const toggleBtnClass = isInactive ? 'btn-primary' : 'btn-danger';
const toggleBtnIcon = isInactive ? 'fa-check-circle' : 'fa-ban';
const toggleBtnText = isInactive ? '启用' : '禁用';
const item = document.createElement('div');
item.className = 'management-staff-item';
item.style.cssText = itemStyle;
item.innerHTML = `
<input type="checkbox" class="staff-checkbox" value="${user.id}">
<div class="staff-avatar" style="background:${isInactive ? '#9ca3af' : avatarColor};">${(user.name || user.username || '').charAt(0)}</div>
<div class="staff-info">
<div class="staff-name">${user.name || user.username}${isInactive ? '<span style="background:#ef4444;color:white;padding:2px 8px;border-radius:4px;font-size:0.75rem;margin-left:8px;">已禁用</span>' : ''}</div>
<div class="staff-details">
<span><i class="fas fa-user-circle"></i> ${user.username}</span> |
<span><i class="fas fa-phone"></i> ${user.phone || '-'}</span> |
<span><i class="fas fa-envelope"></i> ${user.email || '-'}</span>
</div>
<div class="staff-details">
<span><i class="fas fa-calendar-alt"></i> 入职: ${joinDate}</span> |
<span style="${statusStyle}"><i class="fas fa-circle"></i> ${statusText}</span>
</div>
</div>
</div>
<div class="staff-actions">
<button class="btn btn-small btn-primary" onclick='openUserModal(${JSON.stringify(user).replace(/'/g, "&#39;")})'>
<i class="fas fa-edit"></i> 编辑
</button>
<button class="btn btn-small btn-warning" onclick="openResetPasswordModal(${user.id}, '${safeName}')">
<i class="fas fa-key"></i> 重置密码
</button>
<button class="btn btn-small ${toggleBtnClass}" onclick="toggleUserStatus(${user.id})">
<i class="fas ${toggleBtnIcon}"></i> ${toggleBtnText}
</button>
</div>
`;
container.appendChild(item);
}
let currentEditUserId = null;
function openUserModal(userOrRole = 'staff') {
const modal = $('addStaffModal');
const title = modal.querySelector('.modal-header h3');
modal.style.display = 'flex';
// 重置表单
$('staffName').value = '';
$('staffUsername').value = '';
$('staffPassword').value = '';
$('staffConfirmPassword').value = '';
$('staffPhone').value = '';
$('staffEmail').value = '';
// 只有 admin 可以看到所属组长选项
const leaderGroup = $('staffLeaderGroup');
const leaderSelect = $('staffLeaderSelect');
if (leaderGroup) leaderGroup.style.display = 'none';
if (typeof userOrRole === 'object' && userOrRole !== null) {
// 编辑模式
const user = userOrRole;
currentEditUserId = user.id;
title.innerHTML = '<i class="fas fa-user-edit"></i> 编辑用户';
$('staffRole').value = user.role;
$('staffName').value = user.name || '';
$('staffUsername').value = user.username || '';
$('staffPhone').value = user.phone || '';
$('staffEmail').value = user.email || '';
$('staffPassword').placeholder = '留空则不修改密码';
$('staffConfirmPassword').placeholder = '留空则不修改密码';
// 如果是编辑员工且当前是admin显示修改组长选项
if (user.role === 'staff' && leaderGroup) {
leaderGroup.style.display = 'block';
populateLeaderSelect(user.creator_id);
}
} else {
// 新增模式
currentEditUserId = null;
const role = typeof userOrRole === 'string' ? userOrRole : 'staff';
title.innerHTML = `<i class="fas fa-user-plus"></i> 添加${role === 'leader' ? '组长' : '员工'}`;
$('staffRole').value = role;
$('staffPassword').placeholder = '设置登录密码';
$('staffConfirmPassword').placeholder = '再次输入密码';
// 新增员工时也可以选择组长
if (role === 'staff' && leaderGroup) {
leaderGroup.style.display = 'block';
populateLeaderSelect(null);
}
}
}
async function populateLeaderSelect(currentCreatorId) {
const select = $('staffLeaderSelect');
if (!select) return;
select.innerHTML = '<option value="">加载中...</option>';
try {
// Check cache or fetch
if (!adminData.leaders || adminData.leaders.length === 0) {
const ret = await apiFetch('/api/staff?includeInactive=true');
const all = ret.staff || [];
adminData.leaders = all.filter(u => u.role === 'leader');
}
let html = '<option value="">直属 (管理员)</option>';
adminData.leaders.forEach(l => {
const selected = (currentCreatorId && currentCreatorId === l.id) ? 'selected' : '';
html += `<option value="${l.id}" ${selected}>${l.name || l.username} (组长)</option>`;
});
select.innerHTML = html;
// 如果 currentCreatorId 既不是 admin 也不在列表里(可能被删了),默认选 admin 或保持原样。
// 这里简单处理如果value匹配不上select依然会显示第一项或空所以没大问题。
} catch (e) {
console.error('Fetch leaders failed', e);
select.innerHTML = '<option value="">加载失败</option>';
}
}
function closeAddStaffModal() {
$('addStaffModal').style.display = 'none';
currentEditUserId = null;
}
async function saveStaff() {
const btn = document.querySelector('#addStaffModal .btn-primary');
const role = $('staffRole').value;
const name = $('staffName').value.trim();
const username = $('staffUsername').value.trim();
const password = $('staffPassword').value;
const confirmPassword = $('staffConfirmPassword').value;
const phone = $('staffPhone').value.trim();
const email = $('staffEmail').value.trim();
if (!name || !username) return showMessage('请填写姓名和登录账号', 'error');
// 新增时必须填密码,编辑时可选
if (!currentEditUserId && !password) return showMessage('请设置登录密码', 'error');
if (password && password !== confirmPassword) return showMessage('两次输入的密码不一致', 'error');
try {
setBtnLoading(btn, true, '保存中...');
if (currentEditUserId) {
// 编辑
const body = { role, name, username, phone, email };
if (password) body.password = password;
// 处理组长修改
const leaderSelect = $('staffLeaderSelect');
if (leaderSelect && leaderSelect.offsetParent !== null) { // visible
body.creator_id = leaderSelect.value; // "" for admin, "id" for leader
}
await apiFetch(`/api/staff/${currentEditUserId}`, {
method: 'PUT',
body: body
});
showMessage('用户修改成功', 'success');
} else {
// 新增
const body = { role, name, username, password, phone, email };
// 处理组长选择
const leaderSelect = $('staffLeaderSelect');
if (leaderSelect && leaderSelect.offsetParent !== null) {
body.creator_id = leaderSelect.value;
}
await apiFetch('/api/staff', {
method: 'POST',
body: body
});
showMessage('用户添加成功', 'success');
}
closeAddStaffModal();
await loadStaffManagementList();
} catch (e) {
showMessage('保存失败:' + e.message, 'error');
} finally {
setBtnLoading(btn, false);
}
}
async function deleteSelectedStaff() {
const checkboxes = document.querySelectorAll('.staff-checkbox:checked');
if (checkboxes.length === 0) return showMessage('请选择要删除的员工', 'error');
if (!confirm(`确定要删除选中的 ${checkboxes.length} 名员工吗?`)) return;
try {
for (const cb of checkboxes) {
const id = Number(cb.value);
await apiFetch(`/api/staff/${id}`, { method: 'DELETE' });
}
showMessage('删除成功', 'success');
await reloadDashboard();
await loadStaffManagementList();
} catch (e) {
showMessage('删除失败:' + e.message, 'error');
}
}
async function deleteStaff(staffId) {
if (!confirm('确定要禁用此用户吗?')) return;
try {
await apiFetch(`/api/staff/${staffId}`, { method: 'DELETE' });
showMessage('已禁用', 'success');
await reloadDashboard();
await loadStaffManagementList();
} catch (e) {
showMessage('操作失败:' + e.message, 'error');
}
}
async function toggleUserStatus(userId) {
try {
const ret = await apiFetch(`/api/staff/${userId}/toggle_status`, { method: 'POST' });
showMessage(ret.status === 'active' ? '已启用' : '已禁用', 'success');
await loadStaffManagementList();
} catch (e) {
showMessage('操作失败:' + e.message, 'error');
}
}
// =======================
async function deleteClient(clientId) {
if (!confirm('确定要删除该客户吗?')) return;
try {
await apiFetch(`/api/clients/${clientId}`, { method: 'DELETE' });
showMessage('客户已删除', 'success');
await reloadDashboard();
const clientsPage = $('clientsPage');
if (clientsPage && clientsPage.style.display !== 'none') {
const q = $('searchClient').value.trim();
const staffId = $('clientStaffSelect').value;
const serviceType = $('serviceTypeSelect').value;
await reloadClientsTable({ q, staffId, serviceType });
} else {
const staffId = $('staffSelect').value;
const status = $('statusSelect').value;
const dateFrom = $('dateFrom').value;
const dateTo = $('dateTo').value;
await reloadClientsTable({ staffId, status, dateFrom, dateTo });
}
} catch (e) {
showMessage('删除失败:' + e.message, 'error');
}
}
// ✅ 表格渲染 + 筛选/搜索/导出
// =======================
function renderClientTables(list) {
const tbody = $('dataTableBody');
const allClientsTable = $('allClientsTable');
if (tbody) tbody.innerHTML = '';
if (allClientsTable) allClientsTable.innerHTML = '';
list.forEach(client => {
const statusInfo = getStatusInfo(client);
const html = `
<td>${client.staff}</td>
<td>${client.customer}</td>
<td>${client.phone}</td>
<td>${client.service}</td>
<td>${client.regDate}</td>
<td>${client.expireDate}</td>
<td style="font-weight:600;">¥ ${formatNumber(client.amount)}</td>
<td style="${statusInfo.style} font-weight:600;">${statusInfo.text}</td>
<td>
<button class="btn btn-small btn-danger" onclick="deleteClient(${client.id})">
<i class="fas fa-trash"></i> 删除
</button>
</td>
<td>${client.remark}</td>
`;
if (tbody) {
const row = document.createElement('tr');
row.innerHTML = html;
tbody.appendChild(row);
}
if (allClientsTable) {
const row = document.createElement('tr');
row.innerHTML = html;
allClientsTable.appendChild(row);
}
});
}
async function filterData() {
const btn = document.querySelector('#dashboardPage .filter-bar .btn-primary');
const staffId = $('staffSelect').value;
const status = $('statusSelect').value;
const dateFrom = $('dateFrom').value;
const dateTo = $('dateTo').value;
try {
setBtnLoading(btn, true, '筛选中...');
await reloadClientsTable({ staffId, status, dateFrom, dateTo });
showMessage('筛选完成', 'success');
} catch (e) {
showMessage('筛选失败:' + e.message, 'error');
} finally {
setBtnLoading(btn, false);
}
}
async function searchClients() {
const btn = document.querySelector('#clientsPage .filter-bar .btn-primary');
const q = $('searchClient').value.trim();
const staffId = $('clientStaffSelect').value;
const serviceType = $('serviceTypeSelect').value;
try {
setBtnLoading(btn, true, '搜索中...');
await reloadClientsTable({ q, staffId, serviceType });
showMessage('搜索完成', 'success');
} catch (e) {
showMessage('搜索失败:' + e.message, 'error');
} finally {
setBtnLoading(btn, false);
}
}
function exportData() {
const staffId = $('staffSelect').value;
const status = $('statusSelect').value;
const dateFrom = $('dateFrom').value;
const dateTo = $('dateTo').value;
downloadExport({ staffId, status, dateFrom, dateTo });
}
function exportAllClients() {
const q = $('searchClient').value.trim();
const staffId = $('clientStaffSelect').value;
const serviceType = $('serviceTypeSelect').value;
downloadExport({ q, staffId, serviceType });
}
async function downloadExport(params = {}) {
const btn = document.querySelector('.btn.btn-cancel'); // 简单取一个,你也可分别传入
const url = new URL(API_BASE + '/api/export/clients');
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === '' || v === 'all') return;
url.searchParams.set(k, v);
});
try {
setBtnLoading(btn, true, '导出中...');
// 这里用 fetch + blob 才能带 Authorization
const res = await apiFetch('/api/export/clients' + url.search);
const blob = await res.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `clients_${Date.now()}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
showMessage('导出成功', 'success');
} catch (e) {
showMessage('导出失败:' + e.message, 'error');
} finally {
setBtnLoading(btn, false);
}
}
function getStatusInfo(client) {
const today = new Date();
const expireDate = client.expireDate ? new Date(client.expireDate) : null;
if (!expireDate || Number.isNaN(expireDate.getTime())) {
return { text: '正常', style: 'color: var(--success);' };
}
const diffDays = Math.ceil((expireDate - today) / (1000 * 60 * 60 * 24));
if (diffDays < 0) return { text: '已过期', style: 'color: var(--danger);' };
if (diffDays <= 7) return { text: `${diffDays}天后到期`, style: 'color: var(--warning);' };
return { text: '正常', style: 'color: var(--success);' };
}
function formatNumber(num) {
const n = Number(num || 0);
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
function logout() {
if (!confirm('确定要退出登录吗?')) return;
clearAuth();
showMessage('已退出登录', 'success');
setTimeout(() => location.href = 'index.html', 600);
}
function showAllClients() {
document.querySelector('.nav-item[data-page="clients"]').click();
}
async function showExpiringClients() {
document.querySelector('.nav-item[data-page="clients"]').click();
await reloadClientsTable({ status: 'expiring' });
}
async function showExpiredClients() {
document.querySelector('.nav-item[data-page="clients"]').click();
await reloadClientsTable({ status: 'expired' });
}
// 页面加载
document.addEventListener('DOMContentLoaded', () => {
initPage().catch(e => {
if (String(e.message || '').includes('Unauthorized')) return;
showMessage('初始化失败:' + e.message, 'error');
});
});
</script>
<!-- Font Awesome图标 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<div id="message" class="message" style="display:none;"></div>
</body>
</html>