feat: add server-side admin user pagination and align traffic report accounting
This commit is contained in:
@@ -886,6 +886,199 @@ const UserDB = {
|
|||||||
return db.prepare(query).all(...params);
|
return db.prepare(query).all(...params);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
queryAdminUsers(options = {}) {
|
||||||
|
const parsedPage = Number.parseInt(options.page, 10);
|
||||||
|
const parsedPageSize = Number.parseInt(options.pageSize, 10);
|
||||||
|
const safePage = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
|
||||||
|
const safePageSize = Number.isFinite(parsedPageSize)
|
||||||
|
? Math.min(200, Math.max(1, parsedPageSize))
|
||||||
|
: 20;
|
||||||
|
|
||||||
|
const keyword = typeof options.keyword === 'string' ? options.keyword.trim() : '';
|
||||||
|
const role = ['all', 'admin', 'user'].includes(options.role) ? options.role : 'all';
|
||||||
|
const status = ['all', 'active', 'banned', 'unverified', 'download_blocked'].includes(options.status)
|
||||||
|
? options.status
|
||||||
|
: 'all';
|
||||||
|
const storage = ['all', 'local', 'oss', 'local_only', 'oss_only', 'user_choice'].includes(options.storage)
|
||||||
|
? options.storage
|
||||||
|
: 'all';
|
||||||
|
const sort = [
|
||||||
|
'created_desc',
|
||||||
|
'created_asc',
|
||||||
|
'username_asc',
|
||||||
|
'username_desc',
|
||||||
|
'storage_usage_desc',
|
||||||
|
'download_usage_desc'
|
||||||
|
].includes(options.sort)
|
||||||
|
? options.sort
|
||||||
|
: 'created_desc';
|
||||||
|
|
||||||
|
const whereClauses = ['1=1'];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (keyword) {
|
||||||
|
const likeKeyword = `%${keyword}%`;
|
||||||
|
whereClauses.push('(CAST(id AS TEXT) LIKE ? OR username LIKE ? OR email LIKE ?)');
|
||||||
|
params.push(likeKeyword, likeKeyword, likeKeyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
whereClauses.push('is_admin = 1');
|
||||||
|
} else if (role === 'user') {
|
||||||
|
whereClauses.push('is_admin = 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'banned') {
|
||||||
|
whereClauses.push('is_banned = 1');
|
||||||
|
} else if (status === 'unverified') {
|
||||||
|
whereClauses.push('is_banned = 0 AND is_verified = 0');
|
||||||
|
} else if (status === 'download_blocked') {
|
||||||
|
whereClauses.push(`
|
||||||
|
is_banned = 0
|
||||||
|
AND is_verified = 1
|
||||||
|
AND COALESCE(download_traffic_quota, 0) >= 0
|
||||||
|
AND (
|
||||||
|
COALESCE(download_traffic_quota, 0) = 0
|
||||||
|
OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} else if (status === 'active') {
|
||||||
|
whereClauses.push(`
|
||||||
|
is_banned = 0
|
||||||
|
AND is_verified = 1
|
||||||
|
AND (
|
||||||
|
COALESCE(download_traffic_quota, -1) < 0
|
||||||
|
OR (
|
||||||
|
COALESCE(download_traffic_quota, 0) > 0
|
||||||
|
AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storage === 'local' || storage === 'oss') {
|
||||||
|
whereClauses.push("COALESCE(current_storage_type, 'oss') = ?");
|
||||||
|
params.push(storage);
|
||||||
|
} else if (storage !== 'all') {
|
||||||
|
whereClauses.push("COALESCE(storage_permission, 'oss_only') = ?");
|
||||||
|
params.push(storage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOssQuota = 1024 * 1024 * 1024; // 1GB
|
||||||
|
const storageUsageExpr = `
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(current_storage_type, 'oss') = 'local' THEN
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(local_storage_quota, 0) > 0
|
||||||
|
THEN (COALESCE(local_storage_used, 0) * 100.0 / COALESCE(local_storage_quota, 1))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(oss_storage_quota, 0) > 0
|
||||||
|
THEN (COALESCE(storage_used, 0) * 100.0 / COALESCE(oss_storage_quota, ${defaultOssQuota}))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
const downloadUsageExpr = `
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(download_traffic_quota, 0) < 0 THEN 0
|
||||||
|
WHEN COALESCE(download_traffic_quota, 0) = 0 THEN 100
|
||||||
|
ELSE MIN(
|
||||||
|
100,
|
||||||
|
COALESCE(download_traffic_used, 0) * 100.0 / MAX(COALESCE(download_traffic_quota, 0), 1)
|
||||||
|
)
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
const orderByMap = {
|
||||||
|
created_desc: 'datetime(created_at) DESC, id DESC',
|
||||||
|
created_asc: 'datetime(created_at) ASC, id ASC',
|
||||||
|
username_asc: "LOWER(COALESCE(username, '')) ASC, id ASC",
|
||||||
|
username_desc: "LOWER(COALESCE(username, '')) DESC, id DESC",
|
||||||
|
storage_usage_desc: `${storageUsageExpr} DESC, id DESC`,
|
||||||
|
download_usage_desc: `${downloadUsageExpr} DESC, id DESC`
|
||||||
|
};
|
||||||
|
const orderBySql = orderByMap[sort] || orderByMap.created_desc;
|
||||||
|
const whereSql = whereClauses.join(' AND ');
|
||||||
|
|
||||||
|
const globalTotalRow = db.prepare('SELECT COUNT(*) AS total FROM users').get();
|
||||||
|
const globalTotal = Number(globalTotalRow?.total || 0);
|
||||||
|
|
||||||
|
const totalRow = db.prepare(`
|
||||||
|
SELECT COUNT(*) AS total
|
||||||
|
FROM users
|
||||||
|
WHERE ${whereSql}
|
||||||
|
`).get(...params);
|
||||||
|
const filteredTotal = Number(totalRow?.total || 0);
|
||||||
|
|
||||||
|
const summaryRow = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS filtered_total,
|
||||||
|
SUM(CASE WHEN is_banned = 1 THEN 1 ELSE 0 END) AS banned,
|
||||||
|
SUM(CASE WHEN is_banned = 0 AND is_verified = 0 THEN 1 ELSE 0 END) AS unverified,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN is_banned = 0
|
||||||
|
AND is_verified = 1
|
||||||
|
AND COALESCE(download_traffic_quota, 0) >= 0
|
||||||
|
AND (
|
||||||
|
COALESCE(download_traffic_quota, 0) = 0
|
||||||
|
OR COALESCE(download_traffic_used, 0) >= COALESCE(download_traffic_quota, 0)
|
||||||
|
)
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
) AS download_blocked,
|
||||||
|
SUM(
|
||||||
|
CASE
|
||||||
|
WHEN is_banned = 0
|
||||||
|
AND is_verified = 1
|
||||||
|
AND (
|
||||||
|
COALESCE(download_traffic_quota, -1) < 0
|
||||||
|
OR (
|
||||||
|
COALESCE(download_traffic_quota, 0) > 0
|
||||||
|
AND COALESCE(download_traffic_used, 0) < COALESCE(download_traffic_quota, 0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
THEN 1 ELSE 0
|
||||||
|
END
|
||||||
|
) AS active
|
||||||
|
FROM users
|
||||||
|
WHERE ${whereSql}
|
||||||
|
`).get(...params);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredTotal / safePageSize));
|
||||||
|
const page = Math.min(Math.max(1, safePage), totalPages);
|
||||||
|
const offset = (page - 1) * safePageSize;
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM users
|
||||||
|
WHERE ${whereSql}
|
||||||
|
ORDER BY ${orderBySql}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(...params, safePageSize, offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
pageSize: safePageSize,
|
||||||
|
total: filteredTotal,
|
||||||
|
totalPages
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
global_total: Number.isFinite(globalTotal) ? globalTotal : 0,
|
||||||
|
filtered_total: Number(summaryRow?.filtered_total || 0),
|
||||||
|
active: Number(summaryRow?.active || 0),
|
||||||
|
banned: Number(summaryRow?.banned || 0),
|
||||||
|
unverified: Number(summaryRow?.unverified || 0),
|
||||||
|
download_blocked: Number(summaryRow?.download_blocked || 0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// 删除用户
|
// 删除用户
|
||||||
delete(id) {
|
delete(id) {
|
||||||
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
return db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
|||||||
@@ -1013,13 +1013,16 @@ const applyConfirmedDownloadTrafficFromLogTransaction = db.transaction((userId,
|
|||||||
: Math.min(trafficState.quota, trafficState.used + bytes);
|
: Math.min(trafficState.quota, trafficState.used + bytes);
|
||||||
|
|
||||||
UserDB.update(userId, { download_traffic_used: nextUsed });
|
UserDB.update(userId, { download_traffic_used: nextUsed });
|
||||||
DownloadTrafficReportDB.addUsage(userId, bytes, count > 0 ? count : 1, eventDate);
|
const addedBytes = Math.max(0, nextUsed - trafficState.used);
|
||||||
|
if (addedBytes > 0) {
|
||||||
|
DownloadTrafficReportDB.addUsage(userId, addedBytes, count > 0 ? count : 1, eventDate);
|
||||||
|
}
|
||||||
const consumeResult = DownloadTrafficReservationDB.consumePendingBytes(userId, bytes);
|
const consumeResult = DownloadTrafficReservationDB.consumePendingBytes(userId, bytes);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
confirmed: bytes,
|
confirmed: bytes,
|
||||||
added: Math.max(0, nextUsed - trafficState.used),
|
added: addedBytes,
|
||||||
usedBefore: trafficState.used,
|
usedBefore: trafficState.used,
|
||||||
usedAfter: nextUsed,
|
usedAfter: nextUsed,
|
||||||
consumedReserved: Number(consumeResult?.consumed || 0),
|
consumedReserved: Number(consumeResult?.consumed || 0),
|
||||||
@@ -7054,39 +7057,59 @@ app.get('/api/admin/storage-stats', authMiddleware, adminMiddleware, async (req,
|
|||||||
// 获取所有用户
|
// 获取所有用户
|
||||||
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
app.get('/api/admin/users', authMiddleware, adminMiddleware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const users = UserDB.getAll().map(user => {
|
const toAdminUserPayload = (u) => ({
|
||||||
|
id: u.id,
|
||||||
|
username: u.username,
|
||||||
|
email: u.email,
|
||||||
|
is_admin: u.is_admin,
|
||||||
|
is_active: u.is_active,
|
||||||
|
is_verified: u.is_verified,
|
||||||
|
is_banned: u.is_banned,
|
||||||
|
has_oss_config: u.has_oss_config,
|
||||||
|
created_at: u.created_at,
|
||||||
|
storage_permission: u.storage_permission || 'oss_only',
|
||||||
|
current_storage_type: u.current_storage_type || 'oss',
|
||||||
|
local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
|
||||||
|
local_storage_used: u.local_storage_used || 0,
|
||||||
|
oss_storage_quota: normalizeOssQuota(u.oss_storage_quota),
|
||||||
|
storage_used: u.storage_used || 0,
|
||||||
|
download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota),
|
||||||
|
download_traffic_used: normalizeDownloadTrafficUsed(
|
||||||
|
u.download_traffic_used,
|
||||||
|
normalizeDownloadTrafficQuota(u.download_traffic_quota)
|
||||||
|
),
|
||||||
|
download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null,
|
||||||
|
download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none',
|
||||||
|
download_traffic_last_reset_at: u.download_traffic_last_reset_at || null
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPagedQuery = req.query?.paged === '1'
|
||||||
|
|| ['page', 'pageSize', 'keyword', 'role', 'status', 'storage', 'sort']
|
||||||
|
.some((key) => req.query && req.query[key] !== undefined);
|
||||||
|
|
||||||
|
if (hasPagedQuery) {
|
||||||
|
const queryResult = UserDB.queryAdminUsers(req.query || {});
|
||||||
|
const users = queryResult.rows.map((user) => {
|
||||||
|
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list_page');
|
||||||
|
return policyState?.user || user;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
users: users.map(toAdminUserPayload),
|
||||||
|
pagination: queryResult.pagination,
|
||||||
|
summary: queryResult.summary
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = UserDB.getAll().map((user) => {
|
||||||
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list');
|
const policyState = enforceDownloadTrafficPolicy(user.id, 'admin_list');
|
||||||
return policyState?.user || user;
|
return policyState?.user || user;
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
users: users.map(u => ({
|
users: users.map(toAdminUserPayload)
|
||||||
id: u.id,
|
|
||||||
username: u.username,
|
|
||||||
email: u.email,
|
|
||||||
is_admin: u.is_admin,
|
|
||||||
is_active: u.is_active,
|
|
||||||
is_verified: u.is_verified,
|
|
||||||
is_banned: u.is_banned,
|
|
||||||
has_oss_config: u.has_oss_config,
|
|
||||||
created_at: u.created_at,
|
|
||||||
// 新增:存储相关字段
|
|
||||||
storage_permission: u.storage_permission || 'oss_only',
|
|
||||||
current_storage_type: u.current_storage_type || 'oss',
|
|
||||||
local_storage_quota: u.local_storage_quota || DEFAULT_LOCAL_STORAGE_QUOTA_BYTES,
|
|
||||||
local_storage_used: u.local_storage_used || 0,
|
|
||||||
oss_storage_quota: normalizeOssQuota(u.oss_storage_quota),
|
|
||||||
storage_used: u.storage_used || 0,
|
|
||||||
download_traffic_quota: normalizeDownloadTrafficQuota(u.download_traffic_quota),
|
|
||||||
download_traffic_used: normalizeDownloadTrafficUsed(
|
|
||||||
u.download_traffic_used,
|
|
||||||
normalizeDownloadTrafficQuota(u.download_traffic_quota)
|
|
||||||
),
|
|
||||||
download_traffic_quota_expires_at: u.download_traffic_quota_expires_at || null,
|
|
||||||
download_traffic_reset_cycle: u.download_traffic_reset_cycle || 'none',
|
|
||||||
download_traffic_last_reset_at: u.download_traffic_last_reset_at || null
|
|
||||||
}))
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取用户列表失败:', error);
|
console.error('获取用户列表失败:', error);
|
||||||
|
|||||||
@@ -2698,6 +2698,9 @@
|
|||||||
<span>按天用量明细(最近 {{ downloadTrafficReport.days }} 天)</span>
|
<span>按天用量明细(最近 {{ downloadTrafficReport.days }} 天)</span>
|
||||||
<span v-if="downloadTrafficReport.summary?.peak_day">峰值: {{ formatReportDateLabel(downloadTrafficReport.summary.peak_day.date) }} / {{ formatBytes(downloadTrafficReport.summary.peak_day.bytes_used || 0) }}</span>
|
<span v-if="downloadTrafficReport.summary?.peak_day">峰值: {{ formatReportDateLabel(downloadTrafficReport.summary.peak_day.date) }} / {{ formatBytes(downloadTrafficReport.summary.peak_day.bytes_used || 0) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="padding: 8px 12px; border-bottom: 1px solid var(--glass-border); font-size: 12px; color: var(--text-muted);">
|
||||||
|
说明:预览/在线播放同样计入下载流量;OSS 明细来自访问日志,通常有 5-15 分钟延迟。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="downloadTrafficReport.loading && downloadTrafficDailyRowsDesc.length === 0" style="padding: 16px; text-align: center; color: var(--text-muted);">
|
<div v-if="downloadTrafficReport.loading && downloadTrafficDailyRowsDesc.length === 0" style="padding: 16px; text-align: center; color: var(--text-muted);">
|
||||||
<i class="fas fa-spinner fa-spin"></i> 报表加载中...
|
<i class="fas fa-spinner fa-spin"></i> 报表加载中...
|
||||||
@@ -3522,11 +3525,11 @@
|
|||||||
<div class="admin-users-toolbar">
|
<div class="admin-users-toolbar">
|
||||||
<div class="admin-users-filter admin-users-filter-search">
|
<div class="admin-users-filter admin-users-filter-search">
|
||||||
<label>搜索</label>
|
<label>搜索</label>
|
||||||
<input type="text" v-model.trim="adminUserFilters.keyword" @input="adminUsersPage = 1" placeholder="ID / 用户名 / 邮箱">
|
<input type="text" v-model.trim="adminUserFilters.keyword" @input="triggerAdminUsersKeywordSearch" placeholder="ID / 用户名 / 邮箱">
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-users-filter">
|
<div class="admin-users-filter">
|
||||||
<label>角色</label>
|
<label>角色</label>
|
||||||
<select v-model="adminUserFilters.role" @change="adminUsersPage = 1">
|
<select v-model="adminUserFilters.role" @change="handleAdminUsersFilterChange">
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
<option value="admin">管理员</option>
|
<option value="admin">管理员</option>
|
||||||
<option value="user">普通用户</option>
|
<option value="user">普通用户</option>
|
||||||
@@ -3534,7 +3537,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-users-filter">
|
<div class="admin-users-filter">
|
||||||
<label>状态</label>
|
<label>状态</label>
|
||||||
<select v-model="adminUserFilters.status" @change="adminUsersPage = 1">
|
<select v-model="adminUserFilters.status" @change="handleAdminUsersFilterChange">
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
<option value="active">正常</option>
|
<option value="active">正常</option>
|
||||||
<option value="banned">已封禁</option>
|
<option value="banned">已封禁</option>
|
||||||
@@ -3544,7 +3547,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-users-filter">
|
<div class="admin-users-filter">
|
||||||
<label>存储</label>
|
<label>存储</label>
|
||||||
<select v-model="adminUserFilters.storage" @change="adminUsersPage = 1">
|
<select v-model="adminUserFilters.storage" @change="handleAdminUsersFilterChange">
|
||||||
<option value="all">全部</option>
|
<option value="all">全部</option>
|
||||||
<option value="local">当前本地</option>
|
<option value="local">当前本地</option>
|
||||||
<option value="oss">当前OSS</option>
|
<option value="oss">当前OSS</option>
|
||||||
@@ -3555,7 +3558,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-users-filter">
|
<div class="admin-users-filter">
|
||||||
<label>排序</label>
|
<label>排序</label>
|
||||||
<select v-model="adminUserFilters.sort" @change="adminUsersPage = 1">
|
<select v-model="adminUserFilters.sort" @change="handleAdminUsersFilterChange">
|
||||||
<option value="created_desc">注册时间(新到旧)</option>
|
<option value="created_desc">注册时间(新到旧)</option>
|
||||||
<option value="created_asc">注册时间(旧到新)</option>
|
<option value="created_asc">注册时间(旧到新)</option>
|
||||||
<option value="username_asc">用户名(A-Z)</option>
|
<option value="username_asc">用户名(A-Z)</option>
|
||||||
@@ -3566,7 +3569,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="admin-users-filter admin-users-filter-page-size">
|
<div class="admin-users-filter admin-users-filter-page-size">
|
||||||
<label>每页</label>
|
<label>每页</label>
|
||||||
<select v-model.number="adminUsersPageSize" @change="adminUsersPage = 1">
|
<select v-model.number="adminUsersPageSize" @change="handleAdminUsersPageSizeChange">
|
||||||
<option :value="20">20</option>
|
<option :value="20">20</option>
|
||||||
<option :value="50">50</option>
|
<option :value="50">50</option>
|
||||||
<option :value="100">100</option>
|
<option :value="100">100</option>
|
||||||
@@ -3580,7 +3583,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-users-stats">
|
<div class="admin-users-stats">
|
||||||
<span class="admin-users-stat-chip">总用户 {{ adminUsers.length }}</span>
|
<span class="admin-users-stat-chip">总用户 {{ adminUsersGlobalCount }}</span>
|
||||||
<span class="admin-users-stat-chip">筛选后 {{ adminUsersFilteredCount }}</span>
|
<span class="admin-users-stat-chip">筛选后 {{ adminUsersFilteredCount }}</span>
|
||||||
<span class="admin-users-stat-chip">正常 {{ adminUserStats.active }}</span>
|
<span class="admin-users-stat-chip">正常 {{ adminUserStats.active }}</span>
|
||||||
<span class="admin-users-stat-chip">封禁 {{ adminUserStats.banned }}</span>
|
<span class="admin-users-stat-chip">封禁 {{ adminUserStats.banned }}</span>
|
||||||
@@ -3611,7 +3614,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="u in adminUsersPaged" :key="u.id" style="border-bottom: 1px solid #eee;">
|
<tr v-for="u in adminUsers" :key="u.id" style="border-bottom: 1px solid #eee;">
|
||||||
<td style="padding: 10px;">{{ u.id }}</td>
|
<td style="padding: 10px;">{{ u.id }}</td>
|
||||||
<td style="padding: 10px; overflow: hidden;">
|
<td style="padding: 10px; overflow: hidden;">
|
||||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="u.username" v-html="getHighlightedText(u.username, adminUserFilters.keyword)"></div>
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" :title="u.username" v-html="getHighlightedText(u.username, adminUserFilters.keyword)"></div>
|
||||||
@@ -3703,7 +3706,7 @@
|
|||||||
|
|
||||||
<div class="admin-users-pagination" v-if="adminUsersFilteredCount > 0">
|
<div class="admin-users-pagination" v-if="adminUsersFilteredCount > 0">
|
||||||
<div class="admin-users-pagination-info">
|
<div class="admin-users-pagination-info">
|
||||||
显示 {{ adminUsersPageStart }}-{{ adminUsersPageEnd }} 条,共 {{ adminUsersFilteredCount }} 条(总 {{ adminUsers.length }} 条)
|
显示 {{ adminUsersPageStart }}-{{ adminUsersPageEnd }} 条,共 {{ adminUsersFilteredCount }} 条(总 {{ adminUsersGlobalCount }} 条)
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-users-pagination-actions">
|
<div class="admin-users-pagination-actions">
|
||||||
<button class="btn btn-secondary" @click="setAdminUsersPage(1)" :disabled="adminUsersCurrentPage <= 1">
|
<button class="btn btn-secondary" @click="setAdminUsersPage(1)" :disabled="adminUsersCurrentPage <= 1">
|
||||||
@@ -3724,7 +3727,7 @@
|
|||||||
|
|
||||||
<div v-else class="admin-users-empty-state">
|
<div v-else class="admin-users-empty-state">
|
||||||
<i class="fas fa-users-slash"></i>
|
<i class="fas fa-users-slash"></i>
|
||||||
<span v-if="adminUsers.length > 0">没有符合当前筛选条件的用户</span>
|
<span v-if="adminUsersGlobalCount > 0">没有符合当前筛选条件的用户</span>
|
||||||
<span v-else>暂无用户数据</span>
|
<span v-else>暂无用户数据</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
235
frontend/app.js
235
frontend/app.js
@@ -144,6 +144,15 @@ createApp({
|
|||||||
adminUsersLoading: false,
|
adminUsersLoading: false,
|
||||||
adminUsersPage: 1,
|
adminUsersPage: 1,
|
||||||
adminUsersPageSize: 20,
|
adminUsersPageSize: 20,
|
||||||
|
adminUsersTotalCount: 0,
|
||||||
|
adminUsersTotalPages: 1,
|
||||||
|
adminUsersGlobalCount: 0,
|
||||||
|
adminUserStats: {
|
||||||
|
active: 0,
|
||||||
|
banned: 0,
|
||||||
|
unverified: 0,
|
||||||
|
download_blocked: 0
|
||||||
|
},
|
||||||
adminUserFilters: {
|
adminUserFilters: {
|
||||||
keyword: '',
|
keyword: '',
|
||||||
role: 'all', // all/admin/user
|
role: 'all', // all/admin/user
|
||||||
@@ -571,91 +580,8 @@ createApp({
|
|||||||
return list;
|
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() {
|
adminUsersFilteredCount() {
|
||||||
return this.adminUsersFiltered.length;
|
return Math.max(0, Number(this.adminUsersTotalCount) || 0);
|
||||||
},
|
|
||||||
|
|
||||||
adminUsersTotalPages() {
|
|
||||||
const size = Math.max(1, Number(this.adminUsersPageSize) || 20);
|
|
||||||
return Math.max(1, Math.ceil(this.adminUsersFilteredCount / size));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
adminUsersCurrentPage() {
|
adminUsersCurrentPage() {
|
||||||
@@ -673,28 +599,6 @@ createApp({
|
|||||||
if (this.adminUsersFilteredCount <= 0) return 0;
|
if (this.adminUsersFilteredCount <= 0) return 0;
|
||||||
const size = Math.max(1, Number(this.adminUsersPageSize) || 20);
|
const size = Math.max(1, Number(this.adminUsersPageSize) || 20);
|
||||||
return Math.min(this.adminUsersCurrentPage * size, this.adminUsersFilteredCount);
|
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;
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2847,17 +2751,91 @@ handleDragLeave(e) {
|
|||||||
|
|
||||||
// ===== 管理员功能 =====
|
// ===== 管理员功能 =====
|
||||||
|
|
||||||
async loadUsers() {
|
calculateAdminUserStats(users = []) {
|
||||||
|
const stats = {
|
||||||
|
active: 0,
|
||||||
|
banned: 0,
|
||||||
|
unverified: 0,
|
||||||
|
download_blocked: 0
|
||||||
|
};
|
||||||
|
for (const user of users) {
|
||||||
|
const statusTag = this.getAdminUserStatusTag(user);
|
||||||
|
if (Object.prototype.hasOwnProperty.call(stats, statusTag)) {
|
||||||
|
stats[statusTag] += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stats;
|
||||||
|
},
|
||||||
|
|
||||||
|
buildAdminUsersQueryParams() {
|
||||||
|
const params = {
|
||||||
|
paged: 1,
|
||||||
|
page: Math.max(1, Number(this.adminUsersPage) || 1),
|
||||||
|
pageSize: Math.max(1, Number(this.adminUsersPageSize) || 20),
|
||||||
|
sort: this.adminUserFilters.sort || 'created_desc'
|
||||||
|
};
|
||||||
|
|
||||||
|
const keyword = String(this.adminUserFilters.keyword || '').trim();
|
||||||
|
if (keyword) params.keyword = keyword;
|
||||||
|
if (this.adminUserFilters.role && this.adminUserFilters.role !== 'all') {
|
||||||
|
params.role = this.adminUserFilters.role;
|
||||||
|
}
|
||||||
|
if (this.adminUserFilters.status && this.adminUserFilters.status !== 'all') {
|
||||||
|
params.status = this.adminUserFilters.status;
|
||||||
|
}
|
||||||
|
if (this.adminUserFilters.storage && this.adminUserFilters.storage !== 'all') {
|
||||||
|
params.storage = this.adminUserFilters.storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params;
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadUsers(options = {}) {
|
||||||
|
const resetPage = options && options.resetPage === true;
|
||||||
|
if (resetPage) {
|
||||||
|
this.adminUsersPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
this.adminUsersLoading = true;
|
this.adminUsersLoading = true;
|
||||||
try {
|
try {
|
||||||
const response = await axios.get(`${this.apiBase}/api/admin/users`);
|
const response = await axios.get(`${this.apiBase}/api/admin/users`, {
|
||||||
|
params: this.buildAdminUsersQueryParams()
|
||||||
|
});
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
this.adminUsers = Array.isArray(response.data.users) ? response.data.users : [];
|
const rows = 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)));
|
this.adminUsers = rows;
|
||||||
if (this.adminUsersPage > maxPage) {
|
|
||||||
this.adminUsersPage = maxPage;
|
const rawTotal = Number(response.data?.pagination?.total);
|
||||||
}
|
const totalCount = Number.isFinite(rawTotal) && rawTotal >= 0
|
||||||
|
? Math.floor(rawTotal)
|
||||||
|
: rows.length;
|
||||||
|
this.adminUsersTotalCount = totalCount;
|
||||||
|
|
||||||
|
const rawTotalPages = Number(response.data?.pagination?.totalPages);
|
||||||
|
this.adminUsersTotalPages = Number.isFinite(rawTotalPages) && rawTotalPages > 0
|
||||||
|
? Math.floor(rawTotalPages)
|
||||||
|
: Math.max(1, Math.ceil(totalCount / Math.max(1, Number(this.adminUsersPageSize) || 20)));
|
||||||
|
|
||||||
|
const rawPage = Number(response.data?.pagination?.page);
|
||||||
|
const nextPage = Number.isFinite(rawPage) && rawPage > 0
|
||||||
|
? Math.floor(rawPage)
|
||||||
|
: this.adminUsersPage;
|
||||||
|
this.adminUsersPage = Math.min(Math.max(1, nextPage), this.adminUsersTotalPages);
|
||||||
|
|
||||||
|
const summary = response.data?.summary || {};
|
||||||
|
const rawGlobalTotal = Number(summary.global_total);
|
||||||
|
this.adminUsersGlobalCount = Number.isFinite(rawGlobalTotal) && rawGlobalTotal >= 0
|
||||||
|
? Math.floor(rawGlobalTotal)
|
||||||
|
: this.adminUsersTotalCount;
|
||||||
|
|
||||||
|
const fallbackStats = this.calculateAdminUserStats(rows);
|
||||||
|
this.adminUserStats = {
|
||||||
|
active: Number.isFinite(Number(summary.active)) ? Math.max(0, Math.floor(Number(summary.active))) : fallbackStats.active,
|
||||||
|
banned: Number.isFinite(Number(summary.banned)) ? Math.max(0, Math.floor(Number(summary.banned))) : fallbackStats.banned,
|
||||||
|
unverified: Number.isFinite(Number(summary.unverified)) ? Math.max(0, Math.floor(Number(summary.unverified))) : fallbackStats.unverified,
|
||||||
|
download_blocked: Number.isFinite(Number(summary.download_blocked)) ? Math.max(0, Math.floor(Number(summary.download_blocked))) : fallbackStats.download_blocked
|
||||||
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载用户列表失败:', error);
|
console.error('加载用户列表失败:', error);
|
||||||
@@ -3519,11 +3497,37 @@ handleDragLeave(e) {
|
|||||||
setAdminUsersPage(page) {
|
setAdminUsersPage(page) {
|
||||||
const nextPage = Number(page) || 1;
|
const nextPage = Number(page) || 1;
|
||||||
if (nextPage < 1) {
|
if (nextPage < 1) {
|
||||||
this.adminUsersPage = 1;
|
if (this.adminUsersPage !== 1) {
|
||||||
|
this.adminUsersPage = 1;
|
||||||
|
this.loadUsers();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const maxPage = this.adminUsersTotalPages;
|
const maxPage = this.adminUsersTotalPages;
|
||||||
this.adminUsersPage = Math.min(nextPage, maxPage);
|
const targetPage = Math.min(nextPage, maxPage);
|
||||||
|
if (targetPage === this.adminUsersPage) return;
|
||||||
|
this.adminUsersPage = targetPage;
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
triggerAdminUsersKeywordSearch() {
|
||||||
|
this.adminUsersPage = 1;
|
||||||
|
if (!this._debouncedAdminUsersQuery) {
|
||||||
|
this._debouncedAdminUsersQuery = this.debounce(() => {
|
||||||
|
this.loadUsers();
|
||||||
|
}, 260);
|
||||||
|
}
|
||||||
|
this._debouncedAdminUsersQuery();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleAdminUsersFilterChange() {
|
||||||
|
this.adminUsersPage = 1;
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleAdminUsersPageSizeChange() {
|
||||||
|
this.adminUsersPage = 1;
|
||||||
|
this.loadUsers();
|
||||||
},
|
},
|
||||||
|
|
||||||
resetAdminUserFilters() {
|
resetAdminUserFilters() {
|
||||||
@@ -3536,6 +3540,7 @@ handleDragLeave(e) {
|
|||||||
};
|
};
|
||||||
this.adminUsersPageSize = 20;
|
this.adminUsersPageSize = 20;
|
||||||
this.adminUsersPage = 1;
|
this.adminUsersPage = 1;
|
||||||
|
this.loadUsers();
|
||||||
},
|
},
|
||||||
|
|
||||||
getAdminUserStatusTag(user) {
|
getAdminUserStatusTag(user) {
|
||||||
|
|||||||
Reference in New Issue
Block a user