移除用户系统级角色,权限完全迁移至项目级管理

- 用户注册/创建不再需要选择角色,账号管理只管资料和状态
- 移除前后端所有系统级 role 字段的暴露,改用 is_admin 标识管理员
- PM(项目负责人)角色自动拥有全部项目权限且不可修改
- 系统管理员在项目中的成员身份不可被删除
- 管理界面全面美化

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Cheng Zhou
2026-05-20 15:52:17 +08:00
parent 6cefa620e4
commit 5b97ea32ab
35 changed files with 1937 additions and 481 deletions
+198 -27
View File
@@ -1,22 +1,59 @@
<template>
<el-dialog append-to=".layout-main .content-wrapper" :title="TEXT.modules.adminUsers.resetTitle" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-alert
type="warning"
show-icon
:title="TEXT.modules.adminUsers.resetWarningTitle"
:description="TEXT.modules.adminUsers.resetWarningDesc"
class="mb-12"
/>
<div class="info">{{ TEXT.modules.adminUsers.resetTarget }}<strong>{{ user?.username }}</strong></div>
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
<el-dialog
append-to=".layout-main .content-wrapper"
: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>
<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.passwordMode" prop="mode">
<el-radio-group v-model="form.mode">
<el-radio label="auto">{{ TEXT.modules.adminUsers.passwordAuto }}</el-radio>
<el-radio label="manual">{{ TEXT.modules.adminUsers.passwordManual }}</el-radio>
</el-radio-group>
<div class="mode-cards">
<div class="mode-card" :class="{ active: form.mode === 'auto' }" @click="form.mode = 'auto'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 9.9-1"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordAuto }}</span>
</div>
<div class="mode-card" :class="{ active: form.mode === 'manual' }" @click="form.mode = 'manual'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="18" height="18">
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
<span>{{ TEXT.modules.adminUsers.passwordManual }}</span>
</div>
</div>
</el-form-item>
<el-form-item
:label="form.mode === 'auto' ? TEXT.modules.adminUsers.tempPassword : TEXT.modules.adminUsers.newPassword"
@@ -32,7 +69,13 @@
readonly
>
<template #append>
<el-button @click="regenerate">{{ TEXT.modules.adminUsers.regenerate }}</el-button>
<el-button @click="regenerate">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14" style="margin-right:4px">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
{{ TEXT.modules.adminUsers.regenerate }}
</el-button>
</template>
</el-input>
<el-input
@@ -44,12 +87,21 @@
name="ctms-reset-manual-password"
:placeholder="TEXT.modules.adminUsers.manualPasswordHint"
/>
<div class="hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
<div class="password-hint">{{ TEXT.modules.adminUsers.passwordHint }}</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">{{ TEXT.modules.adminUsers.resetConfirm }}</el-button>
<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>
@@ -85,6 +137,13 @@ const form = reactive({
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" }],
mode: [{ required: true, message: requiredMessage(TEXT.modules.adminUsers.passwordMode), trigger: "change" }],
@@ -148,9 +207,7 @@ watch(
watch(
() => form.mode,
() => {
clearPasswordValidation();
}
() => { clearPasswordValidation(); }
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
@@ -188,15 +245,129 @@ const onSubmit = async () => {
</script>
<style scoped>
.mb-12 {
margin-bottom: 12px;
.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;
}
.info {
margin-bottom: 8px;
.warning-icon {
flex-shrink: 0;
color: var(--ctms-warning);
margin-top: 1px;
}
.hint {
.warning-body {
display: flex;
flex-direction: column;
}
.warning-title {
font-size: 13px;
font-weight: 600;
color: #92400e;
}
.warning-desc {
font-size: 12px;
color: #999;
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);
}
.reset-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.mode-cards {
display: flex;
gap: 10px;
width: 100%;
}
.mode-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px;
border-radius: 10px;
border: 2px solid var(--ctms-border-color);
cursor: pointer;
transition: var(--ctms-transition);
color: var(--ctms-text-secondary);
}
.mode-card:hover {
border-color: var(--ctms-border-color-hover);
background: var(--ctms-neutral-100);
}
.mode-card.active {
border-color: var(--ctms-primary);
background: var(--ctms-primary-light);
color: var(--ctms-primary);
}
.mode-card span {
font-size: 12px;
font-weight: 600;
}
.password-hint {
font-size: 12px;
color: var(--ctms-text-secondary);
margin-top: 4px;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
</style>