缺口①:「连接」是按平台连、且恒打第一条账号(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 行按普通行渲染)。
163 lines
5.2 KiB
TypeScript
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>
|
|
);
|
|
}
|