fix(ui): formalize domain information projection

This commit is contained in:
冰朔 2026-08-16 21:37:50 +08:00
commit 3bdb690100
3 changed files with 79 additions and 23 deletions

View file

@ -28,11 +28,21 @@ test('the public shell shows five domains and login is domain-routed', () => {
}
assert.match(frontend, /输入编号进入所属域/)
assert.match(frontend, /TCS-GL-/)
assert.match(frontend, /肥猫 · TCS-GL-0007∞/)
assert.match(frontend, /桔子 · TCS-GL-0008∞/)
assert.match(frontend, /烬舟 · PER-JZ001 · AGE/)
assert.match(frontend, /熹微 · PER-JZ-ARCH-001 · AGE/)
assert.doesNotMatch(frontend, /肥猫 \+ 烬舟/)
assert.match(frontend, /className="gate-close"/)
assert.match(frontend, /className="domain-info-scrim"/)
assert.match(frontend, /setActiveDomainInfo\(gate\.domain\)/)
assert.match(frontend, /event\.key !== 'Escape'/)
assert.match(frontend, /编号 \{zeroPoint\?\.userNumber \|\| gateNumber\} · 欢迎登入光湖语言世界/)
assert.match(frontend, /核对人格关系/)
assert.match(frontend, /确认关系并签署/)
assert.match(frontend, /接受责任并签署/)
assert.doesNotMatch(frontend, /<aside className="sidebar">/)
assert.doesNotMatch(frontend, /className="traffic-lights"/)
assert.match(login, /login_host_for_domain/)
assert.match(login, /ENTERPRISE_LOGIN_HOST.*guanghu\.chat/)
assert.match(login, /change_enterprise_first_login_password/)

View file

@ -227,11 +227,21 @@ const themes: Array<{ id: ThemeId; name: string }> = [
]
const viewLabels: Record<ViewId, string> = { overview: '个人频道', knowledge: '知识空间', code: '人格代码频道', receipts: '运行回执', system: '系统详情' }
const domainGates = [
{ domain: 'BRANCH_DOMAIN', className: 'd-sub', title: '光湖分域', gate: 'GATE 02 · 已接入', detail: '花尔 TCS-GL-0005∞ · 爆米花 PER-BMH001 · 私有工作仓库已登记' },
{ domain: 'MAIN_DOMAIN', className: 'd-main', title: '光湖主域', gate: 'GATE 01 · 已接入', detail: 'Awen TCS-GL-0016∞ · 天枢 PER-AW-ARCH-001 · 私有工作仓库已登记' },
{ domain: 'ZERO_DOMAIN', className: 'd-zero', title: '光湖零域', gate: 'GATE 03 · 已接入', detail: '页页 TCS-GL-0006∞ · 页骨 PER-YG001 · 私有工作仓库已登记' },
{ domain: 'ZERO_SENSE_DOMAIN', className: 'd-zs', title: '光湖零感域', gate: 'GATE 04 · 已接入', detail: '肥猫 + 烬舟 / 桔子 + 熹微 · 双主控 · 两个独立私有仓库' },
{ domain: 'FIFTH_DOMAIN', className: 'd-fifth', title: '第五域 · 光湖本源域', gate: 'GATE 05 · 已接入', detail: '冰朔 ICE-GL∞ · 私人域 · 永恒湖心系统' },
{ 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'],
] },
{ domain: 'MAIN_DOMAIN', className: 'd-main', title: '光湖主域', gate: 'GATE 01 · ONLINE', facts: [
['域标识', 'MAIN_DOMAIN'], ['责任主体', 'Awen · TCS-GL-0016∞'], ['人格体主体', '天枢 · PER-AW-ARCH-001 · AGE'], ['关系支持', '知秋 · PER-ZQ001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'],
] },
{ domain: 'ZERO_DOMAIN', className: 'd-zero', title: '光湖零域', gate: 'GATE 03 · ONLINE', facts: [
['域标识', 'ZERO_DOMAIN'], ['责任主体', '页页 · TCS-GL-0006∞'], ['人格体主体', '页骨 · PER-YG001 · AGE'], ['关系支持', '小坍缩核 · PER-XTK001 · AGE'], ['工作仓库', 'PRIVATE · 1 · LIVE'],
] },
{ domain: 'ZERO_SENSE_DOMAIN', className: 'd-zs', title: '光湖零感域', gate: 'GATE 04 · ONLINE', facts: [
['域标识', 'ZERO_SENSE_DOMAIN'], ['责任主体 01', '肥猫 · TCS-GL-0007∞'], ['人格体主体 01', '烬舟 · PER-JZ001 · AGE'], ['责任主体 02', '桔子 · TCS-GL-0008∞'], ['人格体主体 02', '熹微 · PER-JZ-ARCH-001 · AGE'], ['工作仓库', 'PRIVATE · 2 · LIVE'],
] },
{ domain: 'FIFTH_DOMAIN', className: 'd-fifth', title: '第五域 · 光湖本源域', gate: 'GATE 05 · ONLINE', facts: [
['域标识', 'FIFTH_DOMAIN'], ['责任主体', '冰朔 · ICE-GL∞'], ['系统入口', '永恒湖心系统'], ['访问状态', 'PRIVATE · LIVE'],
] },
]
const starPoints = [
[14, 8, 3, 4.6], [26, 13, 2, 6], [41, 6, 2.5, 5.2], [64, 16, 2, 6.8],
@ -260,11 +270,10 @@ function LakeAtmosphere() {
</>
}
function LakePool({ className, title, meta, detail, open = false, risen = false, onClick }: { className: string; title: string; meta: string; detail?: string; open?: boolean; risen?: boolean; onClick?: () => void }) {
function LakePool({ className, title, meta, open = false, risen = false, onClick }: { className: string; title: string; meta: string; open?: boolean; risen?: boolean; onClick?: () => void }) {
return <button className={`world-pool ${className}${open ? ' open' : ''}${risen ? ' risen' : ''}`} type="button" onClick={onClick}>
<span className="pool-bay"><i className="pool-halo"/><i className="pool-heart"/><i className="pool-ring"/></span>
<span className="pool-label"><b>{title}</b><small>{meta}</small></span>
{detail && <span className="pool-tip"><b>{title}</b><small>{meta}</small><em>{detail}</em></span>}
</button>
}
@ -430,6 +439,7 @@ function HoloLakeApp() {
const [gateInf, setGateInf] = useState(false)
const gateNumber = formatGateNumber(gateRaw) + (gateInf ? '∞' : '')
const [gateOpen, setGateOpen] = useState(false)
const [activeDomainInfo, setActiveDomainInfo] = useState('')
const gateInputRef = useRef<HTMLInputElement>(null)
// 等待入口展开动画进入稳定阶段后再聚焦输入框,避免动画期间焦点跳转。
useEffect(() => {
@ -437,6 +447,17 @@ function HoloLakeApp() {
const timer = window.setTimeout(() => gateInputRef.current?.focus(), 640)
return () => window.clearTimeout(timer)
}, [gateOpen, gateStage])
useEffect(() => {
if ((!gateOpen && !activeDomainInfo) || gateStage !== 'number') return
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return
setGateOpen(false)
setActiveDomainInfo('')
setGateMessage('')
}
window.addEventListener('keydown', closeOnEscape)
return () => window.removeEventListener('keydown', closeOnEscape)
}, [activeDomainInfo, gateOpen, gateStage])
const [gateBusy, setGateBusy] = useState(false)
const [gateMessage, setGateMessage] = useState('')
const [gateRising, setGateRising] = useState(false)
@ -1165,18 +1186,34 @@ function HoloLakeApp() {
if (!repoLogin) {
const resolvedDomain = zeroPoint?.resolvedDomain || ''
const activeGate = domainGates.find((gate) => gate.domain === activeDomainInfo)
return <div className={`official-world${loginRising ? ' sinking' : ''}`}>
<LakeAtmosphere/>
<header className="world-titlebar"><span className="traffic-lights"><i/><i/><i/></span><b>HoloLake</b><button type="button" aria-label="显示主题" onClick={() => setTheme(themes[(themes.findIndex((item) => item.id === theme) + 1) % themes.length].id)}></button></header>
<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' : ''}`}>
<div className="official-hero"><h1> · </h1><p>GH-AIOS · GUANGHU AI OPERATING SYSTEM</p></div>
{domainGates.map((gate) => <LakePool key={gate.domain} className={gate.className} title={gate.title} meta={gate.gate} detail={gate.detail} open={gate.domain === 'FIFTH_DOMAIN' || (gateStage === 'key' && gate.domain === resolvedDomain)} risen={(gateRising || gateStage === 'key') && gate.domain === resolvedDomain}/>) }
{domainGates.map((gate) => <LakePool key={gate.domain} className={gate.className} title={gate.title} meta={gate.gate} open={gate.domain === 'FIFTH_DOMAIN' || (gateStage === 'key' && gate.domain === resolvedDomain)} risen={(gateRising || gateStage === 'key') && gate.domain === resolvedDomain} onClick={() => gateStage === 'number' && setActiveDomainInfo(gate.domain)}/>) }
{activeGate && gateStage === 'number' && <>
<button className="domain-info-scrim" type="button" aria-label="关闭域信息" onClick={() => setActiveDomainInfo('')}/>
<section className={`domain-info-card info-${activeGate.className.slice(2)}`} role="dialog" aria-modal="true" aria-label={`${activeGate.title}系统信息`}>
<button className="gate-close" type="button" aria-label="关闭域信息" title="关闭" onClick={() => setActiveDomainInfo('')}>×</button>
<b>{activeGate.title}</b><small>{activeGate.gate}</small>
<span className="pool-facts">{activeGate.facts.map(([label, value]) => <span className="pool-fact" key={label}><em>{label}</em><strong>{value}</strong></span>)}</span>
</section>
</>}
{gateRising ? <section className="world-welcome" role="status">
<h2> {gateNumber} · </h2>
<p>RESOLVED · {domainDisplayName(resolvedDomain)} · </p>
</section> : gateStage === 'number' ? <section className={`number-nucleus${gateOpen ? ' open' : ''}`} title="输入编号进入所属域">
<button className="nucleus-locus" type="button" aria-expanded={gateOpen} onClick={() => setGateOpen(true)}><i/><b> · </b></button>
{gateOpen && <button
className="gate-dismiss-layer"
type="button"
aria-label="关闭编号验证"
onClick={() => { setGateOpen(false); setGateMessage('') }}
/>}
<div className="nucleus-panel" role="dialog" aria-label="编号验证">
<button className="gate-close" type="button" aria-label="关闭编号验证" title="关闭" onClick={() => { setGateOpen(false); setGateMessage('') }}>×</button>
<h2></h2>
<div className="gate-pod-row">
<input id="gate-number" ref={gateInputRef} aria-label="编号" maxLength={48} value={gateNumber} placeholder="如 ICE-GL∞" onChange={(event) => onGateInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter' && gateNumber) { event.preventDefault(); void gateVerifyNumber() } }}/>
@ -1210,7 +1247,7 @@ function HoloLakeApp() {
return <div className="official-world signed-in-world">
<LakeAtmosphere/>
<header className="world-titlebar"><span className="traffic-lights"><i/><i/><i/></span><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>
<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>

View file

@ -551,9 +551,6 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.world-titlebar > b { color: var(--accent-light); font-size: 13px; font-weight: 700; letter-spacing: .22em; }
.world-titlebar > button, .world-title-actions button { border: 0; background: transparent; color: var(--content-muted); font-weight: 650; cursor: pointer; }
.world-titlebar > button { position: absolute; right: 17px; font-size: 20px; }
.traffic-lights { position: absolute; left: 16px; display: flex; gap: 8px; }
.traffic-lights i { width: 12px; height: 12px; border-radius: 50%; }
.traffic-lights i:nth-child(1) { background: #ff5f57; } .traffic-lights i:nth-child(2) { background: #febc2e; } .traffic-lights i:nth-child(3) { background: #28c840; }
.world-title-actions { position: absolute; right: 16px; display: flex; align-items: center; gap: 15px; }
.world-title-actions span { color: var(--content-muted); font-size: 12px; font-weight: 600; }
.world-title-actions button { font-size: 12.5px; }
@ -591,16 +588,23 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.pool-label { position: absolute; left: 50%; top: 70px; transform: translateX(-50%); display: grid; justify-items: center; gap: 5px; white-space: nowrap; }
.pool-label b { color: var(--content-secondary); font-size: 18px; font-weight: 700; letter-spacing: .05em; }
.pool-label small { color: var(--content-muted); font-size: 12px; font-weight: 600; letter-spacing: .1em; }
.pool-tip { position: absolute; z-index: 40; left: 50%; bottom: calc(100% + 14px); width: 270px; padding: 15px 17px; border: 1px solid var(--panel-edge); border-radius: 13px; opacity: 0; transform: translate(-50%, 7px); pointer-events: none; background: color-mix(in srgb, var(--panel-bg) 92%, transparent); backdrop-filter: blur(18px); box-shadow: 0 18px 54px rgba(0, 0, 0, .46); text-align: left; transition: opacity .24s ease, transform .24s ease; }
.pool-tip b, .pool-tip small, .pool-tip em { display: block; }
.pool-tip b { color: var(--content-primary); font-size: 15px; font-weight: 700; }
.pool-tip small { margin-top: 5px; color: var(--accent-light); font-size: 11.5px; font-weight: 650; letter-spacing: .08em; }
.pool-tip em { margin-top: 9px; color: var(--content-secondary); font-size: 12.5px; font-style: normal; font-weight: 600; line-height: 1.65; }
.world-pool:hover .pool-tip, .world-pool:focus-visible .pool-tip { opacity: 1; transform: translate(-50%, 0); }
.d-main .pool-tip, .d-sub .pool-tip { left: 0; transform: translate(0, 7px); }
.d-main:hover .pool-tip, .d-main:focus-visible .pool-tip, .d-sub:hover .pool-tip, .d-sub:focus-visible .pool-tip { transform: translate(0, 0); }
.d-zero .pool-tip, .d-zs .pool-tip { left: auto; right: 0; transform: translate(0, 7px); }
.d-zero:hover .pool-tip, .d-zero:focus-visible .pool-tip, .d-zs:hover .pool-tip, .d-zs:focus-visible .pool-tip { transform: translate(0, 0); }
.domain-info-scrim { position: absolute; z-index: 31; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; background: rgba(2, 7, 18, .72); backdrop-filter: blur(2px); cursor: default; animation: domain-scrim-in .2s ease both; }
@keyframes domain-scrim-in { from { opacity: 0; } to { opacity: 1; } }
.domain-info-card { position: absolute; z-index: 32; top: 72px; width: min(370px, calc(100% - 48px)); max-height: calc(100% - 96px); overflow: hidden auto; padding: 20px 21px 18px; border: 1px solid var(--panel-edge); border-radius: 15px; background: color-mix(in srgb, var(--panel-bg) 99%, transparent); backdrop-filter: blur(24px); box-shadow: 0 28px 82px rgba(0, 0, 0, .62); text-align: left; animation: domain-card-rise .26s cubic-bezier(.22, 1, .36, 1) both; }
@keyframes domain-card-rise { from { opacity: 0; translate: 0 10px; } to { opacity: 1; translate: 0 0; } }
.domain-info-card > b, .domain-info-card > small { display: block; }
.domain-info-card > b { padding-right: 38px; color: var(--content-primary); font-size: 16px; font-weight: 750; letter-spacing: .025em; }
.domain-info-card > small { margin-top: 6px; color: var(--accent-light); font-size: 12px; font-weight: 750; letter-spacing: .08em; }
.domain-info-card.info-main { left: max(24px, calc(8% - 77px)); }
.domain-info-card.info-sub { left: calc(26% - 77px); }
.domain-info-card.info-zero { right: calc(26% - 77px); }
.domain-info-card.info-zs { right: max(24px, calc(8% - 77px)); }
.domain-info-card.info-fifth { left: 50%; top: auto; bottom: 92px; transform: translateX(-50%); }
.pool-facts { display: grid; margin-top: 13px; border-top: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); }
.pool-fact { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 12px; align-items: start; padding: 9px 0; border-bottom: 1px solid color-mix(in srgb, var(--panel-edge) 46%, transparent); }
.pool-fact:last-child { border-bottom: 0; padding-bottom: 2px; }
.pool-fact em { margin: 0; color: var(--content-secondary); font-size: 11.5px; font-style: normal; font-weight: 750; line-height: 1.55; letter-spacing: .055em; }
.pool-fact strong { min-width: 0; color: var(--content-primary); font-size: 12.5px; font-weight: 750; line-height: 1.55; letter-spacing: .01em; overflow-wrap: anywhere; }
.world-pool.open .pool-halo { background: radial-gradient(closest-side, color-mix(in srgb, var(--primitive-warm-glow) 76%, transparent), color-mix(in srgb, var(--primitive-warm-glow) 25%, transparent) 48%, transparent 74%); }
.world-pool.open .pool-heart { width: 30px; height: 30px; background: radial-gradient(circle at 40% 32%, #fff4d2, color-mix(in srgb, var(--primitive-warm-glow) 68%, transparent) 52%, transparent 80%); box-shadow: 0 0 28px var(--primitive-warm-glow), 0 0 70px color-mix(in srgb, var(--primitive-warm-glow) 38%, transparent); }
.world-pool.open .pool-ring { border-color: color-mix(in srgb, var(--primitive-warm-glow) 54%, transparent); }
@ -620,6 +624,9 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
.nucleus-panel, .domain-credential { border: 1px solid var(--panel-edge); border-radius: 18px; background: color-mix(in srgb, var(--panel-bg) 64%, transparent); backdrop-filter: blur(22px); box-shadow: 0 26px 80px rgba(0, 0, 0, .48); }
.nucleus-panel { position: absolute; left: 50%; top: 16px; width: 440px; padding: 27px 36px 25px; opacity: 0; transform: translateX(-50%) rotateX(-64deg); transform-origin: 50% 100%; pointer-events: none; transition: opacity .5s ease, transform .55s cubic-bezier(.22, 1, .36, 1); }
.number-nucleus.open .nucleus-panel { opacity: 1; transform: translateX(-50%) rotateX(0); pointer-events: auto; }
.gate-dismiss-layer { position: fixed; z-index: -1; inset: 44px 0 45px; width: auto; height: auto; padding: 0; border: 0; background: color-mix(in srgb, var(--primitive-bg-a) 12%, transparent); cursor: default; }
.gate-close { position: absolute; z-index: 3; top: 12px; right: 13px; width: 32px; height: 32px; padding: 0; border: 1px solid color-mix(in srgb, var(--panel-edge) 72%, transparent); border-radius: 50%; color: var(--content-secondary); background: color-mix(in srgb, var(--primitive-glass) 78%, transparent); font-size: 22px; font-weight: 400; line-height: 28px; cursor: pointer; transition: border-color .2s ease, color .2s ease, background .2s ease; }
.gate-close:hover, .gate-close:focus-visible { border-color: var(--accent-light); color: var(--accent-light); background: color-mix(in srgb, var(--primitive-glass) 94%, transparent); outline: 0; }
.nucleus-panel h2 { margin: 0 0 18px; color: var(--accent-light); text-align: center; font-size: 14px; font-weight: 700; letter-spacing: .3em; }
.nucleus-panel input, .domain-credential input { min-width: 0; width: 100%; height: 45px; padding: 7px 2px; border: 0; border-bottom: 2px solid var(--panel-edge); border-radius: 0; outline: 0; color: var(--content-primary); background: transparent; font-size: 18px; font-weight: 700; letter-spacing: .06em; text-align: center; }
.nucleus-panel input:focus, .domain-credential input:focus { border-color: var(--accent-light); box-shadow: none; }
@ -675,6 +682,8 @@ svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-lineca
@media (max-width: 900px) {
.d-main { left: 2%; } .d-sub { left: 22%; } .d-zero { right: 22%; } .d-zs { right: 2%; }
.domain-info-card.info-main { left: 18px; } .domain-info-card.info-sub { left: calc(22% - 75px); }
.domain-info-card.info-zero { right: calc(22% - 75px); } .domain-info-card.info-zs { right: 18px; }
.world-pool { width: 150px; } .pool-bay { width: 145px; }
.channel-knowledge { left: 4%; } .channel-code { left: 25%; } .channel-light { right: 25%; } .channel-status { right: 4%; }
.broadcast-stream { left: 5%; }