增加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
+17
View File
@@ -0,0 +1,17 @@
import { apiDelete, apiGet, apiPatch, apiPost } from "./axios";
import type { StudyMember } from "../types/api";
export const listMembers = (studyId: string) =>
apiGet<StudyMember[]>(`/api/v1/studies/${studyId}/members/`);
export const addMember = (studyId: string, payload: { user_id: string; role_in_study: string; is_active?: boolean }) =>
apiPost<StudyMember>(`/api/v1/studies/${studyId}/members/`, payload);
export const updateMember = (
studyId: string,
memberId: string,
payload: Partial<{ role_in_study: string; is_active: boolean }>
) => apiPatch<StudyMember>(`/api/v1/studies/${studyId}/members/${memberId}`, payload);
export const removeMember = (studyId: string, memberId: string) =>
apiDelete<StudyMember>(`/api/v1/studies/${studyId}/members/${memberId}`);
+9 -3
View File
@@ -1,5 +1,11 @@
import { apiGet } from "./axios";
import type { ApiListResponse } from "../types/api";
import { apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, Site } from "../types/api";
export const fetchSites = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/sites/`, { params });
apiGet<ApiListResponse<Site> | Site[]>(`/api/v1/studies/${studyId}/sites/`, { params });
export const createSite = (studyId: string, payload: Partial<Site> & { name: string }) =>
apiPost<Site>(`/api/v1/studies/${studyId}/sites/`, payload);
export const updateSite = (studyId: string, siteId: string, payload: Partial<Site>) =>
apiPatch<Site>(`/api/v1/studies/${studyId}/sites/${siteId}`, payload);
+9 -1
View File
@@ -1,5 +1,13 @@
import { apiGet } from "./axios";
import { apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, Study } from "../types/api";
// Backend expects trailing slash to avoid 307 redirect
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies/");
export const createStudy = (payload: Partial<Study> & { code: string; name: string }) =>
apiPost<Study>("/api/v1/studies/", payload);
export const updateStudy = (studyId: string, payload: Partial<Study>) =>
apiPatch<Study>(`/api/v1/studies/${studyId}`, payload);
export const fetchStudyDetail = (studyId: string) => apiGet<Study>(`/api/v1/studies/${studyId}`);
+9 -3
View File
@@ -1,5 +1,11 @@
import { apiGet } from "./axios";
import type { ApiListResponse } from "../types/api";
import { apiGet, apiPatch, apiPost } from "./axios";
import type { ApiListResponse, UserInfo } from "../types/api";
export const fetchUsers = (params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>("/api/v1/users", { params });
apiGet<ApiListResponse<UserInfo>>("/api/v1/users", { params });
export const createUser = (payload: { username: string; password: string; role: string }) =>
apiPost<UserInfo>("/api/v1/users", payload);
export const updateUser = (userId: string, payload: Partial<{ role: string; is_active: boolean; password: string }>) =>
apiPatch<UserInfo>(`/api/v1/users/${userId}`, payload);
+14 -6
View File
@@ -12,10 +12,15 @@
<el-button size="small" type="danger" @click="onLogout">退出登录</el-button>
</div>
</el-header>
<el-container>
<el-aside width="200px" class="aside" v-if="study.currentStudy">
<el-menu router :default-active="$route.path">
<el-menu-item index="/home">首页</el-menu-item>
<el-container>
<el-aside width="220px" class="aside" v-if="isAdmin || study.currentStudy">
<el-menu router :default-active="$route.path">
<template v-if="isAdmin">
<el-menu-item index="/admin/users">账号治理</el-menu-item>
<el-menu-item index="/admin/projects">项目治理</el-menu-item>
<el-menu-item index="/studies">项目列表(PM 视角)</el-menu-item>
</template>
<template v-if="study.currentStudy">
<el-menu-item index="/study/home">项目概览</el-menu-item>
<el-menu-item index="/study/milestones">伦理与启动管理</el-menu-item>
<el-menu-item index="/study/tasks">任务</el-menu-item>
@@ -33,8 +38,9 @@
</el-sub-menu>
<el-menu-item index="/study/finance">费用</el-menu-item>
<el-menu-item index="/study/faq">知识库</el-menu-item>
</el-menu>
</el-aside>
</template>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
@@ -43,6 +49,7 @@
</template>
<script setup lang="ts">
import { computed } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
@@ -51,6 +58,7 @@ import StudySelector from "./StudySelector.vue";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const isAdmin = computed(() => auth.user?.role === "ADMIN");
const onLogout = () => {
auth.logout();
+50 -11
View File
@@ -2,7 +2,6 @@ import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import Layout from "../components/Layout.vue";
import Home from "../views/Home.vue";
import Login from "../views/Login.vue";
import StudyList from "../views/StudyList.vue";
import StudyHome from "../views/StudyHome.vue";
@@ -26,6 +25,10 @@ import FaqDetail from "../views/FaqDetail.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
import Verification from "../views/Verification.vue";
import AdminUsers from "../views/admin/Users.vue";
import AdminProjects from "../views/admin/Projects.vue";
import ProjectMembers from "../views/admin/ProjectMembers.vue";
import AdminSites from "../views/admin/Sites.vue";
const routes: RouteRecordRaw[] = [
{
@@ -37,14 +40,8 @@ const routes: RouteRecordRaw[] = [
{
path: "/",
component: Layout,
redirect: "/home",
redirect: "/studies",
children: [
{
path: "home",
name: "Home",
component: Home,
meta: { title: "首页" },
},
{
path: "study/home",
name: "StudyHome",
@@ -173,6 +170,37 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
path: "/admin",
component: Layout,
redirect: "/admin/users",
children: [
{
path: "users",
name: "AdminUsers",
component: AdminUsers,
meta: { title: "账号管理", requiresAdmin: true },
},
{
path: "projects",
name: "AdminProjects",
component: AdminProjects,
meta: { title: "项目管理", requiresAdmin: true },
},
{
path: "projects/:projectId/members",
name: "AdminProjectMembers",
component: ProjectMembers,
meta: { title: "项目成员", requiresAdmin: true },
},
{
path: "projects/:projectId/sites",
name: "AdminSites",
component: AdminSites,
meta: { title: "中心管理", requiresAdmin: true },
},
],
},
{
path: "/studies",
component: Layout,
@@ -192,16 +220,27 @@ const router = createRouter({
routes,
});
router.beforeEach((to, _from, next) => {
router.beforeEach(async (to, _from, next) => {
const auth = useAuthStore();
const studyStore = useStudyStore();
const studyStore = useStudyStore();
const token = auth.token;
if (!auth.user && token) {
try {
await auth.fetchMe();
} catch {
/* ignore */
}
}
if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (to.path === "/login" && token) {
next({ path: studyStore.currentStudy ? "/study/home" : "/home" });
next({ path: studyStore.currentStudy ? "/study/home" : "/studies" });
return;
}
if (to.meta.requiresAdmin && auth.user?.role !== "ADMIN") {
next({ path: "/studies" });
return;
}
if (to.path.startsWith("/study") && !studyStore.currentStudy) {
+31 -1
View File
@@ -18,10 +18,14 @@ export interface LoginResponse {
token_type: string;
}
export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "ADMIN";
export interface UserInfo {
id: string;
username: string;
role: string;
role: UserRole;
is_active: boolean;
created_at?: string;
}
export type UserMeResponse = UserInfo;
@@ -30,5 +34,31 @@ export interface Study {
id: string;
code: string;
name: string;
sponsor?: string | null;
protocol_no?: string | null;
phase?: string | null;
status: string;
created_by?: string | null;
created_at?: string;
role_in_study?: string | null;
}
export interface StudyMember {
id: string;
study_id: string;
user_id: string;
role_in_study: string;
is_active: boolean;
added_at: string;
}
export interface Site {
id: string;
study_id: string;
name: string;
city?: string | null;
pi_name?: string | null;
contact?: string | null;
is_active: boolean;
created_at?: string;
}
-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>