feat(kook): P2 KOOK 专属页+后端 admin 端点(design/17 §2/§4)
后端(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:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user