diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 991c18ae..0508068e 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -1,52 +1,119 @@ diff --git a/frontend/src/main.ts b/frontend/src/main.ts index b2748e98..c791b970 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,6 +2,7 @@ import { createApp } from "vue"; import { createPinia } from "pinia"; import ElementPlus from "element-plus"; import "element-plus/dist/index.css"; +import "./styles/main.css"; import App from "./App.vue"; import router from "./router"; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css new file mode 100644 index 00000000..212373c0 --- /dev/null +++ b/frontend/src/styles/main.css @@ -0,0 +1,130 @@ +/** + * CTMS 全局样式系统 + * 基于 8px 网格系统与医疗专业色板 + */ + +:root { + /* 品牌色 - 医疗蓝 */ + --ctms-primary: #2f54eb; + --ctms-primary-hover: #597ef7; + --ctms-primary-active: #1d39c4; + --ctms-primary-light: #f0f5ff; + + /* 语义色 */ + --ctms-success: #52c41a; + --ctms-warning: #faad14; + --ctms-danger: #ff4d4f; + --ctms-info: #8c8c8c; + + /* 文字颜色 - 高层级到低层级 */ + --ctms-text-main: #262626; + --ctms-text-regular: #595959; + --ctms-text-secondary: #8c8c8c; + --ctms-text-disabled: #bfbfbf; + + /* 中性色 & 背景 */ + --ctms-bg-base: #f0f2f5; + --ctms-bg-card: #ffffff; + --ctms-border-color: #f0f0f0; + --ctms-border-color-hover: #d9d9d9; + + /* 间距 - 8px 网格 */ + --ctms-spacing-xs: 4px; + --ctms-spacing-sm: 8px; + --ctms-spacing-md: 16px; + --ctms-spacing-lg: 24px; + --ctms-spacing-xl: 32px; + + /* 圆角 & 阴影 */ + --ctms-radius: 4px; + --ctms-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.04); + --ctms-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +/* 全局重置 */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--ctms-bg-base); + color: var(--ctms-text-main); + -webkit-font-smoothing: antialiased; +} + +/* Element Plus 全局覆盖 */ + +/* 按钮美化 */ +.el-button { + border-radius: var(--ctms-radius); + font-weight: 500; + transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); +} + +.el-button--primary { + background-color: var(--ctms-primary); + border-color: var(--ctms-primary); +} + +.el-button--primary:hover { + background-color: var(--ctms-primary-hover); + border-color: var(--ctms-primary-hover); +} + +/* 卡片美化 */ +.el-card { + border: none; + box-shadow: var(--ctms-shadow-sm) !important; + border-radius: var(--ctms-radius); + margin-bottom: var(--ctms-spacing-md); +} + +.el-card__header { + padding: var(--ctms-spacing-md) var(--ctms-spacing-lg); + border-bottom: 1px solid var(--ctms-border-color); + font-weight: 600; + color: var(--ctms-text-main); +} + +.el-card__body { + padding: var(--ctms-spacing-lg); +} + +/* 表格美化 */ +.el-table { + color: var(--ctms-text-regular); +} + +.el-table th.el-table__cell { + background-color: #fafafa; + color: var(--ctms-text-main); + font-weight: 600; + height: 48px; +} + +.el-table td.el-table__cell { + padding: 12px 0; /* 增加行高 */ +} + +.el-table--enable-row-hover .el-table__row:hover > td.el-table__cell { + background-color: var(--ctms-primary-light); +} + +/* 表单美化 */ +.el-form-item__label { + font-weight: 500; + color: var(--ctms-text-regular); +} + +.el-input__inner { + border-radius: var(--ctms-radius); +} + +/* 标签美化 */ +.el-tag { + border-radius: 2px; + border: none; +} + +.el-tag--info { background-color: #f5f5f5; color: var(--ctms-text-secondary); } +.el-tag--success { background-color: #f6ffed; color: var(--ctms-success); border: 1px solid #b7eb8f; } +.el-tag--warning { background-color: #fffbe6; color: var(--ctms-warning); border: 1px solid #ffe58f; } +.el-tag--danger { background-color: #fff1f0; color: var(--ctms-danger); border: 1px solid #ffa39e; } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 3375ab4e..06bb417f 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -1,19 +1,58 @@ @@ -56,18 +95,100 @@ const onSubmit = async () => { diff --git a/frontend/src/views/SubjectDetail.vue b/frontend/src/views/SubjectDetail.vue index 3f801e58..0924c306 100644 --- a/frontend/src/views/SubjectDetail.vue +++ b/frontend/src/views/SubjectDetail.vue @@ -1,18 +1,31 @@ @@ -33,6 +58,7 @@ import { onMounted, ref, computed } from "vue"; import { useRoute } from "vue-router"; import { ElMessage, ElMessageBox } from "element-plus"; +import { Calendar } from "@element-plus/icons-vue"; import { fetchSubjects, updateSubject } from "../api/subjects"; import { fetchVisits } from "../api/visits"; import { fetchSites } from "../api/sites"; @@ -41,7 +67,7 @@ import { useAuthStore } from "../store/auth"; import PermissionAction from "../components/PermissionAction.vue"; import { usePermission } from "../utils/permission"; import VisitTable from "../components/VisitTable.vue"; -import { getAvailableActions, getStateColor, getStateLabel, subjectMachine } from "../state-machine"; +import { getAvailableActions, subjectMachine } from "../state-machine"; import type { ActionConfig } from "../state-machine"; import { statusDict, getDictLabel, getDictColor } from "../dictionaries"; import { evaluateAction } from "../guards/actionGuard"; @@ -62,7 +88,16 @@ const permissionMap: Record = { complete: "subject.complete", drop: "subject.drop", }; -const availableActions = computed(() => getAvailableActions(subjectMachine, subject.value?.status)); + +// 增加 primary 属性判断以便视觉区分 +const availableActions = computed(() => { + const actions = getAvailableActions(subjectMachine, subject.value?.status); + return actions.map((a: any) => ({ + ...a, + primary: a.key === 'enroll' || a.key === 'complete' + })); +}); + const stateLabel = (v?: string | null) => getDictLabel(statusDict, v || ""); const stateColor = (v?: string | null) => getDictColor(statusDict, v || "") || "info"; @@ -71,7 +106,8 @@ const loadSubject = async () => { try { const subjectId = route.params.subjectId as string; const { data } = await fetchSubjects(study.currentStudy.id, { skip: 0, limit: 1000 }); - const list = data.items || data || []; + // 兼容處理 data 結構,解決類型檢查問題 + const list = (data as any).items || (Array.isArray(data) ? data : []); subject.value = list.find((s: any) => s.id === subjectId) || null; } catch (e: any) { ElMessage.error(e?.response?.data?.message || "受试者加载失败"); @@ -82,7 +118,7 @@ const loadSites = async () => { if (!study.currentStudy) return; try { const { data } = await fetchSites(study.currentStudy.id, { limit: 500 }); - sites.value = (data as any).items || data || []; + sites.value = (data as any).items || (Array.isArray(data) ? data : []); } catch { sites.value = []; } @@ -98,7 +134,7 @@ const loadVisits = async () => { if (!study.currentStudy || !route.params.subjectId) return; try { const { data } = await fetchVisits(study.currentStudy.id, route.params.subjectId as string); - visits.value = data.items || data; + visits.value = (data as any).items || (Array.isArray(data) ? data : []); } catch (e: any) { ElMessage.error(e?.response?.data?.message || "访视加载失败"); } @@ -168,15 +204,115 @@ onMounted(async () => { diff --git a/frontend/src/views/Subjects.vue b/frontend/src/views/Subjects.vue index 8b0debee..0078f397 100644 --- a/frontend/src/views/Subjects.vue +++ b/frontend/src/views/Subjects.vue @@ -1,49 +1,97 @@