Files
zkxw/docs/mockups/oa-related-approval-picker-card-demo.html
T
2026-08-11 11:13:01 +08:00

721 lines
21 KiB
HTML
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.
<!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: #1e293b;
--text-muted: #64748b;
--text-faint: #94a3b8;
--border: #e2e8f0;
--border-soft: #f1f5f9;
--bg: #f1f5f9;
--surface: #ffffff;
--warn: #d97706;
--danger: #ef4444;
--success: #059669;
--info: #64748b;
--shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
--radius: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background:
radial-gradient(1200px 400px at 10% -10%, #d1fae5 0%, transparent 55%),
radial-gradient(900px 360px at 100% 0%, #e0f2fe 0%, transparent 50%),
var(--bg);
}
.page {
max-width: 960px;
margin: 0 auto;
padding: 28px 20px 80px;
}
h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
color: var(--text);
}
.lead {
margin: 0 0 20px;
color: var(--text-muted);
max-width: 720px;
}
.notes {
margin-bottom: 20px;
padding: 12px 14px;
border: 1px dashed var(--primary-border);
border-radius: var(--radius);
background: #fff;
color: var(--text);
font-size: 13px;
}
.notes strong { color: var(--primary-dark); }
.notes ul { margin: 8px 0 0; padding-left: 18px; }
.notes li { margin: 2px 0; }
/* Dialog shell */
.dialog {
background: var(--surface);
border-radius: 12px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
overflow: hidden;
display: flex;
flex-direction: column;
max-height: calc(100vh - 120px);
}
.dialog__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
}
.dialog__title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.dialog__close {
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--text-muted);
font-size: 18px;
cursor: pointer;
}
.dialog__close:hover { background: var(--border-soft); }
.dialog__body {
padding: 0;
overflow: auto;
flex: 1;
}
.dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid var(--border);
background: #f8fafc;
}
.dialog__footer-left {
color: var(--text-muted);
font-size: 13px;
}
.dialog__footer-left em {
font-style: normal;
color: var(--primary-dark);
font-weight: 600;
}
.dialog__footer-right { display: flex; gap: 8px; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.btn:hover { background: #f8fafc; }
.btn--primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.btn--primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
.btn--primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
padding: 0 18px;
border-bottom: 1px solid var(--border);
background: #fff;
}
.tab {
position: relative;
height: 44px;
padding: 0 16px;
border: 0;
background: transparent;
color: var(--text-muted);
font-size: 14px;
cursor: pointer;
}
.tab:hover { color: var(--text); }
.tab.is-active {
color: var(--primary-dark);
font-weight: 600;
}
.tab.is-active::after {
content: "";
position: absolute;
left: 12px;
right: 12px;
bottom: 0;
height: 2px;
border-radius: 1px;
background: var(--primary);
}
.tab .count {
margin-left: 4px;
color: var(--text-faint);
font-weight: 400;
font-size: 12px;
}
/* Filters */
.filters {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 12px 18px;
border-bottom: 1px solid var(--border-soft);
background: #fff;
}
.field {
display: flex;
align-items: center;
gap: 6px;
}
.field label {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
}
.input, .select {
height: 34px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text);
font-size: 13px;
outline: none;
}
.input { min-width: 200px; }
.input:focus, .select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.select { min-width: 120px; }
/* Card list */
.card-list {
background: #fff;
}
.card {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 14px 18px;
border-bottom: 1px solid var(--border-soft);
cursor: pointer;
transition: background 0.12s;
}
.card:hover { background: #f8fafc; }
.card.is-selected { background: var(--primary-bg); }
.card.is-disabled {
opacity: 0.55;
cursor: not-allowed;
}
.card__check {
flex-shrink: 0;
margin-top: 10px;
}
.card__check input {
width: 16px;
height: 16px;
accent-color: var(--primary);
cursor: pointer;
}
.card__avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.5px;
margin-top: 2px;
}
.card__main {
flex: 1;
min-width: 0;
}
.card__title {
margin: 0 0 4px;
font-size: 14px;
font-weight: 600;
color: var(--text);
line-height: 1.4;
}
.card__meta {
margin: 0;
padding: 0;
list-style: none;
color: var(--text-muted);
font-size: 12.5px;
}
.card__meta li {
margin: 1px 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card__time {
margin-top: 6px;
color: var(--text-faint);
font-size: 12px;
}
.card__right {
flex-shrink: 0;
text-align: right;
min-width: 100px;
padding-top: 2px;
}
.card__actor {
font-size: 12.5px;
color: var(--warn);
line-height: 1.4;
}
.card__status {
margin-top: 4px;
font-size: 12.5px;
font-weight: 500;
}
.st-running { color: var(--warn); }
.st-completed { color: var(--success); }
.st-rejected { color: var(--danger); }
.st-returned { color: var(--warn); }
.st-cancelled { color: var(--info); }
.empty {
padding: 48px 18px;
text-align: center;
color: var(--text-faint);
font-size: 13px;
}
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 10px 18px 14px;
color: var(--text-muted);
font-size: 12px;
background: #fff;
}
.pager button {
height: 28px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.pager button:disabled { opacity: 0.4; cursor: not-allowed; }
.legend {
margin-top: 18px;
font-size: 12px;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="page">
<h1>关联审批单 · 选单卡片模式</h1>
<p class="lead">
需求草案 Demo:去掉「仅已通过」限制;三 Tab(我发起的 / 我审批的 / 抄送我的);
钉钉式卡片;展示部门与表单摘要;支持筛选。交互可点选,便于对齐产品预期。
</p>
<div class="notes">
<strong>本 Demo 约定(待评审)</strong>
<ul>
<li>Tab 默认落在「我发起的」;切换 Tab 清空当前页勾选草稿(已确认关联保留在表单外)。</li>
<li>状态不限制:处理中 / 已通过 / 已拒绝 / 已打回 / 已撤销均可出现;右侧展示当前处理人 + 状态文案。</li>
<li>卡片主信息:标题、部门、前 2 个业务字段摘要、申请时间;左勾选 + 发起人头像字。</li>
<li>筛选:仅关键词(流程名 / 标题)。</li>
</ul>
</div>
<div class="dialog" role="dialog" aria-label="选择关联审批单">
<div class="dialog__header">
<h2 class="dialog__title">选择关联审批单</h2>
<button class="dialog__close" type="button" aria-label="关闭">×</button>
</div>
<div class="tabs" role="tablist">
<button class="tab is-active" data-tab="mine" type="button">我发起的<span class="count" id="count-mine"></span></button>
<button class="tab" data-tab="done" type="button">我审批的<span class="count" id="count-done"></span></button>
<button class="tab" data-tab="cc" type="button">抄送我的<span class="count" id="count-cc"></span></button>
</div>
<div class="filters">
<div class="field">
<label>关键词</label>
<input class="input" id="kw" type="search" placeholder="搜索流程名 / 标题" />
</div>
<button class="btn" type="button" id="btnSearch">查询</button>
<button class="btn" type="button" id="btnReset">重置</button>
</div>
<div class="dialog__body">
<div class="card-list" id="list"></div>
<div class="pager">
<span id="pageInfo">第 1 页</span>
<button type="button" id="prevPage">上一页</button>
<button type="button" id="nextPage">下一页</button>
</div>
</div>
<div class="dialog__footer">
<div class="dialog__footer-left">已选 <em id="selCount">0</em> 项</div>
<div class="dialog__footer-right">
<button class="btn" type="button">取消</button>
<button class="btn btn--primary" type="button" id="btnOk" disabled>确定</button>
</div>
</div>
</div>
<p class="legend">
静态 Mock · Fresh Mint 配色 · 打开本文件即可预览:
<code>docs/mockups/oa-related-approval-picker-card-demo.html</code>
</p>
</div>
<script>
const AVATAR_COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#06b6d4", "#ef4444"];
const STATUS_TEXT = {
running: "处理中",
completed: "已通过",
rejected: "已拒绝",
returned: "已打回",
cancelled: "已撤销",
};
/** 已关联(模拟表单已选,不可再选) */
const alreadyLinked = new Set(["m2"]);
const DATA = {
mine: [
{
id: "m1",
appName: "人事审批",
title: "吴自龙提交的人事审批",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "数字输入框", value: "123" },
{ label: "单行输入框", value: "123" },
],
applyTime: "2026-07-23 13:50",
status: "running",
actor: "吴震 (审批人)",
},
{
id: "m2",
appName: "请假",
title: "吴自龙提交的请假",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "请假类型", value: "年假" },
{ label: "时长", value: "1.5 天" },
],
applyTime: "2026-07-20 09:12",
status: "completed",
actor: "",
},
{
id: "m3",
appName: "报销",
title: "吴自龙提交的报销",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "报销金额", value: "¥1,280.00" },
{ label: "事由", value: "客户拜访差旅" },
],
applyTime: "2026-07-18 16:40",
status: "rejected",
actor: "",
},
{
id: "m4",
appName: "采购",
title: "吴自龙提交的采购",
initiatorName: "吴自龙",
deptName: "研发中心 / 平台组",
fields: [
{ label: "采购品类", value: "办公设备" },
{ label: "预算", value: "¥8,000" },
],
applyTime: "2026-07-10 11:05",
status: "returned",
actor: "",
},
],
done: [
{
id: "d1",
appName: "请假",
title: "张三提交的请假",
initiatorName: "张三",
deptName: "市场部 / 华东区",
fields: [
{ label: "请假类型", value: "事假" },
{ label: "时长", value: "0.5 天" },
],
applyTime: "2026-07-22 10:20",
status: "running",
actor: "吴自龙 (审批人)",
},
{
id: "d2",
appName: "人事审批",
title: "李四提交的人事审批",
initiatorName: "李四",
deptName: "人事行政部",
fields: [
{ label: "调整类型", value: "转正" },
{ label: "生效日期", value: "2026-08-01" },
],
applyTime: "2026-07-15 14:33",
status: "completed",
actor: "",
},
{
id: "d3",
appName: "报销",
title: "王五提交的报销",
initiatorName: "王五",
deptName: "财务部",
fields: [
{ label: "报销金额", value: "¥560.00" },
{ label: "科目", value: "招待费" },
],
applyTime: "2026-07-12 08:50",
status: "cancelled",
actor: "",
},
],
cc: [
{
id: "c1",
appName: "采购",
title: "赵六提交的采购",
initiatorName: "赵六",
deptName: "供应链 / 采购组",
fields: [
{ label: "供应商", value: "某某科技" },
{ label: "金额", value: "¥12,000" },
],
applyTime: "2026-07-21 17:08",
status: "running",
actor: "陈七 (审批人)",
},
{
id: "c2",
appName: "人事审批",
title: "钱八提交的人事审批",
initiatorName: "钱八",
deptName: "研发中心 / 测试组",
fields: [
{ label: "岗位", value: "高级测试工程师" },
{ label: "职级", value: "P6" },
],
applyTime: "2026-07-08 09:40",
status: "completed",
actor: "",
},
],
};
const state = {
tab: "mine",
kw: "",
page: 1,
pageSize: 5,
selected: new Set(),
};
function shortName(name) {
const s = String(name || "").trim();
return s.length <= 2 ? s : s.slice(-2);
}
function avatarColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * 17) % AVATAR_COLORS.length;
return AVATAR_COLORS[h];
}
function statusClass(st) {
return "st-" + (st || "running");
}
function filtered() {
return (DATA[state.tab] || []).filter((row) => {
if (state.kw) {
const blob = [row.appName, row.title].join(" ").toLowerCase();
if (!blob.includes(state.kw.toLowerCase())) return false;
}
return true;
});
}
function renderCounts() {
for (const k of ["mine", "done", "cc"]) {
const el = document.getElementById("count-" + k);
if (el) el.textContent = "(" + DATA[k].length + ")";
}
}
function renderList() {
const all = filtered();
const totalPages = Math.max(1, Math.ceil(all.length / state.pageSize));
if (state.page > totalPages) state.page = totalPages;
const start = (state.page - 1) * state.pageSize;
const pageRows = all.slice(start, start + state.pageSize);
const list = document.getElementById("list");
if (!pageRows.length) {
list.innerHTML = '<div class="empty">暂无符合条件的审批单</div>';
} else {
list.innerHTML = pageRows
.map((row) => {
const disabled = alreadyLinked.has(row.id);
const checked = state.selected.has(row.id);
const meta = [
row.deptName ? `<li>部门:${escapeHtml(row.deptName)}</li>` : "",
...(row.fields || [])
.slice(0, 2)
.map((f) => `<li>${escapeHtml(f.label)}:${escapeHtml(f.value)}</li>`),
].join("");
const actor =
row.status === "running" && row.actor
? `<div class="card__actor">${escapeHtml(row.actor)}</div>`
: "";
return `
<div class="card ${checked ? "is-selected" : ""} ${disabled ? "is-disabled" : ""}"
data-id="${row.id}" data-disabled="${disabled ? "1" : "0"}">
<div class="card__check">
<input type="checkbox" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
</div>
<div class="card__avatar" style="background:${avatarColor(row.initiatorName)}">
${escapeHtml(shortName(row.initiatorName))}
</div>
<div class="card__main">
<h3 class="card__title">${escapeHtml(row.title)}</h3>
<ul class="card__meta">${meta}</ul>
<div class="card__time">${escapeHtml(row.applyTime)}</div>
</div>
<div class="card__right">
${actor}
<div class="card__status ${statusClass(row.status)}">${STATUS_TEXT[row.status] || row.status}</div>
</div>
</div>`;
})
.join("");
}
document.getElementById("pageInfo").textContent =
`共 ${all.length} 条 · 第 ${state.page}/${totalPages} 页`;
document.getElementById("prevPage").disabled = state.page <= 1;
document.getElementById("nextPage").disabled = state.page >= totalPages;
document.getElementById("selCount").textContent = String(state.selected.size);
document.getElementById("btnOk").disabled = state.selected.size === 0;
}
function escapeHtml(s) {
return String(s || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
document.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
state.tab = btn.dataset.tab;
state.page = 1;
state.selected.clear();
renderList();
});
});
document.getElementById("btnSearch").addEventListener("click", () => {
state.kw = document.getElementById("kw").value.trim();
state.page = 1;
renderList();
});
document.getElementById("btnReset").addEventListener("click", () => {
document.getElementById("kw").value = "";
state.kw = "";
state.page = 1;
renderList();
});
document.getElementById("kw").addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("btnSearch").click();
});
document.getElementById("prevPage").addEventListener("click", () => {
if (state.page > 1) {
state.page -= 1;
renderList();
}
});
document.getElementById("nextPage").addEventListener("click", () => {
state.page += 1;
renderList();
});
document.getElementById("list").addEventListener("click", (e) => {
const card = e.target.closest(".card");
if (!card || card.dataset.disabled === "1") return;
const id = card.dataset.id;
if (state.selected.has(id)) state.selected.delete(id);
else state.selected.add(id);
renderList();
});
renderCounts();
renderList();
</script>
</body>
</html>