新增用户注册功能

This commit is contained in:
Cheng Zhou
2025-12-22 21:19:48 +08:00
parent 03fddba406
commit 6ddf9901a0
33 changed files with 1389 additions and 138 deletions
+12
View File
@@ -0,0 +1,12 @@
import type { AxiosResponse } from "axios";
import { apiGet, apiPost } from "./axios";
import type { AdminUserListResponse, UserInfo, UserStatus } from "../types/api";
export const listPendingUsers = (status: UserStatus = "PENDING"): Promise<AxiosResponse<AdminUserListResponse>> =>
apiGet("/api/v1/admin/users", { params: { status } });
export const approveUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
apiPost(`/api/v1/admin/users/${userId}/approve`, { action: "approve" });
export const rejectUser = (userId: string): Promise<AxiosResponse<UserInfo>> =>
apiPost(`/api/v1/admin/users/${userId}/reject`, { action: "reject" });
+12 -2
View File
@@ -1,10 +1,20 @@
import type { AxiosResponse } from "axios";
import api, { apiGet, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse } from "../types/api";
import api, { apiGet, apiPatch, apiPost } from "./axios";
import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api";
export const login = (payload: LoginRequest): Promise<AxiosResponse<LoginResponse>> =>
apiPost<LoginResponse>("/api/v1/auth/login", payload);
export const fetchMe = (): Promise<AxiosResponse<UserMeResponse>> => apiGet<UserMeResponse>("/api/v1/auth/me");
export const register = (payload: RegisterRequest): Promise<AxiosResponse<{ message: string }>> =>
apiPost("/api/v1/auth/register", payload);
export const updateProfile = (payload: {
full_name?: string;
department?: string;
current_password?: string;
password?: string;
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
export default api;
+6
View File
@@ -27,6 +27,12 @@ instance.interceptors.response.use(
async (error: AxiosError<ApiError>) => {
const status = error.response?.status;
const data = error.response?.data;
const reqUrl = error.config?.url || "";
const isAuthEndpoint = reqUrl.includes("/api/v1/auth/login") || reqUrl.includes("/api/v1/auth/register");
if (isAuthEndpoint) {
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
// 401 统一处理:同步清理本地 token,并防止重复弹窗
const handleUnauthorized = () => {
const auth = useAuthStore();
+11 -2
View File
@@ -5,10 +5,19 @@ import { apiDelete } from "./axios";
export const fetchUsers = (params?: Record<string, any>) =>
apiGet<ApiListResponse<UserInfo>>("/api/v1/users", { params });
export const createUser = (payload: { username: string; password: string; role: string }) =>
export const createUser = (payload: {
email: string;
password: string;
full_name: string;
role: string;
department: string;
}) =>
apiPost<UserInfo>("/api/v1/users", payload);
export const updateUser = (userId: string, payload: Partial<{ role: string; is_active: boolean; password: string }>) =>
export const updateUser = (
userId: string,
payload: Partial<{ role: string; status: string; password: string; full_name: string; department: string; is_active: boolean }>
) =>
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
export const deleteUser = (userId: string, payload: { admin_password: string }) =>
+63 -10
View File
@@ -39,7 +39,7 @@
</el-menu-item>
<el-menu-item index="/study/milestones">
<el-icon><Timer /></el-icon>
<span>里程碑管理</span>
<span>伦理与启动</span>
</el-menu-item>
<el-menu-item index="/study/subjects">
<el-icon><UserFilled /></el-icon>
@@ -94,14 +94,28 @@
</div>
<div class="header-right">
<StudySelector />
<div class="user-profile">
<div class="user-avatar">{{ auth.user?.username?.charAt(0).toUpperCase() }}</div>
<div class="user-details">
<span class="username">{{ auth.user?.username }}</span>
<span class="user-role">{{ auth.user?.role }}</span>
</div>
<el-button link type="danger" @click="onLogout" class="logout-btn">退出</el-button>
</div>
<el-dropdown trigger="click" @command="onCommand" class="user-profile">
<span class="dropdown-trigger compact">
<div class="user-avatar">
<img v-if="auth.user?.avatar_url" :src="auth.user.avatar_url" alt="avatar" />
<span v-else>{{ (auth.user?.username || auth.user?.email || "用户").charAt(0).toUpperCase() }}</span>
</div>
<span class="username">{{ auth.user?.username || auth.user?.email || "用户" }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu class="user-dropdown-menu">
<el-dropdown-item command="profile">
<el-icon><User /></el-icon>
<span>个人中心</span>
</el-dropdown-item>
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
@@ -110,6 +124,7 @@
<router-view />
</div>
</el-main>
</el-container>
</el-container>
</template>
@@ -123,7 +138,7 @@ import StudySelector from "./StudySelector.vue";
import {
Monitor, User, Suitcase, List, House, Timer, UserFilled,
Warning, InfoFilled, QuestionFilled, Checked, Management,
Money, Collection, Memo
Money, Collection, Memo, ArrowDown, SwitchButton
} from "@element-plus/icons-vue";
const auth = useAuthStore();
@@ -137,6 +152,15 @@ const onLogout = () => {
study.clearCurrentStudy();
router.replace("/login");
};
const onCommand = (cmd: string) => {
if (cmd === "logout") {
onLogout();
} else if (cmd === "profile") {
// 预留个人设置路由占位,如后续有页面则跳转
router.push("/profile");
}
};
</script>
<style scoped>
@@ -254,6 +278,7 @@ const onLogout = () => {
gap: 12px;
padding-left: 16px;
border-left: 1px solid var(--ctms-border-color);
cursor: pointer;
}
.user-avatar {
@@ -267,6 +292,12 @@ const onLogout = () => {
justify-content: center;
font-weight: 600;
font-size: 14px;
overflow: hidden;
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-details {
@@ -290,6 +321,28 @@ const onLogout = () => {
margin-left: 8px;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dropdown-trigger.compact {
padding: 6px 10px;
border-radius: 10px;
background: #f4f6fa;
border: 1px solid #e2e6f0;
color: #333;
}
.avatar-icon {
font-size: 18px;
}
.user-dropdown-menu :deep(.el-dropdown-menu__item) {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
}
.study-tag {
font-weight: 500;
color: var(--ctms-primary);
+2 -2
View File
@@ -1,5 +1,5 @@
<template>
<el-dialog :title="isEdit ? '编辑里程碑' : '新增里程碑'" v-model="visible" width="520px" @close="onClose">
<el-dialog :title="isEdit ? '编辑节点' : '新增节点'" v-model="visible" width="520px" @close="onClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" />
@@ -98,7 +98,7 @@ const memberOptions = computed(() => {
return memberList
.map((m: any) => {
const user = m.user || {};
const label = user.display_name || user.username || m.username || "—";
const label = user.full_name || user.display_name || user.username || user.email || m.username || m.email || "—";
return { value: m.user_id, label };
})
.filter((opt) => {
+1 -1
View File
@@ -31,7 +31,7 @@ import {
const router = useRouter();
const actions = [
{ label: "里程碑管理", path: "/study/milestones", icon: Timer, color: "#2f54eb" },
{ label: "伦理与启动", path: "/study/milestones", icon: Timer, color: "#2f54eb" },
{ label: "受试者", path: "/study/subjects", icon: UserFilled, color: "#722ed1" },
{ label: "不良事件", path: "/study/aes", icon: Warning, color: "#faad14" },
{ label: "数据问题", path: "/study/data-queries", icon: QuestionFilled, color: "#1890ff" },
+27 -4
View File
@@ -3,6 +3,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import Layout from "../components/Layout.vue";
import Login from "../views/Login.vue";
import Register from "../views/Register.vue";
import StudyList from "../views/StudyList.vue";
import StudyHome from "../views/StudyHome.vue";
import Milestones from "../views/Milestones.vue";
@@ -28,10 +29,12 @@ import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue";
import AuditLogs from "../views/admin/AuditLogs.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminUserApproval from "../views/admin/AdminUserApproval.vue";
import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue";
import ProjectDetail from "../views/admin/ProjectDetail.vue";
import ProfileSettings from "../views/ProfileSettings.vue";
const routes: RouteRecordRaw[] = [
{
@@ -40,6 +43,12 @@ const routes: RouteRecordRaw[] = [
component: Login,
meta: { public: true, title: "登录" },
},
{
path: "/register",
name: "Register",
component: Register,
meta: { public: true, title: "注册" },
},
{
path: "/",
component: Layout,
@@ -51,6 +60,12 @@ const routes: RouteRecordRaw[] = [
component: MyWorkbench,
meta: { title: "我的工作台" },
},
{
path: "profile",
name: "ProfileSettings",
component: ProfileSettings,
meta: { title: "个人设置" },
},
{
path: "study/home",
name: "StudyHome",
@@ -61,7 +76,7 @@ const routes: RouteRecordRaw[] = [
path: "study/milestones",
name: "StudyMilestones",
component: Milestones,
meta: { title: "里程碑", requiresStudy: true },
meta: { title: "伦理与启动", requiresStudy: true },
},
{
path: "study/milestones/:milestoneId",
@@ -196,6 +211,12 @@ const routes: RouteRecordRaw[] = [
component: AdminUsers,
meta: { title: "账号管理", requiresAdmin: true },
},
{
path: "user-approval",
name: "AdminUserApproval",
component: AdminUserApproval,
meta: { title: "注册审核", requiresAdmin: true },
},
{
path: "projects",
name: "AdminProjects",
@@ -257,9 +278,11 @@ router.beforeEach(async (to, _from, next) => {
next({ path: "/login" });
return;
}
if (to.path === "/login" && token) {
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
return;
if ((to.path === "/login" || to.path === "/register") && token) {
if (!auth.forceLogin) {
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
return;
}
}
if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") {
next({ path: "/studies" });
+9 -2
View File
@@ -9,14 +9,16 @@ export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(getToken());
const user = ref<UserInfo | null>(null);
const loading = ref(false);
const forceLogin = ref(false);
const login = async (username: string, password: string) => {
const login = async (email: string, password: string) => {
loading.value = true;
try {
const { data } = await apiLogin({ username, password });
const { data } = await apiLogin({ email, password });
token.value = data.access_token;
setToken(data.access_token);
await fetchMeAction();
forceLogin.value = false;
} finally {
loading.value = false;
}
@@ -31,6 +33,7 @@ export const useAuthStore = defineStore("auth", () => {
const logout = () => {
token.value = null;
user.value = null;
forceLogin.value = false;
clearToken();
const studyStore = useStudyStore();
studyStore.clearCurrentStudy();
@@ -40,8 +43,12 @@ export const useAuthStore = defineStore("auth", () => {
token,
user,
loading,
forceLogin,
login,
fetchMe: fetchMeAction,
logout,
requestReLogin: () => {
forceLogin.value = true;
},
};
});
+24 -3
View File
@@ -9,7 +9,7 @@ export interface ApiError {
}
export interface LoginRequest {
username: string;
email: string;
password: string;
}
@@ -19,17 +19,38 @@ export interface LoginResponse {
}
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN";
export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED";
export interface UserInfo {
id: string;
username: string;
email: string;
username?: string;
full_name: string;
department: string;
role: UserRole;
is_active: boolean;
status: UserStatus;
is_active?: boolean;
created_at?: string;
approved_at?: string | null;
approved_by?: string | null;
avatar_url?: string | null;
}
export type UserMeResponse = UserInfo;
export interface RegisterRequest {
email: string;
password: string;
full_name: string;
role: Exclude<UserRole, "ADMIN">;
department: string;
}
export interface AdminUserListResponse {
items: UserInfo[];
total: number;
}
export interface Study {
id: string;
code: string;
+28
View File
@@ -1,4 +1,5 @@
const TOKEN_KEY = "ctms_token";
const CREDENTIAL_KEY = "ctms_credential";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
@@ -9,3 +10,30 @@ export const setToken = (token: string): void => {
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
export const setCachedCredential = (email: string, password: string) => {
try {
const payload = btoa(JSON.stringify({ email, password }));
localStorage.setItem(CREDENTIAL_KEY, payload);
} catch {
/* ignore */
}
};
export const getCachedCredential = (): { email: string; password: string } | null => {
const value = localStorage.getItem(CREDENTIAL_KEY);
if (!value) return null;
try {
const parsed = JSON.parse(atob(value));
if (parsed?.email && parsed?.password) {
return { email: parsed.email, password: parsed.password };
}
} catch {
/* ignore */
}
return null;
};
export const clearCachedCredential = (): void => {
localStorage.removeItem(CREDENTIAL_KEY);
};
+51 -19
View File
@@ -9,7 +9,7 @@
<el-card class="login-card">
<div class="login-header">
<h2 class="form-title">用户登录</h2>
<p class="form-desc">请输入您的账号与密码以访问系统</p>
<p class="form-desc">请输入邮箱与密码登录未激活账号将无法登录</p>
</div>
<el-form
@@ -19,11 +19,11 @@
label-position="top"
@keyup.enter.native="onSubmit"
>
<el-form-item label="用户名" prop="username">
<el-form-item label="邮箱" prop="email">
<el-input
v-model="form.username"
placeholder="请输入用户名"
autocomplete="username"
v-model="form.email"
placeholder="请输入邮箱"
autocomplete="email"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
@@ -37,6 +37,7 @@
</el-form-item>
<div class="form-footer">
<el-checkbox v-model="form.remember">记住账号密码</el-checkbox>
<el-button
type="primary"
:loading="loading"
@@ -45,6 +46,9 @@
>
</el-button>
<div class="link-row">
<RouterLink to="/register">没有账号注册</RouterLink>
</div>
</div>
</el-form>
</el-card>
@@ -57,40 +61,60 @@
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { reactive, ref, onMounted } from "vue";
import { useRouter } from "vue-router";
import type { FormInstance, FormRules } from "element-plus";
import { ElCheckbox, ElMessage, type FormInstance, type FormRules } from "element-plus";
import { useAuthStore } from "../store/auth";
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
const auth = useAuthStore();
const router = useRouter();
const formRef = ref<FormInstance>();
const form = reactive({
username: "",
email: "",
password: "",
remember: false,
});
const rules: FormRules<typeof form> = {
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
email: [
{ required: true, message: "请输入邮箱", trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
],
password: [{ required: true, message: "请输入密码", trigger: "blur" }],
};
const loading = ref(false);
onMounted(() => {
const cached = getCachedCredential();
if (cached) {
form.email = cached.email;
form.password = cached.password;
form.remember = true;
}
});
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
loading.value = true;
try {
await auth.login(form.username, form.password);
await auth.fetchMe();
router.push("/");
} finally {
loading.value = false;
const valid = await formRef.value.validate();
if (!valid) return;
loading.value = true;
try {
await auth.login(form.email, form.password);
if (form.remember) {
setCachedCredential(form.email, form.password);
} else {
clearCachedCredential();
}
});
router.push("/");
} catch (error: any) {
const detail = error?.response?.data?.detail || error?.response?.data?.message;
ElMessage.error(detail || "邮箱或密码错误");
} finally {
loading.value = false;
}
};
</script>
@@ -169,6 +193,14 @@ const onSubmit = async () => {
font-size: 16px;
margin-top: 16px;
}
.link-row {
margin-top: 12px;
text-align: center;
}
.link-row a {
color: var(--ctms-primary);
font-size: 14px;
}
.login-footer {
text-align: center;
+10 -2
View File
@@ -102,7 +102,7 @@ const loadMilestones = async () => {
const { data } = await fetchMilestones(study.currentStudy.id);
milestones.value = data.items || data;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "里程碑加载失败");
ElMessage.error(e?.response?.data?.message || "节点加载失败");
} finally {
loading.value = false;
}
@@ -150,7 +150,13 @@ const statusColor = (v: string) => getMilestoneStatusColor(v);
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
const username = cur?.user?.display_name || cur?.user?.username || cur?.username;
const username =
cur?.user?.full_name ||
cur?.user?.display_name ||
cur?.user?.username ||
cur?.user?.email ||
cur?.username ||
cur?.email;
if (cur?.user_id) acc[cur.user_id] = username || cur.user_id;
return acc;
}, {})
@@ -166,8 +172,10 @@ const siteName = (row: any) => {
const ownerName = (ownerId: string) => {
const fromPayload = milestones.value.find((m) => m.owner_id === ownerId)?.owner;
return (
fromPayload?.full_name ||
fromPayload?.display_name ||
fromPayload?.username ||
fromPayload?.email ||
displayUser(ownerId, { members: memberMap.value })
);
};
+185
View File
@@ -0,0 +1,185 @@
<template>
<div class="page">
<el-card>
<h3 class="title">个人设置</h3>
<p class="subtitle">更新个人信息或修改登录密码</p>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form">
<el-form-item label="头像">
<div class="avatar-row">
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
</el-avatar>
<el-upload
class="avatar-uploader"
:show-file-list="false"
action="/api/v1/auth/me/avatar"
name="file"
:headers="uploadHeaders"
:on-success="onAvatarUploaded"
:on-error="onAvatarError"
>
<el-button>上传头像</el-button>
</el-upload>
</div>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" disabled />
</el-form-item>
<el-form-item label="姓名" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" />
</el-form-item>
<el-divider />
<el-form-item label="当前密码" prop="current_password">
<el-input v-model="form.current_password" type="password" show-password placeholder="修改密码时必填" />
</el-form-item>
<el-form-item label="新密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="不修改请留空" />
</el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword">
<el-input v-model="form.confirmPassword" type="password" show-password placeholder="再次输入新密码" />
</el-form-item>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</div>
</el-form>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { updateProfile, fetchMe } from "../api/auth";
import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth";
const auth = useAuthStore();
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
email: "",
full_name: "",
department: "",
current_password: "",
password: "",
confirmPassword: "",
});
const avatarPreview = ref<string | undefined>();
const uploadHeaders = computed<Record<string, string>>(() => ({
Authorization: `Bearer ${getToken() || ""}`,
}));
const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
current_password: [
{
validator: (_r, value, cb) => {
if (form.password && !value) {
cb(new Error("修改密码需输入当前密码"));
return;
}
cb();
},
trigger: "blur",
},
],
password: [
{
validator: (_r, value, cb) => {
if (!value) return cb();
if (value.length < 8 || !/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
cb(new Error("至少 8 位且包含字母与数字"));
return;
}
cb();
},
trigger: "blur",
},
],
confirmPassword: [
{
validator: (_r, value, cb) => {
if (form.password && value !== form.password) {
cb(new Error("两次输入的密码不一致"));
return;
}
cb();
},
trigger: "blur",
},
],
};
const loadProfile = async () => {
const { data } = await fetchMe();
form.email = data.email;
form.full_name = data.full_name;
form.department = data.department;
avatarPreview.value = data.avatar_url || undefined;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
await updateProfile({
full_name: form.full_name,
department: form.department,
current_password: form.password ? form.current_password : undefined,
password: form.password || undefined,
});
await auth.fetchMe();
ElMessage.success("已更新");
form.current_password = "";
form.password = "";
form.confirmPassword = "";
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || "更新失败");
} finally {
submitting.value = false;
}
});
};
const onAvatarUploaded = async () => {
await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success("头像已更新");
};
const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || "头像上传失败");
};
onMounted(() => {
loadProfile();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.title {
margin: 0;
}
.subtitle {
margin: 6px 0 16px;
color: #666;
}
.form {
max-width: 520px;
}
.actions {
margin-top: 12px;
text-align: right;
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div class="register-container">
<div class="register-card">
<div class="header">
<h2>创建账号</h2>
<p>提交后需管理员审核通过方可登录</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
@keyup.enter.native="onSubmit"
>
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="name@example.com" autocomplete="email" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="至少 8 位且包含字母与数字"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="确认密码" prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="再次输入密码"
show-password
autocomplete="new-password"
/>
</el-form-item>
<el-form-item label="姓名" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" />
</el-form-item>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">提交注册</el-button>
<RouterLink to="/login">已有账号返回登录</RouterLink>
</div>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { register as apiRegister } from "../api/auth";
import type { RegisterRequest } from "../types/api";
const router = useRouter();
const defaultRole: RegisterRequest["role"] = "CRA";
const formRef = ref<FormInstance>();
const form = reactive({
email: "",
password: "",
confirmPassword: "",
full_name: "",
department: "",
});
const submitting = ref(false);
const passwordRule = {
validator: (_: any, value: string, callback: (err?: Error) => void) => {
if (!value || value.length < 8) {
callback(new Error("密码至少 8 位"));
return;
}
if (!/[A-Za-z]/.test(value) || !/[0-9]/.test(value)) {
callback(new Error("需包含字母和数字"));
return;
}
callback();
},
trigger: "blur",
};
const rules: FormRules<typeof form> = {
email: [
{ required: true, message: "请输入邮箱", trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
],
password: [
{ required: true, message: "请输入密码", trigger: "blur" },
passwordRule,
],
confirmPassword: [
{ required: true, message: "请确认密码", trigger: "blur" },
{
validator: (_: any, value: string, callback: (err?: Error) => void) => {
if (value !== form.password) {
callback(new Error("两次输入的密码不一致"));
} else {
callback();
}
},
trigger: "blur",
},
],
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
submitting.value = true;
try {
await apiRegister({
email: form.email,
password: form.password,
full_name: form.full_name,
role: defaultRole,
department: form.department,
});
ElMessage.success("注册成功,等待管理员审核");
setTimeout(() => {
router.push("/login");
}, 3000);
Object.assign(form, {
email: "",
password: "",
confirmPassword: "",
full_name: "",
department: "",
});
} finally {
submitting.value = false;
}
});
};
</script>
<style scoped>
.register-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f7f9fb 0%, #eef2f7 100%);
padding: 24px;
}
.register-card {
width: 420px;
background: #fff;
padding: 24px 28px;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.05);
border: 1px solid #f0f2f5;
}
.header h2 {
margin: 0;
font-size: 22px;
color: #1f2d3d;
}
.header p {
margin: 6px 0 16px;
color: #6c7a89;
}
.actions {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12px;
}
.actions a {
color: var(--ctms-primary);
font-size: 14px;
}
</style>
@@ -0,0 +1,135 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>注册审核</h3>
<p>仅显示待审核账号审核通过后即可登录</p>
</div>
<el-select v-model="status" size="small" style="width: 180px" @change="loadUsers">
<el-option label="待审核" value="PENDING" />
<el-option label="已通过" value="ACTIVE" />
<el-option label="已拒绝" value="REJECTED" />
<el-option label="已禁用" value="DISABLED" />
</el-select>
</div>
<el-table :data="users" v-loading="loading" style="width: 100%">
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="full_name" label="姓名" min-width="140" />
<el-table-column prop="role" label="角色" width="120" />
<el-table-column prop="department" label="部门" min-width="160" />
<el-table-column prop="created_at" label="注册时间" width="200">
<template #default="scope">{{ displayDateTime(scope.row.created_at) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="scope">
<el-button
v-if="scope.row.status === 'PENDING'"
type="success"
link
size="small"
@click="onApprove(scope.row.id)"
>
审核通过
</el-button>
<el-button
v-if="scope.row.status === 'PENDING'"
type="danger"
link
size="small"
@click="onReject(scope.row.id)"
>
拒绝
</el-button>
<span v-else></span>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { approveUser, listPendingUsers, rejectUser } from "../../api/admin";
import type { UserInfo, UserStatus } from "../../types/api";
import { displayDateTime } from "../../utils/display";
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const status = ref<UserStatus>("PENDING");
const statusType = (s: UserStatus) => {
switch (s) {
case "ACTIVE":
return "success";
case "PENDING":
return "warning";
case "REJECTED":
return "danger";
default:
return "info";
}
};
const loadUsers = async () => {
loading.value = true;
try {
const { data } = await listPendingUsers(status.value);
users.value = data.items || [];
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || "加载待审核用户失败");
} finally {
loading.value = false;
}
};
const onApprove = async (id: string) => {
try {
await approveUser(id);
ElMessage.success("已通过审核");
loadUsers();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || "审核失败");
}
};
const onReject = async (id: string) => {
try {
await rejectUser(id);
ElMessage.success("已拒绝");
loadUsers();
} catch (error: any) {
ElMessage.error(error?.response?.data?.message || "拒绝失败");
}
};
onMounted(() => {
loadUsers();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.header h3 {
margin: 0;
}
.header p {
margin: 4px 0 0;
color: #6c7a89;
}
</style>
+31 -8
View File
@@ -1,8 +1,14 @@
<template>
<el-dialog :title="user ? '编辑用户' : '新建用户'" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="!!user" placeholder="请输入用户名" />
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" :disabled="!!user" placeholder="请输入邮箱" />
</el-form-item>
<el-form-item label="姓名" prop="full_name">
<el-input v-model="form.full_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" />
</el-form-item>
<el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
@@ -42,13 +48,22 @@ const visibleProxy = computed({
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
username: "",
email: "",
full_name: "",
department: "",
password: "",
is_active: true,
});
const defaultRole = "PV";
const rules = reactive<FormRules>({
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
email: [
{ required: true, message: "请输入邮箱", trigger: "blur" },
{ type: "email", message: "邮箱格式不正确", trigger: "blur" },
],
full_name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
department: [{ required: true, message: "请输入部门", trigger: "blur" }],
password: [
{
validator: (_rule, value, callback) => {
@@ -64,7 +79,9 @@ const rules = reactive<FormRules>({
});
const resetForm = () => {
form.username = "";
form.email = "";
form.full_name = "";
form.department = "";
form.password = "";
form.is_active = true;
};
@@ -75,7 +92,9 @@ watch(
if (val) {
resetForm();
if (props.user) {
form.username = props.user.username;
form.email = props.user.email;
form.full_name = props.user.full_name;
form.department = props.user.department;
form.is_active = props.user.is_active;
}
}
@@ -90,13 +109,17 @@ const onSubmit = async () => {
if (props.user) {
await updateUser(props.user.id, {
is_active: form.is_active,
full_name: form.full_name,
department: form.department,
password: form.password || undefined,
});
ElMessage.success("用户已更新");
} else {
await createUser({
username: form.username,
role: "PV",
email: form.email,
full_name: form.full_name,
department: form.department,
role: defaultRole,
password: form.password,
});
ElMessage.success("用户已创建");
+39 -8
View File
@@ -9,11 +9,14 @@
<el-button type="primary" @click="openCreate">新建用户</el-button>
</div>
<el-table :data="users" stripe v-loading="loading" style="width: 100%">
<el-table-column prop="username" label="用户名" min-width="140" />
<el-table-column label="状态" width="120">
<el-table-column prop="email" label="邮箱" min-width="200" />
<el-table-column prop="full_name" label="姓名" min-width="140" />
<el-table-column prop="department" label="部门" min-width="140" />
<el-table-column label="状态" width="140">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag>
<el-tag type="danger" v-else>已禁用</el-tag>
<el-tag :type="statusType(scope.row.status)">
{{ statusLabel(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200">
@@ -24,11 +27,11 @@
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
:type="scope.row.status === 'ACTIVE' ? 'danger' : 'primary'"
size="small"
@click="toggleStatus(scope.row)"
>
{{ scope.row.is_active ? "禁用" : "启用" }}
{{ scope.row.status === 'ACTIVE' ? "禁用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button>
<el-button
@@ -105,7 +108,7 @@ const openEdit = (row: UserInfo) => {
};
const toggleStatus = async (row: UserInfo) => {
const disable = row.is_active;
const disable = row.status === "ACTIVE";
const ok = await ElMessageBox.confirm(
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
disable ? "禁用账号" : "启用账号",
@@ -117,7 +120,7 @@ const toggleStatus = async (row: UserInfo) => {
).catch(() => null);
if (!ok) return;
try {
await updateUser(row.id, { is_active: !row.is_active });
await updateUser(row.id, { status: disable ? "DISABLED" : "ACTIVE", is_active: !disable });
ElMessage.success(disable ? "已禁用" : "已启用");
loadUsers();
} catch (e: any) {
@@ -161,6 +164,34 @@ const onDelete = async (row: UserInfo) => {
onMounted(() => {
loadUsers();
});
const statusType = (status: string) => {
switch (status) {
case "ACTIVE":
return "success";
case "PENDING":
return "warning";
case "REJECTED":
return "danger";
default:
return "info";
}
};
const statusLabel = (status: string) => {
switch (status) {
case "ACTIVE":
return "启用";
case "PENDING":
return "待审核";
case "REJECTED":
return "已拒绝";
case "DISABLED":
return "已停用";
default:
return status || "未知";
}
};
</script>
<style scoped>