Files
Cyrene_v2/frontend/web/src/components/primitives.tsx
T
AskaEth 42279b7124 feat(channels): 按账号连接/断开 + 可删空 id 条目(修主人真机踩的两个缺口)
缺口①:「连接」是按平台连、且恒打第一条账号(channelActionRequest 只有 name)⇒ 主人有两条
qq-obv11 时点到的是 mode=server 的历史条目,报 server mode requires listenAddr,而正确的
client 条目轮不到。缺口②:空 id 条目既删不掉(DELETE 要求 id 非空)也改不了(UpsertChannel
空 id = 新建),越改越多。

后端:
- POST /admin/channels/open|close 请求体新增可选 accountId(缺省逐字兼容,老客户端零影响);
  按该账号驱动;未知 id ⇒ 404 且零连接(不静默回落第一条);已配置未注册 ⇒ 409
- DELETE /admin/channels/{platform}/config 新增显式 empty=1(id 与 empty=1 互斥 ⇒ 400;
  无可删 ⇒ 404);语义严格 —— 只删空 id 条目,绝不把「id 为空」默认解释成删空 id
- 响应新增:open/close 的 resolved(实际驱动的适配器名)与 accountId;delete 的 removed/empty;
  GET /admin/channels 的 accounts[].connected(可选 bool,缺省 = 未知)
- 审计动作名不变(channel.open/close/delete),日志行新增 accountId 字段
前端:每条账号一行「连接/断开」(带 accountId);按钮旁明确「会连哪一条」;空 id 行显著标注
+ 删除入口;channelActionBody 携带 accountId;新增 action 测试 33 checks + 空 id 行渲染 31 checks。
测试:后端 14 个用例(真实 HTTP 路由 + JWT + 真注册表 + spy 适配器,断言「哪一条被驱动、
另一条一次都没被碰」)。
变异 6 条(accountId 被忽略 / id 为空当删空 id / 找不到静默回落 / 前端丢 accountId /
删空 id 退化成 ?id= / 空 id 行按普通行渲染)。
2026-10-05 10:37:56 +08:00

163 lines
5.2 KiB
TypeScript

import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
/** Miuix-style primitives: Button / Tag / Switch / Spinner / Surface / Empty.
* Springs are applied only to interactive elements (cubic-bezier(0.34,1.56,0.64,1)). */
type ButtonVariant = "primary" | "soft" | "ghost" | "outline" | "danger";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: "sm" | "md";
children: ReactNode;
}
const buttonVariant: Record<ButtonVariant, string> = {
primary: "bg-[var(--mu-accent)] text-white hover:bg-[var(--mu-accent-hi)] shadow-[var(--mu-shadow-2)]",
soft: "bg-[var(--mu-surface-4)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-5)]",
ghost: "text-[var(--mu-text-mid)] hover:bg-[var(--mu-surface-4)]",
outline:
"border border-[var(--mu-glass-border)] text-[var(--mu-text-hi)] hover:bg-[var(--mu-surface-4)]",
danger: "bg-[var(--mu-err)] text-white",
};
export function Button({
variant = "soft",
size = "md",
className = "",
children,
...rest
}: ButtonProps): React.ReactNode {
const pad = size === "sm" ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium transition-all duration-200 ease-[var(--mu-spring)] active:scale-[0.97] disabled:opacity-50 disabled:pointer-events-none ${pad} ${buttonVariant[variant]} ${className}`}
{...rest}
>
{children}
</button>
);
}
export type TagTone = "accent" | "ok" | "warn" | "err" | "neutral";
export interface TagProps {
tone?: TagTone;
children: ReactNode;
className?: string;
/** 原生 title:给标签补充「为什么」的悬停说明(如无 id 条目的后果)。 */
title?: string;
}
const tagTone: Record<TagTone, string> = {
accent: "bg-[var(--mu-accent)]/15 text-[var(--mu-accent-hi)]",
ok: "bg-[var(--mu-ok)]/15 text-[var(--mu-ok)]",
warn: "bg-[var(--mu-warn)]/15 text-[var(--mu-warn)]",
err: "bg-[var(--mu-err)]/15 text-[var(--mu-err)]",
neutral: "bg-[var(--mu-surface-4)] text-[var(--mu-text-mid)]",
};
export function Tag({ tone = "neutral", children, className = "", title }: TagProps): React.ReactNode {
return (
<span
title={title}
className={`inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] px-2 py-0.5 text-xs font-medium ${tagTone[tone]} ${className}`}
>
{children}
</span>
);
}
export interface SwitchProps {
checked: boolean;
onChange: (next: boolean) => void;
disabled?: boolean;
label?: string;
}
export function Switch({ checked, onChange, disabled, label }: SwitchProps): React.ReactNode {
return (
<label
className={`flex items-center gap-2 select-none ${disabled ? "opacity-50 pointer-events-none" : ""}`}
>
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={() => onChange(!checked)}
className={`relative h-6 w-11 shrink-0 rounded-[var(--mu-radius-pill)] transition-colors duration-200 ${
checked ? "bg-[var(--mu-accent)]" : "bg-[var(--mu-surface-5)]"
}`}
>
<span
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-md transition-transform duration-300 ease-[var(--mu-spring)] ${
checked ? "translate-x-[22px]" : "translate-x-0.5"
}`}
/>
</button>
{label ? <span className="text-sm text-[var(--mu-text-mid)]">{label}</span> : null}
</label>
);
}
export interface SpinnerProps {
size?: number;
label?: string;
}
export function Spinner({ size = 18, label }: SpinnerProps): React.ReactNode {
return (
<span className="inline-flex items-center gap-2 text-[var(--mu-text-lo)]">
<span
className="inline-block animate-spin rounded-full border-2 border-[var(--mu-surface-5)] border-t-[var(--mu-accent)]"
style={{ width: size, height: size }}
/>
{label ? <span className="text-xs">{label}</span> : null}
</span>
);
}
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement> {
glass?: boolean;
liquid?: boolean;
gap?: "none" | "sm" | "md";
children: ReactNode;
}
export function Surface({
glass = false,
liquid = false,
gap = "md",
className = "",
children,
...rest
}: SurfaceProps): React.ReactNode {
const pad = gap === "none" ? "" : gap === "sm" ? "p-3" : "p-5";
const base = glass
? "glass"
: liquid
? "liquid-glass"
: "bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)]";
return (
<div className={`rounded-[var(--mu-radius-lg)] ${base} ${pad} ${className}`} {...rest}>
{children}
</div>
);
}
export interface EmptyStateProps {
icon?: ReactNode;
title: string;
hint?: string;
}
export function EmptyState({ icon, title, hint }: EmptyStateProps): React.ReactNode {
return (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-center">
{icon ? <div className="text-3xl opacity-60">{icon}</div> : null}
<div className="text-sm font-medium text-[var(--mu-text-mid)]">{title}</div>
{hint ? <div className="text-xs text-[var(--mu-text-lo)]">{hint}</div> : null}
</div>
);
}