Files
ctms/frontend/src/views/documents/DocumentDetail.vue
T
Cheng Zhou ab59476d10
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(桌面与监控): 完善工作台导航和登录活动定位
- 优化桌面标签、上下文标题、前进后退、导航栏隐藏和原生菜单体验

- 补充登录会话 IP 采集、地理位置回退、管理端展示及数据库迁移

- 更新桌面发布检查、运维文档和前后端测试覆盖
2026-07-13 16:03:20 +08:00

1277 lines
53 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page">
<StateError v-if="errorMessage" :description="errorMessage">
<template #action>
<el-button type="primary" @click="loadDetail">{{ 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-bg-pattern" />
<div class="hero-content">
<div class="hero-top">
<div class="hero-title-group">
<h2 class="hero-title">{{ detail.title || TEXT.modules.fileVersionManagement.title }}</h2>
<el-tag size="small" effect="plain" class="hero-tag">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-tag>
<el-tag v-if="isReadOnly" size="small" effect="plain" type="info" class="hero-tag">中心已停用</el-tag>
</div>
<el-button v-if="canUpdateDocument" type="primary" @click="openEdit" 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--type">
<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.modules.fileVersionManagement.fields.docType }}</span>
<span class="stat-value">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</span>
</div>
</div>
<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.modules.fileVersionManagement.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--version">
<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="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.modules.fileVersionManagement.labels.currentEffective }}</span>
<span class="stat-value">{{ currentEffectiveVersion?.version_no || TEXT.common.fallback }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--date">
<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">版本日期</span>
<span class="stat-value">{{ formatDateOnly(currentEffectiveVersion?.effective_at || currentEffectiveVersion?.created_at || null) }}</span>
</div>
</div>
<div class="hero-stat">
<div class="stat-icon-wrap stat-icon-wrap--update">
<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="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</div>
<div class="stat-body">
<span class="stat-label">{{ TEXT.modules.fileVersionManagement.columns.updatedAt }}</span>
<span class="stat-value">{{ formatDateOnly(detail.updated_at) }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== 版本与分发 Tabs ==================== -->
<div class="section-card">
<div class="tabs-header">
<el-tabs v-model="activeTab" @tab-change="handleTabChange" class="ctms-tabs">
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.versions" name="versions">
<div class="table-card-inner">
<el-table :data="detail.version_timeline" v-loading="versionLoading" class="version-table" style="width: 100%" table-layout="fixed">
<el-table-column prop="version_no" :label="TEXT.modules.fileVersionManagement.fields.versionNo">
<template #default="{ row }">
<span class="cell-primary cell-nowrap">V{{ row.version_no }}</span>
</template>
</el-table-column>
<el-table-column label="版本日期">
<template #default="{ row }">
<span class="cell-nowrap">{{ formatDateOnly(row.effective_at || row.created_at) }}</span>
</template>
</el-table-column>
<el-table-column prop="change_summary" :label="TEXT.modules.fileVersionManagement.fields.changeSummary" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ row.change_summary || TEXT.common.fallback }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.createdBy" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-nowrap">{{ displayCreator(row.created_by_user, row.created_by) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.parentVersion">
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ displayParentVersion(row.parent_version_id) }}</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.effectiveAt">
<template #default="{ row }">
<span class="cell-muted datetime-stack">
<span>{{ splitDateTime(row.created_at).date }}</span>
<span class="datetime-stack__time">{{ splitDateTime(row.created_at).time }}</span>
</span>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="160" fixed="right">
<template #default="{ row }">
<div class="cell-actions">
<el-button link type="primary" size="small" @click="previewVersion(row)" v-if="canReadDocument">
{{ TEXT.common.labels.preview }}
</el-button>
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
另存为
</el-button>
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
打开
</el-button>
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
<el-tab-pane :label="TEXT.modules.fileVersionManagement.tabs.distributions" name="distributions">
<div class="table-card-inner">
<el-table :data="distributions" v-loading="distributionLoading" class="version-table" style="width: 100%" table-layout="fixed">
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.targetType">
<template #default="{ row }">
<el-tag effect="plain" type="info" class="table-tag">{{ displayEnum(TEXT.enums.distributionTargetType, row.target_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column :label="TEXT.modules.fileVersionManagement.fields.target" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-primary cell-nowrap">{{ displayTarget(row) }}</span>
</template>
</el-table-column>
<el-table-column label="抄送时间">
<template #default="{ row }">
<span class="cell-muted cell-nowrap">{{ formatDate(row.created_at) }}</span>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<div class="tabs-actions">
<el-button v-if="activeTab === 'versions' && canUpdateDocument" type="primary" size="small" @click="openUpload" class="tab-action-btn">
<el-icon class="el-icon--left"><Upload /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.uploadVersion }}
</el-button>
<el-button v-if="activeTab === 'distributions' && canUpdateDocument" type="primary" size="small" @click="openDistribute" class="tab-action-btn">
<el-icon class="el-icon--left"><Share /></el-icon>
{{ TEXT.modules.fileVersionManagement.actions.distribute }}
</el-button>
</div>
</div>
</div>
</template>
<el-dialog v-if="previewVisible" append-to-body v-model="previewVisible" :title="previewTitle" width="720px" :close-on-click-modal="false">
<div v-if="previewError" class="preview-error">
{{ previewError }}
</div>
<template v-else>
<div v-loading="previewLoading" class="preview-body">
<img v-if="previewType === 'image' && previewUrl" :src="previewUrl" class="preview-media" />
<iframe v-else-if="previewType === 'pdf' && previewUrl" :src="previewUrl" class="preview-frame" />
</div>
</template>
</el-dialog>
<!-- ==================== 编辑抽屉 ==================== -->
<el-drawer v-if="editorVisible" v-model="editorVisible" direction="rtl" size="620px" :close-on-click-modal="true" :before-close="editorDirtyGuard.beforeClose" :show-close="false" destroy-on-close class="editor-drawer">
<template #header>
<div class="editor-header"><div class="editor-title">{{ TEXT.common.actions.edit }}</div></div>
</template>
<el-form ref="editorFormRef" :model="editorForm" :rules="editorRules" label-position="top" class="detail-form">
<div class="form-section">
<div class="form-section-title"><span class="dot dot--blue" />{{ TEXT.common.labels.basicInfo }}</div>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.title" prop="title">
<el-input v-model="editorForm.title" />
</el-form-item>
<div class="field-grid field-grid--2">
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.scope" prop="scope_type">
<el-select v-model="editorForm.scope_type" class="full-width">
<el-option v-for="item in scopeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.docType" prop="doc_type">
<el-select v-model="editorForm.doc_type" class="full-width">
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
</div>
<el-form-item v-if="editorForm.scope_type === 'SITE'" :label="TEXT.modules.fileVersionManagement.fields.site" prop="site_id">
<el-select v-model="editorForm.site_id" filterable class="full-width">
<el-option v-for="item in siteOptions" :key="item.value" :label="item.label" :value="item.value" :disabled="item.disabled" />
</el-select>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="editorVisible = false" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="savingEditor" @click="submitEditor" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template>
</el-drawer>
<!-- ==================== 上传版本抽屉 ==================== -->
<el-drawer v-if="uploadVisible" v-model="uploadVisible" direction="rtl" size="620px" :close-on-click-modal="true" :before-close="uploadDirtyGuard.beforeClose" :show-close="false" destroy-on-close class="upload-drawer">
<template #header>
<div class="upload-header">
<div class="upload-header-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<div class="upload-header-text">
<div class="upload-header-title">{{ TEXT.modules.fileVersionManagement.dialog.uploadTitle }}</div>
<div class="upload-header-subtitle">填写版本信息并选择文件</div>
</div>
</div>
</template>
<el-form ref="uploadFormRef" :model="uploadForm" :rules="uploadRules" label-position="top" class="detail-form">
<div class="upload-form-card">
<div class="upload-form-card-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
版本信息
</div>
<div class="field-grid field-grid--2">
<el-form-item label="版本号" prop="version_no">
<el-input v-model="uploadForm.version_no" :placeholder="TEXT.modules.fileVersionManagement.placeholders.versionNo" />
</el-form-item>
<el-form-item label="版本日期" prop="version_date">
<el-date-picker v-model="uploadForm.version_date" type="date" value-format="YYYY-MM-DD" :placeholder="TEXT.common.placeholders.select" style="width: 100%" />
</el-form-item>
</div>
<el-form-item label="变更说明" prop="change_summary">
<el-input v-model="uploadForm.change_summary" type="textarea" :rows="3" :placeholder="TEXT.common.placeholders.input" />
</el-form-item>
<el-form-item label="基于版本">
<el-select v-model="uploadForm.parent_version_id" clearable placeholder="可选,默认基于当前最新版本" style="width: 100%">
<el-option v-for="item in detail.version_timeline" :key="item.id" :label="`V${item.version_no}`" :value="item.id" />
</el-select>
</el-form-item>
</div>
<div class="upload-form-card">
<div class="upload-form-card-title">
<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="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="upload-zone"
:class="{ 'has-file': uploadFile }"
@click="triggerFileInput"
@dragover.prevent
@drop.prevent="handleUploadDrop"
>
<template v-if="!uploadFile">
<div class="upload-zone-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
</div>
<span class="upload-zone-text">点击选择文件或将文件拖拽到此处</span>
<span class="upload-zone-hint">支持 PDFWordExcelPPTTXT图片等格式</span>
</template>
<template v-else>
<div class="upload-file-preview">
<div class="upload-file-icon">
<svg width="20" height="20" 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="upload-file-info">
<span class="upload-file-name">{{ uploadFile.name }}</span>
<span class="upload-file-size">{{ (uploadFile.size / 1024).toFixed(1) }} KB</span>
</div>
<button type="button" class="upload-file-remove" @click.stop="removeFile">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</template>
</div>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="uploadVisible = false" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="uploading" @click="submitUpload" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template>
</el-drawer>
<!-- ==================== 分发抽屉 ==================== -->
<el-drawer v-if="distributeVisible" v-model="distributeVisible" direction="rtl" size="620px" :close-on-click-modal="true" :before-close="distributeDirtyGuard.beforeClose" :show-close="false" destroy-on-close class="editor-drawer">
<template #header>
<div class="editor-header"><div class="editor-title">{{ TEXT.modules.fileVersionManagement.dialog.distributeTitle }}</div></div>
</template>
<el-form :model="distributeForm" label-position="top" class="detail-form">
<div class="form-section">
<div class="form-section-title"><span class="dot dot--blue" />{{ TEXT.modules.fileVersionManagement.fields.targetList }}</div>
<el-form-item :label="TEXT.modules.fileVersionManagement.fields.targetList">
<div class="target-list w-full">
<div class="target-toolbar">
<el-select v-model="distributeForm.target_type" class="target-type-preset" :placeholder="TEXT.common.labels.all" @change="onTargetTypeChange">
<el-option :label="TEXT.common.labels.all" value="ALL" />
<el-option :label="TEXT.enums.distributionTargetType.ROLE" value="ROLE" />
<el-option :label="TEXT.enums.distributionTargetType.USER" value="USER" />
</el-select>
</div>
<div class="target-row">
<template v-if="distributeForm.target_type === 'ROLE'">
<el-select v-model="distributeForm.role_ids" multiple filterable clearable :placeholder="TEXT.common.placeholders.select" class="target-select">
<el-option v-for="role in roleOptions" :key="role.value" :label="role.label" :value="role.value" />
</el-select>
</template>
<template v-else-if="distributeForm.target_type === 'USER'">
<el-select v-model="distributeForm.user_ids" multiple filterable clearable :placeholder="TEXT.common.placeholders.select" class="target-select">
<el-option v-for="member in memberOptions" :key="member.value" :label="member.label" :value="member.value" />
</el-select>
</template>
<div v-else class="target-all">
<el-tag effect="plain" type="info">{{ TEXT.common.labels.all }}</el-tag>
</div>
</div>
</div>
</el-form-item>
</div>
</el-form>
<template #footer>
<div class="drawer-footer">
<el-button @click="distributeVisible = false" class="footer-btn">{{ TEXT.common.actions.cancel }}</el-button>
<el-button type="primary" :loading="distributing" @click="submitDistribute" class="footer-btn footer-btn--primary">{{ TEXT.common.actions.confirm }}</el-button>
</div>
</template>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
import {
createDistributions,
deleteDocumentVersion,
downloadDocumentVersion,
fetchDocumentDetail,
listDistributions,
uploadDocumentVersion,
updateDocument,
} from "../../api/documents";
import { listMembers } from "../../api/members";
import { fetchSites } from "../../api/sites";
import { fetchUsers } from "../../api/users";
import type { Distribution, DocumentDetail, DocumentVersion } from "../../types/documents";
import type { Site, StudyMember } from "../../types/api";
import type { UserInfo } from "../../types/api";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import { TEXT } from "../../locales";
import { displayDateTime, displayEnum, displayText, getUserDisplayName } from "../../utils/display";
import { useDrawerDirtyGuard } from "../../utils/drawerDirtyGuard";
import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute();
const auth = useAuthStore();
const study = useStudyStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const { can } = usePermission();
const documentId = computed(() => route.params.id as string);
let desktopRefreshCleanup: (() => void) | undefined;
const loading = ref(false);
const errorMessage = ref("");
const versionLoading = ref(false);
const distributionLoading = ref(false);
const detail = reactive<DocumentDetail>({
id: "",
trial_id: "",
site_id: null,
doc_type: "",
title: "",
scope_type: "GLOBAL",
current_effective_version_id: null,
current_effective_version: null,
created_at: "",
updated_at: "",
version_timeline: [],
distribution_stats: { total: 0, received: 0, read: 0, trained: 0, overdue: 0 },
});
const activeTab = ref("versions");
const distributions = ref<Distribution[]>([]);
const users = ref<UserInfo[]>([]);
const sites = ref<Site[]>([]);
const members = ref<StudyMember[]>([]);
const siteActiveMap = computed(() => {
const map: Record<string, boolean> = {};
sites.value.forEach((site) => {
if (site?.id) map[site.id] = !!site.is_active;
});
return map;
});
const isReadOnly = computed(() => !!detail.site_id && siteActiveMap.value[detail.site_id] === false);
const isAdmin = computed(() => auth.user?.is_admin);
const canReadDocument = computed(() => can("documents.read"));
const canUpdateDocument = computed(() => !isReadOnly.value && can("documents.update"));
const canDeleteDocument = computed(() => !isReadOnly.value && can("documents.delete"));
const canReadMembers = computed(() => can("project.members.list"));
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
value,
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
}));
const scopeOptions = [
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
];
const siteOptions = computed(() =>
sites.value.map((site) => ({ value: site.id, label: site.name, disabled: !site.is_active }))
);
const memberUserMap = computed(() =>
members.value.reduce<Record<string, string>>((acc, member) => {
const user = (member as any).user;
const name = getUserDisplayName(user) || user?.email || user?.username || member.user_id;
acc[member.user_id] = name;
return acc;
}, {})
);
const userMap = computed(() => {
const map = { ...memberUserMap.value };
users.value.forEach((user) => {
map[user.id] = getUserDisplayName(user) || user.email || user.username || user.id;
});
return map;
});
const displayCreator = (user?: UserInfo | null, userId?: string | null) => {
const name = getUserDisplayName(user);
if (name) return name;
if (!userId) return TEXT.common.fallback;
return userMap.value[userId] || TEXT.common.fallback;
};
const displayParentVersion = (parentId?: string | null) => {
if (!parentId) return TEXT.common.fallback;
const hit = detail.version_timeline?.find((item: any) => item.id === parentId);
return hit?.version_no ? `V${hit.version_no}` : TEXT.common.fallback;
};
const displaySite = (siteId?: string | null) => {
if (!siteId) return TEXT.enums.scopeType.GLOBAL;
return sites.value.find((s) => s.id === siteId)?.name || siteId;
};
const syncBreadcrumbContext = () => {
study.setViewContext({
siteName: displaySite(detail.site_id),
pageTitle: detail.title || TEXT.modules.fileVersionManagement.title,
objectType: detail.doc_type ? displayText(detail.doc_type, TEXT.enums.documentType) : undefined,
});
};
const currentEffectiveVersion = computed(() => {
if (!detail.current_effective_version_id) return null;
return detail.version_timeline?.find((item: any) => item.id === detail.current_effective_version_id) || null;
});
const distributionVersionId = computed(() => {
if (detail.current_effective_version_id) return detail.current_effective_version_id;
return detail.version_timeline?.[0]?.id || null;
});
const editorVisible = ref(false);
const savingEditor = ref(false);
const editorFormRef = ref<FormInstance>();
const editorForm = reactive({
title: "",
scope_type: "GLOBAL",
site_id: "",
doc_type: "",
});
const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const uploadForm = reactive({
version_no: "",
version_date: "",
change_summary: "",
parent_version_id: "",
});
const uploadFile = ref<File | null>(null);
const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
form: uploadForm,
file: uploadFile.value
? {
name: uploadFile.value.name,
size: uploadFile.value.size,
lastModified: uploadFile.value.lastModified,
}
: null,
}));
const triggerFileInput = async () => {
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
};
const handleUploadDrop = (event: DragEvent) => {
const [file] = Array.from(event.dataTransfer?.files || []);
if (file) uploadFile.value = file;
};
const removeFile = () => { uploadFile.value = null; };
const distributeVisible = ref(false);
const distributing = ref(false);
const distributeForm = reactive({
target_type: "ALL" as "ALL" | "ROLE" | "USER",
role_ids: [] as string[],
user_ids: [] as string[],
});
const distributeDirtyGuard = useDrawerDirtyGuard(() => distributeForm);
const previewVisible = ref(false);
const previewLoading = ref(false);
const previewUrl = ref("");
const previewObjectUrl = ref("");
const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref<string>(TEXT.common.labels.preview);
const previewError = ref("");
const uploadRules: FormRules = {
version_no: [
{ required: true, message: TEXT.common.messages.required, trigger: "blur" },
{
validator: (_rule, value, callback) => {
const valid = /^\d+\.\d+$/.test(value);
callback(valid ? undefined : new Error(TEXT.modules.fileVersionManagement.messages.versionNoInvalid));
},
trigger: "blur",
},
],
version_date: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
};
const editorRules: FormRules = {
title: [{ required: true, message: TEXT.common.messages.required, trigger: "blur" }],
scope_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
site_id: [
{
validator: (_rule, value, callback) => {
if (editorForm.scope_type === "SITE" && !value) {
callback(new Error(TEXT.common.messages.required));
return;
}
callback();
},
trigger: "change",
},
],
doc_type: [{ required: true, message: TEXT.common.messages.required, trigger: "change" }],
};
const roleOptions = computed(() => {
const fallbackRoles = ["CRA", "PM", "CTA", "PV", "QA"];
const roles = new Set(members.value.map((m) => m.role_in_study).filter(Boolean));
const values = roles.size ? Array.from(roles) : fallbackRoles;
return values.map((value) => ({
value,
label: roleLabel(value),
}));
});
const memberOptions = computed(() =>
members.value.map((m) => {
const name = getUserDisplayName((m as any).user) || (m as any).user?.email || m.user_id;
return {
value: m.user_id,
label: `${name}${roleLabel(m.role_in_study)}`,
role: m.role_in_study,
};
})
);
const displayTarget = (row: Distribution) => {
if (row.target_type === "ROLE") {
return roleLabel(row.target_id);
}
if (row.target_type === "USER") {
const hit = memberOptions.value.find((m) => m.value === row.target_id);
return hit?.label || row.target_id;
}
return row.target_id;
};
const loadMembers = async () => {
if (!canReadMembers.value) {
members.value = [];
return;
}
if (!detail.trial_id) {
members.value = [];
return;
}
try {
const { data } = await listMembers(detail.trial_id, { limit: 500 });
members.value = Array.isArray(data) ? data : data.items || [];
} catch {
members.value = [];
}
};
const loadSites = async () => {
if (!detail.trial_id) {
sites.value = [];
return;
}
try {
const { data } = await fetchSites(detail.trial_id, { limit: 500 });
sites.value = Array.isArray(data) ? data : data.items || [];
} catch {
sites.value = [];
}
};
const loadUsers = async () => {
if (!isAdmin.value) return;
try {
const { data } = await fetchUsers({ limit: 500 });
users.value = (data as any).items || data || [];
} catch {
users.value = [];
}
};
const loadDetail = async () => {
if (!documentId.value) return;
loading.value = true;
errorMessage.value = "";
try {
const { data } = await fetchDocumentDetail(documentId.value);
Object.assign(detail, data);
if (!users.value.length) await loadUsers();
if (!members.value.length) await loadMembers();
if (!sites.value.length) await loadSites();
syncBreadcrumbContext();
if (activeTab.value === "distributions") await loadDistributions();
} catch (e: any) {
errorMessage.value = e?.response?.data?.message || TEXT.common.messages.loadFailed;
} finally {
loading.value = false;
}
};
const loadDistributions = async () => {
if (!distributionVersionId.value) { distributions.value = []; return; }
distributionLoading.value = true;
try {
const { data } = await listDistributions(distributionVersionId.value);
distributions.value = data || [];
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed);
} finally {
distributionLoading.value = false;
}
};
const handleTabChange = async (name: string) => {
if (name === "distributions") await loadDistributions();
};
const openEdit = () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
Object.assign(editorForm, {
title: detail.title || "", scope_type: detail.scope_type || "GLOBAL",
site_id: detail.site_id || "", doc_type: detail.doc_type || "",
});
editorFormRef.value?.clearValidate();
editorDirtyGuard.syncBaseline();
editorVisible.value = true;
};
const submitEditor = async () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
if (!editorFormRef.value) return;
await editorFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value) return;
savingEditor.value = true;
try {
await updateDocument(documentId.value, { title: editorForm.title.trim(), scope_type: editorForm.scope_type, site_id: editorForm.scope_type === "SITE" ? editorForm.site_id : null, doc_type: editorForm.doc_type });
editorVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.common.messages.updateSuccess);
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); }
finally { savingEditor.value = false; }
});
};
const openUpload = () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
uploadForm.version_no = ""; uploadForm.version_date = ""; uploadForm.change_summary = ""; uploadForm.parent_version_id = ""; uploadFile.value = null;
uploadDirtyGuard.syncBaseline();
uploadVisible.value = true;
};
const submitUpload = async () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
if (!uploadFormRef.value) return;
await uploadFormRef.value.validate(async (valid) => {
if (!valid || !documentId.value || !uploadFile.value) {
if (!uploadFile.value) ElMessage.warning(TEXT.modules.fileVersionManagement.messages.missingFile);
return;
}
uploading.value = true;
try {
const formData = new FormData();
formData.append("version_no", uploadForm.version_no);
if (uploadForm.version_date) formData.append("version_date", uploadForm.version_date);
if (uploadForm.change_summary) formData.append("change_summary", uploadForm.change_summary);
if (uploadForm.parent_version_id) formData.append("parent_version_id", uploadForm.parent_version_id);
formData.append("file", uploadFile.value);
await uploadDocumentVersion(documentId.value, formData);
uploadVisible.value = false;
await loadDetail();
ElMessage.success(TEXT.modules.fileVersionManagement.messages.uploadSuccess);
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); }
finally { uploading.value = false; }
});
};
const getFilename = (header?: string | null) => {
if (!header) return null;
const match = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(header);
if (!match) return null;
return decodeURIComponent(match[1] || match[2] || "");
};
const getVersionFileName = (version: DocumentVersion) => {
const uri = version.file_uri || "";
const name = uri.split(/[\\/]/).filter(Boolean).pop() || "";
return name.toLowerCase();
};
const detectPreviewType = (version: DocumentVersion) => {
const mime = (version.mime_type || "").toLowerCase();
if (mime.startsWith("image/")) return "image";
if (mime === "application/pdf") return "pdf";
const name = getVersionFileName(version);
if ([".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".svg"].some((ext) => name.endsWith(ext))) return "image";
if (name.endsWith(".pdf")) return "pdf";
return "other";
};
const clearPreviewObjectUrl = () => {
if (!previewObjectUrl.value) return;
URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = "";
};
const previewVersion = async (version: DocumentVersion) => {
if (!canReadDocument.value) { ElMessage.warning("权限不足"); return; }
clearPreviewObjectUrl();
previewType.value = detectPreviewType(version);
previewTitle.value = `${detail.title || TEXT.modules.fileVersionManagement.title} V${version.version_no}`;
previewUrl.value = "";
previewError.value = "";
previewVisible.value = true;
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
return;
}
previewLoading.value = true;
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || version.mime_type || "application/octet-stream";
const blob = new Blob([response.data], { type: contentType });
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch (e: any) {
previewError.value = e?.response?.data?.message || TEXT.common.messages.previewNotSupported;
} finally {
previewLoading.value = false;
}
};
const downloadVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
const openVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
}
};
const confirmDeleteVersion = async (version: DocumentVersion) => {
if (!version?.id) return;
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
const ok = await ElMessageBox.confirm(TEXT.common.confirm.delete, TEXT.common.labels.tips).catch(() => null);
if (!ok) return;
try {
await deleteDocumentVersion(version.id);
ElMessage.success(TEXT.common.messages.deleteSuccess);
await loadDetail();
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.deleteFailed); }
};
const openDistribute = () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
distributeForm.target_type = "ALL"; distributeForm.role_ids = []; distributeForm.user_ids = [];
distributeDirtyGuard.syncBaseline();
distributeVisible.value = true;
if (!members.value.length) loadMembers();
};
const onTargetTypeChange = () => { distributeForm.role_ids = []; distributeForm.user_ids = []; };
const submitDistribute = async () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
if (!distributionVersionId.value) { ElMessage.warning(TEXT.modules.fileVersionManagement.messages.noEffective); return; }
distributing.value = true;
try {
let targets: { target_type: string; target_id: string }[] = [];
if (distributeForm.target_type === "ROLE") {
targets = distributeForm.role_ids.map((id) => ({ target_type: "ROLE", target_id: id }));
} else if (distributeForm.target_type === "USER") {
targets = distributeForm.user_ids.map((id) => ({ target_type: "USER", target_id: id }));
}
await createDistributions(distributionVersionId.value, targets);
distributeVisible.value = false;
await loadDistributions();
ElMessage.success(TEXT.common.messages.saveSuccess);
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); }
finally { distributing.value = false; }
};
const formatDateOnly = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.split("T")[0] || value;
};
const formatDate = (value?: string | null) => {
if (!value) return TEXT.common.fallback;
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const splitDateTime = (value?: string | null) => {
const displayValue = displayDateTime(value);
const [date, time] = displayValue.split(" ");
return { date, time: time || "" };
};
watch(previewVisible, (visible) => {
if (!visible) {
clearPreviewObjectUrl();
previewUrl.value = "";
previewError.value = "";
previewLoading.value = false;
}
});
onMounted(async () => {
desktopRefreshCleanup = onDesktopRefreshCurrentView(loadDetail);
await loadRoleTemplates();
await loadDetail();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
</script>
<style scoped>
/* ==================== Base ==================== */
.page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0;
background: transparent;
}
/* ==================== 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);
}
.hero-banner::before { display: none; }
.hero-banner::after { display: none; }
.hero-bg-pattern { display: none; }
.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: 10px; flex-wrap: wrap; }
.hero-title {
margin: 0;
font-size: 19px;
font-weight: 600;
color: #3d2410;
letter-spacing: 0.005em;
}
.hero-tag {
background: rgba(255, 255, 255, 0.55) !important;
color: rgba(80, 45, 20, 0.78) !important;
border: 1px solid rgba(180, 110, 50, 0.18) !important;
font-weight: 500;
backdrop-filter: blur(8px);
}
.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--type { background: rgba(212, 145, 80, 0.14); color: #b06b30; }
.stat-icon-wrap--site { background: rgba(168, 158, 80, 0.16); color: #8a7028; }
.stat-icon-wrap--version{ background: rgba(196, 116, 92, 0.14); color: #a85838; }
.stat-icon-wrap--date { background: rgba(220, 160, 90, 0.18); color: #b87532; }
.stat-icon-wrap--update { 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: 10.5px;
font-weight: 500;
color: rgba(80, 45, 20, 0.55);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.stat-value {
font-size: 14px;
font-weight: 600;
color: #3d2410;
word-break: break-all;
letter-spacing: 0.005em;
}
/* ==================== Section Card ==================== */
.section-card {
background: #ffffff; border-radius: 6px; overflow: hidden;
box-shadow: none;
}
.tabs-header {
position: relative;
}
.tabs-actions {
position: absolute;
top: 10px;
right: 20px;
display: flex;
gap: 8px;
z-index: 1;
}
.tab-action-btn { height: 28px; border-radius: 6px; font-size: 12px; font-weight: 500; padding: 0 10px; }
.table-card-inner { padding: 0; }
/* ==================== Tables ==================== */
.version-table {
--el-table-border-color: transparent;
--el-table-row-hover-bg-color: #f8f9fb;
}
.version-table :deep(.el-table__inner-wrapper::before) { display: none; }
.version-table :deep(th.el-table__cell) {
background: #f4f5f7; color: #2a2a2a;
font-size: 12px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.05em;
padding: 14px 14px;
border-bottom: 2px solid #e0e0e0 !important;
}
.version-table :deep(td.el-table__cell) {
padding: 12px 14px; color: #0a0a0a; font-size: 14px; font-weight: 500;
}
.version-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
.version-table :deep(.el-tag) { font-weight: 600; border: none; }
/* ==================== Cell Helpers ==================== */
.cell-nowrap { white-space: nowrap; }
.cell-primary { font-weight: 600; color: #0a0a0a; }
.cell-muted { color: #8a8a8a; }
.datetime-stack {
display: inline-flex;
flex-direction: column;
gap: 2px;
font-size: 12px;
font-variant-numeric: tabular-nums;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
}
.datetime-stack__time {
color: #9a9a9a;
font-size: 11px;
}
.cell-actions { display: flex; gap: 8px; white-space: nowrap; }
.cell-actions :deep(.el-button) { font-size: 13px; padding: 0; height: auto; }
.cell-actions :deep(.el-button + .el-button) { margin-left: 0; }
.preview-body {
min-height: 120px;
}
.preview-frame {
width: 100%;
height: 520px;
border: none;
}
.preview-media {
max-width: 100%;
max-height: 520px;
display: block;
margin: 0 auto;
}
.preview-error {
color: #8a8a8a;
font-size: 13px;
padding: 12px 0;
text-align: center;
}
/* ==================== Tabs Override ==================== */
.ctms-tabs :deep(.el-tabs__header) {
margin: 0;
padding: 0 24px;
background: transparent;
border-bottom: 1px solid #e8e8e8;
}
.ctms-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.ctms-tabs :deep(.el-tabs__active-bar) {
background: #0a0a0a;
height: 2px;
bottom: -1px;
}
.ctms-tabs :deep(.el-tabs__item) {
font-size: 13px;
font-weight: 600;
color: #a3a3a3;
padding: 14px 20px;
transition: color 0.15s ease;
}
.ctms-tabs :deep(.el-tabs__item:hover) { color: #525252; }
.ctms-tabs :deep(.el-tabs__item.is-active) { color: #0a0a0a; }
.ctms-tabs :deep(.el-tabs__content) { padding: 0; }
.ctms-tabs :deep(.el-tab-pane) { padding: 0; }
/* ==================== Drawer Editor ==================== */
:deep(.editor-drawer > .el-drawer__header) { margin-bottom: 0; padding: 20px 24px 8px; }
:deep(.editor-drawer > .el-drawer__body) { padding: 0 24px 4px; }
.editor-header { display: flex; flex-direction: column; }
.editor-title { font-size: 18px; font-weight: 700; color: #0a0a0a; line-height: 1.2; letter-spacing: -0.01em; }
.detail-form { padding: 0; }
.form-section { padding-bottom: 6px; }
.form-section + .form-section { margin-top: 20px; padding-top: 20px; border-top: 1px solid #f0f0f0; }
.form-section-title { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; color: #0a0a0a; margin-bottom: 16px; }
.dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot--blue { background: #3b82f6; }
.field-grid { display: grid; gap: 0 16px; }
.field-grid--2 { grid-template-columns: 1fr 1fr; }
.full-width { width: 100%; }
.drawer-footer { display: flex; justify-content: flex-end; gap: 10px; }
.footer-btn { height: 36px; border-radius: 8px; padding: 0 20px; font-size: 13px; font-weight: 500; }
.footer-btn--primary { padding: 0 24px; }
.detail-form :deep(.el-form-item) { margin-bottom: 14px; }
.detail-form :deep(.el-form-item__label) { font-size: 12px; font-weight: 600; color: #525252; padding-bottom: 4px; }
.detail-form :deep(.el-form-item__error) { font-size: 11px; }
.file-input-hidden { display: none; }
/* ==================== Upload Drawer ==================== */
.upload-drawer :deep(.el-drawer__header) { margin-bottom: 0; padding: 20px 24px 12px; border-bottom: 1px solid #f0f0f0; }
.upload-drawer :deep(.el-drawer__body) { padding: 16px 24px 4px; }
.upload-header { display: flex; align-items: center; gap: 14px; }
.upload-header-icon {
display: flex; align-items: center; justify-content: center;
width: 40px; height: 40px; border-radius: 10px;
background: #eff4ff; color: #2563eb;
}
.upload-header-text { display: flex; flex-direction: column; gap: 2px; }
.upload-header-title { font-size: 18px; font-weight: 700; color: #0a0a0a; letter-spacing: -0.01em; }
.upload-header-subtitle { font-size: 12px; color: #8a8a8a; }
.upload-form-card {
background: #f8f9fb;
border: 1px solid #edf0f5;
border-radius: 10px;
padding: 18px 20px 8px;
margin-bottom: 14px;
}
.upload-form-card-title {
display: flex; align-items: center; gap: 6px;
font-size: 13px; font-weight: 600; color: #0a0a0a;
margin-bottom: 14px;
}
.upload-form-card-title svg { color: #2563eb; flex-shrink: 0; }
.upload-zone {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 8px; padding: 28px 20px;
border: 1.5px dashed #d4d4d4; border-radius: 10px;
background: #ffffff; cursor: pointer;
transition: all 0.15s ease;
}
.upload-zone:hover { border-color: #2563eb; background: #f0f6ff; }
.upload-zone.has-file { border-style: solid; border-color: #d4d4d4; background: #f8f9fb; padding: 14px 20px; cursor: default; }
.upload-zone.has-file:hover { border-color: #d4d4d4; background: #f8f9fb; }
.upload-zone-icon { display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; border-radius: 50%; background: #f0f6ff; color: #2563eb; }
.upload-zone:hover .upload-zone-icon { background: #e0eeff; }
.upload-zone-text { font-size: 13px; font-weight: 600; color: #0a0a0a; }
.upload-zone-hint { font-size: 11px; color: #a3a3a3; }
.upload-file-preview { display: flex; align-items: center; gap: 12px; width: 100%; }
.upload-file-icon { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 8px; background: #eff4ff; color: #2563eb; flex-shrink: 0; }
.upload-file-info { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.upload-file-name { font-size: 13px; font-weight: 600; color: #0a0a0a; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upload-file-size { font-size: 11px; color: #8a8a8a; }
.upload-file-remove { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: none; border-radius: 6px; background: transparent; color: #a3a3a3; cursor: pointer; flex-shrink: 0; }
.upload-file-remove:hover { background: #fef2f2; color: #dc2626; }
/* ==================== Distribution helpers ==================== */
.target-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.target-type-preset { width: 120px; }
.target-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.target-select { flex: 1; min-width: 180px; }
.target-all { flex: 1; display: flex; align-items: center; min-width: 180px; }
.w-full { width: 100%; }
/* ==================== Responsive ==================== */
@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%; }
.field-grid--2 { grid-template-columns: 1fr; }
}
</style>