工作台界面-初版

This commit is contained in:
Cheng Zhou
2025-12-17 22:37:51 +08:00
parent c50ca05099
commit ad4fa6ae7a
13 changed files with 685 additions and 75 deletions
+1
View File
@@ -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};
+1
View File
@@ -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}
+1
View File
@@ -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};
+1
View File
@@ -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}
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 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
View File
@@ -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;
}
+1
View File
@@ -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>