Files
nanobot/nanobot/channels/feishu/webui/FeishuAssistantsPanel.tsx
T
chengyongruandGitHub 462a0dfb0f refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
2026-07-19 23:30:49 +08:00

183 lines
5.6 KiB
TypeScript

import { useState } from "react";
import { Loader2, RotateCcw } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
channelTranslator,
type ChannelTranslator,
} from "@/channel-plugins/i18n";
import type { ChannelPluginPanelProps } from "@/channel-plugins/types";
import { ChannelInstancesPanel } from "@/components/settings/channels/ChannelInstancesPanel";
import { Button } from "@/components/ui/button";
import { enableNanobotFeature } from "@/lib/api";
import type {
NanobotChannelInstanceInfo,
NanobotFeatureInfo,
NanobotFeaturesPayload,
} from "@/lib/types";
import { FeishuConnectFlow } from "./FeishuConnectFlow";
export function FeishuAssistantsPanel({
token,
feature,
showBrandLogos,
chatAppsDocsUrl,
onFeaturesUpdate,
}: ChannelPluginPanelProps) {
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const instances = feature.instances?.length
? feature.instances
: [defaultFeishuInstance(feature)];
return (
<ChannelInstancesPanel
token={token}
feature={feature}
showBrandLogos={showBrandLogos}
chatAppsDocsUrl={chatAppsDocsUrl}
instances={instances}
onFeaturesUpdate={onFeaturesUpdate}
customization={{
countLabel: (count) => feishuAssistantCountLabel(count, tx),
toggleAriaLabel: (instance) => tx("custom.toggleAssistant", "{{name}} assistant", {
name: instanceDisplayName(instance),
}),
configuredLabel: tx("custom.configured", "Connected"),
needsSetupLabel: tx("custom.needsSetup", "Needs authorization"),
renderInstanceSummary: (instance) => (
maskFeishuAppId(instance.config_values?.["channels.feishu.appId"])
|| tx("custom.noAppId", "No App ID")
),
renderInstanceAction: (instance) => (
<FeishuInstanceAction
key={instance.id}
token={token}
instance={instance}
onFeaturesUpdate={onFeaturesUpdate}
/>
),
footer: (
<div className="mt-4 overflow-hidden rounded-[16px] border border-border/70 bg-background px-4 py-4">
<div className="text-[13px] font-semibold text-foreground">
{tx("custom.createAnother", "Create another assistant")}
</div>
<p className="mt-1 text-[12.5px] leading-5 text-muted-foreground">
{tx(
"custom.createHint",
"Create a separate Feishu bot for another team, space, or workflow.",
)}
</p>
<FeishuConnectFlow
token={token}
instanceId="default"
mode="create"
idleLabel={tx("custom.createAssistant", "Create assistant")}
onFeaturesUpdate={onFeaturesUpdate}
/>
</div>
),
}}
/>
);
}
function FeishuInstanceAction({
token,
instance,
onFeaturesUpdate,
}: {
token: string;
instance: NanobotChannelInstanceInfo;
onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void;
}) {
const { t } = useTranslation();
const tx = channelTranslator(t, "feishu");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (!instance.configured) {
return (
<FeishuConnectFlow
token={token}
instanceId={instance.id}
mode="replace"
idleLabel={t("settings.channels.connect", { defaultValue: "Connect" })}
onFeaturesUpdate={onFeaturesUpdate}
/>
);
}
const reconnect = async () => {
setBusy(true);
setError(null);
try {
onFeaturesUpdate(
await enableNanobotFeature(token, "feishu", { instanceId: instance.id }),
);
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
};
return (
<>
<div className="mt-3 flex justify-end">
<Button
type="button"
size="sm"
variant="outline"
className="h-8 rounded-full border-border/65 bg-background/80 px-3 text-[12px] font-semibold hover:bg-muted/70"
onClick={() => void reconnect()}
disabled={busy || !instance.enabled}
>
{busy ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
) : (
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
)}
{tx("custom.reconnect", "Reconnect")}
</Button>
</div>
{error ? (
<div className="mt-3 rounded-[12px] border border-destructive/20 px-3 py-2 text-[12px] leading-5 text-destructive">
{error}
</div>
) : null}
</>
);
}
function defaultFeishuInstance(feature: NanobotFeatureInfo): NanobotChannelInstanceInfo {
return {
id: "default",
name: "nanobot",
enabled: feature.enabled,
configured: Boolean(feature.configured),
config_values: feature.config_values ?? {},
configured_fields: feature.configured_fields ?? [],
};
}
function feishuAssistantCountLabel(
count: number,
tx: ChannelTranslator,
): string {
if (count === 0) return tx("custom.countNone", "No assistant connected");
if (count === 1) return tx("custom.countOne", "1 assistant connected");
return tx("custom.countMany", "{{count}} assistants connected", { count });
}
function instanceDisplayName(instance: NanobotChannelInstanceInfo): string {
return instance.display_name?.trim() || instance.name.trim() || instance.id;
}
function maskFeishuAppId(appId: string | undefined): string {
if (!appId) return "";
if (appId.length <= 10) return appId;
return `${appId.slice(0, 7)}...${appId.slice(-4)}`;
}