工作台界面-初版
This commit is contained in:
@@ -0,0 +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};
|
||||
@@ -0,0 +1 @@
|
||||
.todo-card[data-v-addac169]{min-height:200px}.section-header[data-v-addac169]{font-weight:600;margin-bottom:8px}.input-row[data-v-addac169]{display:flex;gap:8px;margin-bottom:8px}.todo-list[data-v-addac169]{display:flex;flex-direction:column;gap:6px}.todo-item[data-v-addac169]{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;border:1px solid #ebeef5;border-radius:6px}.empty[data-v-addac169]{color:#888}
|
||||
@@ -0,0 +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};
|
||||
@@ -0,0 +1 @@
|
||||
.section-card[data-v-63b635fd]{min-height:220px}.section-header[data-v-63b635fd]{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.list[data-v-63b635fd]{display:flex;flex-direction:column;gap:8px}.row[data-v-63b635fd]{padding:8px;border:1px solid #ebeef5;border-radius:6px;cursor:pointer}.row[data-v-63b635fd]:hover{background:#f5f7fa}.title[data-v-63b635fd]{font-weight:600}.meta[data-v-63b635fd]{margin-top:4px;display:flex;align-items:center;gap:8px;color:#666;font-size:12px}.sub[data-v-63b635fd],.empty[data-v-63b635fd]{color:#888}
|
||||
-72
File diff suppressed because one or more lines are too long
+73
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CTMS</title>
|
||||
<script type="module" crossorigin src="/assets/index-Bpcf_Y-a.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Dh_vTCuZ.css">
|
||||
<script type="module" crossorigin src="/assets/index-CgfVP8Bj.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CjHGg_qM.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
// @ts-nocheck
|
||||
export {};
|
||||
declare global {
|
||||
var __VLS_PROPS_FALLBACK: Record<string, unknown>;
|
||||
|
||||
const __VLS_directiveBindingRestFields: { instance: null, oldValue: null, modifiers: any, dir: any };
|
||||
const __VLS_placeholder: any;
|
||||
const __VLS_intrinsics: import('vue/jsx-runtime').JSX.IntrinsicElements;
|
||||
|
||||
type __VLS_Elements = __VLS_SpreadMerge<SVGElementTagNameMap, HTMLElementTagNameMap>;
|
||||
type __VLS_GlobalComponents = import('vue').GlobalComponents;
|
||||
type __VLS_GlobalDirectives = import('vue').GlobalDirectives;
|
||||
type __VLS_IsAny<T> = 0 extends 1 & T ? true : false;
|
||||
type __VLS_PickNotAny<A, B> = __VLS_IsAny<A> extends true ? B : A;
|
||||
type __VLS_SpreadMerge<A, B> = Omit<A, keyof B> & B;
|
||||
type __VLS_WithComponent<N0 extends string, LocalComponents, Self, N1 extends string, N2 extends string = N1, N3 extends string = N1> =
|
||||
N1 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N1] } :
|
||||
N2 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N2] } :
|
||||
N3 extends keyof LocalComponents ? { [K in N0]: LocalComponents[N3] } :
|
||||
Self extends object ? { [K in N0]: Self } :
|
||||
N1 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N1] } :
|
||||
N2 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N2] } :
|
||||
N3 extends keyof __VLS_GlobalComponents ? { [K in N0]: __VLS_GlobalComponents[N3] } :
|
||||
{};
|
||||
type __VLS_FunctionalComponentCtx<T, K> = __VLS_PickNotAny<'__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
||||
? K extends { __ctx?: infer Ctx } ? NonNullable<Ctx> : never : any
|
||||
, T extends (props: any, ctx: infer Ctx) => any ? Ctx : any
|
||||
>;
|
||||
type __VLS_FunctionalComponentProps<T, K> = '__ctx' extends keyof __VLS_PickNotAny<K, {}>
|
||||
? K extends { __ctx?: { props?: infer P } } ? NonNullable<P> : never
|
||||
: T extends (props: infer P, ...args: any) => any ? P
|
||||
: {};
|
||||
type __VLS_FunctionalComponent<T> = (props: (T extends { $props: infer Props } ? Props : {}) & Record<string, unknown>, ctx?: any) => import('vue/jsx-runtime').JSX.Element & {
|
||||
__ctx?: {
|
||||
attrs?: any;
|
||||
slots?: T extends { $slots: infer Slots } ? Slots : Record<string, any>;
|
||||
emit?: T extends { $emit: infer Emit } ? Emit : {};
|
||||
props?: typeof props;
|
||||
expose?: (exposed: T) => void;
|
||||
};
|
||||
};
|
||||
type __VLS_IsFunction<T, K> = K extends keyof T
|
||||
? __VLS_IsAny<T[K]> extends false
|
||||
? unknown extends T[K]
|
||||
? false
|
||||
: true
|
||||
: false
|
||||
: false;
|
||||
type __VLS_NormalizeComponentEvent<
|
||||
Props,
|
||||
Emits,
|
||||
onEvent extends keyof Props,
|
||||
Event extends keyof Emits,
|
||||
CamelizedEvent extends keyof Emits,
|
||||
> = __VLS_IsFunction<Props, onEvent> extends true
|
||||
? Props
|
||||
: __VLS_IsFunction<Emits, Event> extends true
|
||||
? { [K in onEvent]?: Emits[Event] }
|
||||
: __VLS_IsFunction<Emits, CamelizedEvent> extends true
|
||||
? { [K in onEvent]?: Emits[CamelizedEvent] }
|
||||
: Props;
|
||||
// fix https://github.com/vuejs/language-tools/issues/926
|
||||
type __VLS_UnionToIntersection<U> = (U extends unknown ? (arg: U) => unknown : never) extends ((arg: infer P) => unknown) ? P : never;
|
||||
type __VLS_OverloadUnionInner<T, U = unknown> = U & T extends (...args: infer A) => infer R
|
||||
? U extends T
|
||||
? never
|
||||
: __VLS_OverloadUnionInner<T, Pick<T, keyof T> & U & ((...args: A) => R)> | ((...args: A) => R)
|
||||
: never;
|
||||
type __VLS_OverloadUnion<T> = Exclude<
|
||||
__VLS_OverloadUnionInner<(() => never) & T>,
|
||||
T extends () => never ? never : () => never
|
||||
>;
|
||||
type __VLS_ConstructorOverloads<T> = __VLS_OverloadUnion<T> extends infer F
|
||||
? F extends (event: infer E, ...args: infer A) => any
|
||||
? { [K in E & string]: (...args: A) => void; }
|
||||
: never
|
||||
: never;
|
||||
type __VLS_NormalizeEmits<T> = __VLS_PrettifyGlobal<
|
||||
__VLS_UnionToIntersection<
|
||||
__VLS_ConstructorOverloads<T> & {
|
||||
[K in keyof T]: T[K] extends any[] ? { (...args: T[K]): void } : never
|
||||
}
|
||||
>
|
||||
>;
|
||||
type __VLS_EmitsToProps<T> = __VLS_PrettifyGlobal<{
|
||||
[K in string & keyof T as `on${Capitalize<K>}`]?:
|
||||
(...args: T[K] extends (...args: infer P) => any ? P : T[K] extends null ? any[] : never) => any;
|
||||
}>;
|
||||
type __VLS_ResolveEmits<
|
||||
Comp,
|
||||
Emits,
|
||||
TypeEmits = {},
|
||||
NormalizedEmits = __VLS_NormalizeEmits<Emits> extends infer E ? string extends keyof E ? {} : E : never,
|
||||
> = __VLS_SpreadMerge<NormalizedEmits, TypeEmits>;
|
||||
type __VLS_ResolveDirectives<T> = {
|
||||
[K in keyof T & string as `v${Capitalize<K>}`]: T[K];
|
||||
};
|
||||
type __VLS_PrettifyGlobal<T> = (T extends any ? { [K in keyof T]: T[K]; } : { [K in keyof T as K]: T[K]; }) & {};
|
||||
type __VLS_UseTemplateRef<T> = Readonly<import('vue').ShallowRef<T | null>>;
|
||||
type __VLS_ProxyRefs<T> = import('vue').ShallowUnwrapRef<T>;
|
||||
|
||||
function __VLS_getVForSourceType<T extends number | string | any[] | Iterable<any>>(source: T): [
|
||||
item: T extends number ? number
|
||||
: T extends string ? string
|
||||
: T extends any[] ? T[number]
|
||||
: T extends Iterable<infer T1> ? T1
|
||||
: any,
|
||||
index: number,
|
||||
][];
|
||||
function __VLS_getVForSourceType<T>(source: T): [
|
||||
item: T[keyof T],
|
||||
key: keyof T,
|
||||
index: number,
|
||||
][];
|
||||
function __VLS_getSlotParameters<S, D extends S>(slot: S, decl?: D):
|
||||
D extends (...args: infer P) => any ? P : any[];
|
||||
function __VLS_asFunctionalDirective<T>(dir: T): T extends import('vue').ObjectDirective
|
||||
? NonNullable<T['created' | 'beforeMount' | 'mounted' | 'beforeUpdate' | 'updated' | 'beforeUnmount' | 'unmounted']>
|
||||
: T extends (...args: any) => any
|
||||
? T
|
||||
: (arg1: unknown, arg2: unknown, arg3: unknown, arg4: unknown) => void;
|
||||
function __VLS_asFunctionalComponent<T, K = T extends new (...args: any) => any ? InstanceType<T> : unknown>(t: T, instance?: K):
|
||||
T extends new (...args: any) => any ? __VLS_FunctionalComponent<K>
|
||||
: T extends () => any ? (props: {}, ctx?: any) => ReturnType<T>
|
||||
: T extends (...args: any) => any ? T
|
||||
: __VLS_FunctionalComponent<{}>;
|
||||
function __VLS_functionalComponentArgsRest<T extends (...args: any) => any>(t: T): 2 extends Parameters<T>['length'] ? [any] : [];
|
||||
function __VLS_asFunctionalElement<T>(tag: T, endTag?: T): (attrs: T & Record<string, unknown>) => void;
|
||||
function __VLS_asFunctionalSlot<S>(slot: S): S extends () => infer R ? (props: {}) => R : NonNullable<S>;
|
||||
function __VLS_tryAsConstant<const T>(t: T): T;
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
<el-container>
|
||||
<el-aside width="220px" class="aside" v-if="isAdmin || study.currentStudy">
|
||||
<el-menu router :default-active="$route.path">
|
||||
<el-menu-item v-if="!isAdmin" index="/workbench">我的工作台</el-menu-item>
|
||||
<template v-if="isAdmin">
|
||||
<el-menu-item index="/admin/users">账号治理</el-menu-item>
|
||||
<el-menu-item index="/admin/projects">项目治理</el-menu-item>
|
||||
|
||||
@@ -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