工作台界面-初版
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-card class="mb-12">
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>我的工作台</h2>
|
||||
<div class="sub">基于当前角色,为你聚合最需要处理的事项</div>
|
||||
</div>
|
||||
<el-tag type="info">{{ roleLabel }}</el-tag>
|
||||
</div>
|
||||
<div v-if="!currentStudyId" class="empty-tip">
|
||||
请选择一个项目后查看工作台内容
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<template v-if="currentStudyId">
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<SectionCard
|
||||
title="今日待办"
|
||||
:items="todayList"
|
||||
:loading="loading"
|
||||
empty-text="暂无需要你处理的事项"
|
||||
@more="goMore(todayMorePath)"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<SectionCard
|
||||
title="已逾期"
|
||||
:items="overdueList"
|
||||
:loading="loading"
|
||||
empty-text="暂无需要你处理的事项"
|
||||
@more="goMore(overdueMorePath)"
|
||||
/>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12" class="mt-12">
|
||||
<el-col :span="12">
|
||||
<SectionCard
|
||||
title="需要我处理的事项"
|
||||
:items="actionList"
|
||||
:loading="loading"
|
||||
empty-text="暂无需要你处理的事项"
|
||||
@more="goMore(actionMorePath)"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-card shadow="never" class="section-card">
|
||||
<div class="section-header">
|
||||
<span>快捷入口</span>
|
||||
<el-button type="primary" link @click="goMore('/study/home')">进入项目</el-button>
|
||||
</div>
|
||||
<div class="quick-list">
|
||||
<el-button v-for="qa in quickActions" :key="qa.path" type="primary" plain size="small" @click="goMore(qa.path)">
|
||||
{{ qa.label }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div v-if="!quickActions.length" class="empty">{{ emptyText }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row v-if="showPersonalTodo" class="mt-12">
|
||||
<el-col :span="24">
|
||||
<PersonalTodo :storage-key="todoStorageKey" />
|
||||
</el-col>
|
||||
</el-row>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, defineAsyncComponent } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import { fetchTasks } from "../../api/tasks";
|
||||
import { fetchAes } from "../../api/aes";
|
||||
import { fetchFinanceItems } from "../../api/finance";
|
||||
import { fetchImpTransactions } from "../../api/impTransactions";
|
||||
|
||||
interface WorkItem {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
status?: string;
|
||||
overdue?: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const SectionCard = defineAsyncComponent(() => import("./components/SectionCard.vue"));
|
||||
const PersonalTodo = defineAsyncComponent(() => import("./components/PersonalTodo.vue"));
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const todayList = ref<WorkItem[]>([]);
|
||||
const overdueList = ref<WorkItem[]>([]);
|
||||
const actionList = ref<WorkItem[]>([]);
|
||||
|
||||
const currentStudyId = computed(() => study.currentStudy?.id || "");
|
||||
const todayStr = computed(() => new Date().toISOString().slice(0, 10));
|
||||
const roleLabel = computed(() => auth.user?.role || "-");
|
||||
const emptyText = "暂无需要你处理的事项";
|
||||
const showPersonalTodo = computed(() => role.value !== "ADMIN");
|
||||
const todoStorageKey = computed(() => `workbench_todos_${auth.user?.id || "guest"}`);
|
||||
|
||||
const role = computed(() => auth.user?.role || "");
|
||||
const quickActions = computed(() => {
|
||||
if (!currentStudyId.value) return [];
|
||||
if (role.value === "CRA")
|
||||
return [
|
||||
{ label: "新建 AE", path: "/study/aes" },
|
||||
{ label: "我的任务", path: "/study/tasks" },
|
||||
];
|
||||
if (role.value === "PM")
|
||||
return [
|
||||
{ label: "派发任务", path: "/study/tasks" },
|
||||
{ label: "发公告", path: "/study/faq" },
|
||||
{ label: "查看 CRA 列表", path: "/study/tasks" },
|
||||
];
|
||||
if (role.value === "PV") return [{ label: "AE 列表", path: "/study/aes" }];
|
||||
if (role.value === "IMP") return [{ label: "药品台账", path: "/study/imp/transactions" }];
|
||||
return [];
|
||||
});
|
||||
|
||||
const todayMorePath = computed(() => (role.value === "CRA" ? "/study/tasks" : "/study/tasks"));
|
||||
const overdueMorePath = computed(() => (role.value === "CRA" ? "/study/tasks" : "/study/tasks"));
|
||||
const actionMorePath = computed(() => {
|
||||
if (role.value === "IMP") return "/study/imp/transactions";
|
||||
if (role.value === "PV") return "/study/aes";
|
||||
if (role.value === "PM" || role.value === "ADMIN") return "/study/finance";
|
||||
return "/study/aes";
|
||||
});
|
||||
|
||||
const isOverdue = (d?: string | null) => {
|
||||
if (!d) return false;
|
||||
return new Date(d) < new Date(todayStr.value);
|
||||
};
|
||||
|
||||
const toTaskItem = (task: any): WorkItem => ({
|
||||
title: task.title || "任务",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: task.due_date || task.status,
|
||||
overdue: isOverdue(task.due_date),
|
||||
path: "/study/tasks",
|
||||
});
|
||||
|
||||
const toAeItem = (ae: any): WorkItem => ({
|
||||
title: ae.term || "不良事件",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: ae.status,
|
||||
overdue: false,
|
||||
path: `/study/aes/${ae.id}`,
|
||||
});
|
||||
|
||||
const toFinanceItem = (fi: any): WorkItem => ({
|
||||
title: fi.title || "费用",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: fi.status,
|
||||
overdue: false,
|
||||
path: `/study/finance/${fi.id}`,
|
||||
});
|
||||
|
||||
const toImpItem = (tx: any): WorkItem => ({
|
||||
title: tx.type || "药品记录",
|
||||
subtitle: study.currentStudy?.name || "",
|
||||
status: tx.status || "",
|
||||
overdue: false,
|
||||
path: "/study/imp/transactions",
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
if (!currentStudyId.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const studyId = currentStudyId.value;
|
||||
const tasksReq = fetchTasks(studyId, { limit: 200, assignee_id: auth.user?.id });
|
||||
const aesReq = fetchAes(studyId, { status: "NEW", limit: 50 });
|
||||
const financeReq = fetchFinanceItems(studyId, { status: "SUBMITTED", limit: 50 });
|
||||
const impReq = fetchImpTransactions ? fetchImpTransactions(studyId, { limit: 50 }) : Promise.resolve({ data: [] });
|
||||
|
||||
const [tasksRes, aesRes, financeRes, impRes] = await Promise.allSettled([tasksReq, aesReq, financeReq, impReq]);
|
||||
const tasks = tasksRes.status === "fulfilled" ? (tasksRes.value.data.items || tasksRes.value.data || []) : [];
|
||||
const aes = aesRes.status === "fulfilled" ? (aesRes.value.data.items || aesRes.value.data || []) : [];
|
||||
const finances =
|
||||
financeRes.status === "fulfilled" ? (financeRes.value.data.items || financeRes.value.data || []) : [];
|
||||
const impTx = impRes.status === "fulfilled" ? (impRes.value.data.items || impRes.value.data || []) : [];
|
||||
|
||||
buildSections(tasks, aes, finances, impTx);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "工作台数据加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildSections = (tasks: any[], aes: any[], finances: any[], impTx: any[]) => {
|
||||
todayList.value = [];
|
||||
overdueList.value = [];
|
||||
actionList.value = [];
|
||||
|
||||
if (role.value === "CRA") {
|
||||
todayList.value = tasks.filter((t) => t.due_date === todayStr.value).slice(0, 5).map(toTaskItem);
|
||||
overdueList.value = tasks.filter((t) => isOverdue(t.due_date)).slice(0, 5).map(toTaskItem);
|
||||
actionList.value = aes.slice(0, 5).map(toAeItem);
|
||||
} else if (role.value === "PM") {
|
||||
todayList.value = tasks.filter((t) => t.due_date === todayStr.value).slice(0, 5).map(toTaskItem);
|
||||
overdueList.value = tasks.filter((t) => isOverdue(t.due_date)).slice(0, 5).map(toTaskItem);
|
||||
const financePending = finances.slice(0, 3).map(toFinanceItem);
|
||||
const aePending = aes.slice(0, 2).map(toAeItem);
|
||||
actionList.value = [...financePending, ...aePending].slice(0, 5);
|
||||
} else if (role.value === "PV") {
|
||||
const aeOpen = aes.filter((a) => a.status !== "CLOSED");
|
||||
todayList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
overdueList.value = aeOpen.filter((a) => isOverdue(a.updated_at)).slice(0, 5).map(toAeItem);
|
||||
actionList.value = aeOpen.slice(0, 5).map(toAeItem);
|
||||
} else if (role.value === "IMP") {
|
||||
actionList.value = impTx.slice(0, 5).map(toImpItem);
|
||||
todayList.value = [];
|
||||
overdueList.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const goMore = (path: string) => {
|
||||
if (!path) return;
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!auth.user && auth.token) {
|
||||
await auth.fetchMe().catch(() => {});
|
||||
}
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
padding: 16px;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.sub {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
.empty-tip {
|
||||
padding: 12px 0;
|
||||
color: #888;
|
||||
}
|
||||
.section-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.quick-list {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.empty {
|
||||
color: #888;
|
||||
}
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="todo-card">
|
||||
<div class="section-header">
|
||||
<span>个人待办(本地)</span>
|
||||
</div>
|
||||
<div class="input-row">
|
||||
<el-input v-model="input" placeholder="记录一个备忘..." @keyup.enter.native="add" />
|
||||
<el-button type="primary" @click="add">添加</el-button>
|
||||
</div>
|
||||
<div v-if="todos.length" class="todo-list">
|
||||
<div v-for="item in todos" :key="item.id" class="todo-item">
|
||||
<el-checkbox v-model="item.done" @change="save">{{ item.text }}</el-checkbox>
|
||||
<el-button link type="danger" size="small" @click="remove(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">暂无待办,可添加你的个人备忘</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{ storageKey: string | undefined }>();
|
||||
|
||||
interface TodoItem {
|
||||
id: string;
|
||||
text: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const input = ref("");
|
||||
const todos = ref<TodoItem[]>([]);
|
||||
|
||||
const load = () => {
|
||||
if (!props.storageKey) return;
|
||||
const raw = localStorage.getItem(props.storageKey);
|
||||
if (raw) {
|
||||
try {
|
||||
todos.value = JSON.parse(raw) || [];
|
||||
} catch {
|
||||
todos.value = [];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!props.storageKey) return;
|
||||
localStorage.setItem(props.storageKey, JSON.stringify(todos.value));
|
||||
};
|
||||
|
||||
const add = () => {
|
||||
const text = input.value.trim();
|
||||
if (!text) return;
|
||||
todos.value.unshift({
|
||||
id: crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(),
|
||||
text,
|
||||
done: false,
|
||||
});
|
||||
input.value = "";
|
||||
save();
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
todos.value = todos.value.filter((t) => t.id !== id);
|
||||
save();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => load(),
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.todo-card {
|
||||
min-height: 200px;
|
||||
}
|
||||
.section-header {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.todo-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.todo-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.empty {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="section-card">
|
||||
<div class="section-header">
|
||||
<span>{{ title }}</span>
|
||||
<el-button v-if="!loading" type="primary" link @click="$emit('more')">查看更多</el-button>
|
||||
</div>
|
||||
<el-skeleton :loading="loading" animated :rows="3">
|
||||
<template #default>
|
||||
<div v-if="items && items.length" class="list">
|
||||
<div v-for="item in items.slice(0, 5)" :key="item.title + item.path" class="row" @click="go(item.path)">
|
||||
<div class="title">{{ item.title }}</div>
|
||||
<div class="meta">
|
||||
<el-tag size="small" :type="item.overdue ? 'danger' : 'info'">{{ item.status || "待处理" }}</el-tag>
|
||||
<span class="sub">{{ item.subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty">{{ emptyText }}</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
items: any[];
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["more"]);
|
||||
const router = useRouter();
|
||||
|
||||
const go = (path: string) => {
|
||||
if (!path) return;
|
||||
router.push(path);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
min-height: 220px;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.row {
|
||||
padding: 8px;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row:hover {
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sub {
|
||||
color: #888;
|
||||
}
|
||||
.empty {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user