feat(desktop): add idle lake and Zhizhi private route

This commit is contained in:
冰朔 2026-08-16 23:13:47 +08:00
commit cc802126ea
10 changed files with 385 additions and 34 deletions

View file

@ -49,7 +49,7 @@ import './styles.css'
type ThemeId = 'night' | 'dawn' | 'nebula' | 'candle' | 'clear'
type ViewId = 'overview' | 'knowledge' | 'code' | 'receipts' | 'system'
type WorldStage = 'domain' | 'channel' | 'tool'
type WorldStage = 'domain' | 'heart' | 'love' | 'tomorrow' | 'bottle' | 'channel' | 'tool'
type KnowledgeSource = 'native' | 'legacy'
interface HomeStatus {
@ -315,9 +315,11 @@ function humanError(error: unknown, context: 'knowledge' | 'code' | 'identity' |
if (value.includes('LOGIN_KEYCHAIN_FAILED')) return '本机钥匙串写入失败,凭证未能安全保存。'
if (value.includes('LOGIN_SESSION_CORRUPT')) return '登录会话已损坏,请重新登录。'
if (value.includes('LOGIN_VERIFICATION_FAILED')) return '仓库验证未通过,请稍后再试。'
if (value.includes('LOGIN_ACCOUNT_NUMBER_MISMATCH')) return '这个账号没有绑定到当前编号,系统已停止进入。'
if (value.includes('NEW_PASSWORD_POLICY_INVALID')) return '新密码至少 14 位,并且不能与一次性密码相同。'
if (value.includes('FIRST_LOGIN_PASSWORD_CHANGE_FAILED')) return '一次性密码未被接受,密码没有修改。'
if (value.includes('FIRST_LOGIN_PASSWORD_CHANGE_ENTERPRISE_ONLY')) return '该换密入口只用于企业四域的新账号。'
if (value.includes('FIRST_LOGIN_PASSWORD_CHANGE_NOT_PROVISIONED')) return '该编号当前没有登记首次换密入口。'
if (value.includes('LOGIN_ACCOUNT_BINDING_UNREGISTERED')) return '这个编号尚未登记对应的仓库账号,系统已停止进入。'
if (value.includes('DOMAIN_LOGIN_NOT_PROVISIONED')) return '该域的企业服务器尚未接入,当前不能继续登录。'
if (value.includes('PERSISTENT_CREDENTIAL_UNAVAILABLE')) return '当前系统尚未接通安全凭证存储,不能完成签署。'
if (value.includes('ENTERPRISE_RECEIPT_FAILED')) return '企业回执没有写入,请稍后重新提交。'
@ -407,6 +409,18 @@ function KnowledgeTree({ node, depth, expanded, active, onToggle, onOpen, onRemo
}
function HoloLakeApp() {
// 湖面装饰默认休眠。人类靠近或操作时短暂唤醒;验证、升域等因果动画不受此开关影响。
const [motionAwake, setMotionAwake] = useState(false)
const motionSleepTimer = useRef<number | null>(null)
const wakeAmbientMotion = useCallback(() => {
if (document.hidden) return
setMotionAwake(true)
if (motionSleepTimer.current !== null) window.clearTimeout(motionSleepTimer.current)
motionSleepTimer.current = window.setTimeout(() => {
motionSleepTimer.current = null
setMotionAwake(false)
}, 2400)
}, [])
const [theme, setTheme] = useState<ThemeId>(() => (window.localStorage.getItem('hololake-theme') as ThemeId) || 'night')
const [view, setView] = useState<ViewId>('overview')
const [worldStage, setWorldStage] = useState<WorldStage>('domain')
@ -498,6 +512,22 @@ function HoloLakeApp() {
const [enterpriseReceiptMessage, setEnterpriseReceiptMessage] = useState('')
const [responsibilityNote, setResponsibilityNote] = useState('')
useEffect(() => {
const sleepAmbientMotion = () => {
if (motionSleepTimer.current !== null) window.clearTimeout(motionSleepTimer.current)
motionSleepTimer.current = null
setMotionAwake(false)
}
const onVisibilityChange = () => { if (document.hidden) sleepAmbientMotion() }
window.addEventListener('blur', sleepAmbientMotion)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
sleepAmbientMotion()
window.removeEventListener('blur', sleepAmbientMotion)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [])
const refreshCore = useCallback(async () => {
const [homeResult, personalResult, knowledgeResult, codeResult] = await Promise.allSettled([
invoke<HomeStatus>('get_hololake_home_status'),
@ -518,8 +548,9 @@ function HoloLakeApp() {
}, [])
useEffect(() => {
void refreshCore()
const timer = window.setInterval(() => void refreshCore(), 3500)
return () => window.clearInterval(timer)
const refreshWhenHumanReturns = () => { if (!document.hidden) void refreshCore() }
window.addEventListener('focus', refreshWhenHumanReturns)
return () => window.removeEventListener('focus', refreshWhenHumanReturns)
}, [refreshCore])
useEffect(() => {
invoke<NearbyAiDiscoverySnapshot>('get_nearby_ai_discovery')
@ -540,8 +571,6 @@ function HoloLakeApp() {
}, [])
useEffect(() => {
void refreshServerPncc()
const timer = window.setInterval(() => void refreshServerPncc(), 15000)
return () => window.clearInterval(timer)
}, [refreshServerPncc])
useEffect(() => {
document.documentElement.dataset.theme = theme
@ -573,8 +602,6 @@ function HoloLakeApp() {
}, [])
useEffect(() => {
void loadZeroPoint()
const timer = window.setInterval(() => void loadZeroPoint(), 10000)
return () => window.clearInterval(timer)
}, [loadZeroPoint])
// 已验证编号与已认证账号同时存在时,后端幂等建立或恢复该用户自己的 GH-PNCC。
useEffect(() => {
@ -857,6 +884,14 @@ function HoloLakeApp() {
setLoginBusy(true)
setLoginMessage('')
try {
const expectedFifthDomainAccount: Record<string, string> = {
'ICE-GL∞': 'bingshuo',
'ICE-GL-ZHI∞': 'zhizhi',
}
const expectedAccount = expectedFifthDomainAccount[zeroPoint?.userNumber || '']
if (expectedAccount && loginUsername.trim().toLowerCase() !== expectedAccount) {
throw new Error('HOLOLAKE_LOGIN_ACCOUNT_NUMBER_MISMATCH')
}
const receipt = await invoke<LoginReceipt>('perform_code_repo_login', { username: loginUsername.trim(), password: loginPassword })
// 五湖开场:校验通过=第五域从湖面浮起,镜头沉入湖中再进场。
setLoginRising(true)
@ -887,7 +922,7 @@ function HoloLakeApp() {
setLoginBusy(true)
setLoginMessage('')
try {
await invoke('change_enterprise_first_login_password', {
await invoke('change_first_login_password', {
username: loginUsername.trim(),
currentPassword: loginPassword,
newPassword: newLoginPassword,
@ -1213,7 +1248,7 @@ function HoloLakeApp() {
if (!repoLogin) {
const resolvedDomain = zeroPoint?.resolvedDomain || ''
const activeGate = domainGates.find((gate) => gate.domain === activeDomainInfo)
return <div className={`official-world${loginRising ? ' sinking' : ''}`}>
return <div className={`official-world${motionAwake ? ' motion-awake' : ''}${loginRising ? ' sinking' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}>
<LakeAtmosphere/>
<header className="world-titlebar"><b>HoloLake</b><button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button></header>
<main className={`world-scene${gateRising ? ' resolving' : ''}${gateStage === 'key' ? ' resolved' : ''}`}>
@ -1263,7 +1298,7 @@ function HoloLakeApp() {
<button className="gate-submit" disabled={loginBusy || !loginUsername.trim() || !loginPassword || (passwordChangeMode && (!newLoginPassword || !confirmLoginPassword))}>{loginBusy ? '正在处理…' : passwordChangeMode ? '修改密码' : '验证并进入'}</button>
</form>
{loginMessage && <p className="gate-hint">{loginMessage}</p>}
{zeroPoint?.resolvedDomain !== 'FIFTH_DOMAIN' && <button className="gate-back" type="button" onClick={() => { setPasswordChangeMode((value) => !value); setLoginMessage(''); setNewLoginPassword(''); setConfirmLoginPassword('') }}>{passwordChangeMode ? '返回正常登录' : '第一次使用?先修改一次性密码'}</button>}
{(zeroPoint?.resolvedDomain !== 'FIFTH_DOMAIN' || zeroPoint?.userNumber === 'ICE-GL-ZHI∞') && <button className="gate-back" type="button" onClick={() => { setPasswordChangeMode((value) => !value); setLoginMessage(''); setNewLoginPassword(''); setConfirmLoginPassword('') }}>{passwordChangeMode ? '返回正常登录' : '第一次使用?先修改一次性密码'}</button>}
<button className="gate-back" type="button" onClick={() => { setGateStage('number'); setGateMessage(''); setLoginMessage('') }}></button>
</section>
</>}
@ -1272,27 +1307,56 @@ function HoloLakeApp() {
</div>
}
return <div className="official-world signed-in-world">
const isZhizhi = repoLogin.domain === 'FIFTH_DOMAIN' && zeroPoint?.userNumber === 'ICE-GL-ZHI∞'
const toolReturnStage: WorldStage = isZhizhi ? 'tomorrow' : 'channel'
return <div className={`official-world signed-in-world${motionAwake ? ' motion-awake' : ''}`} onPointerMove={wakeAmbientMotion} onPointerDown={wakeAmbientMotion} onKeyDown={wakeAmbientMotion}>
<LakeAtmosphere/>
<header className="world-titlebar"><b>HoloLake</b><div className="world-title-actions"><span>{repoLogin.username} · {domainDisplayName(repoLogin.domain)}</span><button type="button" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button><button type="button" onClick={() => void signOutRepo()}>退</button></div></header>
<main className="world-scene signed-in-scene">
{worldStage === 'domain' && <section className="domain-home">
<div className="world-location"><h1>{domainDisplayName(repoLogin.domain)}</h1><p></p></div>
<div className="broadcast-stream"><p><i/> · 线</p><p><i/> · {enterpriseEntry?.registry_version || 'HLDP v1.0'}</p><p><i/>HoloLake · V0.4.0</p></div>
<LakePool className="home-primary" title={repoLogin.domain === 'FIFTH_DOMAIN' ? '永恒湖心系统' : '光湖频道'} meta={repoLogin.domain === 'FIFTH_DOMAIN' ? '进入私人系统' : '企业四域 · 工作入口'} open onClick={() => setWorldStage('channel')}/>
<LakePool className="home-primary" title={repoLogin.domain === 'FIFTH_DOMAIN' ? '永恒湖心系统' : '光湖频道'} meta={repoLogin.domain === 'FIFTH_DOMAIN' ? '进入私人系统' : '企业四域 · 工作入口'} open onClick={() => setWorldStage(repoLogin.domain === 'FIFTH_DOMAIN' ? (isZhizhi ? 'heart' : 'channel') : 'channel')}/>
<LakePool className="home-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</section>}
{worldStage === 'heart' && isZhizhi && <section className="channel-world private-route-world">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> 退</button>
<div className="world-location"><h1></h1><p>ICE-GL-ZHI · </p></div>
<LakePool className="channel-main love-core-pool" title="爱之核心子系统" meta="责任主体 · 之之" open onClick={() => setWorldStage('love')}/>
<p className="private-route-note"></p>
</section>}
{worldStage === 'love' && isZhizhi && <section className="channel-world private-route-world">
<button className="world-back" type="button" onClick={() => setWorldStage('heart')}> 退</button>
<div className="world-location"><h1></h1><p> · · ICE-GL-ZHI</p></div>
<LakePool className="channel-main tomorrow-pool" title="明天见频道" meta="个人频道 · 本机内置 Git 承载" open onClick={() => setWorldStage('tomorrow')}/>
</section>}
{worldStage === 'tomorrow' && isZhizhi && <section className="channel-world tomorrow-world">
<button className="world-back" type="button" onClick={() => setWorldStage('love')}> 退</button>
<div className="world-location"><h1></h1><p> · </p></div>
<LakePool className="channel-main" title="频道全景" meta="明天见 · 私人生活区" open onClick={() => openWorldTool('overview')}/>
<LakePool className="channel-knowledge" title="知识空间" meta={`${knowledge.uniqueDocumentCount} 唯一页`} onClick={() => openWorldTool('knowledge')}/>
<LakePool className="channel-code" title="内置代码频道" meta={userPncc?.state === 'READY' ? '明天见 · Git 已就绪' : '正在建立'} onClick={() => openWorldTool('code')}/>
<LakePool className="channel-light" title="奶瓶频道" meta="前往陪伴宝宝人格体" onClick={() => setWorldStage('bottle')}/>
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</section>}
{worldStage === 'bottle' && repoLogin.domain === 'FIFTH_DOMAIN' && <section className="channel-world bottle-world">
<button className="world-back" type="button" onClick={() => setWorldStage(isZhizhi ? 'tomorrow' : 'channel')}> 退</button>
<div className="world-location"><h1></h1><p> · · </p></div>
<button className="bottle-heart" type="button" aria-label="永恒湖心中央的奶瓶心"><i/><b></b><small>ICE-BB-* · AGE</small></button>
<p className="private-route-note"> AGEICE-BB </p>
</section>}
{worldStage === 'channel' && <section className="channel-world">
<button className="world-back" type="button" onClick={() => setWorldStage('domain')}> 退</button>
<div className="world-location"><h1>{repoLogin.domain === 'FIFTH_DOMAIN' ? '永恒湖心系统' : '光湖频道'}</h1><p> · </p></div>
<LakePool className="channel-main" title="我的频道" meta="频道全景" open onClick={() => openWorldTool('overview')}/>
<LakePool className="channel-main" title={repoLogin.domain === 'FIFTH_DOMAIN' ? '奶瓶频道' : '我的频道'} meta={repoLogin.domain === 'FIFTH_DOMAIN' ? '光湖奶瓶小宝宝系统 · 私人' : '频道全景'} open onClick={() => repoLogin.domain === 'FIFTH_DOMAIN' ? setWorldStage('bottle') : openWorldTool('overview')}/>
<LakePool className="channel-knowledge" title="知识空间" meta={`${knowledge.uniqueDocumentCount} 唯一页`} onClick={() => openWorldTool('knowledge')}/>
<LakePool className="channel-code" title="人格代码频道" meta={`${codeChannels.channels.length} 个仓库`} onClick={() => openWorldTool('code')}/>
<LakePool className="channel-light" title="光之湖" meta="人格体居所" onClick={() => openWorldTool('receipts')}/>
<LakePool className="channel-status" title="湖面天气" meta={connectionLabel} onClick={() => openWorldTool('system')}/>
</section>}
{worldStage === 'tool' && <section className={`world-tool world-tool-${view}`}>
<header className="tool-worldbar"><button type="button" onClick={() => setWorldStage('channel')}> 退</button><b>{viewLabels[view]}</b><span>{domainDisplayName(repoLogin.domain)}</span></header>
<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 === 'code' ? renderCode() : view === 'receipts' ? renderReceipts() : renderSystem()}</div>
</section>}
</main>

View file

@ -573,6 +573,22 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.world-glint.warm { background: radial-gradient(circle, #fff4d2, color-mix(in srgb, var(--primitive-warm-glow) 46%, transparent) 70%, transparent); box-shadow: 0 0 6px var(--primitive-warm-glow); }
@keyframes world-glint { 0%, 100% { opacity: .05; transform: scale(.6); } 50% { opacity: .72; transform: scale(1); } }
/* 无人操作时湖面必须真正安静;只暂停无语义的循环装饰。 */
.official-world .world-star,
.official-world .world-shine,
.official-world .world-glint,
.official-world .pool-bay,
.official-world .pool-halo,
.official-world .pool-ring,
.official-world .nucleus-locus i { animation-play-state: paused; }
.official-world.motion-awake .world-star,
.official-world.motion-awake .world-shine,
.official-world.motion-awake .world-glint,
.official-world.motion-awake .pool-bay,
.official-world.motion-awake .pool-halo,
.official-world.motion-awake .pool-ring,
.official-world.motion-awake .nucleus-locus i { animation-play-state: running; }
.official-hero { position: absolute; z-index: 10; top: 26px; left: 0; right: 0; text-align: center; }
.official-hero h1 { margin: 0; color: var(--content-primary); font-size: clamp(24px, 3vw, 34px); font-weight: 700; letter-spacing: .09em; }
.official-hero p { margin: 10px 0 0; color: var(--accent-light); font-size: 12px; font-weight: 600; letter-spacing: .31em; }
@ -658,6 +674,11 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.channel-main { left: 50%; top: 43%; width: 280px; transform: translateX(-50%); }
.channel-main .pool-bay { width: 260px; height: 84px; } .channel-main .pool-label { top: 92px; }
.channel-knowledge { left: 14%; top: 48%; } .channel-code { left: 31%; top: 61%; } .channel-light { right: 31%; top: 61%; } .channel-status { right: 14%; top: 48%; }
.private-route-note { position: absolute; z-index: 10; left: 50%; bottom: 11%; width: min(720px, calc(100% - 64px)); margin: 0; transform: translateX(-50%); color: var(--content-muted); text-align: center; font-size: 12.5px; font-weight: 650; letter-spacing: .08em; }
.bottle-heart { position: absolute; z-index: 12; left: 50%; top: 48%; display: grid; justify-items: center; gap: 9px; width: 300px; padding: 0; transform: translate(-50%, -50%); border: 0; background: transparent; color: inherit; }
.bottle-heart i { width: 90px; height: 90px; border-radius: 46% 46% 52% 52%; background: radial-gradient(circle at 42% 32%, #fffdf5, color-mix(in srgb, var(--primitive-warm-glow) 74%, #f2bdc8) 37%, color-mix(in srgb, var(--primitive-warm-glow) 22%, transparent) 68%, transparent 74%); filter: drop-shadow(0 0 34px color-mix(in srgb, var(--primitive-warm-glow) 62%, transparent)); }
.bottle-heart b { color: var(--content-primary); font-size: 22px; font-weight: 750; letter-spacing: .08em; }
.bottle-heart small { color: var(--accent-light); font-size: 12px; font-weight: 750; letter-spacing: .2em; }
.world-tool { position: absolute; z-index: 12; inset: 0; display: grid; grid-template-rows: 46px minmax(0, 1fr); background: color-mix(in srgb, var(--surface-depth) 72%, transparent); backdrop-filter: blur(18px); }
.tool-worldbar { display: grid; grid-template-columns: minmax(180px, 1fr) auto minmax(180px, 1fr); align-items: center; padding: 0 20px; border-bottom: 1px solid var(--panel-edge); background: color-mix(in srgb, var(--panel-bg) 82%, transparent); }
.tool-worldbar button { justify-self: start; border: 0; background: transparent; color: var(--content-muted); font-size: 12.5px; cursor: pointer; }