Merge branch 'codex/enterprise-ui-implementation' into dev
This commit is contained in:
@@ -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.
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,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,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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user