Files
ctms/frontend/src/views/admin/UserResetPassword.vue
T
Cheng Zhou d5279b124f
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
release(main): 同步 dev 最新候选改动
2026-07-16 17:15:50 +08:00

361 lines
9.3 KiB
Vue

<template>
<el-dialog
append-to="body"
:title="TEXT.modules.adminUsers.resetTitle"
width="540px"
v-model="visibleProxy"
:close-on-click-modal="false"
class="reset-password-dialog"
>
<!-- 警告提示 -->
<div class="reset-warning">
<div class="warning-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<div class="warning-body">
<span class="warning-title">{{ TEXT.modules.adminUsers.resetWarningTitle }}</span>
<span class="warning-desc">{{ TEXT.modules.adminUsers.resetWarningDesc }}</span>
</div>
</div>
<!-- 目标用户 -->
<div class="target-user">
<div class="target-avatar">
{{ getInitials(user?.full_name || user?.username || '') }}
</div>
<div class="target-info">
<span class="target-name">{{ user?.full_name || user?.username }}</span>
<span class="target-email">{{ user?.email }}</span>
</div>
</div>
<div class="self-service-panel">
<div class="self-service-copy">
<span class="self-service-title">{{ TEXT.modules.adminUsers.selfServiceResetTitle }}</span>
<span class="self-service-desc">{{ TEXT.modules.adminUsers.selfServiceResetDesc }}</span>
</div>
<el-button class="reset-code-send-btn" type="primary" :loading="sendingCode" @click="sendResetCode">
{{ TEXT.modules.adminUsers.sendResetCode }}
</el-button>
</div>
<div class="emergency-divider">
<span>{{ TEXT.modules.adminUsers.emergencyResetDivider }}</span>
</div>
<el-form ref="formRef" :model="form" label-width="100px" :rules="rules" class="reset-form">
<el-form-item :label="TEXT.modules.adminUsers.confirmUsername" prop="confirmUsername">
<el-input v-model="form.confirmUsername" autocomplete="off" name="ctms-reset-confirm-username" :placeholder="TEXT.modules.adminUsers.confirmUsernameHint" />
</el-form-item>
<el-form-item :label="TEXT.modules.adminUsers.emergencyPassword" prop="manualPassword">
<el-input
v-model="form.manualPassword"
type="password"
show-password
autocomplete="new-password"
name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.emergencyPasswordHint"
/>
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
{{ TEXT.modules.adminUsers.resetConfirm }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { sendPasswordResetLink } from "../../api/auth";
import { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
import { TEXT, requiredMessage } from "../../locales";
const props = defineProps<{
visible: boolean;
user: UserInfo | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "reset"): void;
}>();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const sendingCode = ref(false);
const form = reactive({
confirmUsername: "",
manualPassword: "",
});
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return name.slice(0, 2).toUpperCase();
};
const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.confirmUsername), trigger: "blur" }],
manualPassword: [
{
validator: (_rule, _value, callback) => {
if (!form.manualPassword) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRequired));
return;
}
if (!/^(?=.*[A-Za-z])(?=.*\d).{8,}$/.test(form.manualPassword)) {
callback(new Error(TEXT.modules.adminUsers.manualPasswordRule));
return;
}
callback();
},
trigger: "blur",
},
],
});
const clearPasswordValidation = () => {
formRef.value?.clearValidate(["manualPassword"]);
};
watch(
() => props.visible,
(val) => {
if (val) {
form.confirmUsername = "";
form.manualPassword = "";
clearPasswordValidation();
}
}
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username && !!form.manualPassword);
const sendResetCode = async () => {
if (!props.user?.email) return;
sendingCode.value = true;
try {
await sendPasswordResetLink(props.user.email);
ElMessage.success(TEXT.modules.adminUsers.sendResetCodeSuccess);
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || e?.response?.data?.message || TEXT.modules.adminUsers.sendResetCodeFailed);
} finally {
sendingCode.value = false;
}
};
const onSubmit = async () => {
if (!props.user) return;
await formRef.value?.validate();
if (form.confirmUsername !== props.user.username) {
ElMessage.error(TEXT.modules.adminUsers.confirmMismatch);
return;
}
const ok = await ElMessageBox.confirm(
TEXT.modules.adminUsers.emergencyResetConfirmPrompt,
TEXT.modules.adminUsers.resetConfirmTitle,
{
type: "warning",
confirmButtonText: TEXT.modules.adminUsers.resetConfirm,
cancelButtonText: TEXT.common.actions.cancel,
}
).catch(() => null);
if (!ok) return;
submitting.value = true;
try {
await updateUser(props.user.id, { password: form.manualPassword });
ElMessage.success(TEXT.modules.adminUsers.resetSuccess);
emit("reset");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.modules.adminUsers.resetFailed);
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.reset-warning {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: #fef3e2;
border: 1px solid #fde68a;
margin-bottom: 16px;
}
.warning-icon {
flex-shrink: 0;
color: var(--ctms-warning);
margin-top: 1px;
}
.warning-body {
display: flex;
flex-direction: column;
}
.warning-title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.warning-desc {
font-size: 12px;
color: #a16207;
margin-top: 2px;
}
.target-user {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
background: var(--ctms-neutral-100);
margin-bottom: 18px;
}
.target-avatar {
width: 38px;
height: 38px;
border-radius: 10px;
background: linear-gradient(135deg, var(--ctms-primary), var(--ctms-primary-active));
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.target-info {
display: flex;
flex-direction: column;
}
.target-name {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main);
}
.target-email {
font-size: 12px;
color: var(--ctms-text-secondary);
}
.self-service-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px;
border: 1px solid #bfdbfe;
border-radius: 10px;
background: #eff6ff;
margin-bottom: 16px;
}
.self-service-copy {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.self-service-title {
font-size: 13px;
font-weight: 700;
color: #1e40af;
}
.self-service-desc {
font-size: 12px;
line-height: 1.5;
color: #3b5f9a;
}
.reset-code-send-btn {
flex: 0 0 auto;
min-width: 118px;
height: 38px;
color: #fff;
background: var(--ctms-primary);
border-color: var(--ctms-primary);
}
.reset-code-send-btn:hover,
.reset-code-send-btn:focus {
color: #fff;
background: var(--ctms-primary-hover);
border-color: var(--ctms-primary-hover);
}
.reset-code-send-btn :deep(span) {
color: inherit;
}
.emergency-divider {
display: flex;
align-items: center;
gap: 12px;
color: var(--ctms-text-tertiary);
font-size: 12px;
font-weight: 600;
margin: 2px 0 16px;
}
.emergency-divider::before,
.emergency-divider::after {
content: "";
height: 1px;
flex: 1;
background: var(--ctms-border-color);
}
.reset-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.password-hint {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>