ci/woodpecker/push/main-amd64-build Pipeline was successful
Namespaces, assemblies, solution, binaries, images, systemd units, config paths, environment variables, protocol methods and the Linux user are all renamed. Breaking: existing databases, node identities and installed units must be recreated.
511 lines
19 KiB
TypeScript
511 lines
19 KiB
TypeScript
import { Check, Copy, Moon, Plug, Plus, Sun, X } from 'lucide-react'
|
||
import { createPortal } from 'react-dom'
|
||
import { Component, createContext, useCallback, useContext, useEffect, useRef, useState, type ErrorInfo, type ReactNode } from 'react'
|
||
import { ApiError, loginTo } from '../api/client'
|
||
import { activateSource, getBase, listSources, normalizeBase, pingBackend, removeSource, setSource } from '../api/source'
|
||
import { useQuery, useTheme } from '../hooks'
|
||
import { translateCode, translateField, translateMessage } from '../i18n'
|
||
|
||
/** 连接式分组(单选):组内小圆角、外角大圆角,选中段变胶囊。 */
|
||
export function Segmented<T extends string>({ options, value, onChange }: { options: { value: T; label: ReactNode; disabled?: boolean; title?: string }[]; value: T; onChange: (v: T) => void }) {
|
||
return (
|
||
<div className="group" role="tablist">
|
||
{options.map((o) => (
|
||
<button key={o.value} type="button" role="tab" aria-selected={o.value === value} className={o.value === value ? 'on' : ''} disabled={o.disabled} title={o.title} onClick={() => onChange(o.value)}>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Chip({ tone = '', children }: { tone?: 'ok' | 'warn' | 'bad' | 'accent' | ''; children: ReactNode }) {
|
||
return <span className={`chip ${tone}`}>{children}</span>
|
||
}
|
||
|
||
export function Dot({ tone = '' }: { tone?: 'ok' | 'warn' | 'bad' | '' }) {
|
||
return <span className={`dot ${tone}`} />
|
||
}
|
||
|
||
export function Spinner() {
|
||
return <span className="spinner" role="status" aria-label="加载中" />
|
||
}
|
||
|
||
export function Empty({ children }: { children: ReactNode }) {
|
||
return <div className="empty">{children}</div>
|
||
}
|
||
|
||
export function Stat({ label, value, unit, tone = '' }: { label: string; value: ReactNode; unit?: string; tone?: 'ok' | 'warn' | 'bad' | '' }) {
|
||
return (
|
||
<div className="stat">
|
||
<span className="k">{label}</span>
|
||
<span className={`v ${tone}`}>
|
||
{value}
|
||
{unit ? <small>{unit}</small> : null}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||
return (
|
||
<div className="field">
|
||
<span className="label">{label}</span>
|
||
{children}
|
||
{hint ? <span className="hint">{hint}</span> : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Dialog({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose()
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [onClose])
|
||
// 渲染到 body 上:对话框是 position: fixed 的,如果放在带 backdrop-filter / transform 的祖先里(比如展示端的顶栏),
|
||
// 它会以那个祖先为定位基准,被挤到顶栏里、一半在屏幕外
|
||
return createPortal(
|
||
<div className="scrim" onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
|
||
<div className="dialog" role="dialog" aria-modal="true" aria-label={title}>
|
||
<h2>{title}</h2>
|
||
{children}
|
||
{actions ? <div className="actions">{actions}</div> : null}
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
)
|
||
}
|
||
|
||
/** 错误提示:把常见的错误码、字段名和校验信息翻成中文;没有收录的信息原样显示。 */
|
||
export function describeError(e: unknown): string {
|
||
if (e instanceof ApiError) {
|
||
const fields = Object.entries(e.errors).flatMap(([field, messages]) => messages.map((m) => `${translateField(field)}:${translateMessage(m)}`))
|
||
if (fields.length > 0) return fields.join(';')
|
||
const byCode = translateCode(e.code)
|
||
// 有具体说明的(例如“要覆盖的模板不存在”)优先给说明,翻不出来就用错误码对应的话
|
||
const detail = e.detail ? translateMessage(e.detail) : ''
|
||
if (detail && detail !== e.detail) return detail
|
||
return byCode ?? (detail || e.code)
|
||
}
|
||
return e instanceof Error ? e.message : String(e)
|
||
}
|
||
|
||
export function ErrorNotice({ error }: { error: unknown }) {
|
||
if (!error) return null
|
||
const privateSite = error instanceof ApiError && error.code === 'site_private'
|
||
return <div className={`notice ${privateSite ? '' : 'bad'}`}>{describeError(error)}</div>
|
||
}
|
||
|
||
/* ── 提示 ── */
|
||
interface ToastItem {
|
||
id: number
|
||
text: string
|
||
bad: boolean
|
||
}
|
||
|
||
const ToastContext = createContext<(text: string, bad?: boolean) => void>(() => undefined)
|
||
|
||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||
const [items, setItems] = useState<ToastItem[]>([])
|
||
const nextId = useRef(1)
|
||
const push = useCallback((text: string, bad = false) => {
|
||
const id = nextId.current++
|
||
setItems((list) => [...list, { id, text, bad }])
|
||
window.setTimeout(() => setItems((list) => list.filter((t) => t.id !== id)), bad ? 6000 : 3000)
|
||
}, [])
|
||
return (
|
||
<ToastContext.Provider value={push}>
|
||
{children}
|
||
<div className="toasts" aria-live="polite">
|
||
{items.map((t) => (
|
||
<div key={t.id} className={`toast ${t.bad ? 'bad' : ''}`}>
|
||
{t.text}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ToastContext.Provider>
|
||
)
|
||
}
|
||
|
||
export const useToast = () => useContext(ToastContext)
|
||
|
||
/** 执行一个操作,成功提示、失败显示错误。 */
|
||
export function useAction() {
|
||
const toast = useToast()
|
||
const [busy, setBusy] = useState(false)
|
||
const run = useCallback(
|
||
async (action: () => Promise<unknown>, done?: string) => {
|
||
setBusy(true)
|
||
try {
|
||
await action()
|
||
if (done) toast(done)
|
||
return true
|
||
} catch (e) {
|
||
toast(describeError(e), true)
|
||
return false
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
},
|
||
[toast],
|
||
)
|
||
return { busy, run }
|
||
}
|
||
|
||
/** 确认对话框。 */
|
||
export function Confirm({ title, children, confirmText = '确定', danger, onConfirm, onClose }: { title: string; children: ReactNode; confirmText?: string; danger?: boolean; onConfirm: () => void | Promise<void>; onClose: () => void }) {
|
||
const [busy, setBusy] = useState(false)
|
||
return (
|
||
<Dialog
|
||
title={title}
|
||
onClose={onClose}
|
||
actions={
|
||
<>
|
||
<button type="button" className="btn text" onClick={onClose}>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`btn ${danger ? 'danger' : 'primary'}`}
|
||
disabled={busy}
|
||
onClick={async () => {
|
||
setBusy(true)
|
||
try {
|
||
await onConfirm()
|
||
} finally {
|
||
setBusy(false)
|
||
onClose()
|
||
}
|
||
}}
|
||
>
|
||
{confirmText}
|
||
</button>
|
||
</>
|
||
}
|
||
>
|
||
<div className="muted">{children}</div>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
export function CopyButton({ text, label = '复制' }: { text: string; label?: string }) {
|
||
const [done, setDone] = useState(false)
|
||
return (
|
||
<button
|
||
type="button"
|
||
className="btn small"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text)
|
||
setDone(true)
|
||
window.setTimeout(() => setDone(false), 1500)
|
||
} catch {
|
||
// 没有剪贴板权限时用户可以手动选中
|
||
}
|
||
}}
|
||
>
|
||
{done ? <Check size={14} /> : <Copy size={14} />}
|
||
{done ? '已复制' : label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
export function ThemeToggle() {
|
||
const [theme, toggle] = useTheme()
|
||
return (
|
||
<button type="button" className="btn icon" onClick={toggle} aria-label="切换明暗主题" title="切换明暗主题">
|
||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 组件上的加载进度条:放在卡片(`position: relative`)里,贴着上边缘。
|
||
* 数据在加载时流动,不加载时不占地方。
|
||
*/
|
||
export function LoadingBar({ active }: { active: boolean }) {
|
||
return active ? (
|
||
<div className="loading-bar" role="progressbar" aria-label="加载中">
|
||
<i />
|
||
</div>
|
||
) : null
|
||
}
|
||
|
||
export interface CommandTab {
|
||
id: string
|
||
label: string
|
||
command: string
|
||
}
|
||
|
||
/** 按浏览器所在的系统挑一个默认的标签(Windows / macOS / 其他当 Linux)。 */
|
||
export function guessPlatform(): 'windows' | 'macos' | 'linux' {
|
||
const text = `${navigator.platform} ${navigator.userAgent}`.toLowerCase()
|
||
return text.includes('win') ? 'windows' : text.includes('mac') ? 'macos' : 'linux'
|
||
}
|
||
|
||
/**
|
||
* 带标签切换的命令框(curl / npm / bun 那样):上面一排下划线标签,下面是等宽的命令,右侧一个复制图标。
|
||
* 记住上次选的标签(只是浏览器里的小方便)。
|
||
*/
|
||
export function CommandTabs({ tabs, initial, storageKey }: { tabs: CommandTab[]; initial?: string; storageKey?: string }) {
|
||
const [active, setActive] = useState(() => {
|
||
let saved: string | null = null
|
||
try {
|
||
saved = storageKey ? localStorage.getItem(storageKey) : null
|
||
} catch {
|
||
// 浏览器禁用存储时只是不记住
|
||
}
|
||
return [saved, initial].find((id) => id && tabs.some((t) => t.id === id)) ?? tabs[0]?.id ?? ''
|
||
})
|
||
const [done, setDone] = useState(false)
|
||
const tab = tabs.find((t) => t.id === active) ?? tabs[0]
|
||
if (!tab) return null
|
||
const pick = (id: string) => {
|
||
setActive(id)
|
||
try {
|
||
if (storageKey) localStorage.setItem(storageKey, id)
|
||
} catch {
|
||
// 同上
|
||
}
|
||
}
|
||
return (
|
||
<div className="cmd-tabs">
|
||
<div className="cmd-tablist" role="tablist">
|
||
{tabs.map((t) => (
|
||
<button key={t.id} type="button" role="tab" aria-selected={t.id === tab.id} className={t.id === tab.id ? 'on' : ''} onClick={() => pick(t.id)}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="cmd-body">
|
||
<code>{tab.command}</code>
|
||
<button
|
||
type="button"
|
||
className="cmd-copy"
|
||
aria-label="复制命令"
|
||
title="复制命令"
|
||
onClick={async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(tab.command)
|
||
setDone(true)
|
||
window.setTimeout(() => setDone(false), 1500)
|
||
} catch {
|
||
// 没有剪贴板权限时用户可以手动选中
|
||
}
|
||
}}
|
||
>
|
||
{done ? <Check size={16} /> : <Copy size={16} />}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 页面出错时显示提示,而不是整页白屏:说明出了什么错,可以重试(重新渲染这一块)或刷新整个页面。
|
||
* 常见原因是前端和后端版本对不上(后端还没升级到新接口)。
|
||
*/
|
||
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||
state: { error: Error | null } = { error: null }
|
||
|
||
static getDerivedStateFromError(error: Error) {
|
||
return { error }
|
||
}
|
||
|
||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||
console.error(error, info.componentStack)
|
||
}
|
||
|
||
render() {
|
||
const { error } = this.state
|
||
if (!error) return this.props.children
|
||
return (
|
||
<div className="login">
|
||
<div className="card stack" style={{ maxWidth: 520 }}>
|
||
<div>
|
||
<h1>页面出错了</h1>
|
||
<p className="muted">这一页没能显示出来。如果刚升级过,多半是前端和后端的版本对不上:先把后端升级到对应的版本,再刷新。</p>
|
||
</div>
|
||
<pre className="code">{error.message}</pre>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button type="button" className="btn primary" onClick={() => this.setState({ error: null })}>
|
||
重试
|
||
</button>
|
||
<button type="button" className="btn" onClick={() => location.reload()}>
|
||
刷新页面
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
}
|
||
|
||
/** 地址的简短写法:去掉协议,留 host(和端口)。 */
|
||
const shortHost = (base: string) => base.replace(/^https?:\/\//i, '')
|
||
|
||
/**
|
||
* 数据源:默认连这个页面所在的后端(同源);也可以保存几个别的后端(比如本机跑前端、连线上的),在列表里点一下就切换。
|
||
* 跨域时不用 Cookie,改用 API 令牌,所以每个后端各存一个令牌(只在浏览器里),后端需要放行这个页面的来源(`MoShiro__CorsOrigins`)。
|
||
*/
|
||
export function SourceButton({ variant = 'icon' }: { variant?: 'icon' | 'status' }) {
|
||
const [open, setOpen] = useState(false)
|
||
const [adding, setAdding] = useState(listSources().length === 0)
|
||
const [base, setBase] = useState('')
|
||
const [username, setUsername] = useState('')
|
||
const [password, setPassword] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [, refresh] = useState(0)
|
||
const active = getBase()
|
||
const saved = listSources()
|
||
const switchTo = (next: string) => {
|
||
if (next !== active) {
|
||
activateSource(next)
|
||
location.reload()
|
||
}
|
||
}
|
||
// 状态条用:每 15 秒问一下当前后端还连不连得上
|
||
const health = useQuery(() => (variant === 'status' ? pingBackend() : Promise.resolve(undefined)), [variant, active], variant === 'status' ? 15_000 : undefined)
|
||
const state = health.data === false ? 'bad' : health.data === undefined ? 'wait' : 'ok'
|
||
const rows = saved.map((x) => ({ base: x.base, label: shortHost(x.base), detail: x.token ? '已登录' : '未登录' }))
|
||
// 连接中的排在最前面
|
||
rows.sort((a, b) => Number(b.base === active) - Number(a.base === active))
|
||
return (
|
||
<>
|
||
{variant === 'status' ? (
|
||
<button type="button" className={`src-status ${state}`} onClick={() => setOpen(true)} aria-label="数据源" title={`后端:${active || location.origin}(点击切换)`}>
|
||
<span className="st-dot" />
|
||
<span className="st-host">{shortHost(active || location.origin)}</span>
|
||
<span className="st">{state === 'ok' ? '已连接' : state === 'bad' ? '无法连接' : '连接中…'}</span>
|
||
</button>
|
||
) : (
|
||
<button type="button" className={`btn icon${active ? ' primary' : ''}`} onClick={() => setOpen(true)} aria-label="数据源" title={active ? `后端:${active}` : `后端:${location.origin}`}>
|
||
<Plug size={18} />
|
||
</button>
|
||
)}
|
||
{open ? (
|
||
<Dialog
|
||
title="数据源"
|
||
onClose={() => setOpen(false)}
|
||
actions={
|
||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||
关闭
|
||
</button>
|
||
}
|
||
>
|
||
<div className="stack">
|
||
{rows.length > 0 ? (
|
||
<div className="src-list" role="listbox" aria-label="已保存的后端">
|
||
{rows.map((row) => {
|
||
const on = row.base === active
|
||
return (
|
||
<div key={row.base || 'same-origin'} className={`src-row${on ? ' on' : ''}`}>
|
||
<button type="button" role="option" aria-selected={on} className="src-pick" onClick={() => switchTo(row.base)} title={on ? '正在连接' : '点击切换到这个后端'}>
|
||
<span className="src-dot" />
|
||
<span className="src-text">
|
||
<b>{row.label}</b>
|
||
<small>{row.detail}</small>
|
||
</span>
|
||
{on ? <span className="chip accent">连接中</span> : null}
|
||
</button>
|
||
{row.base ? (
|
||
<button
|
||
type="button"
|
||
className="btn icon small"
|
||
aria-label="忘掉这个后端"
|
||
title={on ? '断开并忘掉这个后端' : '忘掉这个后端'}
|
||
onClick={() => {
|
||
removeSource(row.base)
|
||
if (on) location.reload()
|
||
else refresh((n) => n + 1)
|
||
}}
|
||
>
|
||
<X size={14} />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<p className="muted">还没有保存后端。填上后端地址和用户名、密码,就可以连上去;不填则使用 {location.origin} 的 API。</p>
|
||
)}
|
||
{adding ? (
|
||
<div className="stack">
|
||
<Field label="后端地址" hint={base && normalizeBase(base) !== base.trim() ? `将使用 ${normalizeBase(base)}` : '例如 https://mo.oeo.one'}>
|
||
<input className="input" name="backend-url" type="url" autoComplete="url" value={base} onChange={(e) => setBase(e.target.value)} placeholder="https://mo.oeo.one" autoFocus />
|
||
</Field>
|
||
<Field label="用户名" hint="站点公开、只想看数据的话可以留空">
|
||
<input className="input" name="backend-username" autoComplete="off" value={username} onChange={(e) => setUsername(e.target.value)} />
|
||
</Field>
|
||
<Field label="密码">
|
||
<input className="input" name="backend-password" type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||
</Field>
|
||
{error ? <div className="notice bad">{error}</div> : null}
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button
|
||
type="button"
|
||
className="btn primary"
|
||
disabled={!normalizeBase(base) || busy}
|
||
onClick={async () => {
|
||
setBusy(true)
|
||
setError('')
|
||
try {
|
||
// 填了用户名就先登录(拿回会话存起来),登录成功才保存这个后端
|
||
setSource(base, username ? await loginTo(normalizeBase(base), username, password) : '')
|
||
location.reload()
|
||
} catch (err) {
|
||
setError(describeError(err))
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}}
|
||
>
|
||
保存并连接
|
||
</button>
|
||
{rows.length > 0 ? (
|
||
<button type="button" className="btn" onClick={() => setAdding(false)}>
|
||
取消
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div>
|
||
<button
|
||
type="button"
|
||
className="btn small"
|
||
onClick={() => {
|
||
setBase('')
|
||
setUsername('')
|
||
setPassword('')
|
||
setError('')
|
||
setAdding(true)
|
||
}}
|
||
>
|
||
<Plus size={14} />
|
||
添加后端
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Dialog>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 下载文本文件(probe.json 只给一次)。 */
|
||
export function downloadText(filename: string, text: string) {
|
||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }))
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = filename
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|