feat(admin): 管理员密码管理功能,支持搜索用户名/邮箱重置密码
This commit is contained in:
parent
4004bceb0c
commit
3abbf30422
@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@ -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/存储/上传等数据的仪表盘快照。
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user