完整示例:卡片视图
这是内置卡片视图插件的完整源码 —— tableViews 贡献的官方样板:单文件、零构建、零依赖。有单选字段时按它分组成列,卡片可拖动换组(写回走宿主 op 管线,可撤销、协同实时);没有则平铺网格。
两个文件抄下来放进 ~/xinghan/plugins/dev.cards/ 就能跑(把 manifest 里的 id 改成 dev.cards)。配合自定义表格视图指南读效果最好。
plugin.json
Section titled “plugin.json”{ "id": "xinghan.card-view", "name": "卡片视图", "version": "0.1.0", "description": "给任意表加一个「卡片」视图:有单选字段时按它分组成列,卡片可拖动换组(直接改该字段,走宿主 op 管线可撤销、协同实时);没有则平铺网格。插件定义表视图(tableViews 贡献)的官方样板——单文件零构建:等 context → capabilities 特性检测 → ui 桥读 fields/records → 自由渲染;写入须在 tableViews 贡献里声明 writes 且只能写本视图挂载的表", "author": "Xinghan", "runtime": "static", "ui": "ui", "market": { "category": "视图", "tags": [ "view", "cards" ] }, "capabilities": [], "contributes": { "tableViews": [ { "viewKey": "cards", "name": { "zh-CN": "卡片视图", "en-US": "Card view" }, "icon": "🃏", "entry": "ui/index.html", "writes": { "cells": true } } ] }}ui/index.html(完整源码)
Section titled “ui/index.html(完整源码)”读的时候抓四个骨架点:①顶部手写的 rpc()(零依赖场景直接 postMessage,等价于 SDK 的 ui 对象);②cellText() 把选项 id 解回文字;③renderBoard 的拖拽 → dataEngine.updateCells → 失败 toast 回滚;④启动序列:等 context → capabilities() 特性检测 → 并发拉 fields/records。
<!doctype html><!-- 卡片视图 —— 插件表视图(tableViews 贡献)的官方样板。
刻意做成**单文件零构建**:证明写一个表视图不需要任何框架/打包器, 三步走完 —— ①等宿主 context(拿 tableNodeId/viewId) ②经 ui 桥读 fields/records(postMessage RPC,协议见下) ③自由渲染。主题变量由宿主 推送(theme/init,shadcn token:--background/--foreground/--border 等), 直接 var(--token) 就自动跟随亮暗色。--><html lang="zh-CN"><head><meta charset="utf-8" /><title>卡片视图</title><style> :root { color-scheme: light dark; } body { margin: 0; font: 13px/1.6 system-ui, -apple-system, sans-serif; background: var(--background, transparent); color: var(--foreground, #1f2329); } .wrap { padding: 14px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .card { border: 1px solid var(--border, #e5e6eb); border-radius: 10px; background: var(--muted, rgba(127,127,127,.06)); padding: 10px 12px; cursor: pointer; transition: border-color .12s; } .card:hover { border-color: var(--primary, #3370ff); } .card h3 { margin: 0 0 6px; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .kv { display: flex; gap: 6px; font-size: 12px; margin: 2px 0; } .kv .k { color: var(--muted-foreground, #888); flex: 0 0 auto; max-width: 40%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .kv .v { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .status { padding: 24px; color: var(--muted-foreground, #888); } /* 分组列(有单选字段时):横排列,卡片可拖动换组 */ .board { display: flex; gap: 12px; align-items: flex-start; overflow-x: auto; } .col { flex: 0 0 240px; border-radius: 10px; background: var(--muted, rgba(127,127,127,.05)); padding: 8px; min-height: 80px; } .col.dragover { outline: 2px dashed var(--primary, #3370ff); outline-offset: -2px; } .col h2 { margin: 2px 4px 8px; font-size: 12px; color: var(--muted-foreground, #888); display: flex; gap: 6px; align-items: center; } .col h2 .n { font-weight: normal; } .col .card { margin-bottom: 8px; background: var(--background, #fff); } .card[draggable="true"] { cursor: grab; } .card.dragging { opacity: .5; }</style></head><body><div class="wrap"><div id="app" class="status">加载中…</div></div><script>(() => { // ── 宿主桥(内联最小实现;有构建的插件用 @xinghan/plugin-sdk 的 ui 即可) ── let ctx = null const pendingCtx = [] let rpcSeq = 0 const rpcPending = new Map()
window.addEventListener('message', (e) => { const d = e.data if (!d || typeof d !== 'object') return if (d.type === 'context/init' || d.type === 'context/update') { ctx = d.context pendingCtx.splice(0).forEach((fn) => fn(ctx)) return } if (d.type === 'theme/init' || d.type === 'theme/update') { for (const [k, v] of Object.entries(d.tokens ?? {})) { document.documentElement.style.setProperty(k, v) } return } if (typeof d.type === 'string' && d.type.endsWith('/result') && rpcPending.has(d.id)) { const { resolve, reject } = rpcPending.get(d.id) rpcPending.delete(d.id) d.error ? reject(new Error(d.error)) : resolve(d.data) } }) const waitContext = () => new Promise((r) => (ctx ? r(ctx) : pendingCtx.push(r))) const rpc = (method, args) => new Promise((resolve, reject) => { const id = ++rpcSeq // 宿主要求 number id(字符串会被静默丢弃) rpcPending.set(id, { resolve, reject }) parent.postMessage({ type: 'ui/' + method, id, args }, '*') setTimeout(() => { if (rpcPending.delete(id)) reject(new Error(method + ' 超时')) }, 15000) })
// ── 渲染 ── const app = document.getElementById('app') const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]) /** 单元格值 → 展示文本。field 传入时把 optionId 映射回选项文字 * (单选/多选存的是 opt_xxx id,引擎契约如此;渲染方自己解) */ const cellText = (v, field) => { if (v == null || v === '') return '' if (Array.isArray(v)) return v.map((x) => cellText(x, field)).filter(Boolean).join('、') if (typeof v === 'object') return v.name ?? v.title ?? v.text ?? '' const s = String(v) const opts = field?.properties?.options if (Array.isArray(opts)) { const hit = opts.find((o) => o?.id === s) if (hit) return hit.text ?? hit.name ?? s } return s }
const cardHtml = (r, title, rest) => { const data = r.data ?? r const kvs = rest .map((f) => { const v = cellText(data[f.id], f) return v ? `<div class="kv"><span class="k">${esc(f.name)}</span><span class="v">${esc(v)}</span></div>` : '' }) .join('') return `<div class="card" data-rid="${esc(r.id ?? r._recordId ?? '')}"> <h3>${esc(cellText(data[title?.id], title) || '(未命名)')}</h3>${kvs}</div>` }
// 卡片点击 → 宿主「记录列表」弹层定位到那一行(展示归插件,详情/编辑归内核) const wireCardClick = (root, tableNodeId) => { root.addEventListener('click', (e) => { const card = e.target.closest('.card') const rid = card?.dataset.rid if (!rid || card.classList.contains('dragging')) return const label = card.querySelector('h3')?.textContent ?? rid void rpc('openRecordList', { title: label, tableNodeId, groups: [{ value: '', count: 1, records: [{ recordId: rid, label }] }], }).catch(() => {}) }) }
/** 平铺网格(无单选字段/写入未放行时的原始形态) */ function renderGrid(records, fields, tableNodeId) { const title = fields[0] const rest = fields.slice(1, 5) // 卡上最多列 4 个字段,多了看记录详情 const grid = document.createElement('div') grid.className = 'grid' grid.innerHTML = records.map((r) => cardHtml(r, title, rest)).join('') wireCardClick(grid, tableNodeId) return grid }
/** * 分组列 + 拖卡换组(写入面 v2 样板):按第一个单选字段分组,卡片拖到别的 * 列 = 改该字段值。写走 rpc('dataEngine.updateCells') —— 宿主治理:须 * plugin.json 的 tableViews 贡献里声明 writes.cells,且只能写本视图挂载 * 的表;执行走宿主 op 管线(表格里 Ctrl+Z 可撤销,协同端实时看到)。 */ function renderBoard(records, fields, groupField, tableNodeId, canWrite) { const title = fields[0] const rest = fields.filter((f) => f !== title && f !== groupField).slice(0, 4) const options = (groupField.properties?.options ?? []).map((o) => ({ id: o.id, text: o.text ?? o.name ?? o.id })) const board = document.createElement('div') board.className = 'board' const buckets = new Map(options.map((o) => [o.id, []])) const none = [] for (const r of records) { const v = (r.data ?? r)[groupField.id] const key = Array.isArray(v) ? v[0] : v ;(buckets.get(key) ?? none).push(r) } const cols = [...options.map((o) => ({ key: o.id, label: o.text, rows: buckets.get(o.id) })), ...(none.length ? [{ key: '', label: '(未分组)', rows: none }] : [])] board.innerHTML = cols .map((col) => `<div class="col" data-key="${esc(col.key)}"> <h2>${esc(col.label)} <span class="n">${col.rows.length}</span></h2> ${col.rows.map((r) => cardHtml(r, title, rest)).join('')} </div>`) .join('') wireCardClick(board, tableNodeId) if (!canWrite) return board // 只读:分组展示但不可拖
for (const card of board.querySelectorAll('.card')) card.setAttribute('draggable', 'true') let dragRid = null board.addEventListener('dragstart', (e) => { const card = e.target.closest('.card') if (!card) return dragRid = card.dataset.rid card.classList.add('dragging') e.dataTransfer.effectAllowed = 'move' }) board.addEventListener('dragend', (e) => { e.target.closest('.card')?.classList.remove('dragging') for (const c of board.querySelectorAll('.col.dragover')) c.classList.remove('dragover') }) board.addEventListener('dragover', (e) => { const col = e.target.closest('.col') if (!col || !dragRid) return e.preventDefault() // 必须 preventDefault 才允许 drop e.dataTransfer.dropEffect = 'move' col.classList.add('dragover') }) board.addEventListener('dragleave', (e) => { e.target.closest('.col')?.classList.remove('dragover') }) board.addEventListener('drop', async (e) => { const col = e.target.closest('.col') if (!col || !dragRid) return e.preventDefault() const rid = dragRid dragRid = null const newKey = col.dataset.key || null const card = board.querySelector('.card[data-rid="' + CSS.escape(rid) + '"]') if (!card || card.parentElement === col) return col.appendChild(card) // 乐观移动;失败再移回 const from = card.parentElement try { await rpc('dataEngine.updateCells', { tableNodeId, cells: [{ recordId: rid, fieldId: groupField.id, value: newKey }], }) } catch (err) { from !== col && from?.appendChild(card) void rpc('toast', { message: '改分组失败:' + err.message, type: 'error' }).catch(() => {}) } }) return board }
async function main() { const c = await waitContext() const tableNodeId = c.tableNodeId || c.nodeId if (!tableNodeId) { app.textContent = '没有拿到表 id(context.tableNodeId)' return } // capabilities = 特性检测:宿主按 manifest writes + 挂载形态给出本实例 // 真实能力面。写代码先探再用,不猜宿主版本。 const [{ fields }, result, caps] = await Promise.all([ rpc('dataEngine.getFields', { tableNodeId }), rpc('dataEngine.queryRecords', { tableNodeId, opts: { limit: 200 } }), rpc('capabilities', {}).catch(() => ({ methods: [], tableView: null })), ]) const records = result?.records ?? [] if (records.length === 0) { app.textContent = '这张表还没有数据。' return } app.classList.remove('status') // 第一个单选字段(type '3')做分组列;写档放行才开拖拽 const groupField = fields.find((f) => String(f.type) === '3' && Array.isArray(f.properties?.options) && f.properties.options.length) const canWrite = !!caps.tableView?.writes?.cells app.replaceChildren( groupField ? renderBoard(records, fields, groupField, tableNodeId, canWrite) : renderGrid(records, fields, tableNodeId), ) }
main().catch((e) => { app.textContent = '加载失败:' + e.message })})()</script></body></html>