Files
goodbuddy-shareserver/web/src/components/local-sharing.tsx
T

99 lines
8.4 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 { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import type { Device, Publication } from '../../../shared/sharing'
import { Button } from './ui/button'
import { StatusBadge } from './ui/status-badge'
const panel = 'space-y-4 rounded-2xl border border-white/10 bg-[var(--panel)] p-4 sm:p-5'
const muted = 'text-sm leading-6 text-[var(--text-muted)]'
const control = 'mt-1 w-full min-w-0 rounded-lg border border-white/10 bg-[var(--control)] p-2 text-sm focus-visible:outline-2'
type Directory = { devices: Device[]; publications: Publication[] }
type LoadState = { status: 'loading' } | { status: 'error'; message: string } | { status: 'loaded'; data: Directory }
export function LocalSharing() {
const [params, setParams] = useSearchParams()
const [attempt, setAttempt] = useState(0)
const [state, setState] = useState<LoadState>({ status: 'loading' })
const deviceId = params.get('device') ?? ''
const kind = params.get('kind') ?? ''
const status = params.get('status') ?? ''
const query = params.get('q') ?? ''
useEffect(() => {
const controller = new AbortController()
async function load<T>(resource: string, label: string): Promise<T> {
const response = await fetch(`/api/v1/sharing/${resource}`, { signal: controller.signal })
if (!response.ok) {
const error = await response.json().catch(() => null)
throw new Error(`${label}加载失败(HTTP ${response.status})${typeof error?.code === 'string' ? ` · ${error.code}` : ''}${typeof error?.requestId === 'string' ? ` · 请求 ID:${error.requestId}` : ''}`)
}
return response.json() as Promise<T>
}
Promise.all([
load<Pick<Directory, 'devices'>>('devices', '设备'),
load<Pick<Directory, 'publications'>>('publications', '发布记录')
]).then(([devices, publications]) => {
if (!controller.signal.aborted) setState({ status: 'loaded', data: { ...devices, ...publications } })
}).catch((error: unknown) => {
if (!controller.signal.aborted) setState({ status: 'error', message: error instanceof Error ? error.message : '请求失败' })
})
return () => controller.abort()
}, [attempt])
const reload = () => { setState({ status: 'loading' }); setAttempt((value) => value + 1) }
const update = (key: string, value: string) => {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
setParams(next, { replace: true })
}
return <>
<div className={panel}>
<p className={muted}>此页读取当前服务的真实设备和发布元数据,与其他页签的模拟用户、组织数据独立。请在 GoodBuddy 应用中心登记设备、发布或撤销共享内容,再刷新此目录。</p>
<p className={muted}>登记不代表设备在线;已发布不代表可以调用。来源模式仅为声明,服务器未同步正文。知识权限分别记录声明,尚未执行授权校验;此页不搜索正文、读取原文、下载文件或调用能力。</p>
<Button variant="secondary" disabled={state.status === 'loading'} onClick={reload}>刷新目录</Button>
</div>
{state.status === 'loading' && <p role="status" className={muted}>正在加载本地服务目录…</p>}
{state.status === 'error' && <div className={panel}>
<div role="alert"><h2 className="font-semibold">本地服务目录加载失败</h2><p className={`${muted} break-words`}>{state.message}</p><p className={muted}>请确认本地服务可访问后重试。</p></div>
<Button variant="secondary" onClick={reload}>重试</Button>
</div>}
{state.status === 'loaded' && (() => {
const { devices, publications } = state.data
const visible = publications.filter((item) => (!deviceId || item.deviceId === deviceId) && (!kind || item.kind === kind) && (!status || item.status === status) &&
[item.name, item.description, item.id, item.deviceId, devices.find((device) => device.id === item.deviceId)?.name].join(' ').toLowerCase().includes(query.trim().toLowerCase()))
return <>
<div className={panel}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<label className="text-sm">搜索本地元数据<input className={control} value={query} placeholder="名称、描述、设备或标识" onChange={(event) => update('q', event.target.value)} /></label>
<label className="text-sm">登记设备<select className={control} value={deviceId} onChange={(event) => update('device', event.target.value)}><option value="">全部设备</option>{devices.map((device) => <option key={device.id} value={device.id}>{device.name}({device.id})</option>)}{deviceId && !devices.some((device) => device.id === deviceId) && <option value={deviceId}>未找到设备({deviceId})</option>}</select></label>
<label className="text-sm">共享类型<select className={control} value={kind} onChange={(event) => update('kind', event.target.value)}><option value="">全部类型</option><option value="capability">能力</option><option value="knowledge">知识库</option></select></label>
<label className="text-sm">发布状态<select className={control} value={status} onChange={(event) => update('status', event.target.value)}><option value="">全部状态</option><option value="published">已发布</option><option value="revoked">已撤销</option></select></label>
</div>
<div className="flex flex-wrap items-center justify-between gap-3"><p role="status" className={muted}>当前结果:{visible.length} 项本地发布记录</p><Button variant="secondary" onClick={() => setParams({ view: 'local' })}>清除筛选</Button></div>
</div>
<section className={panel} aria-label="真实登记设备">
<h2 className="font-semibold">登记设备 · {devices.length}</h2>
{!devices.length && <p className={muted}>尚无登记设备。请在 GoodBuddy 应用中心连接此服务并登记设备。</p>}
{devices.filter((device) => !deviceId || device.id === deviceId).map((device) => <div key={device.id} className="space-y-1 break-words">
<h3 className="font-semibold">{device.name}</h3>
<p className={muted}>设备标识:{device.id} · 平台:{device.platform} · 应用版本:{device.appVersion}</p>
<p className={muted}>登记时间:<time dateTime={device.registeredAt}>{device.registeredAt}</time></p>
</div>)}
</section>
{!publications.length ? <div className={panel}><h2 className="font-semibold">尚无发布记录</h2><p className={muted}>请在 GoodBuddy 应用中心发布能力或知识库,再刷新目录。</p></div> : !visible.length && <div className={panel}><h2 className="font-semibold">没有符合条件的本地记录</h2><p className={muted}>请调整条件或清除筛选。</p></div>}
{visible.map((item) => <article key={item.id} className={`${panel} break-words`}>
<div className="flex flex-wrap items-center justify-between gap-3"><h2 className="min-w-0 font-semibold text-[var(--text-strong)]">{item.name}</h2><StatusBadge status={item.status === 'revoked' ? '已撤销' : '已发布'} tone={item.status === 'revoked' ? 'warning' : 'neutral'} /></div>
<p className={`${muted} whitespace-pre-wrap`}>{item.description || '未提供描述。'}</p>
<p className={muted}>类型:{item.kind === 'knowledge' ? '知识库' : '能力'} · 提供设备:{devices.find((device) => device.id === item.deviceId)?.name ?? item.deviceId}</p>
<p className={muted}>发布标识:{item.id} · 设备标识:{item.deviceId}</p>
<p className={muted}>来源声明:{item.sourceMode === 'server' ? '服务器' : '设备'}(仅描述来源,不代表内容已同步或可取用)</p>
<p className={muted}>发布时间:<time dateTime={item.createdAt}>{item.createdAt}</time> · 更新时间:<time dateTime={item.updatedAt}>{item.updatedAt}</time></p>
{item.kind === 'knowledge' && <div className="space-y-2"><h3 className="text-sm font-semibold">知识权限声明(各自独立,未执行授权校验)</h3><div className="flex flex-wrap gap-3 text-sm"><span>搜索:{item.permissions.search ? '声明允许' : '未声明允许'}</span><span>读取原文:{item.permissions.read ? '声明允许' : '未声明允许'}</span><span>下载:{item.permissions.download ? '声明允许' : '未声明允许'}</span></div></div>}
</article>)}
</>
})()}
</>
}