帐号治理-帐号管理--更新

This commit is contained in:
Cheng Zhou
2025-12-18 19:14:38 +08:00
parent f6b89cb8c4
commit eed83cec5a
23 changed files with 233 additions and 30 deletions
+50 -2
View File
@@ -23,7 +23,7 @@
</template>
</el-table-column>
<el-table-column prop="added_at" label="加入时间" width="200" />
<el-table-column label="操作" width="260" fixed="right">
<el-table-column label="操作" width="320" fixed="right">
<template #default="scope">
<el-select
v-model="scope.row.role_in_study"
@@ -38,6 +38,7 @@
<el-option label="IMP" value="IMP" />
<el-option label="ADMIN" value="ADMIN" />
</el-select>
<el-button link type="danger" size="small" @click="onDelete(scope.row)">删除</el-button>
<el-button
link
:type="scope.row.is_active ? 'danger' : 'primary'"
@@ -271,7 +272,7 @@ const toggleActive = async (row: StudyMember) => {
}).catch(() => null);
if (!ok) return;
try {
await removeMember(projectId.value, row.id);
await updateMember(projectId.value, row.id, { is_active: false });
ElMessage.success("成员已停用");
loadMembers();
logAudit("PROJECT_MEMBER_UPDATED", {
@@ -318,6 +319,53 @@ const toggleActive = async (row: StudyMember) => {
}
};
const onDelete = async (row: StudyMember) => {
if (!projectId.value) return;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "project.members.manage",
target: { projectId: projectId.value, memberId: row.id },
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "无权限执行该操作");
logAudit(decision.auditType, {
targetId: projectId.value,
targetName: project.value?.name,
reason: decision.reason,
severity: decision.severity,
});
return;
}
const ok = await ElMessageBox.confirm("确认将该账号从本项目移除?账号本身不会被删除。", "删除成员", {
type: "warning",
confirmButtonText: "确认",
cancelButtonText: "取消",
}).catch(() => null);
if (!ok) return;
try {
await removeMember(projectId.value, row.id);
members.value = members.value.filter((m) => m.id !== row.id);
ElMessage.success("已从项目移除");
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { removed_member_id: row.id },
severity: "normal",
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "删除失败");
logAudit("PROJECT_MEMBER_UPDATED", {
targetId: projectId.value,
targetName: project.value?.name,
before: { is_active: row.is_active },
after: { removed_member_id: row.id },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
const availableUsers = computed(() => users.value);
onMounted(async () => {
+47 -4
View File
@@ -10,8 +10,23 @@
<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 label="电话" prop="phone">
<el-input v-model="form.phone" placeholder="电话(可选)" />
</el-form-item>
<el-form-item label="负责人" prop="contact">
<el-select
v-model="form.craSelections"
multiple
filterable
placeholder="选择项目成员作为负责人,可多选"
>
<el-option
v-for="user in craOptions"
:key="user.value"
:label="user.label"
:value="user.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="site" label="状态" prop="is_active">
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
@@ -37,6 +52,8 @@ const props = defineProps<{
visible: boolean;
studyId: string;
site?: Site | null;
members?: any[];
users?: any[];
}>();
const emit = defineEmits<{
@@ -56,7 +73,9 @@ const form = reactive({
name: "",
city: "",
pi_name: "",
phone: "",
contact: "",
craSelections: [] as string[],
is_active: true,
});
@@ -64,11 +83,24 @@ const rules = reactive<FormRules>({
name: [{ required: true, message: "请输入中心名称", trigger: "blur" }],
});
const craOptions = computed(() => {
const userMap = (props.users || []).reduce<Record<string, string>>((acc, cur: any) => {
if (cur?.id) acc[cur.id] = cur.username || cur.id;
return acc;
}, {});
return (props.members || []).map((m: any) => ({
label: userMap[m.user_id] || m.username || m.user_id,
value: m.user_id,
}));
});
const resetForm = () => {
form.name = "";
form.city = "";
form.pi_name = "";
form.phone = "";
form.contact = "";
form.craSelections = [];
form.is_active = true;
};
@@ -81,7 +113,17 @@ watch(
form.name = props.site.name;
form.city = props.site.city || "";
form.pi_name = props.site.pi_name || "";
form.phone = (props.site as any)?.contact_phone || (props.site as any)?.phone || "";
form.contact = props.site.contact || "";
const rawSelections = (props.site.contact || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const optionByLabel: Record<string, string> = {};
craOptions.value.forEach((opt) => {
optionByLabel[opt.label] = opt.value;
});
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
form.is_active = props.site.is_active;
}
}
@@ -108,12 +150,13 @@ const onSubmit = async () => {
await formRef.value.validate();
submitting.value = true;
try {
const contact = form.craSelections.length ? form.craSelections.join(",") : form.contact;
if (props.site) {
await updateSite(props.studyId, props.site.id, {
name: form.name,
city: form.city,
pi_name: form.pi_name,
contact: form.contact,
contact,
is_active: form.is_active,
});
ElMessage.success("中心已更新");
@@ -128,7 +171,7 @@ const onSubmit = async () => {
name: form.name,
city: form.city,
pi_name: form.pi_name,
contact: form.contact,
contact,
});
ElMessage.success("中心已创建");
logAudit("SITE_STATUS_CHANGED", {
+52 -21
View File
@@ -12,17 +12,25 @@
<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="160">
<template #default="scope">
{{ phoneText(scope.row) }}
</template>
</el-table-column>
<el-table-column prop="contact" label="负责人" min-width="180">
<template #default="scope">
{{ contactLabel(scope.row) }}
</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 label="操作" width="280" fixed="right">
<el-table-column label="操作" width="220" 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'"
@@ -40,14 +48,8 @@
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"
:members="members"
:users="users"
@saved="loadSites"
/>
</div>
@@ -60,8 +62,8 @@ import { ElMessage, ElMessageBox } from "element-plus";
import { fetchStudyDetail } from "../../api/studies";
import { fetchSites, updateSite } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import { listMembers } from "../../api/members";
import SiteForm from "./SiteForm.vue";
import SiteCraBinding from "./SiteCraBinding.vue";
import type { Site, Study, UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { evaluateAction } from "../../guards/actionGuard";
@@ -74,11 +76,10 @@ const auth = useAuthStore();
const project = ref<Study | null>(null);
const sites = ref<Site[]>([]);
const users = ref<UserInfo[]>([]);
const members = ref<any[]>([]);
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;
@@ -112,23 +113,53 @@ const loadUsers = async () => {
}
};
const craUsers = computed(() => users.value.filter((u) => u.role === "CRA" && u.is_active));
const loadMembers = async () => {
if (!projectId.value) return;
try {
const { data } = await listMembers(projectId.value, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u) => {
map[u.id] = u.username || u.id;
});
members.value.forEach((m) => {
if (m.user_id && !map[m.user_id]) map[m.user_id] = m.username || m.user_id;
});
return map;
});
const contactLabel = (row: any) => {
if (!row?.contact) return "—";
return String(row.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || "—";
};
const phoneText = (row: any) => row?.phone || row?.contact_phone || "—";
const openCreate = () => {
loadUsers();
loadMembers();
editingSite.value = null;
formVisible.value = true;
};
const openEdit = (row: Site) => {
loadUsers();
loadMembers();
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;
const decision = evaluateAction({
@@ -178,7 +209,7 @@ const toggleSite = async (row: Site) => {
};
onMounted(async () => {
await Promise.all([loadProject(), loadUsers()]);
await Promise.all([loadProject(), loadUsers(), loadMembers()]);
loadSites();
});
</script>
+41 -2
View File
@@ -17,7 +17,7 @@
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="200" />
<el-table-column label="操作" width="240" fixed="right">
<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
@@ -29,6 +29,15 @@
{{ scope.row.is_active ? "禁用" : "启用" }}
</el-button>
<el-button link type="danger" size="small" @click="openReset(scope.row)">重置密码</el-button>
<el-button
link
type="danger"
size="small"
:disabled="scope.row.id === auth.user?.id"
@click="onDelete(scope.row)"
>
删除
</el-button>
</template>
</el-table-column>
</el-table>
@@ -51,10 +60,11 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchUsers, updateUser } from "../../api/users";
import { fetchUsers, updateUser, deleteUser } from "../../api/users";
import type { UserInfo } from "../../types/api";
import UserForm from "./UserForm.vue";
import UserResetPassword from "./UserResetPassword.vue";
import { useAuthStore } from "../../store/auth";
const users = ref<UserInfo[]>([]);
const total = ref(0);
@@ -65,6 +75,7 @@ const formVisible = ref(false);
const resetVisible = ref(false);
const editingUser = ref<UserInfo | null>(null);
const resetUser = ref<UserInfo | null>(null);
const auth = useAuthStore();
const loadUsers = async () => {
loading.value = true;
@@ -116,6 +127,34 @@ const openReset = (row: UserInfo) => {
resetVisible.value = true;
};
const onDelete = async (row: UserInfo) => {
const { value, action } = await ElMessageBox.prompt(
"删除后账号将无法登录且无法恢复,需先在各项目成员配置中移除该账号。请输入当前管理员密码确认删除。",
"危险操作:删除账号",
{
inputType: "password",
inputPlaceholder: "请输入 admin 密码",
confirmButtonText: "确认删除",
confirmButtonClass: "el-button--danger",
cancelButtonText: "取消",
}
).catch(() => ({ action: "cancel", value: "" }));
if (action !== "confirm" || !value) return;
try {
await deleteUser(row.id, { admin_password: value });
ElMessage.success("账号已删除");
loadUsers();
} catch (e: any) {
const detail = e?.response?.data?.detail || e?.response?.data?.message;
if (detail && detail.includes("项目成员")) {
ElMessage.closeAll();
ElMessage.error(detail);
} else {
ElMessage.error(detail || "删除失败");
}
}
};
onMounted(() => {
loadUsers();
});