UX-2(操作级权限控制)
This commit is contained in:
@@ -9,6 +9,14 @@
|
|||||||
|
|
||||||
> 其余历史示例账号已被停用,仅保留以上 5 个。若需新增,可用管理员在 `/api/v1/users` 创建,并在项目成员中赋予角色。
|
> 其余历史示例账号已被停用,仅保留以上 5 个。若需新增,可用管理员在 `/api/v1/users` 创建,并在项目成员中赋予角色。
|
||||||
|
|
||||||
|
## 角色权限概要(前端操作级提示,后端仍最终裁决)
|
||||||
|
- ADMIN:全权限
|
||||||
|
- PM:里程碑/任务维护、受试者状态、AE 创建/关闭、Issue 创建/关闭、Finance 创建/审批/支付、IMP 交易、FAQ 维护
|
||||||
|
- CRA:任务/受试者/AE/Data Query 创建,受试者状态更新,Finance 创建/提交,其他只读
|
||||||
|
- PV:AE 创建/关闭,Issue 创建/关闭,其余只读
|
||||||
|
- IMP:IMP 交易/产品/批次维护,其余只读
|
||||||
|
- 普通成员(无项目角色):仅浏览
|
||||||
|
|
||||||
## 访问方式
|
## 访问方式
|
||||||
- 前端:`http://localhost`(Nginx 反代到 Vite 开发容器)
|
- 前端:`http://localhost`(Nginx 反代到 Vite 开发容器)
|
||||||
- 后端 API:同域 `/api/v1/*`(已在前端代理)
|
- 后端 API:同域 `/api/v1/*`(已在前端代理)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<span>分类</span>
|
<span>分类</span>
|
||||||
<div v-if="canMaintain">
|
<div v-if="canMaintain">
|
||||||
<el-button type="primary" size="small" @click="openForm()">新建</el-button>
|
<PermissionAction action="faq.edit">
|
||||||
|
<el-button type="primary" size="small" @click="openForm()">新建</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<el-menu :default-active="activeCategory" @select="onSelect">
|
<el-menu :default-active="activeCategory" @select="onSelect">
|
||||||
@@ -12,15 +14,16 @@
|
|||||||
<span>{{ c.name }}</span>
|
<span>{{ c.name }}</span>
|
||||||
<el-tag v-if="c.study_id" size="small" type="success" class="ml-4">项目</el-tag>
|
<el-tag v-if="c.study_id" size="small" type="success" class="ml-4">项目</el-tag>
|
||||||
<el-tag v-else size="small" class="ml-4">全局</el-tag>
|
<el-tag v-else size="small" class="ml-4">全局</el-tag>
|
||||||
<el-button
|
<PermissionAction v-if="canEdit(c)" action="faq.edit">
|
||||||
v-if="canEdit(c)"
|
<el-button
|
||||||
type="text"
|
type="text"
|
||||||
size="small"
|
size="small"
|
||||||
style="margin-left: auto"
|
style="margin-left: auto"
|
||||||
@click.stop="openForm(c)"
|
@click.stop="openForm(c)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</el-button>
|
</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
|
<FaqCategoryForm v-model="showForm" :category="editing" :is-admin="isAdmin" @success="emit('refresh')" />
|
||||||
@@ -33,6 +36,8 @@ import FaqCategoryForm from "./FaqCategoryForm.vue";
|
|||||||
import type { FaqCategory } from "../api/faqs";
|
import type { FaqCategory } from "../api/faqs";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import PermissionAction from "./PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
categories: FaqCategory[];
|
categories: FaqCategory[];
|
||||||
@@ -46,8 +51,9 @@ const study = useStudyStore();
|
|||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editing = ref<FaqCategory | null>(null);
|
const editing = ref<FaqCategory | null>(null);
|
||||||
|
|
||||||
|
const { can } = usePermission();
|
||||||
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
const isAdmin = computed(() => auth.user?.role === "ADMIN");
|
||||||
const canMaintain = computed(() => isAdmin.value || auth.user?.role === "PM");
|
const canMaintain = computed(() => can("faq.edit"));
|
||||||
|
|
||||||
const sortedCategories = computed(() =>
|
const sortedCategories = computed(() =>
|
||||||
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
[...props.categories].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||||
@@ -56,8 +62,9 @@ const sortedCategories = computed(() =>
|
|||||||
const activeCategory = computed(() => props.modelValue || "");
|
const activeCategory = computed(() => props.modelValue || "");
|
||||||
|
|
||||||
const canEdit = (c: FaqCategory) => {
|
const canEdit = (c: FaqCategory) => {
|
||||||
|
if (!canMaintain.value) return false;
|
||||||
if (isAdmin.value) return true;
|
if (isAdmin.value) return true;
|
||||||
return auth.user?.role === "PM" && c.study_id === study.currentStudy?.id;
|
return c.study_id === study.currentStudy?.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect = (id: string) => {
|
const onSelect = (id: string) => {
|
||||||
|
|||||||
@@ -16,16 +16,19 @@
|
|||||||
<el-table-column label="操作" width="180">
|
<el-table-column label="操作" width="180">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
|
<el-button type="text" size="small" @click="view(scope.row)">查看</el-button>
|
||||||
<el-button v-if="canEdit" type="text" size="small" @click="edit(scope.row)">编辑</el-button>
|
<PermissionAction v-if="canEdit" action="faq.edit">
|
||||||
<el-button
|
<el-button type="text" size="small" @click="edit(scope.row)">编辑</el-button>
|
||||||
v-if="canEdit"
|
</PermissionAction>
|
||||||
type="text"
|
<PermissionAction v-if="canEdit" action="faq.edit">
|
||||||
size="small"
|
<el-button
|
||||||
@click="toggle(scope.row)"
|
type="text"
|
||||||
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
|
size="small"
|
||||||
>
|
@click="toggle(scope.row)"
|
||||||
{{ scope.row.is_active ? "停用" : "启用" }}
|
:style="{ color: scope.row.is_active ? '#f56c6c' : '#67c23a' }"
|
||||||
</el-button>
|
>
|
||||||
|
{{ scope.row.is_active ? "停用" : "启用" }}
|
||||||
|
</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -37,6 +40,7 @@ import { computed } from "vue";
|
|||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { updateFaqItem } from "../api/faqs";
|
import { updateFaqItem } from "../api/faqs";
|
||||||
import type { FaqItem, FaqCategory } from "../api/faqs";
|
import type { FaqItem, FaqCategory } from "../api/faqs";
|
||||||
|
import PermissionAction from "./PermissionAction.vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: FaqItem[];
|
items: FaqItem[];
|
||||||
|
|||||||
@@ -1,63 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<el-button
|
<PermissionAction action="finance.create">
|
||||||
v-if="item.status === 'DRAFT' && canSubmit"
|
<el-button
|
||||||
type="primary"
|
v-if="item.status === 'DRAFT'"
|
||||||
size="small"
|
type="primary"
|
||||||
:loading="loading"
|
size="small"
|
||||||
@click="change('SUBMITTED')"
|
:loading="loading"
|
||||||
>
|
@click="change('SUBMITTED')"
|
||||||
提交
|
>
|
||||||
</el-button>
|
提交
|
||||||
<el-button
|
</el-button>
|
||||||
v-if="item.status === 'SUBMITTED' && canApprove"
|
</PermissionAction>
|
||||||
type="success"
|
<PermissionAction action="finance.approve">
|
||||||
size="small"
|
<el-button
|
||||||
:loading="loading"
|
v-if="item.status === 'SUBMITTED'"
|
||||||
@click="change('APPROVED')"
|
type="success"
|
||||||
>
|
size="small"
|
||||||
审批通过
|
:loading="loading"
|
||||||
</el-button>
|
@click="change('APPROVED')"
|
||||||
<el-button
|
>
|
||||||
v-if="item.status === 'SUBMITTED' && canApprove"
|
审批通过
|
||||||
type="danger"
|
</el-button>
|
||||||
size="small"
|
</PermissionAction>
|
||||||
:loading="loading"
|
<PermissionAction action="finance.approve">
|
||||||
@click="reject"
|
<el-button
|
||||||
>
|
v-if="item.status === 'SUBMITTED'"
|
||||||
驳回
|
type="danger"
|
||||||
</el-button>
|
size="small"
|
||||||
<el-button
|
:loading="loading"
|
||||||
v-if="item.status === 'APPROVED' && canPay"
|
@click="reject"
|
||||||
type="warning"
|
>
|
||||||
size="small"
|
驳回
|
||||||
:loading="loading"
|
</el-button>
|
||||||
@click="change('PAID')"
|
</PermissionAction>
|
||||||
>
|
<PermissionAction action="finance.pay">
|
||||||
确认支付
|
<el-button
|
||||||
</el-button>
|
v-if="item.status === 'APPROVED'"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
:loading="loading"
|
||||||
|
@click="change('PAID')"
|
||||||
|
>
|
||||||
|
确认支付
|
||||||
|
</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { computed, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { changeFinanceStatus } from "../api/finance";
|
import { changeFinanceStatus } from "../api/finance";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import PermissionAction from "./PermissionAction.vue";
|
||||||
|
|
||||||
const props = defineProps<{ item: any }>();
|
const props = defineProps<{ item: any }>();
|
||||||
const emit = defineEmits(["success"]);
|
const emit = defineEmits(["success"]);
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
|
||||||
const role = computed(() => auth.user?.role);
|
|
||||||
const canSubmit = computed(() => role.value === "ADMIN" || role.value === "PM" || role.value === "CRA");
|
|
||||||
const canApprove = computed(() => role.value === "ADMIN" || role.value === "PM");
|
|
||||||
const canPay = canApprove;
|
|
||||||
|
|
||||||
const change = async (status: string, reject_reason?: string) => {
|
const change = async (status: string, reject_reason?: string) => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<el-tooltip v-if="!allowed && tooltip" :content="reason" placement="top">
|
||||||
|
<span class="perm-wrapper perm-disabled">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</el-tooltip>
|
||||||
|
<span v-else-if="!allowed" class="perm-wrapper perm-disabled">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
<span v-else class="perm-wrapper">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
action: string;
|
||||||
|
tooltip?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { can, reason: reasonFn } = usePermission();
|
||||||
|
const allowed = computed(() => can(props.action));
|
||||||
|
const reason = computed(() => reasonFn(props.action));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.perm-wrapper {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.perm-disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,13 +3,20 @@ import { ref } from "vue";
|
|||||||
import type { Study } from "../types/api";
|
import type { Study } from "../types/api";
|
||||||
|
|
||||||
const STUDY_KEY = "ctms_current_study";
|
const STUDY_KEY = "ctms_current_study";
|
||||||
|
const STUDY_ROLE_KEY = "ctms_current_study_role";
|
||||||
|
|
||||||
export const useStudyStore = defineStore("study", () => {
|
export const useStudyStore = defineStore("study", () => {
|
||||||
const currentStudy = ref<Study | null>(null);
|
const currentStudy = ref<Study | null>(null);
|
||||||
|
const currentStudyRole = ref<string | null>(null);
|
||||||
|
|
||||||
const setCurrentStudy = (study: Study) => {
|
const setCurrentStudy = (study: Study) => {
|
||||||
currentStudy.value = study;
|
currentStudy.value = study;
|
||||||
|
const role = (study as any).role_in_study || (study as any).role || null;
|
||||||
|
currentStudyRole.value = role;
|
||||||
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
|
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
|
||||||
|
if (role) {
|
||||||
|
localStorage.setItem(STUDY_ROLE_KEY, role);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadCurrentStudy = () => {
|
const loadCurrentStudy = () => {
|
||||||
@@ -21,16 +28,31 @@ export const useStudyStore = defineStore("study", () => {
|
|||||||
currentStudy.value = null;
|
currentStudy.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const savedRole = localStorage.getItem(STUDY_ROLE_KEY);
|
||||||
|
currentStudyRole.value = savedRole || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearCurrentStudy = () => {
|
const clearCurrentStudy = () => {
|
||||||
currentStudy.value = null;
|
currentStudy.value = null;
|
||||||
|
currentStudyRole.value = null;
|
||||||
localStorage.removeItem(STUDY_KEY);
|
localStorage.removeItem(STUDY_KEY);
|
||||||
|
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentStudyRole = (role: string | null) => {
|
||||||
|
currentStudyRole.value = role;
|
||||||
|
if (role) {
|
||||||
|
localStorage.setItem(STUDY_ROLE_KEY, role);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STUDY_ROLE_KEY);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentStudy,
|
currentStudy,
|
||||||
|
currentStudyRole,
|
||||||
setCurrentStudy,
|
setCurrentStudy,
|
||||||
|
setCurrentStudyRole,
|
||||||
loadCurrentStudy,
|
loadCurrentStudy,
|
||||||
clearCurrentStudy,
|
clearCurrentStudy,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,62 @@
|
|||||||
// 预留权限工具,可在后续基于 role 与项目内角色实现细粒度控制
|
import { computed } from "vue";
|
||||||
export const hasPermission = (requiredRoles: string[], userRole?: string): boolean => {
|
import { useAuthStore } from "../store/auth";
|
||||||
if (!requiredRoles.length) return true;
|
import { useStudyStore } from "../store/study";
|
||||||
if (!userRole) return false;
|
|
||||||
return requiredRoles.includes(userRole);
|
const PERMISSIONS: Record<string, string[]> = {
|
||||||
|
"task.create": ["ADMIN", "PM"],
|
||||||
|
"task.edit": ["ADMIN", "PM"],
|
||||||
|
"milestone.create": ["ADMIN", "PM"],
|
||||||
|
"subject.create": ["ADMIN", "PM", "CRA"],
|
||||||
|
"subject.enroll": ["ADMIN", "PM", "CRA"],
|
||||||
|
"subject.complete": ["ADMIN", "PM", "CRA"],
|
||||||
|
"subject.drop": ["ADMIN", "PM", "CRA"],
|
||||||
|
"ae.create": ["ADMIN", "PM", "CRA", "PV"],
|
||||||
|
"ae.close": ["ADMIN", "PM", "PV", "CRA"],
|
||||||
|
"issue.create": ["ADMIN", "PM", "PV"],
|
||||||
|
"issue.close": ["ADMIN", "PM", "PV"],
|
||||||
|
"finance.create": ["ADMIN", "PM", "CRA"],
|
||||||
|
"finance.approve": ["ADMIN", "PM"],
|
||||||
|
"finance.pay": ["ADMIN", "PM"],
|
||||||
|
"imp.transaction": ["ADMIN", "PM", "IMP"],
|
||||||
|
"faq.edit": ["ADMIN", "PM"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const REASONS: Record<string, string> = {
|
||||||
|
"task.create": "仅项目负责人可以创建任务",
|
||||||
|
"task.edit": "仅项目负责人可以编辑任务",
|
||||||
|
"milestone.create": "仅项目负责人可以维护里程碑",
|
||||||
|
"subject.enroll": "仅 PM/CRA 可更新受试者状态",
|
||||||
|
"subject.complete": "仅 PM/CRA 可更新受试者状态",
|
||||||
|
"subject.drop": "仅 PM/CRA 可更新受试者状态",
|
||||||
|
"ae.close": "仅 PM/PV/ADMIN 可关闭 AE",
|
||||||
|
"finance.approve": "审批需要 PM/ADMIN 权限",
|
||||||
|
"finance.pay": "支付确认需要 PM/ADMIN 权限",
|
||||||
|
"imp.transaction": "仅 IMP/PM/ADMIN 可操作台账",
|
||||||
|
"faq.edit": "仅 PM/ADMIN 可维护 FAQ",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePermission = () => {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const study = useStudyStore();
|
||||||
|
|
||||||
|
const userRole = computed(() => auth.user?.role || null);
|
||||||
|
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || null);
|
||||||
|
|
||||||
|
const can = (action: string): boolean => {
|
||||||
|
if (userRole.value === "ADMIN") return true;
|
||||||
|
const allowList = PERMISSIONS[action];
|
||||||
|
if (!allowList) return false;
|
||||||
|
if (!projectRole.value) return false;
|
||||||
|
return allowList.includes(projectRole.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reason = (action: string): string => {
|
||||||
|
if (userRole.value === "ADMIN") return "";
|
||||||
|
return REASONS[action] || "当前角色无权执行该操作";
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
can,
|
||||||
|
reason,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,11 @@
|
|||||||
<h3>AE 详情</h3>
|
<h3>AE 详情</h3>
|
||||||
<p>{{ ae.term }}</p>
|
<p>{{ ae.term }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="canClose" :type="ae.status === 'CLOSED' ? 'primary' : 'danger'" size="small" @click="toggleClose">
|
<PermissionAction action="ae.close">
|
||||||
{{ ae.status === "CLOSED" ? "重新打开" : "关闭" }}
|
<el-button :type="ae.status === 'CLOSED' ? 'primary' : 'danger'" size="small" @click="toggleClose">
|
||||||
</el-button>
|
{{ ae.status === "CLOSED" ? "重新打开" : "关闭" }}
|
||||||
|
</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item>
|
<el-descriptions-item label="受试者ID">{{ ae.subject_id }}</el-descriptions-item>
|
||||||
@@ -43,6 +45,8 @@ import { useStudyStore } from "../store/study";
|
|||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import CommentList from "../components/CommentList.vue";
|
import CommentList from "../components/CommentList.vue";
|
||||||
import AttachmentList from "../components/AttachmentList.vue";
|
import AttachmentList from "../components/AttachmentList.vue";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -51,10 +55,8 @@ const auth = useAuthStore();
|
|||||||
const ae = ref<any | null>(null);
|
const ae = ref<any | null>(null);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
|
||||||
const canClose = computed(() => {
|
const { can } = usePermission();
|
||||||
// CRA 也显示按钮,后端会校验权限
|
const canClose = computed(() => can("ae.close"));
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const enrichOverdue = (item: any) => {
|
const enrichOverdue = (item: any) => {
|
||||||
if (!item) return item;
|
if (!item) return item;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" />
|
<el-switch v-model="onlyActive" active-text="仅启用" @change="loadFaqs" />
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button type="primary" v-if="canEdit" @click="openForm()">新建 FAQ</el-button>
|
<PermissionAction action="faq.edit">
|
||||||
|
<el-button type="primary" @click="openForm()">新建 FAQ</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -61,6 +63,8 @@ import FaqList from "../components/FaqList.vue";
|
|||||||
import FaqItemForm from "../components/FaqItemForm.vue";
|
import FaqItemForm from "../components/FaqItemForm.vue";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -78,10 +82,8 @@ const activeCategory = ref("");
|
|||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editing = ref<any | null>(null);
|
const editing = ref<any | null>(null);
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
const canEdit = computed(() => can("faq.edit"));
|
||||||
return role === "ADMIN" || role === "PM";
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadCategories = async () => {
|
const loadCategories = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@
|
|||||||
@change="loadList"
|
@change="loadList"
|
||||||
/>
|
/>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建费用</el-button>
|
<PermissionAction action="finance.create">
|
||||||
|
<el-button type="primary" @click="showForm = true">新建费用</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -71,6 +73,8 @@ import FinanceForm from "../components/FinanceForm.vue";
|
|||||||
import { fetchFinanceItems, fetchFinanceSummary } from "../api/finance";
|
import { fetchFinanceItems, fetchFinanceSummary } from "../api/finance";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -95,10 +99,8 @@ const categories = ["SITE_FEE", "SUBJECT_STIPEND", "TRAVEL", "OTHER"];
|
|||||||
|
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
|
|
||||||
const canCreate = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
const canCreate = computed(() => can("finance.create"));
|
||||||
return role === "ADMIN" || role === "PM" || role === "CRA";
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatAmount = (num?: number | string) => {
|
const formatAmount = (num?: number | string) => {
|
||||||
const n = Number(num);
|
const n = Number(num);
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
<el-tag :type="statusTag(item?.status)">{{ item?.status }}</el-tag>
|
<el-tag :type="statusTag(item?.status)">{{ item?.status }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions" v-if="item">
|
<div class="header-actions" v-if="item">
|
||||||
<el-button v-if="canEditDraft" size="small" @click="showEdit = true">编辑</el-button>
|
<PermissionAction action="finance.create">
|
||||||
|
<el-button v-if="canEditDraft" size="small" @click="showEdit = true">编辑</el-button>
|
||||||
|
</PermissionAction>
|
||||||
<FinanceStatusActions :item="item" @success="loadItem" />
|
<FinanceStatusActions :item="item" @success="loadItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -36,9 +38,11 @@ import { useRoute } from "vue-router";
|
|||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import FinanceStatusActions from "../components/FinanceStatusActions.vue";
|
import FinanceStatusActions from "../components/FinanceStatusActions.vue";
|
||||||
import FinanceForm from "../components/FinanceForm.vue";
|
import FinanceForm from "../components/FinanceForm.vue";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
import { fetchFinanceItem } from "../api/finance";
|
import { fetchFinanceItem } from "../api/finance";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -47,10 +51,8 @@ const route = useRoute();
|
|||||||
const item = ref<any>(null);
|
const item = ref<any>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showEdit = ref(false);
|
const showEdit = ref(false);
|
||||||
const canEditDraft = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
const canEditDraft = computed(() => item.value?.status === "DRAFT" && can("finance.create"));
|
||||||
return item.value?.status === "DRAFT" && (role === "ADMIN" || role === "PM" || role === "CRA");
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadItem = async () => {
|
const loadItem = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
|
|||||||
@@ -22,7 +22,9 @@
|
|||||||
@change="load"
|
@change="load"
|
||||||
/>
|
/>
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button type="primary" v-if="canEdit" @click="showForm = true">新增交易</el-button>
|
<PermissionAction action="imp.transaction">
|
||||||
|
<el-button type="primary" @click="showForm = true">新增交易</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
<el-card>
|
<el-card>
|
||||||
@@ -55,6 +57,8 @@ import { fetchImpTransactions } from "../api/impTransactions";
|
|||||||
import { fetchImpBatches } from "../api/impBatches";
|
import { fetchImpBatches } from "../api/impBatches";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
|
import ImpTransactionForm from "../components/ImpTransactionForm.vue";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -70,10 +74,8 @@ const loading = ref(false);
|
|||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const batches = ref<any[]>([]);
|
const batches = ref<any[]>([]);
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
const canEdit = computed(() => can("imp.transaction"));
|
||||||
return role === "ADMIN" || role === "PM" || role === "IMP";
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadBatches = async () => {
|
const loadBatches = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<h3>Issue 详情</h3>
|
<h3>Issue 详情</h3>
|
||||||
<p>{{ issue.title }}</p>
|
<p>{{ issue.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
<el-button v-if="canEdit" type="danger" size="small" @click="closeIssue">关闭</el-button>
|
<PermissionAction action="issue.close">
|
||||||
|
<el-button type="danger" size="small" @click="closeIssue">关闭</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
<el-descriptions :column="2" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="类别">{{ issue.category }}</el-descriptions-item>
|
<el-descriptions-item label="类别">{{ issue.category }}</el-descriptions-item>
|
||||||
@@ -38,20 +40,18 @@ import { ElMessage, ElMessageBox } from "element-plus";
|
|||||||
import { fetchIssues, updateIssue } from "../api/issues";
|
import { fetchIssues, updateIssue } from "../api/issues";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
import CommentList from "../components/CommentList.vue";
|
import CommentList from "../components/CommentList.vue";
|
||||||
import AttachmentList from "../components/AttachmentList.vue";
|
import AttachmentList from "../components/AttachmentList.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const issue = ref<any | null>(null);
|
const issue = ref<any | null>(null);
|
||||||
const studyId = computed(() => study.currentStudy?.id || "");
|
const studyId = computed(() => study.currentStudy?.id || "");
|
||||||
|
const { can } = usePermission();
|
||||||
const canEdit = computed(() => {
|
const canEdit = computed(() => can("issue.close"));
|
||||||
const role = auth.user?.role;
|
|
||||||
return role === "ADMIN" || role === "PM" || role === "PV";
|
|
||||||
});
|
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<el-card class="mb-12">
|
<el-card class="mb-12">
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<h3>里程碑</h3>
|
<h3>里程碑</h3>
|
||||||
<el-button type="primary" v-if="canEdit" @click="openCreate">新增里程碑</el-button>
|
<PermissionAction action="milestone.create">
|
||||||
|
<el-button type="primary" @click="openCreate">新增里程碑</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -15,9 +17,11 @@
|
|||||||
<el-table-column prop="actual_date" label="实际日期" width="140" />
|
<el-table-column prop="actual_date" label="实际日期" width="140" />
|
||||||
<el-table-column prop="status" label="状态" width="140" />
|
<el-table-column prop="status" label="状态" width="140" />
|
||||||
<el-table-column prop="owner_id" label="负责人" width="160" />
|
<el-table-column prop="owner_id" label="负责人" width="160" />
|
||||||
<el-table-column label="操作" width="120" v-if="canEdit">
|
<el-table-column label="操作" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
|
<PermissionAction action="milestone.create">
|
||||||
|
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -28,23 +32,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones } from "../api/milestones";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
import MilestoneForm from "../components/MilestoneForm.vue";
|
import MilestoneForm from "../components/MilestoneForm.vue";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
const auth = useAuthStore();
|
const { can } = usePermission();
|
||||||
|
|
||||||
const milestones = ref<any[]>([]);
|
const milestones = ref<any[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editingMilestone = ref<any | null>(null);
|
const editingMilestone = ref<any | null>(null);
|
||||||
|
|
||||||
const canEdit = computed(() => auth.user?.role === "ADMIN" || auth.user?.role === "PM");
|
|
||||||
|
|
||||||
const loadMilestones = async () => {
|
const loadMilestones = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
|
|||||||
@@ -4,14 +4,20 @@
|
|||||||
<h3>受试者 {{ subject.subject_no }}</h3>
|
<h3>受试者 {{ subject.subject_no }}</h3>
|
||||||
<p>中心:{{ subject.site_id }}</p>
|
<p>中心:{{ subject.site_id }}</p>
|
||||||
<p>状态:{{ subject.status }}</p>
|
<p>状态:{{ subject.status }}</p>
|
||||||
<div v-if="canEdit" class="actions">
|
<div class="actions">
|
||||||
<el-button size="small" type="primary" @click="setStatus('ENROLLED')">标记入组</el-button>
|
<PermissionAction action="subject.enroll">
|
||||||
<el-button size="small" type="success" @click="setStatus('COMPLETED')">标记完成</el-button>
|
<el-button size="small" type="primary" @click="setStatus('ENROLLED')">标记入组</el-button>
|
||||||
<el-button size="small" type="danger" @click="setStatus('DROPPED')">标记脱落</el-button>
|
</PermissionAction>
|
||||||
|
<PermissionAction action="subject.complete">
|
||||||
|
<el-button size="small" type="success" @click="setStatus('COMPLETED')">标记完成</el-button>
|
||||||
|
</PermissionAction>
|
||||||
|
<PermissionAction action="subject.drop">
|
||||||
|
<el-button size="small" type="danger" @click="setStatus('DROPPED')">标记脱落</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<VisitTable :visits="visits" :subject-id="subject.id" :can-edit="canEdit" :on-refresh="loadVisits" />
|
<VisitTable :visits="visits" :subject-id="subject.id" :can-edit="canVisitEdit" :on-refresh="loadVisits" />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="page">
|
<div v-else class="page">
|
||||||
<el-skeleton rows="3" animated />
|
<el-skeleton rows="3" animated />
|
||||||
@@ -26,6 +32,8 @@ import { fetchSubjects, updateSubject } from "../api/subjects";
|
|||||||
import { fetchVisits } from "../api/visits";
|
import { fetchVisits } from "../api/visits";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
import VisitTable from "../components/VisitTable.vue";
|
import VisitTable from "../components/VisitTable.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -35,10 +43,8 @@ const auth = useAuthStore();
|
|||||||
const subject = ref<any | null>(null);
|
const subject = ref<any | null>(null);
|
||||||
const visits = ref<any[]>([]);
|
const visits = ref<any[]>([]);
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
const canVisitEdit = computed(() => can("subject.enroll"));
|
||||||
return role === "ADMIN" || role === "PM" || role === "CRA";
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadSubject = async () => {
|
const loadSubject = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
<el-input v-model="filters.assignee_id" placeholder="负责人ID" style="width: 180px" @change="loadTasks" />
|
<el-input v-model="filters.assignee_id" placeholder="负责人ID" style="width: 180px" @change="loadTasks" />
|
||||||
<div class="spacer" />
|
<div class="spacer" />
|
||||||
<el-button type="primary" v-if="canEdit" @click="openCreate">新增任务</el-button>
|
<PermissionAction action="task.create">
|
||||||
|
<el-button type="primary" @click="openCreate">新增任务</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
@@ -22,9 +24,11 @@
|
|||||||
<el-table-column prop="priority" label="优先级" width="100" />
|
<el-table-column prop="priority" label="优先级" width="100" />
|
||||||
<el-table-column prop="status" label="状态" width="120" />
|
<el-table-column prop="status" label="状态" width="120" />
|
||||||
<el-table-column prop="due_date" label="截止日期" width="140" />
|
<el-table-column prop="due_date" label="截止日期" width="140" />
|
||||||
<el-table-column label="操作" width="120" v-if="canEdit">
|
<el-table-column label="操作" width="120">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
|
<PermissionAction action="task.edit">
|
||||||
|
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||||
|
</PermissionAction>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -43,12 +47,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
import { fetchTasks } from "../api/tasks";
|
import { fetchTasks } from "../api/tasks";
|
||||||
import { fetchMilestones } from "../api/milestones";
|
import { fetchMilestones } from "../api/milestones";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import PermissionAction from "../components/PermissionAction.vue";
|
||||||
|
import { usePermission } from "../utils/permission";
|
||||||
import TaskForm from "../components/TaskForm.vue";
|
import TaskForm from "../components/TaskForm.vue";
|
||||||
|
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -72,11 +78,7 @@ const filters = ref({
|
|||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
const editingTask = ref<any | null>(null);
|
const editingTask = ref<any | null>(null);
|
||||||
|
|
||||||
const canEdit = computed(() => {
|
const { can } = usePermission();
|
||||||
const role = auth.user?.role;
|
|
||||||
// 简化:ADMIN/PM 可编辑
|
|
||||||
return role === "ADMIN" || role === "PM";
|
|
||||||
});
|
|
||||||
|
|
||||||
const loadMilestones = async () => {
|
const loadMilestones = async () => {
|
||||||
if (!study.currentStudy) return;
|
if (!study.currentStudy) return;
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user