chore: snapshot audited HoloLake convergence baseline
This commit is contained in:
parent
7a14c06e41
commit
1b6b17b5ab
47 changed files with 10722 additions and 87 deletions
|
|
@ -0,0 +1,97 @@
|
|||
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'
|
||||
|
||||
// Keep the built-in engine deliberately small and only register Markdown
|
||||
// transformers whose node types are present below. Lexical's full TRANSFORMERS
|
||||
// set also requires code/link nodes; mounting it without those dependencies
|
||||
// throws during editor startup and used to blank the whole document screen.
|
||||
const CHANNEL_DOCUMENT_TRANSFORMERS: Transformer[] = [
|
||||
HEADING,
|
||||
QUOTE,
|
||||
UNORDERED_LIST,
|
||||
ORDERED_LIST,
|
||||
CHECK_LIST,
|
||||
BOLD_ITALIC_STAR,
|
||||
BOLD_STAR,
|
||||
ITALIC_STAR,
|
||||
]
|
||||
|
||||
function ChannelDocumentToolbar() {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const setBlock = (kind: 'paragraph' | 'heading') => editor.update(() => {
|
||||
const selection = $getSelection()
|
||||
if (kind === 'heading') $setBlocksType(selection, () => $createHeadingNode('h2'))
|
||||
else $setBlocksType(selection, () => $createParagraphNode())
|
||||
})
|
||||
return <div className="education-engine-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 interface ChannelDocumentEngineProps {
|
||||
body: string
|
||||
onChange: (body: string) => void
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export default function ChannelDocumentEngine({ body, onChange, placeholder = '从这里开始记录……' }: ChannelDocumentEngineProps) {
|
||||
const initialConfig = useMemo(() => ({
|
||||
namespace: 'HoloLakeChannelDocumentEngine',
|
||||
nodes: [HeadingNode, QuoteNode, ListNode, ListItemNode],
|
||||
theme: {
|
||||
paragraph: 'education-engine-paragraph',
|
||||
heading: { h2: 'education-engine-heading-two' },
|
||||
quote: 'education-engine-quote',
|
||||
list: { ul: 'education-engine-list', checklist: 'education-engine-checklist' },
|
||||
text: { bold: 'education-engine-bold', italic: 'education-engine-italic' },
|
||||
},
|
||||
onError: (error: Error) => { throw error },
|
||||
editorState: () => $convertFromMarkdownString(body, CHANNEL_DOCUMENT_TRANSFORMERS),
|
||||
}), [])
|
||||
return <LexicalComposer initialConfig={initialConfig}>
|
||||
<ChannelDocumentToolbar/>
|
||||
<div className="education-engine-canvas">
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable className="education-engine-editor" aria-label="频道文档正文"/>}
|
||||
placeholder={<div className="education-engine-placeholder">{placeholder}</div>}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin/>
|
||||
<MarkdownShortcutPlugin transformers={CHANNEL_DOCUMENT_TRANSFORMERS}/>
|
||||
<OnChangePlugin ignoreSelectionChange onChange={(editorState) => editorState.read(() => onChange($convertToMarkdownString(CHANNEL_DOCUMENT_TRANSFORMERS)))}/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export const channelWorkbenchManifest = {
|
||||
schema: 'hololake.channel-module/v1',
|
||||
moduleId: 'hololake.builtin.channel-workbench',
|
||||
name: '频道轻量工作台',
|
||||
version: '0.2.0',
|
||||
slot: 'channel-workbench',
|
||||
origin: 'resident',
|
||||
state: 'BUILT_IN_FOUNDATION',
|
||||
marketplaceRegistration: 'NOT_A_MARKETPLACE_MODULE',
|
||||
engines: {
|
||||
document: 'LEXICAL_0_49',
|
||||
spreadsheet: 'FORTUNE_SHEET_1_0_4',
|
||||
},
|
||||
exports: ['ChannelDocumentEngine', 'ChannelSpreadsheetEngine'],
|
||||
consumerRule: 'INDUSTRY_MODULES_CONSUME_FOUNDATION_WITHOUT_DUPLICATING_ENGINES',
|
||||
authority: 'CURRENT_CHANNEL_LOCAL_DATA_ONLY',
|
||||
} as const
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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'
|
||||
|
||||
interface ChannelSpreadsheetEngineProps extends ChannelSpreadsheetValue {
|
||||
tableId: string
|
||||
title: string
|
||||
onChange: (value: ChannelSpreadsheetValue) => void
|
||||
}
|
||||
|
||||
export default function ChannelSpreadsheetEngine({ tableId, title, columns, rows, onChange }: ChannelSpreadsheetEngineProps) {
|
||||
const workbookRef = useRef<WorkbookInstance>(null)
|
||||
const [initialData] = useState(() => createInitialWorkbook(title, tableId, { columns, rows }))
|
||||
const [latest, setLatest] = useState<ChannelSpreadsheetValue>({ columns, rows })
|
||||
const latestRef = useRef<ChannelSpreadsheetValue>({ columns, rows })
|
||||
const publish = useCallback((data: FortuneSheet[]) => {
|
||||
const next = readWorkbookValue(data[0], latestRef.current)
|
||||
latestRef.current = next
|
||||
setLatest(next)
|
||||
onChange(next)
|
||||
}, [onChange])
|
||||
|
||||
useEffect(() => {
|
||||
// FortuneSheet preserves formula expressions but does not automatically
|
||||
// execute its persisted calc chain when a workbook is mounted. Use its
|
||||
// public calculation API after the instance is ready so reopening a local
|
||||
// channel workbook restores rendered results as well as expressions.
|
||||
// The workbook builds its cell matrix in an effect of its own. Waiting for
|
||||
// that initialization avoids an upstream race where the public API sees an
|
||||
// empty matrix and returns without calculating anything.
|
||||
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
|
||||
// Persisted native rows store the formula expression rather than a
|
||||
// stale rendered result. Recalculate on mount so saved workbooks open
|
||||
// with both the expression and its current value restored.
|
||||
forceCalculation
|
||||
row={Math.max(40, latest.rows.length + 10)}
|
||||
column={Math.max(12, latest.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>
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { createInitialWorkbook, readWorkbookValue, type ChannelSpreadsheetValue, type FortuneCell } from './spreadsheet-model'
|
||||
|
||||
const native: ChannelSpreadsheetValue = {
|
||||
columns: [
|
||||
{ columnId: 'COL-NAME', title: '项目' },
|
||||
{ columnId: 'COL-AMOUNT', title: '金额' },
|
||||
],
|
||||
rows: [
|
||||
{ rowId: 'ROW-1', cells: ['第一项', '100'] },
|
||||
{ rowId: 'ROW-2', cells: ['合计', '=B2*2'] },
|
||||
],
|
||||
}
|
||||
|
||||
describe('channel spreadsheet model', () => {
|
||||
it('boots native channel rows into a real workbook without losing formulas', () => {
|
||||
const [sheet] = createInitialWorkbook('验收工作表', 'TABLE-1', native)
|
||||
expect(sheet.celldata).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ r: 1, c: 1, v: expect.objectContaining({ v: 100 }) }),
|
||||
expect.objectContaining({ r: 2, c: 1, v: expect.objectContaining({ f: '=B2*2' }) }),
|
||||
]))
|
||||
expect(sheet.row).toBeGreaterThanOrEqual(40)
|
||||
expect(sheet.column).toBeGreaterThanOrEqual(12)
|
||||
expect(sheet.calcChain).toEqual([{ r: 2, c: 1, id: 'TABLE-1' }])
|
||||
expect(sheet.luckysheet_select_save).toEqual([
|
||||
{ row: [0, 0], column: [0, 0], row_focus: 0, column_focus: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
it('round-trips edited workbook cells while retaining stable native ids', () => {
|
||||
const value = readWorkbookValue({
|
||||
name: '验收工作表',
|
||||
data: [
|
||||
[{ v: '项目' }, { v: '金额' }, { v: '备注' }],
|
||||
[{ v: '第一项' }, { v: 100 }, { v: '已核验' }],
|
||||
[{ v: '合计' }, { f: '=B2*2', v: 200 }, null],
|
||||
],
|
||||
}, native, () => 'NEW-ID')
|
||||
expect(value.columns.map((column) => column.columnId)).toEqual(['COL-NAME', 'COL-AMOUNT', 'COL-NEW-ID'])
|
||||
expect(value.rows[0].rowId).toBe('ROW-1')
|
||||
expect(value.rows[1].cells[1]).toBe('=B2*2')
|
||||
expect(value.rows[0].cells[2]).toBe('已核验')
|
||||
})
|
||||
|
||||
it('trims the engine canvas to meaningful rows instead of saving its empty viewport', () => {
|
||||
const matrix: Array<Array<FortuneCell | null>> = Array.from({ length: 40 }, () => Array.from({ length: 12 }, () => null))
|
||||
matrix[0][0] = { v: '字段' }
|
||||
matrix[1][0] = { v: '内容' }
|
||||
const value = readWorkbookValue({ name: '空白工作表', data: matrix }, { columns: [], rows: [] }, () => 'ID')
|
||||
expect(value.columns).toHaveLength(1)
|
||||
expect(value.rows).toHaveLength(1)
|
||||
expect(value.rows[0].cells).toEqual(['内容'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
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,
|
||||
// FortuneSheet requires formula cells in calcChain during initialization;
|
||||
// without it, the expression survives persistence but is not recalculated.
|
||||
calcChain,
|
||||
// FortuneSheet 1.0.4 creates `[0]` instead of `[0, 0]` for its first
|
||||
// automatic selection. Seed a complete A1 range for the location box,
|
||||
// formula editor and keyboard navigation.
|
||||
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
|
||||
const value = cell.m ?? cell.v ?? ''
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function readWorkbookValue(
|
||||
sheet: FortuneSheet | undefined,
|
||||
previous: ChannelSpreadsheetValue,
|
||||
createId: () => string = () => crypto.randomUUID(),
|
||||
): 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 || `COL-${createId()}`,
|
||||
title: cellText(matrix[0]?.[index]).trim() || previous.columns[index]?.title || `字段 ${index + 1}`,
|
||||
}))
|
||||
const rows = Array.from({ length: Math.min(5000, lastRow) }, (_, offset) => {
|
||||
const rowIndex = offset + 1
|
||||
return {
|
||||
rowId: previous.rows[offset]?.rowId || `ROW-${createId()}`,
|
||||
cells: Array.from({ length: columnCount }, (_, columnIndex) => cellText(matrix[rowIndex]?.[columnIndex])),
|
||||
}
|
||||
})
|
||||
return { columns, rows }
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const EDUCATION_EDITABLE_CELL_BUDGET = 240
|
||||
|
||||
export type EducationAdaptiveView = 'grid' | 'cards' | 'board' | 'dashboard'
|
||||
|
||||
export interface EducationAdaptiveInputs {
|
||||
rowCount: number
|
||||
columnCount: number
|
||||
numericColumnCount: number
|
||||
groupableColumnCount: number
|
||||
focusConfidence: 'high' | 'medium' | 'low'
|
||||
}
|
||||
|
||||
export interface EducationModelRenderProposal {
|
||||
primaryFocus: string
|
||||
groupColumnIndex?: number
|
||||
measureColumnIndex?: number
|
||||
views: EducationAdaptiveView[]
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export interface EducationAdaptiveRenderPlan {
|
||||
source: 'LOCAL_BASELINE' | 'MODEL_PROPOSAL'
|
||||
state: 'READY' | 'DEGRADED' | 'WAITING_HUMAN_CONFIRMATION' | 'MODEL_PROPOSAL_REJECTED'
|
||||
views: EducationAdaptiveView[]
|
||||
pageSize: number
|
||||
primaryFocus: string
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export function educationEditablePageSize(columnCount: number): number {
|
||||
const boundedColumns = Math.max(1, Math.min(30, columnCount))
|
||||
return Math.max(5, Math.min(20, Math.floor(EDUCATION_EDITABLE_CELL_BUDGET / boundedColumns)))
|
||||
}
|
||||
|
||||
export function buildEducationBaselineRenderPlan(inputs: EducationAdaptiveInputs): EducationAdaptiveRenderPlan {
|
||||
const reasons: string[] = []
|
||||
const views: EducationAdaptiveView[] = ['grid', 'cards']
|
||||
if (inputs.groupableColumnCount > 0) views.push('board')
|
||||
else reasons.push('没有可靠分类字段,分类看板已降级停用。')
|
||||
if (inputs.numericColumnCount > 0) views.push('dashboard')
|
||||
else reasons.push('没有可靠数值字段,数据概览降级为计数摘要。')
|
||||
if (inputs.focusConfidence === 'low') reasons.push('重点置信度不足,保留人工选择,不自动突出字段。')
|
||||
if (inputs.rowCount * inputs.columnCount > EDUCATION_EDITABLE_CELL_BUDGET) reasons.push('可编辑单元格超过本机安全预算,已启用动态分页。')
|
||||
return {
|
||||
source: 'LOCAL_BASELINE',
|
||||
state: reasons.length ? 'DEGRADED' : 'READY',
|
||||
views,
|
||||
pageSize: educationEditablePageSize(inputs.columnCount),
|
||||
primaryFocus: inputs.focusConfidence === 'low' ? '重点待选择' : '本地基础算法重点',
|
||||
reasons,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateEducationModelRenderProposal(
|
||||
inputs: EducationAdaptiveInputs,
|
||||
proposal: EducationModelRenderProposal,
|
||||
humanConfirmed: boolean,
|
||||
): EducationAdaptiveRenderPlan {
|
||||
const baseline = buildEducationBaselineRenderPlan(inputs)
|
||||
const reasons: string[] = []
|
||||
const uniqueViews = [...new Set(proposal.views)]
|
||||
if (!proposal.primaryFocus.trim() || proposal.primaryFocus.length > 120) reasons.push('模型建议的重点标题无效。')
|
||||
if (!uniqueViews.length || uniqueViews.some((view) => !['grid', 'cards', 'board', 'dashboard'].includes(view))) reasons.push('模型建议包含未登记视图。')
|
||||
if (proposal.groupColumnIndex !== undefined && (proposal.groupColumnIndex < 0 || proposal.groupColumnIndex >= inputs.columnCount)) reasons.push('模型建议的分类字段不存在。')
|
||||
if (proposal.measureColumnIndex !== undefined && (proposal.measureColumnIndex < 0 || proposal.measureColumnIndex >= inputs.columnCount)) reasons.push('模型建议的统计字段不存在。')
|
||||
const requestedPageSize = proposal.pageSize ?? baseline.pageSize
|
||||
const maximumSafePageSize = educationEditablePageSize(inputs.columnCount)
|
||||
if (!Number.isInteger(requestedPageSize) || requestedPageSize < 5 || requestedPageSize > maximumSafePageSize) reasons.push('模型建议的页面密度超过本机安全预算。')
|
||||
if (reasons.length) return { ...baseline, state: 'MODEL_PROPOSAL_REJECTED', reasons: [...reasons, '已优雅退回本地基础算法。'] }
|
||||
if (!humanConfirmed) return { ...baseline, state: 'WAITING_HUMAN_CONFIRMATION', reasons: ['模型建议已通过本地校验,等待用户确认后启用。'] }
|
||||
return {
|
||||
source: 'MODEL_PROPOSAL',
|
||||
state: 'READY',
|
||||
views: uniqueViews,
|
||||
pageSize: requestedPageSize,
|
||||
primaryFocus: proposal.primaryFocus.trim(),
|
||||
reasons: ['模型建议已通过本地结构校验、资源上限与用户确认。'],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { analyzeEducationTableData } from './education-data'
|
||||
|
||||
describe('education data cleanup', () => {
|
||||
it('trims cells, removes only fully empty rows and keeps the first exact row', () => {
|
||||
const result = analyzeEducationTableData({
|
||||
columns: [
|
||||
{ columnId: 'name', title: '姓名' },
|
||||
{ columnId: 'course', title: '课程' },
|
||||
],
|
||||
rows: [
|
||||
{ rowId: 'row-1', cells: [' 冰朔 ', ' 写作 '] },
|
||||
{ rowId: 'row-2', cells: ['冰朔', '写作'] },
|
||||
{ rowId: 'row-3', cells: [' ', ''] },
|
||||
{ rowId: 'row-4', cells: ['另一位学员', ''] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.originalRows).toBe(4)
|
||||
expect(result.emptyRows).toBe(1)
|
||||
expect(result.duplicateRows).toBe(1)
|
||||
expect(result.trimmedCells).toBe(3)
|
||||
expect(result.changeCount).toBe(5)
|
||||
expect(result.changePreview).toHaveLength(5)
|
||||
expect(result.cleanedRows).toEqual([
|
||||
{ rowId: 'row-1', cells: ['冰朔', '写作'] },
|
||||
{ rowId: 'row-4', cells: ['另一位学员', ''] },
|
||||
])
|
||||
})
|
||||
|
||||
it('does not merge rows that differ in any field', () => {
|
||||
const result = analyzeEducationTableData({
|
||||
columns: [{ columnId: 'status', title: '状态' }],
|
||||
rows: [
|
||||
{ rowId: 'row-1', cells: ['已完成'] },
|
||||
{ rowId: 'row-2', cells: ['进行中'] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.duplicateRows).toBe(0)
|
||||
expect(result.cleanedRows).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('analyzes the entire table while keeping the human preview bounded', () => {
|
||||
const result = analyzeEducationTableData({
|
||||
columns: Array.from({ length: 23 }, (_, index) => ({ columnId: `column-${index}`, title: `字段 ${index + 1}` })),
|
||||
rows: Array.from({ length: 55 }, (_, rowIndex) => ({
|
||||
rowId: `row-${rowIndex}`,
|
||||
cells: Array.from({ length: 23 }, (_, columnIndex) => ` value-${rowIndex}-${columnIndex} `),
|
||||
})),
|
||||
})
|
||||
|
||||
expect(result.originalRows).toBe(55)
|
||||
expect(result.cleanedRows).toHaveLength(55)
|
||||
expect(result.trimmedCells).toBe(55 * 23)
|
||||
expect(result.changeCount).toBe(55 * 23)
|
||||
expect(result.changePreview).toHaveLength(12)
|
||||
expect(result.changePreview.every((change) => change.before.length <= 120 && change.after.length <= 120)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
export interface EducationDataColumn { columnId: string; title: string }
|
||||
export interface EducationDataRow { rowId: string; cells: string[] }
|
||||
export interface EducationDataTable { columns: EducationDataColumn[]; rows: EducationDataRow[] }
|
||||
|
||||
export const EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT = 12
|
||||
|
||||
export type EducationDataChangeKind = 'TRIM_CELL' | 'REMOVE_EMPTY_ROW' | 'REMOVE_DUPLICATE_ROW'
|
||||
|
||||
export interface EducationDataChangePreview {
|
||||
kind: EducationDataChangeKind
|
||||
rowNumber: number
|
||||
columnTitle: string
|
||||
before: string
|
||||
after: string
|
||||
}
|
||||
|
||||
export interface EducationDataAnalysis {
|
||||
originalRows: number
|
||||
cleanedRows: EducationDataRow[]
|
||||
emptyRows: number
|
||||
duplicateRows: number
|
||||
trimmedCells: number
|
||||
changeCount: number
|
||||
changePreview: EducationDataChangePreview[]
|
||||
}
|
||||
|
||||
function previewCell(value: string): string {
|
||||
const singleLine = value.replace(/\t/g, '⇥').replace(/\r?\n/g, '↵').replace(/ /g, '·')
|
||||
return singleLine.length > 120 ? `${singleLine.slice(0, 117)}…` : singleLine
|
||||
}
|
||||
|
||||
export function analyzeEducationTableData(table: EducationDataTable): EducationDataAnalysis {
|
||||
let emptyRows = 0
|
||||
let duplicateRows = 0
|
||||
let trimmedCells = 0
|
||||
let changeCount = 0
|
||||
const seen = new Set<string>()
|
||||
const cleanedRows: EducationDataRow[] = []
|
||||
const changePreview: EducationDataChangePreview[] = []
|
||||
const addPreview = (change: EducationDataChangePreview) => {
|
||||
changeCount += 1
|
||||
if (changePreview.length < EDUCATION_CLEANUP_CHANGE_PREVIEW_LIMIT) changePreview.push(change)
|
||||
}
|
||||
for (const [rowIndex, row] of table.rows.entries()) {
|
||||
const cells = table.columns.map((_, index) => {
|
||||
const original = row.cells[index] || ''
|
||||
const cleaned = original.trim()
|
||||
if (cleaned !== original) {
|
||||
trimmedCells += 1
|
||||
addPreview({
|
||||
kind: 'TRIM_CELL',
|
||||
rowNumber: rowIndex + 1,
|
||||
columnTitle: table.columns[index]?.title || `字段 ${index + 1}`,
|
||||
before: previewCell(original),
|
||||
after: previewCell(cleaned),
|
||||
})
|
||||
}
|
||||
return cleaned
|
||||
})
|
||||
if (cells.every((cell) => !cell)) {
|
||||
emptyRows += 1
|
||||
addPreview({ kind: 'REMOVE_EMPTY_ROW', rowNumber: rowIndex + 1, columnTitle: '整行', before: '完整空行', after: '移除' })
|
||||
continue
|
||||
}
|
||||
const signature = JSON.stringify(cells)
|
||||
if (seen.has(signature)) {
|
||||
duplicateRows += 1
|
||||
addPreview({ kind: 'REMOVE_DUPLICATE_ROW', rowNumber: rowIndex + 1, columnTitle: '整行', before: '与前文完全重复', after: '移除' })
|
||||
continue
|
||||
}
|
||||
seen.add(signature)
|
||||
cleanedRows.push({ ...row, cells })
|
||||
}
|
||||
return { originalRows: table.rows.length, cleanedRows, emptyRows, duplicateRows, trimmedCells, changeCount, changePreview }
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import ChannelDocumentEngine from './channel-workbench/document-engine'
|
||||
|
||||
export default function EducationDocumentEngine({ body, onChange }: { body: string; onChange: (body: string) => void }) {
|
||||
return <ChannelDocumentEngine body={body} onChange={onChange} placeholder="从这里开始记录教学计划、会议纪要或课程资料……"/>
|
||||
}
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import type { CSSProperties } from 'react'
|
||||
export { nativeCompositionManifest } from './manifest'
|
||||
|
||||
export type CompositionDimension = 'SOURCE' | 'TOP_LEVEL_FOLDER'
|
||||
export type CompositionMeasure = 'DOCUMENT_COUNT' | 'TOTAL_BYTES' | 'DUPLICATE_COUNT'
|
||||
export type ProjectionView = 'DASHBOARD' | 'COMPARISON' | 'VERTICAL_BAR' | 'CLASSIFICATION' | 'TABLE'
|
||||
|
||||
export interface NativeCompositionRow {
|
||||
rowId: string
|
||||
source: string
|
||||
path: string
|
||||
title: string
|
||||
topLevelFolder: string
|
||||
sizeBytes: number
|
||||
duplicateCount: number
|
||||
updatedAtUnixMs: number
|
||||
}
|
||||
|
||||
export interface NativeCompositionGroup {
|
||||
key: string
|
||||
label: string
|
||||
documentCount: number
|
||||
totalBytes: number
|
||||
duplicateCount: number
|
||||
measureValue: number
|
||||
share: number
|
||||
}
|
||||
|
||||
export interface NativeCompositionProjection {
|
||||
schema: string
|
||||
state: string
|
||||
executionId: string
|
||||
executedAtUnixMs: number
|
||||
sourceIsRealAccountData: boolean
|
||||
readOnly: boolean
|
||||
dimension: CompositionDimension
|
||||
measure: CompositionMeasure
|
||||
views: ProjectionView[]
|
||||
nativeObject: {
|
||||
schema: string
|
||||
objectId: string
|
||||
title: string
|
||||
rowCount: number
|
||||
truncated: boolean
|
||||
sourceReceipt: string
|
||||
rows: NativeCompositionRow[]
|
||||
}
|
||||
groups: NativeCompositionGroup[]
|
||||
metrics: {
|
||||
rawDocumentCount: number
|
||||
uniqueDocumentCount: number
|
||||
duplicateDocumentCount: number
|
||||
totalBytes: number
|
||||
groupCount: number
|
||||
}
|
||||
recipe: {
|
||||
schema: string
|
||||
recipeId: string
|
||||
title: string
|
||||
nodes: { nodeId: string; moduleId: string }[]
|
||||
edges: { from: string; to: string }[]
|
||||
}
|
||||
dataSha256: string
|
||||
receiptSha256: string
|
||||
}
|
||||
|
||||
const viewNames: Record<ProjectionView, string> = {
|
||||
DASHBOARD: '仪表盘',
|
||||
COMPARISON: '对比',
|
||||
VERTICAL_BAR: '柱状图',
|
||||
CLASSIFICATION: '分类',
|
||||
TABLE: '明细表',
|
||||
}
|
||||
|
||||
const measureNames: Record<CompositionMeasure, string> = {
|
||||
DOCUMENT_COUNT: '文档数量',
|
||||
TOTAL_BYTES: '数据量',
|
||||
DUPLICATE_COUNT: '重复数量',
|
||||
}
|
||||
|
||||
export function formatCompositionValue(value: number, measure: CompositionMeasure): string {
|
||||
if (measure !== 'TOTAL_BYTES') return `${value.toLocaleString('zh-CN')} 篇`
|
||||
if (value < 1024) return `${value.toLocaleString('zh-CN')} B`
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value < 10 * 1024 ? 1 : 0)} KB`
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function toggleProjectionView(current: ProjectionView[], view: ProjectionView): ProjectionView[] {
|
||||
if (current.includes(view)) return current.length === 1 ? current : current.filter((item) => item !== view)
|
||||
return [...current, view]
|
||||
}
|
||||
|
||||
interface NativeCompositionStudioProps {
|
||||
projection: NativeCompositionProjection | null
|
||||
dimension: CompositionDimension
|
||||
measure: CompositionMeasure
|
||||
selectedViews: ProjectionView[]
|
||||
busy: boolean
|
||||
message: string
|
||||
onDimensionChange: (value: CompositionDimension) => void
|
||||
onMeasureChange: (value: CompositionMeasure) => void
|
||||
onViewsChange: (value: ProjectionView[]) => void
|
||||
onExecute: () => void
|
||||
}
|
||||
|
||||
export function NativeCompositionStudio(props: NativeCompositionStudioProps) {
|
||||
const { projection, dimension, measure, selectedViews, busy, message } = props
|
||||
const groups = projection?.groups || []
|
||||
const activeDimension = projection?.dimension || dimension
|
||||
const activeMeasure = projection?.measure || measure
|
||||
const activeViews = projection?.views || selectedViews
|
||||
const maximum = Math.max(1, ...groups.map((group) => group.measureValue))
|
||||
return <div className="native-composition-layout">
|
||||
<aside className="composition-recipe-panel">
|
||||
<header><span>原生组合配方</span><h2>知识结构观察</h2><p>同一份数据 · 多种动态投影</p></header>
|
||||
<section className="composition-source"><i/><div><span>真实数据源</span><b>当前账号知识目录</b><small>{projection ? `${projection.nativeObject.rowCount} 条原生对象` : '等待系统读取'}</small></div></section>
|
||||
<div className="composition-connector" aria-hidden="true"/>
|
||||
<label><span>分类维度</span><select aria-label="组合分类维度" value={dimension} onChange={(event) => props.onDimensionChange(event.target.value as CompositionDimension)}><option value="TOP_LEVEL_FOLDER">一级目录</option><option value="SOURCE">知识来源</option></select></label>
|
||||
<label><span>统计指标</span><select aria-label="组合统计指标" value={measure} onChange={(event) => props.onMeasureChange(event.target.value as CompositionMeasure)}><option value="DOCUMENT_COUNT">文档数量</option><option value="TOTAL_BYTES">数据量</option><option value="DUPLICATE_COUNT">重复数量</option></select></label>
|
||||
<fieldset><legend>人类投影</legend>{(Object.keys(viewNames) as ProjectionView[]).map((view) => <label key={view}><input type="checkbox" checked={selectedViews.includes(view)} onChange={() => props.onViewsChange(toggleProjectionView(selectedViews, view))}/><span>{viewNames[view]}</span></label>)}</fieldset>
|
||||
<button className="composition-execute" type="button" disabled={busy} onClick={props.onExecute}>{busy ? '系统正在组合…' : '按当前配方重新组合'}</button>
|
||||
<footer>{projection ? <><b>已由原生内核执行</b><span>回执 {projection.receiptSha256.slice(0, 12)}</span><small>{new Date(projection.executedAtUnixMs).toLocaleString('zh-CN')}</small></> : <span>{message || '组合执行只读,不修改知识原文。'}</span>}</footer>
|
||||
</aside>
|
||||
<main className="composition-projection-stage" aria-busy={busy}>
|
||||
<header><div><span>HUMAN PROJECTION</span><h1>知识空间结构组合</h1><p>{projection ? `按${activeDimension === 'SOURCE' ? '知识来源' : '一级目录'}观察${measureNames[activeMeasure]} · 数据来自当前账号真实知识目录` : '正在准备当前账号的原生知识对象'}</p></div><div className="composition-state"><i/><span>{projection?.sourceIsRealAccountData ? '真实数据已接入' : '等待数据'}</span><small>{projection?.readOnly ? '只读组合' : '未执行'}</small></div></header>
|
||||
{message && <p className="composition-message">{message}</p>}
|
||||
{projection && projection.nativeObject.rowCount === 0 && <section className="composition-empty"><span>空</span><h2>当前知识目录还没有内容</h2><p>这里不会用样例冒充真实数据。先在知识空间导入或建立页面,再重新组合。</p></section>}
|
||||
{projection && projection.nativeObject.rowCount > 0 && <div className="composition-view-grid">
|
||||
{activeViews.includes('DASHBOARD') && <section className="composition-view composition-dashboard"><header><span>仪表盘</span><small>同一执行结果的总览</small></header><div><article><span>唯一知识页</span><strong>{projection.metrics.uniqueDocumentCount.toLocaleString('zh-CN')}</strong><small>篇</small></article><article><span>原始页面</span><strong>{projection.metrics.rawDocumentCount.toLocaleString('zh-CN')}</strong><small>篇</small></article><article><span>知识数据量</span><strong>{formatCompositionValue(projection.metrics.totalBytes, 'TOTAL_BYTES')}</strong><small>原生目录</small></article><article><span>动态分类</span><strong>{projection.metrics.groupCount}</strong><small>组</small></article></div></section>}
|
||||
{activeViews.includes('VERTICAL_BAR') && <section className="composition-view composition-bars"><header><span>柱状图</span><small>{measureNames[activeMeasure]} · 动态比例</small></header><div className="composition-bar-plot">{groups.slice(0, 12).map((group) => <article key={group.key}><div className="composition-bar-track"><i style={{ '--bar-height': `${Math.max(4, group.measureValue / maximum * 100)}%` } as CSSProperties}/><em>{formatCompositionValue(group.measureValue, activeMeasure)}</em></div><b title={group.label}>{group.label}</b></article>)}</div></section>}
|
||||
{activeViews.includes('COMPARISON') && <section className="composition-view composition-comparison"><header><span>对比</span><small>各分类占比</small></header><div>{groups.slice(0, 10).map((group) => <article key={group.key}><div><b>{group.label}</b><span>{formatCompositionValue(group.measureValue, activeMeasure)}</span></div><i><em style={{ '--share': `${group.share * 100}%` } as CSSProperties}/></i><small>{(group.share * 100).toFixed(1)}%</small></article>)}</div></section>}
|
||||
{activeViews.includes('CLASSIFICATION') && <section className="composition-view composition-classification"><header><span>分类</span><small>从原生路径实时生成</small></header><div>{groups.map((group, index) => <article key={group.key}><i>{String(index + 1).padStart(2, '0')}</i><div><b>{group.label}</b><span>{group.documentCount} 篇文档 · {formatCompositionValue(group.totalBytes, 'TOTAL_BYTES')}</span></div><strong>{formatCompositionValue(group.measureValue, activeMeasure)}</strong></article>)}</div></section>}
|
||||
{activeViews.includes('TABLE') && <section className="composition-view composition-table"><header><span>原生明细表</span><small>{projection.nativeObject.schema} · 只读</small></header><div><table><thead><tr><th>标题</th><th>一级分类</th><th>来源</th><th>数据量</th><th>重复</th><th>更新时间</th></tr></thead><tbody>{projection.nativeObject.rows.slice(0, 100).map((row) => <tr key={row.rowId}><td title={row.path}>{row.title}</td><td>{row.topLevelFolder}</td><td>{row.source === 'native' ? '光湖原生' : row.source}</td><td>{formatCompositionValue(row.sizeBytes, 'TOTAL_BYTES')}</td><td>{row.duplicateCount}</td><td>{new Date(row.updatedAtUnixMs).toLocaleDateString('zh-CN')}</td></tr>)}</tbody></table></div></section>}
|
||||
</div>}
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
export const nativeCompositionManifest = {
|
||||
schema: 'hololake.composition-module/v1',
|
||||
moduleId: 'hololake.native-composition-projection',
|
||||
name: '原生组合视图',
|
||||
version: '0.1.0',
|
||||
slot: 'education-composition',
|
||||
origin: 'resident',
|
||||
input: 'hololake.human-projection/v1',
|
||||
exports: ['NativeCompositionStudio', 'formatCompositionValue', 'toggleProjectionView'],
|
||||
authority: 'READ_ONLY_HUMAN_PROJECTION',
|
||||
} as const
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { formatCompositionValue, toggleProjectionView, type ProjectionView } from './index'
|
||||
|
||||
describe('native composition human projection', () => {
|
||||
it('formats one native measure consistently across projections', () => {
|
||||
expect(formatCompositionValue(12, 'DOCUMENT_COUNT')).toBe('12 篇')
|
||||
expect(formatCompositionValue(1536, 'TOTAL_BYTES')).toBe('1.5 KB')
|
||||
expect(formatCompositionValue(2, 'DUPLICATE_COUNT')).toBe('2 篇')
|
||||
})
|
||||
|
||||
it('allows free projection combinations but never an empty projection', () => {
|
||||
const initial: ProjectionView[] = ['DASHBOARD', 'TABLE']
|
||||
expect(toggleProjectionView(initial, 'VERTICAL_BAR')).toEqual(['DASHBOARD', 'TABLE', 'VERTICAL_BAR'])
|
||||
expect(toggleProjectionView(initial, 'TABLE')).toEqual(['DASHBOARD'])
|
||||
expect(toggleProjectionView(['TABLE'], 'TABLE')).toEqual(['TABLE'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue