Step F6:AE & 风险/问题管理(PV)

This commit is contained in:
Cheng Zhou
2025-12-16 22:17:26 +08:00
parent e047756b9c
commit abb5b0a107
42 changed files with 779 additions and 3 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ from app.schemas.ae import AECreate, AERead, AEUpdate
router = APIRouter() router = APIRouter()
ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"} ALLOWED_CREATE_ROLES = {"PM", "CRA", "PV"}
ALLOWED_UPDATE_ROLES = {"PM", "PV"} ALLOWED_UPDATE_ROLES = {"PM", "PV", "CRA"}
async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID):
+11
View File
@@ -0,0 +1,11 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchAes = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/aes/`, { params });
export const createAe = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/aes/`, payload);
export const updateAe = (studyId: string, aeId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/aes/${aeId}`, payload);
+12
View File
@@ -0,0 +1,12 @@
import { apiGet, apiPost } from "./axios";
export const fetchAttachments = (studyId: string, entityType: string, entityId: string) =>
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`);
export const uploadAttachment = (studyId: string, entityType: string, entityId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
return apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/attachments`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
};
+11
View File
@@ -0,0 +1,11 @@
import { apiGet, apiPost } from "./axios";
export const fetchComments = (studyId: string, entityType: string, entityId: string) =>
apiGet(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`);
export const createComment = (
studyId: string,
entityType: string,
entityId: string,
payload: Record<string, any>
) => apiPost(`/api/v1/studies/${studyId}/${entityType}/${entityId}/comments`, payload);
+11
View File
@@ -0,0 +1,11 @@
import { apiGet, apiPost, apiPatch } from "./axios";
import type { ApiListResponse } from "../types/api";
export const fetchIssues = (studyId: string, params?: Record<string, any>) =>
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/issues/`, { params });
export const createIssue = (studyId: string, payload: Record<string, any>) =>
apiPost(`/api/v1/studies/${studyId}/issues/`, payload);
export const updateIssue = (studyId: string, issueId: string, payload: Record<string, any>) =>
apiPatch(`/api/v1/studies/${studyId}/issues/${issueId}`, payload);
@@ -0,0 +1,84 @@
<template>
<el-card class="attachment-card">
<template #header>
<div class="header">
<span>附件</span>
<el-upload
v-if="canUpload"
:http-request="onUpload"
:show-file-list="false"
:auto-upload="false"
:limit="1"
>
<el-button size="small" type="primary">上传</el-button>
</el-upload>
</div>
</template>
<el-table :data="attachments" v-loading="loading" style="width: 100%">
<el-table-column prop="filename" label="文件名" />
<el-table-column prop="uploaded_by" label="上传人" width="160" />
<el-table-column prop="uploaded_at" label="上传时间" width="180" />
<el-table-column label="操作" width="120">
<template #default="scope">
<el-link type="primary" :href="scope.row.file_path" target="_blank">下载</el-link>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchAttachments, uploadAttachment } from "../api/attachments";
import { useStudyStore } from "../store/study";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canUpload?: boolean;
}
const props = defineProps<Props>();
const study = useStudyStore();
const attachments = ref<any[]>([]);
const loading = ref(false);
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchAttachments(props.studyId, props.entityType, props.entityId);
attachments.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "附件加载失败");
} finally {
loading.value = false;
}
};
const onUpload = async (options: any) => {
if (!study.currentStudy) return;
loading.value = true;
try {
await uploadAttachment(study.currentStudy.id, props.entityType, props.entityId, options.file as File);
ElMessage.success("上传成功");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "上传失败");
} finally {
loading.value = false;
}
};
onMounted(() => load());
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>
+101
View File
@@ -0,0 +1,101 @@
<template>
<el-card class="comment-card">
<template #header>
<div class="header">
<span>评论</span>
<el-button v-if="canComment" type="primary" size="small" @click="showInput = !showInput">新增</el-button>
</div>
</template>
<div v-if="showInput" class="input-area">
<el-input v-model="newComment" type="textarea" rows="3" placeholder="输入评论" />
<div class="actions">
<el-button size="small" @click="showInput = false">取消</el-button>
<el-button size="small" type="primary" :loading="loading" @click="submit">提交</el-button>
</div>
</div>
<el-timeline>
<el-timeline-item v-for="c in comments" :key="c.id" :timestamp="c.created_at">
<div class="comment-item">
<strong>{{ c.created_by }}</strong>
<p>{{ c.content }}</p>
</div>
</el-timeline-item>
</el-timeline>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { fetchComments, createComment } from "../api/comments";
import { useStudyStore } from "../store/study";
interface Props {
studyId: string;
entityType: string;
entityId: string;
canComment?: boolean;
}
const props = defineProps<Props>();
const comments = ref<any[]>([]);
const loading = ref(false);
const newComment = ref("");
const showInput = ref(false);
const study = useStudyStore();
const load = async () => {
if (!props.studyId) return;
loading.value = true;
try {
const { data } = await fetchComments(props.studyId, props.entityType, props.entityId);
comments.value = data.items || data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "评论加载失败");
} finally {
loading.value = false;
}
};
const submit = async () => {
if (!newComment.value.trim()) {
ElMessage.warning("请输入评论");
return;
}
if (!study.currentStudy) return;
loading.value = true;
try {
await createComment(study.currentStudy.id, props.entityType, props.entityId, { content: newComment.value });
ElMessage.success("提交成功");
newComment.value = "";
showInput.value = false;
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
loading.value = false;
}
};
onMounted(() => load());
</script>
<style scoped>
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.input-area {
margin-bottom: 12px;
}
.actions {
margin-top: 8px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.comment-item p {
margin: 4px 0 0;
}
</style>
+128
View File
@@ -0,0 +1,128 @@
<template>
<el-dialog :title="isEdit ? '编辑问题' : '新增问题'" v-model="visible" width="600px" @close="onClose">
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="form.category" placeholder="请选择">
<el-option v-for="c in categories" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<el-form-item label="等级" prop="level">
<el-select v-model="form.level" placeholder="请选择">
<el-option v-for="l in levels" :key="l" :label="l" :value="l" />
</el-select>
</el-form-item>
<el-form-item label="截止日期" prop="due_date">
<el-date-picker v-model="form.due_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="onClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { createIssue, updateIssue } from "../api/issues";
import { useStudyStore } from "../store/study";
interface Props {
modelValue: boolean;
issue?: Record<string, any>;
}
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue", "success"]);
const visible = computed({
get: () => props.modelValue,
set: (v: boolean) => emit("update:modelValue", v),
});
const formRef = ref<FormInstance>();
const submitting = ref(false);
const study = useStudyStore();
const categories = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"];
const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
const form = reactive({
title: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
const rules: FormRules = {
title: [{ required: true, message: "请输入标题", trigger: "blur" }],
category: [{ required: true, message: "请选择类别", trigger: "change" }],
level: [{ required: true, message: "请选择等级", trigger: "change" }],
};
const isEdit = computed(() => !!props.issue);
watch(
() => props.issue,
(val) => {
if (val) {
Object.assign(form, {
title: val.title || "",
category: val.category || "ISSUE",
level: val.level || "MEDIUM",
due_date: val.due_date || "",
description: val.description || "",
});
} else {
Object.assign(form, {
title: "",
category: "ISSUE",
level: "MEDIUM",
due_date: "",
description: "",
});
}
},
{ immediate: true }
);
const onClose = () => {
visible.value = false;
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!study.currentStudy) {
ElMessage.error("未选择项目");
return;
}
submitting.value = true;
try {
const payload = { ...form };
if (isEdit.value && props.issue) {
await updateIssue(study.currentStudy.id, props.issue.id, payload);
} else {
await createIssue(study.currentStudy.id, payload);
}
ElMessage.success("提交成功");
emit("success");
onClose();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "提交失败");
} finally {
submitting.value = false;
}
});
};
</script>
+2
View File
@@ -20,6 +20,8 @@
<el-menu-item index="/study/milestones">里程碑</el-menu-item> <el-menu-item index="/study/milestones">里程碑</el-menu-item>
<el-menu-item index="/study/tasks">任务</el-menu-item> <el-menu-item index="/study/tasks">任务</el-menu-item>
<el-menu-item index="/study/subjects">受试者</el-menu-item> <el-menu-item index="/study/subjects">受试者</el-menu-item>
<el-menu-item index="/study/aes">AE</el-menu-item>
<el-menu-item index="/study/issues">风险/问题</el-menu-item>
</el-menu> </el-menu>
</el-aside> </el-aside>
<el-main> <el-main>
+21
View File
@@ -11,10 +11,13 @@ import Milestones from "../views/Milestones.vue";
import Subjects from "../views/Subjects.vue"; import Subjects from "../views/Subjects.vue";
import SubjectDetail from "../views/SubjectDetail.vue"; import SubjectDetail from "../views/SubjectDetail.vue";
import Aes from "../views/Aes.vue"; import Aes from "../views/Aes.vue";
import AeDetail from "../views/AeDetail.vue";
import DataQueries from "../views/DataQueries.vue"; import DataQueries from "../views/DataQueries.vue";
import Imp from "../views/Imp.vue"; import Imp from "../views/Imp.vue";
import Finance from "../views/Finance.vue"; import Finance from "../views/Finance.vue";
import Faq from "../views/Faq.vue"; import Faq from "../views/Faq.vue";
import Issues from "../views/Issues.vue";
import IssueDetail from "../views/IssueDetail.vue";
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
@@ -70,12 +73,30 @@ const routes: RouteRecordRaw[] = [
component: Aes, component: Aes,
meta: { title: "AE", requiresStudy: true }, meta: { title: "AE", requiresStudy: true },
}, },
{
path: "study/aes/:aeId",
name: "StudyAeDetail",
component: AeDetail,
meta: { title: "AE详情", requiresStudy: true },
},
{ {
path: "study/data-queries", path: "study/data-queries",
name: "StudyDataQueries", name: "StudyDataQueries",
component: DataQueries, component: DataQueries,
meta: { title: "数据问题", requiresStudy: true }, meta: { title: "数据问题", requiresStudy: true },
}, },
{
path: "study/issues",
name: "StudyIssues",
component: Issues,
meta: { title: "风险/问题", requiresStudy: true },
},
{
path: "study/issues/:issueId",
name: "StudyIssueDetail",
component: IssueDetail,
meta: { title: "风险详情", requiresStudy: true },
},
{ {
path: "study/imp", path: "study/imp",
name: "StudyImp", name: "StudyImp",
+148 -2
View File
@@ -1,13 +1,159 @@
<template> <template>
<div class="page"> <div class="page">
<el-card><h2>不良事件</h2></el-card> <el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadAes">
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
</el-select>
<el-select v-model="filters.seriousness" placeholder="严重性" clearable @change="loadAes">
<el-option label="SERIOUS" value="SERIOUS" />
<el-option label="NON_SERIOUS" value="NON_SERIOUS" />
</el-select>
<el-switch v-model="filters.overdue" active-text="仅逾期" @change="loadAes" />
<div class="spacer" />
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建 AE</el-button>
</div>
</el-card>
<el-card>
<el-table :data="aes" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-table-column prop="term" label="事件" />
<el-table-column prop="subject_id" label="受试者ID" width="200" />
<el-table-column prop="seriousness" label="严重性" width="140" />
<el-table-column prop="onset_date" label="发生日期" width="140" />
<el-table-column prop="report_due_date" label="报告截止" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<AeForm v-model="showForm" :subjects="subjects" @success="loadAes" />
</div> </div>
</template> </template>
<script setup lang="ts"></script> <script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchAes } from "../api/aes";
import { fetchSubjects } from "../api/subjects";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import AeForm from "../components/AeForm.vue";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const aes = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const subjects = ref<any[]>([]);
const statuses = ["NEW", "FOLLOW_UP", "CLOSED"];
const filters = ref({
status: "",
seriousness: "",
overdue: false,
});
const showForm = ref(false);
const canCreate = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "CRA" || role === "PV";
});
const loadAes = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status = filters.value.status;
if (filters.value.seriousness) params.seriousness = filters.value.seriousness;
if (filters.value.overdue) params.overdue = true;
const { data } = await fetchAes(study.currentStudy.id, params);
const mapOverdue = (list: any[]) =>
list.map((item) => ({
...item,
is_overdue:
item.is_overdue ??
(item.report_due_date && item.status !== "CLOSED" && new Date() > new Date(item.report_due_date)),
}));
if (Array.isArray(data)) {
aes.value = mapOverdue(data);
total.value = data.length;
} else {
const items = data.items || [];
aes.value = mapOverdue(items);
total.value = data.total || aes.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "AE 加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadAes();
};
const loadSubjects = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 500 });
subjects.value = data.items || data || [];
} catch {
// ignore
}
};
const goDetail = (row: any) => {
router.push(`/study/aes/${row.id}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
await loadSubjects();
loadAes();
});
</script>
<style scoped> <style scoped>
.page { .page {
padding: 16px; padding: 16px;
} }
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style> </style>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="page" v-if="issue">
<el-card class="mb-12">
<div class="header">
<div>
<h3>Issue 详情</h3>
<p>{{ issue.title }}</p>
</div>
<el-button v-if="canEdit" type="danger" size="small" @click="closeIssue">关闭</el-button>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item label="类别">{{ issue.category }}</el-descriptions-item>
<el-descriptions-item label="等级">{{ issue.level }}</el-descriptions-item>
<el-descriptions-item label="截止日期">{{ issue.due_date }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ issue.status }}</el-descriptions-item>
<el-descriptions-item label="描述">{{ issue.description }}</el-descriptions-item>
</el-descriptions>
</el-card>
<el-tabs>
<el-tab-pane label="评论">
<CommentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-comment="true" />
</el-tab-pane>
<el-tab-pane label="附件">
<AttachmentList :study-id="studyId" entity-type="issues" :entity-id="issue.id" :can-upload="canEdit" />
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="page">
<el-skeleton rows="4" animated />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { fetchIssues, updateIssue } from "../api/issues";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import CommentList from "../components/CommentList.vue";
import AttachmentList from "../components/AttachmentList.vue";
const route = useRoute();
const study = useStudyStore();
const auth = useAuthStore();
const issue = ref<any | null>(null);
const studyId = computed(() => study.currentStudy?.id || "");
const canEdit = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "PV";
});
const load = async () => {
if (!study.currentStudy) return;
try {
const { data } = await fetchIssues(study.currentStudy.id, { skip: 0, limit: 500 });
const list = data.items || data || [];
issue.value = list.find((item: any) => item.id === route.params.issueId);
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "Issue 加载失败");
}
};
const closeIssue = async () => {
await ElMessageBox.confirm("确认将 Issue 关闭?", "提示");
if (!study.currentStudy || !issue.value) return;
try {
await updateIssue(study.currentStudy.id, issue.value.id, { status: "CLOSED" });
ElMessage.success("已关闭");
load();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "更新失败");
}
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
load();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+150
View File
@@ -0,0 +1,150 @@
<template>
<div class="page">
<el-card class="mb-12">
<div class="filters">
<el-select v-model="filters.status" placeholder="状态" clearable @change="loadIssues">
<el-option v-for="s in statuses" :key="s" :label="s" :value="s" />
</el-select>
<el-select v-model="filters.level" placeholder="等级" clearable @change="loadIssues">
<el-option v-for="l in levels" :key="l" :label="l" :value="l" />
</el-select>
<el-select v-model="filters.category" placeholder="类别" clearable @change="loadIssues">
<el-option v-for="c in categories" :key="c" :label="c" :value="c" />
</el-select>
<el-switch v-model="filters.overdue" active-text="仅逾期" @change="loadIssues" />
<div class="spacer" />
<el-button type="primary" v-if="canCreate" @click="showForm = true">新建 Issue</el-button>
</div>
</el-card>
<el-card>
<el-table :data="issues" v-loading="loading" style="width: 100%" @row-click="goDetail">
<el-table-column prop="title" label="标题" />
<el-table-column prop="level" label="等级" width="120" />
<el-table-column prop="category" label="类别" width="140" />
<el-table-column prop="due_date" label="截止日期" width="140" />
<el-table-column prop="status" label="状态" width="120">
<template #default="scope">
<el-tag :type="scope.row.is_overdue ? 'danger' : 'info'">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="prev, pager, next"
:page-size="pageSize"
:current-page="page"
:total="total"
@current-change="onPageChange"
/>
</el-card>
<IssueForm v-model="showForm" @success="loadIssues" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { fetchIssues } from "../api/issues";
import { useStudyStore } from "../store/study";
import { useAuthStore } from "../store/auth";
import IssueForm from "../components/IssueForm.vue";
const study = useStudyStore();
const auth = useAuthStore();
const router = useRouter();
const issues = ref<any[]>([]);
const loading = ref(false);
const total = ref(0);
const page = ref(1);
const pageSize = 10;
const statuses = ["OPEN", "MITIGATING", "CLOSED"];
const levels = ["LOW", "MEDIUM", "HIGH", "CRITICAL"];
const categories = ["RISK", "ISSUE", "PROTOCOL_DEVIATION"];
const filters = ref({
status: "",
level: "",
category: "",
overdue: false,
});
const showForm = ref(false);
const canCreate = computed(() => {
const role = auth.user?.role;
return role === "ADMIN" || role === "PM" || role === "PV";
});
const loadIssues = async () => {
if (!study.currentStudy) return;
loading.value = true;
try {
const params: Record<string, any> = { skip: (page.value - 1) * pageSize, limit: pageSize };
if (filters.value.status) params.status = filters.value.status;
if (filters.value.level) params.level = filters.value.level;
if (filters.value.category) params.category = filters.value.category;
if (filters.value.overdue) params.overdue = true;
const { data } = await fetchIssues(study.currentStudy.id, params);
const mapOverdue = (list: any[]) =>
list.map((item) => ({
...item,
is_overdue:
item.is_overdue ?? (item.due_date && item.status !== "CLOSED" && new Date() > new Date(item.due_date)),
}));
if (Array.isArray(data)) {
issues.value = mapOverdue(data);
total.value = data.length;
} else {
const list = data.items || [];
issues.value = mapOverdue(list);
total.value = data.total || issues.value.length;
}
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || "Issue 加载失败");
} finally {
loading.value = false;
}
};
const onPageChange = (p: number) => {
page.value = p;
loadIssues();
};
const goDetail = (row: any) => {
router.push(`/study/issues/${row.id}`);
};
onMounted(async () => {
if (!auth.user && auth.token) {
await auth.fetchMe().catch(() => {});
}
loadIssues();
});
</script>
<style scoped>
.page {
padding: 16px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.spacer {
flex: 1;
}
.pagination {
margin-top: 12px;
text-align: right;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
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.
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.