feat: support per-bot workspace switching

This commit is contained in:
xmanrui 2026-08-17 23:56:27 +08:00
parent adcace3512
commit 44dd50216a
64 changed files with 3878 additions and 712 deletions

View file

@ -8,6 +8,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -145,6 +146,7 @@ function normalizeBot(value) {
state,
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace, 4_096) ?? '',
bot: {
name: optionalString(bot.name, 100) ?? '钉钉机器人',
clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',

View file

@ -2,6 +2,8 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import {
DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL,
@ -208,6 +210,7 @@ export function AccountCard({
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -231,6 +234,11 @@ export function AccountCard({
h('dd', null, account.connected ? 'Stream 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'),
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -256,6 +264,7 @@ function AccountList(props) {
busy: props.busyByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -279,6 +288,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const statusRequestRef = React.useRef(0);
const workspaceFence = useWorkspaceSnapshotFence();
const noticeFrameRef = React.useRef(null);
const focusFrameRef = React.useRef(null);
@ -335,11 +345,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
restoreProvisioning = false,
} = {}) => {
if (!mountedRef.current || signal?.aborted) return undefined;
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
const requestId = statusRequestRef.current + 1;
statusRequestRef.current = requestId;
const canCommit = () => mountedRef.current
&& !signal?.aborted
&& statusRequestRef.current === requestId;
&& statusRequestRef.current === requestId
&& workspaceFence.canCommitStatus(workspaceVersion);
if (!silent && canCommit()) {
setModel((current) => ({ ...current, phase: 'loading', error: null }));
}
@ -373,7 +386,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
}));
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -450,6 +463,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
if (!mountedRef.current) return;
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -458,21 +472,25 @@ export function DingtalkSettingsTab({ rpcCall }) {
{ clientId: identity, clientSecret: secret },
));
if (!mountedRef.current) return;
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
setCredentialOpen(false);
announce('钉钉机器人凭据已绑定。');
} catch (error) {
if (mountedRef.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBusy(false);
}
}, [announce, invoke]);
}, [announce, invoke, loadStatus, workspaceFence]);
const cancelProvisioning = React.useCallback(async () => {
if (!mountedRef.current) return;
@ -575,12 +593,20 @@ export function DingtalkSettingsTab({ rpcCall }) {
const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => {
if (!mountedRef.current) return undefined;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, operation);
try {
await invoke(endpoint, payload);
if (!mountedRef.current) return undefined;
const snapshot = await loadStatus({ silent: true, restoreProvisioning: false });
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (!mountedRef.current) return undefined;
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
announce(typeof success === 'function' ? success(snapshot) : success);
return snapshot;
} catch (error) {
@ -588,9 +614,11 @@ export function DingtalkSettingsTab({ rpcCall }) {
announce(`操作失败:${presentError(error).message}`);
return undefined;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [announce, invoke, loadStatus, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
const reconnect = React.useCallback((account) => runBotAction({
account,
@ -602,6 +630,30 @@ export function DingtalkSettingsTab({ rpcCall }) {
: '钉钉仍未连接,插件会继续自动重试。',
}), [runBotAction]);
const saveWorkspace = React.useCallback(async (account, workspace) => {
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'workspace');
try {
const snapshot = normalizeSnapshot(await invoke(
DINGTALK_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
setModel({
phase: 'ready',
bots: snapshot.bots,
totals: snapshot.totals,
revision: snapshot.revision,
error: null,
});
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React.useCallback(async (account) => {
const snapshot = await runBotAction({
account,
@ -688,6 +740,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
busyByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -17,6 +17,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
reconnectBot: "bot.reconnect",
disconnectBot: "bot.disconnect",
deleteBot: "bot.delete",
setWorkspace: "bot.workspace.set",
// Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
testConnection: "connection.test",
disconnect: "connection.disconnect",
@ -159,6 +160,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
state: authoritativeState(value.state, connected),
connected,
configured: value.configured !== false,
workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
bot: normalizeBot(value.bot),
health: normalizeHealth(value.health, connected),
error: normalizeError(value.error),

View file

@ -14,6 +14,8 @@ import {
unwrapRpcResult,
} from "./api.js";
import { useAnimationFrameScheduler } from "../../lifecycle.js";
import { WorkspaceEditor } from "../../workspace-editor.js";
import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
import { installFeishuStyles } from "./styles.js";
export const name = "feishu-settings";
@ -364,6 +366,7 @@ export function BotCard({
actionError,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -409,6 +412,11 @@ export function BotCard({
h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"),
h("dd", null, formatCheckedTime(health.lastCheckedAt))),
),
h(WorkspaceEditor, {
workspace: connection.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h("div", { className: "bxf-connectedFooter dim-cardFooter" },
summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
summary) : null,
@ -448,6 +456,7 @@ function BotList(props) {
actionError: props.errorsByBot[bot.botId],
removing: props.removeTargetId === bot.botId,
onReconnect: () => props.onReconnect(bot),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
onRequestRemove: () => props.onRequestRemove(bot),
onConfirmRemove: () => props.onConfirmRemove(bot),
onCancelRemove: props.onCancelRemove,
@ -525,8 +534,15 @@ export function FeishuSettingsTab({ rpcCall }) {
const cardRefs = React.useRef(new Map());
const removeButtonRefs = React.useRef(new Map());
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const scheduleAnimationFrame = useAnimationFrameScheduler();
React.useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const announce = React.useCallback((message) => {
setAnnouncement("");
scheduleAnimationFrame(() => {
@ -548,23 +564,27 @@ export function FeishuSettingsTab({ rpcCall }) {
}, []);
const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null || !mountedRef.current) return undefined;
if (!silent) setPageBusy(true);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
if (signal?.aborted || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
mergeSnapshot(snapshot, { restoreProvisioning });
return snapshot;
} catch (error) {
if (signal?.aborted || error?.name === "AbortError") return undefined;
if (signal?.aborted || error?.name === "AbortError" || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) 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 && !signal?.aborted) setPageBusy(false);
if (!silent && !signal?.aborted && mountedRef.current) setPageBusy(false);
}
}, [invoke, mergeSnapshot]);
}, [invoke, mergeSnapshot, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -645,6 +665,7 @@ export function FeishuSettingsTab({ rpcCall }) {
}, [announce, invoke, model.provisioning?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setCredentialBusy(true);
setCredentialError(null);
try {
@ -652,15 +673,19 @@ export function FeishuSettingsTab({ rpcCall }) {
FEISHU_ENDPOINTS.bindCredentials,
{ appId: identity, appSecret: secret },
));
mergeSnapshot(snapshot);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
setCredentialOpen(false);
announce("飞书机器人凭据已绑定。");
} catch (error) {
setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setCredentialBusy(false);
}
}, [announce, invoke, mergeSnapshot]);
}, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
const cancelProvisioning = React.useCallback(async () => {
const attemptId = model.provisioning?.attemptId;
@ -798,11 +823,14 @@ export function FeishuSettingsTab({ rpcCall }) {
const reconnectOneBot = React.useCallback(async (connection) => {
const { botId, bot } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "reconnect");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
mergeSnapshot(snapshot);
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
const refreshed = snapshot.bots.find((item) => item.botId === botId);
if (!refreshed?.connected) {
const error = new Error(
@ -818,9 +846,31 @@ export function FeishuSettingsTab({ rpcCall }) {
setBotError(botId, error);
announce(`${bot.name}操作失败,请查看机器人状态。`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(botId, null);
}
}, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
}, [announce, invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const saveWorkspace = React.useCallback(async (connection, workspace) => {
const { botId } = connection;
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(botId, "workspace");
setBotError(botId, null);
try {
const snapshot = normalizeBotsSnapshot(await invoke(
FEISHU_ENDPOINTS.setWorkspace,
{ botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
mergeSnapshot(snapshot);
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(botId, null);
}
}, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
const requestRemove = React.useCallback((connection) => {
setRemoveTargetId(connection.botId);
@ -834,32 +884,29 @@ export function FeishuSettingsTab({ rpcCall }) {
const confirmRemove = React.useCallback(async (connection) => {
const { botId, bot } = connection;
const snapshotVersion = workspaceFence.beginMutation();
setBotBusy(botId, "delete");
setBotError(botId, null);
try {
await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
const snapshot = normalizeBotsSnapshot(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,
},
};
});
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
mergeSnapshot(snapshot);
}
announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
await loadStatus({ silent: true });
scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
} catch (error) {
setBotError(botId, error);
announce(`${bot.name}移除失败,请重试。`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(botId, null);
}
}, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
}, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
const provision = model.provisioning;
let provisionContent = null;
@ -950,6 +997,7 @@ export function FeishuSettingsTab({ rpcCall }) {
errorsByBot,
removeTargetId,
onReconnect: (bot) => void reconnectOneBot(bot),
onWorkspaceSave: saveWorkspace,
onRequestRemove: requestRemove,
onConfirmRemove: (bot) => void confirmRemove(bot),
onCancelRemove: cancelRemove,

View file

@ -8,6 +8,7 @@ export const QQ_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -79,6 +80,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, 'QQ机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { QqLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
QQ_ENDPOINTS,
@ -146,7 +148,16 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -162,6 +173,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dl', { className: 'ddt-metrics dim-botMetrics' },
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -182,6 +198,7 @@ export function QqSettingsTab({ rpcCall }) {
const [credentialError, setCredentialError] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -201,10 +218,13 @@ export function QqSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(QQ_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -212,12 +232,13 @@ export function QqSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -258,6 +279,7 @@ export function QqSettingsTab({ rpcCall }) {
}, [invoke, provision?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -266,14 +288,18 @@ export function QqSettingsTab({ rpcCall }) {
{ appId: identity, appSecret: secret },
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const closeProvision = React.useCallback(async () => {
setBusy(true);
@ -318,17 +344,22 @@ export function QqSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
return snapshot;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current }; delete next[account.botId]; return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请 QQ 二维码…');
@ -349,6 +380,12 @@ export function QqSettingsTab({ rpcCall }) {
busy: busyByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
QQ_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -24,6 +24,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
export function createTokenChannelApi(channel, connectionSummary) {
@ -47,6 +48,7 @@ export function createTokenChannelApi(channel, connectionSummary) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, `${channel}机器人`, 100),
username: text(value.bot?.username, '', 100),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
const Button = React.forwardRef(function Button(
{ children, kind = 'secondary', className = '', ...props },
@ -51,7 +53,7 @@ export function createTokenChannelSettings(definition) {
emptyActionLabel = '填写 Bot Token',
} = definition;
function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@ -73,6 +75,11 @@ export function createTokenChannelSettings(definition) {
h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' },
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -106,6 +113,7 @@ export function createTokenChannelSettings(definition) {
const [busyByBot, setBusyByBot] = React.useState({});
const [removeTarget, setRemoveTarget] = React.useState(null);
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
React.useEffect(() => {
const disposeDingtalk = installDingtalkStyles();
@ -124,13 +132,17 @@ export function createTokenChannelSettings(definition) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal));
if (!mounted.current || signal?.aborted) return;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({
...current,
phase: silent ? current.phase : 'error',
@ -138,7 +150,7 @@ export function createTokenChannelSettings(definition) {
}));
}
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -160,6 +172,7 @@ export function createTokenChannelSettings(definition) {
}, [loadStatus, model.phase]);
const bindCredentials = React.useCallback(async (values) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -168,30 +181,37 @@ export function createTokenChannelSettings(definition) {
credentialPayload(values),
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(api.presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) {
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' },
@ -208,6 +228,12 @@ export function createTokenChannelSettings(definition) {
endpoints.reconnectBot,
{ botId: account.botId },
),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
endpoints.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -8,6 +8,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
bindCredentials: 'bot.bind-credentials',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@ -79,6 +80,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, '企业微信机器人', 100),
appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { WecomLogoGlyph } from '../../channel-logos.js';
import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
WECOM_ENDPOINTS,
@ -146,7 +148,16 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -162,6 +173,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dl', { className: 'ddt-metrics dim-botMetrics' },
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -182,6 +198,7 @@ export function WecomSettingsTab({ rpcCall }) {
const [credentialError, setCredentialError] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -201,10 +218,13 @@ export function WecomSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -212,12 +232,13 @@ export function WecomSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -258,6 +279,7 @@ export function WecomSettingsTab({ rpcCall }) {
}, [invoke, provision?.attemptId]);
const bindCredentials = React.useCallback(async ({ identity, secret }) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusy(true);
setCredentialError(null);
try {
@ -266,14 +288,18 @@ export function WecomSettingsTab({ rpcCall }) {
{ botId: identity, secret },
));
if (!mounted.current) return;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
setCredentialOpen(false);
} catch (error) {
if (mounted.current) setCredentialError(presentError(error));
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusy(false);
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const closeProvision = React.useCallback(async () => {
setBusy(true);
@ -318,17 +344,22 @@ export function WecomSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
return snapshot;
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current }; delete next[account.botId]; return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
@ -349,6 +380,12 @@ export function WecomSettingsTab({ rpcCall }) {
busy: busyByBot[account.botId],
removing: removeTarget === account.botId,
onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
WECOM_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {

View file

@ -7,6 +7,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@ -103,6 +104,7 @@ function normalizeBot(value) {
state: connected ? 'connected' : state,
connected,
configured: value.configured === true,
workspace: string(value.workspace).slice(0, 4_096),
bot: {
name: string(value.bot.name, '微信机器人'),
accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),

View file

@ -15,6 +15,8 @@ import {
unwrapRpcResult,
} from './api.js';
import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installWeixinStyles } from './styles.js';
export const name = 'weixin-settings';
@ -193,7 +195,16 @@ function checkedTime(timestamp) {
}
}
export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
export function AccountCard({
account,
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
}) {
const state = busy === 'reconnect' ? 'connecting' : account.state;
const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@ -211,6 +222,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'),
h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'dxw-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'dxw-actions dim-cardActions' },
@ -236,6 +252,7 @@ function AccountList(props) {
busy: props.busyByBot[account.botId],
removing: props.removeTarget === account.botId,
onReconnect: () => props.onReconnect(account),
onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
onRequestRemove: () => props.onRequestRemove(account),
onConfirmRemove: () => props.onConfirmRemove(account),
onCancelRemove: props.onCancelRemove,
@ -269,8 +286,15 @@ export function WeixinSettingsTab({ rpcCall }) {
const [notice, setNotice] = React.useState('');
const [now, setNow] = React.useState(() => Date.now());
const addButtonRef = React.useRef(null);
const mountedRef = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const scheduleAnimationFrame = useAnimationFrameScheduler();
React.useEffect(() => {
mountedRef.current = true;
return () => { mountedRef.current = false; };
}, []);
const announce = React.useCallback((value) => {
setNotice('');
scheduleAnimationFrame(() => {
@ -285,10 +309,13 @@ export function WeixinSettingsTab({ rpcCall }) {
silent = false,
restoreProvisioning = false,
} = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null || !mountedRef.current) return undefined;
if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
if (signal?.aborted) return undefined;
if (signal?.aborted || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
@ -302,7 +329,8 @@ export function WeixinSettingsTab({ rpcCall }) {
}
return snapshot;
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError') return undefined;
if (signal?.aborted || error?.name === 'AbortError' || !mountedRef.current
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel((current) => ({
...current,
phase: silent && current.phase === 'ready' ? 'ready' : 'error',
@ -310,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
}));
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -466,35 +494,66 @@ export function WeixinSettingsTab({ rpcCall }) {
}, []);
const reconnect = React.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
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 }));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
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 {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
const saveWorkspace = React.useCallback(async (account, workspace) => {
const workspaceVersion = workspaceFence.beginMutation();
setBotBusy(account.botId, 'workspace');
try {
const snapshot = normalizeSnapshot(await invoke(
WEIXIN_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
));
if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
setModel({
phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
revision: snapshot.revision, error: null,
});
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
if (mountedRef.current) setBotBusy(account.botId, null);
}
}, [invoke, loadStatus, setBotBusy, workspaceFence]);
const remove = React.useCallback(async (account) => {
const snapshotVersion = workspaceFence.beginMutation();
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 }));
if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
}
setRemoveTarget(null);
announce('微信账号及本机凭据已移除。');
} catch (error) {
announce(`移除失败:${presentError(error).message}`);
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
setBotBusy(account.botId, null);
}
}, [announce, invoke, setBotBusy]);
}, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
let provisionView = null;
if (provision?.status === 'starting') {
@ -554,6 +613,7 @@ export function WeixinSettingsTab({ rpcCall }) {
busyByBot,
removeTarget,
onReconnect: (account) => void reconnect(account),
onWorkspaceSave: saveWorkspace,
onRequestRemove: (account) => setRemoveTarget(account.botId),
onConfirmRemove: (account) => void remove(account),
onCancelRemove: () => setRemoveTarget(null),

View file

@ -7,6 +7,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
@ -80,6 +81,7 @@ function normalizeBot(value) {
botId: id(value.botId),
connected,
state: connected ? 'connected' : state,
workspace: text(value.workspace, '', 4_096),
bot: {
name: text(value.bot?.name, 'WhatsApp机器人', 100),
idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),

View file

@ -3,6 +3,8 @@ import * as React from 'react';
import { WhatsappLogoGlyph } from '../../channel-logos.js';
import { QrActionIcon } from '../../credential-binding.js';
import { h } from '../../i18n.js';
import { WorkspaceEditor } from '../../workspace-editor.js';
import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
import { installDingtalkStyles } from '../dingtalk/styles.js';
import {
WHATSAPP_ENDPOINTS,
@ -159,6 +161,7 @@ export function WhatsappAccountCard({
busy,
removing,
onReconnect,
onWorkspaceSave,
onRequestRemove,
onConfirmRemove,
onCancelRemove,
@ -186,6 +189,11 @@ export function WhatsappAccountCard({
h('dd', null, account.connected ? 'WhatsApp Web' : '离线')),
h('div', { className: 'ddt-metric dim-botMetric' },
h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
h(WorkspaceEditor, {
workspace: account.workspace,
disabled: Boolean(busy),
onSave: onWorkspaceSave,
}),
h('div', { className: 'ddt-accountFooter dim-cardFooter' },
summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
h('div', { className: 'ddt-actions dim-cardActions' },
@ -213,6 +221,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
const [removeTarget, setRemoveTarget] = React.useState(null);
const [now, setNow] = React.useState(Date.now());
const mounted = React.useRef(true);
const workspaceFence = useWorkspaceSnapshotFence();
const addButtonRef = React.useRef(null);
React.useEffect(() => {
@ -232,10 +241,13 @@ export function WhatsappSettingsTab({ rpcCall }) {
}, [rpcCall]);
const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
const workspaceVersion = workspaceFence.beginStatus();
if (workspaceVersion === null) return undefined;
if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
try {
const snapshot = normalizeSnapshot(await invoke(WHATSAPP_ENDPOINTS.status, {}, signal));
if (!mounted.current || signal?.aborted) return undefined;
if (!mounted.current || signal?.aborted
|| !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
if (restore && snapshot.provisioning) setProvision({
...snapshot.provisioning,
@ -243,7 +255,8 @@ export function WhatsappSettingsTab({ rpcCall }) {
});
return snapshot;
} catch (error) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
&& workspaceFence.canCommitStatus(workspaceVersion)) {
setModel((current) => ({
...current,
phase: silent ? current.phase : 'error',
@ -252,7 +265,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
}
return undefined;
}
}, [invoke]);
}, [invoke, workspaceFence]);
React.useEffect(() => {
const controller = new AbortController();
@ -350,20 +363,23 @@ export function WhatsappSettingsTab({ rpcCall }) {
}, [invoke, loadStatus, provision?.attemptId, provision?.status]);
const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
const snapshotVersion = workspaceFence.beginMutation();
setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
try {
const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
if (mounted.current) {
if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
}
} finally {
const shouldRefresh = workspaceFence.endMutation();
if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
if (mounted.current) setBusyByBot((current) => {
const next = { ...current };
delete next[account.botId];
return next;
});
}
}, [invoke]);
}, [invoke, loadStatus, workspaceFence]);
const botList = model.bots.length > 0
? h('section', { className: 'dim-listSection' },
@ -380,6 +396,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId: account.botId },
),
onWorkspaceSave: (workspace) => botAction(
account,
'workspace',
WHATSAPP_ENDPOINTS.setWorkspace,
{ botId: account.botId, workspace },
),
onRequestRemove: () => setRemoveTarget(account.botId),
onCancelRemove: () => setRemoveTarget(null),
onConfirmRemove: async () => {