feat: add remote-first education work lake

This commit is contained in:
冰朔 2026-08-07 20:41:03 +08:00
commit 333cd222c5
43 changed files with 923 additions and 74 deletions

View file

@ -19,11 +19,6 @@ const installLinkTargets = [
['ai-agents-onboarding-install-claude_code', 'https://docs.anthropic.com/en/docs/claude-code'],
['ai-agents-onboarding-install-codex', 'https://developers.openai.com/codex/cli'],
['ai-agents-onboarding-install-copilot', 'https://docs.github.com/en/copilot/how-tos/copilot-cli/set-up-copilot-cli/install-copilot-cli'],
['ai-agents-onboarding-install-opencode', 'https://opencode.ai/docs/'],
['ai-agents-onboarding-install-pi', 'https://pi.dev'],
['ai-agents-onboarding-install-antigravity', 'https://antigravity.google/docs/cli/install'],
['ai-agents-onboarding-install-kiro', 'https://kiro.dev/docs/cli'],
['ai-agents-onboarding-install-hermes', 'https://hermes-agent.nousresearch.com/docs/getting-started/quickstart'],
] as const
vi.mock('../utils/url', () => ({

View file

@ -0,0 +1,30 @@
.education-work-lake { display: grid; gap: 20px; width: min(1160px, 100%); margin: 0 auto; padding-bottom: 48px; }
.education-work-lake__header { display: flex; align-items: end; justify-content: space-between; gap: 28px; padding: 24px 0 8px; }
.education-work-lake__header p { margin: 0 0 8px; color: var(--accent-blue); font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .12em; }
.education-work-lake__header h1 { margin: 0; font-family: Georgia, "Songti SC", serif; font-size: clamp(34px, 5vw, 58px); font-weight: 500; letter-spacing: -.04em; }
.education-work-lake__header span { display: block; max-width: 720px; margin-top: 12px; color: var(--text-secondary); line-height: 1.7; }
.education-work-lake__header aside { min-width: 190px; padding: 14px 16px; border: 1px solid var(--border-default); border-radius: 10px; background: var(--surface-card); }
.education-work-lake__header aside i { display: inline-block; width: 7px; height: 7px; margin-right: 8px; border-radius: 50%; background: var(--accent-green); }
.education-work-lake__header aside strong, .education-work-lake__header aside small { display: block; margin-top: 4px; }
.education-work-lake__header aside small { color: var(--text-secondary); }
.education-command { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 14px; border: 1px solid color-mix(in srgb, var(--accent-blue) 35%, var(--border-default)); border-radius: 12px; background: color-mix(in srgb, var(--surface-card) 92%, var(--accent-blue)); }
.education-command input { min-height: 42px; }
.education-receipt { display: flex; align-items: center; gap: 12px; min-height: 38px; padding: 8px 12px; border: 1px solid color-mix(in srgb, var(--accent-green) 36%, var(--border-default)); border-radius: 8px; color: var(--text-secondary); background: color-mix(in srgb, var(--surface-card) 94%, var(--accent-green)); }
.education-receipt code { color: var(--accent-green); }.education-receipt small { margin-left: auto; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.education-work-lake [data-slot="tabs-list"] { max-width: 100%; overflow-x: auto; }
.education-work-lake [data-slot="tabs-content"] { min-height: 430px; margin-top: 12px; padding: 18px; border: 1px solid var(--border-default); border-radius: 12px; background: var(--surface-card); }
.education-document { min-height: 390px; resize: vertical; font: 16px/1.8 ui-serif, "Songti SC", serif; }
.education-sheet { height: 430px; overflow: hidden; border-radius: 8px; }
.education-chart { display: grid; gap: 18px; padding: 24px 16px; }
.education-chart__row { display: grid; grid-template-columns: 64px minmax(140px, 1fr) 42px; align-items: center; gap: 12px; }
.education-chart__row > div { height: 18px; overflow: hidden; border-radius: 999px; background: color-mix(in srgb, var(--surface-muted) 82%, transparent); }
.education-chart__row i { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, var(--accent-blue), #8b7cf6); }
.education-dashboard { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.education-dashboard > article { padding: 20px; border: 1px solid var(--border-default); border-radius: 10px; background: var(--surface-app); }
.education-dashboard article span, .education-dashboard article small { display: block; color: var(--text-secondary); }
.education-dashboard article strong { display: block; margin: 12px 0 8px; font-size: 30px; }
.education-dashboard__chart { grid-column: 1 / -1; }
.education-knowledge { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.education-knowledge article { padding: 22px; border: 1px solid var(--border-default); border-radius: 10px; background: var(--surface-app); }
.education-knowledge code, .education-knowledge strong { display: block; }.education-knowledge code { color: var(--accent-blue); font-size: 11px; }.education-knowledge strong { margin-top: 16px; font-size: 17px; }.education-knowledge p { color: var(--text-secondary); line-height: 1.7; }
@media (max-width: 760px) { .education-work-lake__header { align-items: stretch; flex-direction: column; }.education-work-lake__header aside { min-width: 0; }.education-command { grid-template-columns: 1fr; }.education-dashboard, .education-knowledge { grid-template-columns: 1fr; }.education-dashboard__chart { grid-column: auto; }.education-receipt { align-items: flex-start; flex-direction: column; }.education-receipt small { margin-left: 0; } }

View file

@ -0,0 +1,45 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { EducationWorkLake } from './EducationWorkLake'
vi.mock('./SheetEditor', () => ({
SheetEditor: () => <div data-testid="education-sheet-editor">sheet</div>,
}))
describe('EducationWorkLake', () => {
beforeEach(() => {
localStorage.clear()
})
it('opens an editable sheet from a language instruction and emits a receipt', () => {
render(<EducationWorkLake locale="zh-CN" />)
fireEvent.change(screen.getByLabelText('告诉人格体你要处理什么'), {
target: { value: '整理一下这个表格里面的数据' },
})
fireEvent.click(screen.getByRole('button', { name: '运行指令' }))
expect(screen.getByTestId('education-sheet-editor')).toBeInTheDocument()
expect(screen.getByText('HL-MOD-SHEET-001')).toBeInTheDocument()
expect(screen.getByText(/已在应用内打开/)).toBeInTheDocument()
})
it('keeps a document draft inside the browser workspace', () => {
const { unmount } = render(<EducationWorkLake locale="zh-CN" />)
fireEvent.change(screen.getByLabelText('告诉人格体你要处理什么'), {
target: { value: '起草一份教研文档' },
})
fireEvent.click(screen.getByRole('button', { name: '运行指令' }))
fireEvent.change(screen.getByLabelText('教研文档内容'), {
target: { value: '八年级物理:光的折射' },
})
unmount()
render(<EducationWorkLake locale="zh-CN" />)
fireEvent.change(screen.getByLabelText('告诉人格体你要处理什么'), {
target: { value: '打开文档' },
})
fireEvent.click(screen.getByRole('button', { name: '运行指令' }))
expect(screen.getByLabelText('教研文档内容')).toHaveValue('八年级物理:光的折射')
})
})

View file

@ -0,0 +1,185 @@
import { useMemo, useState } from 'react'
import type { AppLocale, TranslationKey } from '../lib/i18n'
import { translate } from '../lib/i18n'
import {
educationMetricsFromSheet,
routeEducationInstruction,
type EducationSurface,
} from '../lib/educationWorkLake'
import { trackEvent } from '../lib/telemetry'
import { Button } from './ui/button'
import { Input } from './ui/input'
import { Textarea } from './ui/textarea'
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
import { SheetEditor } from './SheetEditor'
import './EducationWorkLake.css'
const DOCUMENT_STORAGE_KEY = 'hololake:education-work-lake:document'
const SHEET_STORAGE_KEY = 'hololake:education-work-lake:sheet'
const DEFAULT_DOCUMENT = '教研主题:八年级物理学情分析\n\n在这里编辑教案、报告或协作文档。'
const DEFAULT_SHEET = [
'---',
'type: Note',
'_display: sheet',
'---',
'班级,平均分',
'一班,86',
'二班,91',
'三班,79',
'四班,88',
].join('\n')
type EducationReceipt = {
capabilityId: string
id: string
}
function readWorkspaceValue(key: string, fallback: string): string {
try {
return localStorage.getItem(key) ?? fallback
} catch {
return fallback
}
}
function storeWorkspaceValue(key: string, value: string): void {
try {
localStorage.setItem(key, value)
} catch {
// The workspace remains editable in memory when browser storage is unavailable.
}
}
function ComparisonChart({ metrics }: { metrics: ReturnType<typeof educationMetricsFromSheet> }) {
const maximum = Math.max(1, ...metrics.map((metric) => metric.value))
return (
<div className="education-chart" role="img" aria-label="班级平均分对比图">
{metrics.map((metric) => (
<div className="education-chart__row" key={metric.label}>
<span>{metric.label}</span>
<div><i style={{ width: `${Math.max(4, metric.value / maximum * 100)}%` }} /></div>
<strong>{metric.value}</strong>
</div>
))}
</div>
)
}
function Dashboard({ metrics }: { metrics: ReturnType<typeof educationMetricsFromSheet> }) {
const total = metrics.reduce((sum, metric) => sum + metric.value, 0)
const average = metrics.length > 0 ? Math.round(total / metrics.length * 10) / 10 : 0
const highest = metrics.reduce((current, metric) =>
metric.value > current.value ? metric : current, { label: '—', value: 0 })
return (
<div className="education-dashboard">
<article><span></span><strong>{metrics.length}</strong><small></small></article>
<article><span></span><strong>{average}</strong><small></small></article>
<article><span></span><strong>{highest.label}</strong><small>{highest.value} </small></article>
<div className="education-dashboard__chart"><ComparisonChart metrics={metrics} /></div>
</div>
)
}
function KnowledgeSurface() {
return (
<div className="education-knowledge">
<article><code>EDU-KNOW-001</code><strong></strong><p></p></article>
<article><code>EDU-KNOW-002</code><strong></strong><p></p></article>
<article><code>EDU-KNOW-003</code><strong></strong><p></p></article>
</div>
)
}
export function EducationWorkLake({ locale }: { locale: AppLocale }) {
const t = (key: TranslationKey) => translate(locale, key)
const [instruction, setInstruction] = useState('')
const [surface, setSurface] = useState<EducationSurface>('knowledge')
const [receipt, setReceipt] = useState<EducationReceipt | null>(null)
const [document, setDocument] = useState(() => readWorkspaceValue(DOCUMENT_STORAGE_KEY, DEFAULT_DOCUMENT))
const [sheet, setSheet] = useState(() => readWorkspaceValue(SHEET_STORAGE_KEY, DEFAULT_SHEET))
const metrics = useMemo(() => educationMetricsFromSheet(sheet), [sheet])
const runInstruction = () => {
const route = routeEducationInstruction(instruction)
const receiptId = `EDU-LOCAL-${Date.now().toString(36).toUpperCase()}`
setSurface(route.surface)
setReceipt({ capabilityId: route.capabilityId, id: receiptId })
trackEvent('education_work_lake_instruction_routed', {
capabilityId: route.capabilityId,
reason: route.reason,
surface: route.surface,
})
}
const updateDocument = (value: string) => {
setDocument(value)
storeWorkspaceValue(DOCUMENT_STORAGE_KEY, value)
}
const updateSheet = (_path: string, value: string) => {
setSheet(value)
storeWorkspaceValue(SHEET_STORAGE_KEY, value)
}
return (
<section className="education-work-lake">
<header className="education-work-lake__header">
<div>
<p>EDU-WORK-LAKE-001 · REMOTE FIRST</p>
<h1>{t('education.title')}</h1>
<span>{t('education.description')}</span>
</div>
<aside><i /><strong>JD-FD-PRIMARY</strong><small>{t('education.hostingState')}</small></aside>
</header>
<div className="education-command">
<Input
aria-label={t('education.instructionLabel')}
onChange={(event) => setInstruction(event.target.value)}
placeholder={t('education.instructionPlaceholder')}
value={instruction}
/>
<Button onClick={runInstruction}>{t('education.runInstruction')}</Button>
</div>
{receipt && (
<div className="education-receipt" role="status">
<span>{t('education.openedInsideApp')}</span>
<code>{receipt.capabilityId}</code>
<small>{receipt.id}</small>
</div>
)}
<Tabs value={surface} onValueChange={(value) => setSurface(value as EducationSurface)}>
<TabsList aria-label={t('education.surfaceNavigation')}>
<TabsTrigger value="document">{t('education.document')}</TabsTrigger>
<TabsTrigger value="sheet">{t('education.sheet')}</TabsTrigger>
<TabsTrigger value="chart">{t('education.chart')}</TabsTrigger>
<TabsTrigger value="dashboard">{t('education.dashboard')}</TabsTrigger>
<TabsTrigger value="knowledge">{t('education.knowledge')}</TabsTrigger>
</TabsList>
<TabsContent value="document">
<Textarea
aria-label={t('education.documentLabel')}
className="education-document"
onChange={(event) => updateDocument(event.target.value)}
value={document}
/>
</TabsContent>
<TabsContent value="sheet">
<div className="education-sheet">
<SheetEditor
content={sheet}
locale={locale}
onContentChange={updateSheet}
path="education-work-lake/class-comparison.md"
/>
</div>
</TabsContent>
<TabsContent value="chart"><ComparisonChart metrics={metrics} /></TabsContent>
<TabsContent value="dashboard"><Dashboard metrics={metrics} /></TabsContent>
<TabsContent value="knowledge"><KnowledgeSurface /></TabsContent>
</Tabs>
</section>
)
}

View file

@ -22,11 +22,13 @@ import { GUANGHU_THEMES, type GuanghuTheme } from '../lib/guanghuTheme'
import { planGuanghuLivingSystem } from '../utils/planGuanghuLivingSystem'
import { Button } from './ui/button'
import { HOLOLAKE_REPOSITORY_URL } from '../constants/feedback'
import { isEducationWorkLakePath } from '../lib/educationWorkLake'
import { GuanghuRouterConsole } from './GuanghuRouterConsole'
import { GuanghuWorldMap } from './GuanghuWorldMap'
import { GuanghuWorldLoginGate } from './GuanghuWorldLoginGate'
import { FifthDomainSystems } from './FifthDomainSystems'
import { EternalLakeHeartPage } from './EternalLakeHeartPage'
import { EducationWorkLake } from './EducationWorkLake'
import {
Dialog,
DialogContent,
@ -89,14 +91,17 @@ export function HoloLakeHome({
onOpenLocalWorkspace,
livingSystemTarget,
}: HoloLakeHomeProps) {
const [route, setRoute] = useState<ChannelRoute>('world')
const initialRoute: ChannelRoute = isEducationWorkLakePath(window.location.pathname)
? 'education-work-lake'
: 'world'
const [route, setRoute] = useState<ChannelRoute>(initialRoute)
const [livingPlan, setLivingPlan] = useState<GuanghuLivingSystemPlan>(() => (
createDeterministicLivingSystemPlan(createLivingSystemEvent({
currentRoute: 'world',
currentRoute: initialRoute,
eventId: 'system-start',
intent: 'navigate',
receiptIds: [],
requestedRoute: 'world',
requestedRoute: initialRoute,
worldOpen: false,
}))
))
@ -407,6 +412,10 @@ export function HoloLakeHome({
)
}
if (route === 'education-work-lake') {
return <EducationWorkLake locale={locale} />
}
if (route === 'servers') {
return (
<section className="channel-stage channel-stage--node">
@ -453,6 +462,7 @@ export function HoloLakeHome({
<p className="channel-stage__lead">{t('hololake.channel.heartbeatDescription')}</p>
<div className="channel-grid channel-grid--three">
<RouteCard eyebrow="PERSONA · AI AGENT" title="人格体工作舱" description="与当前频道的人格体持续协作;保留多 Agent、模型选择、权限模式、工具调用、执行回执与历史会话。" action="打开人格体工作舱" onOpen={onOpenAiWorkspace ? openAiWorkspace : undefined} />
<RouteCard eyebrow="INDUSTRY · EDUCATION" title="教育工作湖" description="通过语言指令调出并编辑文档、表格、对比图、仪表盘与知识。第一阶段权限保留在应用内。" action="进入教育工作湖" onOpen={() => navigate('education-work-lake')} />
<RouteCard eyebrow="MODULE · KNOWLEDGE" title={t('hololake.home.moduleKnowledgeTitle')} description={t('hololake.home.moduleKnowledgeDescription')} action={t('hololake.channel.openKnowledge')} onOpen={openKnowledgeBase} />
<RouteCard eyebrow="LOCAL COMPUTER · LIVE FOLDER" title="本地电脑工作区" description="选择电脑上的任意项目文件夹。编程 AI 直接在原文件夹写入页面,光湖实时显示,不再复制到旧笔记库。" action="打开本地文件夹" onOpen={onOpenLocalWorkspace ? openLocalWorkspace : undefined} />
<RouteCard eyebrow="MODULE · VIDEO AI" title={t('hololake.channel.videoAiTitle')} description={t('hololake.channel.videoAiDescription')} status={t('hololake.channel.prototypeMounted')} />
@ -492,6 +502,7 @@ export function HoloLakeHome({
<Button data-active={route === 'world'} variant="ghost" onClick={() => navigate('world')}><i data-color="cyan" /></Button>
<Button variant="ghost" onClick={openKnowledgeBase}><i data-color="blue" /></Button>
<Button disabled={!onOpenAiWorkspace} variant="ghost" onClick={openAiWorkspace}><i data-color="violet" />Agent </Button>
<Button data-active={route === 'education-work-lake'} variant="ghost" onClick={() => navigate('education-work-lake')}><i data-color="green" /></Button>
<Button disabled={!onOpenLocalWorkspace} variant="ghost" onClick={openLocalWorkspace}><i data-color="amber" /></Button>
</>}
{fifthDomainOpen && <>