feat(ia): polish project operation workspaces
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user