mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 03:03:24 +08:00
Surface chat/access-list contacts as selectable targets with clearer nicknames, and keep conversation peers usable for ops schedulers. Co-authored-by: Cursor <cursoragent@cursor.com>
822 lines
31 KiB
JavaScript
822 lines
31 KiB
JavaScript
import * as React from 'react';
|
||
|
||
import { AccessPolicySettingsPage } from './access-policy-settings.js';
|
||
import { AccessGrantSettingsPage } from './access-grant-settings.js';
|
||
import { h, isEnglish, localizeText } from './i18n.js';
|
||
|
||
export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
|
||
|
||
const DELIVERY_DOCS_URL = Object.freeze({
|
||
zh: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.md',
|
||
en: 'https://github.com/xmanrui/dsh-im/blob/main/PROACTIVE_DELIVERY.en.md',
|
||
});
|
||
|
||
export const DELIVERY_ENDPOINTS = Object.freeze({
|
||
list: 'target.list',
|
||
listSuggestions: 'target.suggestion.list',
|
||
create: 'target.create',
|
||
update: 'target.update',
|
||
delete: 'target.delete',
|
||
test: 'target.test',
|
||
});
|
||
|
||
export const BOT_SETTINGS_TABS = Object.freeze([
|
||
Object.freeze({ id: 'delivery', label: '投递设置' }),
|
||
Object.freeze({ id: 'access', label: '访问设置' }),
|
||
]);
|
||
|
||
const CHANNEL_DEFINITIONS = Object.freeze({
|
||
weixin: {
|
||
label: '微信',
|
||
kinds: [{ value: 'user', label: '用户' }],
|
||
fields: { user: [{ key: 'toUserId', label: '微信用户 ID', placeholder: '填写接收消息的用户 ID' }] },
|
||
},
|
||
feishu: {
|
||
label: '飞书',
|
||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||
fields: {
|
||
user: [{ key: 'openId', label: 'Open ID', placeholder: 'ou_xxx' }],
|
||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: 'oc_xxx' }],
|
||
},
|
||
},
|
||
dingtalk: {
|
||
label: '钉钉',
|
||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||
fields: {
|
||
user: [{ key: 'userId', label: '用户 ID', placeholder: '填写钉钉用户 ID' }],
|
||
group: [{ key: 'openConversationId', label: '群 Open Conversation ID', placeholder: 'cidxxx' }],
|
||
},
|
||
},
|
||
wecom: {
|
||
label: '企业微信',
|
||
kinds: [{ value: 'user', label: '私聊' }, { value: 'group', label: '群聊' }],
|
||
fields: {
|
||
user: [{ key: 'chatId', label: '用户 ID', placeholder: '填写企业微信用户 ID' }],
|
||
group: [{ key: 'chatId', label: '群 Chat ID', placeholder: '填写群 chatid' }],
|
||
},
|
||
},
|
||
qq: {
|
||
label: 'QQ',
|
||
kinds: [{ value: 'user', label: '单聊' }, { value: 'group', label: '群聊' }],
|
||
fields: {
|
||
user: [{ key: 'userOpenId', label: '用户 Open ID', placeholder: '填写 user_openid' }],
|
||
group: [{ key: 'groupOpenId', label: '群 Open ID', placeholder: '填写 group_openid' }],
|
||
},
|
||
},
|
||
slack: {
|
||
label: 'Slack',
|
||
kinds: [{ value: 'conversation', label: '会话' }, { value: 'thread', label: '线程' }],
|
||
fields: {
|
||
conversation: [{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' }],
|
||
thread: [
|
||
{ key: 'channelId', label: 'Channel ID', placeholder: 'C0123456789' },
|
||
{ key: 'threadTs', label: 'Thread 时间戳', placeholder: '1712345678.123456' },
|
||
],
|
||
},
|
||
},
|
||
telegram: {
|
||
label: 'Telegram',
|
||
kinds: [{ value: 'chat', label: '聊天' }, { value: 'topic', label: '话题' }],
|
||
fields: {
|
||
chat: [{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' }],
|
||
topic: [
|
||
{ key: 'chatId', label: 'Chat ID', placeholder: '-1001234567890', inputMode: 'numeric' },
|
||
{ key: 'messageThreadId', label: 'Topic ID', placeholder: '123', inputMode: 'numeric', integer: true },
|
||
],
|
||
},
|
||
},
|
||
discord: {
|
||
label: 'Discord',
|
||
kinds: [{ value: 'channel', label: '频道或私信' }],
|
||
fields: { channel: [{ key: 'channelId', label: 'Channel ID', placeholder: '填写可发消息的 Channel ID', inputMode: 'numeric' }] },
|
||
},
|
||
whatsapp: {
|
||
label: 'WhatsApp',
|
||
kinds: [{ value: 'user', label: '用户' }, { value: 'group', label: '群聊' }],
|
||
fields: {
|
||
user: [{ key: 'jid', label: '用户 JID', placeholder: '8613800000000@s.whatsapp.net' }],
|
||
group: [{ key: 'jid', label: '群 JID', placeholder: '1234567890@g.us' }],
|
||
},
|
||
},
|
||
});
|
||
|
||
export const DELIVERY_CHANNEL_DEFINITIONS = CHANNEL_DEFINITIONS;
|
||
|
||
function presentError(error, fallback) {
|
||
return error?.message || fallback;
|
||
}
|
||
|
||
function unwrapRpcResult(result) {
|
||
if (result?.ok === true) return result.value;
|
||
if (result?.ok === false) {
|
||
const error = new Error(result.error?.message || '请求失败,请稍后重试。');
|
||
error.code = result.error?.code;
|
||
throw error;
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function targetsFrom(value) {
|
||
if (Array.isArray(value)) return value;
|
||
return Array.isArray(value?.targets) ? value.targets : [];
|
||
}
|
||
|
||
function suggestionsFrom(value) {
|
||
if (Array.isArray(value)) return value;
|
||
return Array.isArray(value?.suggestions) ? value.suggestions : [];
|
||
}
|
||
|
||
function kindLabel(definition, kind) {
|
||
return definition.kinds.find((entry) => entry.value === kind)?.label ?? kind;
|
||
}
|
||
|
||
function fieldsFor(definition, kind) {
|
||
return definition.fields[kind] ?? [];
|
||
}
|
||
|
||
function cleanDisplayName(value) {
|
||
return typeof value === 'string' && value.trim() ? value.trim().slice(0, 80) : null;
|
||
}
|
||
|
||
function suggestionDisplayName(suggestion) {
|
||
return cleanDisplayName(
|
||
suggestion?.name ?? suggestion?.displayName ?? suggestion?.label,
|
||
);
|
||
}
|
||
|
||
function routeIdentity(definition, candidate) {
|
||
const fields = fieldsFor(definition, candidate?.kind);
|
||
if (fields.length === 0 || !candidate?.route || typeof candidate.route !== 'object') return null;
|
||
const values = [];
|
||
for (const field of fields) {
|
||
const value = candidate.route[field.key];
|
||
if (value === undefined || value === null || String(value).trim() === '') return null;
|
||
values.push(String(value).trim());
|
||
}
|
||
return JSON.stringify([candidate.kind, ...values]);
|
||
}
|
||
|
||
function validSuggestions(definition, value) {
|
||
const seen = new Set();
|
||
return suggestionsFrom(value).filter((suggestion) => {
|
||
const identity = routeIdentity(definition, suggestion);
|
||
if (!identity || seen.has(identity)) return false;
|
||
seen.add(identity);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function maskRouteValue(value) {
|
||
const text = String(value ?? '').trim();
|
||
if (!text) return '';
|
||
const at = text.indexOf('@');
|
||
if (at > 0) {
|
||
const local = text.slice(0, at);
|
||
const domain = text.slice(at);
|
||
const visible = local.length <= 4
|
||
? `${local.slice(0, 1)}••${local.slice(-1)}`
|
||
: `${local.slice(0, 3)}…${local.slice(-2)}`;
|
||
return `${visible}${domain}`;
|
||
}
|
||
if (text.length <= 6) return `${text.slice(0, 2)}…${text.slice(-1)}`;
|
||
if (text.length <= 12) return `${text.slice(0, 3)}…${text.slice(-3)}`;
|
||
return `${text.slice(0, 5)}…${text.slice(-4)}`;
|
||
}
|
||
|
||
function suggestionFallbackName(definition, suggestion) {
|
||
const displayName = suggestionDisplayName(suggestion);
|
||
if (displayName) return displayName;
|
||
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
||
const route = firstField ? maskRouteValue(suggestion.route?.[firstField.key]) : '';
|
||
return `${localizeText(kindLabel(definition, suggestion.kind))}${route ? ` · ${route}` : ''}`;
|
||
}
|
||
|
||
function randomTargetId(targets) {
|
||
const used = new Set(targets.map((target) => target?.targetId));
|
||
for (let attempt = 0; attempt < 32; attempt += 1) {
|
||
const bytes = new Uint8Array(8);
|
||
const crypto = globalThis.crypto;
|
||
if (typeof crypto?.getRandomValues === 'function') {
|
||
crypto.getRandomValues(bytes);
|
||
} else {
|
||
for (let index = 0; index < bytes.length; index += 1) {
|
||
bytes[index] = Math.floor(Math.random() * 256);
|
||
}
|
||
}
|
||
const suffix = Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('');
|
||
const targetId = `tgt_${suffix}`;
|
||
if (!used.has(targetId)) return targetId;
|
||
}
|
||
throw new Error('无法生成未占用的 Target ID,请重试。');
|
||
}
|
||
|
||
function DeliveryButton({ children, kind = 'secondary', className = '', ...props }) {
|
||
return h('button', {
|
||
...props,
|
||
type: props.type ?? 'button',
|
||
className: `dim-deliveryButton ${className}`.trim(),
|
||
'data-kind': kind,
|
||
}, children);
|
||
}
|
||
|
||
async function copyText(value) {
|
||
const clipboard = globalThis.navigator?.clipboard;
|
||
if (typeof clipboard?.writeText !== 'function') {
|
||
throw new Error('当前浏览器不支持自动复制,请手动选择复制。');
|
||
}
|
||
await clipboard.writeText(value);
|
||
}
|
||
|
||
function TargetForm({
|
||
definition,
|
||
mode,
|
||
initialValue,
|
||
source,
|
||
busy,
|
||
connected,
|
||
onCancel,
|
||
onSave,
|
||
onTest,
|
||
}) {
|
||
const editing = mode === 'edit';
|
||
const initialKind = initialValue?.kind && definition.fields[initialValue.kind]
|
||
? initialValue.kind
|
||
: definition.kinds[0].value;
|
||
const [targetId, setTargetId] = React.useState(initialValue?.targetId ?? '');
|
||
const [name, setName] = React.useState(initialValue?.name ?? '');
|
||
const [kind, setKind] = React.useState(initialKind);
|
||
const [route, setRoute] = React.useState(initialValue?.route ?? {});
|
||
const [error, setError] = React.useState(null);
|
||
const [testing, setTesting] = React.useState(false);
|
||
const [testState, setTestState] = React.useState(null);
|
||
|
||
const currentTarget = () => {
|
||
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
|
||
const raw = String(route[field.key] ?? '').trim();
|
||
return [field.key, field.integer ? Number(raw) : raw];
|
||
}));
|
||
return {
|
||
targetId: targetId.trim(),
|
||
...(name.trim() ? { name: name.trim() } : {}),
|
||
kind,
|
||
route: normalizedRoute,
|
||
};
|
||
};
|
||
|
||
const submit = async (event) => {
|
||
event.preventDefault();
|
||
setError(null);
|
||
try {
|
||
await onSave({
|
||
...currentTarget(),
|
||
});
|
||
} catch (caught) {
|
||
setError(presentError(caught, '目标保存失败,请检查后重试。'));
|
||
}
|
||
};
|
||
|
||
const testTarget = async () => {
|
||
if (typeof onTest !== 'function') return;
|
||
setTesting(true);
|
||
setTestState(null);
|
||
try {
|
||
const target = currentTarget();
|
||
await onTest({ kind: target.kind, route: target.route });
|
||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||
} catch (caught) {
|
||
setTestState({ tone: 'error', message: presentError(caught, '测试发送失败,请稍后重试。') });
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
};
|
||
const testReady = fieldsFor(definition, kind).every((field) => (
|
||
String(route[field.key] ?? '').trim()
|
||
));
|
||
|
||
return h('form', { className: 'dim-targetForm', onSubmit: submit },
|
||
h('div', { className: 'dim-targetFormHeading' },
|
||
h('h3', null, editing ? '编辑投递目标' : '新建投递目标'),
|
||
editing
|
||
? null
|
||
: h('p', null, source === 'suggestion'
|
||
? '已从会话自动填入目标信息;确认后再保存。'
|
||
: '请手动填写从对应平台取得的原生标识。')),
|
||
h('div', { className: 'dim-targetFormGrid' },
|
||
h('label', { className: 'dim-targetField' },
|
||
h('span', null, 'Target ID'),
|
||
h('input', {
|
||
name: 'targetId',
|
||
value: targetId,
|
||
readOnly: editing,
|
||
disabled: testing,
|
||
required: true,
|
||
pattern: '[A-Za-z0-9._:@-]{1,128}',
|
||
maxLength: 128,
|
||
autoCapitalize: 'none',
|
||
autoCorrect: 'off',
|
||
spellCheck: false,
|
||
placeholder: '例如 daily-report',
|
||
onChange: (event) => setTargetId(event.target.value),
|
||
})),
|
||
h('label', { className: 'dim-targetField' },
|
||
h('span', null, '显示名称(可选)'),
|
||
h('input', {
|
||
name: 'name',
|
||
value: name,
|
||
disabled: testing,
|
||
maxLength: 80,
|
||
placeholder: definition.label === 'WhatsApp' ? '例如 张三 · 13800138000' : '例如 每日汇报群',
|
||
onChange: (event) => setName(event.target.value),
|
||
})),
|
||
h('label', { className: 'dim-targetField' },
|
||
h('span', null, '目标类型'),
|
||
h('select', {
|
||
name: 'kind',
|
||
value: kind,
|
||
disabled: testing,
|
||
onChange: (event) => {
|
||
setKind(event.target.value);
|
||
setRoute({});
|
||
setTestState(null);
|
||
},
|
||
}, definition.kinds.map((entry) => h('option', {
|
||
key: entry.value,
|
||
value: entry.value,
|
||
}, entry.label)))),
|
||
fieldsFor(definition, kind).map((field) => h('label', {
|
||
key: field.key,
|
||
className: 'dim-targetField',
|
||
},
|
||
h('span', null, field.label),
|
||
h('input', {
|
||
name: field.key,
|
||
value: route[field.key] ?? '',
|
||
disabled: testing,
|
||
required: true,
|
||
inputMode: field.inputMode,
|
||
autoCapitalize: 'none',
|
||
autoCorrect: 'off',
|
||
spellCheck: false,
|
||
placeholder: field.placeholder,
|
||
onChange: (event) => {
|
||
setRoute((current) => ({
|
||
...current,
|
||
[field.key]: event.target.value,
|
||
}));
|
||
setTestState(null);
|
||
},
|
||
})))),
|
||
error ? h('p', { className: 'dim-targetFormError', role: 'alert' }, error) : null,
|
||
testState ? h('p', {
|
||
className: 'dim-targetFeedback',
|
||
'data-tone': testState.tone,
|
||
role: 'status',
|
||
'aria-live': 'polite',
|
||
}, testState.message) : null,
|
||
h('div', { className: 'dim-targetFormActions' },
|
||
h(DeliveryButton, { onClick: onCancel, disabled: busy || testing }, '取消'),
|
||
h(DeliveryButton, {
|
||
onClick: () => void testTarget(),
|
||
disabled: busy || testing || !connected || !testReady || typeof onTest !== 'function',
|
||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||
'aria-label': '测试投递目标',
|
||
}, testing ? '测试中…' : '测试'),
|
||
h(DeliveryButton, { type: 'submit', kind: 'primary', disabled: busy || testing },
|
||
busy ? '正在保存…' : '保存目标')));
|
||
}
|
||
|
||
function suggestionOptionLabel(definition, suggestion, added) {
|
||
const name = suggestionDisplayName(suggestion) || suggestionFallbackName(definition, suggestion);
|
||
const type = localizeText(kindLabel(definition, suggestion.kind));
|
||
const firstField = fieldsFor(definition, suggestion.kind)[0];
|
||
const route = firstField ? String(suggestion.route?.[firstField.key] ?? '').trim() : '';
|
||
const showRoute = route && !name.includes(route) && !name.includes(route.split('@')[0] || '');
|
||
return [name, type, showRoute ? maskRouteValue(route) : null, added ? localizeText('已添加') : null]
|
||
.filter(Boolean)
|
||
.join(' · ');
|
||
}
|
||
|
||
function TargetSuggestionPicker({
|
||
definition,
|
||
phase,
|
||
suggestions,
|
||
error,
|
||
targets,
|
||
onRefresh,
|
||
onSelect,
|
||
onManual,
|
||
onCancel,
|
||
}) {
|
||
const configured = new Set(targets.map((target) => routeIdentity(definition, target)).filter(Boolean));
|
||
const channelHint = definition.label === 'WhatsApp'
|
||
? '会列出已聊过的私聊/群聊,以及访问名单里记住的联系人与群(显示为昵称 · 电话 / 群名)。'
|
||
: '选择后会自动填写目标信息和调用别名,确认后再保存。';
|
||
|
||
return h('section', { className: 'dim-targetSuggestions', 'aria-label': '从已聊过的会话选择' },
|
||
h('div', { className: 'dim-targetSuggestionHeading' },
|
||
h('div', null,
|
||
h('h3', null, '从已聊过的会话选择'),
|
||
h('p', null, channelHint)),
|
||
h(DeliveryButton, {
|
||
onClick: () => void onRefresh(),
|
||
disabled: phase === 'loading',
|
||
}, phase === 'loading' ? '正在刷新…' : '刷新')),
|
||
phase === 'loading'
|
||
? h('div', { className: 'dim-targetSuggestionState', 'aria-busy': 'true' }, '正在读取已聊会话…')
|
||
: phase === 'error'
|
||
? h('div', { className: 'dim-targetSuggestionState', role: 'alert' },
|
||
h('p', null, error),
|
||
h(DeliveryButton, { onClick: () => void onRefresh() }, '重新读取'))
|
||
: suggestions.length === 0
|
||
? h('div', { className: 'dim-targetSuggestionState' },
|
||
h('strong', null, '还没有可选择的会话'),
|
||
h('p', null, '先在对应平台与机器人聊一条消息(私聊或群聊),或先在「访问设置」里出现过联系人/群,再刷新。'))
|
||
: h('ul', { className: 'dim-targetSuggestionList', role: 'listbox', 'aria-label': '可选投递会话' },
|
||
suggestions.map((suggestion, index) => {
|
||
const identity = routeIdentity(definition, suggestion);
|
||
const added = configured.has(identity);
|
||
return h('li', { key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}` },
|
||
h('button', {
|
||
type: 'button',
|
||
className: 'dim-targetSuggestionItem',
|
||
role: 'option',
|
||
'aria-disabled': added ? 'true' : undefined,
|
||
disabled: added,
|
||
onClick: () => { if (!added) onSelect(suggestion); },
|
||
},
|
||
// Platform display names must stay raw (bypass h() i18n phrase rewrite).
|
||
React.createElement('strong', null,
|
||
suggestionDisplayName(suggestion)
|
||
|| suggestionFallbackName(definition, suggestion)),
|
||
h('span', null, [
|
||
localizeText(kindLabel(definition, suggestion.kind)),
|
||
added ? localizeText('已添加') : null,
|
||
].filter(Boolean).join(' · ')),
|
||
React.createElement('code', null, fieldsFor(definition, suggestion.kind)
|
||
.map((field) => maskRouteValue(suggestion.route?.[field.key]))
|
||
.filter(Boolean)
|
||
.join(' · '))));
|
||
})),
|
||
h('div', { className: 'dim-targetFormActions' },
|
||
h(DeliveryButton, { onClick: onCancel }, '取消'),
|
||
h(DeliveryButton, { onClick: onManual }, '手动填写(高级)')));
|
||
}
|
||
|
||
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
|
||
const [action, setAction] = React.useState(null);
|
||
const [testState, setTestState] = React.useState(null);
|
||
const [copyState, setCopyState] = React.useState(null);
|
||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||
const testTarget = async () => {
|
||
setAction('test');
|
||
setTestState(null);
|
||
try {
|
||
await rpcCall(DELIVERY_ENDPOINTS.test, { botId, targetId: target.targetId });
|
||
setTestState({ tone: 'success', message: '测试消息已发送,请到目标会话确认。' });
|
||
} catch (error) {
|
||
setTestState({ tone: 'error', message: presentError(error, '测试发送失败,请稍后重试。') });
|
||
} finally {
|
||
setAction(null);
|
||
}
|
||
};
|
||
|
||
const copyPair = async () => {
|
||
setCopyState(null);
|
||
try {
|
||
await copyText(JSON.stringify({ botId, targetId: target.targetId }));
|
||
setCopyState('已复制调用参数');
|
||
} catch (error) {
|
||
setCopyState(presentError(error, '复制失败。'));
|
||
}
|
||
};
|
||
|
||
const deleteTarget = async () => {
|
||
setAction('delete');
|
||
try {
|
||
await rpcCall(DELIVERY_ENDPOINTS.delete, { botId, targetId: target.targetId });
|
||
await onChanged();
|
||
} catch (error) {
|
||
setTestState({ tone: 'error', message: presentError(error, '删除失败,请稍后重试。') });
|
||
setAction(null);
|
||
setConfirmDelete(false);
|
||
}
|
||
};
|
||
|
||
return h('li', { className: 'dim-targetRow', 'data-target-id': target.targetId },
|
||
h('div', { className: 'dim-targetSummary' },
|
||
h('div', { className: 'dim-targetTitle' },
|
||
React.createElement('strong', null, target.name || target.targetId),
|
||
h('span', null, kindLabel(definition, target.kind))),
|
||
h('code', null, `targetId: ${target.targetId}`)),
|
||
h('div', { className: 'dim-targetActions' },
|
||
h(DeliveryButton, { onClick: () => void copyPair() }, '复制调用参数'),
|
||
h(DeliveryButton, {
|
||
onClick: () => void testTarget(),
|
||
disabled: !connected || action === 'test',
|
||
title: connected ? undefined : '机器人离线时不可发送测试消息',
|
||
'aria-label': '测试投递目标',
|
||
}, action === 'test' ? '测试中…' : '测试'),
|
||
h(DeliveryButton, { onClick: onEdit, disabled: Boolean(action) }, '编辑'),
|
||
h(DeliveryButton, {
|
||
kind: 'danger',
|
||
onClick: () => setConfirmDelete(true),
|
||
disabled: Boolean(action),
|
||
}, '删除')),
|
||
copyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, copyState) : null,
|
||
testState ? h('p', {
|
||
className: 'dim-targetFeedback',
|
||
'data-tone': testState.tone,
|
||
role: 'status',
|
||
'aria-live': 'polite',
|
||
}, testState.message) : null,
|
||
confirmDelete ? h('div', { className: 'dim-targetDeleteConfirm', role: 'alertdialog' },
|
||
h('p', null,
|
||
'删除 ', h('code', null, target.targetId),
|
||
'?使用这个 targetId 的外部调用将返回 unknown-target。'),
|
||
h('div', { className: 'dim-targetFormActions' },
|
||
h(DeliveryButton, {
|
||
onClick: () => setConfirmDelete(false),
|
||
disabled: action === 'delete',
|
||
}, '取消'),
|
||
h(DeliveryButton, {
|
||
kind: 'danger',
|
||
onClick: () => void deleteTarget(),
|
||
disabled: action === 'delete',
|
||
}, action === 'delete' ? '正在删除…' : '确认删除')))
|
||
: null);
|
||
}
|
||
|
||
export function DeliveryTargetSettingsPage({
|
||
channel,
|
||
account,
|
||
rpcCall,
|
||
accessRpcCall,
|
||
onBack,
|
||
}) {
|
||
const definition = CHANNEL_DEFINITIONS[channel];
|
||
const [activeTabId, setActiveTabId] = React.useState(BOT_SETTINGS_TABS[0].id);
|
||
const [phase, setPhase] = React.useState('loading');
|
||
const [targets, setTargets] = React.useState([]);
|
||
const [suggestionPhase, setSuggestionPhase] = React.useState('idle');
|
||
const [suggestions, setSuggestions] = React.useState([]);
|
||
const [suggestionError, setSuggestionError] = React.useState(null);
|
||
const [error, setError] = React.useState(null);
|
||
const [editor, setEditor] = React.useState(null);
|
||
const [saving, setSaving] = React.useState(false);
|
||
const [botCopyState, setBotCopyState] = React.useState(null);
|
||
const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
|
||
const [accessGrant, setAccessGrant] = React.useState(account.accessGrant);
|
||
const mounted = React.useRef(true);
|
||
|
||
React.useEffect(() => {
|
||
setAccessPolicy(account.accessPolicy);
|
||
setAccessGrant(account.accessGrant);
|
||
}, [account.botId, account.accessPolicy, account.accessGrant]);
|
||
|
||
const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
|
||
if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
|
||
return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
|
||
}, [rpcCall]);
|
||
|
||
const loadTargets = React.useCallback(async ({ signal, silent = false } = {}) => {
|
||
if (!silent) setPhase('loading');
|
||
setError(null);
|
||
try {
|
||
const value = await invoke(DELIVERY_ENDPOINTS.list, { botId: account.botId }, signal);
|
||
if (signal?.aborted || !mounted.current) return;
|
||
setTargets(targetsFrom(value));
|
||
setPhase('ready');
|
||
} catch (caught) {
|
||
if (signal?.aborted || caught?.name === 'AbortError' || !mounted.current) return;
|
||
setError(presentError(caught, '无法读取投递目标,请稍后重试。'));
|
||
setPhase('error');
|
||
}
|
||
}, [account.botId, invoke]);
|
||
|
||
const loadSuggestions = React.useCallback(async () => {
|
||
setSuggestionPhase('loading');
|
||
setSuggestionError(null);
|
||
try {
|
||
const value = await invoke(DELIVERY_ENDPOINTS.listSuggestions, { botId: account.botId });
|
||
if (!mounted.current) return;
|
||
setSuggestions(validSuggestions(definition, value));
|
||
setSuggestionPhase('ready');
|
||
} catch (caught) {
|
||
if (caught?.name === 'AbortError' || !mounted.current) return;
|
||
setSuggestionError(presentError(caught, '无法读取已聊会话,请稍后重试。'));
|
||
setSuggestionPhase('error');
|
||
}
|
||
}, [account.botId, definition, invoke]);
|
||
|
||
React.useEffect(() => {
|
||
mounted.current = true;
|
||
const controller = new AbortController();
|
||
void loadTargets({ signal: controller.signal });
|
||
return () => {
|
||
mounted.current = false;
|
||
controller.abort();
|
||
};
|
||
}, [loadTargets]);
|
||
|
||
if (!definition) {
|
||
return h('section', { className: 'dim-deliveryPage' },
|
||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表'),
|
||
h('p', { role: 'alert' }, '当前渠道暂不支持投递目标。'));
|
||
}
|
||
|
||
const saveTarget = async (target) => {
|
||
setSaving(true);
|
||
try {
|
||
if (editor?.mode === 'edit') {
|
||
await invoke(DELIVERY_ENDPOINTS.update, {
|
||
botId: account.botId,
|
||
targetId: editor.target.targetId,
|
||
target: {
|
||
...(target.name ? { name: target.name } : {}),
|
||
kind: target.kind,
|
||
route: target.route,
|
||
},
|
||
});
|
||
} else {
|
||
await invoke(DELIVERY_ENDPOINTS.create, { botId: account.botId, target });
|
||
}
|
||
await loadTargets({ silent: true });
|
||
if (mounted.current) setEditor(null);
|
||
} finally {
|
||
if (mounted.current) setSaving(false);
|
||
}
|
||
};
|
||
|
||
const openSuggestionPicker = () => {
|
||
setEditor({ mode: 'suggestions' });
|
||
void loadSuggestions();
|
||
};
|
||
|
||
const selectSuggestion = (suggestion) => {
|
||
const identity = routeIdentity(definition, suggestion);
|
||
if (!identity) return;
|
||
const route = Object.fromEntries(fieldsFor(definition, suggestion.kind).map((field) => [
|
||
field.key,
|
||
suggestion.route[field.key],
|
||
]));
|
||
setEditor({
|
||
mode: 'create',
|
||
source: 'suggestion',
|
||
draftKey: identity,
|
||
initialValue: {
|
||
targetId: randomTargetId(targets),
|
||
name: suggestionFallbackName(definition, suggestion),
|
||
kind: suggestion.kind,
|
||
route,
|
||
},
|
||
});
|
||
};
|
||
|
||
const copyBotId = async () => {
|
||
setBotCopyState(null);
|
||
try {
|
||
await copyText(account.botId);
|
||
setBotCopyState('已复制 Bot ID');
|
||
} catch (caught) {
|
||
setBotCopyState(presentError(caught, '复制失败。'));
|
||
}
|
||
};
|
||
|
||
const activeTab = BOT_SETTINGS_TABS.find((tab) => tab.id === activeTabId)
|
||
?? BOT_SETTINGS_TABS[0];
|
||
const activeTabDomId = `dim-bot-settings-${activeTab.id}-tab`;
|
||
const activePanelId = `dim-bot-settings-${activeTab.id}-panel`;
|
||
|
||
return h('section', {
|
||
className: 'dim-deliveryPage',
|
||
'aria-label': `${account.botName || definition.label}机器人设置`,
|
||
},
|
||
h('header', { className: 'dim-deliveryHeader' },
|
||
h(DeliveryButton, { className: 'dim-deliveryBack', onClick: onBack }, '← 返回机器人列表')),
|
||
h('div', { className: 'dim-botSettingsTabsBar' },
|
||
h('nav', {
|
||
className: 'dim-botSettingsTabs',
|
||
role: 'tablist',
|
||
'aria-label': '机器人设置页签',
|
||
}, BOT_SETTINGS_TABS.map((tab) => h('button', {
|
||
key: tab.id,
|
||
id: `dim-bot-settings-${tab.id}-tab`,
|
||
type: 'button',
|
||
role: 'tab',
|
||
className: 'dim-botSettingsTab',
|
||
'aria-selected': tab.id === activeTab.id,
|
||
'aria-controls': `dim-bot-settings-${tab.id}-panel`,
|
||
tabIndex: tab.id === activeTab.id ? 0 : -1,
|
||
onClick: () => setActiveTabId(tab.id),
|
||
}, tab.label)))),
|
||
h('div', {
|
||
id: activePanelId,
|
||
className: 'dim-botSettingsTabPanel',
|
||
role: 'tabpanel',
|
||
'aria-labelledby': activeTabDomId,
|
||
},
|
||
activeTab.id === 'access'
|
||
? (channel === 'whatsapp'
|
||
? h(AccessGrantSettingsPage, {
|
||
channel,
|
||
account: {
|
||
...account,
|
||
accessGrant,
|
||
groupSessionScope: account.groupSessionScope,
|
||
},
|
||
rpcCall: accessRpcCall,
|
||
onSaved: setAccessGrant,
|
||
})
|
||
: h(AccessPolicySettingsPage, {
|
||
channel,
|
||
account: { ...account, accessPolicy },
|
||
rpcCall: accessRpcCall,
|
||
onSaved: setAccessPolicy,
|
||
}))
|
||
: h(React.Fragment, null,
|
||
h('section', { className: 'dim-deliveryIdentity', 'aria-labelledby': 'dim-delivery-bot-title' },
|
||
h('div', { className: 'dim-deliveryIdentityHeading' },
|
||
h('h2', { id: 'dim-delivery-bot-title', className: 'dim-deliveryBotName' },
|
||
account.botName || '机器人设置'),
|
||
h('a', {
|
||
className: 'dim-deliveryDocsLink',
|
||
href: isEnglish() ? DELIVERY_DOCS_URL.en : DELIVERY_DOCS_URL.zh,
|
||
target: '_blank',
|
||
rel: 'noopener noreferrer',
|
||
'aria-label': '打开主动投递使用文档',
|
||
},
|
||
h('span', null, '使用文档'),
|
||
h('span', { 'aria-hidden': 'true' }, '↗'))),
|
||
h('div', { className: 'dim-deliveryBotId' },
|
||
h('span', null, 'Bot ID'),
|
||
h('code', { title: account.botId }, account.botId),
|
||
h(DeliveryButton, { onClick: () => void copyBotId() }, '复制')),
|
||
botCopyState ? h('p', { className: 'dim-targetFeedback', role: 'status' }, botCopyState) : null),
|
||
h('section', { className: 'dim-deliveryTargets', 'aria-labelledby': 'dim-delivery-targets-title' },
|
||
h('div', { className: 'dim-deliverySectionHeading' },
|
||
h('div', null,
|
||
h('h3', { id: 'dim-delivery-targets-title' }, '投递目标'),
|
||
account.connected
|
||
? null
|
||
: h('p', null, '机器人当前离线;仍可配置目标,恢复连接后再测试。')),
|
||
h(DeliveryButton, {
|
||
kind: 'primary',
|
||
onClick: openSuggestionPicker,
|
||
disabled: Boolean(editor) || phase !== 'ready',
|
||
title: phase === 'ready' ? undefined : '请先完成投递目标读取',
|
||
}, '新建目标')),
|
||
editor?.mode === 'suggestions'
|
||
? h(TargetSuggestionPicker, {
|
||
definition,
|
||
phase: suggestionPhase,
|
||
suggestions,
|
||
error: suggestionError,
|
||
targets,
|
||
onRefresh: loadSuggestions,
|
||
onSelect: selectSuggestion,
|
||
onManual: () => setEditor({
|
||
mode: 'create',
|
||
source: 'manual',
|
||
draftKey: 'manual',
|
||
initialValue: { targetId: randomTargetId(targets) },
|
||
}),
|
||
onCancel: () => setEditor(null),
|
||
})
|
||
: editor ? h(TargetForm, {
|
||
key: editor.mode === 'edit' ? editor.target.targetId : editor.draftKey,
|
||
definition,
|
||
mode: editor.mode,
|
||
initialValue: editor.mode === 'edit' ? editor.target : editor.initialValue,
|
||
source: editor.source,
|
||
busy: saving,
|
||
connected: account.connected,
|
||
onCancel: () => setEditor(editor.source === 'suggestion'
|
||
? { mode: 'suggestions' }
|
||
: null),
|
||
onSave: saveTarget,
|
||
onTest: (target) => invoke(DELIVERY_ENDPOINTS.test, {
|
||
botId: account.botId,
|
||
target,
|
||
}),
|
||
}) : null,
|
||
phase === 'loading'
|
||
? h('div', { className: 'dim-deliveryState', 'aria-busy': 'true' }, '正在读取投递目标…')
|
||
: phase === 'error'
|
||
? h('div', { className: 'dim-deliveryState', role: 'alert' },
|
||
h('p', null, error),
|
||
h(DeliveryButton, { onClick: () => void loadTargets() }, '重新读取'))
|
||
: targets.length === 0
|
||
? h('div', { className: 'dim-deliveryState dim-deliveryEmpty' },
|
||
h('strong', null, '尚未配置投递目标'),
|
||
h('p', null, '点击“新建目标”可从已聊过的会话选择,也可手动填写。'))
|
||
: h('ul', { className: 'dim-targetList' }, targets.map((target) => h(TargetRow, {
|
||
key: target.targetId,
|
||
definition,
|
||
target,
|
||
botId: account.botId,
|
||
connected: account.connected,
|
||
rpcCall: invoke,
|
||
onChanged: () => loadTargets({ silent: true }),
|
||
onEdit: () => setEditor({ mode: 'edit', target, source: 'edit' }),
|
||
})))))));
|
||
}
|