feat: add remote-first education work lake
This commit is contained in:
parent
ac9d051778
commit
333cd222c5
43 changed files with 923 additions and 74 deletions
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
@ -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('八年级物理:光的折射')
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 && <>
|
||||
|
|
|
|||
Loading…
Reference in a new issue