hololake-system-architecture/product-source/hololake-native-desktop/src/modules/mobile-sync/index.tsx

164 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useCallback, useEffect, useMemo, useState } from 'react'
import { numberedInvoke as invoke } from '../numbered-ipc'
import './styles.css'
interface PairedDevice {
deviceId: string
displayName: string
platform: string
createdAtUnixMs: number
lastSeenAtUnixMs?: number
state: 'ACTIVE' | 'REVOKED'
}
interface MobileSyncStatus {
schema: string
state: 'OFFLINE' | 'PAIRING_READY' | 'RUNNING'
running: boolean
lanAddress?: string
port?: number
pairingUri?: string
pairingQrSvg?: string
pairingExpiresAtUnixMs?: number
pairedDevices: PairedDevice[]
transport: string
encryption: string
desktopIsRootNode: boolean
platformPrivateDataCustody: boolean
}
interface MobileCapture {
cursor: number
captureId: string
title: string
body: string
sourceDeviceId: string
createdAtUnixMs: number
}
interface MobileSyncSnapshot {
state: string
cursor: number
generatedAtUnixMs: number
desktopName: string
rootNodeOnline: boolean
personalChannel: { home: string; growthEventCount: number; integrity: string }
webNovel: {
workCount: number
volumeCount: number
chapterCount: number
works: Array<{ workId: string; title: string; status: string; updatedAtUnixMs: number }>
}
education: {
activeTableCount: number
archivedTableCount: number
unassignedTableCount: number
sensitiveValuesIncluded: boolean
}
recentCaptures: MobileCapture[]
boundary: {
mobileRole: string
remoteDesktopClone: boolean
desktopOfflineExecution: boolean
sensitiveEducationValues: string
modelApi: string
}
}
function formatTime(value?: number) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未连接'
}
export function MobileSyncPanel({ onBack }: { onBack: () => void }) {
const [status, setStatus] = useState<MobileSyncStatus | null>(null)
const [snapshot, setSnapshot] = useState<MobileSyncSnapshot | null>(null)
const [message, setMessage] = useState('手机是当前个人节点的远程入口,不是第二套人格系统。')
const [pending, setPending] = useState(false)
const refresh = useCallback(async () => {
const next = await invoke<MobileSyncStatus>('get_mobile_sync_status')
setStatus(next)
if (next.running) setSnapshot(await invoke<MobileSyncSnapshot>('get_mobile_sync_snapshot'))
else setSnapshot(null)
}, [])
useEffect(() => {
let cancelled = false
refresh().catch((error) => !cancelled && setMessage(String(error)))
return () => { cancelled = true }
}, [refresh])
const run = async (action: () => Promise<unknown>, success: string) => {
if (pending) return
setPending(true)
try {
await action()
setMessage(success)
await refresh()
} catch (error) {
setMessage(String(error))
} finally {
setPending(false)
}
}
const qrSource = useMemo(
() => status?.pairingQrSvg ? `data:image/svg+xml;charset=utf-8,${encodeURIComponent(status.pairingQrSvg)}` : '',
[status?.pairingQrSvg],
)
return <section className="mobile-sync-world" aria-label="HoloLake 多端同步">
<header className="mobile-sync-worldbar">
<button type="button" onClick={onBack}> </button>
<div><span>HOLOLAKE / SAME PERSONA SYSTEM</span><b>iPhone </b></div>
<em>{status?.running ? '个人主节点在线' : '同步未开启'}</em>
</header>
<div className="mobile-sync-focus">
<div className={`mobile-sync-orbit ${status?.running ? 'is-online' : ''}`} aria-hidden="true"><i/><i/><i/></div>
<div><span></span><h1>{status?.running ? '同一频道,正在等待 iPhone' : '由这台电脑守住唯一真相'}</h1><p></p></div>
</div>
{!status?.running ? <section className="mobile-sync-start">
<b> · </b>
<p></p>
<button type="button" disabled={pending} onClick={() => void run(() => invoke('start_mobile_sync'), 'iPhone 同步入口已开启。')}>{pending ? '正在开启…' : '开启 iPhone 同步'}</button>
</section> : <div className="mobile-sync-grid">
<section className="mobile-sync-pairing">
<header><div><span>PAIRING</span><h2> iPhone</h2></div><button type="button" disabled={pending} onClick={() => void run(() => invoke('rotate_mobile_pairing'), '已换成新的十分钟配对凭据。')}></button></header>
{status.pairingUri ? <>
{qrSource && <img src={qrSource} alt="HoloLake iPhone 配对二维码"/>}
<p> iPhone HoloLake 使</p>
<button type="button" onClick={() => void navigator.clipboard.writeText(status.pairingUri || '').then(() => setMessage('配对链接已复制。'))}></button>
<small>{formatTime(status.pairingExpiresAtUnixMs)}</small>
</> : <div className="mobile-sync-pairing-used"><b>使</b><p></p></div>}
</section>
<section className="mobile-sync-projection">
<header><span>ROOT NODE PROJECTION</span><h2></h2></header>
<div className="mobile-sync-metrics">
<article><b>{snapshot?.webNovel.workCount ?? 0}</b><span></span></article>
<article><b>{snapshot?.webNovel.chapterCount ?? 0}</b><span></span></article>
<article><b>{snapshot?.education.activeTableCount ?? 0}</b><span></span></article>
<article><b>{snapshot?.personalChannel.growthEventCount ?? 0}</b><span></span></article>
</div>
<p>{snapshot?.education.sensitiveValuesIncluded ? '已包含' : '不会进入手机摘要'} · 线</p>
</section>
<section className="mobile-sync-devices">
<header><span>DEVICES</span><h2></h2></header>
{status.pairedDevices.length ? status.pairedDevices.map((device) => <article key={device.deviceId}>
<div><b>{device.displayName}</b><span>{device.platform} · {device.state === 'ACTIVE' ? `最近连接 ${formatTime(device.lastSeenAtUnixMs)}` : '已撤销'}</span></div>
{device.state === 'ACTIVE' && <button type="button" disabled={pending} onClick={() => void run(() => invoke('revoke_mobile_sync_device', { input: { deviceId: device.deviceId } }), `已撤销 ${device.displayName}`)}></button>}
</article>) : <p> iPhone</p>}
</section>
<section className="mobile-sync-captures">
<header><span>MOBILE CAPTURES</span><h2></h2></header>
{snapshot?.recentCaptures.length ? snapshot.recentCaptures.map((capture) => <article key={capture.captureId}><div><b>{capture.title || '未命名记录'}</b><time>{formatTime(capture.createdAtUnixMs)}</time></div><p>{capture.body}</p></article>) : <p></p>}
</section>
</div>}
<footer className="mobile-sync-footer"><span>{message}</span><div><button type="button" disabled={pending} onClick={() => void run(refresh, '状态已从本机根节点重新读取。')}></button>{status?.running && <button type="button" disabled={pending} onClick={() => void run(() => invoke('stop_mobile_sync'), '同步入口已关闭;已配对设备登记仍保留,可单独撤销。')}></button>}</div></footer>
</section>
}