This commit is contained in:
zlwu committed 2026-05-20 14:32:07 +08:00
1 parent 15ed25e33b
commit dc31968204
143 files changed
+15823 -9989

No files matched your search

@@ -40,3 +40,22 @@
- **WHEN** 在 `app/api/` 增加新的路由模块
- **THEN** 该模块 SHALL 在 `app/api/__init__.py` 与 `app/main.py` 中完成注册,并符合现有认证与租户上下文注入模式
### Requirement: 细胞存储样本跨表关联键
细胞存储场景下,将协议样本行(`biz_protocol_sample`)与下游业务(样本接收 `biz_sample_reception`、质检工单 `biz_qc_work_order` 等)进行**业务匹配**时,关联键 SHALL 仅为 `tenant_no` + `sample_code`(租户内样本编码唯一且不变;接收/工单表须持久化 `sample_code` 快照)。**不得**使用 `collection_protocol_id` 或 `protocol_sample_id` 作为查询、列表排除、详情回显的关联依据(协议单可换号/转科研、编辑保存会重建样本行 `id`,但样本编码不变)。
#### Scenario: 协议单查看弹窗展示样本接收
- **WHEN** 用户打开采集协议单详情,且租户内某 `sample_code` 已登记接收
- **THEN** 详情接口 SHALL 仅按样本编码返回对应 `sampleReception`(及同规则的 `qcInfo`),与当前协议单 ID、样本行 `id` 是否变更无关
#### Scenario: 待接收列表排除已接收样本
- **WHEN** 租户内某 `sample_code` 已存在有效接收记录(`del_flag=0`)
- **THEN** 该编码的协议样本 SHALL NOT 出现在待接收列表
#### Scenario: 登记接收防重复
- **WHEN** 用户对某协议样本提交接收登记
- **THEN** 服务端 SHALL 按 `tenant_no` + `sample_code` 校验是否已接收,已存在则拒绝重复登记
@@ -263,22 +263,52 @@ Web 管理端弹窗 SHALL 遵循规范:表单 `label-position="top"`;遮罩
### Requirement: 附件上传卡片化布局
`web-admin` 中弹窗表单的附件上传区域 SHALL 使用**卡片化布局**:上传触发器与已上传附件在同一行 flex 容器中排列,上传卡片作为第一个元素始终可见。具体规则:
`web-admin` 弹窗表单中的附件上传与展示 SHALL 采用**卡片化布局**,实现与交互以 `web-admin/src/views/cell-storage/protocol/ProtocolFormDialog.vue` 中「协议附件」为权威参考;储存证等业务弹窗(如 `web-admin/src/views/cell-storage/storage-cert/index.vue`)SHALL 复用同一套 DOM 结构、类名与样式规则,仅调整 `el-form-item` 的 `label` 与上传 `folder` 等业务参数。
1. **上传卡片样式**:上传触发器 SHALL 渲染为与附件卡片等尺寸的卡片(宽 120px、高 96px),使用虚线边框(`border: 1px dashed #a7f3d0`)、浅绿背景(`background: #f0fdf4`),居中显示 `Plus` 图标(size 28)与"上传附件"文字(12px `#10b981`)。hover 时边框变实线(`border-color: #10b981`)、背景加深(`background: #ecfdf5`)。
2. **同行排列**:上传卡片与已上传附件卡片 SHALL 放在同一个 `display: flex; flex-wrap: wrap; gap: 12px` 容器内,上传卡片为第一个子元素。容器 SHALL 始终可见(不受附件数量影响)。
3. **附件卡片样式**:已上传附件卡片 SHALL 为 120px 宽、96px 预览区高度,带实线边框(`border: 1px solid #e2e8f0`)。图片文件显示缩略图(`object-fit: cover`),视频文件显示紫色视频图标,其他文件显示蓝色文档图标。卡片底部显示文件名(11px,最多 2 行省略)与操作按钮(下载、删除,居中排列)。
4. **上传中状态**:正在上传的附件卡片 SHALL 设置 `opacity: 0.6` 且 `pointer-events: none`,预览区显示 `Loading` 图标。
5. **提示文案**:格式说明文字(如"支持图片、视频、PDF...,单文件不超过 20MB")SHALL 显示在卡片行下方,作为独立的 `11px` 灰色小字(`color: #94a3b8`)。
6. **颜色约束**:上传卡片与附件卡片 SHALL 使用 Fresh Mint 色板(`#10b981`、`#a7f3d0`、`#f0fdf4`、`#ecfdf5`),SHALL NOT 使用小程序品牌色 `#0a9f6d`。
#### 布局与视觉
#### 标准写法示例
1. **结构**:`el-form-item`(建议 `class="form-col-span-2"` 占满弹窗栅格宽)→ `.attachment-upload` → `.attachment-list`(flex 行)→ 上传卡片 + 若干 `.attachment-item`;`.upload-hint` 在 flex 行**下方**独立一行。
2. **上传卡片**:宽 **140px**、高 **96px**;`border: 1px dashed #a7f3d0`;`background: #f0fdf4`;`border-radius: 8px`;居中 `Plus`(size 28,`#10b981`)+ 文案「上传附件」(`.upload-card-text`,12px `#10b981` font-weight 500)。hover:`border-color: #10b981`、`border-style: solid`、`background: #ecfdf5`。
3. **附件卡片**:宽 **140px**;预览区高 **96px**;`border: 1px solid #e2e8f0`;`border-radius: 8px`;hover:`border-color: #10b981`、`box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1)`。
4. **预览区**(`.attachment-preview`):背景 `#f8fafc`;图片已上传且 `fileUrl` 存在时显示缩略图(`object-fit: cover`),可点击预览;图片上传中无 URL 时显示 `Loading`;视频显示 `.attachment-preview__icon--video`(`#ede9fe` / `#7c3aed` + `VideoPlay`);其他文件显示 `.attachment-preview__icon--doc`(`#e0f2fe` / `#0284c7` + `Document`)。
5. **卡片底部**(`.attachment-footer`):文件名 `.attachment-name`(11px `#334155`,最多 2 行省略,`title` 为完整文件名);`.attachment-actions` 居中排列 **下载**(`Download`)、**删除**(`Delete`,`.action-btn--danger` hover 红色)。
6. **上传中**:`.attachment-item.is-uploading` → `opacity: 0.6`、`pointer-events: none`。
7. **提示**:`.upload-hint` — 11px `#94a3b8`,`margin-top: 8px`;默认文案:「支持图片、视频、PDF、Word、Excel等文件,单文件不超过 20MB」。
8. **色板**:SHALL 使用 Fresh Mint(`#10b981`、`#a7f3d0`、`#f0fdf4`、`#ecfdf5`);SHALL NOT 使用小程序主色 `#0a9f6d`。
9. **禁止**:SHALL NOT 使用 `el-upload` 的 `list-type="picture-card"`、默认 `file-list` 展示,或单独一行纯文字「选择文件」按钮替代上传卡片。
```html
#### `el-upload` 配置
| 属性 | 值 |
|------|-----|
| `:auto-upload` | `false` |
| `:show-file-list` | `false` |
| `:multiple` | `true` |
| `accept` | `image/*,video/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar` |
| `@change` | 选中后立即走上传逻辑(见下) |
上传卡片 SHALL 作为 `.attachment-list` 的**第一个子元素**,且始终渲染(不因已有附件数量而隐藏)。
#### 数据模型与上传逻辑
附件列表 SHALL 为响应式数组,元素至少包含:`id`、`fileName`、`fileUrl`、`fileType`(可选)、`_uploading`(可选布尔)。
1. **选择文件后**:校验单文件 ≤ **20MB**;超限则 `ElMessage.error` 并 `clearFiles()`。
2. **立即上传**:向数组 push 占位项(`_uploading: true`、`fileUrl: ''`);`FormData` 调用 `fetchUploadAttachment`(`@/api/business`),`fd.append('folder', '<业务目录>')`(如 `protocol`、`storage-cert`、`protocol-payment`);成功后回填 `fileUrl` 等字段并 `_uploading: false`;失败则移除该项并提示。
3. **收尾**:`nextTick` 后 `uploadRef.clearFiles()`,避免同一文件无法重复选择。
4. **删除**:从数组 `splice` 移除,不单独调删除接口(除非产品另有要求)。
5. **下载**:使用 `@/utils/download-attachment` 的 `downloadAttachmentFile(file, { fullFileUrl })`;`fullFileUrl` 需拼接 `VITE_MINIO_BASE_URL`(与参考页一致)。
6. **预览**:图片/视频已上传项点击预览区,打开独立 `el-dialog`(宽 800px,`destroy-on-close`);图片用 `<img>`,视频用 `<video controls>`。
7. **提交**:若存在 `_uploading === true` 的项,SHALL 阻止提交并提示「附件上传中,请稍候」;业务字段若存逗号分隔 URL,提交前 SHALL 从数组中过滤 `fileUrl` 非空且非上传中的项拼接。
缴费明细等嵌套场景可使用 `.upload-card--sm`(宽 96px、高 76px),其余规则不变。
#### 标准模板(Vue)
```vue
<el-form-item label="协议附件" class="form-col-span-2">
<div class="attachment-upload">
<div class="attachment-list">
<!-- 上传卡片始终在第一个 -->
<el-upload
ref="attachmentUploadRef"
:auto-upload="false"
@@ -292,17 +322,46 @@ Web 管理端弹窗 SHALL 遵循规范:表单 `label-position="top"`;遮罩
<span class="upload-card-text">上传附件</span>
</div>
</el-upload>
<!-- 已上传附件卡片 -->
<div v-for="(file, index) in formData.attachments" :key="file.id || index"
class="attachment-item" :class="{ 'is-uploading': file._uploading }">
<div class="attachment-preview">
<!-- 图片缩略图 / 视频图标 / 文档图标 -->
<div
v-for="(file, index) in attachments"
:key="file.id || index"
class="attachment-item"
:class="{ 'is-uploading': file._uploading }"
>
<div
class="attachment-preview"
@click="isImageFile(file) && file.fileUrl ? handlePreviewAttachment(file) : undefined"
>
<template v-if="isImageFile(file) && file.fileUrl">
<img :src="fullFileUrl(file.fileUrl)" :alt="file.fileName" />
</template>
<template v-else-if="isImageFile(file) && !file.fileUrl">
<el-icon :size="24" color="#94a3b8"><Loading /></el-icon>
</template>
<template v-else-if="isVideoFile(file)">
<div class="attachment-preview__icon attachment-preview__icon--video">
<el-icon :size="28"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="attachment-preview__icon attachment-preview__icon--doc">
<el-icon :size="28"><Document /></el-icon>
</div>
</template>
</div>
<div class="attachment-footer">
<span class="attachment-name">{{ file.fileName }}</span>
<span class="attachment-name" :title="file.fileName">{{ file.fileName }}</span>
<div class="attachment-actions">
<el-icon class="action-btn" title="下载" @click="handleDownload(file)"><Download /></el-icon>
<el-icon class="action-btn action-btn--danger" title="删除" @click="removeAttachment(index)"><Delete /></el-icon>
<el-icon class="action-btn" title="下载" @click="handleDownloadAttachment(file)">
<Download />
</el-icon>
<el-icon
class="action-btn action-btn--danger"
title="删除"
@click="removeAttachment(index)"
>
<Delete />
</el-icon>
</div>
</div>
</div>
@@ -312,135 +371,86 @@ Web 管理端弹窗 SHALL 遵循规范:表单 `label-position="top"`;遮罩
</el-form-item>
```
> 说明:`attachments` 为业务侧附件数组变量名;图标 `Plus`、`Loading`、`VideoPlay`、`Document`、`Download`、`Delete` SHALL 在 `<script setup>` 中从 `@element-plus/icons-vue` 显式 import。
#### 标准样式(SCSS,弹窗 `scoped` 内)
完整类名与尺寸 SHALL 与参考页一致,核心片段:
```scss
.attachment-upload { width: 100%; }
.attachment-list { display: flex; flex-wrap: wrap; gap: 12px; }
.upload-card {
width: 120px;
height: 96px;
border: 1px dashed #a7f3d0;
border-radius: 8px;
background: #f0fdf4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
&:hover {
border-color: #10b981;
border-style: solid;
background: #ecfdf5;
}
width: 140px; height: 96px;
border: 1px dashed #a7f3d0; border-radius: 8px; background: #f0fdf4;
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 6px; cursor: pointer;
&:hover { border-color: #10b981; border-style: solid; background: #ecfdf5; }
}
.upload-card-text {
font-size: 12px;
color: #10b981;
font-weight: 500;
line-height: 1;
}
.upload-hint {
display: block;
font-size: 11px;
color: #94a3b8;
line-height: 1;
margin-top: 8px;
}
.attachment-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.upload-card-text { font-size: 12px; color: #10b981; font-weight: 500; line-height: 1; }
.upload-hint { display: block; font-size: 11px; color: #94a3b8; margin-top: 8px; line-height: 1; }
.attachment-item {
width: 120px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
overflow: hidden;
transition: border-color 0.15s, box-shadow 0.15s;
&:hover {
border-color: #10b981;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
}
&.is-uploading {
opacity: 0.6;
pointer-events: none;
}
width: 140px; border: 1px solid #e2e8f0; border-radius: 8px; background: #fff; overflow: hidden;
&.is-uploading { opacity: 0.6; pointer-events: none; }
&:hover { border-color: #10b981; box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1); }
}
.attachment-preview {
width: 120px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
background: #f8fafc;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
width: 100%; height: 96px; display: flex; align-items: center; justify-content: center;
background: #f8fafc; overflow: hidden; cursor: pointer;
img { width: 100%; height: 100%; object-fit: cover; display: block; }
}
.attachment-footer {
padding: 6px 8px;
position: relative;
.attachment-preview__icon {
width: 48px; height: 48px; border-radius: 10px;
display: flex; align-items: center; justify-content: center;
&--video { background: #ede9fe; color: #7c3aed; }
&--doc { background: #e0f2fe; color: #0284c7; }
}
.attachment-footer { display: flex; flex-direction: column; padding: 6px 8px; }
.attachment-name {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: #334155;
line-height: 1.4;
word-break: break-all;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
overflow: hidden; font-size: 11px; color: #334155; line-height: 1.4; word-break: break-all;
}
.attachment-actions {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 4px;
display: flex; justify-content: center; gap: 8px; margin-top: 4px;
}
.action-btn {
width: 22px; height: 22px; border-radius: 4px; background: #f1f5f9; color: #64748b;
cursor: pointer;
&:hover { background: #10b981; color: #fff; }
&--danger:hover { background: #ef4444; color: #fff; }
}
```
只读查看态 SHALL 使用 `web-admin/src/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue`(无上传卡片、无删除,可保留下载/预览)。
#### Scenario: 新增弹窗包含附件上传
- **WHEN** 在弹窗表单中新增附件上传功能
- **THEN** 上传触发器 SHALL 渲染为卡片样式(120×96px,虚线边框),与附件卡片在同一 flex 行中排列
- **THEN** 容器 SHALL 始终可见(上传卡片始终显示),提示文案在卡片行下方
- **WHEN** 在弹窗表单中新增附件上传
- **THEN** SHALL 使用上述 DOM 结构、类名与 140×96 卡片尺寸
- **THEN** 上传卡片 SHALL 位于 `.attachment-list` 第一项且始终可见
- **THEN** `.upload-hint` SHALL 位于卡片行下方
#### Scenario: 附件卡片预览
#### Scenario: 附件卡片预览与操作
- **WHEN** 用户上传图片文件
- **THEN** 附件卡片预览区 SHALL 显示图片缩略图(`object-fit: cover`),点击可预览大图
- **WHEN** 用户上传视频文件
- **THEN** 预览区 SHALL 显示紫色视频图标(`background: #ede9fe; color: #7c3aed`)
- **WHEN** 用户上传其他类型文件
- **THEN** 预览区 SHALL 显示蓝色文档图标(`background: #e0f2fe; color: #0284c7`)
- **WHEN** 附件为已上传图片
- **THEN** 预览区 SHALL 显示缩略图,点击 SHALL 打开预览弹窗
- **WHEN** 附件为视频或其他文档
- **THEN** 预览区 SHALL 分别显示视频/文档图标(见布局与视觉 §4)
- **WHEN** 用户点击下载或删除
- **THEN** SHALL 分别调用 `downloadAttachmentFile` 与数组移除逻辑
#### Scenario: 附件上传中状态
- **WHEN** 文件正在上传到服务器
- **THEN** 对应附件卡片 SHALL 显示为半透明(`opacity: 0.6`)且不可操作(`pointer-events: none`),预览区显示 Loading 图标
- **WHEN** 文件正在上传
- **THEN** 对应卡片 SHALL 为 `is-uploading`(半透明、不可点),预览区显示 `Loading`
- **WHEN** 用户提交表单且仍有 `_uploading` 项
- **THEN** SHALL 阻止提交并提示等待上传完成
#### Scenario: Code review 附件上传组件
- **WHEN** PR 包含新增或修改的附件上传区域
- **THEN** 审查者 SHALL 确认使用卡片化布局(上传卡片 + 附件卡片同行),SHALL NOT 使用独立的上传按钮行或 Element Plus 默认文件列表
#### Scenario: Code review 附件上传
- **WHEN** PR 新增或修改附件上传 UI
- **THEN** 审查者 SHALL 对照 `ProtocolFormDialog.vue` 确认卡片化布局与立即上传逻辑
- **THEN** SHALL 拒绝 `picture-card`、`show-file-list` 默认列表、与规范不一致的尺寸/色值
### Requirement: 按钮须携带图标
`web-admin` 中所有带文字标签的 `el-button`(或 `ElButton`)SHALL 在文字前方包含 `<el-icon>` 子元素,使用 `@element-plus/icons-vue` 提供的图标组件;SHALL NOT 出现仅有文字而无图标的按钮。图标的引入方式为在文件 `<script setup>` 中显式 `import` 所需图标组件(`@element-plus/icons-vue` 不属于 auto-import 范围)。