列表内高频操作直达-初稿(ID问题未解决)

This commit is contained in:
Cheng Zhou
2025-12-17 23:16:46 +08:00
parent ad4fa6ae7a
commit 7d2ae5658d
2840 changed files with 592 additions and 2020 deletions
@@ -1 +1 @@
import{d as K,i as g,j as C,k as U,r,c as S,o as s,w as d,a as p,g as c,b as u,l as b,f as m,F as I,h as N,t as B,_ as D}from"./index-CgfVP8Bj.js";const T={class:"input-row"},F={key:0,class:"todo-list"},J={key:1,class:"empty"},O=K({__name:"PersonalTodo",props:{storageKey:{}},setup(k){const n=k,l=g(""),t=g([]),_=()=>{if(!n.storageKey)return;const o=localStorage.getItem(n.storageKey);if(o)try{t.value=JSON.parse(o)||[]}catch{t.value=[]}},i=()=>{n.storageKey&&localStorage.setItem(n.storageKey,JSON.stringify(t.value))},v=()=>{const o=l.value.trim();o&&(t.value.unshift({id:crypto.randomUUID?crypto.randomUUID():Date.now().toString(),text:o,done:!1}),l.value="",i())},V=o=>{t.value=t.value.filter(e=>e.id!==o),i()};return C(()=>n.storageKey,()=>_(),{immediate:!0}),U(()=>{_()}),(o,e)=>{const h=r("el-input"),y=r("el-button"),x=r("el-checkbox"),w=r("el-card");return s(),S(w,{shadow:"never",class:"todo-card"},{default:d(()=>[e[3]||(e[3]=p("div",{class:"section-header"},[p("span",null,"个人待办(本地)")],-1)),p("div",T,[u(h,{modelValue:l.value,"onUpdate:modelValue":e[0]||(e[0]=a=>l.value=a),placeholder:"记录一个备忘...",onKeyup:b(v,["enter","native"])},null,8,["modelValue"]),u(y,{type:"primary",onClick:v},{default:d(()=>[...e[1]||(e[1]=[m("添加",-1)])]),_:1})]),t.value.length?(s(),c("div",F,[(s(!0),c(I,null,N(t.value,a=>(s(),c("div",{key:a.id,class:"todo-item"},[u(x,{modelValue:a.done,"onUpdate:modelValue":f=>a.done=f,onChange:i},{default:d(()=>[m(B(a.text),1)]),_:2},1032,["modelValue","onUpdate:modelValue"]),u(y,{link:"",type:"danger",size:"small",onClick:f=>V(a.id)},{default:d(()=>[...e[2]||(e[2]=[m("删除",-1)])]),_:1},8,["onClick"])]))),128))])):(s(),c("div",J,"暂无待办,可添加你的个人备忘"))]),_:1})}}}),j=D(O,[["__scopeId","data-v-addac169"]]);export{j as default}; import{d as K,i as g,j as C,k as U,r,c as S,o as s,w as d,a as p,g as c,b as u,l as b,f as m,F as I,h as N,t as B,_ as D}from"./index-BgaJtbRI.js";const T={class:"input-row"},F={key:0,class:"todo-list"},J={key:1,class:"empty"},O=K({__name:"PersonalTodo",props:{storageKey:{}},setup(k){const n=k,l=g(""),t=g([]),_=()=>{if(!n.storageKey)return;const o=localStorage.getItem(n.storageKey);if(o)try{t.value=JSON.parse(o)||[]}catch{t.value=[]}},i=()=>{n.storageKey&&localStorage.setItem(n.storageKey,JSON.stringify(t.value))},v=()=>{const o=l.value.trim();o&&(t.value.unshift({id:crypto.randomUUID?crypto.randomUUID():Date.now().toString(),text:o,done:!1}),l.value="",i())},V=o=>{t.value=t.value.filter(e=>e.id!==o),i()};return C(()=>n.storageKey,()=>_(),{immediate:!0}),U(()=>{_()}),(o,e)=>{const h=r("el-input"),y=r("el-button"),x=r("el-checkbox"),w=r("el-card");return s(),S(w,{shadow:"never",class:"todo-card"},{default:d(()=>[e[3]||(e[3]=p("div",{class:"section-header"},[p("span",null,"个人待办(本地)")],-1)),p("div",T,[u(h,{modelValue:l.value,"onUpdate:modelValue":e[0]||(e[0]=a=>l.value=a),placeholder:"记录一个备忘...",onKeyup:b(v,["enter","native"])},null,8,["modelValue"]),u(y,{type:"primary",onClick:v},{default:d(()=>[...e[1]||(e[1]=[m("添加",-1)])]),_:1})]),t.value.length?(s(),c("div",F,[(s(!0),c(I,null,N(t.value,a=>(s(),c("div",{key:a.id,class:"todo-item"},[u(x,{modelValue:a.done,"onUpdate:modelValue":f=>a.done=f,onChange:i},{default:d(()=>[m(B(a.text),1)]),_:2},1032,["modelValue","onUpdate:modelValue"]),u(y,{link:"",type:"danger",size:"small",onClick:f=>V(a.id)},{default:d(()=>[...e[2]||(e[2]=[m("删除",-1)])]),_:1},8,["onClick"])]))),128))])):(s(),c("div",J,"暂无待办,可添加你的个人备忘"))]),_:1})}}}),j=D(O,[["__scopeId","data-v-addac169"]]);export{j as default};
@@ -1 +1 @@
import{d as v,u as h,r as i,c as r,o as s,w as c,a as o,b as _,e as C,t as n,f as m,g as d,F as b,h as x,_ as w}from"./index-CgfVP8Bj.js";const B={class:"section-header"},N={key:0,class:"list"},V=["onClick"],S={class:"title"},T={class:"meta"},$={class:"sub"},F={key:1,class:"empty"},z=v({__name:"SectionCard",props:{title:{},items:{},loading:{type:Boolean},emptyText:{}},emits:["more"],setup(t,{emit:D}){const u=h(),p=a=>{a&&u.push(a)};return(a,l)=>{const f=i("el-button"),g=i("el-tag"),k=i("el-skeleton"),y=i("el-card");return s(),r(y,{shadow:"never",class:"section-card"},{default:c(()=>[o("div",B,[o("span",null,n(t.title),1),t.loading?C("",!0):(s(),r(f,{key:0,type:"primary",link:"",onClick:l[0]||(l[0]=e=>a.$emit("more"))},{default:c(()=>[...l[1]||(l[1]=[m("查看更多",-1)])]),_:1}))]),_(k,{loading:t.loading,animated:"",rows:3},{default:c(()=>[t.items&&t.items.length?(s(),d("div",N,[(s(!0),d(b,null,x(t.items.slice(0,5),e=>(s(),d("div",{key:e.title+e.path,class:"row",onClick:E=>p(e.path)},[o("div",S,n(e.title),1),o("div",T,[_(g,{size:"small",type:e.overdue?"danger":"info"},{default:c(()=>[m(n(e.status||"待处理"),1)]),_:2},1032,["type"]),o("span",$,n(e.subtitle),1)])],8,V))),128))])):(s(),d("div",F,n(t.emptyText),1))]),_:1},8,["loading"])]),_:1})}}}),L=w(z,[["__scopeId","data-v-63b635fd"]]);export{L as default}; import{d as v,u as h,r as i,c as r,o as s,w as c,a as o,b as _,e as C,t as n,f as m,g as d,F as b,h as x,_ as w}from"./index-BgaJtbRI.js";const B={class:"section-header"},N={key:0,class:"list"},V=["onClick"],S={class:"title"},T={class:"meta"},$={class:"sub"},F={key:1,class:"empty"},z=v({__name:"SectionCard",props:{title:{},items:{},loading:{type:Boolean},emptyText:{}},emits:["more"],setup(t,{emit:D}){const u=h(),p=a=>{a&&u.push(a)};return(a,l)=>{const f=i("el-button"),g=i("el-tag"),k=i("el-skeleton"),y=i("el-card");return s(),r(y,{shadow:"never",class:"section-card"},{default:c(()=>[o("div",B,[o("span",null,n(t.title),1),t.loading?C("",!0):(s(),r(f,{key:0,type:"primary",link:"",onClick:l[0]||(l[0]=e=>a.$emit("more"))},{default:c(()=>[...l[1]||(l[1]=[m("查看更多",-1)])]),_:1}))]),_(k,{loading:t.loading,animated:"",rows:3},{default:c(()=>[t.items&&t.items.length?(s(),d("div",N,[(s(!0),d(b,null,x(t.items.slice(0,5),e=>(s(),d("div",{key:e.title+e.path,class:"row",onClick:E=>p(e.path)},[o("div",S,n(e.title),1),o("div",T,[_(g,{size:"small",type:e.overdue?"danger":"info"},{default:c(()=>[m(n(e.status||"待处理"),1)]),_:2},1032,["type"]),o("span",$,n(e.subtitle),1)])],8,V))),128))])):(s(),d("div",F,n(t.emptyText),1))]),_:1},8,["loading"])]),_:1})}}}),L=w(z,[["__scopeId","data-v-63b635fd"]]);export{L as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CTMS</title> <title>CTMS</title>
<script type="module" crossorigin src="/assets/index-CgfVP8Bj.js"></script> <script type="module" crossorigin src="/assets/index-BgaJtbRI.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjHGg_qM.css"> <link rel="stylesheet" crossorigin href="/assets/index-OHP6VeHj.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+25
View File
@@ -67,4 +67,29 @@ export const auditDict: Record<
actionText: "导出了项目审计日志", actionText: "导出了项目审计日志",
targetLabel: "审计日志", targetLabel: "审计日志",
}, },
TASK_COMPLETE: {
label: "任务完成",
actionText: "完成了任务",
targetLabel: "任务",
},
FINANCE_APPROVED: {
label: "费用审批",
actionText: "审批通过费用",
targetLabel: "费用",
},
FINANCE_REJECTED: {
label: "费用驳回",
actionText: "驳回了费用",
targetLabel: "费用",
},
IMP_CONFIRMED: {
label: "药品出入库确认",
actionText: "确认了药品出入库记录",
targetLabel: "药品交易",
},
QUERY_RESOLVED: {
label: "数据问题解决",
actionText: "标记数据问题已解决",
targetLabel: "数据问题",
},
}; };
+7 -5
View File
@@ -21,8 +21,8 @@ const PERMISSIONS: Record<string, string[]> = {
"faq.edit": ["ADMIN", "PM"], "faq.edit": ["ADMIN", "PM"],
"dataquery.edit": ["ADMIN", "PM", "CRA"], "dataquery.edit": ["ADMIN", "PM", "CRA"],
"project.members.manage": ["ADMIN", "PM"], "project.members.manage": ["ADMIN", "PM"],
"site.manage": ["ADMIN"], "site.manage": ["ADMIN", "PM"],
"site.cra.bind": ["ADMIN"], "site.cra.bind": ["ADMIN", "PM"],
}; };
const REASONS: Record<string, string> = { const REASONS: Record<string, string> = {
@@ -39,8 +39,8 @@ const REASONS: Record<string, string> = {
"faq.edit": "仅 PM/ADMIN 可维护 FAQ", "faq.edit": "仅 PM/ADMIN 可维护 FAQ",
"dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题", "dataquery.edit": "仅 PM/CRA/ADMIN 可编辑数据问题",
"project.members.manage": "仅 ADMIN / PM 可调整项目成员", "project.members.manage": "仅 ADMIN / PM 可调整项目成员",
"site.manage": "仅 ADMIN 可管理中心", "site.manage": "仅 ADMIN / PM 可管理中心",
"site.cra.bind": "仅 ADMIN 可绑定 CRA", "site.cra.bind": "仅 ADMIN / PM 可绑定 CRA",
}; };
export const usePermission = () => { export const usePermission = () => {
@@ -48,7 +48,9 @@ export const usePermission = () => {
const study = useStudyStore(); const study = useStudyStore();
const userRole = computed(() => auth.user?.role || null); const userRole = computed(() => auth.user?.role || null);
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || null); const projectRole = computed(
() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || auth.user?.role || null
);
const can = (action: string): boolean => { const can = (action: string): boolean => {
if (userRole.value === "ADMIN") return true; if (userRole.value === "ADMIN") return true;
+81 -2
View File
@@ -34,6 +34,18 @@
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag> <el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button
v-if="showClose(scope.row)"
type="danger"
size="small"
@click.stop="onClose(scope.row)"
>
关闭 AE
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-pagination <el-pagination
class="pagination" class="pagination"
@@ -52,12 +64,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchAes } from "../api/aes"; import { fetchAes, updateAe } from "../api/aes";
import { fetchSubjects } from "../api/subjects"; import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import AeForm from "../components/AeForm.vue"; import AeForm from "../components/AeForm.vue";
import { aeMachine, getAvailableActions } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -155,6 +170,70 @@ const seriousnessLabel = (v: string) =>
NON_SERIOUS: "非严重", NON_SERIOUS: "非严重",
}[v] || v); }[v] || v);
const showClose = (row: any) => {
const actions = getAvailableActions(aeMachine, row.status);
const has = actions.some((a) => a.key === "close");
if (!has) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "ae.close",
stateMachine: aeMachine,
currentState: row.status,
actionKey: "close",
});
return decision.allowed;
};
const onClose = async (row: any) => {
if (!study.currentStudy) return;
const action = getAvailableActions(aeMachine, row.status).find((a) => a.key === "close");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "ae.close",
stateMachine: aeMachine,
currentState: row.status,
actionKey: "close",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.term,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认关闭?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
try {
await updateAe(study.currentStudy.id, row.id, { status: action?.to || "CLOSED" });
ElMessage.success("已关闭");
logAudit("AE_CLOSED", {
targetId: row.id,
targetName: row.term,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "normal",
});
loadAes();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("AE_CLOSED", {
targetId: row.id,
targetName: row.term,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => { onMounted(async () => {
if (!auth.user && auth.token) { if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
+96 -2
View File
@@ -36,6 +36,18 @@
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag> <el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ statusLabel(scope.row.status) }}</el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button
v-if="showResolve(scope.row)"
type="primary"
size="small"
@click.stop="onResolve(scope.row)"
>
标记已解决
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-pagination <el-pagination
class="pagination" class="pagination"
@@ -54,12 +66,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchDataQueries } from "../api/dataQueries"; import { fetchDataQueries, updateDataQuery } from "../api/dataQueries";
import { fetchSubjects } from "../api/subjects"; import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study"; import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth"; import { useAuthStore } from "../store/auth";
import DataQueryForm from "../components/DataQueryForm.vue"; import DataQueryForm from "../components/DataQueryForm.vue";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
import { getAvailableActions, type StateMachine } from "../state-machine";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -81,6 +96,24 @@ const subjectMap = computed(() =>
const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"]; const statuses = ["OPEN", "IN_PROGRESS", "ANSWERED", "CLOSED"];
const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"]; const categories = ["MISSING", "INCONSISTENT", "OUT_OF_RANGE", "OTHER"];
const priorities = ["LOW", "MEDIUM", "HIGH"]; const priorities = ["LOW", "MEDIUM", "HIGH"];
const dataQueryMachine: StateMachine = {
states: {
OPEN: { value: "OPEN", label: "待处理" },
IN_PROGRESS: { value: "IN_PROGRESS", label: "处理中" },
ANSWERED: { value: "ANSWERED", label: "已回复" },
CLOSED: { value: "CLOSED", label: "已关闭", isTerminal: true },
},
actions: {
resolve: {
key: "resolve",
label: "标记已解决",
from: ["OPEN", "IN_PROGRESS", "ANSWERED"],
to: "CLOSED",
confirm: true,
confirmText: "确认标记为已解决?",
},
},
};
const filters = ref({ const filters = ref({
status: "", status: "",
@@ -157,6 +190,67 @@ const priorityLabel = (v: string) =>
HIGH: "高", HIGH: "高",
}[v] || v); }[v] || v);
const showResolve = (row: any) => {
const actions = getAvailableActions(dataQueryMachine, row.status).filter((a) => a.key === "resolve");
if (!actions.length) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "dataquery.edit",
stateMachine: dataQueryMachine,
currentState: row.status,
actionKey: "resolve",
});
return decision.allowed;
};
const onResolve = async (row: any) => {
if (!study.currentStudy) return;
const action = getAvailableActions(dataQueryMachine, row.status).find((a) => a.key === "resolve");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "dataquery.edit",
stateMachine: dataQueryMachine,
currentState: row.status,
actionKey: "resolve",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认操作?", "提示").catch(() => null);
if (!ok) return;
}
try {
await updateDataQuery(study.currentStudy.id, row.id, { status: action?.to || "CLOSED" });
ElMessage.success("已标记");
logAudit("QUERY_RESOLVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "normal",
});
loadQueries();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("QUERY_RESOLVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "CLOSED" },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => { onMounted(async () => {
if (!auth.user && auth.token) { if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
+135 -2
View File
@@ -53,6 +53,26 @@
<el-table-column prop="submitted_at" label="提交时间" width="160" /> <el-table-column prop="submitted_at" label="提交时间" width="160" />
<el-table-column prop="approved_at" label="审批时间" width="160" /> <el-table-column prop="approved_at" label="审批时间" width="160" />
<el-table-column prop="paid_at" label="支付时间" width="160" /> <el-table-column prop="paid_at" label="支付时间" width="160" />
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button
v-if="showAction(scope.row, 'approve')"
type="primary"
size="small"
@click.stop="onApprove(scope.row)"
>
审批
</el-button>
<el-button
v-if="showAction(scope.row, 'reject')"
type="danger"
size="small"
@click.stop="onReject(scope.row)"
>
驳回
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-pagination <el-pagination
class="pagination" class="pagination"
@@ -71,14 +91,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import FinanceSummary from "../components/FinanceSummary.vue"; import FinanceSummary from "../components/FinanceSummary.vue";
import FinanceForm from "../components/FinanceForm.vue"; import FinanceForm from "../components/FinanceForm.vue";
import { fetchFinanceItems, fetchFinanceSummary } from "../api/finance"; import { fetchFinanceItems, fetchFinanceSummary, changeFinanceStatus } 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 PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import { financeMachine, getAvailableActions } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -105,6 +128,10 @@ const showForm = ref(false);
const { can } = usePermission(); const { can } = usePermission();
const canCreate = computed(() => can("finance.create")); const canCreate = computed(() => can("finance.create"));
const permissionMap: Record<string, string> = {
approve: "finance.approve",
reject: "finance.approve",
};
const formatAmount = (num?: number | string) => { const formatAmount = (num?: number | string) => {
const n = Number(num); const n = Number(num);
@@ -144,6 +171,112 @@ const categoryLabel = (c?: string) =>
OTHER: "其他", OTHER: "其他",
}[c || ""] || c || ""); }[c || ""] || c || "");
const showAction = (row: any, key: string) => {
const actions = getAvailableActions(financeMachine, row.status).filter((a) => a.key === key);
if (!actions.length) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap[key],
stateMachine: financeMachine,
currentState: row.status,
actionKey: key,
});
return decision.allowed;
};
const doChange = async (row: any, status: string, reason?: string) => {
if (!study.currentStudy) return false;
try {
await changeFinanceStatus(study.currentStudy.id, row.id, { status, reject_reason: reason });
return true;
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
return false;
}
};
const onApprove = async (row: any) => {
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "approve");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap.approve,
stateMachine: financeMachine,
currentState: row.status,
actionKey: "approve",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认审批通过?", "提示").catch(() => null);
if (!ok) return;
}
const ok = await doChange(row, action?.to || "APPROVED");
logAudit("FINANCE_APPROVED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "APPROVED" },
severity: ok ? "normal" : "warning",
});
if (ok) {
ElMessage.success("已审批");
loadList();
}
};
const onReject = async (row: any) => {
const action = getAvailableActions(financeMachine, row.status).find((a) => a.key === "reject");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: permissionMap.reject,
stateMachine: financeMachine,
currentState: row.status,
actionKey: "reject",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
if (action?.confirm) {
const ok = await ElMessageBox.confirm(action.confirmText || "确认驳回?", "提示", {
type: action.danger ? "warning" : "info",
}).catch(() => null);
if (!ok) return;
}
const reason = await ElMessageBox.prompt("请输入驳回原因", "驳回", {
confirmButtonText: "确定",
cancelButtonText: "取消",
}).catch(() => null);
if (!reason || !reason.value) return;
const ok = await doChange(row, action?.to || "REJECTED", reason.value);
logAudit("FINANCE_REJECTED", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "REJECTED" },
severity: ok ? "normal" : "warning",
reason: reason.value,
});
if (ok) {
ElMessage.success("已驳回");
loadList();
}
};
const loadSummary = async () => { const loadSummary = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
try { try {
+43 -1
View File
@@ -40,6 +40,18 @@
<el-table-column prop="subject_id" label="受试者" width="180" /> <el-table-column prop="subject_id" label="受试者" width="180" />
<el-table-column prop="quantity" label="数量" width="100" align="right" /> <el-table-column prop="quantity" label="数量" width="100" align="right" />
<el-table-column prop="reference" label="单号/备注" /> <el-table-column prop="reference" label="单号/备注" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button
v-if="canEdit"
type="primary"
size="small"
@click.stop="confirmTx(scope.row)"
>
确认
</el-button>
</template>
</el-table-column>
</el-table> </el-table>
<el-pagination <el-pagination
class="pagination" class="pagination"
@@ -56,7 +68,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from "vue"; import { computed, onMounted, ref } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage, ElMessageBox } from "element-plus";
import { fetchImpTransactions } from "../api/impTransactions"; 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";
@@ -64,6 +76,8 @@ import { useAuthStore } from "../store/auth";
import PermissionAction from "../components/PermissionAction.vue"; import PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import ImpTransactionForm from "../components/ImpTransactionForm.vue"; import ImpTransactionForm from "../components/ImpTransactionForm.vue";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
@@ -129,6 +143,34 @@ const txTypeLabel = (t: string) =>
TRANSFER_OUT: "调出", TRANSFER_OUT: "调出",
}[t] || t); }[t] || t);
const confirmTx = async (row: any) => {
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "imp.transaction",
actionKey: "confirm",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.reference,
reason: decision.reason,
severity: decision.severity,
});
return;
}
const ok = await ElMessageBox.confirm("确认该出入库记录?", "提示", { type: "info" }).catch(() => null);
if (!ok) return;
// 无需调用后端变更(无状态字段),仅记录审计并提示
logAudit("IMP_CONFIRMED", {
targetId: row.id,
targetName: row.reference,
severity: "normal",
});
ElMessage.success("已确认");
load();
};
onMounted(async () => { onMounted(async () => {
if (!auth.user && auth.token) { if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {}); await auth.fetchMe().catch(() => {});
+13 -1
View File
@@ -23,7 +23,11 @@
<el-card> <el-card>
<el-table :data="subjects" v-loading="loading" style="width: 100%" @row-click="goDetail"> <el-table :data="subjects" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-table-column prop="subject_no" label="受试者编号" /> <el-table-column prop="subject_no" label="受试者编号" />
<el-table-column prop="site_id" label="中心ID" /> <el-table-column prop="site_id" label="中心">
<template #default="scope">
{{ siteName(scope.row.site_id) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="120"> <el-table-column prop="status" label="状态" width="120">
<template #default="scope"> <template #default="scope">
<el-tag>{{ statusLabel(scope.row.status) }}</el-tag> <el-tag>{{ statusLabel(scope.row.status) }}</el-tag>
@@ -66,6 +70,12 @@ const loading = ref(false);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
const pageSize = 10; const pageSize = 10;
const siteMap = computed(() =>
sites.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.name || cur.id;
return acc;
}, {})
);
const statuses = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"]; const statuses = ["SCREENING", "ENROLLED", "COMPLETED", "DROPPED"];
@@ -140,6 +150,8 @@ const statusLabel = (v: string) =>
COMPLETED: "已完成", COMPLETED: "已完成",
DROPPED: "已脱落", DROPPED: "已脱落",
}[v] || v); }[v] || v);
const siteName = (id: string) => siteMap.value[id] || id || "-";
</script> </script>
<style scoped> <style scoped>
+114 -3
View File
@@ -24,7 +24,11 @@
{{ milestoneName(scope.row.milestone_id) }} {{ milestoneName(scope.row.milestone_id) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="assignee_id" label="负责人" width="160" /> <el-table-column prop="assignee_id" label="负责人" width="160">
<template #default="scope">
{{ assigneeName(scope.row.assignee_id) }}
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100"> <el-table-column prop="priority" label="优先级" width="100">
<template #default="scope"> <template #default="scope">
<el-tag :type="priorityColor(scope.row.priority)">{{ priorityLabel(scope.row.priority) }}</el-tag> <el-tag :type="priorityColor(scope.row.priority)">{{ priorityLabel(scope.row.priority) }}</el-tag>
@@ -36,11 +40,19 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="due_date" label="截止日期" width="140" /> <el-table-column prop="due_date" label="截止日期" width="140" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="200">
<template #default="scope"> <template #default="scope">
<PermissionAction action="task.edit"> <PermissionAction action="task.edit">
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button> <el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
</PermissionAction> </PermissionAction>
<el-button
v-if="showComplete(scope.row)"
type="success"
size="small"
@click.stop="onComplete(scope.row)"
>
完成
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -61,20 +73,27 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from "vue"; import { onMounted, ref, computed } from "vue";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { fetchTasks } from "../api/tasks"; import { fetchTasks, updateTask } from "../api/tasks";
import { fetchMilestones } from "../api/milestones"; import { fetchMilestones } from "../api/milestones";
import { listMembers } from "../api/members";
import { fetchUsers } from "../api/users";
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 PermissionAction from "../components/PermissionAction.vue";
import { usePermission } from "../utils/permission"; import { usePermission } from "../utils/permission";
import TaskForm from "../components/TaskForm.vue"; import TaskForm from "../components/TaskForm.vue";
import { getDictColor, getDictLabel, getDictOptions, statusDict, priorityDict } from "../dictionaries"; import { getDictColor, getDictLabel, getDictOptions, statusDict, priorityDict } from "../dictionaries";
import { getAvailableActions, taskMachine } from "../state-machine";
import { evaluateAction } from "../guards/actionGuard";
import { logAudit } from "../audit";
const study = useStudyStore(); const study = useStudyStore();
const auth = useAuthStore(); const auth = useAuthStore();
const tasks = ref<any[]>([]); const tasks = ref<any[]>([]);
const milestones = ref<any[]>([]); const milestones = ref<any[]>([]);
const members = ref<any[]>([]);
const users = ref<any[]>([]);
const loading = ref(false); const loading = ref(false);
const total = ref(0); const total = ref(0);
const page = ref(1); const page = ref(1);
@@ -98,6 +117,18 @@ const milestoneMap = computed(() =>
return acc; return acc;
}, {}) }, {})
); );
const memberMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.user_id] = cur.username || cur.user_id;
return acc;
}, {})
);
const userMap = computed(() =>
users.value.reduce<Record<string, string>>((acc, cur) => {
acc[cur.id] = cur.username || cur.id;
return acc;
}, {})
);
const loadMilestones = async () => { const loadMilestones = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
@@ -109,6 +140,25 @@ const loadMilestones = async () => {
} }
}; };
const loadMembers = async () => {
if (!study.currentStudy) return;
try {
const { data } = await listMembers(study.currentStudy.id);
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadUsers = async () => {
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadTasks = async () => { const loadTasks = async () => {
if (!study.currentStudy) return; if (!study.currentStudy) return;
loading.value = true; loading.value = true;
@@ -188,11 +238,72 @@ const priorityLabel = (v: string) => getDictLabel(priorityDict, v);
const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info"; const priorityColor = (v: string) => getDictColor(priorityDict, v) || "info";
const milestoneName = (id: string) => milestoneMap.value[id] || id || "-"; const milestoneName = (id: string) => milestoneMap.value[id] || id || "-";
const assigneeName = (id: string) => userMap.value[id] || memberMap.value[id] || id || "-";
const showComplete = (row: any) => {
const actions = getAvailableActions(taskMachine, row.status);
const hasComplete = actions.some((a) => a.key === "complete");
if (!hasComplete) return false;
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "task.edit",
stateMachine: taskMachine,
currentState: row.status,
actionKey: "complete",
});
return decision.allowed;
};
const onComplete = async (row: any) => {
if (!study.currentStudy) return;
const action = getAvailableActions(taskMachine, row.status).find((a) => a.key === "complete");
const decision = evaluateAction({
actorRole: auth.user?.role || null,
requiredPermission: "task.edit",
stateMachine: taskMachine,
currentState: row.status,
actionKey: "complete",
});
if (!decision.allowed) {
ElMessage.warning(decision.reason || "当前不可执行该操作");
logAudit(decision.auditType, {
targetId: row.id,
targetName: row.title,
reason: decision.reason,
severity: decision.severity,
});
return;
}
try {
await updateTask(study.currentStudy.id, row.id, { status: action?.to || "DONE" });
ElMessage.success("已完成");
logAudit("TASK_COMPLETE", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "DONE" },
severity: "normal",
});
loadTasks();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "操作失败");
logAudit("TASK_COMPLETE", {
targetId: row.id,
targetName: row.title,
before: { status: row.status },
after: { status: action?.to || "DONE" },
severity: "warning",
reason: e?.response?.data?.message,
});
}
};
onMounted(async () => { onMounted(async () => {
await ensureUser(); await ensureUser();
loadSavedFilters(); loadSavedFilters();
loadMilestones(); loadMilestones();
loadMembers();
loadUsers();
loadTasks(); loadTasks();
}); });
</script> </script>
-1
View File
@@ -1 +0,0 @@
15
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
View File
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
View File
View File
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.
Binary file not shown.
View File
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More