feat: admit signed channel workbench module

This commit is contained in:
冰朔 2026-08-19 02:22:32 +08:00
commit bafaf464c2
25 changed files with 1886 additions and 44 deletions

View file

@ -1,8 +1,11 @@
import { StrictMode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { lazy, StrictMode, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import { numberedInvoke as invoke } from './modules/numbered-ipc'
import { splitFrontmatter, documentOutline, jumpToHeading, MarkdownDocument, markdownHtml } from './modules/knowledge-render'
import { NativeCompositionStudio, type CompositionDimension, type CompositionMeasure, type NativeCompositionProjection, type ProjectionView } from './modules/native-composition'
import type { ChannelWorkbenchSnapshot } from './modules/channel-workbench'
const ChannelWorkbenchStudio = lazy(() => import('./modules/channel-workbench').then((module) => ({ default: module.ChannelWorkbenchStudio })))
const TAG_TINTS = ['tag-lavender', 'tag-sky', 'tag-mint', 'tag-amber', 'tag-rose', 'tag-slate']
@ -49,7 +52,7 @@ import './design-tokens.css'
import './styles.css'
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
type ViewId = 'overview' | 'knowledge' | 'composition' | 'code' | 'receipts' | 'system'
type ViewId = 'overview' | 'knowledge' | 'composition' | 'workbench' | 'code' | 'receipts' | 'system'
type WorldStage = 'domain' | 'heart' | 'heartbeat' | 'lightLake' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'enterpriseWork' | 'personalNodeGuide' | 'tool'
type KnowledgeSource = 'native' | 'legacy'
@ -363,7 +366,7 @@ const previewCode: CodeChannelSnapshot = { state: 'UNAVAILABLE', channels: [], a
const themes: Array<{ id: ThemeId; name: string }> = [
{ id: 'night', name: '夜湖星光' }, { id: 'dawn', name: '晨湖曦光' }, { id: 'nebula', name: '星云紫夜' }, { id: 'candle', name: '烛畔暖湖' }, { id: 'clear', name: '清浅澄湖' },
]
const viewLabels: Record<ViewId, string> = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', code: '人格代码频道', receipts: '运行回执', system: '系统详情' }
const viewLabels: Record<ViewId, string> = { overview: '个人频道', knowledge: '知识空间', composition: '结构组合', workbench: '频道资料工作台', code: '人格代码频道', receipts: '运行回执', system: '系统详情' }
const domainGates = [
{ domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · ONLINE', facts: [
['域标识', 'BRANCH_DOMAIN'], ['责任主体', '花尔 · TCS-GL-0005∞'], ['人格体主体', '爆米花 · PER-BMH001 · AGE'], ['关系支持', '糖星云 · PER-TXY001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'],
@ -596,6 +599,10 @@ function HoloLakeApp() {
const [compositionViews, setCompositionViews] = useState<ProjectionView[]>(['DASHBOARD', 'VERTICAL_BAR', 'TABLE'])
const [compositionBusy, setCompositionBusy] = useState(false)
const [compositionMessage, setCompositionMessage] = useState('')
const [workbenchModule, setWorkbenchModule] = useState<BundledModuleDescriptor | null>(null)
const [workbenchSnapshot, setWorkbenchSnapshot] = useState<ChannelWorkbenchSnapshot | null>(null)
const [workbenchBusy, setWorkbenchBusy] = useState(false)
const [workbenchMessage, setWorkbenchMessage] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(['导入']))
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
@ -1079,6 +1086,53 @@ function HoloLakeApp() {
} finally { setCompositionBusy(false) }
}
const refreshWorkbenchModule = async () => {
try {
const catalog = await invoke<BundledModuleDescriptor[]>('get_bundled_module_catalog')
const module = catalog.find((item) => item.moduleNumber === 'HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001') || null
setWorkbenchModule(module)
if (module?.installedState === 'ACTIVE') setWorkbenchSnapshot(await invoke<ChannelWorkbenchSnapshot>('get_channel_workbench_snapshot'))
return module
} catch (error) {
setWorkbenchMessage(humanError(error, 'system'))
return null
}
}
const openWorkbench = () => {
openWorldTool('workbench')
void refreshWorkbenchModule()
}
const activateWorkbenchModule = async () => {
setWorkbenchBusy(true)
setWorkbenchMessage('正在验证签名、登记编号并执行模块自检……')
try {
await invoke('activate_bundled_module', { input: { moduleNumber: 'HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001', humanConfirmedPermissionExpansion: true } })
const module = await refreshWorkbenchModule()
if (!module || module.installedState !== 'ACTIVE') throw new Error('HOLOLAKE_MODULE_NOT_ACTIVE')
setWorkbenchSnapshot(await invoke<ChannelWorkbenchSnapshot>('get_channel_workbench_snapshot'))
setWorkbenchMessage('模块已通过签名、编号、四项读写权限与自检验收。')
} catch (error) { setWorkbenchMessage(humanError(error, 'system')) }
finally { setWorkbenchBusy(false) }
}
const saveWorkbenchDocument = async (input: { documentId: string; title: string; body: string; expectedRevision: number }) => {
setWorkbenchBusy(true); setWorkbenchMessage('')
try {
const result = await invoke<{ receiptSha256: string }>('save_channel_document', { input })
setWorkbenchSnapshot(await invoke<ChannelWorkbenchSnapshot>('get_channel_workbench_snapshot'))
setWorkbenchMessage(`文档已写入本机频道;回执 ${result.receiptSha256.slice(0, 12)}`)
} catch (error) { setWorkbenchMessage(humanError(error, 'system')) }
finally { setWorkbenchBusy(false) }
}
const saveWorkbenchSpreadsheet = async (input: { tableId: string; title: string; columns: { columnId: string; title: string }[]; rows: { rowId: string; cells: string[] }[]; expectedRevision: number }) => {
setWorkbenchBusy(true); setWorkbenchMessage('')
try {
const result = await invoke<{ receiptSha256: string }>('save_channel_spreadsheet', { input })
setWorkbenchSnapshot(await invoke<ChannelWorkbenchSnapshot>('get_channel_workbench_snapshot'))
setWorkbenchMessage(`工作表已写入本机频道;回执 ${result.receiptSha256.slice(0, 12)}`)
} catch (error) { setWorkbenchMessage(humanError(error, 'system')) }
finally { setWorkbenchBusy(false) }
}
const cloneCodeChannel = async (event: React.FormEvent) => {
event.preventDefault()
if (!cloneUrl.trim()) return
@ -1451,6 +1505,20 @@ function HoloLakeApp() {
</section>
)
const renderWorkbench = () => (
<section className="full-workbench channel-workbench-world">
{workbenchModule?.installedState === 'ACTIVE' && workbenchSnapshot
? <Suspense fallback={<div className="workbench-empty"><p></p></div>}><ChannelWorkbenchStudio snapshot={workbenchSnapshot} busy={workbenchBusy} message={workbenchMessage} onSaveDocument={saveWorkbenchDocument} onSaveSpreadsheet={saveWorkbenchSpreadsheet}/></Suspense>
: <div className="workbench-empty">
<span></span><h2></h2>
<p></p>
<code>HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001 · DOCUMENT/SPREADSHEET READ+WRITE</code>
<button className="primary-button" type="button" disabled={workbenchBusy} onClick={() => void activateWorkbenchModule()}>{workbenchBusy ? '正在验收模块…' : '确认四项权限并启用'}</button>
{workbenchMessage && <p>{workbenchMessage}</p>}
</div>}
</section>
)
const renderCode = () => (
<section className="full-workbench code-workbench">
<aside className="code-channels">
@ -1719,6 +1787,7 @@ function HoloLakeApp() {
<LakePool className="channel-main" title="频道全景" meta="心跳核心频道" open onClick={() => openWorldTool('overview')}/>
<LakePool className="channel-knowledge" title="知识空间" meta={`${knowledge.uniqueDocumentCount} 唯一页`} onClick={() => openWorldTool('knowledge')}/>
<LakePool className="channel-light" title="结构组合" meta="签名模块 · 只读知识投影" onClick={() => { openWorldTool('composition'); void refreshCompositionModule() }}/>
<LakePool className="channel-code" title="资料工作台" meta="签名模块 · 文档与智能表格" onClick={openWorkbench}/>
{timeAuthorityModule && <LakePool className="channel-time" title="时间主控" meta={beijingCoordinate ? `光湖历第 ${beijingCoordinate.guanghuEraDay}` : '北京时间正在流动'} onClick={openEraTimeline}/>}
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</section>}
@ -1731,7 +1800,7 @@ function HoloLakeApp() {
</section>}
{worldStage === 'tool' && <section className={`world-tool world-tool-${view}`}>
<header className="tool-worldbar"><button type="button" onClick={() => setWorldStage(toolReturnStage)}> 退</button><b>{viewLabels[view]}</b><span>{domainDisplayName(repoLogin.domain)}</span></header>
<div className={`tool-projection${inspectorOpen ? '' : ' inspector-closed'}`}>{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</div>
<div className={`tool-projection${inspectorOpen ? '' : ' inspector-closed'}`}>{view === 'overview' ? renderOverview() : view === 'knowledge' ? renderKnowledge() : view === 'composition' ? renderComposition() : view === 'workbench' ? renderWorkbench() : view === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</div>
</section>}
</main>
<footer className="world-footer"><b> · </b><span>GH-AIOS</span></footer>

View file

@ -0,0 +1,58 @@
import { useMemo } from 'react'
import { $createParagraphNode, $getSelection, FORMAT_TEXT_COMMAND, REDO_COMMAND, UNDO_COMMAND } from 'lexical'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $convertFromMarkdownString, $convertToMarkdownString, BOLD_ITALIC_STAR, BOLD_STAR, CHECK_LIST, HEADING, ITALIC_STAR, ORDERED_LIST, QUOTE, UNORDERED_LIST, type Transformer } from '@lexical/markdown'
import { $createHeadingNode, HeadingNode, QuoteNode } from '@lexical/rich-text'
import { INSERT_CHECK_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list'
import { $setBlocksType } from '@lexical/selection'
const TRANSFORMERS: Transformer[] = [HEADING, QUOTE, UNORDERED_LIST, ORDERED_LIST, CHECK_LIST, BOLD_ITALIC_STAR, BOLD_STAR, ITALIC_STAR]
function Toolbar() {
const [editor] = useLexicalComposerContext()
const setBlock = (kind: 'paragraph' | 'heading') => editor.update(() => {
const selection = $getSelection()
$setBlocksType(selection, () => kind === 'heading' ? $createHeadingNode('h2') : $createParagraphNode())
})
return <div className="channel-document-toolbar" aria-label="频道内置文档引擎工具">
<span></span>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')}><b></b></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')}><i></i></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => setBlock('heading')}></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => setBlock('paragraph')}></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)}></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)}></button>
<i/>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}></button>
<button type="button" onMouseDown={(event) => event.preventDefault()} onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}></button>
</div>
}
export default function ChannelDocumentEngine({ body, onChange }: { body: string; onChange: (body: string) => void }) {
const initialConfig = useMemo(() => ({
namespace: 'HoloLakeChannelDocumentEngine',
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode],
theme: {
paragraph: 'channel-document-paragraph', heading: { h2: 'channel-document-heading' },
quote: 'channel-document-quote', list: { ul: 'channel-document-list', checklist: 'channel-document-checklist' },
text: { bold: 'channel-document-bold', italic: 'channel-document-italic' },
},
onError: (error: Error) => { throw error },
editorState: () => $convertFromMarkdownString(body, TRANSFORMERS),
}), [])
return <LexicalComposer initialConfig={initialConfig}>
<Toolbar/>
<div className="channel-document-canvas">
<RichTextPlugin contentEditable={<ContentEditable className="channel-document-editor" aria-label="频道文档正文"/>} placeholder={<div className="channel-document-placeholder"></div>} ErrorBoundary={LexicalErrorBoundary}/>
<HistoryPlugin/><MarkdownShortcutPlugin transformers={TRANSFORMERS}/>
<OnChangePlugin ignoreSelectionChange onChange={(state) => state.read(() => onChange($convertToMarkdownString(TRANSFORMERS)))}/>
</div>
</LexicalComposer>
}

