Files
zkxw/mobile-ui/zkxw-mobile-oa-pending-batch-actions-demo.html
T
2026-08-13 14:31:57 +08:00

696 lines
24 KiB
HTML
Raw Permalink 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<title>待办中心 · 批量通过 / 打回 Demo</title>
<style>
:root {
--brand: #0a9f6d;
--brand-dark: #087f57;
--brand-soft: #e9f9f2;
--warn: #d97706;
--warn-soft: #fff7e8;
--danger: #ef4444;
--danger-soft: #fef2f2;
--nav-bg: #ecfdf5;
--page-bg: #f5f8f7;
--text: #1f2a26;
--text-muted: #6b7b74;
--border: #e8eaed;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans SC", -apple-system, system-ui, sans-serif;
color: var(--text);
background:
radial-gradient(900px 420px at 8% -8%, rgba(10, 159, 109, 0.12), transparent 55%),
linear-gradient(180deg, #f8fffc 0%, #f4f7fb 50%, #f7f8fa 100%);
line-height: 1.45;
}
.stage { max-width: 1180px; margin: 0 auto; padding: 28px 16px 64px; }
.hero h1 { margin: 0 0 8px; font-size: 22px; font-weight: 720; }
.hero p { margin: 0; color: #64748b; font-size: 14px; max-width: 820px; }
.rec {
margin: 16px 0 0;
padding: 14px 16px;
border-radius: 14px;
background: var(--brand-soft);
border: 1px solid #b7ebd4;
font-size: 13px;
color: #166534;
line-height: 1.55;
}
.rec b { color: var(--brand-dark); }
.tabs {
display: flex; flex-wrap: wrap; gap: 10px;
margin: 20px 0 18px;
}
.tab {
appearance: none; border: 1px solid #e2e8f0; background: #fff;
border-radius: 999px; padding: 10px 16px; font-size: 13px;
font-weight: 600; color: #475569; cursor: pointer;
}
.tab.is-active {
color: var(--brand-dark); border-color: var(--brand);
background: var(--brand-soft);
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.16);
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 300px) 1fr;
gap: 20px; align-items: start;
}
@media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: rgba(255,255,255,.94);
border: 1px solid #e2e8f0; border-radius: 16px; padding: 16px;
box-shadow: 0 10px 28px rgba(15,23,42,.05);
}
.panel h2 { margin: 0 0 4px; font-size: 15px; }
.panel .sub { margin: 0 0 10px; font-size: 12px; color: #64748b; }
.panel ul { margin: 0; padding-left: 18px; font-size: 13px; color: #475569; }
.panel li { margin: 6px 0; }
.panel .pick {
margin-top: 12px; padding: 10px 12px; border-radius: 10px;
background: #fff7e8; border: 1px solid #fde68a;
font-size: 12px; color: #92400e;
}
.panel .ok {
margin-top: 10px; padding: 10px 12px; border-radius: 10px;
background: #ecfdf5; border: 1px solid #a7f3d0;
font-size: 12px; color: #047857;
}
.phones { display: flex; flex-wrap: wrap; gap: 18px; justify-content: center; }
.phone {
width: 375px; background: #fff; border-radius: 28px; overflow: hidden;
box-shadow: 0 0 0 1px rgb(15 23 42 / 6%), 0 18px 40px rgb(15 23 42 / 12%);
position: relative;
}
.cap {
text-align: center; font-size: 11px; font-weight: 650; color: #64748b;
padding: 8px; background: #f8fafc; border-bottom: 1px solid #eef2f0;
}
.nav {
height: 44px; display: flex; align-items: center; padding: 0 8px 0 4px;
background: var(--nav-bg); border-bottom: 1px solid #d8f3e5;
position: relative;
}
.nav-back {
width: 36px; height: 36px; display: flex; align-items: center; justify-content: center;
color: #0f291e; flex-shrink: 0;
}
.nav-back::before {
content: ""; width: 10px; height: 10px;
border-left: 2px solid #0f291e; border-bottom: 2px solid #0f291e;
transform: rotate(45deg); margin-left: 4px;
}
.nav-title {
position: absolute; left: 50%; transform: translateX(-50%);
font-size: 16px; font-weight: 650; color: #0f291e;
}
.nav-action {
margin-left: auto;
min-width: 48px;
height: 36px;
padding: 0 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 650;
color: var(--brand-dark);
cursor: pointer;
user-select: none;
}
.nav-action.is-done { color: #64748b; }
.nav-action:active { opacity: 0.72; }
.page {
min-height: 640px;
background: var(--page-bg);
padding-bottom: 24px;
position: relative;
}
.toolbar {
padding: 10px 12px;
background: #fff;
border-bottom: 1px solid #f1f5f9;
}
.search-box {
height: 36px;
display: flex; align-items: center; gap: 8px;
padding: 0 10px; border-radius: 10px;
background: #f1f5f9; border: 1px solid transparent;
}
.search-ico {
width: 13px; height: 13px; border: 1.5px solid #94a3b8; border-radius: 50%;
position: relative; flex-shrink: 0;
}
.search-ico::after {
content: ""; position: absolute; width: 6px; height: 1.5px;
right: -4px; bottom: -2px; background: #94a3b8; border-radius: 2px;
transform: rotate(45deg);
}
.search-ph { font-size: 13px; color: #b0b7c3; }
.batch-bar {
display: none;
align-items: center; justify-content: space-between; gap: 8px;
margin: 8px 12px 0; padding: 10px 12px;
border-radius: 10px; background: #fff;
border: 1px solid var(--border);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.batch-bar.is-show { display: flex; }
.batch-bar__hint { font-size: 13px; color: #646a73; flex-shrink: 0; }
.batch-bar__actions { display: flex; gap: 8px; margin-left: auto; }
.batch-bar__btn {
min-height: 32px; padding: 0 14px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; font-size: 13px; font-weight: 600;
border: none; cursor: pointer;
}
.batch-bar__btn.is-ok {
color: #fff; background: var(--brand);
}
.batch-bar__btn.is-return {
color: var(--warn); background: var(--warn-soft);
border: 1px solid #fde68a;
}
.batch-bar__btn:disabled {
opacity: 0.45; cursor: not-allowed;
}
.list {
padding: 10px 12px 0;
display: flex; flex-direction: column; gap: 10px;
}
.row {
display: flex; align-items: stretch; gap: 0;
}
.check {
display: none; align-items: flex-start;
padding: 18px 0 0 2px; margin-right: 6px; flex-shrink: 0;
cursor: pointer;
}
.page.is-manage .check { display: flex; }
.check__box {
width: 20px; height: 20px; border-radius: 50%;
border: 1.5px solid #cbd5e1; background: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 12px; color: #fff; font-weight: 700;
}
.check__box.on {
background: var(--brand); border-color: var(--brand);
}
.card {
flex: 1; min-width: 0;
background: #fff; border-radius: 14px;
border: 1px solid #d8f3e5;
box-shadow: 0 4px 16px rgba(22, 163, 74, 0.07);
padding: 12px 14px; cursor: pointer;
}
.card.is-selected {
border-color: #86efac;
box-shadow: 0 0 0 2px rgba(10, 159, 109, 0.18);
}
.card__head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 8px;
}
.card__title {
font-size: 15px; font-weight: 700; color: #0f291e; line-height: 1.35;
}
.card__time { flex-shrink: 0; font-size: 11px; color: #94a3b8; margin-top: 2px; }
.card__meta { margin-top: 8px; }
.card__meta-line {
display: block; font-size: 12px; color: var(--text-muted); line-height: 1.5;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card__foot {
margin-top: 10px; display: flex; align-items: center; justify-content: space-between;
}
.card__user { display: flex; align-items: center; gap: 6px; min-width: 0; }
.card__avatar {
width: 22px; height: 22px; border-radius: 6px;
background: var(--brand); color: #fff;
font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center;
}
.card__user-text { font-size: 12px; color: #64748b; }
.card__status {
font-size: 11px; font-weight: 650; padding: 3px 8px; border-radius: 999px;
background: rgba(217, 119, 6, 0.12); color: var(--warn);
}
/* 底部意见弹层 */
.sheet-mask {
display: none; position: absolute; inset: 0;
background: rgba(15, 23, 42, 0.45); z-index: 20;
}
.sheet-mask.is-show { display: block; }
.sheet {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 21;
background: #fff; border-radius: 16px 16px 0 0;
padding: 14px 16px calc(14px + env(safe-area-inset-bottom, 0px));
transform: translateY(110%);
transition: transform .22s ease;
box-shadow: 0 -8px 28px rgba(15, 23, 42, 0.12);
}
.sheet.is-show { transform: translateY(0); }
.sheet__hd {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 10px;
}
.sheet__title { font-size: 16px; font-weight: 700; }
.sheet__close {
border: none; background: transparent; color: #64748b;
font-size: 13px; cursor: pointer; padding: 4px 0;
}
.sheet__hint {
margin: 0 0 8px; font-size: 12px; color: #64748b;
}
.sheet__hint strong { color: var(--brand-dark); }
.sheet__ta {
width: 100%; min-height: 96px; resize: none;
border: 1px solid #e2e8f0; border-radius: 10px;
padding: 10px 12px; font: inherit; font-size: 14px;
color: var(--text); outline: none;
background: #f8fafc;
}
.sheet__ta:focus {
border-color: #86efac; background: #fff;
box-shadow: 0 0 0 3px rgba(10, 159, 109, 0.12);
}
.sheet__ft {
margin-top: 12px; display: flex; gap: 10px;
}
.sheet__btn {
flex: 1; height: 40px; border-radius: 10px;
border: none; font-size: 15px; font-weight: 650; cursor: pointer;
}
.sheet__btn.is-cancel {
background: #f1f5f9; color: #475569;
}
.sheet__btn.is-ok {
background: var(--brand); color: #fff;
}
.sheet__btn.is-return {
background: var(--warn); color: #fff;
}
.toast {
position: absolute; left: 50%; bottom: 88px; z-index: 30;
transform: translateX(-50%);
padding: 8px 14px; border-radius: 8px;
background: rgba(15, 23, 42, 0.88); color: #fff;
font-size: 13px; white-space: nowrap;
opacity: 0; pointer-events: none; transition: opacity .2s;
}
.toast.is-show { opacity: 1; }
.legend {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
}
.legend .box {
background: #fff; border: 1px solid #e2e8f0; border-radius: 12px;
padding: 12px 14px; font-size: 12px; color: #475569;
}
.legend h3 { margin: 0 0 6px; font-size: 13px; color: #0f291e; }
</style>
</head>
<body>
<div class="stage">
<div class="hero">
<h1>待办中心 · 批量通过 / 打回</h1>
<p>
交互对齐抄送「管理 + 多选 + 底栏批量操作」。右侧手机可点击演示:进入管理 → 勾选 → 批量通过 / 打回 → 填写统一意见。
</p>
<div class="rec">
<b>推荐方案(本 Demo):</b>
<b>标题栏右侧「管理 / 完成」</b>(对齐草稿箱自定义顶栏);底栏「批量通过」「批量打回」;
统一意见框(通过默认「同意」可空,打回必填);前端串行调现有单条接口,汇总成功/失败。不含批量拒绝。
</div>
</div>
<div class="tabs" id="sceneTabs">
<button type="button" class="tab is-active" data-scene="browse">① 浏览态</button>
<button type="button" class="tab" data-scene="manage">② 管理多选</button>
<button type="button" class="tab" data-scene="approve">③ 批量通过弹层</button>
<button type="button" class="tab" data-scene="return">④ 批量打回弹层</button>
</div>
<div class="layout">
<aside class="panel" id="sidePanel">
<h2>① 浏览态</h2>
<p class="sub">日常查看 / 点卡片进详情审批</p>
<ul>
<li>标题栏右侧「管理」(对齐草稿箱自定义顶栏)</li>
<li>搜索区仅保留检索,不再放管理按钮</li>
<li>点卡片仍进入审批详情(webview)</li>
</ul>
<div class="pick">右侧手机可点标题栏「管理」亲自走一遍流程。</div>
</aside>
<div class="phones">
<div class="phone">
<div class="cap">可交互 Demo · 375px</div>
<div class="nav">
<div class="nav-back" aria-hidden="true"></div>
<div class="nav-title">待办中心</div>
<div class="nav-action" id="manageBtn">管理</div>
</div>
<div class="page" id="page">
<div class="toolbar">
<div class="search-box">
<span class="search-ico" aria-hidden="true"></span>
<span class="search-ph">搜索流程名称、申请人</span>
</div>
</div>
<div class="batch-bar" id="batchBar">
<span class="batch-bar__hint" id="batchHint">已选 0 项</span>
<div class="batch-bar__actions">
<button type="button" class="batch-bar__btn is-return" id="btnReturn">批量打回</button>
<button type="button" class="batch-bar__btn is-ok" id="btnApprove">批量通过</button>
</div>
</div>
<div class="list" id="list"></div>
<div class="sheet-mask" id="sheetMask"></div>
<div class="sheet" id="sheet">
<div class="sheet__hd">
<div class="sheet__title" id="sheetTitle">批量通过</div>
<button type="button" class="sheet__close" id="sheetClose">取消</button>
</div>
<p class="sheet__hint" id="sheetHint">将对 <strong>2</strong> 条待办执行操作</p>
<textarea class="sheet__ta" id="sheetInput" placeholder="审批意见"></textarea>
<div class="sheet__ft">
<button type="button" class="sheet__btn is-cancel" id="sheetCancel">取消</button>
<button type="button" class="sheet__btn is-ok" id="sheetConfirm">确定</button>
</div>
</div>
<div class="toast" id="toast"></div>
</div>
</div>
</div>
</div>
<div class="legend">
<div class="box">
<h3>意见规则</h3>
批量通过:默认填入「同意」,可改,可空(与单条一致)。<br />
批量打回:意见必填,占位「请填写打回意见(必填)」。
</div>
<div class="box">
<h3>接口策略</h3>
暂无批量 API:前端对选中 taskId 串行调用<br />
<code>POST /tasks/{id}/approve</code>、<code>/return</code>。<br />
结束后 toast:「成功 n,失败 m」。
</div>
<div class="box">
<h3>边界</h3>
仅当前页已加载列表可勾选;不包含「拒绝」。<br />
若流程关闭打回(allowReturn=false),单条失败计入失败数。
</div>
</div>
</div>
<script>
const TASKS = [
{
id: 't1',
title: '请假申请',
time: '今天 09:12',
meta: ['请假类型:事假', '请假天数:1'],
user: '王品龙',
avatar: '王品'
},
{
id: 't2',
title: '费用报销',
time: '昨天 18:40',
meta: ['报销金额:1280.00', '事由:差旅交通'],
user: '吴家猛',
avatar: '吴家'
},
{
id: 't3',
title: '加班申请',
time: '08-10',
meta: ['加班时长:3小时', '加班日期:2026-08-09'],
user: '郑德禄',
avatar: '郑德'
},
{
id: 't4',
title: '出差申请',
time: '08-09',
meta: ['目的地:合肥', '出差天数:2'],
user: '李明',
avatar: '李明'
}
]
const state = {
manage: false,
selected: new Set(),
sheetKind: null // approve | return
}
const page = document.getElementById('page')
const listEl = document.getElementById('list')
const manageBtn = document.getElementById('manageBtn')
const batchBar = document.getElementById('batchBar')
const batchHint = document.getElementById('batchHint')
const btnApprove = document.getElementById('btnApprove')
const btnReturn = document.getElementById('btnReturn')
const sheet = document.getElementById('sheet')
const sheetMask = document.getElementById('sheetMask')
const sheetTitle = document.getElementById('sheetTitle')
const sheetHint = document.getElementById('sheetHint')
const sheetInput = document.getElementById('sheetInput')
const sheetConfirm = document.getElementById('sheetConfirm')
const toastEl = document.getElementById('toast')
const sidePanel = document.getElementById('sidePanel')
const SCENE_COPY = {
browse: {
title: '① 浏览态',
sub: '日常查看 / 点卡片进详情审批',
items: [
'标题栏右侧「管理」(对齐草稿箱自定义顶栏)',
'搜索区仅保留检索,不再放管理按钮',
'点卡片仍进入审批详情(webview)'
],
tip: '右侧手机可点标题栏「管理」亲自走一遍流程。',
tipClass: 'pick'
},
manage: {
title: '② 管理多选',
sub: '勾选多条待办,底栏出现批量操作',
items: [
'标题栏按钮变为「完成」,点按退出并清空勾选',
'左侧圆形勾选,选中卡片高亮描边',
'底栏:已选 N 项 + 批量打回 + 批量通过',
'未勾选时两个按钮禁用'
],
tip: '主行动为「批量通过」(品牌绿);打回为次要警告色。',
tipClass: 'ok'
},
approve: {
title: '③ 批量通过弹层',
sub: '统一意见,一次确认',
items: [
'标题「批量通过」,提示将对 N 条执行',
'默认意见「同意」,可修改或清空',
'确定后串行 approve,刷新列表'
],
tip: '与详情页单条「同意」语义一致。',
tipClass: 'ok'
},
return: {
title: '④ 批量打回弹层',
sub: '打回意见必填',
items: [
'标题「批量打回」,主按钮琥珀警告色',
'意见为空时 toast「打回意见必填」',
'部分失败时提示成功/失败条数'
],
tip: '不在此 Demo 做「批量拒绝」,避免误操作风险。',
tipClass: 'pick'
}
}
function renderSide(scene) {
const c = SCENE_COPY[scene]
sidePanel.innerHTML =
'<h2>' + c.title + '</h2>' +
'<p class="sub">' + c.sub + '</p>' +
'<ul>' + c.items.map((x) => '<li>' + x + '</li>').join('') + '</ul>' +
'<div class="' + c.tipClass + '">' + c.tip + '</div>'
}
function renderList() {
listEl.innerHTML = TASKS.map((t) => {
const on = state.selected.has(t.id)
return (
'<div class="row" data-id="' + t.id + '">' +
'<div class="check" data-check="' + t.id + '">' +
'<div class="check__box' + (on ? ' on' : '') + '">' + (on ? '✓' : '') + '</div>' +
'</div>' +
'<div class="card' + (on ? ' is-selected' : '') + '" data-card="' + t.id + '">' +
'<div class="card__head">' +
'<div class="card__title">' + t.title + '</div>' +
'<div class="card__time">' + t.time + '</div>' +
'</div>' +
'<div class="card__meta">' +
t.meta.map((m) => '<span class="card__meta-line">' + m + '</span>').join('') +
'</div>' +
'<div class="card__foot">' +
'<div class="card__user">' +
'<div class="card__avatar">' + t.avatar + '</div>' +
'<div class="card__user-text">由' + t.user + '提交</div>' +
'</div>' +
'<div class="card__status">进行中</div>' +
'</div>' +
'</div>' +
'</div>'
)
}).join('')
}
function syncChrome() {
page.classList.toggle('is-manage', state.manage)
manageBtn.textContent = state.manage ? '完成' : '管理'
manageBtn.classList.toggle('is-done', state.manage)
const n = state.selected.size
batchBar.classList.toggle('is-show', state.manage && n > 0)
batchHint.textContent = '已选 ' + n + ' 项'
btnApprove.disabled = n === 0
btnReturn.disabled = n === 0
}
function toast(msg) {
toastEl.textContent = msg
toastEl.classList.add('is-show')
clearTimeout(toast._t)
toast._t = setTimeout(() => toastEl.classList.remove('is-show'), 1800)
}
function openSheet(kind) {
const n = state.selected.size
if (!n) return
state.sheetKind = kind
sheetTitle.textContent = kind === 'approve' ? '批量通过' : '批量打回'
sheetHint.innerHTML = '将对 <strong>' + n + '</strong> 条待办执行操作'
sheetInput.value = kind === 'approve' ? '同意' : ''
sheetInput.placeholder =
kind === 'approve' ? '审批意见(可选)' : '请填写打回意见(必填)'
sheetConfirm.textContent = '确定'
sheetConfirm.className =
'sheet__btn ' + (kind === 'approve' ? 'is-ok' : 'is-return')
sheetMask.classList.add('is-show')
sheet.classList.add('is-show')
setTimeout(() => sheetInput.focus(), 80)
}
function closeSheet() {
state.sheetKind = null
sheetMask.classList.remove('is-show')
sheet.classList.remove('is-show')
}
function setManage(on) {
state.manage = on
if (!on) state.selected.clear()
renderList()
syncChrome()
}
function applyScene(scene) {
document.querySelectorAll('#sceneTabs .tab').forEach((el) => {
el.classList.toggle('is-active', el.dataset.scene === scene)
})
renderSide(scene)
closeSheet()
if (scene === 'browse') {
setManage(false)
} else if (scene === 'manage') {
state.selected = new Set(['t1', 't2'])
setManage(true)
} else if (scene === 'approve') {
state.selected = new Set(['t1', 't2'])
setManage(true)
openSheet('approve')
} else if (scene === 'return') {
state.selected = new Set(['t1', 't3'])
setManage(true)
openSheet('return')
}
}
manageBtn.addEventListener('click', () => setManage(!state.manage))
listEl.addEventListener('click', (e) => {
const check = e.target.closest('[data-check]')
const card = e.target.closest('[data-card]')
const id = (check && check.dataset.check) || (card && card.dataset.card)
if (!id) return
if (state.manage) {
if (state.selected.has(id)) state.selected.delete(id)
else state.selected.add(id)
renderList()
syncChrome()
return
}
if (card) toast('进入审批详情(Demo)')
})
btnApprove.addEventListener('click', () => openSheet('approve'))
btnReturn.addEventListener('click', () => openSheet('return'))
sheetMask.addEventListener('click', closeSheet)
document.getElementById('sheetClose').addEventListener('click', closeSheet)
document.getElementById('sheetCancel').addEventListener('click', closeSheet)
sheetConfirm.addEventListener('click', () => {
const kind = state.sheetKind
const comment = String(sheetInput.value || '').trim()
const n = state.selected.size
if (kind === 'return' && !comment) {
toast('打回意见必填')
return
}
closeSheet()
const label = kind === 'approve' ? '通过' : '打回'
toast('已' + label + ' ' + n + ' 条(Demo)')
state.selected.clear()
setManage(false)
})
document.getElementById('sceneTabs').addEventListener('click', (e) => {
const tab = e.target.closest('.tab')
if (!tab) return
applyScene(tab.dataset.scene)
})
renderList()
syncChrome()
renderSide('browse')
</script>
</body>
</html>