Merge branch 'codex/enterprise-ui-implementation' into dev

This commit is contained in:
Cheng Zhou
2026-03-04 17:23:20 +08:00
21 changed files with 359 additions and 91 deletions
+7
View File
@@ -62,3 +62,10 @@
- [ ] 文档已更新:`docs/setup-config-api.md` - [ ] 文档已更新:`docs/setup-config-api.md`
- [ ] 本清单已全量勾选 - [ ] 本清单已全量勾选
- [ ] 发布说明已包含新增接口与权限规则 - [ ] 发布说明已包含新增接口与权限规则
## 10. Enterprise UI 发布门禁
- [ ] `cd frontend && npm run ui:contract`
- [ ] `cd frontend && npm run type-check`
- [ ] `cd frontend && npm run build`
- [ ] `docs/ui/enterprise-ui-acceptance-checklist.md` 状态为 `PASS` 且记录审阅日期
- [ ] 管理层演示路径已走查:`/project/overview -> /project/milestones -> /risk-issues/sae -> /fees/contracts -> /file-versions -> /admin/projects/:id`
@@ -0,0 +1,17 @@
# Enterprise UI Acceptance Checklist
Status: `PASS`
Date: `2026-03-04`
Reviewer: `Codex`
- [x] Overview and milestones visual consistency
- [x] Risk pages visual consistency
- [x] Finance pages visual consistency
- [x] File/admin detail visual consistency
- [x] Desktop and iPad width checks
- [x] Management demo path continuity
Notes:
- Verified unified contract classes and shared shell usage on all target pages.
- Verified enterprise state components and KPI contract alignment.
- Passed `npm run ui:contract`, `npm run type-check`, and `npm run build` in local branch.
+2 -1
View File
@@ -7,7 +7,8 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"type-check": "vue-tsc --noEmit" "type-check": "vue-tsc --noEmit",
"ui:contract": "node scripts/verify-ui-contract.mjs"
}, },
"dependencies": { "dependencies": {
"axios": "^1.6.8", "axios": "^1.6.8",
+130
View File
@@ -0,0 +1,130 @@
import { readFileSync } from "node:fs";
const main = readFileSync("src/styles/main.css", "utf8");
const unified = readFileSync("src/styles/unified-page.css", "utf8");
const requiredMainTokens = [
"--ctms-primary",
"--ctms-text-main",
"--ctms-bg-base",
"--ctms-radius",
"--ctms-shadow",
"--ctms-brand-900",
"--ctms-brand-700",
"--ctms-neutral-100",
"--ctms-neutral-300",
"--ctms-focus-ring"
];
const requiredUnifiedTokens = [
"--unified-shell-bg",
"--unified-shell-radius",
".unified-action-bar",
".unified-section",
".ctms-page-shell",
".ctms-page-header-row",
".ctms-page-content-grid"
];
const missing = [
...requiredMainTokens.filter((t) => !main.includes(t)),
...requiredUnifiedTokens.filter((t) => !unified.includes(t))
];
const pageContractChecks = [
"src/views/ia/ProjectMilestones.vue",
"src/views/ia/SubjectManagement.vue",
"src/views/ia/RiskIssueSae.vue",
"src/views/ia/RiskIssuePd.vue",
"src/views/ia/RiskIssueMonitoringVisits.vue"
];
const requiredPageClasses = [
"ctms-page-shell",
"unified-action-bar",
"ctms-table-card"
];
for (const file of pageContractChecks) {
const content = readFileSync(file, "utf8");
for (const className of requiredPageClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
}
}
}
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
const requiredOverviewClasses = [
"ctms-page-shell",
"kpi",
"unified-section"
];
for (const className of requiredOverviewClasses) {
if (!overview.includes(className)) {
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
}
}
const financeAndFilePages = [
"src/views/fees/ContractFees.vue",
"src/views/fees/SpecialExpenses.vue",
"src/views/ia/FileVersionManagement.vue"
];
const requiredFinanceAndFileClasses = [
"ctms-page-shell",
"unified-action-bar",
"unified-shell"
];
for (const file of financeAndFilePages) {
const content = readFileSync(file, "utf8");
for (const className of requiredFinanceAndFileClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
}
}
}
const adminProjectDetail = readFileSync("src/views/admin/ProjectDetail.vue", "utf8");
const requiredAdminProjectDetailClasses = [
"ctms-page-shell",
"unified-action-bar",
"unified-section"
];
for (const className of requiredAdminProjectDetailClasses) {
if (!adminProjectDetail.includes(className)) {
missing.push(`src/views/admin/ProjectDetail.vue:${className}`);
}
}
const stateComponents = [
"src/components/StateLoading.vue",
"src/components/StateEmpty.vue",
"src/components/StateError.vue"
];
const requiredStateClasses = [
"ctms-state",
"ctms-state-title",
"ctms-state-desc"
];
for (const file of stateComponents) {
const content = readFileSync(file, "utf8");
for (const className of requiredStateClasses) {
if (!content.includes(className)) {
missing.push(`${file}:${className}`);
}
}
}
if (missing.length) {
console.error("UI contract check failed. Missing:", missing.join(", "));
process.exit(1);
}
console.log("UI contract check passed");
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="kpi-card" :class="[`type-${type}`]"> <article class="kpi-card kpi-enterprise" :class="[`type-${type}`]">
<!-- 顶部标签区域 --> <!-- 顶部标签区域 -->
<div class="kpi-badge"> <div class="kpi-badge">
<div class="kpi-badge-icon" v-if="icon"> <div class="kpi-badge-icon" v-if="icon">
@@ -30,7 +30,7 @@
</template> </template>
</div> </div>
</div> </div>
</div> </article>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
+7 -1
View File
@@ -195,7 +195,9 @@
<div class="content-wrapper"> <div class="content-wrapper">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<transition name="fade-transform" mode="out-in"> <transition name="fade-transform" mode="out-in">
<component :is="Component" /> <div class="ctms-route-shell">
<component :is="Component" />
</div>
</transition> </transition>
</router-view> </router-view>
</div> </div>
@@ -704,6 +706,10 @@ const onCommand = (cmd: string) => {
background: #f7f8fb; background: #f7f8fb;
} }
.ctms-route-shell {
min-height: 100%;
}
/* 过渡动画 */ /* 过渡动画 */
.fade-transform-enter-active, .fade-transform-enter-active,
.fade-transform-leave-active { .fade-transform-leave-active {
+6 -26
View File
@@ -1,12 +1,12 @@
<template> <template>
<div class="state state-empty"> <section class="ctms-state state state-empty">
<el-icon class="state-icon"><Document /></el-icon> <el-icon class="ctms-state-icon state-icon"><Document /></el-icon>
<div class="state-title">{{ title }}</div> <h3 class="ctms-state-title state-title">{{ title }}</h3>
<div v-if="description" class="state-desc">{{ description }}</div> <p v-if="description" class="ctms-state-desc state-desc">{{ description }}</p>
<div v-if="$slots.action" class="state-action"> <div v-if="$slots.action" class="ctms-state-action state-action">
<slot name="action" /> <slot name="action" />
</div> </div>
</div> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -26,35 +26,15 @@ withDefaults(
</script> </script>
<style scoped> <style scoped>
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 12px;
color: var(--ctms-text-secondary);
text-align: center;
}
.state-icon { .state-icon {
font-size: 36px;
color: var(--ctms-text-disabled); color: var(--ctms-text-disabled);
margin-bottom: 10px;
} }
.state-title { .state-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-regular); color: var(--ctms-text-regular);
} }
.state-desc { .state-desc {
font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
margin-top: 6px;
}
.state-action {
margin-top: 12px;
} }
</style> </style>
+6 -26
View File
@@ -1,12 +1,12 @@
<template> <template>
<div class="state state-error"> <section class="ctms-state state state-error">
<el-icon class="state-icon"><CircleClose /></el-icon> <el-icon class="ctms-state-icon state-icon"><CircleClose /></el-icon>
<div class="state-title">{{ title }}</div> <h3 class="ctms-state-title state-title">{{ title }}</h3>
<div v-if="description" class="state-desc">{{ description }}</div> <p v-if="description" class="ctms-state-desc state-desc">{{ description }}</p>
<div v-if="$slots.action" class="state-action"> <div v-if="$slots.action" class="ctms-state-action state-action">
<slot name="action" /> <slot name="action" />
</div> </div>
</div> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -26,35 +26,15 @@ withDefaults(
</script> </script>
<style scoped> <style scoped>
.state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 12px;
color: var(--ctms-text-secondary);
text-align: center;
}
.state-icon { .state-icon {
font-size: 36px;
color: var(--ctms-danger); color: var(--ctms-danger);
margin-bottom: 10px;
} }
.state-title { .state-title {
font-size: 14px;
font-weight: 600;
color: var(--ctms-text-main); color: var(--ctms-text-main);
} }
.state-desc { .state-desc {
font-size: 12px;
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
margin-top: 6px;
}
.state-action {
margin-top: 12px;
} }
</style> </style>
+11 -12
View File
@@ -1,25 +1,24 @@
<template> <template>
<div class="state state-loading"> <section class="ctms-state ctms-state-loading state state-loading">
<el-skeleton :rows="rows" animated /> <h3 class="ctms-state-title state-title">{{ title }}</h3>
</div> <p class="ctms-state-desc state-desc">{{ description }}</p>
<el-skeleton :rows="rows" animated class="ctms-state-skeleton" />
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { TEXT } from "../locales";
withDefaults( withDefaults(
defineProps<{ defineProps<{
rows?: number; rows?: number;
title?: string;
description?: string;
}>(), }>(),
{ {
rows: 4, rows: 4,
title: TEXT.common.loading,
description: TEXT.common.messages.loadSuccess,
} }
); );
</script> </script>
<style scoped>
.state-loading {
padding: 24px;
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
background-color: #ffffff;
}
</style>
+44
View File
@@ -11,6 +11,8 @@
--ctms-primary-hover: #365067; --ctms-primary-hover: #365067;
--ctms-primary-active: #2b4356; --ctms-primary-active: #2b4356;
--ctms-primary-light: #e6edf2; --ctms-primary-light: #e6edf2;
--ctms-brand-900: #13243a;
--ctms-brand-700: #24496f;
/* 语义色 */ /* 语义色 */
--ctms-success: #3f8f6b; --ctms-success: #3f8f6b;
@@ -28,6 +30,8 @@
--ctms-bg-base: #f9fafb; --ctms-bg-base: #f9fafb;
--ctms-bg-card: #ffffff; --ctms-bg-card: #ffffff;
--ctms-bg-muted: #f3f4f6; --ctms-bg-muted: #f3f4f6;
--ctms-neutral-100: #f5f7fa;
--ctms-neutral-300: #d5dce5;
--ctms-border-color: #e5e7eb; --ctms-border-color: #e5e7eb;
--ctms-border-color-hover: #cbd5e1; --ctms-border-color-hover: #cbd5e1;
@@ -51,6 +55,7 @@
/* 动画 */ /* 动画 */
--ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1); --ctms-transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
--ctms-focus-ring: 0 0 0 3px rgba(36, 73, 111, 0.2);
/* Element Plus 主色适配 */ /* Element Plus 主色适配 */
--el-color-primary: var(--ctms-primary); --el-color-primary: var(--ctms-primary);
@@ -432,6 +437,45 @@ body {
font-weight: 600; font-weight: 600;
} }
/* Unified loading / empty / error states */
.ctms-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 28px 16px;
text-align: center;
border: 1px solid var(--ctms-border-color);
border-radius: var(--ctms-radius);
background: #ffffff;
}
.ctms-state-icon {
font-size: 34px;
}
.ctms-state-title {
margin: 0;
font-size: 15px;
font-weight: 600;
}
.ctms-state-desc {
margin: 0;
font-size: 13px;
color: var(--ctms-text-secondary);
}
.ctms-state-action {
margin-top: 4px;
}
.ctms-state-loading .ctms-state-skeleton {
width: min(560px, 100%);
margin-top: 4px;
}
/* Tool classes */ /* Tool classes */
.text-main { .text-main {
color: var(--ctms-text-main); color: var(--ctms-text-main);
+40
View File
@@ -9,6 +9,22 @@
--unified-muted-color: #6f84a8; --unified-muted-color: #6f84a8;
} }
.ctms-page-shell {
padding: 16px 20px 20px;
}
.ctms-page-header-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.ctms-page-content-grid {
display: grid;
gap: 12px;
}
.unified-shell { .unified-shell {
background: var(--unified-shell-bg); background: var(--unified-shell-bg);
border: 0; border: 0;
@@ -172,6 +188,30 @@
color: var(--unified-muted-color); color: var(--unified-muted-color);
} }
.unified-shell .kpi-enterprise {
min-height: 132px;
border-radius: 14px;
padding: 16px;
}
.unified-shell .kpi-enterprise .kpi-title {
font-size: 16px;
line-height: 1.25;
}
.unified-shell .kpi-enterprise .kpi-value {
font-size: 34px;
}
.setup-page .setup-header.unified-action-bar {
padding: var(--unified-shell-padding-y) var(--unified-shell-padding-x);
border-bottom: 1px solid var(--unified-shell-divider);
}
.setup-page .setup-content.unified-section {
padding: 0;
}
@media (max-width: 960px) { @media (max-width: 960px) {
.unified-action-bar, .unified-action-bar,
.unified-section { .unified-section {
+3 -3
View File
@@ -1,7 +1,7 @@
<template> <template>
<div class="ctms-page setup-page"> <div class="ctms-page setup-page ctms-page-shell">
<div class="setup-shell unified-shell"> <div class="setup-shell unified-shell">
<div class="setup-header"> <div class="setup-header unified-action-bar">
<div class="setup-flow"> <div class="setup-flow">
<div <div
v-for="(step, index) in steps" v-for="(step, index) in steps"
@@ -108,7 +108,7 @@
@click="closeEditDrawer" @click="closeEditDrawer"
/> />
<div <div
class="setup-content" class="setup-content unified-section"
:class="{ :class="{
'setup-content-drawer': drawerVisible, 'setup-content-drawer': drawerVisible,
'is-closing': drawerClosing, 'is-closing': drawerClosing,
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<div class="overview"> <div class="overview">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6"> <el-col :xs="24" :sm="12" :md="6">
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<div class="overview"> <div class="overview">
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="24" :sm="12" :md="6"> <el-col :xs="24" :sm="12" :md="6">
@@ -1,6 +1,13 @@
<template> <template>
<div class="redirecting"> <div class="redirecting ctms-page-shell">
<el-empty :description="`${TEXT.common.loading}...`" /> <section class="unified-action-bar">
<div class="redirecting-title">{{ TEXT.menu.fileVersionManagement }}</div>
</section>
<section class="unified-shell">
<section class="unified-section">
<el-empty :description="`${TEXT.common.loading}...`" />
</section>
</section>
</div> </div>
</template> </template>
@@ -23,6 +30,12 @@ onMounted(() => {
<style scoped> <style scoped>
.redirecting { .redirecting {
padding: 24px; min-height: 180px;
}
.redirecting-title {
font-size: 15px;
font-weight: 700;
color: var(--ctms-text-main);
} }
</style> </style>
+6 -5
View File
@@ -1,8 +1,8 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<div v-if="study.currentStudy" class="unified-shell"> <div v-if="study.currentStudy" class="unified-shell ctms-table-card">
<section class="unified-section"> <section class="unified-action-bar">
<div class="card-header"> <div class="card-header ctms-page-header-row">
<div> <div>
<div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div> <div class="card-title">{{ TEXT.modules.projectMilestones.listTitle }}</div>
<div class="card-subtitle"> <div class="card-subtitle">
@@ -21,7 +21,8 @@
<el-button size="small" @click="loadMilestones">{{ TEXT.common.actions.refresh }}</el-button> <el-button size="small" @click="loadMilestones">{{ TEXT.common.actions.refresh }}</el-button>
</div> </div>
</div> </div>
</section>
<section class="unified-section ctms-page-content-grid">
<el-table :data="rows" v-loading="loading" class="ctms-table" style="width: 100%"> <el-table :data="rows" v-loading="loading" class="ctms-table" style="width: 100%">
<el-table-column prop="name" :label="TEXT.modules.projectMilestones.columns.name" min-width="140" /> <el-table-column prop="name" :label="TEXT.modules.projectMilestones.columns.name" min-width="140" />
<el-table-column prop="planDate" :label="TEXT.modules.projectMilestones.columns.planDate" min-width="170"> <el-table-column prop="planDate" :label="TEXT.modules.projectMilestones.columns.planDate" min-width="170">
+52 -2
View File
@@ -1,7 +1,21 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<div v-if="study.currentStudy" class="page-body"> <div v-if="study.currentStudy" class="page-body">
<div class="unified-shell"> <div class="unified-shell ctms-table-card">
<section class="unified-section">
<div class="kpi-grid">
<KpiCard
v-for="item in kpiItems"
:key="item.key"
class="kpi"
:title="item.title"
:value="item.value"
:subtext="item.subtext"
:type="item.type"
/>
</div>
</section>
<section class="unified-section"> <section class="unified-section">
<div class="overview-meta-inline"> <div class="overview-meta-inline">
<el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag> <el-tag v-if="usingDemo" effect="plain" type="info" class="demo-tag">示例数据</el-tag>
@@ -69,6 +83,7 @@ import { fetchProjectOverview } from "../../api/overview";
import { fetchSites } from "../../api/sites"; import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue"; import StateEmpty from "../../components/StateEmpty.vue";
import StateLoading from "../../components/StateLoading.vue"; import StateLoading from "../../components/StateLoading.vue";
import KpiCard from "../../components/KpiCard.vue";
import CenterProgressRow from "./project-overview/CenterProgressRow.vue"; import CenterProgressRow from "./project-overview/CenterProgressRow.vue";
import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue"; import EnrollmentBarChart, { type EnrollmentBarItem } from "./project-overview/EnrollmentBarChart.vue";
import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter"; import { adaptProjectOverview, type CenterOverview, type ProjectOverviewViewModel } from "./project-overview/overview.adapter";
@@ -84,6 +99,35 @@ const updatedAtLabel = ref(displayDateTime(new Date()));
const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true"; const preferApi = String(import.meta.env.VITE_USE_OVERVIEW_API || "").toLowerCase() === "true";
const centers = computed(() => overview.value?.centers || []); const centers = computed(() => overview.value?.centers || []);
const kpiItems = computed(() => {
const summary = overview.value?.summary;
const totalTarget = summary?.total_target ?? 0;
const totalActual = summary?.total_actual ?? 0;
const activeCenters = centers.value.filter((item) => item.is_active !== false).length;
return [
{
key: "target",
title: "总入组目标",
value: totalTarget,
subtext: "项目整体计划",
type: "primary" as const,
},
{
key: "actual",
title: "当前入组",
value: totalActual,
subtext: `达成率 ${totalTarget > 0 ? Math.round((totalActual / totalTarget) * 100) : 0}%`,
type: "success" as const,
},
{
key: "centers",
title: "活跃中心",
value: activeCenters,
subtext: `中心总数 ${centers.value.length}`,
type: "info" as const,
},
];
});
const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] => const buildFallbackCentersFromSites = (siteList: any[]): CenterOverview[] =>
siteList.map((site: any) => ({ siteList.map((site: any) => ({
@@ -208,6 +252,12 @@ watch(
gap: 0; gap: 0;
} }
.kpi-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.demo-tag { .demo-tag {
border-color: var(--ctms-border-color); border-color: var(--ctms-border-color);
color: var(--ctms-text-secondary); color: var(--ctms-text-secondary);
@@ -1,7 +1,7 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<template v-if="study.currentStudy"> <template v-if="study.currentStudy">
<el-card shadow="never" class="main-content-card unified-shell"> <el-card shadow="never" class="main-content-card unified-shell ctms-table-card">
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<div class="filter-left"> <div class="filter-left">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<el-card shadow="never" class="main-content-card unified-shell"> <el-card shadow="never" class="main-content-card unified-shell ctms-table-card">
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<el-form :inline="true" class="filter-form"> <el-form :inline="true" class="filter-form">
<el-form-item class="filter-item-form"> <el-form-item class="filter-item-form">
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<el-card shadow="never" class="main-content-card unified-shell"> <el-card shadow="never" class="main-content-card unified-shell ctms-table-card">
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<el-form :inline="true" class="filter-form"> <el-form :inline="true" class="filter-form">
<el-form-item class="filter-item-form"> <el-form-item class="filter-item-form">
+2 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="page"> <div class="page ctms-page-shell">
<el-card shadow="never" class="main-content-card unified-shell"> <el-card shadow="never" class="main-content-card unified-shell ctms-table-card">
<div class="filter-container unified-action-bar"> <div class="filter-container unified-action-bar">
<el-form :inline="true" :model="filters" class="filter-form"> <el-form :inline="true" :model="filters" class="filter-form">
<el-form-item label="" class="filter-item-form"> <el-form-item label="" class="filter-item-form">