agent-118 排版引导: - 核心必填区: 高亮 Callout(各平台'只需填X'头条)+Token必填badge(红底)+自动获取旁注, 不再藏段落 - 高级选项折叠(Miuix arrow折叠): base_url/平台特定字段/额外 config(连接配置), 收起默认 - 各平台引导头条: kook'只需填bot_token其余自动获取'/telegram/qq(mode接入方式)/weixin/webhook 我补(主人原则: 连接配置 vs 业务策略分开): - 黑白名单(allow/deny groups/users)+限流(groupMsgPerMin/msgPerMin/maxMessages) 从'高级选项'抽离成独立'业务策略'折叠区(不参与端到端连接) - openConfig 重置 polOpen; 业务策略默认收起 - 拆 channelFields.ts(PlatformField/PLATFORM_FIELDS/PLATFORM_GUIDE) → Channels.tsx 454行<500红线合规 前端 build 绿; 敏感字段password掩码/不回显
455 lines
20 KiB
TypeScript
455 lines
20 KiB
TypeScript
import { useState } from "react";
|
||
import { useAsyncData } from "../../lib/useAsyncData";
|
||
import {
|
||
adminListChannels,
|
||
adminChannelOpen,
|
||
adminChannelClose,
|
||
adminChannelTest,
|
||
adminSaveChannelConfig,
|
||
adminDeleteChannelAccount,
|
||
} from "../../lib/api";
|
||
import type { AdminChannelRecord, AdminChannelAccount, ChannelTestResult } from "../../lib/types";
|
||
import { useAutoReload } from "../hooks";
|
||
import { useAuditStore } from "../audit";
|
||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||
import { Button, Surface, Tag } from "../../components/primitives";
|
||
import { Icon } from "../../components/Icon";
|
||
import { PLATFORM_FIELDS, PLATFORM_GUIDE } from "./channelFields";
|
||
|
||
/** Platform part of an adapter name ("kook:conf0" → "kook"). */
|
||
function platformOf(name: string): string {
|
||
return name.split(":")[0];
|
||
}
|
||
|
||
export function ChannelsModule(): React.ReactNode {
|
||
const { data, loading, error, reload } = useAsyncData(adminListChannels, []);
|
||
useAutoReload(reload, 10000);
|
||
const log = useAuditStore((s) => s.log);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [note, setNote] = useState<string | null>(null);
|
||
|
||
const [ownerId, setOwnerId] = useState("");
|
||
const [platform, setPlatform] = useState("");
|
||
const [group, setGroup] = useState(false);
|
||
const [verdict, setVerdict] = useState<ChannelTestResult | null>(null);
|
||
|
||
// Config form state (per selected platform).
|
||
const [cfgPlatform, setCfgPlatform] = useState("");
|
||
const [cfgId, setCfgId] = useState("");
|
||
const [token, setToken] = useState("");
|
||
const [baseURL, setBaseURL] = useState("");
|
||
// Platform-specific config values keyed by the adapter Config json tag
|
||
// (receive_mode/webhook_path/mode/listen_addr/webhook_url/... ). Rendered only
|
||
// for the currently selected platform's fields.
|
||
const [platVals, setPlatVals] = useState<Record<string, string>>({});
|
||
const [allowGroups, setAllowGroups] = useState("");
|
||
const [denyGroups, setDenyGroups] = useState("");
|
||
const [allowUsers, setAllowUsers] = useState("");
|
||
const [denyUsers, setDenyUsers] = useState("");
|
||
const [groupMsgPerMin, setGroupMsgPerMin] = useState("");
|
||
const [msgPerMin, setMsgPerMin] = useState("");
|
||
const [maxMessages, setMaxMessages] = useState("");
|
||
const [extraConfig, setExtraConfig] = useState("");
|
||
// Advanced-options fold; default closed (values persist in state regardless).
|
||
const [advOpen, setAdvOpen] = useState(false);
|
||
// Business-policy fold (blacklist/allow + rate limits) is separate from the
|
||
// connection config — it governs admission/de-noise, not the endpoint.
|
||
const [polOpen, setPolOpen] = useState(false);
|
||
|
||
const toggle = (name: string, connect: boolean) => {
|
||
setBusy(name);
|
||
setErr(null);
|
||
const call = connect ? adminChannelOpen(name) : adminChannelClose(name);
|
||
call
|
||
.then(() => {
|
||
log(connect ? "channel.open" : "channel.close", "ok");
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log(connect ? "channel.open" : "channel.close", "error");
|
||
setErr(e instanceof Error ? e.message : `${connect ? "连接" : "断开"}失败`);
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const test = () => {
|
||
if (!ownerId.trim()) {
|
||
setErr("邮箱/账号 id 不能为空");
|
||
return;
|
||
}
|
||
setBusy("__test__");
|
||
setErr(null);
|
||
adminChannelTest({ platform: platform.trim(), accountId: ownerId.trim(), isGroup: group })
|
||
.then((v) => {
|
||
log("channel.identity-test", "ok");
|
||
setVerdict(v);
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("channel.identity-test", "error");
|
||
setErr(e instanceof Error ? e.message : "身份测试失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const openConfig = (name: string) => {
|
||
setErr(null);
|
||
setNote(null);
|
||
const platform = platformOf(name);
|
||
setCfgPlatform(platform);
|
||
// Prefill from the backend-provided account (if already configured) so a
|
||
// saved config is visible again instead of the form opening blank. token is
|
||
// masked by the backend (never raw), so we only echo it as a hint.
|
||
const acct = (data ?? []).find((c) => platformOf(c.name) === platform)?.accounts?.[0];
|
||
setCfgId(acct?.id ?? "");
|
||
const cfg = acct?.config ?? {};
|
||
setToken(typeof cfg.bot_token === "string" && cfg.bot_token ? "(已配置,仅掩码显示)" : "");
|
||
setBaseURL(typeof cfg.base_url === "string" ? cfg.base_url : "");
|
||
const plat: Record<string, string> = {};
|
||
for (const f of PLATFORM_FIELDS[platform] ?? []) {
|
||
const v = cfg[f.key];
|
||
plat[f.key] = v == null ? "" : String(v);
|
||
}
|
||
setPlatVals(plat);
|
||
setAllowGroups((acct?.allowGroups ?? []).join(","));
|
||
setDenyGroups((acct?.denyGroups ?? []).join(","));
|
||
setAllowUsers((acct?.allowUsers ?? []).join(","));
|
||
setDenyUsers(acct?.denyUsers ? (Array.isArray(acct.denyUsers) ? acct.denyUsers.join(",") : "") : "");
|
||
setGroupMsgPerMin(cfg.groupMsgPerMin == null ? "" : String(cfg.groupMsgPerMin));
|
||
setMsgPerMin(cfg.msgPerMin == null ? "" : String(cfg.msgPerMin));
|
||
setMaxMessages(cfg.maxMessages == null ? "" : String(cfg.maxMessages));
|
||
setExtraConfig("");
|
||
setAdvOpen(false); // fold stays closed on open; advanced values are prefilled.
|
||
setPolOpen(false); // business-policy fold also starts closed.
|
||
};
|
||
|
||
const save = () => {
|
||
if (!cfgPlatform) return;
|
||
setBusy("__save__");
|
||
setErr(null);
|
||
setNote(null);
|
||
const toList = (s: string) => s.split(/[\n,]/).map((x) => x.trim()).filter(Boolean);
|
||
// Curate platform-specific config keys, then let an explicit extraConfig JSON
|
||
// override any of them; these json tags are what the backend DecodeConfig reads.
|
||
const specificMap: Record<string, unknown> = {};
|
||
for (const f of PLATFORM_FIELDS[cfgPlatform] ?? []) {
|
||
const v = (platVals[f.key] ?? "").trim();
|
||
if (v) specificMap[f.key] = v;
|
||
}
|
||
let configMap: Record<string, unknown> | undefined;
|
||
if (extraConfig.trim()) {
|
||
let extra: unknown;
|
||
try {
|
||
extra = JSON.parse(extraConfig);
|
||
} catch {
|
||
setErr("额外配置 JSON 格式有误");
|
||
setBusy(null);
|
||
return;
|
||
}
|
||
if (typeof extra !== "object" || extra === null || Array.isArray(extra)) {
|
||
setErr("额外配置 JSON 需为对象(如 {\"receive_mode\":\"ws\"})");
|
||
setBusy(null);
|
||
return;
|
||
}
|
||
configMap = { ...specificMap, ...(extra as Record<string, unknown>) };
|
||
} else if (Object.keys(specificMap).length) {
|
||
configMap = specificMap;
|
||
}
|
||
adminSaveChannelConfig(cfgPlatform, {
|
||
id: cfgId.trim() || undefined,
|
||
token: token.trim() || undefined,
|
||
baseURL: baseURL.trim() || undefined,
|
||
config: configMap,
|
||
allowGroups: toList(allowGroups),
|
||
denyGroups: toList(denyGroups),
|
||
allowUsers: toList(allowUsers),
|
||
denyUsers: toList(denyUsers),
|
||
groupMsgPerMin: groupMsgPerMin ? Number(groupMsgPerMin) : undefined,
|
||
msgPerMin: msgPerMin ? Number(msgPerMin) : undefined,
|
||
maxMessages: maxMessages ? Number(maxMessages) : undefined,
|
||
})
|
||
.then((r) => {
|
||
log("channel.save", "ok");
|
||
setNote(`已保存到 channels.json 并热更新 ${r.platform}(${r.path}),账号 ${r.accountId}`);
|
||
setCfgId("");
|
||
setToken("");
|
||
setBaseURL("");
|
||
setPlatVals({});
|
||
setAllowGroups("");
|
||
setDenyGroups("");
|
||
setAllowUsers("");
|
||
setDenyUsers("");
|
||
setGroupMsgPerMin("");
|
||
setMsgPerMin("");
|
||
setMaxMessages("");
|
||
setExtraConfig("");
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("channel.save", "error");
|
||
setErr(e instanceof Error ? e.message : "保存渠道配置失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const del = (platform: string, id: string) => {
|
||
setBusy("__del__" + id);
|
||
setErr(null);
|
||
setNote(null);
|
||
adminDeleteChannelAccount(platform, id)
|
||
.then(() => {
|
||
log("channel.delete", "ok");
|
||
setNote(`已删除账号 ${id} 并热更新 ${platform}`);
|
||
reload();
|
||
})
|
||
.catch((e: unknown) => {
|
||
log("channel.delete", "error");
|
||
setErr(e instanceof Error ? e.message : "删除渠道账号失败");
|
||
})
|
||
.finally(() => setBusy(null));
|
||
};
|
||
|
||
const columns: AdminColumn<AdminChannelRecord>[] = [
|
||
{ key: "name", title: "渠道", render: (c) => <span className="font-medium">{c.name}</span> },
|
||
{
|
||
key: "connected",
|
||
title: "状态",
|
||
render: (c) => (
|
||
<Tag tone={c.connected ? "ok" : "err"}>
|
||
<span className={`mr-1 inline-block h-1.5 w-1.5 rounded-full ${c.connected ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-err)]"}`} />
|
||
{c.connected ? "已连接" : "未连接"}
|
||
</Tag>
|
||
),
|
||
},
|
||
{
|
||
key: "accounts",
|
||
title: "已配账号",
|
||
render: (c) =>
|
||
c.accounts && c.accounts.length ? (
|
||
<span className="flex flex-wrap items-center gap-1">
|
||
{c.accounts.map((a) => {
|
||
// Show the connected bot's resolved identity (kook user/me) when the
|
||
// adapter fetched it, falling back to the account id.
|
||
const label = a.botNickname || (a.botUsername ? `@${a.botUsername}` : a.id || "default");
|
||
return (
|
||
<span key={a.id || "default"} className="flex items-center gap-1">
|
||
<Tag tone="neutral">{label}</Tag>
|
||
{a.id ? (
|
||
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => del(platformOf(c.name), a.id)}>
|
||
删除
|
||
</Button>
|
||
) : null}
|
||
</span>
|
||
);
|
||
})}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-[var(--mu-text-lo)]">—</span>
|
||
),
|
||
},
|
||
{
|
||
key: "actions",
|
||
title: "操作",
|
||
render: (c) => (
|
||
<span className="flex items-center gap-2">
|
||
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => openConfig(c.name)}>
|
||
配置
|
||
</Button>
|
||
<Button size="sm" variant="ghost" disabled={busy !== null || c.connected} onClick={() => toggle(c.name, true)}>
|
||
连接
|
||
</Button>
|
||
<Button size="sm" variant="ghost" disabled={busy !== null || !c.connected} onClick={() => toggle(c.name, false)}>
|
||
断开
|
||
</Button>
|
||
</span>
|
||
),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<PageHeader
|
||
title="渠道管理"
|
||
subtitle="GET/PUT/DELETE /api/v1/admin/channels · 配置(落盘 channels.json + 热更新) + 连接/断开(真实生命周期) + 身份识别"
|
||
actions={
|
||
<Button variant="soft" disabled={busy !== null} onClick={() => reload()}>
|
||
刷新
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
{err ? <Notice tone="err">{err}</Notice> : null}
|
||
{note ? <Notice tone="ok">{note}</Notice> : null}
|
||
|
||
<Surface className="space-y-2">
|
||
<AdminTable
|
||
columns={columns}
|
||
rows={loading ? null : data}
|
||
loading={loading}
|
||
error={error}
|
||
emptyTitle="无渠道数据"
|
||
rowKey={(c) => c.name}
|
||
/>
|
||
</Surface>
|
||
|
||
{cfgPlatform ? (
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">配置账号 → {cfgPlatform}</h3>
|
||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||
保存后写入 channels.json(本地、已 gitignore)并热更新 {cfgPlatform} 的渠道 adapter,无需重启进程。token 只在写文件时出现,列表/响应不回显裸值。
|
||
</p>
|
||
|
||
{/* 核心必填:最小必备字段 + 醒目 Callout / 高亮 Tag,不平铺全量 */}
|
||
<div className="space-y-2 rounded-[var(--mu-radius)] border border-[var(--mu-accent)]/25 bg-[var(--mu-surface-4)] p-3">
|
||
<div className="flex items-center gap-1.5 text-sm font-semibold text-[var(--mu-accent-hi)]">
|
||
<Icon name="check" size={16} className="text-[var(--mu-accent)]" />
|
||
核心必填
|
||
</div>
|
||
<div className="rounded-[var(--mu-radius)] bg-[var(--mu-accent)]/15 px-3 py-2 text-xs leading-relaxed text-[var(--mu-accent-hi)]">
|
||
{PLATFORM_GUIDE[cfgPlatform].guide}
|
||
</div>
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<Field label="账号 ID(留空自动生成)" value={cfgId} onChange={setCfgId} placeholder="conf0" />
|
||
<label className="block space-y-1">
|
||
<span className="flex items-center gap-1.5 text-xs text-[var(--mu-text-lo)]">
|
||
Token(bot_token)
|
||
{PLATFORM_GUIDE[cfgPlatform].tokenRequired !== false ? (
|
||
<span className="rounded-[var(--mu-radius-xs)] bg-[var(--mu-err)]/15 px-1.5 py-0.5 text-[10px] font-medium text-[var(--mu-err)]">必填</span>
|
||
) : null}
|
||
</span>
|
||
<input type="password" value={token} onChange={(e) => setToken(e.target.value)} placeholder="机器人 token" className={`${inputCls} border border-[var(--mu-accent)]/25`} />
|
||
</label>
|
||
</div>
|
||
{PLATFORM_GUIDE[cfgPlatform].hint ? (
|
||
<p className="text-xs leading-relaxed text-[var(--mu-text-lo)]">{PLATFORM_GUIDE[cfgPlatform].hint}</p>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 高级选项(连接配置):base_url / 平台特定字段 / 额外 config。业务策略单独一区。 */}
|
||
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => setAdvOpen((v) => !v)}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||
>
|
||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${advOpen ? "rotate-90" : ""}`}>
|
||
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
||
</span>
|
||
高级选项(base_url / 平台特定字段 / 额外 config)
|
||
</button>
|
||
{advOpen ? (
|
||
<div className="space-y-3 px-3 pb-3 pt-1">
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
<Field label="baseURL(地址)" value={baseURL} onChange={setBaseURL} placeholder="https://…" />
|
||
{(PLATFORM_FIELDS[cfgPlatform] ?? []).map((f) => (
|
||
<Field key={f.key} label={f.label} value={platVals[f.key] ?? ""} onChange={(v) => setPlatVals((prev) => ({ ...prev, [f.key]: v }))} placeholder={f.placeholder} secret={f.secret} />
|
||
))}
|
||
</div>
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">额外 config(可选 JSON,覆盖平台特有字段如 webhook_url/endpointPath/secret)</span>
|
||
<textarea value={extraConfig} onChange={(e) => setExtraConfig(e.target.value)} rows={2} placeholder='{"webhook_url":"…"}' className="w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]" />
|
||
</label>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{/* 业务策略(独立于连接配置):黑白名单 / 限流——管准入与降噪,不参与端到端连接。 */}
|
||
<div className="overflow-hidden rounded-[var(--mu-radius)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-2)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => setPolOpen((v) => !v)}
|
||
className="flex w-full items-center gap-2 px-3 py-2.5 text-xs font-medium text-[var(--mu-text-mid)] transition-colors duration-200 hover:bg-[var(--mu-surface-4)]/40"
|
||
>
|
||
<span className={`shrink-0 transition-transform duration-200 ease-[var(--mu-spring)] ${polOpen ? "rotate-90" : ""}`}>
|
||
<Icon name="arrow" size={13} className="text-[var(--mu-text-lo)]" />
|
||
</span>
|
||
业务策略(黑白名单 / 群聊限流——不参与连接配置)
|
||
</button>
|
||
{polOpen ? (
|
||
<div className="grid grid-cols-1 gap-2 px-3 pb-3 pt-1 sm:grid-cols-2">
|
||
<Field label="allowGroups(逗号分隔白名单)" value={allowGroups} onChange={setAllowGroups} />
|
||
<Field label="denyGroups(黑名单)" value={denyGroups} onChange={setDenyGroups} />
|
||
<Field label="allowUsers(白名单)" value={allowUsers} onChange={setAllowUsers} />
|
||
<Field label="denyUsers(黑名单)" value={denyUsers} onChange={setDenyUsers} />
|
||
<Field label="groupMsgPerMin(群聊限流,≤0 关闭)" value={groupMsgPerMin} onChange={setGroupMsgPerMin} inputMode="numeric" />
|
||
<Field label="msgPerMin" value={msgPerMin} onChange={setMsgPerMin} inputMode="numeric" />
|
||
<Field label="maxMessages" value={maxMessages} onChange={setMaxMessages} inputMode="numeric" />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="primary" disabled={busy !== null} onClick={save}>保存配置</Button>
|
||
<Button variant="ghost" disabled={busy !== null} onClick={() => setCfgPlatform("")}>取消</Button>
|
||
</div>
|
||
</Surface>
|
||
) : null}
|
||
|
||
<Surface className="space-y-3">
|
||
<h3 className="text-sm font-semibold">身份识别测试(结构性判定 · 绝非提示词)</h3>
|
||
<p className="text-xs text-[var(--mu-text-lo)]">
|
||
输入渠道确认的发送者账号 id,后端用 channel 层 actor 元数据 × 主人白名单(configs/users/owner.yaml)判定;判定绝不依赖提示词 / LLM 自我判断。
|
||
</p>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<input
|
||
value={platform}
|
||
onChange={(e) => setPlatform(e.target.value)}
|
||
placeholder="platform(如 weixin-ilink,可留空)"
|
||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<input
|
||
value={ownerId}
|
||
onChange={(e) => setOwnerId(e.target.value)}
|
||
placeholder="渠道账号 id(如 weixin-ilink 的 id)"
|
||
className="min-w-0 flex-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||
/>
|
||
<label className="flex items-center gap-1.5 text-xs text-[var(--mu-text-mid)]">
|
||
<input type="checkbox" checked={group} onChange={(e) => setGroup(e.target.checked)} className="accent-[var(--mu-accent)]" />
|
||
群聊
|
||
</label>
|
||
<Button variant="primary" disabled={busy !== null} onClick={test}>
|
||
测试
|
||
</Button>
|
||
</div>
|
||
{verdict ? (
|
||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-xs text-[var(--mu-text-mid)]">
|
||
<div>角色:{verdict.role} | 主人:{verdict.owner ? "是" : "否"} | 群聊:{verdict.isGroup ? "是" : "否"} | 环境:{verdict.env}</div>
|
||
<div>判定:{verdict.verdict}</div>
|
||
</div>
|
||
) : null}
|
||
</Surface>
|
||
|
||
<Notice>账号配置落盘 channels.json(本地、已 gitignore)并热更新运行中 adapter;token/密钥不回显、不打印,.env 为兜底。消息流预览、推送测试由后端 M10+ 提供。</Notice>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const inputCls =
|
||
"w-full rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||
|
||
/** Compact labeled input for the config form; `secret` masks the field. */
|
||
interface FieldProps {
|
||
label: string;
|
||
value: string;
|
||
onChange: (v: string) => void;
|
||
placeholder?: string;
|
||
secret?: boolean;
|
||
inputMode?: "text" | "numeric";
|
||
}
|
||
|
||
function Field({ label, value, onChange, placeholder, secret, inputMode = "text" }: FieldProps): React.ReactNode {
|
||
return (
|
||
<label className="block space-y-1">
|
||
<span className="text-xs text-[var(--mu-text-lo)]">{label}</span>
|
||
<input
|
||
type={secret ? "password" : "text"}
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
inputMode={inputMode}
|
||
className={inputCls}
|
||
/>
|
||
</label>
|
||
);
|
||
}
|