Files
zkxw/docs/mockups/oa-approval-apps-card-demo.html
T
2026-07-15 16:20:48 +08:00

639 lines
20 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(Fresh Mint)</title>
<style>
:root {
--primary: #10b981;
--primary-dark: #059669;
--primary-bg: #ecfdf5;
--text: #0f172a;
--text-2: #334155;
--muted: #94a3b8;
--border: #e2e8f0;
--bg: #f1f5f9;
--card: #fff;
--dot: #f87171;
--radius-card: 14px;
--radius-icon: 10px;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font: 14px/1.5 Inter, system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--text);
background:
linear-gradient(180deg, #eefcf6 0%, var(--bg) 180px) fixed;
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 28px 24px 64px;
}
.demo-banner {
display: flex;
flex-wrap: wrap;
gap: 10px 16px;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding: 12px 16px;
background: #fff;
border: 1px solid var(--border);
border-radius: 10px;
}
.demo-banner b { color: var(--primary-dark); }
.demo-banner span { color: var(--muted); font-size: 13px; }
.demo-banner .tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: var(--primary-bg);
color: var(--primary-dark);
font-size: 12px;
font-weight: 600;
}
/* —— 搜索(钉钉胶囊) —— */
.search-wrap {
display: flex;
justify-content: center;
margin: 8px 0 28px;
}
.search {
display: flex;
align-items: center;
gap: 10px;
width: min(520px, 100%);
height: 42px;
padding: 0 18px;
background: #fff;
border: 1px solid var(--border);
border-radius: 999px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.search:focus-within {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-bg);
}
.search svg { flex-shrink: 0; color: var(--muted); }
.search input {
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-size: 14px;
color: var(--text);
}
.search input::placeholder { color: #b0b8c4; }
/* —— 一级分组 Tab(截图:人事管理 + 下划线) —— */
.group-tabs {
display: flex;
flex-wrap: wrap;
gap: 28px;
margin-bottom: 8px;
border-bottom: 1px solid transparent;
}
.group-tab {
position: relative;
padding: 0 0 12px;
border: 0;
background: none;
cursor: pointer;
color: var(--text-2);
font-size: 16px;
font-weight: 600;
}
.group-tab:hover { color: var(--primary-dark); }
.group-tab.active { color: var(--text); }
.group-tab.active::after {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
border-radius: 2px;
background: var(--text);
}
.toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin: 4px 0 20px;
}
.toolbar-hint {
color: var(--muted);
font-size: 13px;
}
.toolbar-hint a {
color: var(--primary-dark);
text-decoration: none;
font-weight: 500;
}
.toolbar-hint a:hover { text-decoration: underline; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
color: var(--text-2);
font-size: 13px;
cursor: pointer;
}
.btn:hover { border-color: #cbd5e1; 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); }
/* —— 分组内容块 —— */
.section { margin-bottom: 32px; }
.section.hidden { display: none; }
.section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.section-head h3 {
margin: 0;
color: var(--text-2);
font-size: 14px;
font-weight: 600;
}
.section-head .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--dot);
}
.section-meta {
margin-left: auto;
color: var(--muted);
font-size: 12px;
}
/* —— 横向应用卡片(钉钉核心) —— */
.card-row {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: stretch;
}
.app-card {
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
min-height: 100px;
padding: 14px 16px 10px;
background: var(--card);
border: 1px solid transparent;
border-radius: var(--radius-card);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
text-align: left;
font: inherit;
color: inherit;
}
.app-card:hover {
border-color: #a7f3d0;
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.12);
transform: translateY(-1px);
}
.app-card__main {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.app-icon {
flex-shrink: 0;
display: grid;
place-items: center;
width: 40px;
height: 40px;
overflow: hidden;
border-radius: var(--radius-icon);
color: #fff;
font-size: 18px;
}
.app-icon img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.app-icon.i-orange { background: linear-gradient(145deg, #fb923c, #f97316); }
.app-icon.i-blue { background: linear-gradient(145deg, #60a5fa, #3b82f6); }
.app-icon.i-mint { background: linear-gradient(145deg, #34d399, #10b981); }
.app-icon.i-violet { background: linear-gradient(145deg, #a78bfa, #8b5cf6); }
.app-icon.i-rose { background: linear-gradient(145deg, #fb7185, #f43f5e); }
.app-icon.i-amber { background: linear-gradient(145deg, #fbbf24, #f59e0b); }
.app-icon.i-cyan { background: linear-gradient(145deg, #22d3ee, #06b6d4); }
.app-icon.i-slate { background: #e2e8f0; color: #64748b; }
.icon-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 18px;
margin: -12px 0 22px;
padding: 10px 14px;
color: var(--muted);
font-size: 12px;
background: rgba(255,255,255,0.7);
border: 1px dashed #cbd5e1;
border-radius: 8px;
}
.icon-legend strong { color: var(--text-2); font-weight: 600; }
.app-body { min-width: 0; flex: 1; }
.app-name {
display: block;
overflow: hidden;
color: var(--text);
font-size: 14px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-sub {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.pill {
display: inline-flex;
align-items: center;
height: 18px;
padding: 0 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
line-height: 1;
}
.pill.ok { background: var(--primary-bg); color: var(--primary-dark); }
.pill.warn { background: #fffbeb; color: #d97706; }
.pill.mute { background: #f1f5f9; color: #64748b; }
/* 操作按钮固定放在卡片内底部,始终可见(小号 + 图标) */
.app-ops {
display: flex;
justify-content: flex-end;
gap: 6px;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.app-ops button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3px;
height: 24px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 5px;
background: #fff;
color: var(--text-2);
font-size: 11px;
line-height: 1;
cursor: pointer;
}
.app-ops button svg {
flex-shrink: 0;
width: 12px;
height: 12px;
}
.app-ops button:hover {
border-color: #a7f3d0;
background: var(--primary-bg);
color: var(--primary-dark);
}
.app-ops button.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.app-ops button.primary:hover {
border-color: var(--primary-dark);
background: var(--primary-dark);
color: #fff;
}
.add-card {
justify-content: center;
min-height: 108px;
border: 1px dashed #cbd5e1;
box-shadow: none;
cursor: pointer;
background: #fafafa;
}
.add-card:hover {
border-color: var(--primary);
background: var(--primary-bg);
box-shadow: none;
}
.add-card .app-name { font-weight: 500; color: var(--text-2); }
.empty {
padding: 48px 16px;
color: var(--muted);
text-align: center;
}
.toast {
position: fixed;
left: 50%;
bottom: 28px;
z-index: 20;
transform: translateX(-50%) translateY(20px);
padding: 10px 16px;
border-radius: 8px;
background: #0f172a;
color: #fff;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s, transform 0.2s;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
@media (max-width: 640px) {
.app-card { width: 100%; }
.toolbar-row { flex-direction: column; align-items: flex-start; }
}
</style>
</head>
<body>
<div class="page">
<div class="demo-banner">
<div>
<b>审批管理 · 卡片布局 Demo</b>
<span>图标:打开「新增」弹窗即随机一个默认并预填;用户不改则按该默认保存 iconUrl</span>
</div>
<span class="tag">Fresh Mint · 非列表</span>
</div>
<div class="icon-legend">
<span><strong>用户上传</strong> · 实图封面(如「人事审批」「用章申请」)</span>
<span><strong>系统默认</strong> · 打开新增弹窗时预填随机预设;不改则原样保存</span>
</div>
<div class="search-wrap">
<label class="search" aria-label="搜索审批单">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="2"/>
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
<input id="keyword" type="search" placeholder="请输入审批单名称" autocomplete="off" />
</label>
</div>
<div class="group-tabs" id="groupTabs" role="tablist"></div>
<div class="toolbar-row">
<div class="toolbar-hint">
没有找到合适的审批单?
<a href="#" id="createLink">创建审批表单</a>
</div>
<div style="display:flex;gap:8px">
<button type="button" class="btn" id="btnGroups">分组管理</button>
<button type="button" class="btn primary" id="btnCreate">+ 新建审批</button>
</div>
</div>
<div id="sections"></div>
<div id="empty" class="empty" hidden>没有匹配的审批单</div>
</div>
<div class="toast" id="toast" role="status"></div>
<script>
/* 系统预设默认图标池:创建时若未上传,随机选一个写入 iconUrl(键名或静态资源路径) */
const DEFAULT_ICON_POOL = [
{ key: 'doc-orange', className: 'i-orange', glyph: '☰' },
{ key: 'leave-mint', className: 'i-mint', glyph: '☁' },
{ key: 'clock-blue', className: 'i-blue', glyph: '⏱' },
{ key: 'mail-violet', className: 'i-violet', glyph: '✉' },
{ key: 'cart-rose', className: 'i-rose', glyph: '🛒' },
{ key: 'yen-amber', className: 'i-amber', glyph: '¥' },
{ key: 'tag-cyan', className: 'i-cyan', glyph: '🏷' }
]
function pickRandomDefaultIcon(seed) {
let h = 0
const s = String(seed || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0
return DEFAULT_ICON_POOL[h % DEFAULT_ICON_POOL.length]
}
/* Demo:模拟「用户已上传」的方形封面(SVG data URL,正式环境为 MinIO 地址) */
function fakeUpload(color, label) {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80">
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="${color}"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.75"/>
</linearGradient></defs>
<rect width="80" height="80" rx="16" fill="url(#g)"/>
<text x="40" y="46" text-anchor="middle" fill="#fff" font-size="22" font-family="system-ui">${label}</text>
</svg>`
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg)
}
const DATA = [
{
id: 'hr',
name: '人事管理',
apps: [
/* 用户上传 */
{ id: 'a1', name: '人事审批', iconUrl: fakeUpload('#f97316', '人'), published: true, version: 2, draft: false, status: '1' },
/* 系统默认(无 iconUrl → 按 id 稳定随机) */
{ id: 'a2', name: '请假申请', iconUrl: null, published: true, version: 1, draft: true, status: '1' },
{ id: 'a3', name: '加班申请', iconUrl: null, published: false, version: null, draft: true, status: '1' }
]
},
{
id: 'admin',
name: '行政管理',
apps: [
{ id: 'a4', name: '用章申请', iconUrl: fakeUpload('#8b5cf6', '章'), published: true, version: 3, draft: false, status: '1' },
{ id: 'a5', name: '采购申请', iconUrl: null, published: false, version: null, draft: true, status: '0' }
]
},
{
id: 'finance',
name: '财务管理',
apps: [
{ id: 'a6', name: '报销审批', iconUrl: null, published: true, version: 1, draft: false, status: '1' }
]
}
]
let activeGroup = 'all'
const tabsEl = document.getElementById('groupTabs')
const sectionsEl = document.getElementById('sections')
const emptyEl = document.getElementById('empty')
const keywordEl = document.getElementById('keyword')
const toastEl = document.getElementById('toast')
let toastTimer
function toast(msg) {
toastEl.textContent = msg
toastEl.classList.add('show')
clearTimeout(toastTimer)
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600)
}
function matches(app, kw) {
if (!kw) return true
return app.name.toLowerCase().includes(kw.toLowerCase())
}
function renderTabs() {
const tabs = [{ id: 'all', name: '全部' }, ...DATA.map(g => ({ id: g.id, name: g.name }))]
tabsEl.innerHTML = tabs.map(t =>
`<button type="button" class="group-tab${t.id === activeGroup ? ' active' : ''}" data-id="${t.id}" role="tab">${t.name}</button>`
).join('')
}
function statusPills(app) {
const bits = []
if (app.published) bits.push(`<span class="pill ok">v${app.version}</span>`)
else bits.push(`<span class="pill warn">未发布</span>`)
if (app.draft) bits.push(`<span class="pill warn">草稿</span>`)
if (app.status !== '1') bits.push(`<span class="pill mute">停用</span>`)
return bits.join('')
}
/** 有上传图用 <img>;否则用系统预设默认图标 */
function renderAppIcon(app) {
if (app.iconUrl) {
return `<span class="app-icon" title="用户上传"><img src="${app.iconUrl}" alt="" /></span>`
}
const def = pickRandomDefaultIcon(app.id)
return `<span class="app-icon ${def.className}" title="系统默认:${def.key}" aria-hidden="true">${def.glyph}</span>`
}
const ICO_SETTING = `<svg viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" stroke="currentColor" stroke-width="1.8"/><path d="M19.4 13a7.7 7.7 0 0 0 .1-2l1.7-1.3-1.7-3-2 .5a7.4 7.4 0 0 0-1.7-1l-.3-2.1h-3.4l-.3 2.1a7.4 7.4 0 0 0-1.7 1l-2-.5-1.7 3L6.5 11a7.7 7.7 0 0 0 .1 2l-1.7 1.3 1.7 3 2-.5c.5.4 1.1.8 1.7 1l.3 2.1h3.4l.3-2.1c.6-.2 1.2-.6 1.7-1l2 .5 1.7-3L19.4 13Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg>`
const ICO_MORE = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>`
function appCard(app) {
return `
<div class="app-card" data-id="${app.id}">
<div class="app-card__main">
${renderAppIcon(app)}
<span class="app-body">
<span class="app-name">${app.name}</span>
<span class="app-sub">${statusPills(app)}</span>
</span>
</div>
<div class="app-ops">
<button type="button" class="primary" data-action="design" data-id="${app.id}" data-name="${app.name}">${ICO_SETTING}设计</button>
<button type="button" data-action="more" data-id="${app.id}" data-name="${app.name}">${ICO_MORE}更多</button>
</div>
</div>`
}
function addCard(groupId) {
return `
<button type="button" class="app-card add-card" data-action="add" data-group="${groupId}">
<span class="app-card__main">
<span class="app-icon i-slate" aria-hidden="true">+</span>
<span class="app-body"><span class="app-name">添加</span></span>
</span>
</button>`
}
function render() {
const kw = keywordEl.value.trim()
const groups = DATA.filter(g => activeGroup === 'all' || g.id === activeGroup)
.map(g => ({
...g,
apps: g.apps.filter(a => matches(a, kw))
}))
.filter(g => !kw || g.apps.length)
renderTabs()
if (!groups.length || groups.every(g => !g.apps.length && kw)) {
sectionsEl.innerHTML = ''
emptyEl.hidden = false
return
}
emptyEl.hidden = true
sectionsEl.innerHTML = groups.map(g => {
const showAdd = !kw
return `
<section class="section" data-group="${g.id}">
<div class="section-head">
<h3>${g.name}</h3>
<span class="dot" aria-hidden="true"></span>
<span class="section-meta">${g.apps.length} 个表单</span>
</div>
<div class="card-row">
${g.apps.map(appCard).join('')}
${showAdd ? addCard(g.id) : ''}
</div>
</section>`
}).join('')
}
tabsEl.addEventListener('click', (e) => {
const btn = e.target.closest('.group-tab')
if (!btn) return
activeGroup = btn.dataset.id
render()
})
keywordEl.addEventListener('input', render)
document.body.addEventListener('click', (e) => {
const t = e.target.closest('[data-action]')
if (!t) return
e.preventDefault()
e.stopPropagation()
const action = t.dataset.action
if (action === 'add' || action === 'create') toast('Demo:打开「新增审批」弹窗')
else if (action === 'design') toast(`Demo:进入设计器「${t.dataset.name || t.dataset.id}」`)
else if (action === 'more') toast('Demo:编辑基础 / 发布 / 删除…')
else if (action === 'groups') toast('Demo:打开分组管理')
})
document.getElementById('btnCreate').addEventListener('click', () => toast('Demo:打开「新增审批」弹窗'))
document.getElementById('createLink').addEventListener('click', (e) => {
e.preventDefault()
toast('Demo:打开「新增审批」弹窗')
})
document.getElementById('btnGroups').addEventListener('click', () => toast('Demo:打开分组管理'))
render()
</script>
</body>
</html>