mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 04:13:17 +08:00
feat: support per-bot workspace switching
This commit is contained in:
parent
adcace3512
commit
44dd50216a
64 changed files with 3878 additions and 712 deletions
|
|
@ -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) ?? '已安全保存',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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, '已安全保存'),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue