# 交互特性 **Repository Path**: MirrorPro/interactive-features ## Basic Information - **Project Name**: 交互特性 - **Description**: 谭清文融合了多种设计标准后,融合的交互设计标准规范 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-07-30 - **Last Updated**: 2026-08-06 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 前端工具交互特性复用手册 > **用途**:本手册提炼自一个 CloudBase 单文件前端工具(下文称"源前端工具")中沉淀的通用设计特性。 > 目标是:开发同类「网页 + 云数据库 + serverless 托管」前端工具时,另一个 AI 上下文可直接照搬这些范式快速实现,不必从零探索。 > > **源文件**:以你实际项目的单文件 HTML 工具为准(下文称"源前端工具") > **部署规范**(appPath 隔离等):见你的工作空间 README > **线上版本**:以实际项目版本号为准 --- ## 0. 技术底座(先确认环境) | 层 | 选型 | |---|---| | 前端 | 单文件 HTML,引用 `./cloudbase.bundle.js`(CloudBase JS SDK v3,UMD 全局 `cloudbase`) | | 数据 | CloudBase 云数据库 doc 集合(实时监听 `watch` / `onSnapshot`) | | 托管 | CloudBase WebApps,**独立 appPath 隔离**(示例 `/your-app-path`) | | 部署产物 | `dist/index.html` + `dist/cloudbase.bundle.js`,`deployApp(buildPath="dist", appPath="/your-app-path")` | | 浏览器记忆 | 统一用 `localStorage`(本设备级,非账户级) | > 关键心法:Chrome 刷新 = 整页销毁重建,内存状态必丢。**任何"跨刷新保留"的需求,唯一解是把状态落到刷新后仍在的地方(localStorage)**。这是本手册几乎所有"保持"类特性的共同基础。 --- ## 1. 刷新免密(记住登录态) **解决什么问题**:带门禁的工具若每次刷新都要求重新输入,体验差。希望"输过一次、未注销就一直可用"。 **核心机制**:进入成功时写 `localStorage['app_gate_passed']='1'`;页面加载时若已存在则直接进入,否则才显示遮罩。 ```js // 进入成功时 localStorage.setItem('app_gate_passed','1'); // 页面启动 IIFE(门禁逻辑同文件内,enterApp 之后执行) if(localStorage.getItem('app_gate_passed')==='1'){ enterApp(); } else { /* 显示 #gate 遮罩 */ } ``` **边界 / 注意事项**: - 本设备浏览器级记忆。换浏览器 / 换设备 / 清缓存 → 记忆不在 → 需重输。 - 若没有门禁(不需要口令),本特性可独立用于"记住某 UI 偏好 / 已初始化状态"。 **适用场景**:所有需要门禁、且希望减少重复输入的内部工具。 --- ## 2. 注销按钮 **解决什么问题**:免密后用户需要一种"主动退出、回到口令页"的方式。 **核心机制**:右上角固定按钮,点击清除门禁记忆并重新显示遮罩。 ```js // 按钮 HTML: function doLogout(){ localStorage.removeItem('app_gate_passed'); // 只清门禁记忆 gate.style.display = 'flex'; // 重新显示遮罩 pwd.value = ''; } document.getElementById('logoutBtn').addEventListener('click', doLogout); ``` **边界 / 注意事项**: - 当前注销**不清**选项卡记忆(特性3),重输口令后仍恢复到上次选项卡。若要求"注销后强制回默认选项卡",在 `doLogout` 里加 `localStorage.removeItem('app_tab')`。 **适用场景**:带门禁的工具,提供"换人 / 收尾"的主动退出入口。 --- ## 3. 选项卡维持(跨刷新保持上下文) **解决什么问题**:用户停留在某个选项卡,刷新后跳回默认选项卡。希望刷新后停在刚才的选项卡。 **核心机制**:点击选项卡时把 `data-tab` 写入 `localStorage['app_tab']`;页面启动后读取并恢复激活。与"刷新免密"同一范式。 ```js function activateTab(name){ document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.tab-pane').forEach(x=>x.classList.remove('active')); var t = document.querySelector('.tab[data-tab="'+name+'"]'); if(t) t.classList.add('active'); var p = document.getElementById('pane-'+name); if(p) p.classList.add('active'); } document.querySelectorAll('.tab').forEach(t=>{ t.addEventListener('click', ()=>{ activateTab(t.dataset.tab); localStorage.setItem('app_tab', t.dataset.tab); }); }); // 页面启动 IIFE(enterApp 之后): var savedTab = localStorage.getItem('app_tab'); if(savedTab) activateTab(savedTab); ``` **边界 / 注意事项**: - 本设备浏览器级记忆;换设备/清缓存回到默认。 - 恢复动作必须在 DOM 与门禁之后执行,确保 tab 元素已存在、免密已完成。 **适用场景**:多选项卡页面、表单分页、任何"用户停留位置"值得保留的工具。 --- ## 4. 失焦生效 + 实时协同(多会话) **解决什么问题**:多人同时打开同一份数据,既要实时看到对方的修改,又不能"我打字时对方屏幕乱跳""自己的修改被自己回环覆盖"。 **核心机制(四个要点)**: **(a) 打字期间只改本地,不扰对方;失焦才同步** ```js let cloudDirty = false; // 输入过程中有未上传的修改 // 输入时:改内存 + cloudDirty=true(本机立即见,但不写云端) // 失焦时:才有资格上传 inp.addEventListener('blur', ()=>{ if(cloudDirty) save(); }); ``` > 要点:监听 `blur` 而非 `input`/`keyup`。这样"打字期间对方零打扰,失焦一次性同步"。 **(b) 云端实时监听 + 回环不自扰** ```js function startWatch(){ cbWatcher = cbDb.collection('docs').doc(DOC_ID).watch({ onChange(snapshot){ applyRemote(snapshot); }, onError(err){ scheduleReconnect(); } }); } function applyRemote(snapshot){ const d = /* 取出 doc */; if(d.updatedTab === TAB_ID){ return; } // ⭐ 自己的回环推送:按「本页唯一ID」识别,跳过 applyRemoteDoc(d); } ``` > ⭐ **关键设计**:回环识别用「本页/本标签唯一 ID(`TAB_ID`,每次打开随机生成)」比对,**不依赖时间戳**。时间戳法在并发下极易误判;用唯一 ID 干净可靠。 **(c) 保存前冲突检查(同事刚保存过则先同步)** ```js async function save(){ // GET 云端当前值 const d = await cbDb.collection('docs').doc(DOC_ID).get(); if(d && d.cats && (d.updatedAt||0) > lastSeenRemoteAt && d.updatedTab !== TAB_ID){ applyRemoteDoc(d); // 同事刚保存 → 先同步最新 setSyncStatus('同事刚保存过,已同步最新数据,请确认后再修改','warn'); return; // 放弃本次覆盖 } cbDb.collection('docs').doc(DOC_ID).set(cloudPayload()); } ``` **(d) 断线自动重连(指数退避)+ 网络/可见性优化** ```js function scheduleReconnect(){ const delay = Math.min(30000, 2000 * Math.pow(2, watchRetry++)); // 2s→4s→8s→16s→30s setTimeout(async ()=>{ await loadCloud(); startWatch(); }, delay); } window.addEventListener('online', ()=>{ /* 有未传修改则补传,否则补拉;然后重建监听 */ }); document.addEventListener('visibilitychange', ()=>{ if(document.hidden) return; const away = Date.now() - hiddenAt; if(away < 5*60*1000) return; // 短暂切走,监听大概率还活着,不无谓重连 const editing = document.activeElement && (document.activeElement.tagName==='INPUT'||document.activeElement.tagName==='TEXTAREA'); if(!editing){ loadCloud().catch(()=>{}); } startWatch(); }); ``` **边界 / 注意事项**: - 实时协同强依赖云端 `watch`;纯静态无后端时退化为"本地存储 + 手动刷新"。 - `TAB_ID` 是每次打开页随机生成,刷新后会变——所以"回环"靠它只能挡住"本页自己的推送",跨刷新不算回环(本来就是新会话)。 **适用场景**:任何需要多人同时编辑 / 观看同一份数据的内部工具(台账、排班、配置器)。 --- ## 5. 修改记录 / 审计日志(防抖 diff) **解决什么问题**:业务要知道"谁在什么时候改了什么",但不想每条按键都记一条。 **核心机制**:每次 `save()` 后做 diff,防抖合并连续修改为一条;新记录格式 `{t, u:署名, m:'改了X'}`,`unshift` 到头部。 ```js function scheduleLog(){ /* 防抖:连续修改 600ms 内合并为一条 */ } // 写入:state.logs.unshift({ t:Date.now(), u: CB_USER, m:'改了…' }); // 渲染:按时间倒序遍历,转成表格行(操作人列如需两行显示见下方边界) ``` > 若工具需要"同一人跨记录可追溯",操作人字段建议带一个与署名解耦的唯一 ID(如 `署名#OPID`),并支持改署名时联动历史记录——该范式较业务相关,未纳入本手册通用集,可参照源文件按需实现。 **边界 / 注意事项**: - 防抖窗口要权衡:太短会记太多条,太长会漏掉快速连续操作。600ms 是经验值,可按操作节奏调整。 - 日志条数建议封顶(本项目 `LOG_MAX=500`),避免无限增长拖慢渲染。 **适用场景**:所有需要留痕的内部工具。配合实时协同(特性4),操作人可追溯"谁改的"。 --- ## 6. 快照 / 备份 **解决什么问题**:误操作或协同冲突后,需要能回退到某个时间点。 **核心机制**:定时 / 手动快照写独立集合,携带 `by: 署名`;与实时 doc 分离,互不干扰。 ```js const snap = { type:'backup', reason:reason||'', snapAt:Date.now(), by:CB_USER, /* …state… */ }; cbDb.collection('backups').add(snap); ``` **边界 / 注意事项**: - 快照应写入**独立集合**(如 `backups`),不要覆盖实时数据 doc,否则会和协同写入相互踩踏。 - 快照频率:定时(如每日)+ 关键操作前(如批量导入)触发,比"每次保存都快照"更省存储。 - 恢复流程需在 UI 上提供"选某个快照 → 覆盖回实时 doc"的入口,否则快照只是摆设。 **适用场景**:数据有"后悔药"需求、或协同场景下希望保留每日基线。 --- ## 7. 账号登录 + 「角色 × 应用」权限矩阵(集中配置) **解决什么问题**:多个内部工具共用同一套账号体系,但"谁能进哪个应用、进了能不能改"各不相同(如"超级管理员在所有应用可查可改""通用查询在所有应用只能看")。不希望每个应用各写一套账号、每个按钮各自判断。希望"账号 → 角色 → 该角色在此应用有什么权限"由**一个集中配置 + 一个判定函数**统一裁决,各应用只订阅结果。 **核心思想(三层映射 + 集中存储)**: ``` 账号(account) ──①查表──▶ 角色(role) ──②角色×应用矩阵──▶ 本应用{可查询,可编辑} ──③驱动──▶ 本应用 UI 可编辑性 + 门户卡片可见性 ``` - ① **账号→角色**:账号与角色写在一张表里(每个账号带一个 `role`)。 - ② **角色×应用矩阵**:`roles[role]` 内含 `apps`(`'*'` 表示全部应用,或具体应用数组)与 `query`/`edit` 开关。某角色在某应用是否可查/可改,由 `rolePerm(role, appId)` 一处算出。 - ③ **集中存储 + 各应用订阅**:配置统一存进云数据库一个集合(文档 `_id:'main'`,含 `roles` 与 `users`);各应用启动时拉取,**库不可达时回退内置副本**,不会因配置拉不到而瘫痪。改一处配置,所有应用刷新后统一生效。 **(a) 集中配置结构(存云数据库集合,如 `auth_config`;库不可达回退此内置副本)** ```js const AUTH_BOOTSTRAP = { roles: { super: { name:'超级管理员', apps:'*', query:true, edit:true }, // 所有应用:可查询 + 可编辑 admin: { name:'管理员', apps:'*', query:true, edit:true }, // 编辑暂全开;要细分可改为 ['appA','appB'] viewer: { name:'通用查询', apps:'*', query:true, edit:false }, // 所有应用:仅查询 }, users: [ { account:'示例超级管理员', pwd:'super123', role:'super' }, { account:'示例管理员', pwd:'admin123', role:'admin' }, { account:'示例查询员', pwd:'viewer12', role:'viewer' }, ], }; ``` **(b) 判定函数(一处算出"某角色在某应用的权限")** ```js function rolePerm(role, appId){ const cfg = (AUTH && AUTH.roles) ? AUTH : AUTH_BOOTSTRAP; const r = cfg.roles[role]; if(!r) return { query:false, edit:false }; const okApp = (r.apps==='*') || (Array.isArray(r.apps) && r.apps.indexOf(appId)>=0); if(!okApp) return { query:false, edit:false }; return { query:!!r.query, edit:!!r.edit }; } ``` **(c) 登录:校验 → 记会话 → 按本应用权限启动** ```js async function doLogin(){ if(cbReady && !AUTH){ try{ await loadAuthConfig(); }catch(e){} } // 优先用库配置,回退 AUTH_BOOTSTRAP const acc = gateAcc.value.trim(), pw = gatePwd.value; const cfg = (AUTH && AUTH.users) ? AUTH : AUTH_BOOTSTRAP; const u = cfg.users.find(x=>x.account===acc); if(!u){ gateErr.textContent='账号不存在或未分配角色'; return; } if(u.pwd !== pw){ gateErr.textContent='账号或密码错误'; return; } if(!((AUTH||AUTH_BOOTSTRAP).roles[u.role])){ gateErr.textContent='该账号尚未分配角色'; return; } sessionStorage.setItem('app_auth', JSON.stringify({ account:u.account, role:u.role })); enterApp(rolePerm(u.role, APP_ID)); // 把"本应用权限"交给启动逻辑 } ``` **(d) 按本应用权限驱动 UI(一处裁决,全 UI 生效)** ```js function applyEditability(perm){ const on = !!perm.edit; document.body.classList.toggle('readonly', !on); // 配合 CSS 隐藏破坏性控件 document.querySelectorAll('.ed-input').forEach(i=>{ if(i) i.disabled = !on; }); document.querySelectorAll('.btn-del,#clearBtn').forEach(b=>{ if(b) b.style.display = on ? '' : 'none'; }); } ``` **(e) 门户:按角色展示应用卡片 + 权限徽标** ```js function renderPortal(){ const cfg = (AUTH && AUTH.users) ? AUTH : AUTH_BOOTSTRAP; const role = (cfg.users.find(u=>u.account===auth.account)||{}).role || ''; APPS.forEach(a=>{ const perm = rolePerm(role, a.id); if(!perm.query) return; // 无查询权限的应用不展示 // 渲染卡片,并打 (perm.edit ? '可编辑' : '仅查询') 徽标;管理后台类应用可额外要求 role==='super' }); } ``` **(f) 权限管理后台(仅超级管理员可访问)** - 独立应用,门户对其做特殊判定:`if(appId==='权限后台') return (role==='super') ? 可见 : 不可见`。 - 提供:用户表格(新增 / 编辑 / 删除账号、分配角色、重置密码)+ 角色×应用权限矩阵编辑。 - 任何改动**写回集中配置集合**;其他业务应用刷新页面后即生效(无实时推送,需手动刷新)。 **(g) 纵深防御:关键写操作再判一次本应用编辑态** ```js function onDeleteRow(){ if(!currentAppPerm.edit) return; /* 只读角色禁止删除 */ /* …实际删除… */ } function onClearAll(){ if(!currentAppPerm.edit) return; /* 只读角色禁止清空 */ /* …实际清空… */ } ``` > ⭐ **为什么还要判一次**:UI 隐藏/只读只是"看起来不能改",懂技术者可在控制台改 DOM 绕过。处理器层的 `if(!currentAppPerm.edit) return;` 是最后一道闸。 **边界 / 注意事项**: - **前端写死密码 = 软隔离,不是真安全**:账号密码明文在网页源码里,查看源码即可获取。本模式解决"界面层按角色×应用授权与体验",**不解决"凭证保密"**。若要真正的访问控制,必须把校验放到后端(独立服务 + 会话令牌),前端只拿"是否可编辑"的结论。 - 会话用 `sessionStorage`(标签页级)而非 `localStorage`:关标签页即注销,适合共用设备的内部工具;若要求"跨刷新保持更久",可换 `localStorage`(但共用设备风险上升)。 - **集中配置、改一处全局生效**:账号/角色/权限不再写死在各应用,统一存集合,改库后各应用刷新即同步;"实时推送"需额外 watch,当前为"刷新生效"模型。 - **库不可达回退内置兜底**:避免集中配置拉取失败时所有应用瘫痪;兜底内容需与库保持一致。 - **跨应用无单点登录**:各应用独立登录,纯前端做不到共享会话(需后端/共享 cookie)。 - 新增应用:在 `APPS` 与 `roles[*].apps` 登记即可;新增细粒度角色:加 `roles` 一项并定义 `apps/query/edit`,UI 订阅方式不变。 **适用场景**:多个内部工具共用同一套账号体系、需要"某角色在某应用有某权限"细粒度授权的工具群(台账、配置器、审批台 + 统一门户 / 权限管理后台)。 --- ## 附:特性速查表(给新上下文的 TL;DR) | # | 特性 | 一句话实现 | 关键存储键 | 复用优先级 | |---|---|---|---|---| | 1 | 刷新免密 | 进入写 `app_gate_passed`,启动读 | `app_gate_passed` | ★★★ 带门禁必配 | | 2 | 注销按钮 | `removeItem`+重显遮罩 | — | ★★ 带门禁建议 | | 3 | 选项卡维持 | 点击写 `app_tab`,启动读 | `app_tab` | ★★ 多选项卡必配 | | 4 | 失焦生效+实时协同 | `blur`才`save`+`watch`+`TAB_ID`回环 | — | ★★★ 多人实时必配 | | 7 | 账号登录+「角色×应用」权限矩阵 | 账号→角色→`rolePerm(role,appId)`→本应用 UI + 门户卡片可见性;集中配置存库,写操作再判本应用编辑态 | `app_auth` | ★★★ 多应用分权限必配 | | — | (已移除)访问口令门禁 / 操作人署名联动+ID / 操作人两行显示 | — | — | — | **新应用落地清单(照搬顺序)**: 1. 搭底座(特性0):单文件 HTML + `cloudbase.bundle.js` + 云数据库 doc + `dist/` 部署。 2. 免密+注销(原 2、3):有门禁需求时,先有免密再给注销。 3. 交互保持(原 4):多选项卡就上。 4. 多人实时协作(原 7):有共享数据就上,四处连动。 5. 修改记录留痕(原 8):需要审计就上。 6. 快照兜底(原 9):数据重要就加快照。 7. 账号登录+「角色×应用」权限矩阵(特性7):多个应用共用账号体系时,先把配置集中到云数据库集合(roles+users)→ 各应用启动拉取并回退兜底 → 登录用 `rolePerm(role,appId)` 算本应用权限 → `applyEditability` 驱动 UI → 关键写操作加 `if(!perm.edit) return` → 门户按角色展示卡片、权限管理后台仅 super 可访问。 > 所有 `localStorage` 键建议加应用前缀(如 `app_`),避免同域下多个工具键名冲突。