feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

后端(KOOK REST, 复用 httpclient WithAuthScheme(Bot), token不打印):
- kook/admin.go(新): ListFriends/HandleFriendRequest/DeleteFriend/BlockFriend/
  ListUserChats/CreateUserChat/ListDirectMessages
- kook/admin_guild.go(新): ListGuilds/ListGuildChannels/ListGuildUsers/ListChannelMsgs/SendTestMessage
- gateway/admin_channels_platform.go(新): kookAdmin(反射解包namedAdapter命中内嵌PlatformAdapter)
  + 4 handler + adminKookView聚合(子项失败记errors不伪造)
- server.go 注册 4 条 ownerAuth 路由: /view /friends /friend/handle /message/test
- admin.go/admin_write_channel_config.go: AdminChannelAccount 加 Capabilities
前端(KOOK 专属页):
- KookPanel.tsx(新): 好友&申请/服务器/频道/成员/私信会话/发消息测试卡片
- AdapterDetailPage.tsx: platform==='kook' 渲染 KookPanel, 否则通用
- types.ts/api.ts: KOOK 类型 + adminChannelPlatformView/Friends/FriendHandle/MessageTest
文档: Docs/api-client.md 新增4端点契约(owner-only)+capabilities+v0.17
验收: go build/vet/test ./... 全绿 + npm run build 97模块全绿; 零新依赖
注: api.ts/types.ts 集中契约文件>500行属既有结构(未新增违规); KOOK其余REST方法留能力面待用
This commit is contained in:
Aska committed 2026-09-07 18:19:31 +08:00
1 parent 8eaf81bd0d
commit 5ec4c9814d
13 files changed
+1714 -2

No files matched your search

