跳转到内容

完整示例:卡片视图

这是内置卡片视图插件的完整源码 —— tableViews 贡献的官方样板:单文件、零构建、零依赖。有单选字段时按它分组成列,卡片可拖动换组(写回走宿主 op 管线,可撤销、协同实时);没有则平铺网格。

两个文件抄下来放进 ~/xinghan/plugins/dev.cards/ 就能跑(把 manifest 里的 id 改成 dev.cards)。配合自定义表格视图指南读效果最好。

{
"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
}
}
]
}
}

读的时候抓四个骨架点:①顶部手写的 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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>