feat(admin): 管理员密码管理功能,支持搜索用户名/邮箱重置密码

This commit is contained in:
JOJO 2026-06-10 17:41:21 +08:00
parent 4004bceb0c
commit 3abbf30422
3 changed files with 432 additions and 2 deletions

View File

@ -543,6 +543,19 @@ class UserManager:
self._save_users()
return record
def reset_password(self, username: str, new_password: str) -> UserRecord:
"""重置用户密码。管理员操作。"""
key = (username or "").strip().lower()
password = (new_password or "").strip()
if not password or len(password) < 8:
raise ValueError("密码长度至少 8 位。")
record = self._users.get(key)
if not record:
raise ValueError("用户不存在。")
record.password_hash = generate_password_hash(password)
self._save_users()
return record
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------

View File

@ -294,6 +294,33 @@ def admin_invites_api():
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/users/<username>/reset-password', methods=['POST'])
@api_login_required
@admin_api_required
def admin_reset_user_password(username: str):
"""管理员重置用户密码。"""
guard = _secondary_required()
if guard:
return guard
if not username:
return jsonify({"success": False, "error": "缺少用户名"}), 400
payload = request.get_json(silent=True) or {}
new_password = (payload.get("password") or "").strip()
if not new_password:
return jsonify({"success": False, "error": "新密码不能为空"}), 400
try:
record = user_manager.reset_password(username, new_password)
return jsonify({
"success": True,
"data": {"username": record.username, "message": "密码已重置"}
})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
logging.exception("reset-password API error")
return jsonify({"success": False, "error": str(exc)}), 500
def build_admin_dashboard_snapshot():
"""
复用对话模块的完整统计逻辑返回带用量/Token/存储/上传等数据的仪表盘快照

View File

@ -380,6 +380,86 @@
</div>
</div>
</section>
<section v-else-if="activeSection === 'passwords'" key="passwords" class="panel">
<h2>密码管理</h2>
<p class="muted">管理员可重置任意用户的登录密码包括自己</p>
<div class="password-form">
<div class="password-field">
<label>选择用户</label>
<div class="user-search-wrapper" ref="userSearchRef">
<div
class="user-search-trigger"
:class="{ open: passwordDropdownOpen, filled: passwordTargetUser }"
@click="togglePasswordDropdown"
>
<span v-if="passwordTargetUser" class="user-search-selected">
<strong>{{ passwordTargetUser }}</strong>
<span class="user-search-email">{{ selectedUserEmail }}</span>
</span>
<span v-else class="user-search-placeholder">搜索用户名或邮箱...</span>
<span class="user-search-arrow"></span>
</div>
<div v-if="passwordDropdownOpen" class="user-search-dropdown">
<input
ref="passwordSearchInputRef"
class="user-search-input"
type="text"
v-model="passwordSearchQuery"
placeholder="输入关键词筛选..."
@keydown.escape="passwordDropdownOpen = false"
/>
<div class="user-search-list">
<div
v-for="user in filteredUsers"
:key="user.username"
class="user-search-item"
:class="{ active: passwordTargetUser === user.username }"
@click="selectPasswordUser(user)"
>
<span class="user-search-name">{{ user.username }}</span>
<span class="user-search-email">{{ user.email || '—' }}</span>
<span v-if="user.role === 'admin'" class="user-search-role">管理员</span>
</div>
<div v-if="!filteredUsers.length" class="user-search-empty">
无匹配用户
</div>
</div>
</div>
</div>
</div>
<div class="password-field">
<label>新密码至少 8 </label>
<input
class="password-input"
type="password"
v-model="passwordNewValue"
:disabled="passwordSubmitting"
placeholder="输入新密码"
@keyup.enter="handleResetPassword"
/>
</div>
<div class="password-actions">
<button
type="button"
:disabled="!passwordTargetUser || !passwordNewValue || passwordSubmitting"
@click="handleResetPassword"
>
{{ passwordSubmitting ? '重置中...' : '重置密码' }}
</button>
<button
type="button"
class="ghost-btn"
:disabled="passwordSubmitting"
@click="passwordTargetUser = ''; passwordNewValue = ''; passwordResult = ''; passwordError = ''"
>
清空
</button>
</div>
<p v-if="passwordResult" class="password-success">{{ passwordResult }}</p>
<p v-if="passwordError" class="password-error-msg">{{ passwordError }}</p>
</div>
</section>
</transition>
</main>
</div>
@ -398,7 +478,7 @@ import { useSecondaryPass } from './useSecondaryPass';
type Snapshot = Record<string, any> | null;
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites';
type SectionId = 'overview' | 'usage' | 'users' | 'containers' | 'uploads' | 'balance' | 'invites' | 'passwords';
const {
verified: secondaryVerified,
@ -426,6 +506,15 @@ const inviteError = ref<string | null>(null);
const newInviteCode = ref('');
const newInviteRemaining = ref('1');
const newInviteUnlimited = ref(false);
const passwordTargetUser = ref('');
const passwordNewValue = ref('');
const passwordSubmitting = ref(false);
const passwordResult = ref('');
const passwordError = ref('');
const passwordSearchQuery = ref('');
const passwordDropdownOpen = ref(false);
const userSearchRef = ref<HTMLElement | null>(null);
const passwordSearchInputRef = ref<HTMLInputElement | null>(null);
const sectionTabs: Array<{ id: SectionId; label: string }> = [
{ id: 'overview', label: '系统总览' },
@ -434,7 +523,8 @@ const sectionTabs: Array<{ id: SectionId; label: string }> = [
{ id: 'containers', label: '容器' },
{ id: 'uploads', label: '上传审计' },
{ id: 'balance', label: '余额查询' },
{ id: 'invites', label: '邀请码' }
{ id: 'invites', label: '邀请码' },
{ id: 'passwords', label: '密码管理' }
];
const isInitialLoading = computed(() => loading.value && !snapshot.value);
@ -566,6 +656,80 @@ const createInvite = async () => {
}
};
const handleResetPassword = async () => {
const username = passwordTargetUser.value.trim();
const password = passwordNewValue.value.trim();
if (!username || !password) return;
if (password.length < 8) {
passwordError.value = '密码长度至少 8 位';
passwordResult.value = '';
return;
}
passwordSubmitting.value = true;
passwordResult.value = '';
passwordError.value = '';
try {
const csrfToken = await fetchCsrfToken();
const resp = await fetch(`/api/admin/users/${encodeURIComponent(username)}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ password })
});
const payload = await resp.json();
if (!resp.ok || !payload.success) {
throw new Error(payload.error || `请求失败:${resp.status}`);
}
passwordResult.value = `用户 ${username} 的密码已成功重置`;
passwordNewValue.value = '';
} catch (error) {
passwordError.value = error instanceof Error ? error.message : String(error);
} finally {
passwordSubmitting.value = false;
}
};
const filteredUsers = computed(() => {
const query = passwordSearchQuery.value.trim().toLowerCase();
if (!query) return users.value;
return users.value.filter(
(u: any) =>
u.username.toLowerCase().includes(query) ||
(u.email || '').toLowerCase().includes(query)
);
});
const selectedUserEmail = computed(() => {
const user = users.value.find((u: any) => u.username === passwordTargetUser.value);
return user?.email || '';
});
const togglePasswordDropdown = () => {
passwordDropdownOpen.value = !passwordDropdownOpen.value;
if (passwordDropdownOpen.value) {
passwordSearchQuery.value = '';
//
setTimeout(() => {
passwordSearchInputRef.value?.focus();
}, 50);
}
};
const selectPasswordUser = (user: any) => {
passwordTargetUser.value = user.username;
passwordSearchQuery.value = '';
passwordDropdownOpen.value = false;
};
const handleClickOutside = (event: MouseEvent) => {
if (userSearchRef.value && !userSearchRef.value.contains(event.target as Node)) {
passwordDropdownOpen.value = false;
}
};
const editInvite = async (item: any) => {
const current = item?.remaining;
const seed = current === null || typeof current === 'undefined' ? 'unlimited' : String(current);
@ -665,6 +829,7 @@ watch(
);
onMounted(async () => {
document.addEventListener('click', handleClickOutside);
await checkSecondary();
if (secondaryVerified.value) {
fetchDashboard(false);
@ -686,6 +851,7 @@ watch(secondaryVerified, (val) => {
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
if (timer) {
clearInterval(timer);
}
@ -1566,4 +1732,228 @@ th {
text-align: center;
}
}
.password-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 480px;
margin-top: 12px;
}
.password-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.password-field label {
font-size: 13px;
color: var(--claude-text-secondary);
font-weight: 600;
}
.password-select,
.password-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
font-size: 14px;
background: rgba(255, 255, 255, 0.92);
}
.password-input {
width: 100%;
box-sizing: border-box;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
font-size: 14px;
background: rgba(255, 255, 255, 0.92);
}
/* 自定义用户搜索下拉 */
.user-search-wrapper {
position: relative;
}
.user-search-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(44, 32, 19, 0.2);
background: rgba(255, 255, 255, 0.92);
cursor: pointer;
min-height: 44px;
box-sizing: border-box;
transition: border-color 0.15s ease;
}
.user-search-trigger:hover {
border-color: rgba(44, 32, 19, 0.4);
}
.user-search-trigger.open {
border-color: rgba(189, 93, 58, 0.5);
border-radius: 12px 12px 0 0;
}
.user-search-trigger.filled {
border-color: rgba(44, 32, 19, 0.35);
}
.user-search-selected {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.user-search-selected strong {
font-size: 14px;
flex-shrink: 0;
}
.user-search-selected .user-search-email {
font-size: 12px;
color: var(--claude-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-search-placeholder {
color: #9a8b7a;
font-size: 14px;
}
.user-search-arrow {
font-size: 12px;
color: var(--claude-text-secondary);
transition: transform 0.15s ease;
flex-shrink: 0;
}
.user-search-trigger.open .user-search-arrow {
transform: rotate(180deg);
}
.user-search-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid rgba(189, 93, 58, 0.5);
border-top: none;
border-radius: 0 0 12px 12px;
z-index: 100;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.user-search-input {
width: 100%;
box-sizing: border-box;
padding: 10px 14px;
border: none;
border-bottom: 1px solid rgba(118, 103, 84, 0.15);
font-size: 13px;
outline: none;
background: rgba(255, 255, 255, 0.95);
}
.user-search-input::placeholder {
color: #b0a392;
}
.user-search-list {
max-height: 220px;
overflow-y: auto;
}
.user-search-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
cursor: pointer;
transition: background 0.1s ease;
}
.user-search-item:hover {
background: rgba(218, 119, 86, 0.08);
}
.user-search-item.active {
background: rgba(218, 119, 86, 0.14);
}
.user-search-name {
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.user-search-item .user-search-email {
font-size: 12px;
color: var(--claude-text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-search-role {
font-size: 11px;
padding: 2px 6px;
border-radius: 6px;
background: rgba(189, 93, 58, 0.15);
color: #7c3418;
flex-shrink: 0;
margin-left: auto;
}
.user-search-empty {
padding: 16px;
text-align: center;
color: var(--claude-text-secondary);
font-size: 13px;
}
.password-actions {
display: flex;
gap: 10px;
align-items: center;
}
.password-actions .ghost-btn {
background: transparent;
border: 1px dashed rgba(44, 32, 19, 0.35);
padding: 10px 14px;
border-radius: 12px;
cursor: pointer;
box-shadow: none;
color: var(--claude-text-secondary);
}
.password-actions .ghost-btn:hover:not(:disabled) {
background: rgba(0, 0, 0, 0.04);
transform: none;
}
.password-success {
color: var(--claude-success);
margin: 0;
font-weight: 600;
}
.password-error-msg {
color: #b5473d;
margin: 0;
}
</style>