帐号治理-帐号管理--更新
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user