View file

@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import ChannelDocumentEngine from './document-engine'
import ChannelSpreadsheetEngine from './spreadsheet-engine'
import type { ChannelSpreadsheetValue, ChannelWorkbenchColumn, ChannelWorkbenchRow } from './spreadsheet-model'
import './styles.css'
export { channelWorkbenchManifest } from './manifest'
export interface ChannelDocumentSnapshot { documentId: string; title: string; body: string; revision: number; updatedAtUnixMs: number; contentSha256: string }
export interface ChannelSpreadsheetSnapshot extends ChannelSpreadsheetValue { tableId: string; title: string; revision: number; updatedAtUnixMs: number; contentSha256: string }
export interface ChannelWorkbenchSnapshot { schema: string; state: string; moduleNumber: string; document: ChannelDocumentSnapshot; spreadsheet: ChannelSpreadsheetSnapshot; receiptCount: number; latestReceiptHash: string; userDataSurvivesUnmount: boolean }
export interface WorkbenchSaveResult { state: string; entityKind: string; entityId: string; revision: number; contentSha256: string; receiptSha256: string; observedAtUnixMs: number }
interface Props {
snapshot: ChannelWorkbenchSnapshot
busy: boolean
message: string
onSaveDocument: (input: { documentId: string; title: string; body: string; expectedRevision: number }) => Promise<void>
onSaveSpreadsheet: (input: { tableId: string; title: string; columns: ChannelWorkbenchColumn[]; rows: ChannelWorkbenchRow[]; expectedRevision: number }) => Promise<void>
}
export function ChannelWorkbenchStudio({ snapshot, busy, message, onSaveDocument, onSaveSpreadsheet }: Props) {
const [mode, setMode] = useState<'document' | 'spreadsheet'>('document')
const [documentTitle, setDocumentTitle] = useState(snapshot.document.title)
const [documentBody, setDocumentBody] = useState(snapshot.document.body)
const [sheetTitle, setSheetTitle] = useState(snapshot.spreadsheet.title)
const [sheetValue, setSheetValue] = useState<ChannelSpreadsheetValue>({ columns: snapshot.spreadsheet.columns, rows: snapshot.spreadsheet.rows })
useEffect(() => { setDocumentTitle(snapshot.document.title); setDocumentBody(snapshot.document.body) }, [snapshot.document.revision])
useEffect(() => { setSheetTitle(snapshot.spreadsheet.title); setSheetValue({ columns: snapshot.spreadsheet.columns, rows: snapshot.spreadsheet.rows }) }, [snapshot.spreadsheet.revision])
return <div className="channel-workbench-layout">
<header className="channel-workbench-header">
<div><span>LOCAL CHANNEL WORKBENCH</span><h1></h1><p></p></div>
<div className="channel-workbench-state"><i/><b></b><small>{snapshot.receiptCount} · {snapshot.latestReceiptHash.slice(0, 12) || '尚未保存'}</small></div>
</header>
<nav className="channel-workbench-tabs" aria-label="工作台类型">
<button type="button" className={mode === 'document' ? 'active' : ''} onClick={() => setMode('document')}></button>
<button type="button" className={mode === 'spreadsheet' ? 'active' : ''} onClick={() => setMode('spreadsheet')}></button>
<span></span>
</nav>
{mode === 'document' ? <section className="channel-workbench-document">
<header><input aria-label="频道文档标题" value={documentTitle} maxLength={120} onChange={(event) => setDocumentTitle(event.target.value)}/><div><small> {snapshot.document.revision} · {snapshot.document.contentSha256.slice(0, 12)}</small><button type="button" disabled={busy || !documentTitle.trim()} onClick={() => void onSaveDocument({ documentId: snapshot.document.documentId, title: documentTitle, body: documentBody, expectedRevision: snapshot.document.revision })}>{busy ? '正在写入…' : '保存并签回执'}</button></div></header>
<ChannelDocumentEngine key={`${snapshot.document.documentId}-${snapshot.document.revision}`} body={documentBody} onChange={setDocumentBody}/>
</section> : <section className="channel-workbench-sheet">
<header><input aria-label="频道工作表标题" value={sheetTitle} maxLength={120} onChange={(event) => setSheetTitle(event.target.value)}/><div><small> {snapshot.spreadsheet.revision} · {snapshot.spreadsheet.contentSha256.slice(0, 12)}</small><button type="button" disabled={busy || !sheetTitle.trim()} onClick={() => void onSaveSpreadsheet({ tableId: snapshot.spreadsheet.tableId, title: sheetTitle, columns: sheetValue.columns, rows: sheetValue.rows, expectedRevision: snapshot.spreadsheet.revision })}>{busy ? '正在写入…' : '保存并签回执'}</button></div></header>
<ChannelSpreadsheetEngine key={`${snapshot.spreadsheet.tableId}-${snapshot.spreadsheet.revision}`} tableId={snapshot.spreadsheet.tableId} title={sheetTitle} columns={snapshot.spreadsheet.columns} rows={snapshot.spreadsheet.rows} onChange={setSheetValue}/>
</section>}
{message && <p className="channel-workbench-message">{message}</p>}
</div>
}

