feat(web): acrylic theme, node flags and icons, build info
Acrylic cards and topbar over a blurred background, country flags, ISP and OS icons, per-node version tags, a build info footer and an admin versions tab. Overview cards drop the sparkline and widen the hover area.
This commit is contained in:
1 parent
1e5c660d05
commit
83f54978d2
29 files changed
+1310
-143
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from './api/client'
|
||||
import type { NodeInfoDto, Page } from './api/types'
|
||||
import { useQuery } from './hooks'
|
||||
import { OsIcon } from './osIcon'
|
||||
import mobileIcon from './assets/isp/mobile.svg?url'
|
||||
import telecomIcon from './assets/isp/telecom.svg?url'
|
||||
import unicomIcon from './assets/isp/unicom.svg?url'
|
||||
|
||||
/**
|
||||
* 节点(Agent、Probe)的展示信息:国旗、运营商、系统和架构。
|
||||
* 后端只给国家代码、运营商名、系统名和架构——没有 IP,所以这里也画不出 IP。
|
||||
*/
|
||||
|
||||
/** 所有国旗的 SVG,按需加载:用到哪个国家才会下载哪个文件。 */
|
||||
const flagLoaders = import.meta.glob<string>('../node_modules/flag-icons/flags/4x3/*.svg', { query: '?url', import: 'default' })
|
||||
|
||||
const regionNames = typeof Intl !== 'undefined' && 'DisplayNames' in Intl ? new Intl.DisplayNames(['zh-CN'], { type: 'region' }) : undefined
|
||||
|
||||
export function regionName(code: string): string {
|
||||
try {
|
||||
return regionNames?.of(code.toUpperCase()) ?? code
|
||||
} catch {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
export function Flag({ code }: { code: string }) {
|
||||
const [url, setUrl] = useState<string>()
|
||||
useEffect(() => {
|
||||
const load = flagLoaders[`../node_modules/flag-icons/flags/4x3/${code.toLowerCase()}.svg`]
|
||||
if (!load) {
|
||||
setUrl(undefined)
|
||||
return
|
||||
}
|
||||
let alive = true
|
||||
void load().then((u) => alive && setUrl(u))
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [code])
|
||||
const name = regionName(code)
|
||||
if (!url) return <span className="flag ph" title={name} />
|
||||
return <img className="flag" src={url} alt={name} title={name} />
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营商的小图标:三大运营商用它们各自的标志(只取标志,不带文字),广电没有现成的文件,用品牌色方块加一个字代替;
|
||||
* 别的运营商没有图标,只显示名字。这些标志是各公司的商标,只用来标明「这是哪家运营商」。
|
||||
*/
|
||||
const ISP_ICON: Record<string, string> = { mobile: mobileIcon, telecom: telecomIcon, unicom: unicomIcon }
|
||||
const ISP_GLYPH: Record<string, string> = { broadnet: '广' }
|
||||
|
||||
export function IspIcon({ ispKey, name }: { ispKey: string; name?: string | null }) {
|
||||
const icon = ISP_ICON[ispKey]
|
||||
if (icon) return <img className="isp logo" src={icon} alt={name ?? ispKey} title={name ?? ispKey} />
|
||||
const glyph = ISP_GLYPH[ispKey]
|
||||
if (!glyph) return null
|
||||
return (
|
||||
<svg className={`isp isp-${ispKey}`} viewBox="0 0 16 16" role="img" aria-label={name ?? ispKey}>
|
||||
<title>{name ?? ispKey}</title>
|
||||
<rect width="16" height="16" rx="4" />
|
||||
<text x="8" y="12.2" textAnchor="middle">
|
||||
{glyph}
|
||||
</text>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** 节点的展示信息,按节点 id 查:`useNodeInfos().get(id)`。每分钟刷新一次。 */
|
||||
export function useNodeInfos(): Map<string, NodeInfoDto> {
|
||||
const { data } = useQuery(() => api.get<Page<NodeInfoDto>>('/api/public/nodes'), [], 60_000)
|
||||
return useMemo(() => new Map((data?.items ?? []).map((n) => [n.id, n])), [data])
|
||||
}
|
||||
|
||||
/** 系统和架构:「Debian 12 · arm64」。 */
|
||||
export const platformText = (info: NodeInfoDto | undefined) => [info?.os_name, info?.arch].filter(Boolean).join(' · ')
|
||||
|
||||
interface TagProps {
|
||||
info: NodeInfoDto | undefined
|
||||
/** 不写系统和架构,只留国旗和运营商。 */
|
||||
geoOnly?: boolean
|
||||
}
|
||||
|
||||
/** 国旗 + 运营商(图标,或者别的运营商的名字)+ 系统和架构。什么都没有就什么也不画。 */
|
||||
export function NodeTag({ info, geoOnly }: TagProps) {
|
||||
if (!info) return null
|
||||
const platform = geoOnly ? '' : platformText(info)
|
||||
if (!info.country && !info.isp_name && !platform) return null
|
||||
const title = [info.country ? regionName(info.country) : null, info.isp_name, platformText(info)].filter(Boolean).join(' · ')
|
||||
return (
|
||||
<span className="node-tag" title={title}>
|
||||
{info.country ? <Flag code={info.country} /> : null}
|
||||
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
|
||||
{info.isp_key === 'other' && info.isp_name ? <span className="isp-name">{info.isp_name}</span> : null}
|
||||
{platform ? (
|
||||
<span className="plat">
|
||||
<OsIcon os={info.os} name={info.os_name} />
|
||||
{platform}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user