diff --git a/frontend/app.html b/frontend/app.html index 8f10f10..670e2f6 100644 --- a/frontend/app.html +++ b/frontend/app.html @@ -654,6 +654,200 @@ -webkit-overflow-scrolling: touch; } + .admin-users-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + } + + .admin-users-toolbar { + display: grid; + grid-template-columns: minmax(220px, 1.2fr) repeat(4, minmax(130px, 1fr)) minmax(84px, 0.6fr) auto; + gap: 10px; + margin-bottom: 10px; + padding: 12px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + } + + .admin-users-filter { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + } + + .admin-users-filter label { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + } + + .admin-users-filter input, + .admin-users-filter select { + width: 100%; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--glass-border); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + } + + .admin-users-filter input::placeholder { + color: var(--text-muted); + } + + .admin-users-filter-reset { + align-self: end; + justify-content: flex-end; + } + + .admin-users-filter-reset .btn { + height: 36px; + width: 100%; + padding: 0 12px; + white-space: nowrap; + } + + .admin-users-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + } + + .admin-users-stat-chip { + display: inline-flex; + align-items: center; + padding: 5px 10px; + border-radius: 999px; + border: 1px solid var(--glass-border); + background: rgba(255, 255, 255, 0.06); + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + line-height: 1.2; + } + + .admin-users-empty-state { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + min-height: 130px; + border: 1px dashed var(--glass-border); + border-radius: 10px; + color: var(--text-muted); + font-size: 14px; + } + + .admin-users-empty-state i { + font-size: 18px; + } + + .admin-users-table thead th { + position: sticky; + top: 0; + z-index: 2; + background: rgba(40, 50, 80, 0.92) !important; + backdrop-filter: blur(6px); + } + + .admin-user-status-tag { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 66px; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + border: 1px solid transparent; + } + + .admin-user-status-tag.status-active { + color: #22c55e; + background: rgba(34, 197, 94, 0.15); + border-color: rgba(34, 197, 94, 0.35); + } + + .admin-user-status-tag.status-banned { + color: #ef4444; + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.35); + } + + .admin-user-status-tag.status-unverified { + color: #f59e0b; + background: rgba(245, 158, 11, 0.15); + border-color: rgba(245, 158, 11, 0.35); + } + + .admin-user-status-tag.status-download_blocked { + color: #f97316; + background: rgba(249, 115, 22, 0.14); + border-color: rgba(249, 115, 22, 0.34); + } + + .admin-users-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; + margin-top: 10px; + padding: 10px 12px; + border: 1px solid var(--glass-border); + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + } + + .admin-users-pagination-info { + color: var(--text-secondary); + font-size: 13px; + } + + .admin-users-pagination-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + .admin-users-pagination-actions .btn { + min-width: 76px; + padding: 6px 10px; + } + + .admin-users-page-indicator { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 78px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--glass-border); + border-radius: 8px; + font-size: 13px; + color: var(--text-secondary); + background: rgba(255, 255, 255, 0.04); + } + + .admin-search-hit { + background: rgba(250, 204, 21, 0.28); + color: var(--text-primary); + padding: 0 2px; + border-radius: 3px; + font-weight: 700; + } + /* 移动端适配 */ @media (max-width: 768px) { @@ -917,6 +1111,39 @@ .admin-log-pager .btn { min-width: 110px; } + .admin-users-header { + flex-direction: column; + align-items: stretch; + } + .admin-users-toolbar { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-users-filter-search { + grid-column: 1 / -1; + } + .admin-users-filter-reset { + grid-column: 1 / -1; + } + .admin-users-filter-reset .btn { + width: 100%; + } + .admin-users-pagination { + padding: 10px; + } + .admin-users-pagination-info { + width: 100%; + } + .admin-users-pagination-actions { + width: 100%; + justify-content: space-between; + } + .admin-users-pagination-actions .btn { + min-width: 0; + flex: 1; + } + .admin-users-page-indicator { + min-width: 64px; + } .admin-users-table { min-width: 700px !important; } @@ -972,6 +1199,9 @@ .admin-log-actions { grid-template-columns: 1fr; } + .admin-users-toolbar { + grid-template-columns: 1fr; + } .admin-health-summary { font-size: 12px !important; } @@ -993,6 +1223,13 @@ .admin-users-table td:nth-child(7) { display: none; } + .admin-users-pagination-actions { + gap: 6px; + } + .admin-users-pagination-actions .btn { + padding: 6px 8px; + font-size: 12px; + } /* 列表视图在超小屏幕隐藏文件大小列 */ .file-list th:nth-child(2), @@ -3275,115 +3512,222 @@
-

用户管理

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ID用户名角色邮箱存储权限当前存储存储配额下载流量状态操作
{{ u.id }} -
- {{ u.username }} -
-
- - 管理员 - - - 用户 - - {{ u.email }} - 仅本地 - 仅OSS - 用户选择 - - - 本地 - - - OSS - - -
-
{{ formatBytes(u.local_storage_used) }} / {{ formatBytes(u.local_storage_quota) }}
-
- {{ getAdminUserQuotaPercentage(u) }}% -
-
-
-
{{ formatBytes(u.storage_used || 0) }} / {{ formatBytes(u.oss_storage_quota) }}
-
- {{ getAdminUserOssQuotaPercentage(u) }}% -
-
-
-
-
{{ formatBytes(u.download_traffic_used || 0) }} / {{ formatBytes(u.download_traffic_quota) }}
-
- {{ u.download_traffic_quota === 0 ? '已禁用下载' : (getAdminUserDownloadQuotaPercentage(u) + '%') }} -
-
-
-
{{ formatBytes(u.download_traffic_used || 0) }} / 不限
-
- 不限 -
-
-
- {{ getDownloadResetCycleText(u.download_traffic_reset_cycle || 'none') }} -
-
- 到期: {{ formatDate(u.download_traffic_quota_expires_at) }} -
-
- 已封禁 - 未激活 - 正常 - - -
+
+

用户管理

+
+ +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ 总用户 {{ adminUsers.length }} + 筛选后 {{ adminUsersFilteredCount }} + 正常 {{ adminUserStats.active }} + 封禁 {{ adminUserStats.banned }} + 未激活 {{ adminUserStats.unverified }} + 下载受限 {{ adminUserStats.download_blocked }} +
+ +
+ + 正在加载用户数据... +
+ +
@@ -5420,6 +5764,62 @@ border-radius: 8px; } + body.enterprise-netdisk .admin-users-toolbar, + body.enterprise-netdisk .admin-users-pagination, + body.enterprise-netdisk .admin-users-empty-state { + background: var(--bg-secondary); + border: 1px solid var(--glass-border); + border-radius: 8px; + } + + body.enterprise-netdisk .admin-users-filter input, + body.enterprise-netdisk .admin-users-filter select { + border: 1px solid var(--glass-border); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + } + + body.enterprise-netdisk .admin-users-stat-chip { + background: var(--bg-card-hover); + border: 1px solid var(--glass-border); + color: var(--text-secondary); + } + + body.enterprise-netdisk .admin-users-table thead th { + background: var(--bg-card-hover) !important; + backdrop-filter: none; + } + + body.enterprise-netdisk .admin-user-status-tag.status-active { + color: #15803d; + background: #dcfce7; + border-color: #86efac; + } + + body.enterprise-netdisk .admin-user-status-tag.status-banned { + color: #b91c1c; + background: #fee2e2; + border-color: #fca5a5; + } + + body.enterprise-netdisk .admin-user-status-tag.status-unverified { + color: #b45309; + background: #fef3c7; + border-color: #fcd34d; + } + + body.enterprise-netdisk .admin-user-status-tag.status-download_blocked { + color: #c2410c; + background: #ffedd5; + border-color: #fdba74; + } + + body.enterprise-netdisk .admin-search-hit { + background: #fde68a; + color: #7c2d12; + } + body.enterprise-netdisk .admin-log-row { border-bottom: 1px solid var(--glass-border); } diff --git a/frontend/app.js b/frontend/app.js index 0ce89d9..aeea406 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -141,6 +141,16 @@ createApp({ // 管理员 adminUsers: [], + adminUsersLoading: false, + adminUsersPage: 1, + adminUsersPageSize: 20, + adminUserFilters: { + keyword: '', + role: 'all', // all/admin/user + status: 'all', // all/active/banned/unverified/download_blocked + storage: 'all', // all/local/oss/local_only/oss_only/user_choice + sort: 'created_desc' // created_desc/created_asc/username_asc/username_desc/storage_usage_desc/download_usage_desc + }, showResetPwdModal: false, resetPwdUser: {}, newPassword: '', @@ -559,6 +569,132 @@ createApp({ }); return list; + }, + + adminUsersFiltered() { + let list = Array.isArray(this.adminUsers) ? [...this.adminUsers] : []; + const keyword = (this.adminUserFilters.keyword || '').trim().toLowerCase(); + + if (keyword) { + list = list.filter((u) => { + const idText = String(u?.id || ''); + const usernameText = String(u?.username || '').toLowerCase(); + const emailText = String(u?.email || '').toLowerCase(); + return idText.includes(keyword) || usernameText.includes(keyword) || emailText.includes(keyword); + }); + } + + if (this.adminUserFilters.role === 'admin') { + list = list.filter((u) => !!u?.is_admin); + } else if (this.adminUserFilters.role === 'user') { + list = list.filter((u) => !u?.is_admin); + } + + if (this.adminUserFilters.status !== 'all') { + list = list.filter((u) => this.getAdminUserStatusTag(u) === this.adminUserFilters.status); + } + + if (this.adminUserFilters.storage !== 'all') { + const selectedStorage = this.adminUserFilters.storage; + list = list.filter((u) => { + const currentStorage = u?.current_storage_type || 'oss'; + const storagePermission = u?.storage_permission || 'oss_only'; + if (selectedStorage === 'local' || selectedStorage === 'oss') { + return currentStorage === selectedStorage; + } + return storagePermission === selectedStorage; + }); + } + + const getCreatedTime = (u) => { + const raw = String(u?.created_at || '').trim(); + if (!raw) return 0; + const normalized = raw.includes('T') ? raw : raw.replace(' ', 'T'); + const ts = Date.parse(normalized); + return Number.isFinite(ts) ? ts : 0; + }; + + const getStorageUsage = (u) => { + const currentStorage = u?.current_storage_type || 'oss'; + if (currentStorage === 'local') { + return this.getAdminUserQuotaPercentage(u); + } + return this.getAdminUserOssQuotaPercentage(u); + }; + + const getDownloadUsage = (u) => this.getAdminUserDownloadQuotaPercentage(u); + + list.sort((a, b) => { + const aName = String(a?.username || '').toLowerCase(); + const bName = String(b?.username || '').toLowerCase(); + const aId = Number(a?.id || 0); + const bId = Number(b?.id || 0); + + switch (this.adminUserFilters.sort) { + case 'created_asc': + return getCreatedTime(a) - getCreatedTime(b) || aId - bId; + case 'username_asc': + return aName.localeCompare(bName, 'zh-CN') || aId - bId; + case 'username_desc': + return bName.localeCompare(aName, 'zh-CN') || bId - aId; + case 'storage_usage_desc': + return getStorageUsage(b) - getStorageUsage(a) || bId - aId; + case 'download_usage_desc': + return getDownloadUsage(b) - getDownloadUsage(a) || bId - aId; + default: + return getCreatedTime(b) - getCreatedTime(a) || bId - aId; + } + }); + + return list; + }, + + adminUsersFilteredCount() { + return this.adminUsersFiltered.length; + }, + + adminUsersTotalPages() { + const size = Math.max(1, Number(this.adminUsersPageSize) || 20); + return Math.max(1, Math.ceil(this.adminUsersFilteredCount / size)); + }, + + adminUsersCurrentPage() { + const page = Math.max(1, Number(this.adminUsersPage) || 1); + return Math.min(page, this.adminUsersTotalPages); + }, + + adminUsersPageStart() { + if (this.adminUsersFilteredCount <= 0) return 0; + const size = Math.max(1, Number(this.adminUsersPageSize) || 20); + return (this.adminUsersCurrentPage - 1) * size + 1; + }, + + adminUsersPageEnd() { + if (this.adminUsersFilteredCount <= 0) return 0; + const size = Math.max(1, Number(this.adminUsersPageSize) || 20); + return Math.min(this.adminUsersCurrentPage * size, this.adminUsersFilteredCount); + }, + + adminUsersPaged() { + const size = Math.max(1, Number(this.adminUsersPageSize) || 20); + const start = (this.adminUsersCurrentPage - 1) * size; + return this.adminUsersFiltered.slice(start, start + size); + }, + + adminUserStats() { + const stats = { + active: 0, + banned: 0, + unverified: 0, + download_blocked: 0 + }; + for (const u of this.adminUsersFiltered) { + const tag = this.getAdminUserStatusTag(u); + if (Object.prototype.hasOwnProperty.call(stats, tag)) { + stats[tag] += 1; + } + } + return stats; } }, @@ -2712,15 +2848,22 @@ handleDragLeave(e) { // ===== 管理员功能 ===== async loadUsers() { + this.adminUsersLoading = true; try { const response = await axios.get(`${this.apiBase}/api/admin/users`); if (response.data.success) { - this.adminUsers = response.data.users; + this.adminUsers = Array.isArray(response.data.users) ? response.data.users : []; + const maxPage = Math.max(1, Math.ceil(this.adminUsers.length / Math.max(1, Number(this.adminUsersPageSize) || 20))); + if (this.adminUsersPage > maxPage) { + this.adminUsersPage = maxPage; + } } } catch (error) { console.error('加载用户列表失败:', error); this.showToast('error', '加载失败', error.response?.data?.message || error.message); + } finally { + this.adminUsersLoading = false; } }, @@ -3373,6 +3516,68 @@ handleDragLeave(e) { // ===== 工具函数 ===== + setAdminUsersPage(page) { + const nextPage = Number(page) || 1; + if (nextPage < 1) { + this.adminUsersPage = 1; + return; + } + const maxPage = this.adminUsersTotalPages; + this.adminUsersPage = Math.min(nextPage, maxPage); + }, + + resetAdminUserFilters() { + this.adminUserFilters = { + keyword: '', + role: 'all', + status: 'all', + storage: 'all', + sort: 'created_desc' + }; + this.adminUsersPageSize = 20; + this.adminUsersPage = 1; + }, + + getAdminUserStatusTag(user) { + if (user?.is_banned) return 'banned'; + if (!user?.is_verified) return 'unverified'; + const quota = Number(user?.download_traffic_quota); + const used = Number(user?.download_traffic_used || 0); + if (Number.isFinite(quota) && quota >= 0 && (quota === 0 || used >= quota)) { + return 'download_blocked'; + } + return 'active'; + }, + + getAdminUserStatusLabel(user) { + const tag = this.getAdminUserStatusTag(user); + if (tag === 'banned') return '已封禁'; + if (tag === 'unverified') return '未激活'; + if (tag === 'download_blocked') return '下载受限'; + return '正常'; + }, + + escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + }, + + escapeRegExp(value) { + return String(value ?? '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, + + getHighlightedText(value, keyword) { + const text = this.escapeHtml(value || '-'); + const search = String(keyword || '').trim(); + if (!search) return text; + const reg = new RegExp(this.escapeRegExp(search), 'ig'); + return text.replace(reg, (match) => `${match}`); + }, + formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024; @@ -3920,6 +4125,9 @@ handleDragLeave(e) { adminTab(newTab) { if (this.isLoggedIn && this.user?.is_admin) { localStorage.setItem('adminTab', newTab); + if (newTab === 'users') { + this.loadUsers(); + } } } }