Add unified IM channel plugin

This commit is contained in:
xmanrui 2026-08-15 04:39:56 +08:00
commit b8bd681a79
26 changed files with 7901 additions and 0 deletions

View file

@ -0,0 +1,38 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
const sourceDirectory = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(sourceDirectory, '../..');
const outputPath = resolve(packageRoot, 'lib/client.js');
const loaderId = process.env.DSH_IM_CLIENT_ID ?? '@xmanrui/dsh-im';
const result = await build({
entryPoints: [resolve(sourceDirectory, 'index.js')],
bundle: true,
format: 'cjs',
platform: 'browser',
target: ['chrome100'],
external: ['react'],
write: false,
minify: process.env.NODE_ENV === 'production',
legalComments: 'none',
});
const bundled = result.outputFiles?.[0]?.text;
if (!bundled) throw new Error('esbuild did not produce a client bundle');
const wrapped = `window.__ModuleLoader__.load({
id: ${JSON.stringify(loaderId)},
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
${bundled}
return module.exports;
}
});
`;
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, wrapped, 'utf8');
console.log(`Wrote ${outputPath}`);

View file

@ -0,0 +1,324 @@
/**
* Browser-safe contract for the Feishu Host plugin.
*
* The Host owns app registration and credentials. This module deliberately
* models only redacted presentation data; app secrets and credential refs
* must never be returned by any endpoint on this channel.
*/
export const FEISHU_RPC_CHANNEL = "/feishu";
export const FEISHU_ENDPOINTS = Object.freeze({
status: "connection.status",
beginProvisioning: "provision.begin",
pollProvisioning: "provision.poll",
cancelProvisioning: "provision.cancel",
reconnectBot: "bot.reconnect",
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
disconnect: "connection.disconnect",
});
const CONNECTION_STATES = new Set([
"disconnected",
"offline",
"provisioning",
"connecting",
"reconnecting",
"connected",
"error",
]);
const POLL_STATES = new Set([
"pending",
"scanned",
"connecting",
"connected",
"expired",
"failed",
]);
function isRecord(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function optionalString(value) {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function optionalTimestamp(value) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.length > 0) {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? undefined : parsed;
}
return undefined;
}
function clamp(value, min, max, fallback) {
return typeof value === "number" && Number.isFinite(value)
? Math.min(max, Math.max(min, value))
: fallback;
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== "boolean") {
throw new Error("飞书服务返回了无法识别的响应");
}
if (!result.ok) {
const message = optionalString(result.error?.message) ?? "飞书服务请求失败";
const error = new Error(message);
error.code = optionalString(result.error?.code) ?? "FEISHU_RPC_ERROR";
throw error;
}
return result.value;
}
export function normalizeProvisioning(value, now = Date.now()) {
const source = isRecord(value?.provisioning) ? value.provisioning : value;
if (!isRecord(source)) throw new Error("飞书服务没有返回二维码信息");
const attemptId = optionalString(source.attemptId)
?? optionalString(source.provisioningId);
const verificationUrl = optionalString(source.verificationUrl);
const qrCodeDataUrl = optionalString(source.qrCodeDataUrl);
if (!attemptId || (!verificationUrl && !qrCodeDataUrl)) {
throw new Error("飞书服务返回的二维码信息不完整");
}
const explicitExpiry = optionalTimestamp(source.expiresAt);
const expireIn = clamp(source.expireIn, 1, 60 * 60, 5 * 60);
return {
attemptId,
verificationUrl,
qrCodeDataUrl,
expiresAt: explicitExpiry ?? now + expireIn * 1000,
pollIntervalMs: clamp(source.pollIntervalMs, 800, 10_000, 1_800),
};
}
function normalizeBot(value) {
const source = isRecord(value) ? value : {};
return {
name: optionalString(source.name) ?? "飞书机器人",
avatarUrl: optionalString(source.avatarUrl),
appIdMasked: optionalString(source.appIdMasked),
tenantName: optionalString(source.tenantName),
domain: source.domain === "lark" ? "lark" : "feishu",
activated: typeof source.activated === "boolean" || typeof source.activated === "number"
? source.activated
: undefined,
};
}
function normalizeHealth(value, connected = false) {
const source = isRecord(value) ? value : {};
const fallbackStatus = connected ? "healthy" : "offline";
const status = ["healthy", "degraded", "offline", "checking"].includes(source.status)
? source.status
: fallbackStatus;
return {
status,
summary: optionalString(source.summary)
?? (connected ? "长连接运行正常" : "机器人尚未连接"),
lastCheckedAt: optionalTimestamp(source.lastCheckedAt),
lastConnectedAt: optionalTimestamp(source.lastConnectedAt),
};
}
function normalizeError(value) {
if (!isRecord(value)) return undefined;
const message = optionalString(value.message);
if (!message) return undefined;
return { message, code: optionalString(value.code) };
}
function authoritativeState(value, connected) {
if (connected) return "connected";
const reported = CONNECTION_STATES.has(value) ? value : "disconnected";
if (reported === "connected" || reported === "connecting" || reported === "reconnecting") {
return "connecting";
}
if (reported === "error") return "error";
return "offline";
}
/** Normalize one redacted bot connection. `connected` is authoritative. */
export function normalizeBotConnection(value, fallbackBotId) {
if (!isRecord(value)) throw new Error("飞书服务返回了无效的机器人状态");
const botId = optionalString(value.botId) ?? optionalString(fallbackBotId);
if (!botId) throw new Error("飞书服务返回的机器人缺少 botId");
const connected = value.connected === true;
return {
botId,
state: authoritativeState(value.state, connected),
connected,
configured: value.configured !== false,
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
error: normalizeError(value.error),
};
}
/**
* Normalize the v2 multi-bot list. A singleton fallback is accepted only so a
* browser/Host rolling upgrade does not strand an existing connection.
*/
export function normalizeBotsSnapshot(value) {
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
let sourceBots = Array.isArray(value.bots) ? value.bots : [];
if (sourceBots.length === 0 && value.configured === true) {
sourceBots = [{
botId: optionalString(value.botId) ?? "legacy-default",
state: value.state,
connected: value.connected,
configured: true,
bot: value.bot,
health: value.health,
error: value.error,
}];
}
const seen = new Set();
const bots = [];
for (const source of sourceBots) {
const bot = normalizeBotConnection(source);
if (seen.has(bot.botId)) continue;
seen.add(bot.botId);
bots.push(bot);
}
const configured = bots.filter((bot) => bot.configured).length;
const connected = bots.filter((bot) => bot.connected).length;
const revision = Number.isSafeInteger(value.revision) && value.revision >= 0
? value.revision
: 0;
const state = CONNECTION_STATES.has(value.state) ? value.state : "disconnected";
return {
schemaVersion: value.schemaVersion === 2 ? 2 : 1,
revision,
state,
bots,
// Derive counts from the authoritative list so stale summary fields never
// make the UI claim that an unavailable bot is online.
totals: { configured, connected },
provisioning: value.provisioning
? normalizeProvisioning(value.provisioning)
: undefined,
error: normalizeError(value.error),
};
}
/** Legacy single-bot normalizer retained for the compatibility surface. */
export function normalizeConnectionSnapshot(value) {
if (!isRecord(value)) throw new Error("飞书服务没有返回连接状态");
const connected = value.connected === true;
const reportedState = CONNECTION_STATES.has(value.state)
? value.state
: "disconnected";
const state = connected
? "connected"
: reportedState === "connected"
? "connecting"
: reportedState;
const snapshot = {
state,
configured: value.configured === true,
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
provisioning: undefined,
errorMessage: optionalString(value.error?.message) ?? optionalString(value.message),
};
if (value.provisioning) snapshot.provisioning = normalizeProvisioning(value.provisioning);
return snapshot;
}
/** Legacy UI projection retained for tests and rolling upgrades. */
export function screenFromSnapshot(snapshot) {
switch (snapshot.state) {
case "connected":
return { phase: "connected", configured: snapshot.configured, bot: snapshot.bot, health: snapshot.health };
case "provisioning":
return snapshot.provisioning
? { phase: "qr", configured: snapshot.configured, provision: snapshot.provisioning, expired: false }
: { phase: "creating", configured: snapshot.configured };
case "connecting":
return { phase: "connecting", configured: snapshot.configured, provision: snapshot.provisioning };
case "error":
return {
phase: "error",
configured: snapshot.configured,
bot: snapshot.bot,
health: snapshot.health,
error: { message: snapshot.errorMessage ?? "飞书连接遇到问题", code: "FEISHU_CONNECTION_ERROR" },
retry: snapshot.configured ? "test" : "begin",
};
default:
return snapshot.configured
? { phase: "offline", configured: true, bot: snapshot.bot, health: snapshot.health }
: { phase: "disconnected", configured: false };
}
}
/** Legacy helper. The new UI uses reconnectBot with an explicit botId. */
export async function retryConnection(invoke, signal) {
if (typeof invoke !== "function") throw new TypeError("retryConnection requires an RPC caller");
await invoke(FEISHU_ENDPOINTS.testConnection, {}, signal);
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
}
/** Reconnect exactly one bot, then fetch the authoritative list once. */
export async function reconnectBot(invoke, botId, signal) {
if (typeof invoke !== "function") throw new TypeError("reconnectBot requires an RPC caller");
const id = optionalString(botId);
if (!id) throw new TypeError("reconnectBot requires a botId");
await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId: id }, signal);
return invoke(FEISHU_ENDPOINTS.status, {}, signal);
}
export function normalizePollResult(value) {
if (!isRecord(value)) throw new Error("飞书服务没有返回创建进度");
const status = POLL_STATES.has(value.status)
? value.status
: POLL_STATES.has(value.state)
? value.state
: undefined;
if (!status) throw new Error("飞书服务返回了未知的创建状态");
const normalized = {
status,
botId: optionalString(value.botId),
message: optionalString(value.error?.message) ?? optionalString(value.message),
connection: undefined,
provisioning: undefined,
};
if (value.provisioning) normalized.provisioning = normalizeProvisioning(value.provisioning);
if (status === "connected" && isRecord(value.connection)) {
normalized.connection = value.connection.botId
? normalizeBotConnection(value.connection)
: normalizeConnectionSnapshot(value.connection);
}
return normalized;
}
/** Keep transport and Host details out of the user-facing alert. */
export function presentError(error) {
const raw = optionalString(error?.message) ?? "操作失败,请稍后重试";
const message = raw
.replace(/(client[_-]?secret|app[_-]?secret|secret|token)\s*[:=]\s*[^\s,;]+/gi, "$1=••••••")
.slice(0, 240);
return { message, code: optionalString(error?.code) };
}
export function formatRemaining(milliseconds) {
const totalSeconds = Math.max(0, Math.ceil(milliseconds / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
}

View file

@ -0,0 +1,936 @@
import * as React from "react";
import {
FEISHU_ENDPOINTS,
FEISHU_RPC_CHANNEL,
formatRemaining,
normalizeBotsSnapshot,
normalizePollResult,
normalizeProvisioning,
presentError,
unwrapRpcResult,
} from "./api.js";
import { installFeishuStyles } from "./styles.js";
const h = React.createElement;
export const name = "feishu-settings";
export const inject = ["slots", "connection"];
function SvgIcon({ children, size = 18, className, viewBox = "0 0 24 24" }) {
return h("svg", {
width: size,
height: size,
viewBox,
fill: "none",
xmlns: "http://www.w3.org/2000/svg",
"aria-hidden": "true",
focusable: "false",
className,
}, children);
}
function ShieldIcon({ size = 18 }) {
return h(SvgIcon, { size },
h("path", {
d: "M12 3 5.5 5.8v5.1c0 4.25 2.72 7.87 6.5 9.1 3.78-1.23 6.5-4.85 6.5-9.1V5.8L12 3Z",
stroke: "currentColor", strokeWidth: "1.7", strokeLinejoin: "round",
}),
h("path", {
d: "m9.3 11.8 1.7 1.7 3.8-4", stroke: "currentColor",
strokeWidth: "1.7", strokeLinecap: "round", strokeLinejoin: "round",
}),
);
}
function RobotIcon({ size = 26 }) {
return h(SvgIcon, { size },
h("rect", {
x: "5", y: "7.5", width: "14", height: "11", rx: "4",
stroke: "currentColor", strokeWidth: "1.7",
}),
h("path", {
d: "M12 4.5v3M8.7 12h.01M15.3 12h.01M9.2 15.3c1.67 1.08 3.93 1.08 5.6 0M3.5 11.5v3M20.5 11.5v3",
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
}),
);
}
function SparkIcon({ size = 18 }) {
return h(SvgIcon, { size },
h("path", {
d: "M12 2.8c.75 3.67 2.7 5.62 6.4 6.4-3.7.77-5.65 2.72-6.4 6.4-.75-3.68-2.7-5.63-6.4-6.4 3.7-.78 5.65-2.73 6.4-6.4Z",
stroke: "currentColor", strokeWidth: "1.55", strokeLinejoin: "round",
}),
h("path", {
d: "M5.2 15.8c.35 1.7 1.28 2.63 3 3-1.72.36-2.65 1.29-3 3-.36-1.71-1.29-2.64-3-3 1.71-.37 2.64-1.3 3-3ZM18.7 2.7c.22 1.06.79 1.63 1.85 1.85-1.06.22-1.63.79-1.85 1.85-.22-1.06-.79-1.63-1.85-1.85 1.06-.22 1.63-.79 1.85-1.85Z",
fill: "currentColor",
}),
);
}
function PlusIcon({ size = 17 }) {
return h(SvgIcon, { size }, h("path", {
d: "M12 5v14M5 12h14", stroke: "currentColor", strokeWidth: "1.8",
strokeLinecap: "round",
}));
}
function RefreshIcon({ size = 16 }) {
return h(SvgIcon, { size }, h("path", {
d: "M19 7.5V4m0 0h-3.5M19 4l-2.1 2.1A7 7 0 1 0 19 13",
stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round",
strokeLinejoin: "round",
}));
}
function ExternalIcon({ size = 15 }) {
return h(SvgIcon, { size }, h("path", {
d: "M13 5h6v6M19 5l-8.5 8.5M18 13.5V18a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4.5",
stroke: "currentColor", strokeWidth: "1.7", strokeLinecap: "round",
strokeLinejoin: "round",
}));
}
function AlertIcon({ size = 22 }) {
return h(SvgIcon, { size },
h("path", {
d: "M12 3.4 21 19H3L12 3.4Z", stroke: "currentColor",
strokeWidth: "1.7", strokeLinejoin: "round",
}),
h("path", {
d: "M12 9v4.4M12 16.6v.01", stroke: "currentColor",
strokeWidth: "1.9", strokeLinecap: "round",
}),
);
}
function QrIcon({ size = 58 }) {
return h(SvgIcon, { size }, h("path", {
d: "M4 4h6v6H4V4Zm10 0h6v6h-6V4ZM4 14h6v6H4v-6Zm10 0h2v2h-2v-2Zm4 0h2v4h-2v-4Zm-4 4h4v2h-4v-2Z",
fill: "currentColor",
}));
}
const Button = React.forwardRef(function Button(
{ children, kind = "secondary", size, icon, className = "", ...props },
ref,
) {
return h("button", {
...props,
ref,
type: "button",
className: `bxf-button ${className}`.trim(),
"data-kind": kind,
"data-size": size,
}, icon, h("span", null, children));
});
function BrandMark({ compact = false }) {
return h("div", { className: compact ? "bxf-avatar" : "bxf-brandMark" },
h(RobotIcon, { size: compact ? 25 : 34 }));
}
function Heading({ totals, onAdd, adding, busy, addButtonRef }) {
const hasBots = totals.configured > 0;
return h("div", { className: "bxf-heading" },
h("div", { className: "bxf-headingTools" },
hasBots
? h("div", {
className: "bxf-totalBadge",
"aria-label": `已接入 ${totals.configured} 个机器人,其中 ${totals.connected} 个在线`,
}, h("strong", null, totals.connected), h("span", null, `/ ${totals.configured} 在线`))
: null,
h("div", {
className: "bxf-localBadge",
title: "每个应用的凭据均由 Host 独立保存,不会发送到浏览器",
}, h(ShieldIcon, { size: 14 }), h("span", null, "凭据仅保存在本机")),
h(Button, {
kind: "primary",
size: "small",
icon: h(PlusIcon),
onClick: onAdd,
disabled: adding || busy,
ref: addButtonRef,
"aria-busy": busy ? "true" : undefined,
}, adding ? "正在添加" : "添加机器人"),
),
);
}
function LoadingView() {
return h("div", {
className: "bxf-card", "aria-busy": "true", "aria-label": "正在读取飞书机器人列表",
}, h("div", { className: "bxf-skeleton" },
h("div", { className: "bxf-skeletonLine" }),
h("div", { className: "bxf-skeletonLine" }),
h("div", { className: "bxf-skeletonLine" }),
h("div", { className: "bxf-skeletonLine" }),
h("div", { className: "bxf-skeletonBox" }),
));
}
function EmptyView({ onStart, busy }) {
return h("div", { className: "bxf-card" },
h("div", { className: "bxf-cardBody bxf-intro" },
h("div", { className: "bxf-introCopy" },
h("div", { className: "bxf-stateLabel" },
h("span", { className: "bxf-dot" }), h("span", null, "尚未接入机器人")),
h("h3", null, "扫码,创建第一个飞书入口"),
h("p", null, "无需手动填写 App ID。以后还可以继续添加机器人,分别服务不同团队或飞书租户。"),
h("div", { className: "bxf-actions" },
h(Button, {
kind: "primary", icon: h(SparkIcon), onClick: onStart,
disabled: busy, "aria-busy": busy ? "true" : undefined,
}, busy ? "正在创建…" : "一键创建飞书机器人")),
h("div", { className: "bxf-note" }, h(ShieldIcon, { size: 16 }),
h("span", null, "每个 App Secret 都只写入 Host 凭据存储,浏览器不会收到 Secret。")),
),
h("div", { className: "bxf-markStage", "aria-hidden": "true" }, h(BrandMark)),
),
);
}
function safeVerificationHref(value) {
if (!value) return undefined;
try {
const url = new URL(value);
return url.protocol === "https:" ? url.toString() : undefined;
} catch {
return undefined;
}
}
function safeQrSource(value) {
if (!value) return undefined;
return /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
? value
: undefined;
}
function QrPane({ provision, now, onRefresh, onCancel, busy }) {
const [imageFailed, setImageFailed] = React.useState(false);
const qrSource = safeQrSource(provision.qrCodeDataUrl);
const href = safeVerificationHref(provision.verificationUrl);
const remaining = Math.max(0, provision.expiresAt - now);
const expired = provision.expired === true || remaining === 0;
const progress = Math.min(1, remaining / Math.max(1, provision.durationMs ?? remaining));
React.useEffect(() => setImageFailed(false), [qrSource]);
return h("div", { className: "bxf-card bxf-provisionCard" },
h("div", { className: "bxf-cardBody bxf-qrLayout" },
h("div", { className: "bxf-qrColumn" },
h("div", { className: "bxf-qrFrame" },
qrSource && !imageFailed
? h("img", {
src: qrSource,
alt: "用于新增 DeepSeek Harness 飞书机器人的一次性授权二维码",
onError: () => setImageFailed(true),
})
: h("div", { className: "bxf-qrFallback" },
h("div", null, h(QrIcon), h("span", null, "二维码未就绪,请打开授权链接"))),
expired
? h("div", { className: "bxf-expiredOverlay", role: "status" },
h("div", null, "二维码已失效", h("br"), "请刷新后重新扫码"))
: null,
),
h("div", {
className: "bxf-countdown",
"aria-label": expired ? "二维码已失效" : `二维码剩余 ${formatRemaining(remaining)}`,
},
h("div", { className: "bxf-countdownTop", "aria-hidden": "true" },
h("span", null, expired ? "等待刷新" : "二维码有效时间"),
h("strong", null, formatRemaining(remaining))),
h("div", { className: "bxf-progress", "aria-hidden": "true" },
h("span", { style: { "--bxf-progress": `${Math.round(progress * 100)}%` } })),
),
),
h("div", { className: "bxf-qrCopy" },
h("div", { className: "bxf-stateLabel" },
h("span", { className: "bxf-dot", "data-tone": "warning" }),
h("span", null, "正在添加新机器人")),
h("h3", null, expired ? "刷新二维码后继续" : "使用飞书扫码创建机器人"),
h("p", null, "扫码只会新增一个机器人,已接入的机器人会继续正常收发消息。"),
h("ol", { className: "bxf-steps" },
h("li", null, "打开飞书移动端,使用扫一扫读取二维码"),
h("li", null, "核对应用名称与权限范围,并确认创建"),
h("li", null, "保持本页打开,等待新机器人的长连接就绪")),
h("div", { className: "bxf-actions" },
expired
? h(Button, {
kind: "primary", icon: h(RefreshIcon), onClick: onRefresh, disabled: busy,
}, busy ? "刷新中…" : "刷新二维码")
: href
? h("a", {
className: "bxf-button bxf-link", "data-kind": "secondary",
href, target: "_blank", rel: "noopener noreferrer",
}, h(ExternalIcon), h("span", null, "在飞书中打开"))
: null,
!expired
? h(Button, { icon: h(RefreshIcon), onClick: onRefresh, disabled: busy }, "换一个二维码")
: null,
h(Button, { onClick: onCancel, disabled: busy }, "取消添加")),
),
),
);
}
function ProvisionProgress({ phase, onCancel, busy }) {
const connecting = phase === "connecting";
return h("div", { className: "bxf-card bxf-provisionCard", "aria-busy": "true" },
h("div", { className: "bxf-connecting bxf-connectingCompact" },
h("div", { className: "bxf-connectingCopy" },
h("div", { className: "bxf-orbit" },
h("div", { className: "bxf-orbitCore" },
connecting ? h(RobotIcon, { size: 24 }) : h(SparkIcon, { size: 24 }))),
h("h3", null, connecting ? "已确认,正在连接新机器人" : "正在准备授权二维码"),
h("p", null, connecting
? "正在安全保存凭据并检查新机器人的消息通道,其他机器人不会中断。"
: "正在向飞书申请一次性授权二维码,请稍候。"),
connecting
? h("div", { className: "bxf-actions", style: { justifyContent: "center" } },
h(Button, { onClick: onCancel, disabled: busy }, "取消添加"))
: null,
),
),
);
}
function ProvisionError({ error, onRetry, onCancel, busy }) {
return h("div", { className: "bxf-card bxf-provisionCard" },
h("div", { className: "bxf-inlineError", role: "alert" },
h("div", { className: "bxf-errorIcon" }, h(AlertIcon)),
h("div", null,
h("h3", null, "新机器人没有添加完成"),
h("p", null, error.message),
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
h("div", { className: "bxf-actions" },
h(Button, { kind: "primary", icon: h(RefreshIcon), onClick: onRetry, disabled: busy },
busy ? "重试中…" : "重新生成二维码"),
h(Button, { onClick: onCancel, disabled: busy }, "关闭")),
),
),
);
}
const HEALTH_LABELS = {
connected: "运行正常",
connecting: "正在连接",
offline: "连接中断",
error: "需要处理",
};
function formatCheckedTime(timestamp) {
if (!timestamp) return "尚未检查";
try {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit", minute: "2-digit", second: "2-digit",
}).format(new Date(timestamp));
} catch {
return "刚刚";
}
}
function RemoveConfirmation({ bot, busy, onConfirm, onCancel }) {
const cancelRef = React.useRef(null);
const idPart = bot.botId.replace(/[^a-zA-Z0-9_-]/g, "-");
const titleId = `bxf-remove-title-${idPart}`;
const descriptionId = `bxf-remove-description-${idPart}`;
React.useEffect(() => cancelRef.current?.focus(), []);
return h("div", {
className: "bxf-confirm",
role: "alertdialog",
"aria-labelledby": titleId,
"aria-describedby": descriptionId,
onKeyDown: (event) => {
if (event.key === "Escape" && !busy) {
event.preventDefault();
onCancel();
}
},
},
h("h4", { id: titleId }, `从 DeepSeek Harness 移除“${bot.bot.name}”?`),
h("p", { id: descriptionId },
"此操作会停止这个机器人的连接,并删除保存在本机的接入配置和凭据。飞书开放平台中的应用不会被自动删除,其他机器人也不受影响。"),
h("div", { className: "bxf-actions" },
h(Button, { ref: cancelRef, onClick: onCancel, disabled: busy }, "保留机器人"),
h(Button, { kind: "danger", onClick: onConfirm, disabled: busy },
busy ? "正在移除…" : "确认移除接入")),
);
}
function BotCard({
connection,
busy,
actionError,
removing,
onReconnect,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
cardRef,
removeButtonRef,
}) {
const { bot, health, state, connected } = connection;
const stateForDisplay = busy === "reconnect"
? "connecting"
: state;
const tone = stateForDisplay === "connected"
? "success"
: stateForDisplay === "connecting"
? "warning"
: "error";
const summary = actionError?.message
?? connection.error?.message
?? health.summary;
const titleId = `bxf-bot-${connection.botId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
const domainLabel = bot.domain === "lark" ? "Lark" : "飞书";
return h("article", {
className: "bxf-card bxf-botCard",
"aria-labelledby": titleId,
"data-bot-id": connection.botId,
tabIndex: -1,
ref: cardRef,
},
h("div", { className: "bxf-cardBody" },
h("div", { className: "bxf-connectedTop" },
h("div", { className: "bxf-botIdentity" },
bot.avatarUrl
? h("div", { className: "bxf-avatar" }, h("img", { src: bot.avatarUrl, alt: "" }))
: h(BrandMark, { compact: true }),
h("div", { className: "bxf-botName" },
h("h3", { id: titleId, title: bot.name }, bot.name),
h("p", null, bot.tenantName ? `${bot.tenantName} · ${domainLabel}机器人` : `${domainLabel}机器人`)),
),
h("div", { className: "bxf-healthPill", "data-health": stateForDisplay },
h("span", { className: "bxf-dot", "data-tone": tone }),
h("span", null, HEALTH_LABELS[stateForDisplay] ?? "状态未知")),
),
h("dl", { className: "bxf-statusGrid" },
h("div", { className: "bxf-metric" }, h("dt", null, "消息通道"),
h("dd", null, connected ? "长连接" : stateForDisplay === "connecting" ? "连接中" : "已断开")),
h("div", { className: "bxf-metric" }, h("dt", null, "应用标识"),
h("dd", { title: bot.appIdMasked }, bot.appIdMasked ?? "已安全保存")),
h("div", { className: "bxf-metric" }, h("dt", null, "最近检查"),
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
),
h("div", { className: "bxf-divider" }),
h("div", { className: "bxf-connectedFooter" },
h("div", { className: "bxf-healthSummary", "data-error": actionError || connection.error ? "true" : undefined },
h("strong", null, "连接状态:"), h("span", null, summary)),
h("div", { className: "bxf-actions bxf-botActions" },
h(Button, {
size: "small", icon: h(RefreshIcon), onClick: onReconnect,
disabled: Boolean(busy), "aria-busy": busy === "reconnect" ? "true" : undefined,
"aria-label": `${connected ? "检查连接" : "重试连接"}${bot.name}`,
}, busy === "reconnect" ? (connected ? "检查中…" : "正在连接…") : connected ? "检查连接" : "重试连接"),
h(Button, {
size: "small", kind: "danger", onClick: onRequestRemove,
disabled: Boolean(busy), ref: removeButtonRef,
"aria-label": `从 DeepSeek Harness 移除${bot.name}`,
}, "移除接入")),
),
),
removing
? h(RemoveConfirmation, {
bot: connection,
busy: busy === "delete",
onConfirm: onConfirmRemove,
onCancel: onCancelRemove,
})
: null,
);
}
function BotList(props) {
return h("section", { className: "bxf-listSection", "aria-labelledby": "bxf-bot-list-title" },
h("div", { className: "bxf-listHeading" },
h("h3", { id: "bxf-bot-list-title" }, "已接入的机器人"),
h("span", null, `${props.bots.length} 个`)),
h("ul", { className: "bxf-botList", role: "list" },
props.bots.map((bot) => h("li", { key: bot.botId },
h(BotCard, {
connection: bot,
busy: props.busyByBot[bot.botId],
actionError: props.errorsByBot[bot.botId],
removing: props.removeTargetId === bot.botId,
onReconnect: () => props.onReconnect(bot),
onRequestRemove: () => props.onRequestRemove(bot),
onConfirmRemove: () => props.onConfirmRemove(bot),
onCancelRemove: props.onCancelRemove,
cardRef: (node) => props.setCardRef(bot.botId, node),
removeButtonRef: (node) => props.setRemoveButtonRef(bot.botId, node),
}),
))),
);
}
function PageError({ error, onRetry, busy }) {
return h("div", { className: "bxf-card" },
h("div", { className: "bxf-error", role: "alert" },
h("div", { className: "bxf-errorIcon" }, h(AlertIcon)),
h("div", null,
h("h3", null, "无法读取飞书机器人"),
h("p", null, error.message),
error.code ? h("span", { className: "bxf-errorCode" }, error.code) : null,
h("div", { className: "bxf-actions" },
h(Button, { kind: "primary", icon: h(RefreshIcon), onClick: onRetry, disabled: busy },
busy ? "重试中…" : "重新读取"))),
),
);
}
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function FeishuSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: "loading",
revision: 0,
bots: [],
totals: EMPTY_TOTALS,
provisioning: null,
pageError: null,
statusError: null,
});
const [pageBusy, setPageBusy] = React.useState(false);
const [provisionBusy, setProvisionBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [errorsByBot, setErrorsByBot] = React.useState({});
const [removeTargetId, setRemoveTargetId] = React.useState(null);
const [announcement, setAnnouncement] = React.useState("");
const [now, setNow] = React.useState(() => Date.now());
const [focusBotId, setFocusBotId] = React.useState(null);
const cardRefs = React.useRef(new Map());
const removeButtonRefs = React.useRef(new Map());
const addButtonRef = React.useRef(null);
const announce = React.useCallback((message) => {
setAnnouncement("");
if (!message) return;
window.requestAnimationFrame(() => setAnnouncement(message));
}, []);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const mergeSnapshot = React.useCallback((snapshot, { restoreProvisioning = true } = {}) => {
setModel((current) => {
if (snapshot.revision > 0 && current.revision > snapshot.revision) return current;
let provisioning = current.provisioning;
if (!provisioning && restoreProvisioning && snapshot.provisioning) {
provisioning = {
phase: snapshot.state === "connecting" ? "connecting" : "qr",
...snapshot.provisioning,
durationMs: Math.max(1, snapshot.provisioning.expiresAt - Date.now()),
expired: snapshot.provisioning.expiresAt <= Date.now(),
};
}
return {
...current,
phase: "ready",
revision: snapshot.revision,
bots: snapshot.bots,
totals: snapshot.totals,
provisioning,
pageError: null,
statusError: null,
};
});
}, []);
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = true } = {}) => {
if (!silent) setPageBusy(true);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
mergeSnapshot(snapshot, { restoreProvisioning });
return snapshot;
} catch (error) {
if (error?.name === "AbortError") return undefined;
const presented = presentError(error);
setModel((current) => current.phase === "loading" || !silent
? { ...current, phase: "error", pageError: presented }
: { ...current, statusError: presented });
return undefined;
} finally {
if (!silent) setPageBusy(false);
}
}, [invoke, mergeSnapshot]);
React.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal });
return () => controller.abort();
}, [loadStatus]);
// One list request refreshes every bot. This continues while a new bot is
// being provisioned so existing connections never disappear from the UI.
React.useEffect(() => {
if (model.phase !== "ready") return undefined;
const controller = new AbortController();
let inFlight = false;
const timer = window.setInterval(async () => {
if (inFlight) return;
inFlight = true;
await loadStatus({ signal: controller.signal, silent: true });
inFlight = false;
}, 15_000);
return () => {
controller.abort();
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React.useEffect(() => {
if (!focusBotId) return;
const node = cardRefs.current.get(focusBotId);
if (!node) return;
node.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
node.focus({ preventScroll: true });
setFocusBotId(null);
}, [focusBotId, model.bots]);
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
setProvisionBusy(true);
announce("");
const previousAttemptId = model.provisioning?.attemptId;
setModel((current) => ({
...current,
phase: current.phase === "loading" ? "ready" : current.phase,
provisioning: { phase: "creating" },
}));
try {
if (replace && previousAttemptId) {
await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId: previousAttemptId });
}
const provision = normalizeProvisioning(await invoke(
FEISHU_ENDPOINTS.beginProvisioning,
{ locale: "zh-CN" },
));
const timestamp = Date.now();
setNow(timestamp);
setModel((current) => ({
...current,
provisioning: {
phase: "qr",
...provision,
durationMs: Math.max(1, provision.expiresAt - timestamp),
expired: false,
},
}));
announce("授权二维码已生成,请使用飞书扫码。");
} catch (error) {
setModel((current) => ({
...current,
provisioning: { phase: "error", error: presentError(error) },
}));
} finally {
setProvisionBusy(false);
}
}, [announce, invoke, model.provisioning?.attemptId]);
const cancelProvisioning = React.useCallback(async () => {
const attemptId = model.provisioning?.attemptId;
setProvisionBusy(true);
try {
if (attemptId) await invoke(FEISHU_ENDPOINTS.cancelProvisioning, { attemptId });
setModel((current) => ({ ...current, provisioning: null }));
announce("已取消添加机器人。");
await loadStatus({ silent: true, restoreProvisioning: false });
window.requestAnimationFrame(() => addButtonRef.current?.focus());
} catch (error) {
setModel((current) => ({
...current,
provisioning: { phase: "error", attemptId, error: presentError(error) },
}));
} finally {
setProvisionBusy(false);
}
}, [announce, invoke, loadStatus, model.provisioning?.attemptId]);
React.useEffect(() => {
const provision = model.provisioning;
if (!provision || provision.phase !== "qr" || provision.expired) return undefined;
const tick = () => {
const timestamp = Date.now();
setNow(timestamp);
if (timestamp >= provision.expiresAt) {
setModel((current) => current.provisioning?.attemptId === provision.attemptId
? { ...current, provisioning: { ...current.provisioning, expired: true } }
: current);
}
};
tick();
const timer = window.setInterval(tick, 1_000);
return () => window.clearInterval(timer);
}, [model.provisioning]);
React.useEffect(() => {
const provision = model.provisioning;
if (!provision
|| !["qr", "connecting"].includes(provision.phase)
|| !provision.attemptId
|| provision.expired) return undefined;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
try {
const result = normalizePollResult(await invoke(
FEISHU_ENDPOINTS.pollProvisioning,
{ attemptId: provision.attemptId },
controller.signal,
));
if (result.status === "connected") {
const snapshot = await loadStatus({ signal: controller.signal, silent: true, restoreProvisioning: false });
const newBot = snapshot?.bots.find((bot) => bot.botId === result.botId);
if (!snapshot) {
throw new Error("机器人已经创建,但暂时无法确认连接状态");
}
if (!newBot?.connected) {
setModel((current) => current.provisioning?.attemptId === provision.attemptId
? { ...current, provisioning: { ...current.provisioning, phase: "connecting" } }
: current);
return;
}
setModel((current) => ({ ...current, provisioning: null }));
announce(newBot
? `${newBot.bot.name}已连接,可以在飞书中开始聊天。`
: "新飞书机器人已连接,可以开始聊天。");
if (result.botId) setFocusBotId(result.botId);
return;
}
if (result.status === "failed") {
const error = new Error(result.message ?? "飞书应用创建失败");
error.code = "FEISHU_PROVISION_FAILED";
throw error;
}
if (result.status === "expired") {
setModel((current) => current.provisioning?.attemptId === provision.attemptId
? { ...current, provisioning: { ...current.provisioning, phase: "qr", expired: true } }
: current);
return;
}
setModel((current) => {
if (current.provisioning?.attemptId !== provision.attemptId) return current;
const next = result.provisioning ?? current.provisioning;
return {
...current,
provisioning: {
...current.provisioning,
...next,
phase: ["scanned", "connecting"].includes(result.status) ? "connecting" : "qr",
},
};
});
} catch (error) {
if (error?.name === "AbortError") return;
setModel((current) => current.provisioning?.attemptId === provision.attemptId
? {
...current,
provisioning: {
phase: "error",
attemptId: provision.attemptId,
error: presentError(error),
},
}
: current);
}
}, provision.pollIntervalMs);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [announce, invoke, loadStatus, model.provisioning]);
const setBotBusy = React.useCallback((botId, value) => {
setBusyByBot((current) => {
const next = { ...current };
if (value) next[botId] = value;
else delete next[botId];
return next;
});
}, []);
const setBotError = React.useCallback((botId, error) => {
setErrorsByBot((current) => {
const next = { ...current };
if (error) next[botId] = presentError(error);
else delete next[botId];
return next;
});
}, []);
const reconnectOneBot = React.useCallback(async (connection) => {
const { botId, bot } = connection;
setBotBusy(botId, "reconnect");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
mergeSnapshot(snapshot);
const refreshed = snapshot.bots.find((item) => item.botId === botId);
if (!refreshed?.connected) {
const error = new Error(
refreshed?.error?.message ?? refreshed?.health.summary ?? "机器人仍未连接",
);
error.code = refreshed?.error?.code ?? "FEISHU_BOT_OFFLINE";
throw error;
}
announce(connection.connected
? `${bot.name}连接检查完成。`
: `${bot.name}已重新连接。`);
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}操作失败,请查看机器人状态。`);
} finally {
setBotBusy(botId, null);
}
}, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
const requestRemove = React.useCallback((connection) => {
setRemoveTargetId(connection.botId);
}, []);
const cancelRemove = React.useCallback(() => {
const botId = removeTargetId;
setRemoveTargetId(null);
window.requestAnimationFrame(() => removeButtonRefs.current.get(botId)?.focus());
}, [removeTargetId]);
const confirmRemove = React.useCallback(async (connection) => {
const { botId, bot } = connection;
setBotBusy(botId, "delete");
setBotError(botId, null);
try {
await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
setRemoveTargetId(null);
setModel((current) => {
const bots = current.bots.filter((item) => item.botId !== botId);
return {
...current,
bots,
totals: {
configured: bots.length,
connected: bots.filter((item) => item.connected).length,
},
};
});
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
await loadStatus({ silent: true });
window.requestAnimationFrame(() => addButtonRef.current?.focus());
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}移除失败,请重试。`);
} finally {
setBotBusy(botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy, setBotError]);
const provision = model.provisioning;
let provisionContent = null;
if (provision?.phase === "creating") {
provisionContent = h(ProvisionProgress, { phase: "creating", busy: provisionBusy });
} else if (provision?.phase === "qr") {
provisionContent = h(QrPane, {
provision, now,
onRefresh: () => void startProvisioning({ replace: true }),
onCancel: () => void cancelProvisioning(),
busy: provisionBusy || model.phase !== "ready",
});
} else if (provision?.phase === "connecting") {
provisionContent = h(ProvisionProgress, {
phase: "connecting",
onCancel: () => void cancelProvisioning(),
busy: provisionBusy,
});
} else if (provision?.phase === "error") {
provisionContent = h(ProvisionError, {
error: provision.error,
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
onCancel: () => void cancelProvisioning(),
busy: provisionBusy,
});
}
const setCardRef = React.useCallback((botId, node) => {
if (node) cardRefs.current.set(botId, node);
else cardRefs.current.delete(botId);
}, []);
const setRemoveButtonRef = React.useCallback((botId, node) => {
if (node) removeButtonRefs.current.set(botId, node);
else removeButtonRefs.current.delete(botId);
}, []);
return h("section", { className: "bxf-page", "aria-label": "飞书机器人设置" },
h(Heading, {
totals: model.totals,
onAdd: () => void startProvisioning(),
adding: Boolean(provision),
busy: provisionBusy,
addButtonRef,
}),
h("div", {
className: "bxf-visuallyHidden", role: "status", "aria-live": "polite", "aria-atomic": "true",
}, announcement),
model.statusError
? h("div", { className: "bxf-statusNotice", role: "status" },
h(AlertIcon, { size: 16 }),
h("span", null, `状态自动刷新失败:${model.statusError.message}`),
h(Button, { size: "small", onClick: () => void loadStatus({ silent: true }), disabled: pageBusy }, "立即重试"))
: null,
model.phase === "loading"
? h(LoadingView)
: model.phase === "error"
? h(PageError, {
error: model.pageError ?? { message: "无法读取连接状态" },
onRetry: () => void loadStatus(),
busy: pageBusy,
})
: h(React.Fragment, null,
provisionContent,
model.bots.length === 0 && !provision
? h(EmptyView, { onStart: () => void startProvisioning(), busy: provisionBusy })
: null,
model.bots.length > 0
? h(BotList, {
bots: model.bots,
busyByBot,
errorsByBot,
removeTargetId,
onReconnect: (bot) => void reconnectOneBot(bot),
onRequestRemove: requestRemove,
onConfirmRemove: (bot) => void confirmRemove(bot),
onCancelRemove: cancelRemove,
setCardRef,
setRemoveButtonRef,
})
: null,
),
);
}
export function apply(ctx) {
ctx.effect(
() => installFeishuStyles(),
"feishu-settings: install client styles",
);
const rpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject("settings.plugins.tab", () =>
ctx.slots.register(
{
name: "settings.plugins.tab",
id: "feishu",
order: 20,
label: "飞书",
inject: () => ({ rpcCall }),
},
FeishuSettingsTab,
),
);
}

View file

@ -0,0 +1,527 @@
export const FEISHU_STYLE_ID = "beihuixinghe-dsh-feishu-settings";
const CSS = String.raw`
.bxf-page {
--bxf-accent: var(--dsw-alias-state-business-primary, #3370ff);
--bxf-success: var(--dsw-alias-state-success-primary, #20a162);
--bxf-warning: var(--dsw-alias-state-warning-primary, #d97706);
--bxf-error: var(--dsw-alias-state-error-primary, #d54941);
box-sizing: border-box;
width: 100%;
max-width: 860px;
color: var(--dsw-alias-label-primary, #1f2329);
display: flex;
flex-direction: column;
container-type: inline-size;
gap: 18px;
padding: 2px 0 24px;
}
.bxf-page *, .bxf-page *::before, .bxf-page *::after { box-sizing: border-box; }
.bxf-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.bxf-headingCopy { min-width: 0; }
.bxf-heading h2, .bxf-heading p, .bxf-card h3, .bxf-card p { margin: 0; }
.bxf-eyebrow {
color: var(--dsw-alias-label-tertiary, #8f959e);
font-size: 12px;
font-weight: 600;
line-height: 18px;
letter-spacing: .08em;
text-transform: uppercase;
margin-bottom: 3px;
}
.bxf-heading h2 {
font-size: 20px;
line-height: 28px;
font-weight: 650;
letter-spacing: -.015em;
}
.bxf-heading p {
max-width: 540px;
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 13px;
line-height: 20px;
margin-top: 5px;
white-space: nowrap;
}
.bxf-headingTools {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.bxf-totalBadge {
min-height: 28px;
display: inline-flex;
align-items: baseline;
gap: 3px;
border-radius: 999px;
padding: 4px 10px;
color: var(--dsw-alias-label-secondary, #646a73);
background: var(--dsw-alias-bg-module-platform, #f2f3f5);
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.bxf-totalBadge strong { color: var(--bxf-success); font-size: 13px; }
.bxf-localBadge {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
border-radius: 999px;
padding: 4px 10px;
color: var(--dsw-alias-label-secondary, #646a73);
background: var(--dsw-alias-bg-layer-1, #fff);
font-size: 11px;
line-height: 16px;
white-space: nowrap;
}
.bxf-card {
position: relative;
overflow: hidden;
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
border-radius: 14px;
background: var(--dsw-alias-bg-layer-3, #fff);
box-shadow: var(--dsw-shadow-lv1, 0 3px 12px rgba(31, 35, 41, .05));
}
.bxf-card::before {
content: "";
pointer-events: none;
position: absolute;
inset: 0 0 auto;
height: 88px;
background:
radial-gradient(circle at 86% -35%, color-mix(in srgb, var(--bxf-accent) 18%, transparent), transparent 68%);
opacity: .85;
}
.bxf-cardBody { position: relative; padding: 24px; }
.bxf-intro {
min-height: 250px;
display: grid;
grid-template-columns: minmax(0, 1fr) 172px;
gap: 32px;
align-items: center;
}
.bxf-introCopy { max-width: 500px; }
.bxf-stateLabel {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 12px;
font-weight: 600;
line-height: 18px;
margin-bottom: 13px;
}
.bxf-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--dsw-alias-label-tertiary, #8f959e);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--dsw-alias-label-tertiary, #8f959e) 12%, transparent);
}
.bxf-dot[data-tone="success"] {
background: var(--bxf-success);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-success) 13%, transparent);
}
.bxf-dot[data-tone="warning"] {
background: var(--bxf-warning);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-warning) 13%, transparent);
}
.bxf-dot[data-tone="error"] {
background: var(--bxf-error);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--bxf-error) 13%, transparent);
}
.bxf-intro h3 {
font-size: 24px;
line-height: 34px;
font-weight: 650;
letter-spacing: -.02em;
}
.bxf-introCopy > p {
max-width: 490px;
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 14px;
line-height: 23px;
margin-top: 8px;
}
.bxf-note {
display: flex;
gap: 8px;
align-items: flex-start;
color: var(--dsw-alias-label-tertiary, #8f959e);
font-size: 12px;
line-height: 18px;
margin-top: 16px;
}
.bxf-note svg { flex: none; margin-top: 1px; }
.bxf-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
}
.bxf-button {
appearance: none;
min-height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
border-radius: 8px;
padding: 7px 13px;
color: var(--dsw-alias-label-primary, #1f2329);
background: var(--dsw-alias-bg-layer-1, #fff);
font: inherit;
font-size: 13px;
font-weight: 550;
line-height: 20px;
text-decoration: none;
cursor: pointer;
transition: background .15s var(--ds-ease-in-out, ease), border-color .15s var(--ds-ease-in-out, ease), transform .15s var(--ds-ease-in-out, ease);
}
.bxf-button:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover, #f2f3f5);
border-color: var(--dsw-alias-border-l1, #c9cdd4);
}
.bxf-button:active:not(:disabled) { transform: translateY(1px); }
.bxf-button:focus-visible, .bxf-link:focus-visible {
outline: 2px solid var(--bxf-accent);
outline-offset: 2px;
}
.bxf-button:disabled { cursor: not-allowed; opacity: .55; }
.bxf-button[data-kind="primary"] {
border-color: var(--bxf-accent);
color: #fff;
background: var(--bxf-accent);
box-shadow: 0 4px 12px color-mix(in srgb, var(--bxf-accent) 24%, transparent);
}
.bxf-button[data-kind="primary"]:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--bxf-accent) 86%, #000);
background: color-mix(in srgb, var(--bxf-accent) 90%, #000);
}
.bxf-button[data-kind="danger"] { color: var(--bxf-error); }
.bxf-button[data-size="small"] { min-height: 32px; padding: 5px 10px; font-size: 12px; }
.bxf-provisionCard {
border-color: color-mix(in srgb, var(--bxf-accent) 32%, var(--dsw-alias-border-l2, #dee0e3));
}
.bxf-markStage {
position: relative;
width: 156px;
height: 156px;
display: grid;
place-items: center;
justify-self: end;
}
.bxf-markStage::before, .bxf-markStage::after {
content: "";
position: absolute;
border-radius: 50%;
}
.bxf-markStage::before {
inset: 12px;
border: 1px solid color-mix(in srgb, var(--bxf-accent) 18%, var(--dsw-alias-border-l2, #dee0e3));
background: color-mix(in srgb, var(--bxf-accent) 4%, var(--dsw-alias-bg-layer-1, #fff));
}
.bxf-markStage::after {
inset: 0;
border: 1px dashed color-mix(in srgb, var(--bxf-accent) 16%, transparent);
animation: bxf-rotate 18s linear infinite;
}
.bxf-brandMark {
position: relative;
z-index: 1;
width: 68px;
height: 68px;
display: grid;
place-items: center;
border-radius: 20px;
color: #fff;
background: var(--bxf-accent);
box-shadow: 0 12px 28px color-mix(in srgb, var(--bxf-accent) 28%, transparent);
}
.bxf-qrLayout {
display: grid;
grid-template-columns: 236px minmax(0, 1fr);
align-items: center;
gap: 32px;
}
.bxf-qrColumn { min-width: 0; }
.bxf-qrFrame {
position: relative;
width: 222px;
height: 222px;
display: grid;
place-items: center;
border: 1px solid var(--dsw-alias-border-l2, #dee0e3);
border-radius: 14px;
padding: 13px;
background: #fff;
box-shadow: 0 8px 24px rgba(31, 35, 41, .07);
}
.bxf-qrFrame::before, .bxf-qrFrame::after {
content: "";
position: absolute;
width: 24px;
height: 24px;
border-color: var(--bxf-accent);
border-style: solid;
}
.bxf-qrFrame::before { inset: -3px auto auto -3px; border-width: 2px 0 0 2px; border-radius: 5px 0 0; }
.bxf-qrFrame::after { inset: auto -3px -3px auto; border-width: 0 2px 2px 0; border-radius: 0 0 5px; }
.bxf-qrFrame img { width: 100%; height: 100%; display: block; object-fit: contain; }
.bxf-qrFallback {
width: 100%;
height: 100%;
display: grid;
place-items: center;
border-radius: 8px;
color: var(--bxf-accent);
background: #f7f9ff;
text-align: center;
padding: 20px;
}
.bxf-qrFallback span { display: block; color: #646a73; font-size: 12px; line-height: 18px; margin-top: 8px; }
.bxf-expiredOverlay {
position: absolute;
inset: 10px;
display: grid;
place-items: center;
border-radius: 9px;
color: #1f2329;
background: rgba(255, 255, 255, .94);
backdrop-filter: blur(3px);
font-size: 13px;
font-weight: 600;
text-align: center;
}
.bxf-countdown {
width: 222px;
color: var(--dsw-alias-label-tertiary, #8f959e);
font-variant-numeric: tabular-nums;
font-size: 11px;
line-height: 17px;
margin-top: 11px;
}
.bxf-countdownTop { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.bxf-progress { height: 3px; overflow: hidden; border-radius: 99px; background: var(--dsw-alias-bg-module-platform, #f2f3f5); margin-top: 6px; }
.bxf-progress > span { display: block; width: var(--bxf-progress, 100%); height: 100%; border-radius: inherit; background: var(--bxf-accent); transition: width 1s linear; }
.bxf-qrCopy h3 { font-size: 20px; line-height: 29px; font-weight: 650; }
.bxf-qrCopy > p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 7px; }
.bxf-steps { counter-reset: bxf-step; display: flex; flex-direction: column; gap: 11px; margin: 20px 0 0; padding: 0; list-style: none; }
.bxf-steps li { counter-increment: bxf-step; display: grid; grid-template-columns: 23px minmax(0, 1fr); align-items: start; gap: 9px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; }
.bxf-steps li::before { content: counter(bxf-step); width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--dsw-alias-border-l2, #dee0e3); border-radius: 50%; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 10px; font-weight: 650; }
.bxf-connecting { min-height: 292px; display: grid; place-items: center; text-align: center; padding: 36px 24px; }
.bxf-connectingCopy { max-width: 430px; }
.bxf-orbit { position: relative; width: 86px; height: 86px; display: grid; place-items: center; margin: 0 auto 22px; }
.bxf-orbit::before, .bxf-orbit::after { content: ""; position: absolute; border-radius: 50%; }
.bxf-orbit::before { inset: 3px; border: 1px solid color-mix(in srgb, var(--bxf-accent) 24%, transparent); animation: bxf-pulse 1.8s var(--ds-ease-in-out, ease) infinite; }
.bxf-orbit::after { inset: 0; border: 2px solid transparent; border-top-color: var(--bxf-accent); animation: bxf-rotate 1.2s linear infinite; }
.bxf-orbitCore { width: 50px; height: 50px; display: grid; place-items: center; border-radius: 16px; color: var(--bxf-accent); background: color-mix(in srgb, var(--bxf-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)); }
.bxf-connecting h3 { font-size: 20px; line-height: 29px; }
.bxf-connecting p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 7px; }
.bxf-connectingCompact { min-height: 248px; }
.bxf-inlineError {
min-height: 190px;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-content: center;
gap: 15px;
padding: 28px;
}
.bxf-inlineError h3 { font-size: 17px; line-height: 25px; margin: 0; }
.bxf-inlineError p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 5px; overflow-wrap: anywhere; }
.bxf-listSection { display: flex; flex-direction: column; gap: 10px; }
.bxf-listHeading { min-height: 28px; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 2px; }
.bxf-listHeading h3 { font-size: 14px; line-height: 22px; font-weight: 650; margin: 0; }
.bxf-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; }
.bxf-botList { display: flex; flex-direction: column; gap: 12px; margin: 0; padding: 0; list-style: none; }
.bxf-botList > li { min-width: 0; }
.bxf-botCard:focus { outline: none; }
.bxf-botCard:focus-visible { outline: 2px solid var(--bxf-accent); outline-offset: 2px; }
.bxf-connectedTop { display: flex; align-items: center; justify-content: space-between; gap: 20px; }
.bxf-botIdentity { min-width: 0; display: flex; align-items: center; gap: 13px; }
.bxf-avatar { flex: none; width: 48px; height: 48px; display: grid; place-items: center; overflow: hidden; border-radius: 14px; color: var(--bxf-accent); background: color-mix(in srgb, var(--bxf-accent) 9%, var(--dsw-alias-bg-layer-1, #fff)); }
.bxf-avatar img { width: 100%; height: 100%; object-fit: cover; }
.bxf-botName { min-width: 0; }
.bxf-botName h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 17px; line-height: 24px; font-weight: 650; }
.bxf-botName p { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 18px; margin-top: 2px; }
.bxf-healthPill { flex: none; display: inline-flex; align-items: center; gap: 7px; min-height: 28px; border-radius: 999px; padding: 4px 10px; color: var(--bxf-success); background: color-mix(in srgb, var(--bxf-success) 10%, transparent); font-size: 12px; font-weight: 600; line-height: 18px; }
.bxf-healthPill[data-health="degraded"], .bxf-healthPill[data-health="checking"], .bxf-healthPill[data-health="connecting"] { color: var(--bxf-warning); background: color-mix(in srgb, var(--bxf-warning) 10%, transparent); }
.bxf-healthPill[data-health="offline"], .bxf-healthPill[data-health="error"] { color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 10%, transparent); }
.bxf-statusGrid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 22px; }
.bxf-metric { min-width: 0; border: 1px solid var(--dsw-alias-border-l2, #dee0e3); border-radius: 9px; padding: 12px 13px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
.bxf-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; }
.bxf-metric dd { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; font-weight: 550; margin: 3px 0 0; }
.bxf-divider { height: 1px; background: var(--dsw-alias-border-l2, #dee0e3); margin: 22px 0 0; }
.bxf-connectedFooter { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 17px; }
.bxf-healthSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 18px; }
.bxf-healthSummary strong { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 600; }
.bxf-healthSummary[data-error="true"] { color: var(--bxf-error); }
.bxf-botActions { margin-top: 0; justify-content: flex-end; }
.bxf-confirm {
border-top: 1px solid var(--dsw-alias-border-l2, #dee0e3);
background: color-mix(in srgb, var(--bxf-error) 4%, var(--dsw-alias-bg-module-platform, #f7f8fa));
padding: 17px 24px 20px;
}
.bxf-confirm:focus { outline: none; }
.bxf-confirm h4 { font-size: 13px; line-height: 20px; margin: 0; }
.bxf-confirm p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 19px; margin: 4px 0 0; }
.bxf-confirm .bxf-actions { margin-top: 12px; }
.bxf-error { min-height: 252px; display: grid; grid-template-columns: 44px minmax(0, 1fr); align-content: center; gap: 15px; padding: 30px; }
.bxf-errorIcon { width: 44px; height: 44px; display: grid; place-items: center; border-radius: 13px; color: var(--bxf-error); background: color-mix(in srgb, var(--bxf-error) 9%, transparent); }
.bxf-error h3 { font-size: 17px; line-height: 25px; }
.bxf-error p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 21px; margin-top: 5px; overflow-wrap: anywhere; }
.bxf-errorCode { display: inline-block; color: var(--dsw-alias-label-tertiary, #8f959e); font-family: var(--ds-font-family-code, monospace); font-size: 11px; margin-top: 7px; }
.bxf-statusNotice {
display: flex;
align-items: center;
gap: 9px;
border: 1px solid color-mix(in srgb, var(--bxf-warning) 28%, var(--dsw-alias-border-l2, #dee0e3));
border-radius: 10px;
padding: 9px 11px;
color: var(--dsw-alias-label-secondary, #646a73);
background: color-mix(in srgb, var(--bxf-warning) 5%, var(--dsw-alias-bg-layer-1, #fff));
font-size: 12px;
line-height: 18px;
}
.bxf-statusNotice > svg { flex: none; color: var(--bxf-warning); }
.bxf-statusNotice > span { min-width: 0; flex: 1; overflow-wrap: anywhere; }
.bxf-skeleton { min-height: 260px; padding: 28px; }
.bxf-skeletonLine { height: 12px; border-radius: 999px; background: linear-gradient(90deg, var(--dsw-alias-bg-module-platform, #f2f3f5), color-mix(in srgb, var(--dsw-alias-label-tertiary, #8f959e) 10%, transparent), var(--dsw-alias-bg-module-platform, #f2f3f5)); background-size: 220% 100%; animation: bxf-shimmer 1.5s linear infinite; }
.bxf-skeletonLine:nth-child(1) { width: 92px; }
.bxf-skeletonLine:nth-child(2) { width: 44%; height: 22px; margin-top: 23px; }
.bxf-skeletonLine:nth-child(3) { width: 72%; margin-top: 14px; }
.bxf-skeletonLine:nth-child(4) { width: 58%; margin-top: 9px; }
.bxf-skeletonBox { width: 138px; height: 38px; border-radius: 8px; background: var(--dsw-alias-bg-module-platform, #f2f3f5); margin-top: 28px; }
.bxf-visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@keyframes bxf-rotate { to { transform: rotate(360deg); } }
@keyframes bxf-pulse { 0%, 100% { transform: scale(.9); opacity: .45; } 50% { transform: scale(1.08); opacity: 1; } }
@keyframes bxf-shimmer { to { background-position: -220% 0; } }
@container (max-width: 620px) {
.bxf-heading { flex-direction: column; gap: 10px; }
.bxf-headingTools { width: 100%; justify-content: flex-start; }
.bxf-headingTools .bxf-button { margin-left: auto; }
}
@media (max-width: 680px) {
.bxf-heading { flex-direction: column; gap: 10px; }
.bxf-headingTools { width: 100%; justify-content: flex-start; }
.bxf-headingTools .bxf-button { margin-left: auto; }
.bxf-intro { grid-template-columns: minmax(0, 1fr); }
.bxf-markStage { display: none; }
.bxf-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
.bxf-qrCopy { width: 100%; }
.bxf-statusGrid { grid-template-columns: minmax(0, 1fr); }
.bxf-connectedTop, .bxf-connectedFooter { align-items: flex-start; flex-direction: column; }
.bxf-botActions { width: 100%; justify-content: flex-start; }
.bxf-botActions .bxf-button { min-height: 44px; }
.bxf-inlineError { grid-template-columns: minmax(0, 1fr); padding: 20px; }
.bxf-statusNotice { align-items: flex-start; flex-wrap: wrap; }
.bxf-cardBody { padding: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.bxf-page *, .bxf-page *::before, .bxf-page *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; }
}
`;
export function installFeishuStyles() {
if (typeof document === "undefined") {
return () => {};
}
const existing = document.querySelector(
`style[data-plugin-css="${FEISHU_STYLE_ID}"]`,
);
if (existing) {
return () => {};
}
const style = document.createElement("style");
style.dataset.plugin = "@xmanrui/dsh-feishu";
style.dataset.pluginCss = FEISHU_STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
return () => {
style.remove();
};
}

View file

@ -0,0 +1,156 @@
export const WEIXIN_RPC_CHANNEL = '/weixin';
export const WEIXIN_ENDPOINTS = Object.freeze({
status: 'connection.status',
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
submitVerification: 'provision.verify',
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
const PROVISION_STATES = new Set([
'starting',
'pending',
'scanned',
'needs_verification',
'connecting',
'connected',
'expired',
'failed',
'cancelled',
]);
function isRecord(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function string(value, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function timestamp(value) {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
export function unwrapRpcResult(result) {
if (!isRecord(result) || typeof result.ok !== 'boolean') {
throw new Error('微信服务返回了无法识别的响应');
}
if (!result.ok) {
const error = new Error(string(result.error?.message, '微信操作失败'));
error.code = string(result.error?.code, 'WEIXIN_RPC_ERROR');
throw error;
}
return result.value;
}
export function safeQrSource(value) {
return typeof value === 'string'
&& /^data:image\/(?:png|webp|svg\+xml)(?:;charset=[^;,]+)?;base64,/i.test(value)
? value
: undefined;
}
export function safeVerificationUrl(value) {
if (typeof value !== 'string') return undefined;
try {
const url = new URL(value);
const host = url.hostname.toLowerCase();
return url.protocol === 'https:'
&& (host === 'weixin.qq.com' || host.endsWith('.weixin.qq.com'))
? url.toString()
: undefined;
} catch {
return undefined;
}
}
export function normalizeProvisioning(value) {
if (!isRecord(value) || !string(value.attemptId)) {
throw new Error('微信扫码服务没有返回有效的绑定任务');
}
const status = PROVISION_STATES.has(value.status) ? value.status : 'failed';
const result = {
attemptId: string(value.attemptId),
status,
expiresAt: timestamp(value.expiresAt) ?? Date.now(),
pollIntervalMs: Math.min(5_000, Math.max(500, Number(value.pollIntervalMs) || 1_000)),
verificationRequired: value.verificationRequired === true || status === 'needs_verification',
};
const verificationUrl = safeVerificationUrl(value.verificationUrl);
const qrCodeDataUrl = safeQrSource(value.qrCodeDataUrl);
if (verificationUrl) result.verificationUrl = verificationUrl;
if (qrCodeDataUrl) result.qrCodeDataUrl = qrCodeDataUrl;
if (string(value.botId)) result.botId = string(value.botId);
if (value.alreadyConnected === true) result.alreadyConnected = true;
if (isRecord(value.error)) {
result.error = {
code: string(value.error.code, 'WEIXIN_PROVISION_FAILED'),
message: string(value.error.message, '微信绑定没有完成'),
};
}
return result;
}
function normalizeBot(value) {
if (!isRecord(value) || !string(value.botId) || !isRecord(value.bot)) return null;
const state = ACCOUNT_STATES.has(value.state) ? value.state : 'error';
const connected = value.connected === true;
return {
botId: string(value.botId),
state: connected ? 'connected' : state,
connected,
configured: value.configured === true,
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
},
health: {
status: string(value.health?.status, connected ? 'healthy' : 'offline'),
summary: string(value.health?.summary, connected ? '微信连接正常' : '微信连接未就绪'),
lastCheckedAt: timestamp(value.health?.lastCheckedAt),
},
stats: {
messagesReceived: Math.max(0, Number(value.stats?.messagesReceived) || 0),
messagesReplied: Math.max(0, Number(value.stats?.messagesReplied) || 0),
},
error: isRecord(value.error)
? {
code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
message: string(value.error.message, '微信连接未就绪'),
}
: null,
};
}
export function normalizeSnapshot(value) {
if (!isRecord(value) || !Array.isArray(value.bots)) {
throw new Error('微信服务没有返回有效的账号列表');
}
const bots = value.bots.map(normalizeBot).filter(Boolean);
return {
schemaVersion: Number(value.schemaVersion) || 1,
revision: Number(value.revision) || 0,
state: string(value.state, 'offline'),
bots,
totals: {
configured: bots.length,
connected: bots.filter((bot) => bot.connected).length,
},
provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
};
}
export function presentError(error) {
return {
code: string(error?.code, 'WEIXIN_ERROR'),
message: string(error?.message, '微信操作失败,请稍后重试'),
};
}
export function formatRemaining(milliseconds) {
const seconds = Math.max(0, Math.ceil(milliseconds / 1_000));
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;
}

View file

@ -0,0 +1,562 @@
import * as React from 'react';
import {
WEIXIN_ENDPOINTS,
WEIXIN_RPC_CHANNEL,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
presentError,
safeQrSource,
safeVerificationUrl,
unwrapRpcResult,
} from './api.js';
import { installWeixinStyles } from './styles.js';
const h = React.createElement;
export const name = 'weixin-settings';
export const inject = ['slots', 'connection'];
function WeixinIcon({ size = 26 }) {
return h('svg', {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
xmlns: 'http://www.w3.org/2000/svg',
'aria-hidden': 'true',
},
h('path', {
d: 'M9.7 4.2c-4.15 0-7.5 2.72-7.5 6.08 0 1.91 1.08 3.61 2.78 4.72l-.7 2.35 2.75-1.37c.84.25 1.73.38 2.67.38 4.14 0 7.5-2.72 7.5-6.08S13.84 4.2 9.7 4.2Z',
fill: 'currentColor', opacity: '.96',
}),
h('path', {
d: 'M14.4 8.2c4.08 0 7.4 2.66 7.4 5.95 0 1.78-.97 3.38-2.5 4.47l.58 2-2.36-1.18c-.97.43-2.03.65-3.12.65-4.09 0-7.4-2.66-7.4-5.94 0-3.29 3.31-5.95 7.4-5.95Z',
fill: 'currentColor', stroke: 'white', strokeWidth: '.8',
}),
h('circle', { cx: '7.25', cy: '9.5', r: '1', fill: 'white' }),
h('circle', { cx: '11.55', cy: '9.5', r: '1', fill: 'white' }),
h('circle', { cx: '12.1', cy: '13.2', r: '.9', fill: 'white' }),
h('circle', { cx: '16.3', cy: '13.2', r: '.9', fill: 'white' }));
}
const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props },
ref,
) {
return h('button', {
...props,
ref,
type: 'button',
className: `dxw-button ${className}`.trim(),
'data-kind': kind,
}, children);
});
function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
return h('div', { className: 'dxw-heading' },
h('div', { className: 'dxw-tools' },
totals.configured > 0
? h('div', { className: 'dxw-badge' },
h('span', { className: 'dxw-dot', 'data-tone': totals.connected > 0 ? 'success' : 'warning' }),
h('span', null, `${totals.connected} / ${totals.configured} 在线`))
: null,
h('div', { className: 'dxw-badge', title: 'bot_token 仅保存在 Harness Host 凭据服务中' },
h('span', null, '凭据仅保存在本机')),
h(Button, {
kind: 'primary',
onClick: onAdd,
disabled: adding || busy,
ref: addButtonRef,
}, adding ? '正在绑定' : '扫码绑定微信'),
),
);
}
function LoadingView() {
return h('div', { className: 'dxw-card dxw-loading', 'aria-busy': 'true' },
h('div', { className: 'dxw-spinner' }),
h('span', null, '正在读取微信连接状态…'));
}
function EmptyView({ onStart, busy }) {
return h('div', { className: 'dxw-card' },
h('div', { className: 'dxw-cardBody dxw-empty' },
h('div', null,
h('div', { className: 'dxw-stateLabel' },
h('span', { className: 'dxw-dot' }), h('span', null, '尚未绑定微信')),
h('h3', null, '扫一次码,就能在微信里使用 Harness'),
h('p', null, '二维码由腾讯微信 iLink 服务签发。用手机微信扫描并确认后,账号凭据会直接写入 Harness Host,浏览器不会收到 bot_token。'),
h('div', { className: 'dxw-actions' },
h(Button, { kind: 'primary', onClick: onStart, disabled: busy },
busy ? '正在生成二维码…' : '生成微信二维码')),
),
h('div', { className: 'dxw-logo', 'aria-hidden': 'true' }, h(WeixinIcon, { size: 64 })),
));
}
function QrPanel({ provision, now, busy, onRefresh, onCancel }) {
const [imageFailed, setImageFailed] = React.useState(false);
const source = safeQrSource(provision.qrCodeDataUrl);
const href = safeVerificationUrl(provision.verificationUrl);
const remaining = Math.max(0, provision.expiresAt - now);
const expired = remaining === 0 || provision.status === 'expired';
const duration = Math.max(1, provision.durationMs ?? 5 * 60_000);
const progress = Math.round(Math.min(1, remaining / duration) * 100);
React.useEffect(() => setImageFailed(false), [source]);
return h('div', { className: 'dxw-card' },
h('div', { className: 'dxw-cardBody dxw-qrLayout' },
h('div', { className: 'dxw-qrColumn' },
h('div', { className: 'dxw-qrFrame' },
source && !imageFailed
? h('img', {
src: source,
alt: '用于把微信机器人绑定到 DeepSeek Harness 的一次性二维码',
onError: () => setImageFailed(true),
})
: h('div', { className: 'dxw-qrFallback' }, '二维码图片未就绪,请使用备用链接。'),
expired ? h('div', { className: 'dxw-expired' }, '二维码已过期\n请重新生成') : null,
),
h('div', { className: 'dxw-countdown' },
h('div', null, h('span', null, '二维码有效时间'), h('strong', null, formatRemaining(remaining))),
h('div', { className: 'dxw-progress', 'aria-hidden': 'true' },
h('span', { style: { '--dxw-progress': `${progress}%` } })),
)),
h('div', { className: 'dxw-qrCopy' },
h('div', { className: 'dxw-stateLabel' },
h('span', { className: 'dxw-dot', 'data-tone': provision.status === 'scanned' ? 'success' : 'warning' }),
h('span', null, provision.status === 'scanned' ? '已扫码,请在手机上确认' : '等待微信扫码')),
h('h3', null, expired ? '二维码已失效' : '使用手机微信扫描二维码'),
h('p', null, '请在手机上核对并确认授权。部分账号会额外显示一个配对数字,页面会在需要时提示输入。'),
h('ol', { className: 'dxw-steps' },
h('li', null, '打开手机微信并扫描左侧二维码'),
h('li', null, '在微信中确认连接该机器人'),
h('li', null, '保持本页打开,等待消息长轮询变为在线')),
h('div', { className: 'dxw-actions' },
expired
? h(Button, { kind: 'primary', onClick: onRefresh, disabled: busy }, '重新生成二维码')
: null,
href ? h('a', {
className: 'dxw-button', href, target: '_blank', rel: 'noopener noreferrer',
}, '打开备用链接') : null,
!expired ? h(Button, { onClick: onRefresh, disabled: busy }, '换一个二维码') : null,
h(Button, { onClick: onCancel, disabled: busy }, '取消')),
),
));
}
function VerificationPanel({ provision, busy, onSubmit, onCancel }) {
const [code, setCode] = React.useState('');
const valid = /^\d{4,8}$/.test(code);
React.useEffect(() => setCode(''), [provision.attemptId]);
return h('div', { className: 'dxw-card' },
h('form', {
className: 'dxw-verify',
onSubmit: (event) => {
event.preventDefault();
if (valid && !busy) onSubmit(code);
},
},
h('div', { className: 'dxw-stateLabel' },
h('span', { className: 'dxw-dot', 'data-tone': 'warning' }), h('span', null, '需要配对码')),
h('h3', null, '输入手机微信显示的数字'),
h('p', null, '这是微信附加的安全确认步骤。配对码只用于本次扫码轮询,不会写入配置或日志。'),
h('div', { className: 'dxw-codeRow' },
h('input', {
className: 'dxw-input',
value: code,
inputMode: 'numeric',
autoComplete: 'one-time-code',
maxLength: 8,
'aria-label': '微信配对码',
onChange: (event) => setCode(event.target.value.replace(/\D/g, '').slice(0, 8)),
autoFocus: true,
}),
h('button', {
type: 'submit',
className: 'dxw-button',
'data-kind': 'primary',
disabled: !valid || busy,
}, busy ? '正在验证…' : '继续连接')),
h(Button, { onClick: onCancel, disabled: busy }, '取消绑定')));
}
function ProgressPanel({ scanned, onCancel, busy }) {
return h('div', { className: 'dxw-card dxw-loading', 'aria-busy': 'true' },
h('div', { className: 'dxw-spinner' }),
h('h3', null, scanned ? '微信已确认,正在启动消息连接' : '正在准备微信二维码'),
h('p', null, scanned ? '正在保存凭据并验证 Harness 与微信长轮询。' : '正在联系腾讯微信 iLink 服务。'),
onCancel ? h('div', { className: 'dxw-actions', style: { justifyContent: 'center', marginTop: 14 } },
h(Button, { onClick: onCancel, disabled: busy }, '取消')) : null);
}
function ProvisionError({ provision, busy, onRetry, onClose }) {
const error = provision.error ?? { code: 'WEIXIN_PROVISION_FAILED', message: '微信绑定没有完成' };
return h('div', { className: 'dxw-card' },
h('div', { className: 'dxw-error', role: 'alert' },
h('h3', null, provision.status === 'expired' ? '二维码已过期' : '微信没有绑定完成'),
h('p', null, error.message),
h('span', { className: 'dxw-errorCode' }, error.code),
h('div', { className: 'dxw-actions' },
h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
}
function checkedTime(timestamp) {
if (!timestamp) return '尚未检查';
try {
return new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit',
}).format(new Date(timestamp));
} catch {
return '刚刚';
}
}
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
return h('article', { className: 'dxw-card', tabIndex: -1, 'data-bot-id': account.botId },
h('div', { className: 'dxw-cardBody' },
h('div', { className: 'dxw-accountTop' },
h('div', { className: 'dxw-accountIdentity' },
h('div', { className: 'dxw-avatar', 'aria-hidden': 'true' }, h(WeixinIcon, { size: 27 })),
h('div', null, h('h3', null, account.bot.name), h('p', null, account.bot.accountIdMasked))),
h('div', { className: 'dxw-health' },
h('span', { className: 'dxw-dot', 'data-tone': tone }),
h('span', null, account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪'))),
h('dl', { className: 'dxw-metrics' },
h('div', { className: 'dxw-metric' }, h('dt', null, '消息通道'),
h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
h('div', { className: 'dxw-metric' }, h('dt', null, '收到 / 回复'),
h('dd', null, `${account.stats.messagesReceived} / ${account.stats.messagesReplied}`)),
h('div', { className: 'dxw-metric' }, h('dt', null, '最近检查'),
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h('div', { className: 'dxw-accountFooter' },
h('div', { className: 'dxw-summary' }, account.error?.message ?? account.health.summary),
h('div', { className: 'dxw-actions' },
h(Button, { onClick: onReconnect, disabled: Boolean(busy) },
busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
h(Button, { kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')))),
removing ? h('div', { className: 'dxw-confirm', role: 'alertdialog' },
h('strong', null, '从此 Harness 移除这个微信账号?'),
h('p', null, '这会停止消息连接,并删除本机保存的 bot_token、账号配置和会话映射。其他微信账号不受影响。'),
h('div', { className: 'dxw-actions' },
h(Button, { onClick: onCancelRemove, disabled: busy === 'delete' }, '保留账号'),
h(Button, { kind: 'danger', onClick: onConfirmRemove, disabled: busy === 'delete' },
busy === 'delete' ? '正在移除…' : '确认移除')))
: null);
}
function AccountList(props) {
return h('section', null,
h('div', { className: 'dxw-listHeading' }, h('h3', null, '已接入的微信账号'), h('span', null, `${props.bots.length} 个`)),
h('ul', { className: 'dxw-list' }, props.bots.map((account) => h('li', { key: account.botId },
h(AccountCard, {
account,
busy: props.busyByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
})))));
}
const EMPTY_TOTALS = Object.freeze({ configured: 0, connected: 0 });
export function WeixinSettingsTab({ rpcCall }) {
const [model, setModel] = React.useState({
phase: 'loading', bots: [], totals: EMPTY_TOTALS, revision: 0, error: null,
});
const [provision, setProvision] = React.useState(null);
const [busy, setBusy] = React.useState(false);
const [busyByBot, setBusyByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now());
const addButtonRef = React.useRef(null);
const announce = React.useCallback((value) => {
setNotice('');
if (value) window.requestAnimationFrame(() => setNotice(value));
}, []);
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
});
if (snapshot.provisioning) {
setProvision((current) => !current || current.attemptId === snapshot.provisioning.attemptId
? { ...current, ...snapshot.provisioning, durationMs: current?.durationMs ?? 5 * 60_000 }
: current);
}
return snapshot;
} catch (error) {
if (error?.name === 'AbortError') return undefined;
setModel((current) => ({
...current,
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
error: presentError(error),
}));
return undefined;
}
}, [invoke]);
React.useEffect(() => {
const controller = new AbortController();
void loadStatus({ signal: controller.signal });
return () => controller.abort();
}, [loadStatus]);
React.useEffect(() => {
if (model.phase !== 'ready') return undefined;
const controller = new AbortController();
let running = false;
const timer = window.setInterval(async () => {
if (running) return;
running = true;
await loadStatus({ signal: controller.signal, silent: true });
running = false;
}, 15_000);
return () => {
controller.abort();
window.clearInterval(timer);
};
}, [loadStatus, model.phase]);
React.useEffect(() => {
if (!provision || !['pending', 'scanned'].includes(provision.status)) return undefined;
const timer = window.setInterval(() => setNow(Date.now()), 1_000);
return () => window.clearInterval(timer);
}, [provision?.attemptId, provision?.status]);
const startProvisioning = React.useCallback(async ({ replace = false } = {}) => {
setBusy(true);
try {
if (replace && provision?.attemptId) {
await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
}
setProvision({ status: 'starting' });
const started = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' }));
setNow(Date.now());
setProvision({ ...started, durationMs: Math.max(1, started.expiresAt - Date.now()) });
announce('微信二维码已生成,请使用手机微信扫描。');
} catch (error) {
setProvision({
status: 'failed',
error: presentError(error),
...(provision?.attemptId ? { attemptId: provision.attemptId } : {}),
});
} finally {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId]);
const cancelProvisioning = React.useCallback(async () => {
setBusy(true);
try {
if (provision?.attemptId && !['failed', 'expired', 'cancelled'].includes(provision.status)) {
await invoke(WEIXIN_ENDPOINTS.cancelProvisioning, { attemptId: provision.attemptId });
}
setProvision(null);
announce('已取消微信绑定。');
window.requestAnimationFrame(() => addButtonRef.current?.focus());
} catch (error) {
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
} finally {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId, provision?.status]);
const submitVerification = React.useCallback(async (verifyCode) => {
if (!provision?.attemptId) return;
setBusy(true);
try {
const next = normalizeProvisioning(await invoke(WEIXIN_ENDPOINTS.submitVerification, {
attemptId: provision.attemptId,
verifyCode,
}));
setProvision((current) => ({ ...current, ...next }));
announce('配对码已提交,正在等待微信确认。');
} catch (error) {
setProvision((current) => ({ ...current, status: 'failed', error: presentError(error) }));
} finally {
setBusy(false);
}
}, [announce, invoke, provision?.attemptId]);
React.useEffect(() => {
const attemptId = provision?.attemptId;
if (!attemptId || !['pending', 'scanned', 'connecting'].includes(provision.status)) return undefined;
const controller = new AbortController();
let timer;
const poll = async () => {
try {
const result = normalizeProvisioning(await invoke(
WEIXIN_ENDPOINTS.pollProvisioning,
{ attemptId },
controller.signal,
));
if (result.status === 'connected') {
const snapshot = await loadStatus({ signal: controller.signal, silent: true });
const account = snapshot?.bots.find((bot) => bot.botId === result.botId);
if (!account?.connected) {
setProvision((current) => current?.attemptId === attemptId
? { ...current, ...result, status: 'connecting' }
: current);
timer = window.setTimeout(poll, result.pollIntervalMs);
return;
}
setProvision(null);
announce(result.alreadyConnected
? '这个微信账号已经绑定并保持在线。'
: '微信已绑定,可以开始向已绑定的机器人发消息。');
return;
}
setProvision((current) => current?.attemptId === attemptId
? { ...current, ...result, durationMs: current.durationMs }
: current);
if (['pending', 'scanned', 'connecting'].includes(result.status)) {
timer = window.setTimeout(poll, result.pollIntervalMs);
}
} catch (error) {
if (error?.name === 'AbortError') return;
setProvision((current) => current?.attemptId === attemptId
? { ...current, status: 'failed', error: presentError(error) }
: current);
}
};
timer = window.setTimeout(poll, provision.pollIntervalMs ?? 1_000);
return () => {
controller.abort();
window.clearTimeout(timer);
};
}, [announce, invoke, loadStatus, provision?.attemptId, provision?.status, provision?.pollIntervalMs]);
const setBotBusy = React.useCallback((botId, value) => {
setBusyByBot((current) => {
const next = { ...current };
if (value) next[botId] = value;
else delete next[botId];
return next;
});
}, []);
const reconnect = React.useCallback(async (account) => {
setBotBusy(account.botId, 'reconnect');
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
} catch (error) {
announce(`连接检查失败:${presentError(error).message}`);
} finally {
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
const remove = React.useCallback(async (account) => {
setBotBusy(account.botId, 'delete');
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
botId: account.botId,
confirm: true,
}));
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
setRemoveTarget(null);
announce('微信账号及本机凭据已移除。');
} catch (error) {
announce(`移除失败:${presentError(error).message}`);
} finally {
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
let provisionView = null;
if (provision?.status === 'starting') {
provisionView = h(ProgressPanel, { busy });
} else if (['pending', 'scanned'].includes(provision?.status)) {
provisionView = h(QrPanel, {
provision, now, busy,
onRefresh: () => void startProvisioning({ replace: true }),
onCancel: () => void cancelProvisioning(),
});
} else if (provision?.status === 'needs_verification') {
provisionView = h(VerificationPanel, {
provision, busy,
onSubmit: (code) => void submitVerification(code),
onCancel: () => void cancelProvisioning(),
});
} else if (provision?.status === 'connecting') {
provisionView = h(ProgressPanel, {
scanned: true, busy, onCancel: () => void cancelProvisioning(),
});
} else if (provision && ['failed', 'expired', 'cancelled'].includes(provision.status)) {
provisionView = h(ProvisionError, {
provision, busy,
onRetry: () => void startProvisioning({ replace: Boolean(provision.attemptId) }),
onClose: () => void cancelProvisioning(),
});
}
return h('section', { className: 'dxw-page', 'aria-label': '微信设置' },
h(Heading, {
totals: model.totals,
adding: Boolean(provision),
busy,
onAdd: () => void startProvisioning(),
addButtonRef,
}),
h('div', { className: 'dxw-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
model.error && model.phase === 'ready'
? h('div', { className: 'dxw-statusNotice' }, `状态刷新失败:${model.error.message}`)
: null,
model.phase === 'loading'
? h(LoadingView)
: model.phase === 'error'
? h('div', { className: 'dxw-card' },
h('div', { className: 'dxw-error' },
h('h3', null, '无法读取微信状态'),
h('p', null, model.error?.message ?? '请稍后重试'),
h(Button, { onClick: () => void loadStatus() }, '重新读取')))
: h(React.Fragment, null,
provisionView,
model.bots.length === 0 && !provision
? h(EmptyView, { onStart: () => void startProvisioning(), busy })
: null,
model.bots.length > 0
? h(AccountList, {
bots: model.bots,
busyByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),
})
: null),
);
}
export function apply(ctx) {
ctx.effect(() => installWeixinStyles(), 'weixin-settings: install client styles');
const rpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'weixin',
order: 30,
label: '微信',
inject: () => ({ rpcCall }),
}, WeixinSettingsTab));
}

View file

@ -0,0 +1,120 @@
export const WEIXIN_STYLE_ID = 'xmanrui-dsh-weixin-settings';
const CSS = String.raw`
.dxw-page {
--dxw-accent: #07c160;
--dxw-accent-dark: #05994c;
--dxw-success: var(--dsw-alias-state-success-primary, #20a162);
--dxw-warning: var(--dsw-alias-state-warning-primary, #d97706);
--dxw-error: var(--dsw-alias-state-error-primary, #d54941);
width: 100%;
max-width: 880px;
display: flex;
flex-direction: column;
gap: 18px;
padding: 2px 0 28px;
color: var(--dsw-alias-label-primary, #1f2329);
box-sizing: border-box;
}
.dxw-page *, .dxw-page *::before, .dxw-page *::after { box-sizing: border-box; }
.dxw-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; }
.dxw-heading h2, .dxw-heading p, .dxw-card h3, .dxw-card p { margin: 0; }
.dxw-eyebrow { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; font-weight: 650; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 3px; }
.dxw-heading h2 { font-size: 20px; line-height: 28px; font-weight: 680; }
.dxw-heading p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; margin-top: 5px; white-space: nowrap; }
.dxw-tools, .dxw-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.dxw-tools { justify-content: flex-end; }
.dxw-badge { display: inline-flex; align-items: center; gap: 7px; min-height: 30px; padding: 0 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 12px; white-space: nowrap; }
.dxw-dot { width: 8px; height: 8px; border-radius: 50%; background: #aeb3bb; flex: none; }
.dxw-dot[data-tone="success"] { background: var(--dxw-success); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dxw-success) 14%, transparent); }
.dxw-dot[data-tone="warning"] { background: var(--dxw-warning); }
.dxw-dot[data-tone="error"] { background: var(--dxw-error); }
.dxw-button { min-height: 34px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 8px; padding: 0 13px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-body, #fff); font: inherit; font-size: 13px; font-weight: 560; cursor: pointer; text-decoration: none; transition: border-color .15s ease, background .15s ease, transform .15s ease; }
.dxw-button:hover:not(:disabled) { border-color: #aeb3bb; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
.dxw-button:active:not(:disabled) { transform: translateY(1px); }
.dxw-button:focus-visible, .dxw-input:focus-visible { outline: 2px solid color-mix(in srgb, var(--dxw-accent) 70%, white); outline-offset: 2px; }
.dxw-button:disabled { cursor: not-allowed; opacity: .55; }
.dxw-button[data-kind="primary"] { color: white; border-color: var(--dxw-accent); background: var(--dxw-accent); }
.dxw-button[data-kind="primary"]:hover:not(:disabled) { border-color: var(--dxw-accent-dark); background: var(--dxw-accent-dark); }
.dxw-button[data-kind="danger"] { color: var(--dxw-error); }
.dxw-card { overflow: hidden; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-body, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
.dxw-cardBody { padding: 24px; }
.dxw-empty { min-height: 230px; display: grid; grid-template-columns: minmax(0, 1fr) 180px; align-items: center; gap: 30px; }
.dxw-empty h3 { font-size: 18px; margin-bottom: 8px; }
.dxw-empty p { max-width: 560px; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; }
.dxw-empty .dxw-actions { margin-top: 20px; }
.dxw-logo { width: 110px; height: 110px; display: grid; place-items: center; justify-self: center; border-radius: 28px; color: white; background: var(--dxw-accent); box-shadow: 0 18px 45px rgb(7 193 96 / 22%); }
.dxw-logo svg { width: 62px; height: 62px; }
.dxw-qrLayout { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 34px; align-items: center; }
.dxw-qrColumn { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.dxw-qrFrame { position: relative; width: 270px; aspect-ratio: 1; display: grid; place-items: center; overflow: hidden; padding: 10px; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); border-radius: 16px; background: white; }
.dxw-qrFrame img { display: block; width: 100%; height: 100%; object-fit: contain; }
.dxw-qrFallback { padding: 24px; text-align: center; color: #646a73; }
.dxw-expired { position: absolute; inset: 0; display: grid; place-items: center; padding: 30px; color: white; text-align: center; font-weight: 650; background: rgb(31 35 41 / 76%); backdrop-filter: blur(3px); }
.dxw-countdown { width: 270px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
.dxw-countdown div { display: flex; justify-content: space-between; margin-bottom: 6px; }
.dxw-progress { height: 4px; overflow: hidden; border-radius: 99px; background: #eef0f3; }
.dxw-progress span { display: block; width: var(--dxw-progress); height: 100%; background: var(--dxw-accent); transition: width .2s linear; }
.dxw-qrCopy h3 { margin: 9px 0 8px; font-size: 18px; }
.dxw-qrCopy > p { color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.65; }
.dxw-steps { margin: 18px 0 22px; padding-left: 22px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 1.9; }
.dxw-stateLabel { display: inline-flex; align-items: center; gap: 8px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; font-weight: 600; }
.dxw-verify { max-width: 560px; margin: 0 auto; padding: 32px; text-align: center; }
.dxw-verify h3 { margin: 8px 0; font-size: 19px; }
.dxw-verify p { color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; }
.dxw-codeRow { display: flex; justify-content: center; gap: 10px; margin: 24px 0 10px; }
.dxw-input { width: 190px; height: 42px; padding: 0 14px; border: 1px solid var(--dsw-alias-line-border, #dfe1e5); border-radius: 9px; background: var(--dsw-alias-bg-body, white); color: inherit; font: inherit; font-size: 18px; letter-spacing: .16em; text-align: center; }
.dxw-statusNotice, .dxw-error { display: flex; align-items: center; gap: 10px; padding: 13px 15px; border: 1px solid color-mix(in srgb, var(--dxw-error) 28%, transparent); border-radius: 10px; color: var(--dxw-error); background: color-mix(in srgb, var(--dxw-error) 7%, transparent); font-size: 13px; }
.dxw-error { align-items: flex-start; flex-direction: column; padding: 22px; }
.dxw-error h3 { font-size: 17px; }
.dxw-errorCode { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 11px; opacity: .8; }
.dxw-listHeading { display: flex; justify-content: space-between; align-items: center; margin: 2px 0 9px; }
.dxw-listHeading h3 { margin: 0; font-size: 14px; }
.dxw-listHeading span { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; }
.dxw-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.dxw-accountTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
.dxw-accountIdentity { display: flex; align-items: center; gap: 12px; min-width: 0; }
.dxw-avatar { width: 42px; height: 42px; display: grid; place-items: center; flex: none; border-radius: 12px; color: white; background: var(--dxw-accent); }
.dxw-accountIdentity h3 { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
.dxw-accountIdentity p { color: var(--dsw-alias-label-secondary, #646a73); font: 12px ui-monospace, SFMono-Regular, monospace; margin-top: 4px; }
.dxw-health { display: inline-flex; align-items: center; gap: 7px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; white-space: nowrap; }
.dxw-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 20px 0; }
.dxw-metric { padding: 12px 14px; border-radius: 9px; background: var(--dsw-alias-fill-tertiary, #f7f8fa); }
.dxw-metric dt { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; }
.dxw-metric dd { overflow: hidden; margin: 5px 0 0; font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.dxw-accountFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 16px; border-top: 1px solid var(--dsw-alias-line-divider, #eef0f3); }
.dxw-summary { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; }
.dxw-confirm { padding: 18px 24px; border-top: 1px solid color-mix(in srgb, var(--dxw-error) 25%, transparent); background: color-mix(in srgb, var(--dxw-error) 5%, transparent); }
.dxw-confirm strong { display: block; font-size: 14px; margin-bottom: 6px; }
.dxw-confirm p { color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; }
.dxw-confirm .dxw-actions { margin-top: 13px; }
.dxw-loading { padding: 36px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
.dxw-spinner { width: 24px; height: 24px; margin: 0 auto 12px; border: 3px solid #e6e8eb; border-top-color: var(--dxw-accent); border-radius: 50%; animation: dxw-spin .8s linear infinite; }
.dxw-visuallyHidden { position: absolute !important; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@keyframes dxw-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) {
.dxw-heading, .dxw-accountTop, .dxw-accountFooter { flex-direction: column; align-items: stretch; }
.dxw-tools { justify-content: flex-start; }
.dxw-empty { grid-template-columns: minmax(0, 1fr); }
.dxw-logo { display: none; }
.dxw-qrLayout { grid-template-columns: minmax(0, 1fr); justify-items: center; }
.dxw-qrCopy { width: 100%; }
.dxw-metrics { grid-template-columns: minmax(0, 1fr); }
.dxw-cardBody { padding: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.dxw-page *, .dxw-page *::before, .dxw-page *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}
`;
export function installWeixinStyles() {
if (typeof document === 'undefined') return () => {};
const existing = document.querySelector(`style[data-plugin-css="${WEIXIN_STYLE_ID}"]`);
if (existing) return () => {};
const style = document.createElement('style');
style.dataset.plugin = '@xmanrui/dsh-weixin';
style.dataset.pluginCss = WEIXIN_STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
return () => style.remove();
}

103
plugin-src/client/index.js Normal file
View file

@ -0,0 +1,103 @@
import * as React from 'react';
import { FeishuSettingsTab } from './channels/feishu/index.js';
import { FEISHU_RPC_CHANNEL } from './channels/feishu/api.js';
import { installFeishuStyles } from './channels/feishu/styles.js';
import { WeixinSettingsTab } from './channels/weixin/index.js';
import { WEIXIN_RPC_CHANNEL } from './channels/weixin/api.js';
import { installWeixinStyles } from './channels/weixin/styles.js';
import { installImStyles } from './styles.js';
const h = React.createElement;
export const name = 'im-settings';
export const inject = ['slots', 'connection'];
const CHANNELS = Object.freeze([
{ id: 'weixin', label: '微信', description: '扫码绑定' },
{ id: 'feishu', label: '飞书', description: '扫码接入' },
]);
function WeixinLogo() {
return h('span', { className: 'dim-logo dim-logoWeixin', 'aria-hidden': 'true' },
h('svg', { viewBox: '0 0 24 24', focusable: 'false' },
h('path', {
fill: 'currentColor',
d: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z',
}),
));
}
function FeishuLogo() {
return h('span', { className: 'dim-logo dim-logoFeishu', 'aria-hidden': 'true' },
h('svg', { viewBox: '0 0 24 24', focusable: 'false' },
h('path', { fill: '#00D6B9', d: 'M7.2 4.5h7.6c1.2 0 2.1.55 2.7 1.58 1.05 1.8 1.55 3.45 1.58 4.95-2.04-.62-4.2-.15-6.22 1.45C11.3 9.7 9.42 7.04 7.2 4.5Z' }),
h('path', { fill: '#1456B8', d: 'M10.8 13.55c3.3-2.93 5.72-4.24 9.47-2.52-1.2 1.45-2.27 4.18-3.86 5.43-1.67 1.31-3.9.5-5.61-.64v-2.27Z' }),
h('path', { fill: '#3370FF', d: 'M4.4 8.35c3.47 3.61 7.25 6.1 10.33 5.7 1.06-.14 2.2-.72 3.4-1.72-1.04 2.65-2.6 4.8-5.06 6-2.46 1.2-5.56.52-7.42-.72A2.76 2.76 0 0 1 4.4 15.3V8.35Z' }),
));
}
function ChannelLogo({ channel }) {
return channel === 'weixin' ? h(WeixinLogo) : h(FeishuLogo);
}
export function IMSettingsTab({ feishuRpcCall, weixinRpcCall }) {
const [selected, setSelected] = React.useState('weixin');
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
h('header', { className: 'dim-title' },
h('p', null, '通过扫码把机器人接入 DeepSeek Harness'),
h('div', { className: 'dim-channelCount' }, '2 个渠道'),
),
h('div', { className: 'dim-layout' },
h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
CHANNELS.map((channel) => h('button', {
key: channel.id,
type: 'button',
role: 'tab',
id: `dim-tab-${channel.id}`,
className: 'dim-channel',
'aria-selected': channel.id === active.id,
'aria-controls': `dim-panel-${channel.id}`,
onClick: () => setSelected(channel.id),
},
h(ChannelLogo, { channel: channel.id }),
h('span', { className: 'dim-channelCopy' },
h('strong', null, channel.label),
h('small', null, channel.description),
),
h('span', { className: 'dim-chevron', 'aria-hidden': 'true' }, '›')))),
h('div', { className: 'dim-divider', 'aria-hidden': 'true' }),
h('main', {
className: 'dim-panel',
role: 'tabpanel',
id: `dim-panel-${active.id}`,
'aria-labelledby': `dim-tab-${active.id}`,
}, active.id === 'weixin'
? h(WeixinSettingsTab, { rpcCall: weixinRpcCall })
: h(FeishuSettingsTab, { rpcCall: feishuRpcCall })),
),
);
}
export function apply(ctx) {
ctx.effect(() => {
const disposers = [installFeishuStyles(), installWeixinStyles(), installImStyles()];
return () => {
for (const dispose of disposers.reverse()) dispose();
};
}, 'im-settings: install combined channel styles');
const feishuRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(FEISHU_RPC_CHANNEL, endpoint, payload, signal);
const weixinRpcCall = (endpoint, payload, signal) =>
ctx.connection.rpc.call(WEIXIN_RPC_CHANNEL, endpoint, payload, signal);
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
name: 'settings.plugins.tab',
id: 'im',
order: 20,
label: 'IM机器人',
inject: () => ({ feishuRpcCall, weixinRpcCall }),
}, IMSettingsTab));
}

View file

@ -0,0 +1,66 @@
export const IM_STYLE_ID = 'xmanrui-dsh-im-settings';
const CSS = String.raw`
.dim-page {
--dim-blue: #3370ff;
--dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent);
width: 100%;
max-width: 1080px;
padding: 2px 0 30px;
color: var(--dsw-alias-label-primary, #1f2329);
box-sizing: border-box;
}
.dim-page *, .dim-page *::before, .dim-page *::after { box-sizing: border-box; }
.dim-title { display: flex; align-items: center; justify-content: space-between; gap: 22px; margin: 0 0 26px; }
.dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; white-space: nowrap; }
.dim-channelCount { flex: none; padding: 6px 11px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-fill-secondary, #f2f3f5); font-size: 11px; white-space: nowrap; }
.dim-layout { display: grid; grid-template-columns: 188px 1px minmax(0, 1fr); gap: 26px; align-items: start; }
.dim-rail { display: grid; gap: 12px; }
.dim-channel { width: 100%; min-height: 76px; display: grid; grid-template-columns: 42px minmax(0, 1fr) 14px; align-items: center; gap: 12px; padding: 13px 12px; border: 1px solid transparent; border-radius: 16px; color: inherit; background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 5px 18px rgb(31 35 41 / 5%); font: inherit; text-align: left; cursor: pointer; transition: border-color .16s ease, background .16s ease, box-shadow .16s ease, transform .16s ease; }
.dim-channel:hover { transform: translateY(-1px); border-color: color-mix(in srgb, var(--dim-blue) 25%, transparent); box-shadow: 0 8px 24px rgb(31 35 41 / 8%); }
.dim-channel[aria-selected="true"] { border-color: color-mix(in srgb, var(--dim-blue) 52%, transparent); color: var(--dim-blue); background: var(--dim-blue-soft); box-shadow: 0 0 0 1px color-mix(in srgb, var(--dim-blue) 8%, transparent), 0 10px 26px rgb(51 112 255 / 10%); }
.dim-channel:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 68%, white); outline-offset: 2px; }
.dim-logo { width: 42px; height: 42px; display: grid; place-items: center; border-radius: 12px; box-shadow: 0 1px 4px rgb(31 35 41 / 8%); }
.dim-logo svg { display: block; width: 27px; height: 27px; }
.dim-logoWeixin { color: white; background: #07c160; }
.dim-logoWeixin svg { width: 25px; height: 25px; }
.dim-logoFeishu { background: white; border: 1px solid var(--dsw-alias-line-border, #e5e6eb); }
.dim-logoFeishu svg { width: 30px; height: 30px; }
.dim-channelCopy { min-width: 0; display: grid; gap: 2px; }
.dim-channelCopy strong { overflow: hidden; color: inherit; font-size: 15px; line-height: 21px; font-weight: 680; text-overflow: ellipsis; white-space: nowrap; }
.dim-channelCopy small { overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 10px; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; }
.dim-chevron { color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 22px; line-height: 1; transform: translateY(-1px); }
.dim-channel[aria-selected="true"] .dim-chevron { color: var(--dim-blue); }
.dim-divider { width: 1px; min-height: 520px; background: var(--dsw-alias-line-divider, #eef0f3); }
.dim-panel { min-width: 0; }
.dim-panel .bxf-page, .dim-panel .dxw-page { width: 100%; max-width: none; padding: 0 0 24px; }
.dim-panel .bxf-heading, .dim-panel .dxw-heading { justify-content: flex-end; }
.dim-panel .bxf-headingTools, .dim-panel .dxw-tools { width: 100%; justify-content: flex-end; }
@media (max-width: 840px) {
.dim-title { align-items: flex-start; }
.dim-layout { grid-template-columns: minmax(0, 1fr); gap: 18px; }
.dim-rail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dim-divider { display: none; }
.dim-channel { min-height: 68px; }
}
@media (max-width: 560px) {
.dim-title { flex-direction: column; gap: 10px; }
.dim-title p { white-space: normal; }
.dim-rail { grid-template-columns: minmax(0, 1fr); }
}
@media (prefers-reduced-motion: reduce) {
.dim-page * { transition-duration: .01ms !important; }
}
`;
export function installImStyles() {
if (typeof document === 'undefined') return () => {};
const existing = document.querySelector(`style[data-plugin-css="${IM_STYLE_ID}"]`);
if (existing) return () => {};
const style = document.createElement('style');
style.dataset.plugin = '@xmanrui/dsh-im';
style.dataset.pluginCss = IM_STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
return () => style.remove();
}