View file

@ -0,0 +1,11 @@
export const channelWorkbenchManifest = {
schema: 'hololake.channel-module/v1',
moduleNumber: 'HLP-MOD-LOCAL-CHANNEL-WORKBENCH-0001',
name: '频道文档与表格工作台',
version: '0.1.0',
slot: 'channel-workbench',
state: 'SIGNED_LOCAL_MODULE',
engines: { document: 'LEXICAL_0_49', spreadsheet: 'FORTUNE_SHEET_1_0_4' },
authority: 'CURRENT_AUTHENTICATED_ACCOUNT_LOCAL_DATA_ONLY',
numberedOperations: ['HLP-NIPC-OP-0074', 'HLP-NIPC-OP-0075', 'HLP-NIPC-OP-0076'],
} as const

View file

@ -0,0 +1,21 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { Workbook, type WorkbookInstance } from '@fortune-sheet/react'
import '@fortune-sheet/react/dist/index.css'
import { createInitialWorkbook, readWorkbookValue, type ChannelSpreadsheetValue, type FortuneSheet } from './spreadsheet-model'
export type { ChannelSpreadsheetValue } from './spreadsheet-model'
export default function ChannelSpreadsheetEngine({ tableId, title, columns, rows, onChange }: ChannelSpreadsheetValue & { tableId: string; title: string; onChange: (value: ChannelSpreadsheetValue) => void }) {
const workbookRef = useRef<WorkbookInstance>(null)
const [initialData] = useState(() => createInitialWorkbook(title, tableId, { columns, rows }))
const latestRef = useRef<ChannelSpreadsheetValue>({ columns, rows })
const publish = useCallback((data: FortuneSheet[]) => {
const next = readWorkbookValue(data[0], latestRef.current)
latestRef.current = next
onChange(next)
}, [onChange])
useEffect(() => { const timer = window.setTimeout(() => workbookRef.current?.calculateFormula(), 300); return () => window.clearTimeout(timer) }, [tableId])
return <section className="channel-spreadsheet-engine" aria-label="频道内置工作表引擎">
<header><div><b></b><span> · </span></div><em></em></header>
<div className="channel-spreadsheet-canvas"><Workbook ref={workbookRef} data={initialData} lang="zh" onChange={publish} allowEdit showToolbar showFormulaBar showSheetTabs forceCalculation row={Math.max(40, rows.length + 10)} column={Math.max(12, columns.length + 4)} defaultColWidth={112} defaultRowHeight={30} defaultFontSize={12} toolbarItems={['undo', 'redo', '|', 'currency-format', 'percentage-format', 'format', '|', 'bold', 'italic', 'underline', '|', 'font-color', 'background', 'border', 'merge-cell', '|', 'horizontal-align', 'vertical-align', 'text-wrap', '|', 'freeze', 'conditionFormat', 'filter', 'quick-formula', 'dataVerification', 'search']} cellContextMenu={['copy', 'paste', '|', 'insert-row', 'insert-column', 'delete-row', 'delete-column', 'delete-cell', 'hide-row', 'hide-column', 'set-row-height', 'set-column-width', '|', 'clear', 'sort', 'orderAZ', 'orderZA']}/></div>
</section>
}

