docs(spec): design OA done-center bank receipt upload
Capture approved approach A (biz_attachment + permission-gated dialog) and the Fresh Mint HTML demo for the done-center upload flow. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
a148961b55
commit
35532eceee
2 files changed
+854
No files matched your search
@@ -0,0 +1,664 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>已办中心 · 银行回执单上传 Demo</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #10b981;
|
||||
--primary-dark: #059669;
|
||||
--primary-bg: #ecfdf5;
|
||||
--primary-border: #a7f3d0;
|
||||
--text: #0f172a;
|
||||
--text-2: #334155;
|
||||
--muted: #94a3b8;
|
||||
--border: #e2e8f0;
|
||||
--bg: #f8fafc;
|
||||
--panel: #ffffff;
|
||||
--danger: #ef4444;
|
||||
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #f0fdf4 0%, #f8fafc 40%, #f1f5f9 100%);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
.page-title h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.page-title h1::before {
|
||||
content: "";
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
border-radius: 2px;
|
||||
background: var(--primary);
|
||||
}
|
||||
.page-title .hint {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
th, td {
|
||||
padding: 12px 14px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
tbody tr:nth-child(even) { background: #fafafa; }
|
||||
tbody tr:hover { background: #f0fdf4; }
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tag.success { background: #d1fae5; color: #047857; }
|
||||
.tag.warning { background: #fef3c7; color: #b45309; }
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
}
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn.primary {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn.primary:hover { background: var(--primary-bg); }
|
||||
.btn.main {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.btn.main:hover { background: var(--primary-dark); }
|
||||
.btn.ghost {
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.btn svg { width: 14px; height: 14px; flex-shrink: 0; }
|
||||
.btn.main svg, .btn.ghost svg { width: 16px; height: 16px; }
|
||||
|
||||
/* Mask + Dialog */
|
||||
.mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
z-index: 100;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
.mask.open { display: flex; }
|
||||
.dialog {
|
||||
width: min(720px, 100%);
|
||||
max-height: calc(100vh - 100px);
|
||||
background: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: pop .18s ease-out;
|
||||
}
|
||||
@keyframes pop {
|
||||
from { transform: translateY(8px); opacity: 0; }
|
||||
to { transform: none; opacity: 1; }
|
||||
}
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dialog-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dialog-close {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
.dialog-close:hover { background: var(--bg); color: var(--text); }
|
||||
.dialog-body {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px 16px;
|
||||
margin-bottom: 20px;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
.meta .label { color: var(--muted); font-size: 11px; margin-bottom: 4px; }
|
||||
.meta .value { color: var(--text-2); font-weight: 500; }
|
||||
.section-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.attachment-upload.is-dragover {
|
||||
outline: 2px dashed var(--primary);
|
||||
outline-offset: 4px;
|
||||
background: var(--primary-bg);
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.attachment-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.upload-card {
|
||||
width: 140px;
|
||||
height: 96px;
|
||||
border: 1px dashed var(--primary-border);
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
user-select: none;
|
||||
}
|
||||
.upload-card:hover {
|
||||
border-color: var(--primary);
|
||||
border-style: solid;
|
||||
background: var(--primary-bg);
|
||||
}
|
||||
.upload-card .plus {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--primary);
|
||||
}
|
||||
.upload-card-text {
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
.attachment-item {
|
||||
width: 140px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: all .15s;
|
||||
}
|
||||
.attachment-item:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.attachment-item.is-uploading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
.attachment-preview {
|
||||
width: 100%;
|
||||
height: 96px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
.attachment-preview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.doc-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: #e0f2fe;
|
||||
color: #0284c7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.attachment-footer {
|
||||
padding: 6px 8px 8px;
|
||||
}
|
||||
.attachment-name {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.35;
|
||||
min-height: 30px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
}
|
||||
.action-btn:hover { color: var(--primary); background: var(--primary-bg); }
|
||||
.action-btn.danger:hover { color: var(--danger); background: #fef2f2; }
|
||||
.action-btn svg { width: 14px; height: 14px; }
|
||||
.upload-hint {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
margin-top: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding: 12px 20px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(-12px);
|
||||
background: #fff;
|
||||
border: 1px solid var(--primary-border);
|
||||
color: var(--primary-dark);
|
||||
padding: 10px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
box-shadow: var(--shadow);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all .2s;
|
||||
z-index: 200;
|
||||
}
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
.note {
|
||||
margin-top: 16px;
|
||||
padding: 12px 14px;
|
||||
background: #fff;
|
||||
border: 1px dashed var(--primary-border);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-2);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.note strong { color: var(--primary-dark); }
|
||||
#fileInput { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-title">
|
||||
<h1>已办中心</h1>
|
||||
<span class="hint">Demo · 点击操作列「上传回执」打开弹窗</span>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:68px">序号</th>
|
||||
<th>流程名称</th>
|
||||
<th>申请人</th>
|
||||
<th>申请时间</th>
|
||||
<th>当前节点</th>
|
||||
<th>流程状态</th>
|
||||
<th style="min-width:320px">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>费用报销申请</td>
|
||||
<td>张三</td>
|
||||
<td>2026-10-12 14:22</td>
|
||||
<td>—</td>
|
||||
<td><span class="tag success">已完成</span></td>
|
||||
<td>
|
||||
<div class="ops">
|
||||
<button class="btn primary" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
查看
|
||||
</button>
|
||||
<button class="btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||
打印
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="openDialog()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||
上传回执
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>付款申请单</td>
|
||||
<td>李四</td>
|
||||
<td>2026-10-10 09:05</td>
|
||||
<td>财务复核</td>
|
||||
<td><span class="tag warning">进行中</span></td>
|
||||
<td>
|
||||
<div class="ops">
|
||||
<button class="btn primary" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
查看
|
||||
</button>
|
||||
<button class="btn" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>
|
||||
打印
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="openDialog()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
||||
上传回执
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div class="note">
|
||||
<strong>交互说明:</strong>
|
||||
操作列新增「上传回执」→ 弹窗回显该审批实例已上传的银行回执单(多张)→
|
||||
左侧上传卡片可继续追加 → 支持预览/下载/删除 → 点「确定」保存。
|
||||
本 Demo 为前端示意,文件不会真正上传到服务器。
|
||||
</div>
|
||||
|
||||
<div class="mask" id="mask" onclick="onMaskClick(event)">
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dlgTitle">
|
||||
<div class="dialog-header">
|
||||
<h2 id="dlgTitle">上传银行回执单</h2>
|
||||
<button class="dialog-close" type="button" aria-label="关闭" onclick="closeDialog()">×</button>
|
||||
</div>
|
||||
<div class="dialog-body">
|
||||
<div class="meta">
|
||||
<div>
|
||||
<div class="label">流程名称</div>
|
||||
<div class="value">费用报销申请</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">申请人</div>
|
||||
<div class="value">张三</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">申请时间</div>
|
||||
<div class="value">2026-10-12 14:22</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="label">已上传</div>
|
||||
<div class="value"><span id="countText">2</span> 张回执单</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="section-label">银行回执单</p>
|
||||
<div
|
||||
class="attachment-upload"
|
||||
id="dropZone"
|
||||
ondragover="onDragOver(event)"
|
||||
ondragleave="onDragLeave(event)"
|
||||
ondrop="onDrop(event)"
|
||||
>
|
||||
<div class="attachment-list" id="list">
|
||||
<div class="upload-card" onclick="document.getElementById('fileInput').click()" title="点击或拖拽上传">
|
||||
<svg class="plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
<span class="upload-card-text">上传附件</span>
|
||||
</div>
|
||||
<!-- history items injected by JS -->
|
||||
</div>
|
||||
<span class="upload-hint">支持拖拽或点击上传图片、PDF 等文件,单文件不超过 20MB;可上传多张回执单</span>
|
||||
</div>
|
||||
<input id="fileInput" type="file" multiple accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" />
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<button class="btn ghost" type="button" onclick="closeDialog()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
取消
|
||||
</button>
|
||||
<button class="btn main" type="button" onclick="saveDialog()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script>
|
||||
const seedFiles = [
|
||||
{ id: '1', fileName: '电子回单_工商银行_1012.pdf', kind: 'pdf' },
|
||||
{ id: '2', fileName: '银行回执单_微信转账.jpg', kind: 'image',
|
||||
thumb: 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="280" height="192"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#ecfdf5"/><stop offset="1" stop-color="#d1fae5"/></linearGradient></defs><rect width="280" height="192" fill="url(#g)"/><rect x="24" y="24" width="232" height="144" rx="8" fill="#fff" stroke="#a7f3d0"/><text x="140" y="90" text-anchor="middle" font-family="sans-serif" font-size="14" fill="#059669">银行回执单</text><text x="140" y="114" text-anchor="middle" font-family="sans-serif" font-size="11" fill="#94a3b8">示意缩略图</text></svg>`)
|
||||
}
|
||||
];
|
||||
|
||||
let files = [];
|
||||
|
||||
function openDialog() {
|
||||
files = seedFiles.map(f => ({ ...f }));
|
||||
renderList();
|
||||
document.getElementById('mask').classList.add('open');
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
document.getElementById('mask').classList.remove('open');
|
||||
}
|
||||
|
||||
function onMaskClick(e) {
|
||||
if (e.target === document.getElementById('mask')) closeDialog();
|
||||
}
|
||||
|
||||
function saveDialog() {
|
||||
showToast('已保存 ' + files.length + ' 张银行回执单(Demo)');
|
||||
closeDialog();
|
||||
}
|
||||
|
||||
function showToast(msg) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 1800);
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
document.getElementById('countText').textContent = String(files.length);
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const list = document.getElementById('list');
|
||||
const uploadCard = list.querySelector('.upload-card');
|
||||
list.innerHTML = '';
|
||||
list.appendChild(uploadCard);
|
||||
|
||||
files.forEach((file, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'attachment-item' + (file._uploading ? ' is-uploading' : '');
|
||||
const previewInner = file.kind === 'image' && file.thumb
|
||||
? `<img src="${file.thumb}" alt="${escapeHtml(file.fileName)}" />`
|
||||
: `<div class="doc-icon">PDF</div>`;
|
||||
item.innerHTML = `
|
||||
<div class="attachment-preview">${previewInner}</div>
|
||||
<div class="attachment-footer">
|
||||
<div class="attachment-name" title="${escapeHtml(file.fileName)}">${escapeHtml(file.fileName)}</div>
|
||||
<div class="attachment-actions">
|
||||
<button class="action-btn" type="button" title="下载" onclick="downloadFile(${index})">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
</button>
|
||||
<button class="action-btn danger" type="button" title="删除" onclick="removeFile(${index})">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
list.appendChild(item);
|
||||
});
|
||||
updateCount();
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, c => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
|
||||
function removeFile(index) {
|
||||
files.splice(index, 1);
|
||||
renderList();
|
||||
showToast('已移除(未保存前可撤销感)');
|
||||
}
|
||||
|
||||
function downloadFile(index) {
|
||||
showToast('下载:' + files[index].fileName);
|
||||
}
|
||||
|
||||
function addFiles(fileList) {
|
||||
Array.from(fileList || []).forEach(raw => {
|
||||
const kind = raw.type.startsWith('image/') ? 'image' : 'pdf';
|
||||
const id = 'n' + Date.now() + Math.random().toString(16).slice(2, 6);
|
||||
const entry = { id, fileName: raw.name, kind, _uploading: true, thumb: '' };
|
||||
files.push(entry);
|
||||
renderList();
|
||||
// simulate upload
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const idx = files.findIndex(f => f.id === id);
|
||||
if (idx < 0) return;
|
||||
files[idx]._uploading = false;
|
||||
if (kind === 'image') files[idx].thumb = reader.result;
|
||||
renderList();
|
||||
};
|
||||
if (kind === 'image') reader.readAsDataURL(raw);
|
||||
else setTimeout(() => {
|
||||
const idx = files.findIndex(f => f.id === id);
|
||||
if (idx < 0) return;
|
||||
files[idx]._uploading = false;
|
||||
renderList();
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('fileInput').addEventListener('change', (e) => {
|
||||
addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
});
|
||||
|
||||
function onDragOver(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.add('is-dragover');
|
||||
}
|
||||
function onDragLeave(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('is-dragover');
|
||||
}
|
||||
function onDrop(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById('dropZone').classList.remove('is-dragover');
|
||||
addFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeDialog();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,190 @@
|
||||
# OA 已办中心 · 上传银行回执单
|
||||
|
||||
**模块**: OA 审批 · 已办中心
|
||||
**日期**: 2026-10-16
|
||||
**状态**: 待实现
|
||||
**UI Demo**: `docs/superpowers/specs/2026-10-16-oa-done-bank-receipt-upload-demo.html`
|
||||
**决策记录**:
|
||||
- 实现方案: **方案 A**(`biz_attachment` 挂实例,不改 `oa_instance` 表)
|
||||
- 入口仅 `web-admin`【已办中心】;有独立权限才显示按钮
|
||||
- 弹窗回显历史多张回执,支持追加、下载、删除;确定时整体覆盖保存
|
||||
- 全部已办行均可操作(不按流程类型过滤)
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
财务/经办人在审批办结后,需要为流程实例补传银行回执单(可多张)。入口放在【已办中心】操作列,通过弹窗管理该实例的回执附件。
|
||||
|
||||
**成功标准**:
|
||||
- 拥有权限的用户在已办列表看到「上传回执」,打开弹窗可回显历史并继续上传
|
||||
- 无权限用户看不到按钮,且接口拒绝
|
||||
- 附件交互与展示对齐 web-admin 附件卡片化规范(Fresh Mint)
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围
|
||||
|
||||
### 2.1 在范围内
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 入口 | `web-admin/src/views/oa-approval/done/index.vue` 操作列 |
|
||||
| 弹窗 | 新组件(建议 `OaBankReceiptDialog.vue`) |
|
||||
| 存储 | `biz_attachment`:`business_type=oa_bank_receipt`,`business_id=instance_id` |
|
||||
| 上传 | 现有 `fetchUploadAttachment`,`folder=oa-bank-receipt` |
|
||||
| API | `GET` / `PUT` `.../instances/{instance_id}/bank-receipts` |
|
||||
| 权限 | 按钮级菜单 `perms=oa:done:bank-receipt` + SQL 种子 |
|
||||
|
||||
### 2.2 非目标
|
||||
|
||||
- 小程序 / H5 已办中心
|
||||
- 审批详情页内展示回执
|
||||
- 打印 PDF 嵌入回执
|
||||
- 按流程类型隐藏按钮
|
||||
- 删除时物理删除 MinIO 对象(仅软删 `biz_attachment`)
|
||||
- 修改 `oa_instance` 表结构
|
||||
|
||||
---
|
||||
|
||||
## 3. 交互
|
||||
|
||||
1. 操作列在「查看 / 打印」旁增加「上传回执」(图标 + 文案;有权限才渲染)。
|
||||
2. 点击打开弹窗(`dialog-form-layout`,无 backdrop blur):
|
||||
- 只读摘要:流程名称、申请人、申请时间、已上传张数
|
||||
- 「银行回执单」附件区:上传卡片在首 + 历史附件卡片;支持拖拽;预览 / 下载 / 删除
|
||||
3. 打开时 `GET` 拉取历史;选文件后立即上传 MinIO;点「确定」时 `PUT` 整体覆盖保存。
|
||||
4. 取消 / 关闭不提交关联变更(已上传到 MinIO 但未 PUT 的孤儿文件可接受,与现有附件习惯一致)。
|
||||
|
||||
UI 布局以 Demo 为准。
|
||||
|
||||
---
|
||||
|
||||
## 4. 权限模型
|
||||
|
||||
| 角色/权限 | 「上传回执」按钮 | GET/PUT |
|
||||
|-----------|------------------|---------|
|
||||
| 能进已办中心、无 `oa:done:bank-receipt` | 不显示 | 403 |
|
||||
| 拥有 `oa:done:bank-receipt` | 显示 | 允许(同租户实例) |
|
||||
| 超级管理员 / `menuPermAll` | 显示 | 允许 |
|
||||
|
||||
- 权限字符:`oa:done:bank-receipt`
|
||||
- 菜单名:已办中心-上传银行回执
|
||||
- 挂载:`sys_menu` 按钮级节点(`menu_type=F`),父级为「已办中心」菜单
|
||||
- 前端:`useMenuPerm().hasMenuPerm('oa:done:bank-receipt')` 控制按钮显隐
|
||||
- 后端:`user_has_menu_perm` **强制**校验;禁止仅靠前端隐藏
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据与 API
|
||||
|
||||
### 5.1 存储
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| `business_type` | `oa_bank_receipt` |
|
||||
| `business_id` | OA 实例 `id`(`instance_id`) |
|
||||
| 其它 | 沿用 `biz_attachment` 公共字段;删除 = `del_flag=1` |
|
||||
|
||||
不新增业务表,不改 `oa_instance`。
|
||||
|
||||
### 5.2 端点
|
||||
|
||||
```
|
||||
GET /web/admin/oa-approval/instances/{instance_id}/bank-receipts
|
||||
PUT /web/admin/oa-approval/instances/{instance_id}/bank-receipts
|
||||
```
|
||||
|
||||
鉴权:管理端登录 + `oa:done:bank-receipt`;实例须存在且 `tenant_no` 与当前用户一致。
|
||||
|
||||
**GET 响应**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "...",
|
||||
"fileName": "电子回单.pdf",
|
||||
"fileUrl": "oa-bank-receipt/...",
|
||||
"fileType": "application/pdf",
|
||||
"fileSize": 120849,
|
||||
"createTime": "2026-10-16 12:00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
排序:`create_time ASC`(稳定回显顺序)。
|
||||
|
||||
**PUT body**
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"fileUrl": "oa-bank-receipt/...",
|
||||
"fileName": "电子回单.pdf",
|
||||
"fileType": "application/pdf",
|
||||
"fileSize": 120849
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
语义:**整体覆盖**——以 body 中的 `fileUrl` 集合为准:
|
||||
- 已有且仍在集合中:保留(可更新文件名等展示字段)
|
||||
- 集合中新增:创建或绑定 `biz_attachment` 行(`business_type` / `business_id`)
|
||||
- 库中已有但不在集合中:软删
|
||||
|
||||
允许 `items: []`(清空该实例全部银行回执关联)。
|
||||
|
||||
文件二进制上传仍走通用附件上传接口,本 PUT 只维护业务关联。
|
||||
|
||||
### 5.3 实例可见性
|
||||
|
||||
已办列表本身仅含当前用户办过的任务。本接口在有权限前提下,对**同租户且存在的实例**允许读写回执;不额外校验「当前用户必须是该实例办理人」(避免权限角色无法代传)。若产品后续要求收紧,可再加办理人校验。
|
||||
|
||||
---
|
||||
|
||||
## 6. 前端实现要点
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `done/index.vue` | 操作列按钮 + 打开弹窗;`hasMenuPerm` |
|
||||
| `OaBankReceiptDialog.vue`(建议路径 `components/oa-approval/`) | GET 回显、卡片上传、PUT 保存 |
|
||||
| `api/oaApproval.ts` | 封装 GET/PUT |
|
||||
| `docs/sql/migrations/...` | 按钮菜单种子 |
|
||||
|
||||
附件卡片:对齐 `ProtocolFormDialog`「协议附件」与 OpenSpec ui-conventions「附件上传卡片化布局」;单文件 ≤ 20MB;上传中禁止确定。
|
||||
|
||||
操作列 `min-width` 在现有基础上加宽,避免按钮被裁切。
|
||||
|
||||
---
|
||||
|
||||
## 7. 错误处理与边界
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 无权限 | 按钮隐藏;API 403 |
|
||||
| 实例不存在 / 跨租户 | 400/404,不泄露他租户 |
|
||||
| 上传中点确定 | 提示「附件上传中,请稍候」 |
|
||||
| 单文件超 20MB | 前端拦截 |
|
||||
| PUT 空列表 | 软删全部关联 |
|
||||
| 并发保存 | 后写覆盖先写;不做乐观锁 |
|
||||
| MinIO 上传失败 | 移除占位项并提示 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收
|
||||
|
||||
1. 无 `oa:done:bank-receipt`:看不到按钮;直接调 GET/PUT 被拒。
|
||||
2. 有权限:打开可见历史;追加 / 下载 / 删除后确定;再次打开与保存一致。
|
||||
3. UI 符合附件卡片规范与 Demo。
|
||||
4. 种子 SQL 可执行;角色勾选后刷新菜单权限码即可赋权,无需改代码。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试建议
|
||||
|
||||
- 后端:无权限 403;有权限 GET 空/非空;PUT 增删改覆盖;跨租户隔离。
|
||||
- 前端:权限显隐;上传中拦截;确定后列表与 GET 一致(可用组件/接口 mock)。
|
||||
Reference in new issue
Block a user