增加admin管理帐号和项目的界面--初步实现

This commit is contained in:
Cheng Zhou
2025-12-17 20:12:49 +08:00
parent 4f50237f56
commit c074c2b5bc
18933 changed files with 2088454 additions and 63 deletions
-27
View File
@@ -1,27 +0,0 @@
<template>
<div class="home">
<el-card>
<h2>欢迎使用 CTMS</h2>
<p>当前角色{{ auth.user?.role || "未知" }}</p>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useAuthStore } from "../store/auth";
const auth = useAuthStore();
onMounted(() => {
if (!auth.user) {
auth.fetchMe();
}
});
</script>
<style scoped>
.home {
padding: 16px;
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<template>
<el-dialog :title="project ? '编辑项目' : '新建项目'" width="560px" v-model="visibleProxy" :close-on-click-modal="false">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="项目编号" prop="code">
<el-input v-model="form.code" :disabled="!!project" placeholder="请输入项目编号" />
</el-form-item>
<el-form-item label="项目名称" prop="name">
<el-input v-model="form.name" placeholder="请输入项目名称" />
</el-form-item>
<el-form-item label="申办方" prop="sponsor">
<el-input v-model="form.sponsor" placeholder="申办方(可选)" />
</el-form-item>
<el-form-item label="方案号" prop="protocol_no">
<el-input v-model="form.protocol_no" placeholder="方案号(可选)" />
</el-form-item>
<el-form-item label="分期" prop="phase">
<el-input v-model="form.phase" placeholder="研究分期(可选)" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择状态">
<el-option label="草稿" value="DRAFT" />
<el-option label="进行中" value="ACTIVE" />
<el-option label="已关闭" value="CLOSED" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createStudy, updateStudy } from "../../api/studies";
import type { Study } from "../../types/api";
const props = defineProps<{
visible: boolean;
project?: Study | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "saved"): void;
}>();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
code: "",
name: "",
sponsor: "",
protocol_no: "",
phase: "",
status: "DRAFT",
});
const rules = reactive<FormRules>({
code: [{ required: true, message: "请输入项目编号", trigger: "blur" }],
name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
status: [{ required: true, message: "请选择项目状态", trigger: "change" }],
});
const resetForm = () => {
form.code = "";
form.name = "";
form.sponsor = "";
form.protocol_no = "";
form.phase = "";
form.status = "DRAFT";
};
watch(
() => props.visible,
(val) => {
if (val) {
resetForm();
if (props.project) {
form.code = props.project.code;
form.name = props.project.name;
form.sponsor = props.project.sponsor || "";
form.protocol_no = props.project.protocol_no || "";
form.phase = props.project.phase || "";
form.status = props.project.status || "DRAFT";
}
}
}
);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (props.project) {
await updateStudy(props.project.id, {
name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
});
ElMessage.success("项目已更新");
} else {
await createStudy({
code: form.code,
name: form.name,
sponsor: form.sponsor,
protocol_no: form.protocol_no,
phase: form.phase,
status: form.status,
});
ElMessage.success("项目已创建");
}
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>项目成员配置</h3>
<div class="sub" v-if="project">项目{{ project.code }} - {{ project.name }}</div>
</div>
<el-button type="primary" @click="openAdd">新增成员</el-button>
</div>
<el-table :data="memberRows" v-loading="loading" stripe>
<el-table-column prop="username" label="用户名" min-width="160" />
<el-table-column prop="role_in_study" label="角色" width="120">
<template #default="scope">
<el-tag>{{ scope.row.role_in_study }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag>
<el-tag type="danger" v-else>已禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="added_at" label="加入时间" width="200" />
<el-table-column label="操作" width="260" fixed="right">
<template #default="scope">
<el-select
v-model="scope.row.role_in_study"
size="small"
style="width: 120px"
@change="(val) => updateRole(scope.row.id, val)"
:disabled="!scope.row.is_active"
>
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
</el-select>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleActive(scope.row)"
>
{{ scope.row.is_active ? "停用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog title="新增成员" width="520px" v-model="addVisible" :close-on-click-modal="false">
<el-form :model="newMember" label-width="120px" ref="addFormRef" :rules="addRules">
<el-form-item label="用户" prop="user_id">
<el-select v-model="newMember.user_id" filterable placeholder="请选择用户">
<el-option
v-for="user in availableUsers"
:key="user.id"
:label="`${user.username} (${user.role})`"
:value="user.id"
:disabled="!user.is_active"
/>
</el-select>
</el-form-item>
<el-form-item label="角色" prop="role_in_study">
<el-select v-model="newMember.role_in_study" placeholder="请选择角色">
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" :loading="adding" @click="submitAdd">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { fetchUsers } from "../../api/users";
import { addMember, listMembers, removeMember, updateMember } from "../../api/members";
import { fetchStudyDetail } from "../../api/studies";
import type { Study, StudyMember, UserInfo } from "../../types/api";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const project = ref<Study | null>(null);
const members = ref<StudyMember[]>([]);
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const addVisible = ref(false);
const adding = ref(false);
const addFormRef = ref<FormInstance>();
const newMember = reactive({
user_id: "",
role_in_study: "PM",
});
const addRules = reactive<FormRules>({
user_id: [{ required: true, message: "请选择用户", trigger: "change" }],
role_in_study: [{ required: true, message: "请选择角色", trigger: "change" }],
});
const loadProject = async () => {
if (!projectId.value) return;
try {
const { data } = await fetchStudyDetail(projectId.value);
project.value = data;
} catch {
/* ignore */
}
};
const loadMembers = async () => {
if (!projectId.value) return;
loading.value = true;
try {
const { data } = await listMembers(projectId.value);
members.value = data as any;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "成员列表加载失败");
} finally {
loading.value = false;
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || [];
} catch {
users.value = [];
}
};
const memberRows = computed(() =>
members.value.map((m) => {
const user = users.value.find((u) => u.id === m.user_id);
return {
...m,
username: user?.username || m.user_id,
};
})
);
const openAdd = () => {
newMember.user_id = "";
newMember.role_in_study = "PM";
addVisible.value = true;
};
const submitAdd = async () => {
if (!projectId.value) return;
await addFormRef.value?.validate();
adding.value = true;
try {
await addMember(projectId.value, newMember);
ElMessage.success("成员已添加");
addVisible.value = false;
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "添加失败");
} finally {
adding.value = false;
}
};
const updateRole = async (memberId: string, role: string) => {
if (!projectId.value) return;
try {
await updateMember(projectId.value, memberId, { role_in_study: role });
ElMessage.success("角色已更新");
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
loadMembers();
}
};
const toggleActive = async (row: StudyMember) => {
if (!projectId.value) return;
if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响项目成员,请确认是否继续", "停用成员", {
type: "warning",
confirmButtonText: "确认",
}).catch(() => null);
if (!ok) return;
try {
await removeMember(projectId.value, row.id);
ElMessage.success("成员已停用");
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
} else {
try {
await updateMember(projectId.value, row.id, { is_active: true });
ElMessage.success("成员已启用");
loadMembers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
}
};
const availableUsers = computed(() => users.value);
onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]);
loadMembers();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sub {
color: #666;
margin-top: 4px;
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<div class="page">
<el-card>
<div class="header">
<h3>项目管理</h3>
<el-button type="primary" @click="openCreate">新建项目</el-button>
</div>
<el-table :data="projects" v-loading="loading" stripe>
<el-table-column prop="code" label="项目编号" width="140" />
<el-table-column prop="name" label="项目名称" min-width="200" />
<el-table-column prop="sponsor" label="申办方" min-width="160" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="statusTag(scope.row.status)">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200" />
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button link type="primary" size="small" @click="goMembers(scope.row)">成员配置</el-button>
<el-button link type="primary" size="small" @click="goSites(scope.row)">中心管理</el-button>
<el-button link type="success" size="small" @click="enterStudy(scope.row)">进入项目</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<ProjectForm v-model:visible="formVisible" :project="editingProject" @saved="loadProjects" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchStudies } from "../../api/studies";
import type { Study } from "../../types/api";
import ProjectForm from "./ProjectForm.vue";
import { useStudyStore } from "../../store/study";
const projects = ref<Study[]>([]);
const loading = ref(false);
const formVisible = ref(false);
const editingProject = ref<Study | null>(null);
const router = useRouter();
const studyStore = useStudyStore();
const loadProjects = async () => {
loading.value = true;
try {
const { data } = await fetchStudies();
const items = (data as any).items || [];
projects.value = items;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "项目列表加载失败");
} finally {
loading.value = false;
}
};
const openCreate = () => {
editingProject.value = null;
formVisible.value = true;
};
const openEdit = (row: Study) => {
editingProject.value = row;
formVisible.value = true;
};
const goMembers = (row: Study) => {
router.push(`/admin/projects/${row.id}/members`);
};
const goSites = (row: Study) => {
router.push(`/admin/projects/${row.id}/sites`);
};
const enterStudy = (row: Study) => {
studyStore.setCurrentStudy({ ...row, role_in_study: "PM" } as Study);
router.push("/study/home");
};
const statusLabel = (status: string) =>
({
DRAFT: "草稿",
ACTIVE: "进行中",
CLOSED: "已关闭",
}[status] || status);
const statusTag = (status: string) =>
({
DRAFT: "info",
ACTIVE: "success",
CLOSED: "warning",
}[status] || "info");
onMounted(() => {
loadProjects();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<el-dialog title="CRA 绑定" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<div class="tip">中心{{ site?.name }}</div>
<el-form label-width="120px">
<el-form-item label="选择 CRA">
<el-select v-model="selectedCras" multiple filterable placeholder="请选择 CRA 账号" style="width: 100%">
<el-option v-for="user in craUsers" :key="user.id" :label="user.username" :value="user.id" />
</el-select>
<div class="hint">支持多选保存后将以用户名写入中心联系人字段</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="onSave">保存绑定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { updateSite } from "../../api/sites";
import type { Site, UserInfo } from "../../types/api";
const props = defineProps<{
visible: boolean;
studyId: string;
site: Site | null;
craUsers: UserInfo[];
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "saved"): void;
}>();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const selectedCras = ref<string[]>([]);
const saving = ref(false);
const loadSelected = () => {
if (!props.site) {
selectedCras.value = [];
return;
}
const raw = props.site.contact || "";
const tokens = raw
.split(",")
.map((i) => i.trim())
.filter(Boolean);
selectedCras.value = tokens
.map((token) => {
const byId = props.craUsers.find((u) => u.id === token);
if (byId) return byId.id;
const byName = props.craUsers.find((u) => u.username === token);
return byName?.id;
})
.filter((v): v is string => !!v);
};
watch(
() => props.visible,
(val) => {
if (val) {
loadSelected();
}
}
);
const onSave = async () => {
if (!props.site) return;
saving.value = true;
try {
const names = selectedCras.value
.map((id) => props.craUsers.find((u) => u.id === id)?.username || id)
.filter(Boolean);
await updateSite(props.studyId, props.site.id, { contact: names.join(",") });
ElMessage.success("绑定已保存");
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
saving.value = false;
}
};
</script>
<style scoped>
.tip {
margin-bottom: 8px;
}
.hint {
color: #888;
font-size: 12px;
margin-top: 4px;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<el-dialog :title="site ? '编辑中心' : '新建中心'" 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="name">
<el-input v-model="form.name" placeholder="请输入中心名称" />
</el-form-item>
<el-form-item label="城市" prop="city">
<el-input v-model="form.city" placeholder="所在城市(可选)" />
</el-form-item>
<el-form-item label="PI" prop="pi_name">
<el-input v-model="form.pi_name" placeholder="PI(可选)" />
</el-form-item>
<el-form-item label="CRA 联系人" prop="contact">
<el-input v-model="form.contact" placeholder="多个联系人用逗号分隔" />
</el-form-item>
<el-form-item v-if="site" label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createSite, updateSite } from "../../api/sites";
import type { Site } from "../../types/api";
const props = defineProps<{
visible: boolean;
studyId: string;
site?: Site | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "saved"): void;
}>();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
name: "",
city: "",
pi_name: "",
contact: "",
is_active: true,
});
const rules = reactive<FormRules>({
name: [{ required: true, message: "请输入中心名称", trigger: "blur" }],
});
const resetForm = () => {
form.name = "";
form.city = "";
form.pi_name = "";
form.contact = "";
form.is_active = true;
};
watch(
() => props.visible,
(val) => {
if (val) {
resetForm();
if (props.site) {
form.name = props.site.name;
form.city = props.site.city || "";
form.pi_name = props.site.pi_name || "";
form.contact = props.site.contact || "";
form.is_active = props.site.is_active;
}
}
}
);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (props.site) {
await updateSite(props.studyId, props.site.id, {
name: form.name,
city: form.city,
pi_name: form.pi_name,
contact: form.contact,
is_active: form.is_active,
});
ElMessage.success("中心已更新");
} else {
await createSite(props.studyId, {
name: form.name,
city: form.city,
pi_name: form.pi_name,
contact: form.contact,
});
ElMessage.success("中心已创建");
}
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
+166
View File
@@ -0,0 +1,166 @@
<template>
<div class="page">
<el-card>
<div class="header">
<div>
<h3>中心管理</h3>
<div class="sub" v-if="project">项目{{ project.code }} - {{ project.name }}</div>
</div>
<el-button type="primary" @click="openCreate">新建中心</el-button>
</div>
<el-table :data="sites" v-loading="loading" stripe>
<el-table-column prop="name" label="中心名称" min-width="180" />
<el-table-column prop="city" label="城市" width="140" />
<el-table-column prop="pi_name" label="PI" width="160" />
<el-table-column prop="contact" label="CRA 联系人" min-width="180" />
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag>
<el-tag type="danger" v-else>停用</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openBind(scope.row)">绑定 CRA</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleSite(scope.row)"
>
{{ scope.row.is_active ? "停用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<SiteForm
v-if="projectId"
v-model:visible="formVisible"
:study-id="projectId"
:site="editingSite"
@saved="loadSites"
/>
<SiteCraBinding
v-if="projectId"
v-model:visible="bindVisible"
:study-id="projectId"
:site="bindingSite"
:cra-users="craUsers"
@saved="loadSites"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import SiteForm from "./SiteForm.vue";
import SiteCraBinding from "./SiteCraBinding.vue";
import type { Site, Study, UserInfo } from "../../types/api";
const route = useRoute();
const projectId = computed(() => route.params.projectId as string);
const project = ref<Study | null>(null);
const sites = ref<Site[]>([]);
const users = ref<UserInfo[]>([]);
const loading = ref(false);
const formVisible = ref(false);
const editingSite = ref<Site | null>(null);
const bindVisible = ref(false);
const bindingSite = ref<Site | null>(null);
const loadProject = async () => {
if (!projectId.value) return;
try {
const { data } = await fetchStudyDetail(projectId.value);
project.value = data;
} catch {
/* ignore */
}
};
const loadSites = async () => {
if (!projectId.value) return;
loading.value = true;
try {
const { data } = await fetchSites(projectId.value);
sites.value = Array.isArray(data) ? data : (data as any).items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "中心列表加载失败");
} finally {
loading.value = false;
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || [];
} catch {
users.value = [];
}
};
const craUsers = computed(() => users.value.filter((u) => u.role === "CRA" && u.is_active));
const openCreate = () => {
editingSite.value = null;
formVisible.value = true;
};
const openEdit = (row: Site) => {
editingSite.value = row;
formVisible.value = true;
};
const openBind = (row: Site) => {
bindingSite.value = row;
bindVisible.value = true;
};
const toggleSite = async (row: Site) => {
if (!projectId.value) return;
if (row.is_active) {
const ok = await ElMessageBox.confirm("该操作将影响中心数据,请确认是否继续", "停用中心", {
type: "warning",
confirmButtonText: "确认",
}).catch(() => null);
if (!ok) return;
}
try {
await updateSite(projectId.value, row.id, { is_active: !row.is_active });
ElMessage.success(row.is_active ? "已停用" : "已启用");
loadSites();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]);
loadSites();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.sub {
color: #666;
margin-top: 4px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<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>
<el-form-item label="角色" prop="role">
<el-select v-model="form.role" placeholder="请选择角色">
<el-option label="ADMIN" value="ADMIN" />
<el-option label="PM" value="PM" />
<el-option label="CRA" value="CRA" />
<el-option label="PV" value="PV" />
<el-option label="IMP" value="IMP" />
</el-select>
</el-form-item>
<el-form-item :label="user ? '新密码(可选)' : '初始密码'" prop="password">
<el-input v-model="form.password" type="password" show-password :placeholder="user ? '留空则不修改密码' : '请输入初始密码'" />
</el-form-item>
<el-form-item v-if="user" label="账号状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
import { createUser, updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
const props = defineProps<{
visible: boolean;
user?: UserInfo | null;
}>();
const emit = defineEmits<{
(e: "update:visible", value: boolean): void;
(e: "saved"): void;
}>();
const visibleProxy = computed({
get: () => props.visible,
set: (val: boolean) => emit("update:visible", val),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
username: "",
role: "PM",
password: "",
is_active: true,
});
const rules = reactive<FormRules>({
username: [{ required: true, message: "请输入用户名", trigger: "blur" }],
role: [{ required: true, message: "请选择角色", trigger: "change" }],
password: [
{
validator: (_rule, value, callback) => {
if (!props.user && !value) {
callback(new Error("请输入初始密码"));
return;
}
callback();
},
trigger: "blur",
},
],
});
const resetForm = () => {
form.username = "";
form.role = "PM";
form.password = "";
form.is_active = true;
};
watch(
() => props.visible,
(val) => {
if (val) {
resetForm();
if (props.user) {
form.username = props.user.username;
form.role = props.user.role;
form.is_active = props.user.is_active;
}
}
}
);
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate();
submitting.value = true;
try {
if (props.user) {
await updateUser(props.user.id, {
role: form.role,
is_active: form.is_active,
password: form.password || undefined,
});
ElMessage.success("用户已更新");
} else {
await createUser({
username: form.username,
role: form.role,
password: form.password,
});
ElMessage.success("用户已创建");
}
emit("saved");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "保存失败");
} finally {
submitting.value = false;
}
};
</script>
@@ -0,0 +1,170 @@
<template>
<el-dialog title="重置密码" width="520px" v-model="visibleProxy" :close-on-click-modal="false">
<el-alert
type="warning"
show-icon
title="该操作将影响用户账号,请确认是否继续"
description="系统将生成临时密码,需再次输入用户名才能提交。"
class="mb-12"
/>
<div class="info">重置对象<strong>{{ user?.username }}</strong></div>
<el-form ref="formRef" :model="form" label-width="140px" :rules="rules">
<el-form-item label="确认用户名" prop="confirmUsername">
<el-input v-model="form.confirmUsername" placeholder="请输入用户名以确认" />
</el-form-item>
<el-form-item label="密码方式" prop="mode">
<el-radio-group v-model="form.mode">
<el-radio label="auto">系统生成临时密码</el-radio>
<el-radio label="manual">手动输入新密码</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item :label="form.mode === 'auto' ? '临时密码' : '新密码'" prop="passwordValue">
<el-input
v-if="form.mode === 'auto'"
v-model="form.tempPassword"
type="password"
show-password
readonly
>
<template #append>
<el-button @click="regenerate">重新生成</el-button>
</template>
</el-input>
<el-input
v-else
v-model="form.manualPassword"
type="password"
show-password
placeholder="请输入新密码"
/>
<div class="hint">不会明文展示请通过安全渠道告知用户</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visibleProxy = false">取消</el-button>
<el-button type="danger" :disabled="!canSubmit" :loading="submitting" @click="onSubmit">确认重置</el-button>
</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 { updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
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 form = reactive({
confirmUsername: "",
mode: "auto",
tempPassword: "",
manualPassword: "",
});
const rules = reactive<FormRules>({
confirmUsername: [{ required: true, message: "请再次输入用户名", trigger: "blur" }],
mode: [{ required: true, message: "请选择密码方式", trigger: "change" }],
passwordValue: [
{
validator: (_rule, _value, callback) => {
if (form.mode === "auto" && !form.tempPassword) {
callback(new Error("临时密码生成异常"));
return;
}
if (form.mode === "manual" && !form.manualPassword) {
callback(new Error("请输入新密码"));
return;
}
callback();
},
trigger: "blur",
},
],
});
const generatePassword = () => {
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789!@#$%^&*";
let pwd = "";
for (let i = 0; i < 12; i++) {
pwd += chars.charAt(Math.floor(Math.random() * chars.length));
}
form.tempPassword = pwd;
};
const regenerate = () => generatePassword();
watch(
() => props.visible,
(val) => {
if (val) {
form.confirmUsername = "";
form.mode = "auto";
generatePassword();
form.manualPassword = "";
}
}
);
const canSubmit = computed(() => !!props.user && form.confirmUsername === props.user.username);
const onSubmit = async () => {
if (!props.user) return;
await formRef.value?.validate();
if (form.confirmUsername !== props.user.username) {
ElMessage.error("确认用户名不匹配,无法提交");
return;
}
const ok = await ElMessageBox.confirm(
"该操作将影响用户账号,请确认是否继续",
"确认重置密码",
{
type: "warning",
confirmButtonText: "确认重置",
cancelButtonText: "取消",
}
).catch(() => null);
if (!ok) return;
submitting.value = true;
try {
const password = form.mode === "manual" ? form.manualPassword : form.tempPassword;
await updateUser(props.user.id, { password });
ElMessage.success("密码已重置");
emit("reset");
visibleProxy.value = false;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "重置失败");
} finally {
submitting.value = false;
}
};
</script>
<style scoped>
.mb-12 {
margin-bottom: 12px;
}
.info {
margin-bottom: 8px;
}
.hint {
font-size: 12px;
color: #999;
margin-top: 4px;
}
</style>
+141
View File
@@ -0,0 +1,141 @@
<template>
<div class="page">
<el-card>
<div class="header">
<h3>用户账号管理</h3>
<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 prop="role" label="角色" width="120">
<template #default="scope">
<el-tag type="success" v-if="scope.row.role === 'ADMIN'">ADMIN</el-tag>
<el-tag v-else>{{ scope.row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag type="success" v-if="scope.row.is_active">启用</el-tag>
<el-tag type="danger" v-else>已禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200" />
<el-table-column label="操作" width="240" fixed="right">
<template #default="scope">
<el-button link type="primary" size="small" @click="openEdit(scope.row)">编辑</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
size="small"
@click="toggleStatus(scope.row)"
>
{{ scope.row.is_active ? "禁用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination">
<el-pagination
background
layout="prev, pager, next, total"
:page-size="pageSize"
:total="total"
v-model:current-page="page"
@current-change="loadUsers"
/>
</div>
</el-card>
<UserForm v-model:visible="formVisible" :user="editingUser" @saved="loadUsers" />
<UserResetPassword v-model:visible="resetVisible" :user="resetUser" @reset="loadUsers" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchUsers, updateUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue";
const users = ref<UserInfo[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const loading = ref(false);
const formVisible = ref(false);
const resetVisible = ref(false);
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const loadUsers = async () => {
loading.value = true;
try {
const { data } = await fetchUsers({ skip: (page.value - 1) * pageSize.value, limit: pageSize.value });
const items = (data as any).items || [];
users.value = items;
total.value = (data as any).total || items.length;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "用户列表加载失败");
} finally {
loading.value = false;
}
};
const openCreate = () => {
editingUser.value = null;
formVisible.value = true;
};
const openEdit = (row: UserInfo) => {
editingUser.value = row;
formVisible.value = true;
};
const toggleStatus = async (row: UserInfo) => {
const disable = row.is_active;
const ok = await ElMessageBox.confirm(
disable ? "该操作将影响用户账号,请确认是否继续" : "确认启用该用户账号?",
disable ? "禁用账号" : "启用账号",
{
type: disable ? "warning" : "info",
confirmButtonText: "确认",
cancelButtonText: "取消",
}
).catch(() => null);
if (!ok) return;
try {
await updateUser(row.id, { is_active: !row.is_active });
ElMessage.success(disable ? "已禁用" : "已启用");
loadUsers();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
}
};
const openReset = (row: UserInfo) => {
resetUser.value = row;
resetVisible.value = true;
};
onMounted(() => {
loadUsers();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.pagination {
margin-top: 12px;
text-align: right;
}
</style>