View file

@ -0,0 +1,38 @@
export interface ChannelWorkbenchColumn { columnId: string; title: string }
export interface ChannelWorkbenchRow { rowId: string; cells: string[] }
export interface ChannelSpreadsheetValue { columns: ChannelWorkbenchColumn[]; rows: ChannelWorkbenchRow[] }
export type FortuneCell = { v?: string | number | boolean; m?: string | number; f?: string; bg?: string; fc?: string; bl?: number; fs?: number; ht?: number; vt?: number; tb?: string }
export type FortuneSheet = { name: string; id?: string; status?: number; row?: number; column?: number; data?: Array<Array<FortuneCell | null>>; celldata?: Array<{ r: number; c: number; v: FortuneCell | null }>; calcChain?: Array<{ r: number; c: number; id: string }>; luckysheet_select_save?: Array<{ row: number[]; column: number[]; row_focus?: number; column_focus?: number }> }
function nativeCell(value: string, header = false): FortuneCell {
if (header) return { v: value, m: value, bl: 1, fs: 12, bg: '#ece8dc', fc: '#202632', ht: 0, vt: 0, tb: '2' }
if (value.startsWith('=')) return { f: value }
const normalized = value.trim().replaceAll(',', '')
if (normalized && /^-?(?:\d+\.?\d*|\.\d+)$/.test(normalized)) return { v: Number(normalized), m: value }
return { v: value, m: value, tb: '2' }
}
export function createInitialWorkbook(title: string, tableId: string, value: ChannelSpreadsheetValue): FortuneSheet[] {
const width = Math.max(1, value.columns.length)
const matrix: Array<Array<FortuneCell | null>> = [value.columns.map((column) => nativeCell(column.title, true)), ...value.rows.map((row) => Array.from({ length: width }, (_, index) => nativeCell(row.cells[index] || '')))]
const celldata = matrix.flatMap((row, r) => row.map((v, c) => ({ r, c, v })))
const calcChain = celldata.flatMap(({ r, c, v }) => typeof v?.f === 'string' && v.f ? [{ r, c, id: tableId }] : [])
return [{ name: title || '工作表 1', id: tableId, status: 1, row: Math.max(40, matrix.length + 10), column: Math.max(12, width + 4), celldata, calcChain, luckysheet_select_save: [{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 }] }]
}
function cellText(cell: FortuneCell | null | undefined): string {
if (!cell) return ''
if (typeof cell.f === 'string' && cell.f) return cell.f
return String(cell.m ?? cell.v ?? '')
}
export function readWorkbookValue(sheet: FortuneSheet | undefined, previous: ChannelSpreadsheetValue, createId: () => string = () => crypto.randomUUID().replaceAll('-', '').toUpperCase()): ChannelSpreadsheetValue {
const matrix = sheet?.data || []
let lastRow = 0
let lastColumn = Math.max(0, previous.columns.length - 1)
matrix.forEach((row, rowIndex) => row?.forEach((cell, columnIndex) => { if (cellText(cell).trim()) { lastRow = Math.max(lastRow, rowIndex); lastColumn = Math.max(lastColumn, columnIndex) } }))
const columnCount = Math.min(64, Math.max(1, lastColumn + 1))
const columns = Array.from({ length: columnCount }, (_, index) => ({ columnId: previous.columns[index]?.columnId || `HLP-WB-COL-${createId()}`, title: cellText(matrix[0]?.[index]).trim() || previous.columns[index]?.title || `字段 ${index + 1}` }))
const rows = Array.from({ length: Math.min(5000, lastRow) }, (_, offset) => ({ rowId: previous.rows[offset]?.rowId || `HLP-WB-ROW-${createId()}`, cells: Array.from({ length: columnCount }, (_, columnIndex) => cellText(matrix[offset + 1]?.[columnIndex])) }))
return { columns, rows }
}

