feat(ia): polish project operation workspaces

This commit is contained in:
Cheng Zhou
2026-06-08 11:06:07 +08:00
parent 834b4f1d48
commit 8cd8c38900
25 changed files with 2744 additions and 1516 deletions
+204 -87
View File
@@ -1,38 +1,110 @@
<template>
<div class="page">
<div class="unified-shell" v-loading="loading">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== Hero ==================== -->
<div class="hero-banner">
<div class="hero-content">
<div class="hero-top">
<div class="hero-title-group">
<h2 class="hero-title">{{ TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle }}</h2>
<span :class="['hero-status-badge', `hero-status-badge--${statusTagType(status)}`]">
{{ statusLabel(status) }}
</span>
</div>
<el-button v-if="canUpdateEthics" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor" class="hero-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
<div class="hero-stats">
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--site">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.site }}</span>
<span class="stat-value">{{ displaySite(detail.site_id) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--no">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.approvalNo }}</span>
<span class="stat-value stat-value--mono">{{ detail.approval_no || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--submit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.submitDate }}</span>
<span class="stat-value">{{ displayDate(detail.submit_date) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--accept">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.acceptDate }}</span>
<span class="stat-value">{{ displayDate(detail.accept_date) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--meeting">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.meetingDate }}</span>
<span class="stat-value">{{ displayDate(detail.meeting_date) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--approved">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.approvedDate }}</span>
<span class="stat-value">{{ displayDate(detail.approved_date) }}</span>
</div>
</div>
</div>
</div>
<el-descriptions :column="2" border class="ctms-descriptions">
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.approvalNo">{{ detail.approval_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.meetingDate">{{ displayDate(detail.meeting_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section attachment-section">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</section>
</div>
</div>
<!-- ==================== 附件 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_ethics"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</div>
</div>
</template>
<EthicsEditorDrawer
v-model="editorVisible"
:record-id="recordId"
@@ -45,6 +117,7 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { useStudyStore } from "../../store/study";
import { getEthics } from "../../api/startup";
@@ -52,6 +125,8 @@ import { fetchSites } from "../../api/sites";
import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
@@ -61,6 +136,7 @@ const route = useRoute();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
@@ -73,30 +149,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
const canUpdateEthics = computed(() => can("startup.ethics.update"));
const detail = reactive<any>({
site_id: "",
submit_date: "",
accept_date: "",
meeting_date: "",
approved_date: "",
approval_no: "",
site_id: "", submit_date: "", accept_date: "", meeting_date: "", approved_date: "", approval_no: "",
});
const SUBMIT_TO_ACCEPT_MONTHS =
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) ||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
1;
const ACCEPT_TO_APPROVAL_MONTHS =
Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) ||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
1;
const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
};
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
const statusTagType = (status: StageStatus) => {
if (status === "COMPLETED") return "success";
@@ -109,7 +170,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
if (!acceptDate || approvedDate) return false;
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
};
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
if (!submitDate || acceptDate) return false;
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
@@ -128,67 +188,124 @@ const loadSites = async () => {
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
} catch { sites.value = []; }
};
const load = async () => {
if (!studyId || !recordId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getEthics(studyId, recordId);
Object.assign(detail, data);
const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: detail.approval_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle),
});
study.setViewContext({ siteName, pageTitle: detail.approval_no || siteName || TEXT.modules.startupFeasibilityEthics.ethicsDetailTitle });
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { loading.value = false; }
};
const openEditor = () => {
if (!canUpdateEthics.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateEthics.value) { ElMessage.warning("权限不足"); return; }
if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
editorVisible.value = true;
};
const handleEditorSuccess = async () => { await load(); };
const handleEditorSuccess = async () => {
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
onMounted(async () => { await loadSites(); await load(); });
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
}
.attachment-section {
padding: 0;
/* ==================== Hero Banner ==================== */
.hero-banner {
position: relative;
background: #fff7ee;
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 8px;
padding: 28px 32px 24px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.7) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
}
</style>
.hero-content { position: relative; z-index: 1; }
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
.hero-title-group { display: flex; align-items: center; gap: 12px; }
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
.hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
.hero-status-badge {
display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
border-radius: 20px; letter-spacing: 0.02em; backdrop-filter: blur(8px);
}
.hero-status-badge--info { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.55); border: 1px solid rgba(180,110,50,0.12); }
.hero-status-badge--primary { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
.hero-status-badge--success { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
.hero-status-badge--danger { background: rgba(255,240,240,0.70); color: rgba(160,40,40,0.75); border: 1px solid rgba(200,80,80,0.18); }
.hero-edit-btn {
height: 34px; border-radius: 6px; padding: 0 18px; font-weight: 500;
background: rgba(255,255,255,0.85); border: 1px solid rgba(180,110,50,0.18); color: #3d2410;
backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);
transition: all 0.2s ease;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.06);
}
.hero-edit-btn:hover {
background: #ffffff; border-color: rgba(180,110,50,0.30); color: #3d2410;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 4px 12px rgba(212,130,60,0.14);
}
.hero-edit-btn:active { transform: scale(0.97); }
.hero-stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; }
.hero-stat {
position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 100%);
border: 1px solid rgba(180,110,50,0.10); border-radius: 6px;
backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%);
transition: all 0.25s ease;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.04);
}
.hero-stat:hover {
background: linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.92) 100%);
border-color: rgba(180,110,50,0.18); transform: translateY(-1px);
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 6px 16px rgba(212,130,60,0.10);
}
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; border: 1px solid rgba(180,110,50,0.06); }
.stat-icon-wrap--site { background: rgba(212,145,80,0.14); color: #b06b30; }
.stat-icon-wrap--no { background: rgba(168,158,80,0.16); color: #8a7028; }
.stat-icon-wrap--submit { background: rgba(220,160,90,0.18); color: #b87532; }
.stat-icon-wrap--accept { background: rgba(196,116,92,0.14); color: #a85838; }
.stat-icon-wrap--meeting { background: rgba(212,145,80,0.14); color: #b06b30; }
.stat-icon-wrap--approved { background: rgba(196,100,100,0.14); color: #aa4444; }
.stat-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.stat-label { font-size: 11px; font-weight: 500; color: rgba(80,45,20,0.50); letter-spacing: 0.03em; text-transform: uppercase; }
.stat-value { font-size: 14px; font-weight: 600; color: #3d2410; word-break: break-all; }
.stat-value--mono { font-family: ui-monospace, SFMono-Regular, monospace; }
/* ==================== Section Card ==================== */
.section-card { background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: none; }
.section-card-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px 0; }
.section-card-header-left { display: flex; align-items: center; gap: 10px; }
.section-card-title { font-size: 14px; font-weight: 600; color: #0a0a0a; letter-spacing: -0.01em; }
.section-divider { display: none; }
.section-card-body { padding: 20px 24px 24px; }
.accent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.accent-dot--blue { background: #3b82f6; }
@media (max-width: 1200px) { .hero-stats { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 960px) { .hero-stats { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 640px) {
.hero-banner { padding: 20px; }
.hero-stats { grid-template-columns: 1fr; }
.hero-top { flex-direction: column; gap: 12px; }
.hero-edit-btn { width: 100%; }
}
</style>
+193 -85
View File
@@ -1,37 +1,101 @@
<template>
<div class="page">
<div class="unified-shell" v-loading="loading">
<section class="unified-section">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== Hero ==================== -->
<div class="hero-banner">
<div class="hero-content">
<div class="hero-top">
<div class="hero-title-group">
<h2 class="hero-title">{{ TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle }}</h2>
<span :class="['hero-status-badge', `hero-status-badge--${statusTagType(status)}`]">
{{ statusLabel(status) }}
</span>
</div>
<el-button v-if="canUpdateInitiation" type="primary" :disabled="isInactiveSite(detail.site_id)" @click="openEditor" class="hero-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
<div class="hero-stats">
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--site">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.site }}</span>
<span class="stat-value">{{ displaySite(detail.site_id) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--no">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.projectNo }}</span>
<span class="stat-value stat-value--mono">{{ detail.project_no || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--submit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.submitDate }}</span>
<span class="stat-value">{{ displayDate(detail.submit_date) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--accept">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.acceptDate }}</span>
<span class="stat-value">{{ displayDate(detail.accept_date) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--approved">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.common.fields.approvedDate }}</span>
<span class="stat-value">{{ displayDate(detail.approved_date) }}</span>
</div>
</div>
</div>
</div>
<el-descriptions :column="2" border class="ctms-descriptions">
<el-descriptions-item :label="TEXT.common.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.projectNo">{{ detail.project_no || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.submitDate">{{ displayDate(detail.submit_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.acceptDate">{{ displayDate(detail.accept_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.approvedDate">{{ displayDate(detail.approved_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-tag :type="statusTagType(status)">{{ statusLabel(status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</section>
<section class="unified-section attachment-section">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</section>
</div>
</div>
<!-- ==================== 附件 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-header-left">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
</div>
</div>
<div class="section-divider" />
<div class="section-card-body">
<AttachmentList
v-if="recordId"
:study-id="studyId"
entity-type="startup_feasibility"
:entity-id="recordId"
:readonly="isInactiveSite(detail.site_id)"
:hide-uploader="true"
/>
</div>
</div>
</template>
<FeasibilityEditorDrawer
v-model="editorVisible"
:record-id="recordId"
@@ -44,6 +108,7 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { useStudyStore } from "../../store/study";
import { getFeasibility } from "../../api/startup";
@@ -51,6 +116,8 @@ import { fetchSites } from "../../api/sites";
import type { Site } from "../../types/api";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import type { StageStatus } from "../ia/project-overview/overview.adapter";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
@@ -60,6 +127,7 @@ const route = useRoute();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
@@ -72,29 +140,15 @@ const isInactiveSite = (siteId?: string) => Boolean(siteId) && siteActiveMap.val
const canUpdateInitiation = computed(() => can("startup.initiation.update"));
const detail = reactive<any>({
site_id: "",
submit_date: "",
accept_date: "",
approved_date: "",
project_no: "",
site_id: "", submit_date: "", accept_date: "", approved_date: "", project_no: "",
});
const SUBMIT_TO_ACCEPT_MONTHS =
Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) ||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
1;
const ACCEPT_TO_APPROVAL_MONTHS =
Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) ||
Number(import.meta.env.VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS) ||
1;
const SUBMIT_TO_ACCEPT_MONTHS = Number(import.meta.env.VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS) || 1;
const ACCEPT_TO_APPROVAL_MONTHS = Number(import.meta.env.VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS) || 1;
const statusLabelMap: Record<StageStatus, string> = {
COMPLETED: "已完成",
IN_PROGRESS: "进行中",
NOT_STARTED: "未开始",
BLOCKED: "阻塞/延期",
COMPLETED: "已完成", IN_PROGRESS: "进行中", NOT_STARTED: "未开始", BLOCKED: "阻塞/延期",
};
const statusLabel = (status: StageStatus) => statusLabelMap[status] || "未开始";
const statusTagType = (status: StageStatus) => {
if (status === "COMPLETED") return "success";
@@ -107,7 +161,6 @@ const isBlockedByAcceptDate = (acceptDate?: string, approvedDate?: string) => {
if (!acceptDate || approvedDate) return false;
return dayjs(acceptDate).add(ACCEPT_TO_APPROVAL_MONTHS, "month").isBefore(dayjs());
};
const isBlockedBySubmitDate = (submitDate?: string, acceptDate?: string) => {
if (!submitDate || acceptDate) return false;
return dayjs(submitDate).add(SUBMIT_TO_ACCEPT_MONTHS, "month").isBefore(dayjs());
@@ -126,67 +179,122 @@ const loadSites = async () => {
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
}
} catch { sites.value = []; }
};
const load = async () => {
if (!studyId || !recordId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getFeasibility(studyId, recordId);
Object.assign(detail, data);
const siteName = displaySite(detail.site_id);
const hasSiteName = siteName !== TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: detail.project_no || (hasSiteName ? siteName : TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle),
});
study.setViewContext({ siteName, pageTitle: detail.project_no || siteName || TEXT.modules.startupFeasibilityEthics.feasibilityDetailTitle });
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally { loading.value = false; }
};
const openEditor = () => {
if (!canUpdateInitiation.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite(detail.site_id)) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateInitiation.value) { ElMessage.warning("权限不足"); return; }
if (isInactiveSite(detail.site_id)) { ElMessage.warning("中心已停用"); return; }
editorVisible.value = true;
};
const handleEditorSuccess = async () => { await load(); };
const handleEditorSuccess = async () => {
await load();
};
onMounted(async () => {
await loadSites();
await load();
});
onMounted(async () => { await loadSites(); await load(); });
</script>
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
display: flex; flex-direction: column; gap: 16px; padding: 0; background: transparent;
}
.attachment-section {
padding: 0;
/* ==================== Hero Banner ==================== */
.hero-banner {
position: relative;
background: #fff7ee;
border: 1px solid rgba(180, 110, 50, 0.10);
border-radius: 8px;
padding: 28px 32px 24px;
overflow: hidden;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.7) inset,
0 1px 2px rgba(180, 110, 50, 0.04);
}
</style>
.hero-content { position: relative; z-index: 1; }
.hero-top { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 22px; }
.hero-title-group { display: flex; align-items: center; gap: 12px; }
<style scoped>
.page {
display: flex;
flex-direction: column;
gap: 0;
.hero-title { margin: 0; font-size: 19px; font-weight: 600; color: #3d2410; letter-spacing: 0.005em; }
.hero-status-badge {
display: inline-flex; align-items: center; padding: 2px 12px; font-size: 11px; font-weight: 500;
border-radius: 20px; letter-spacing: 0.02em; backdrop-filter: blur(8px);
}
.hero-status-badge--info { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.55); border: 1px solid rgba(180,110,50,0.12); }
.hero-status-badge--primary { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
.hero-status-badge--success { background: rgba(255,255,255,0.55); color: rgba(80,45,20,0.78); border: 1px solid rgba(180,110,50,0.18); }
.hero-status-badge--danger { background: rgba(255,240,240,0.70); color: rgba(160,40,40,0.75); border: 1px solid rgba(200,80,80,0.18); }
.hero-edit-btn {
height: 34px; border-radius: 6px; padding: 0 18px; font-weight: 500;
background: rgba(255,255,255,0.85); border: 1px solid rgba(180,110,50,0.18); color: #3d2410;
backdrop-filter: blur(10px) saturate(140%); -webkit-backdrop-filter: blur(10px) saturate(140%);
transition: all 0.2s ease;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.06);
}
.hero-edit-btn:hover {
background: #ffffff; border-color: rgba(180,110,50,0.30); color: #3d2410;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 4px 12px rgba(212,130,60,0.14);
}
.hero-edit-btn:active { transform: scale(0.97); }
.hero-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 10px; }
.hero-stat {
position: relative; display: flex; align-items: center; gap: 14px; padding: 14px 16px;
background: linear-gradient(180deg, rgba(255,255,255,0.92) 0%, rgba(255,255,255,0.62) 100%);
border: 1px solid rgba(180,110,50,0.10); border-radius: 6px;
backdrop-filter: blur(14px) saturate(140%); -webkit-backdrop-filter: blur(14px) saturate(140%);
transition: all 0.25s ease;
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 1px 2px rgba(180,110,50,0.04);
}
.hero-stat:hover {
background: linear-gradient(180deg, #ffffff 0%, rgba(255,255,255,0.92) 100%);
border-color: rgba(180,110,50,0.18); transform: translateY(-1px);
box-shadow: 0 1px 0 rgba(255,255,255,1) inset, 0 6px 16px rgba(212,130,60,0.10);
}
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 6px; flex-shrink: 0; border: 1px solid rgba(180,110,50,0.06); }
.stat-icon-wrap--site { background: rgba(212,145,80,0.14); color: #b06b30; }
.stat-icon-wrap--no { background: rgba(168,158,80,0.16); color: #8a7028; }
.stat-icon-wrap--submit { background: rgba(220,160,90,0.18); color: #b87532; }
.stat-icon-wrap--accept { background: rgba(196,116,92,0.14); color: #a85838; }
.stat-icon-wrap--approved { background: rgba(196,100,100,0.14); color: #aa4444; }
.stat-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.stat-label { font-size: 11px; font-weight: 500; color: rgba(80,45,20,0.50); letter-spacing: 0.03em; text-transform: uppercase; }
.stat-value { font-size: 14px; font-weight: 600; color: #3d2410; word-break: break-all; }
.stat-value--mono { font-family: ui-monospace, SFMono-Regular, monospace; }
/* ==================== Section Card ==================== */
.section-card { background: #ffffff; border-radius: 6px; overflow: hidden; box-shadow: none; }
.section-card-header { display: flex; justify-content: space-between; align-items: center; padding: 18px 24px 0; }
.section-card-header-left { display: flex; align-items: center; gap: 10px; }
.section-card-title { font-size: 14px; font-weight: 600; color: #0a0a0a; letter-spacing: -0.01em; }
.section-divider { display: none; }
.section-card-body { padding: 20px 24px 24px; }
.accent-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.accent-dot--blue { background: #3b82f6; }
@media (max-width: 960px) { .hero-stats { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 640px) {
.hero-banner { padding: 20px; }
.hero-stats { grid-template-columns: 1fr; }
.hero-top { flex-direction: column; gap: 12px; }
.hero-edit-btn { width: 100%; }
}
</style>
+323 -219
View File
@@ -1,117 +1,126 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section" v-loading="loading">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button
v-if="canUpdateAuth"
type="primary"
:disabled="loading || isReadOnly"
@click="startEdit"
>
{{ TEXT.common.actions.edit }}
</el-button>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadAll">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== 顶部信息区 ==================== -->
<div class="top-section">
<div class="top-header">
<div class="top-header-left">
<span class="top-breadcrumb">{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}</span>
<h1 class="top-title">{{ siteInfo.name || TEXT.common.fallback }}</h1>
</div>
<el-button v-if="canUpdateAuth" :disabled="loading || isReadOnly" @click="startEdit" class="top-edit-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
<div class="info-bar">
<div class="info-block">
<span class="info-block-label">{{ TEXT.modules.startupMeetingAuth.ownerLabel }}</span>
<span class="info-block-value">{{ ownerLabel }}</span>
</div>
<div class="info-divider" />
<div class="info-block">
<span class="info-block-label">{{ TEXT.common.fields.status }}</span>
<span :class="['info-block-value', statusTagType === 'success' ? 'info-block-value--success' : 'info-block-value--info']">{{ statusLabel }}</span>
</div>
<div class="info-divider" />
<div class="info-block">
<span class="info-block-label">{{ TEXT.common.fields.kickoffDate }}</span>
<span class="info-block-value">{{ displayDate(detail.kickoff_date) || TEXT.common.fallback }}</span>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.site">
{{ siteInfo.name || TEXT.common.fallback }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.modules.startupMeetingAuth.ownerLabel">
{{ ownerLabel }}
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.status">
<el-tag :type="statusTagType">{{ statusLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.kickoffDate">
{{ displayDate(detail.kickoff_date) }}
</el-descriptions-item>
</el-descriptions>
</section>
</div>
<section class="unified-section training-section">
<div class="training-header">
<div class="training-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</div>
<el-button v-if="canCreateAuth" type="primary" size="small" :disabled="isReadOnly" @click="startTrainingAdd">
<!-- ==================== 培训授权列表 ==================== -->
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<span class="section-card-title">{{ TEXT.modules.startupMeetingAuth.trainingTab }}</span>
<span class="section-card-badge">{{ trainingItems.length }}</span>
</div>
<el-button v-if="canCreateAuth" size="small" :disabled="isReadOnly" @click="startTrainingAdd" class="add-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}
</el-button>
</div>
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="ctms-table" table-layout="fixed">
<el-table :data="trainingItems" v-loading="loadingTraining" style="width: 100%" class="training-table" table-layout="fixed">
<el-table-column prop="name" :label="TEXT.common.fields.name" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.name || TEXT.common.fallback }}
</template>
<template #default="scope"><span class="cell-primary cell-nowrap">{{ scope.row.name || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="role" :label="TEXT.common.labels.role" show-overflow-tooltip>
<template #default="scope">
{{ scope.row.role || TEXT.common.fallback }}
</template>
<template #default="scope"><span class="cell-nowrap">{{ scope.row.role || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.trained">
<template #default="scope">
<div class="status-with-date">
<span>{{ trainingStatusLabel(scope.row) }}</span>
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date-small">
{{ displayDate(scope.row.trained_date) }}
</span>
<div class="status-cell">
<span :class="['status-dot', scope.row.trained ? 'status-dot--yes' : 'status-dot--no']" />
<span>{{ scope.row.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
<span v-if="scope.row.trained && scope.row.trained_date" class="status-date">{{ displayDate(scope.row.trained_date) }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.authorized">
<template #default="scope">
<div class="status-with-date">
<span>{{ authorizationStatusLabel(scope.row) }}</span>
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date-small">
{{ displayDate(scope.row.authorized_date) }}
</span>
<div class="status-cell">
<span :class="['status-dot', scope.row.authorized ? 'status-dot--yes' : 'status-dot--no']" />
<span>{{ scope.row.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
<span v-if="scope.row.authorized && scope.row.authorized_date" class="status-date">{{ displayDate(scope.row.authorized_date) }}</span>
</div>
</template>
</el-table-column>
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="140">
<el-table-column v-if="canCreateAuth || canUpdateAuth || canDeleteAuth" :label="TEXT.common.labels.actions" width="110" fixed="right">
<template #default="scope">
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">
{{ TEXT.common.actions.edit }}
</el-button>
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
<div class="cell-actions">
<el-button v-if="canUpdateAuth" link type="primary" size="small" :disabled="isReadOnly" @click.stop="startTrainingEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
<el-button v-if="canDeleteAuth" link type="danger" size="small" :disabled="isReadOnly" @click.stop="removeTraining(scope.row)">{{ TEXT.common.actions.delete }}</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loadingTraining" class="table-empty">
<div class="empty-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>{{ TEXT.modules.startupMeetingAuth.emptyTraining }}</span>
</div>
</template>
</el-table>
</section>
</div>
<section v-if="meetingId" class="unified-section attachment-section">
<AttachmentList
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
:entity-groups="kickoffAttachmentGroups"
:title="TEXT.common.labels.attachments"
:readonly="true"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</section>
</div>
<KickoffEditorDrawer
v-model="editorVisible"
:meeting-id="meetingId"
:study-id="studyId"
:site-name="siteInfo.name"
:owner-label="ownerLabel"
:readonly="isReadOnly"
@saved="handleEditorSaved"
/>
<TrainingEditorDrawer
v-model="trainingEditorVisible"
:record-id="editingTrainingId"
:study-id="studyId"
:sites="sites"
:initial-site-name="siteInfo.name"
@saved="handleTrainingEditorSaved"
/>
<!-- ==================== 附件 ==================== -->
<div v-if="meetingId" class="section-card">
<div class="section-card-header">
<div class="section-card-title-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#6c5ce7"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
<span class="section-card-title">{{ TEXT.common.labels.attachments }}</span>
</div>
</div>
<div class="section-card-body">
<AttachmentList
:study-id="studyId"
entity-type="startup_kickoff"
:entity-id="meetingId"
:entity-groups="kickoffAttachmentGroups"
:readonly="true"
:hide-uploader="true"
:refresh-key="attachmentRefreshKey"
/>
</div>
</div>
</template>
<KickoffEditorDrawer v-model="editorVisible" :meeting-id="meetingId" :study-id="studyId" :site-name="siteInfo.name" :owner-label="ownerLabel" :readonly="isReadOnly" @saved="handleEditorSaved" />
<TrainingEditorDrawer v-model="trainingEditorVisible" :record-id="editingTrainingId" :study-id="studyId" :sites="sites" :initial-site-name="siteInfo.name" @saved="handleTrainingEditorSaved" />
</div>
</template>
@@ -119,18 +128,17 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Plus } from "@element-plus/icons-vue";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import {
getKickoff,
listTrainingAuthorizations,
deleteTrainingAuthorization,
} from "../../api/startup";
import { getKickoff, listTrainingAuthorizations, deleteTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { listMembers } from "../../api/members";
import { fetchUsers } from "../../api/users";
import { displayDate } from "../../utils/display";
import AttachmentList from "../../components/attachments/AttachmentList.vue";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import KickoffEditorDrawer from "./KickoffEditorDrawer.vue";
@@ -141,6 +149,7 @@ const auth = useAuthStore();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const errorMessage = ref("");
const loadingTraining = ref(false);
const editorVisible = ref(false);
const trainingEditorVisible = ref(false);
@@ -164,46 +173,33 @@ const kickoffAttachmentGroups = [
{ entityType: "startup_kickoff", label: TEXT.modules.startupMeetingAuth.kickoffOtherLabel },
];
const detail = reactive<any>({
kickoff_date: "",
});
const detail = reactive<any>({ kickoff_date: "" });
const trainingItems = ref<any[]>([]);
const memberNameMap = computed(() => {
const map: Record<string, string> = {};
users.value.forEach((u: any) => {
if (u?.id) map[u.id] = u.full_name || u.username || u.id;
});
users.value.forEach((u: any) => { if (u?.id) map[u.id] = u.full_name || u.username || u.id; });
members.value.forEach((m: any) => {
const user = m?.user;
if (m?.user_id && !map[m.user_id]) {
map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
}
if (m?.user_id && !map[m.user_id]) map[m.user_id] = user?.full_name || user?.username || user?.email || m.user_id;
});
return map;
});
const ownerLabel = computed(() => {
if (!siteInfo.contact) return TEXT.common.fallback;
return String(siteInfo.contact)
.split(",")
.map((c) => c.trim())
.filter(Boolean)
.map((c) => memberNameMap.value[c] || c)
.join("、") || TEXT.common.fallback;
return String(siteInfo.contact).split(",").map((c) => c.trim()).filter(Boolean).map((c) => memberNameMap.value[c] || c).join("、") || TEXT.common.fallback;
});
const statusLabel = computed(() =>
detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending
);
const statusTagType = computed(() => (detail.kickoff_date ? "success" : "info"));
const statusLabel = computed(() => detail.kickoff_date ? TEXT.modules.startupMeetingAuth.statusCompleted : TEXT.modules.startupMeetingAuth.statusPending);
const statusTagType = computed(() => detail.kickoff_date ? "success" : "info");
const isReadOnly = computed(() => siteInfo.is_active === false);
const trainingStatusLabel = (row: any) => (row?.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const authorizationStatusLabel = (row: any) => (row?.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no);
const loadAll = async () => { await load(); await loadTraining(); };
const load = async () => {
if (!studyId || !meetingId) return;
loading.value = true;
loading.value = true; errorMessage.value = "";
try {
const { data } = await getKickoff(studyId, meetingId);
Object.assign(detail, data);
@@ -213,19 +209,11 @@ const load = async () => {
sites.value = list;
const matched = list.find((site: any) => site.id === data.site_id);
Object.assign(siteInfo, matched || { name: "" });
} else {
Object.assign(siteInfo, { name: "" });
}
} else { Object.assign(siteInfo, { name: "" }); }
const siteName = siteInfo.name || TEXT.common.fallback;
study.setViewContext({
siteName,
pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
study.setViewContext({ siteName, pageTitle: siteInfo.name ? `${siteInfo.name} ${TEXT.modules.startupMeetingAuth.kickoffTab}` : TEXT.modules.startupMeetingAuth.kickoffDetailTitle });
} catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
finally { loading.value = false; }
};
const loadTraining = async () => {
@@ -234,134 +222,250 @@ const loadTraining = async () => {
try {
const { data } = await listTrainingAuthorizations(studyId, { site_name: siteInfo.name });
trainingItems.value = Array.isArray(data) ? data : data.items || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loadingTraining.value = false;
}
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); }
finally { loadingTraining.value = false; }
};
const startTrainingEdit = (row: any) => {
if (isReadOnly.value) return;
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
editingTrainingId.value = row?.id || undefined;
trainingEditorVisible.value = true;
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
editingTrainingId.value = row?.id || undefined; trainingEditorVisible.value = true;
};
const startTrainingAdd = () => {
if (isReadOnly.value) return;
if (!canCreateAuth.value) {
ElMessage.warning("权限不足");
return;
}
editingTrainingId.value = undefined;
trainingEditorVisible.value = true;
if (!canCreateAuth.value) { ElMessage.warning("权限不足"); return; }
editingTrainingId.value = undefined; trainingEditorVisible.value = true;
};
const removeTraining = async (row: any) => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canDeleteAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
if (!canDeleteAuth.value) { ElMessage.warning("权限不足"); return; }
if (!studyId) return;
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteTrainingAuthorization(studyId, row.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
loadTraining();
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed);
}
try { await deleteTrainingAuthorization(studyId, row.id); ElMessage.success(TEXT.common.messages.deleteSuccess); loadTraining(); }
catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
};
const startEdit = () => {
if (isReadOnly.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isReadOnly.value) { ElMessage.warning("中心已停用"); return; }
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
editorVisible.value = true;
};
const handleEditorSaved = () => {
load();
attachmentRefreshKey.value += 1;
};
const handleTrainingEditorSaved = () => {
loadTraining();
};
const handleEditorSaved = () => { load(); attachmentRefreshKey.value += 1; };
const handleTrainingEditorSaved = () => { loadTraining(); };
onMounted(async () => {
if (studyId) {
const usersReq = isAdmin.value ? fetchUsers({ limit: 500 }) : Promise.resolve(null);
let membersReq: Promise<any> = Promise.resolve(null);
if (canReadMembers.value) {
membersReq = listMembers(studyId, { limit: 500 });
}
if (canReadMembers.value) membersReq = listMembers(studyId, { limit: 500 });
const [membersResp, usersResp] = await Promise.all([membersReq, usersReq]).catch(() => [null, null] as const);
if (membersResp?.data) {
members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
}
if (usersResp?.data && !Array.isArray(usersResp.data)) {
users.value = usersResp.data.items || [];
}
if (membersResp?.data) members.value = Array.isArray(membersResp.data) ? membersResp.data : membersResp.data.items || [];
if (usersResp?.data && !Array.isArray(usersResp.data)) users.value = usersResp.data.items || [];
}
await load();
await loadTraining();
await loadAll();
});
</script>
<style scoped>
.page {
display: flex; flex-direction: column; gap: 16px; padding: 4px 0; background: #f4f5f7;
}
/* ==================== Top Section ==================== */
.top-section {
background: #ffffff;
border-radius: 12px;
padding: 24px 28px;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
}
.top-header {
display: flex;
flex-direction: column;
gap: 0;
}
.training-section,
.attachment-section {
padding-left: 0;
padding-right: 0;
}
.training-section {
padding-top: 0;
}
.training-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
align-items: flex-start;
margin-bottom: 20px;
}
.training-title {
font-size: 15px;
font-weight: 600;
color: var(--ctms-text-main);
}
.status-with-date {
display: inline-flex;
.top-header-left {
display: flex;
flex-direction: column;
gap: 2px;
gap: 4px;
}
.top-breadcrumb {
font-size: 11px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.top-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: -0.02em;
line-height: 1.2;
}
.status-date-small {
font-size: 12px;
color: var(--ctms-text-secondary);
.top-edit-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
background: #f4f3ff;
border: 1px solid #e0ddff;
color: #6c5ce7;
transition: all 0.15s ease;
}
.top-edit-btn:hover {
background: #6c5ce7;
border-color: #6c5ce7;
color: #ffffff;
}
/* ==================== Info Bar ==================== */
.info-bar {
display: flex;
align-items: center;
gap: 0;
background: #f8f9fc;
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: hidden;
}
.info-block {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 20px;
min-width: 0;
}
.info-block + .info-block {
border-left: 1px solid #edf0f5;
}
.info-block-label {
font-size: 10px;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-block-value {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.info-block-value--success { color: #16a34a; }
.info-block-value--info { color: #6b7280; }
.info-divider { display: none; }
/* ==================== Section Cards ==================== */
.section-card {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
}
.section-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.section-card-title-row {
display: flex;
align-items: center;
gap: 8px;
color: #6c5ce7;
}
.section-card-title {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
.section-card-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: #f0f0f5;
color: #6b7280;
font-size: 11px;
font-weight: 700;
}
.add-btn {
height: 28px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
padding: 0 10px;
}
.section-card-body {
padding: 16px 20px;
}
/* ==================== Training Table ==================== */
.training-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.training-table :deep(.el-table__inner-wrapper::before) { display: none; }
.training-table :deep(th.el-table__cell) {
background: #f8f9fc;
color: #6b7280;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 10px 14px;
border-bottom: 1px solid #edf0f5 !important;
}
.training-table :deep(td.el-table__cell) {
padding: 10px 14px;
color: #1a1a2e;
font-size: 13px;
font-weight: 500;
}
.training-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
/* ==================== Cell Helpers ==================== */
.cell-nowrap { white-space: nowrap; }
.cell-primary { font-weight: 600; color: #1a1a2e; }
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
.cell-actions :deep(.el-button) { font-size: 12px; padding: 0; height: auto; }
.cell-actions :deep(.el-button + .el-button) { margin-left: 0; }
/* ==================== Status ==================== */
.status-cell { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.status-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.status-dot--yes { background: #22c55e; }
.status-dot--no { background: #d1d5db; }
.status-date { font-size: 11px; color: #9ca3af; margin-left: 2px; }
/* ==================== Empty State ==================== */
.table-empty { min-height: 160px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; }
.empty-icon { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; background: #f3f4f6; color: #c4c4c4; }
.table-empty span { font-size: 13px; font-weight: 500; color: #a3a3a3; }
</style>
@@ -27,7 +27,7 @@
<el-form v-else ref="formRef" :model="form" label-position="top" class="startup-editor-form">
<div class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-basic"></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#3b82f6;flex-shrink:0"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
{{ TEXT.modules.startupMeetingAuth.kickoffDetailTitle }}
</div>
<el-row :gutter="16">
@@ -60,7 +60,7 @@
<div v-if="meetingId" class="form-group">
<div class="form-group-title">
<span class="group-dot group-dot-attachment"></span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:#8b5cf6;flex-shrink:0"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{{ TEXT.common.labels.attachments }}
</div>
<AttachmentList
+269 -66
View File
@@ -1,32 +1,78 @@
<template>
<div class="page">
<div class="unified-shell">
<section class="unified-section" v-loading="loading">
<div class="unified-section-header">
<div class="ctms-section-title">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
<div class="ctms-section-actions">
<el-button v-if="canUpdateAuth" type="primary" :disabled="isInactiveSite" @click="goEdit">{{ TEXT.common.actions.edit }}</el-button>
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="load">{{ TEXT.common.actions.retry }}</el-button>
</template>
</StateError>
<StateLoading v-else-if="loading" :rows="6" />
<template v-else>
<!-- ==================== 顶部信息卡 ==================== -->
<div class="hero-card">
<div class="hero-accent" />
<div class="hero-body">
<div class="hero-top">
<div class="hero-main">
<div class="hero-label">{{ TEXT.modules.startupMeetingAuth.trainingDetailTitle }}</div>
<h1 class="hero-title">{{ detail.name || TEXT.common.fallback }}</h1>
<div class="hero-role-tag">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</div>
</div>
<el-button v-if="canUpdateAuth" :disabled="isInactiveSite" @click="goEdit" class="hero-action-btn">
<el-icon class="el-icon--left"><Edit /></el-icon>
{{ TEXT.common.actions.edit }}
</el-button>
</div>
<div class="hero-meta">
<div class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
<span class="meta-label">{{ TEXT.common.fields.site }}</span>
<span class="meta-value">{{ detail.site_name || TEXT.common.fallback }}</span>
</div>
<div class="meta-divider" />
<div class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
<span class="meta-label">{{ TEXT.common.fields.trainedDate }}</span>
<span class="meta-value">{{ displayDate(detail.trained_date) }}</span>
</div>
<div class="meta-divider" />
<div class="meta-item">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
<span class="meta-label">{{ TEXT.common.fields.authorizedDate }}</span>
<span class="meta-value">{{ displayDate(detail.authorized_date) }}</span>
</div>
</div>
<div class="hero-status-row">
<div class="hero-status-item">
<span class="hero-status-circle" :class="detail.trained ? 'circle--yes' : 'circle--no'" />
<div class="hero-status-text">
<span class="hero-status-label">{{ TEXT.common.fields.trained }}</span>
<span class="hero-status-value">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
</div>
</div>
<div class="hero-status-item">
<span class="hero-status-circle" :class="detail.authorized ? 'circle--yes' : 'circle--no'" />
<div class="hero-status-text">
<span class="hero-status-label">{{ TEXT.common.fields.authorized }}</span>
<span class="hero-status-value">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</span>
</div>
</div>
</div>
</div>
<el-descriptions :column="2" border>
<el-descriptions-item :label="TEXT.common.fields.name">{{ detail.name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.labels.role">{{ displayEnum(TEXT.enums.userRole, detail.role) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.site">{{ detail.site_name || TEXT.common.fallback }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.trained">{{ detail.trained ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.trainedDate">{{ displayDate(detail.trained_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.authorized">{{ detail.authorized ? TEXT.common.boolean.yes : TEXT.common.boolean.no }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.authorizedDate">{{ displayDate(detail.authorized_date) }}</el-descriptions-item>
<el-descriptions-item :label="TEXT.common.fields.remark" :span="2">{{ detail.remark || TEXT.common.fallback }}</el-descriptions-item>
</el-descriptions>
</section>
</div>
<TrainingEditorDrawer
v-model="editorVisible"
:record-id="recordId"
:study-id="studyId"
:sites="sites"
@saved="handleEditorSaved"
/>
</div>
<!-- ==================== 备注 ==================== -->
<div v-if="detail.remark" class="remark-card">
<div class="remark-header">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<span>{{ TEXT.common.fields.remark }}</span>
</div>
<p class="remark-text">{{ detail.remark }}</p>
</div>
</template>
<TrainingEditorDrawer v-model="editorVisible" :record-id="recordId" :study-id="studyId" :sites="sites" @saved="handleEditorSaved" />
</div>
</template>
@@ -34,10 +80,13 @@
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute } from "vue-router";
import { ElMessage } from "element-plus";
import { Edit } from "@element-plus/icons-vue";
import { useStudyStore } from "../../store/study";
import { getTrainingAuthorization } from "../../api/startup";
import { fetchSites } from "../../api/sites";
import { displayDate, displayEnum } from "../../utils/display";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { TEXT } from "../../locales";
import { usePermission } from "../../utils/permission";
import TrainingEditorDrawer from "./TrainingEditorDrawer.vue";
@@ -46,86 +95,240 @@ const route = useRoute();
const study = useStudyStore();
const { can } = usePermission();
const loading = ref(false);
const errorMessage = ref("");
const editorVisible = ref(false);
const recordId = route.params.recordId as string;
const studyId = study.currentStudy?.id || "";
const sites = ref<any[]>([]);
const detail = reactive<any>({
name: "",
role: "",
site_name: "",
trained: false,
authorized: false,
trained_date: "",
authorized_date: "",
remark: "",
name: "", role: "", site_name: "", trained: false, authorized: false, trained_date: "", authorized_date: "", remark: "",
});
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.name) map[site.name] = !!site.is_active;
});
sites.value.forEach((site) => { if (site?.name) map[site.name] = !!site.is_active; });
return map;
});
const canUpdateAuth = computed(() => can("startup.auth.update"));
const isInactiveSite = computed(() => !!detail.site_name && siteActiveMap.value[detail.site_name] === false);
const isReadOnly = computed(() => !canUpdateAuth.value || isInactiveSite.value);
const loadSites = async () => {
if (!studyId) return;
try {
const { data } = await fetchSites(studyId, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
} catch { sites.value = []; }
};
const load = async () => {
if (!studyId || !recordId) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await getTrainingAuthorization(studyId, recordId);
Object.assign(detail, data);
study.setViewContext({
siteName: detail.site_name || TEXT.common.fallback,
pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle,
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
loading.value = false;
}
study.setViewContext({ siteName: detail.site_name || TEXT.common.fallback, pageTitle: detail.name || TEXT.modules.startupMeetingAuth.trainingDetailTitle });
} catch (e: any) { errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed; }
finally { loading.value = false; }
};
const goEdit = () => {
if (!canUpdateAuth.value) {
ElMessage.warning("权限不足");
return;
}
if (isInactiveSite.value) {
ElMessage.warning("中心已停用");
return;
}
if (!canUpdateAuth.value) { ElMessage.warning("权限不足"); return; }
if (isInactiveSite.value) { ElMessage.warning("中心已停用"); return; }
editorVisible.value = true;
};
const handleEditorSaved = () => { load(); };
const handleEditorSaved = () => {
load();
};
onMounted(async () => {
await loadSites();
load();
});
onMounted(async () => { await loadSites(); load(); });
</script>
<style scoped>
.page {
display: flex; flex-direction: column; gap: 16px; padding: 4px 0; background: #f4f5f7;
}
/* ==================== Hero Card ==================== */
.hero-card {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
}
.hero-accent {
height: 4px;
background: linear-gradient(90deg, #6c5ce7 0%, #a29bfe 50%, #6c5ce7 100%);
}
.hero-body {
padding: 24px 28px 20px;
}
.hero-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.hero-main {
display: flex;
flex-direction: column;
gap: 0;
gap: 6px;
}
.hero-label {
font-size: 11px;
font-weight: 600;
color: #8a8a8a;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.hero-title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: #1a1a2e;
letter-spacing: -0.02em;
line-height: 1.2;
}
.hero-role-tag {
display: inline-flex;
align-items: center;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
background: #f4f3ff;
color: #6c5ce7;
border-radius: 4px;
width: fit-content;
}
.hero-action-btn {
height: 34px;
border-radius: 8px;
padding: 0 16px;
font-weight: 500;
background: #f4f3ff;
border: 1px solid #e0ddff;
color: #6c5ce7;
transition: all 0.15s ease;
}
.hero-action-btn:hover {
background: #6c5ce7;
border-color: #6c5ce7;
color: #ffffff;
}
/* ==================== Hero Meta Row ==================== */
.hero-meta {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fc;
border: 1px solid #edf0f5;
border-radius: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
color: #6b7280;
}
.meta-item svg { flex-shrink: 0; }
.meta-label {
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
}
.meta-value {
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
}
.meta-divider {
width: 1px;
height: 16px;
background: #e0e0e0;
}
/* ==================== Status Row ==================== */
.hero-status-row {
display: flex;
gap: 24px;
}
.hero-status-item {
display: flex;
align-items: center;
gap: 10px;
}
.hero-status-circle {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.circle--yes { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.15); }
.circle--no { background: #d1d5db; }
.hero-status-text {
display: flex;
flex-direction: column;
gap: 1px;
}
.hero-status-label {
font-size: 11px;
font-weight: 500;
color: #8a8a8a;
}
.hero-status-value {
font-size: 14px;
font-weight: 600;
color: #1a1a2e;
}
/* ==================== Remark Card ==================== */
.remark-card {
background: #ffffff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0,0,0,0.04), 0 4px 16px rgba(0,0,0,0.03);
padding: 20px 24px;
}
.remark-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
margin-bottom: 10px;
}
.remark-header svg { color: #6c5ce7; }
.remark-text {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: #4b5563;
}
</style>
@@ -510,7 +510,6 @@ watch(
display: flex;
flex-direction: column;
gap: 0;
border-top: 1px solid #edf2f7;
}
.batch-entry {