@@ -6,6 +6,7 @@ import { Button, Surface, Tag } from "../../../components/primitives";
import { PLATFORM_LABEL } from "../channelFields";
import { ChannelLogList } from "./channelLogList";
import { ChannelIdentityTest } from "./channelIdentityTest";
import { KookPanel } from "./KookPanel";
import { platformOf } from "./channelUtils";
import { useChannelActions } from "./useChannelActions";
@@ -31,7 +32,11 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
<div className="space-y-5">
<PageHeader
title={`${label} · ${adapter}`}
subtitle="该配置专属页 · 实时日志 + 功能操作(完整配置字段见 P2)"
subtitle={
platform === "kook"
? "该配置专属页 · 连接状态 + KOOK 平台能力(好友/频道/私信/测试)+ 实时日志"
: "该配置专属页 · 实时日志 + 功能操作(完整配置字段见 P2)"
}
actions={
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={onBack}>
返回列表
@@ -69,6 +74,8 @@ export function AdapterDetailPage({ adapter, onBack }: { adapter: string; onBack
<ChannelIdentityTest defaultPlatform={platform} disabled={busy !== null} />
{platform === "kook" ? <KookPanel platform={platform} accountId={accountId} /> : null}
<div className="rounded-[var(--mu-radius-lg)] bg-[var(--mu-surface-3)] border border-[var(--mu-glass-border)] shadow-[var(--mu-shadow-1)] p-5">
<ChannelLogList adapter={adapter} limit={200} />
</div>
@@ -0,0 +1,280 @@
import { useState } from "react";
import {
adminChannelFriendHandle,
adminChannelMessageTest,
adminChannelPlatformView,
} from "../../../lib/api";
import type {
KookChannel,
KookFriendList,
KookGuild,
KookGuildUser,
KookUserChat,
} from "../../../lib/types";
import { useAsyncData } from "../../../lib/useAsyncData";
import { useAuditStore } from "../../audit";
import { Button, Surface, Tag } from "../../../components/primitives";
import { Notice } from "../../components";
/** KOOK per-adapter panel (design/17 §2): friends/requests, servers/channels/
* members, private chats, and a test-send card. It renders only the elements the
* platform's account actually supports and surfaces per-sub-fetch errors rather
* than fabricating data. Non-KOOK platforms fall back to the generic detail page. */
export function KookPanel({
platform,
accountId,
}: {
platform: string;
accountId?: string;
}): React.ReactNode {
const log = useAuditStore((s) => s.log);
const { data, loading, error, reload } = useAsyncData(
() => adminChannelPlatformView(platform, accountId),
[platform, accountId],
);
const [busy, setBusy] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const handleRequest = (id: number, accept: boolean) => {
setBusy(`req-${id}`);
setNotice(null);
adminChannelFriendHandle(platform, id, accept, accountId)
.then(() => {
log("channel.friend.handle", "ok");
setNotice(accept ? `已同意好友申请 #${id}` : `已拒绝好友申请 #${id}`);
reload();
})
.catch((e: unknown) => {
log("channel.friend.handle", "error");
setNotice(e instanceof Error ? e.message : "处理好友申请失败");
})
.finally(() => setBusy(null));
};
const sendTest = (target: string, content: string): Promise<string> => {
return adminChannelMessageTest(platform, target, content, accountId)
.then((r) => {
log("channel.message.test", "ok");
return `已发送,消息 id:${r.msg_id}`;
})
.catch((e: unknown) => {
log("channel.message.test", "error");
throw e;
});
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">KOOK 平台能力</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">owner 专属 · 已审计</span>
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">
GET /api/v1/admin/channels/{platform}/view
</span>
</div>
{error ? <Notice tone="err">{error}</Notice> : null}
{notice ? <Notice tone={notice.startsWith("已") || notice.includes("成功") ? "ok" : "err"}>{notice}</Notice> : null}
{loading ? <Notice>加载中…</Notice> : null}
{data ? (
<>
{Object.keys(data.errors ?? {}).length > 0 ? (
<Notice tone="warn">部分子项拉取失败:{Object.entries(data.errors ?? {}).map(([k, v]) => `${k}(${v})`).join(";")}</Notice>
) : null}
<KookFriendsCard friends={data.friends} busy={busy} onHandle={handleRequest} />
<KookGuildCard guilds={data.guilds} channels={data.channels} members={data.members} />
<KookChatsCard chats={data.userChats} />
<KookTestCard busy={busy !== null} onSend={sendTest} />
</>
) : null}
</div>
);
}
function KookFriendsCard({
friends,
busy,
onHandle,
}: {
friends?: KookFriendList;
busy: string | null;
onHandle: (id: number, accept: boolean) => void;
}): React.ReactNode {
const requests = friends?.requests ?? [];
const list = friends?.friends ?? [];
const blocked = friends?.blocked ?? [];
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">好友 & 申请</h3>
{requests.length === 0 && list.length === 0 ? (
<Notice>暂无好友/申请数据(未连接或账号无好友)。</Notice>
) : null}
{requests.length > 0 ? (
<div className="flex flex-wrap gap-2">
{requests.map((r) => (
<div key={r.id} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5">
<Tag tone="warn">申请</Tag>
<span className="text-sm">{r.friend.nickname || r.friend.username || r.friend.user_id}</span>
<span className="text-xs text-[var(--mu-text-lo)]">#{r.friend.user_id}</span>
<Button size="sm" variant="soft" disabled={busy !== null} onClick={() => onHandle(r.id, true)}>
同意
</Button>
<Button size="sm" variant="ghost" disabled={busy !== null} onClick={() => onHandle(r.id, false)}>
拒绝
</Button>
</div>
))}
</div>
) : null}
{list.length > 0 ? (
<>
<div className="text-xs text-[var(--mu-text-lo)]">好友列表({list.length})</div>
<div className="flex flex-wrap gap-2">
{list.slice(0, 40).map((f) => (
<span key={f.user_id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${f.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-text-lo)]"}`} />
{f.nickname || f.username || f.user_id}
</span>
))}
</div>
</>
) : null}
{blocked.length > 0 ? <div className="text-xs text-[var(--mu-text-lo)]">已屏蔽 {blocked.length} 人</div> : null}
</Surface>
);
}
function KookGuildCard({
guilds,
channels,
members,
}: {
guilds?: KookGuild[];
channels?: KookChannel[];
members?: KookGuildUser[];
}): React.ReactNode {
const hasChannels = (channels ?? []).length > 0 || (members ?? []).length > 0;
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">服务器 / 频道 / 成员</h3>
{!guilds || guilds.length === 0 ? (
<Notice>暂无服务器数据(bot 未加入任何服务器或未连接)。</Notice>
) : (
<div className="flex flex-wrap gap-2">
{guilds.map((g) => (
<span key={g.id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<Tag tone="accent">{g.name || g.id}</Tag>
<span className="text-[var(--mu-text-lo)]">{g.id}</span>
</span>
))}
</div>
)}
{hasChannels ? (
<div className="flex flex-wrap items-center gap-2">
<div className="text-xs text-[var(--mu-text-lo)]">频道(首个服务器)</div>
<div className="overflow-x-auto whitespace-nowrap">
{(channels ?? []).map((c) => (
<span key={c.id} className="mr-2 inline-flex items-center gap-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-3)] px-2 py-1 text-xs">
{c.is_category ? <Tag tone="warn">分组</Tag> : c.type === 2 ? <Tag tone="neutral">语音</Tag> : <Tag tone="neutral">文字</Tag>}
{c.name || c.id}
</span>
))}
</div>
</div>
) : null}
{members && members.length > 0 ? (
<>
<div className="text-xs text-[var(--mu-text-lo)]">成员(前 {members.length},首个服务器)</div>
<div className="flex flex-wrap gap-2">
{members.slice(0, 40).map((m) => (
<span key={m.user_id} className="flex items-center gap-1.5 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${m.online ? "bg-[var(--mu-ok)]" : "bg-[var(--mu-text-lo)]"}`} />
{m.nickname || m.username || m.user_id}
{m.bot ? <Tag tone="neutral">bot</Tag> : null}
</span>
))}
</div>
</>
) : null}
</Surface>
);
}
function KookChatsCard({ chats }: { chats?: KookUserChat[] }): React.ReactNode {
const list = chats ?? [];
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">私信会话</h3>
{list.length === 0 ? (
<Notice>暂无私信会话。</Notice>
) : (
<div className="overflow-x-auto">
<div className="min-w-[320px] space-y-1.5">
{list.map((c) => (
<div key={c.code} className="flex items-center gap-2 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-1.5">
<Tag tone="neutral">{c.target_name || c.target_id}</Tag>
<span className="text-xs text-[var(--mu-text-lo)]">code:{c.code}</span>
{c.unread_count > 0 ? <Tag tone="warn">{c.unread_count} 未读</Tag> : null}
<span className="ml-auto text-xs text-[var(--mu-text-lo)]">{new Date(c.latest_msg_time).toLocaleString()}</span>
</div>
))}
</div>
</div>
)}
<p className="text-xs text-[var(--mu-text-lo)]">仅列出会话;消息内容与发送请通过 /direct-message 或消息链路。</p>
</Surface>
);
}
function KookTestCard({
busy,
onSend,
}: {
busy: boolean;
onSend: (target: string, content: string) => Promise<string>;
}): React.ReactNode {
const [target, setTarget] = useState("");
const [content, setContent] = useState("");
const [sending, setSending] = useState(false);
const [result, setResult] = useState<string | null>(null);
const send = () => {
if (!target.trim() || !content.trim()) {
setResult("频道 target id 与内容均不能为空");
return;
}
setSending(true);
setResult(null);
onSend(target.trim(), content.trim())
.then(setResult)
.catch((e: unknown) => {
setResult(e instanceof Error ? e.message : "发送测试消息失败");
})
.finally(() => setSending(false));
};
return (
<Surface gap="sm" className="space-y-3">
<h3 className="text-sm font-semibold">发消息测试(POST /message/create)</h3>
<div className="flex flex-wrap items-center gap-2">
<input
value={target}
onChange={(e) => setTarget(e.target.value)}
placeholder="目标频道 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)]"
/>
<input
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="测试内容(kmarkdown)"
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)]"
/>
<Button variant="primary" disabled={busy || sending} onClick={send}>
{sending ? "发送中…" : "发送"}
</Button>
</div>
{result ? <Notice tone={result.includes("失败") || result.includes("不能为空") ? "err" : "ok"}>{result}</Notice> : null}
<p className="text-xs text-[var(--mu-text-lo)]">这条消息会真实发送到所选频道,用于验证 bot 可达性。</p>
</Surface>
);
}