View file

@ -0,0 +1,26 @@
.channel-workbench-world { background: radial-gradient(circle at 18% 10%, color-mix(in srgb, var(--primitive-warm-glow) 8%, transparent), transparent 33%), color-mix(in srgb, var(--surface-depth) 98%, transparent); }
.channel-workbench-layout { min-width: 0; min-height: 0; display: grid; grid-template-rows: auto 44px minmax(0, 1fr); margin: 14px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--panel-edge) 78%, transparent); border-radius: 17px; background: color-mix(in srgb, var(--panel-bg) 78%, transparent); box-shadow: 0 26px 74px rgba(0,0,0,.24); }
.channel-workbench-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 18px 22px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); }
.channel-workbench-header > div:first-child { min-width: 0; display: grid; gap: 4px; }
.channel-workbench-header span { color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .16em; }
.channel-workbench-header h1 { margin: 0; color: var(--content-primary); font-size: 23px; }
.channel-workbench-header p { margin: 0; color: var(--content-muted); font-size: 10.5px; }
.channel-workbench-state { display: grid; grid-template-columns: 10px auto; align-items: center; gap: 2px 8px; color: var(--content-secondary); text-align: right; white-space: nowrap; }
.channel-workbench-state i { grid-row: 1 / 3; width: 8px; height: 8px; border-radius: 50%; background: var(--accent-light); box-shadow: 0 0 14px var(--accent-light); }
.channel-workbench-state b { font-size: 11px; }.channel-workbench-state small { color: var(--content-muted); font-size: 9px; }
.channel-workbench-tabs { display: flex; align-items: center; gap: 4px; padding: 5px 12px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 58%, transparent); background: color-mix(in srgb, var(--primitive-glass) 42%, transparent); }
.channel-workbench-tabs button { height: 32px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; color: var(--content-muted); background: transparent; font-size: 11px; font-weight: 700; cursor: pointer; }
.channel-workbench-tabs button.active { color: var(--content-primary); border-color: color-mix(in srgb, var(--accent-light) 28%, transparent); background: color-mix(in srgb, var(--primitive-cool-glow) 11%, transparent); }
.channel-workbench-tabs span { margin-left: auto; color: var(--content-faint); font-size: 9px; }
.channel-workbench-document,.channel-workbench-sheet { min-width: 0; min-height: 0; display: grid; grid-template-rows: 48px minmax(0,1fr); }
.channel-workbench-document > header,.channel-workbench-sheet > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 7px 14px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 58%, transparent); }
.channel-workbench-document > header input,.channel-workbench-sheet > header input { min-width: 0; flex: 1; border: 0; outline: 0; color: var(--content-primary); background: transparent; font-size: 16px; font-weight: 750; }
.channel-workbench-document > header div,.channel-workbench-sheet > header div { display: flex; align-items: center; gap: 11px; }.channel-workbench-document > header small,.channel-workbench-sheet > header small { color: var(--content-muted); font-size: 9px; }
.channel-workbench-document > header button,.channel-workbench-sheet > header button { padding: 8px 12px; border: 1px solid color-mix(in srgb, var(--accent-light) 40%, transparent); border-radius: 8px; color: var(--content-primary); background: color-mix(in srgb, var(--primitive-warm-glow) 12%, transparent); font-size: 10.5px; font-weight: 730; cursor: pointer; }.channel-workbench-document > header button:disabled,.channel-workbench-sheet > header button:disabled { opacity: .45; cursor: default; }
.channel-document-toolbar { display: flex; align-items: center; gap: 5px; min-height: 43px; padding: 6px 16px; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 60%, transparent); background: color-mix(in srgb, var(--primitive-glass) 48%, transparent); }
.channel-document-toolbar > span { margin-right: 8px; color: var(--accent-light); font-size: 9px; font-weight: 800; letter-spacing: .12em; }.channel-document-toolbar > i { flex: 1; }.channel-document-toolbar button { min-width: 42px; padding: 6px 9px; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 7px; color: var(--content-secondary); background: color-mix(in srgb, var(--panel-bg) 48%, transparent); font-size: 10.5px; font-weight: 650; cursor: pointer; }
.channel-document-canvas { position: relative; min-height: 0; overflow: auto; background: color-mix(in srgb, var(--surface-depth) 58%, transparent); }.channel-document-editor { width: min(900px, calc(100% - 52px)); min-height: 100%; margin: 0 auto; padding: 42px clamp(34px,6vw,76px) 80px; border-right: 1px solid color-mix(in srgb,var(--panel-edge) 50%,transparent); border-left: 1px solid color-mix(in srgb,var(--panel-edge) 50%,transparent); outline: 0; color: var(--content-primary); background: color-mix(in srgb,var(--panel-bg) 72%,transparent); font: 500 15px/1.9 -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif; }.channel-document-placeholder { position: absolute; top: 43px; left: max(calc((100% - 900px)/2 + clamp(34px,6vw,76px)),calc(26px + clamp(34px,6vw,76px))); color: var(--content-faint); font-size: 14px; pointer-events: none; }
.channel-document-paragraph { margin: 0 0 12px; }.channel-document-heading { margin: 24px 0 10px; font-size: 22px; line-height: 1.4; }.channel-document-quote { margin: 15px 0; padding: 8px 14px; border-left: 3px solid var(--accent-light); color: var(--content-secondary); }.channel-document-list { margin: 8px 0 14px; padding-left: 26px; }.channel-document-checklist { padding-left: 7px; list-style: none; }.channel-document-bold { font-weight: 780; }.channel-document-italic { font-style: italic; }
.channel-spreadsheet-engine { min-width: 0; min-height: 0; display: grid; grid-template-rows: 42px minmax(0,1fr); overflow: hidden; background: #f6f7f9; }.channel-spreadsheet-engine > header { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 0 14px; border-bottom: 1px solid #d9dde4; color: #202632; background: #f8f9fb; }.channel-spreadsheet-engine > header div { min-width: 0; display: flex; align-items: baseline; gap: 10px; }.channel-spreadsheet-engine > header b { font-size: 12.5px; }.channel-spreadsheet-engine > header span,.channel-spreadsheet-engine > header em { overflow: hidden; color: #667085; text-overflow: ellipsis; white-space: nowrap; font-size: 9.5px; font-style: normal; }.channel-spreadsheet-canvas { min-width: 0; min-height: 0; overflow: hidden; color: #202632; background: #fff; }.channel-spreadsheet-canvas .fortune-container { font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC',sans-serif; }
.channel-workbench-message { position: absolute; right: 28px; bottom: 24px; z-index: 10; max-width: 440px; margin: 0; padding: 9px 13px; border: 1px solid color-mix(in srgb,var(--accent-light) 34%,transparent); border-radius: 9px; color: var(--content-primary); background: color-mix(in srgb,var(--panel-bg) 95%,transparent); box-shadow: 0 12px 28px rgba(0,0,0,.3); font-size: 10.5px; }
@media (max-width: 760px) { .channel-workbench-header { align-items: start; flex-direction: column; }.channel-workbench-state { text-align: left; }.channel-document-toolbar button:nth-of-type(4),.channel-document-toolbar button:nth-of-type(5),.channel-document-toolbar button:nth-of-type(6) { display:none; } }

View file

@ -584,6 +584,30 @@ const ROUTES = {
"moduleNumber": "HLP-NIPC-MOD-0021",
"operationNumber": "HLP-NIPC-OP-0073",
"targetNumber": "HLP-NIPC-TGT-0021"
},
"get_channel_workbench_snapshot": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0022",
"operationNumber": "HLP-NIPC-OP-0074",
"targetNumber": "HLP-NIPC-TGT-0022"
},
"save_channel_document": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0022",
"operationNumber": "HLP-NIPC-OP-0075",
"targetNumber": "HLP-NIPC-TGT-0022"
},
"save_channel_spreadsheet": {
"protocolVersion": "HLP-NIPC-v1",
"callerNumber": "HLP-NIPC-CALLER-MAIN-WEBVIEW-0001",
"channelNumber": "HLP-NIPC-CH-0002",
"moduleNumber": "HLP-NIPC-MOD-0022",
"operationNumber": "HLP-NIPC-OP-0076",
"targetNumber": "HLP-NIPC-TGT-0022"
}
} as const