935 lines
44 KiB
TypeScript
935 lines
44 KiB
TypeScript
import { ChevronDown, ChevronUp, ExternalLink, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react';
|
|
import type { RefObject } from 'react';
|
|
|
|
import { DrawerShell } from '../../../components/DrawerShell';
|
|
import { PasswordInput } from '../../../components/PasswordInput';
|
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
|
import type { BotChannel, BotTopic, ChannelType, TopicPresetTemplate, WeixinLoginStatus } from '../types';
|
|
import './DashboardManagementModals.css';
|
|
|
|
interface PasswordToggleLabels {
|
|
show: string;
|
|
hide: string;
|
|
}
|
|
|
|
function parseChannelListValue(raw: unknown): string {
|
|
if (!Array.isArray(raw)) return '';
|
|
return raw
|
|
.map((item) => String(item || '').trim())
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
}
|
|
|
|
function parseChannelListInput(raw: string): string[] {
|
|
const rows: string[] = [];
|
|
String(raw || '')
|
|
.split(/[\n,]/)
|
|
.forEach((item) => {
|
|
const text = String(item || '').trim();
|
|
if (text && !rows.includes(text)) rows.push(text);
|
|
});
|
|
return rows;
|
|
}
|
|
|
|
function isChannelConfigured(channel: BotChannel): boolean {
|
|
const ctype = String(channel.channel_type || '').trim().toLowerCase();
|
|
if (ctype === 'email') {
|
|
const extra = channel.extra_config || {};
|
|
return Boolean(
|
|
String(extra.imapHost || '').trim()
|
|
&& String(extra.imapUsername || '').trim()
|
|
&& String(extra.imapPassword || '').trim()
|
|
&& String(extra.smtpHost || '').trim()
|
|
&& String(extra.smtpUsername || '').trim()
|
|
&& String(extra.smtpPassword || '').trim(),
|
|
);
|
|
}
|
|
if (ctype === 'weixin') {
|
|
return true;
|
|
}
|
|
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
|
|
}
|
|
|
|
function ChannelFieldsEditor({
|
|
channel,
|
|
labels,
|
|
passwordToggleLabels,
|
|
onPatch,
|
|
}: {
|
|
channel: BotChannel;
|
|
labels: Record<string, any>;
|
|
passwordToggleLabels: PasswordToggleLabels;
|
|
onPatch: (patch: Partial<BotChannel>) => void;
|
|
}) {
|
|
const ctype = String(channel.channel_type).toLowerCase();
|
|
if (ctype === 'telegram') {
|
|
return (
|
|
<>
|
|
<PasswordInput className="input" placeholder={labels.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
<input
|
|
className="input"
|
|
placeholder={labels.proxy}
|
|
value={String((channel.extra_config || {}).proxy || '')}
|
|
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
|
|
autoComplete="off"
|
|
/>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean((channel.extra_config || {}).replyToMessage)}
|
|
onChange={(e) =>
|
|
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
|
|
}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.replyToMessage}
|
|
</label>
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (ctype === 'feishu') {
|
|
return (
|
|
<>
|
|
<input className="input" placeholder={labels.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
|
<PasswordInput className="input" placeholder={labels.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
<input className="input" placeholder={labels.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
|
|
<input className="input" placeholder={labels.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (ctype === 'dingtalk') {
|
|
return (
|
|
<>
|
|
<input className="input" placeholder={labels.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
|
<PasswordInput className="input" placeholder={labels.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (ctype === 'slack') {
|
|
return (
|
|
<>
|
|
<input className="input" placeholder={labels.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
|
<PasswordInput className="input" placeholder={labels.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (ctype === 'qq') {
|
|
return (
|
|
<>
|
|
<input className="input" placeholder={labels.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
|
|
<PasswordInput className="input" placeholder={labels.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (ctype === 'weixin') {
|
|
return null;
|
|
}
|
|
|
|
if (ctype === 'email') {
|
|
const extra = channel.extra_config || {};
|
|
return (
|
|
<>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.consentGranted)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailConsentGranted}
|
|
</label>
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailFromAddress}</label>
|
|
<input
|
|
className="input"
|
|
value={String(extra.fromAddress || '')}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
<div className="ops-config-field ops-config-field-full">
|
|
<label className="field-label">{labels.emailAllowFrom}</label>
|
|
<textarea
|
|
className="textarea"
|
|
rows={3}
|
|
value={parseChannelListValue(extra.allowFrom)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
|
|
placeholder={labels.emailAllowFromPlaceholder}
|
|
/>
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailImapHost}</label>
|
|
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailImapPort}</label>
|
|
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailImapUsername}</label>
|
|
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailImapPassword}</label>
|
|
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailImapMailbox}</label>
|
|
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.imapUseSsl ?? true)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailImapUseSsl}
|
|
</label>
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailSmtpHost}</label>
|
|
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailSmtpPort}</label>
|
|
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailSmtpUsername}</label>
|
|
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailSmtpPassword}</label>
|
|
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.smtpUseTls ?? true)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailSmtpUseTls}
|
|
</label>
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.smtpUseSsl)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailSmtpUseSsl}
|
|
</label>
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.autoReplyEnabled ?? true)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailAutoReplyEnabled}
|
|
</label>
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailPollIntervalSeconds}</label>
|
|
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailMaxBodyChars}</label>
|
|
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.emailSubjectPrefix}</label>
|
|
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(extra.markSeen ?? true)}
|
|
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.emailMarkSeen}
|
|
</label>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
interface ChannelConfigModalProps {
|
|
open: boolean;
|
|
channels: BotChannel[];
|
|
globalDelivery: { sendProgress: boolean; sendToolHints: boolean };
|
|
expandedChannelByKey: Record<string, boolean>;
|
|
newChannelDraft: BotChannel;
|
|
addableChannelTypes: ChannelType[];
|
|
newChannelPanelOpen: boolean;
|
|
channelCreateMenuOpen: boolean;
|
|
channelCreateMenuRef: RefObject<HTMLDivElement | null>;
|
|
isSavingGlobalDelivery: boolean;
|
|
isSavingChannel: boolean;
|
|
weixinLoginStatus: WeixinLoginStatus | null;
|
|
hasSelectedBot: boolean;
|
|
isZh: boolean;
|
|
labels: Record<string, any>;
|
|
passwordToggleLabels: PasswordToggleLabels;
|
|
onClose: () => void;
|
|
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
|
|
onSaveGlobalDelivery: () => Promise<void> | void;
|
|
getChannelUiKey: (channel: Pick<BotChannel, 'id' | 'channel_type'>, fallbackIndex: number) => string;
|
|
isDashboardChannel: (channel: BotChannel) => boolean;
|
|
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
|
|
onToggleExpandedChannel: (key: string) => void;
|
|
onRemoveChannel: (channel: BotChannel) => Promise<void> | void;
|
|
onSaveChannel: (channel: BotChannel) => Promise<void> | void;
|
|
onReloginWeixin: () => Promise<void> | void;
|
|
onSetNewChannelPanelOpen: (value: boolean) => void;
|
|
onSetChannelCreateMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
onResetNewChannelDraft: (channelType?: ChannelType) => void;
|
|
onUpdateNewChannelDraft: (patch: Partial<BotChannel>) => void;
|
|
onBeginChannelCreate: (channelType: ChannelType) => void;
|
|
onAddChannel: () => Promise<void> | void;
|
|
}
|
|
|
|
export function ChannelConfigModal({
|
|
open,
|
|
channels,
|
|
globalDelivery,
|
|
expandedChannelByKey,
|
|
newChannelDraft,
|
|
addableChannelTypes,
|
|
newChannelPanelOpen,
|
|
channelCreateMenuOpen,
|
|
channelCreateMenuRef,
|
|
isSavingGlobalDelivery,
|
|
isSavingChannel,
|
|
weixinLoginStatus,
|
|
hasSelectedBot,
|
|
isZh,
|
|
labels,
|
|
passwordToggleLabels,
|
|
onClose,
|
|
onUpdateGlobalDeliveryFlag,
|
|
onSaveGlobalDelivery,
|
|
getChannelUiKey,
|
|
isDashboardChannel,
|
|
onUpdateChannelLocal,
|
|
onToggleExpandedChannel,
|
|
onRemoveChannel,
|
|
onSaveChannel,
|
|
onReloginWeixin,
|
|
onSetNewChannelPanelOpen,
|
|
onSetChannelCreateMenuOpen,
|
|
onResetNewChannelDraft,
|
|
onUpdateNewChannelDraft,
|
|
onBeginChannelCreate,
|
|
onAddChannel,
|
|
}: ChannelConfigModalProps) {
|
|
if (!open) return null;
|
|
|
|
const renderWeixinLoginBlock = (channel: BotChannel) => {
|
|
const channelType = String(channel.channel_type || '').trim().toLowerCase();
|
|
if (String(channelType || '').trim().toLowerCase() !== 'weixin') return null;
|
|
|
|
const loginUrl = String(weixinLoginStatus?.login_url || '').trim();
|
|
|
|
return (
|
|
<div className="ops-config-weixin-login">
|
|
<div className="ops-config-weixin-actions">
|
|
<button
|
|
className="btn btn-primary btn-sm"
|
|
disabled={isSavingChannel}
|
|
onClick={() => void onReloginWeixin()}
|
|
>
|
|
{labels.weixinRelogin}
|
|
</button>
|
|
</div>
|
|
<div className="ops-config-weixin-login-hint">{labels.weixinLoginHint}</div>
|
|
{loginUrl ? (
|
|
<div className="ops-config-weixin-login-body">
|
|
<div className="ops-config-weixin-login-url mono" title={loginUrl}>{loginUrl}</div>
|
|
<a
|
|
className="ops-config-weixin-login-link"
|
|
href={loginUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<ExternalLink size={14} />
|
|
<span>{labels.weixinLoginOpen}</span>
|
|
</a>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<DrawerShell
|
|
open={open}
|
|
onClose={onClose}
|
|
title={labels.wizardSectionTitle}
|
|
subtitle={labels.wizardSectionDesc}
|
|
size="extend"
|
|
closeLabel={labels.close}
|
|
bodyClassName="ops-config-drawer-body"
|
|
footer={(
|
|
!newChannelPanelOpen ? (
|
|
<div className="drawer-shell-footer-content">
|
|
<div className="drawer-shell-footer-main field-label">{labels.channelAddHint}</div>
|
|
<div className="ops-topic-create-menu-wrap" ref={channelCreateMenuRef}>
|
|
<button
|
|
className="btn btn-primary"
|
|
disabled={addableChannelTypes.length === 0 || isSavingChannel}
|
|
onClick={() => onSetChannelCreateMenuOpen((prev) => !prev)}
|
|
>
|
|
<Plus size={14} />
|
|
<span style={{ marginLeft: 6 }}>{labels.addChannel}</span>
|
|
</button>
|
|
{channelCreateMenuOpen ? (
|
|
<div className="ops-topic-create-menu">
|
|
{addableChannelTypes.map((channelType) => (
|
|
<button key={channelType} className="ops-topic-create-menu-item" onClick={() => onBeginChannelCreate(channelType)}>
|
|
{String(channelType || '').toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : undefined
|
|
)}
|
|
>
|
|
<div className="ops-config-modal">
|
|
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
|
{labels.wizardSectionDesc}
|
|
</div>
|
|
<div className="card">
|
|
<div className="section-mini-title">{labels.globalDeliveryTitle}</div>
|
|
<div className="field-label">{labels.globalDeliveryDesc}</div>
|
|
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(globalDelivery.sendProgress)}
|
|
onChange={(e) => onUpdateGlobalDeliveryFlag('sendProgress', e.target.checked)}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.sendProgress}
|
|
</label>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(globalDelivery.sendToolHints)}
|
|
onChange={(e) => onUpdateGlobalDeliveryFlag('sendToolHints', e.target.checked)}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.sendToolHints}
|
|
</label>
|
|
<LucentIconButton
|
|
className="btn btn-primary btn-sm icon-btn"
|
|
disabled={isSavingGlobalDelivery || !hasSelectedBot}
|
|
onClick={() => void onSaveGlobalDelivery()}
|
|
tooltip={labels.saveChannel}
|
|
aria-label={labels.saveChannel}
|
|
>
|
|
<Save size={14} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
<div className="wizard-channel-list ops-config-list-scroll">
|
|
{channels.filter((channel) => !isDashboardChannel(channel)).length === 0 ? (
|
|
<div className="ops-empty-inline">{labels.channelEmpty}</div>
|
|
) : (
|
|
channels.map((channel, idx) => {
|
|
if (isDashboardChannel(channel)) return null;
|
|
const uiKey = getChannelUiKey(channel, idx);
|
|
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
|
|
const summary = [
|
|
String(channel.channel_type || '').toUpperCase(),
|
|
channel.is_active ? labels.enabled : labels.disabled,
|
|
isChannelConfigured(channel) ? labels.channelConfigured : labels.channelPending,
|
|
].join(' · ');
|
|
return (
|
|
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
|
|
<div className="ops-config-card-header">
|
|
<div className="ops-config-card-main">
|
|
<strong>{String(channel.channel_type || '').toUpperCase()}</strong>
|
|
<div className="ops-config-collapsed-meta">{summary}</div>
|
|
</div>
|
|
<div className="ops-config-card-actions">
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={channel.is_active}
|
|
onChange={(e) => onUpdateChannelLocal(idx, { is_active: e.target.checked })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.enabled}
|
|
</label>
|
|
<LucentIconButton
|
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
|
disabled={isSavingChannel}
|
|
onClick={() => void onRemoveChannel(channel)}
|
|
tooltip={labels.remove}
|
|
aria-label={labels.remove}
|
|
>
|
|
<Trash2 size={14} />
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="ops-plain-icon-btn"
|
|
onClick={() => onToggleExpandedChannel(uiKey)}
|
|
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
>
|
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
{expanded ? (
|
|
<>
|
|
<div className="ops-topic-grid">
|
|
<ChannelFieldsEditor
|
|
channel={channel}
|
|
labels={labels}
|
|
passwordToggleLabels={passwordToggleLabels}
|
|
onPatch={(patch) => onUpdateChannelLocal(idx, patch)}
|
|
/>
|
|
</div>
|
|
{renderWeixinLoginBlock(channel)}
|
|
<div className="row-between ops-config-footer">
|
|
<span className="field-label">{labels.customChannel}</span>
|
|
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onSaveChannel(channel)}>
|
|
<Save size={14} />
|
|
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{newChannelPanelOpen ? (
|
|
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
|
|
<div className="ops-config-card-header">
|
|
<div className="ops-config-card-main">
|
|
<strong>{labels.addChannel}</strong>
|
|
<div className="ops-config-collapsed-meta">{String(newChannelDraft.channel_type || '').toUpperCase()}</div>
|
|
</div>
|
|
<div className="ops-config-card-actions">
|
|
<LucentIconButton
|
|
className="ops-plain-icon-btn"
|
|
onClick={() => {
|
|
onSetNewChannelPanelOpen(false);
|
|
onResetNewChannelDraft();
|
|
}}
|
|
tooltip={labels.cancel}
|
|
aria-label={labels.cancel}
|
|
>
|
|
<X size={15} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
<div className="ops-topic-grid">
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.channelType}</label>
|
|
<input className="input mono" value={String(newChannelDraft.channel_type || '').toUpperCase()} readOnly />
|
|
</div>
|
|
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
|
|
<label className="field-label" style={{ visibility: 'hidden' }}>{labels.enabled}</label>
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={newChannelDraft.is_active}
|
|
onChange={(e) => onUpdateNewChannelDraft({ is_active: e.target.checked })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.enabled}
|
|
</label>
|
|
</div>
|
|
<ChannelFieldsEditor
|
|
channel={newChannelDraft}
|
|
labels={labels}
|
|
passwordToggleLabels={passwordToggleLabels}
|
|
onPatch={onUpdateNewChannelDraft}
|
|
/>
|
|
</div>
|
|
<div className="row-between ops-config-footer">
|
|
<span className="field-label">{labels.channelAddHint}</span>
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
|
<button
|
|
className="btn btn-secondary btn-sm"
|
|
onClick={() => {
|
|
onSetNewChannelPanelOpen(false);
|
|
onResetNewChannelDraft();
|
|
}}
|
|
>
|
|
{labels.cancel}
|
|
</button>
|
|
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => void onAddChannel()}>
|
|
<Save size={14} />
|
|
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</DrawerShell>
|
|
);
|
|
}
|
|
|
|
interface TopicConfigModalProps {
|
|
open: boolean;
|
|
topics: BotTopic[];
|
|
expandedTopicByKey: Record<string, boolean>;
|
|
newTopicPanelOpen: boolean;
|
|
topicPresetMenuOpen: boolean;
|
|
newTopicAdvancedOpen: boolean;
|
|
newTopicSourceLabel: string;
|
|
newTopicKey: string;
|
|
newTopicName: string;
|
|
newTopicDescription: string;
|
|
newTopicPurpose: string;
|
|
newTopicIncludeWhen: string;
|
|
newTopicExcludeWhen: string;
|
|
newTopicExamplesPositive: string;
|
|
newTopicExamplesNegative: string;
|
|
newTopicPriority: string;
|
|
effectiveTopicPresetTemplates: TopicPresetTemplate[];
|
|
topicPresetMenuRef: RefObject<HTMLDivElement | null>;
|
|
isSavingTopic: boolean;
|
|
hasSelectedBot: boolean;
|
|
isZh: boolean;
|
|
labels: Record<string, any>;
|
|
onClose: () => void;
|
|
getTopicUiKey: (topic: Pick<BotTopic, 'topic_key' | 'id'>, fallbackIndex: number) => string;
|
|
countRoutingTextList: (raw: string) => number;
|
|
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
|
|
onToggleExpandedTopic: (key: string) => void;
|
|
onRemoveTopic: (topic: BotTopic) => Promise<void> | void;
|
|
onSaveTopic: (topic: BotTopic) => Promise<void> | void;
|
|
onSetNewTopicPanelOpen: (value: boolean) => void;
|
|
onSetTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
onSetNewTopicAdvancedOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
|
|
onResetNewTopicDraft: () => void;
|
|
onNormalizeTopicKeyInput: (value: string) => string;
|
|
onSetNewTopicKey: (value: string) => void;
|
|
onSetNewTopicName: (value: string) => void;
|
|
onSetNewTopicDescription: (value: string) => void;
|
|
onSetNewTopicPurpose: (value: string) => void;
|
|
onSetNewTopicIncludeWhen: (value: string) => void;
|
|
onSetNewTopicExcludeWhen: (value: string) => void;
|
|
onSetNewTopicExamplesPositive: (value: string) => void;
|
|
onSetNewTopicExamplesNegative: (value: string) => void;
|
|
onSetNewTopicPriority: (value: string) => void;
|
|
onBeginTopicCreate: (presetId: string) => void;
|
|
onResolvePresetLabel: (preset: TopicPresetTemplate) => string;
|
|
onAddTopic: () => Promise<void> | void;
|
|
}
|
|
|
|
export function TopicConfigModal({
|
|
open,
|
|
topics,
|
|
expandedTopicByKey,
|
|
newTopicPanelOpen,
|
|
topicPresetMenuOpen,
|
|
newTopicAdvancedOpen,
|
|
newTopicSourceLabel,
|
|
newTopicKey,
|
|
newTopicName,
|
|
newTopicDescription,
|
|
newTopicPurpose,
|
|
newTopicIncludeWhen,
|
|
newTopicExcludeWhen,
|
|
newTopicExamplesPositive,
|
|
newTopicExamplesNegative,
|
|
newTopicPriority,
|
|
effectiveTopicPresetTemplates,
|
|
topicPresetMenuRef,
|
|
isSavingTopic,
|
|
hasSelectedBot,
|
|
isZh,
|
|
labels,
|
|
onClose,
|
|
getTopicUiKey,
|
|
countRoutingTextList,
|
|
onUpdateTopicLocal,
|
|
onToggleExpandedTopic,
|
|
onRemoveTopic,
|
|
onSaveTopic,
|
|
onSetNewTopicPanelOpen,
|
|
onSetTopicPresetMenuOpen,
|
|
onSetNewTopicAdvancedOpen,
|
|
onResetNewTopicDraft,
|
|
onNormalizeTopicKeyInput,
|
|
onSetNewTopicKey,
|
|
onSetNewTopicName,
|
|
onSetNewTopicDescription,
|
|
onSetNewTopicPurpose,
|
|
onSetNewTopicIncludeWhen,
|
|
onSetNewTopicExcludeWhen,
|
|
onSetNewTopicExamplesPositive,
|
|
onSetNewTopicExamplesNegative,
|
|
onSetNewTopicPriority,
|
|
onBeginTopicCreate,
|
|
onResolvePresetLabel,
|
|
onAddTopic,
|
|
}: TopicConfigModalProps) {
|
|
if (!open) return null;
|
|
|
|
return (
|
|
<DrawerShell
|
|
open={open}
|
|
onClose={onClose}
|
|
title={labels.topicPanel}
|
|
subtitle={labels.topicPanelDesc}
|
|
size="extend"
|
|
closeLabel={labels.close}
|
|
bodyClassName="ops-config-drawer-body"
|
|
footer={(
|
|
!newTopicPanelOpen ? (
|
|
<div className="drawer-shell-footer-content">
|
|
<div className="drawer-shell-footer-main field-label">{labels.topicAddHint}</div>
|
|
<div className="ops-topic-create-menu-wrap" ref={topicPresetMenuRef}>
|
|
<button className="btn btn-primary" disabled={isSavingTopic || !hasSelectedBot} onClick={() => onSetTopicPresetMenuOpen((prev) => !prev)}>
|
|
<Plus size={14} />
|
|
<span style={{ marginLeft: 6 }}>{labels.topicAdd}</span>
|
|
</button>
|
|
{topicPresetMenuOpen ? (
|
|
<div className="ops-topic-create-menu">
|
|
{effectiveTopicPresetTemplates.map((preset) => (
|
|
<button key={preset.id} className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate(preset.id)}>
|
|
{onResolvePresetLabel(preset)}
|
|
</button>
|
|
))}
|
|
<button className="ops-topic-create-menu-item" onClick={() => onBeginTopicCreate('blank')}>{labels.topicPresetBlank}</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
) : undefined
|
|
)}
|
|
>
|
|
<div className="ops-config-modal">
|
|
<div className="card" style={{ fontSize: 12, color: 'var(--muted)' }}>
|
|
{labels.topicPanelDesc}
|
|
</div>
|
|
<div className="wizard-channel-list ops-config-list-scroll">
|
|
{topics.length === 0 ? (
|
|
<div className="ops-empty-inline">{labels.topicEmpty}</div>
|
|
) : (
|
|
topics.map((topic, idx) => {
|
|
const uiKey = getTopicUiKey(topic, idx);
|
|
const expanded = expandedTopicByKey[uiKey] ?? idx === 0;
|
|
const includeCount = countRoutingTextList(String(topic.routing_include_when || ''));
|
|
const excludeCount = countRoutingTextList(String(topic.routing_exclude_when || ''));
|
|
return (
|
|
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
|
|
<div className="ops-config-card-header">
|
|
<div className="ops-config-card-main">
|
|
<strong className="mono">{topic.topic_key}</strong>
|
|
<div className="field-label">{topic.name || topic.topic_key}</div>
|
|
{!expanded ? (
|
|
<div className="ops-config-collapsed-meta">
|
|
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
<div className="ops-config-card-actions">
|
|
<label className="field-label">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(topic.is_active)}
|
|
onChange={(e) => onUpdateTopicLocal(idx, { is_active: e.target.checked })}
|
|
style={{ marginRight: 6 }}
|
|
/>
|
|
{labels.topicActive}
|
|
</label>
|
|
<LucentIconButton
|
|
className="btn btn-danger btn-sm wizard-icon-btn"
|
|
disabled={isSavingTopic}
|
|
onClick={() => void onRemoveTopic(topic)}
|
|
tooltip={labels.delete}
|
|
aria-label={labels.delete}
|
|
>
|
|
<Trash2 size={14} />
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="ops-plain-icon-btn"
|
|
onClick={() => onToggleExpandedTopic(uiKey)}
|
|
tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')}
|
|
>
|
|
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
{expanded ? (
|
|
<>
|
|
<div className="ops-topic-grid">
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicName}</label>
|
|
<input className="input" value={topic.name || ''} onChange={(e) => onUpdateTopicLocal(idx, { name: e.target.value })} placeholder={labels.topicName} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicPriority}</label>
|
|
<input className="input mono" type="number" min={0} max={100} step={1} value={topic.routing_priority || '50'} onChange={(e) => onUpdateTopicLocal(idx, { routing_priority: e.target.value })} />
|
|
</div>
|
|
<div className="ops-config-field ops-config-field-full">
|
|
<label className="field-label">{labels.topicDescription}</label>
|
|
<textarea className="input" rows={3} value={topic.description || ''} onChange={(e) => onUpdateTopicLocal(idx, { description: e.target.value })} placeholder={labels.topicDescription} />
|
|
</div>
|
|
<div className="ops-config-field ops-config-field-full">
|
|
<label className="field-label">{labels.topicPurpose}</label>
|
|
<textarea className="input" rows={3} value={topic.routing_purpose || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_purpose: e.target.value })} placeholder={labels.topicPurpose} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicIncludeWhen}</label>
|
|
<textarea className="input mono" rows={4} value={topic.routing_include_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_include_when: e.target.value })} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExcludeWhen}</label>
|
|
<textarea className="input mono" rows={4} value={topic.routing_exclude_when || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_exclude_when: e.target.value })} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExamplesPositive}</label>
|
|
<textarea className="input mono" rows={4} value={topic.routing_examples_positive || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_positive: e.target.value })} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExamplesNegative}</label>
|
|
<textarea className="input mono" rows={4} value={topic.routing_examples_negative || ''} onChange={(e) => onUpdateTopicLocal(idx, { routing_examples_negative: e.target.value })} placeholder={labels.topicListHint} />
|
|
</div>
|
|
</div>
|
|
<div className="row-between ops-config-footer">
|
|
<span className="field-label">{labels.topicAddHint}</span>
|
|
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => void onSaveTopic(topic)}>
|
|
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
|
|
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
{newTopicPanelOpen ? (
|
|
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
|
|
<div className="ops-config-card-header">
|
|
<div className="ops-config-card-main">
|
|
<strong>{labels.topicAdd}</strong>
|
|
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
|
|
</div>
|
|
<div className="ops-config-card-actions">
|
|
<LucentIconButton
|
|
className="ops-plain-icon-btn"
|
|
onClick={() => onSetNewTopicAdvancedOpen((prev) => !prev)}
|
|
tooltip={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
|
|
aria-label={newTopicAdvancedOpen ? (isZh ? '收起高级路由' : 'Hide Advanced') : (isZh ? '展开高级路由' : 'Show Advanced')}
|
|
>
|
|
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="ops-plain-icon-btn"
|
|
onClick={() => {
|
|
onSetNewTopicPanelOpen(false);
|
|
onSetTopicPresetMenuOpen(false);
|
|
onResetNewTopicDraft();
|
|
}}
|
|
tooltip={labels.cancel}
|
|
aria-label={labels.cancel}
|
|
>
|
|
<X size={15} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
<div className="ops-topic-grid">
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicKey}</label>
|
|
<input className="input mono" value={newTopicKey} onChange={(e) => onSetNewTopicKey(onNormalizeTopicKeyInput(e.target.value))} placeholder={labels.topicKeyPlaceholder} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicName}</label>
|
|
<input className="input" value={newTopicName} onChange={(e) => onSetNewTopicName(e.target.value)} placeholder={labels.topicName} autoComplete="off" />
|
|
</div>
|
|
<div className="ops-config-field ops-config-field-full">
|
|
<label className="field-label">{labels.topicDescription}</label>
|
|
<textarea className="input" rows={3} value={newTopicDescription} onChange={(e) => onSetNewTopicDescription(e.target.value)} placeholder={labels.topicDescription} />
|
|
</div>
|
|
{newTopicAdvancedOpen ? (
|
|
<>
|
|
<div className="ops-config-field ops-config-field-full">
|
|
<label className="field-label">{labels.topicPurpose}</label>
|
|
<textarea className="input" rows={3} value={newTopicPurpose} onChange={(e) => onSetNewTopicPurpose(e.target.value)} placeholder={labels.topicPurpose} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicIncludeWhen}</label>
|
|
<textarea className="input mono" rows={4} value={newTopicIncludeWhen} onChange={(e) => onSetNewTopicIncludeWhen(e.target.value)} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExcludeWhen}</label>
|
|
<textarea className="input mono" rows={4} value={newTopicExcludeWhen} onChange={(e) => onSetNewTopicExcludeWhen(e.target.value)} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExamplesPositive}</label>
|
|
<textarea className="input mono" rows={4} value={newTopicExamplesPositive} onChange={(e) => onSetNewTopicExamplesPositive(e.target.value)} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicExamplesNegative}</label>
|
|
<textarea className="input mono" rows={4} value={newTopicExamplesNegative} onChange={(e) => onSetNewTopicExamplesNegative(e.target.value)} placeholder={labels.topicListHint} />
|
|
</div>
|
|
<div className="ops-config-field">
|
|
<label className="field-label">{labels.topicPriority}</label>
|
|
<input className="input mono" type="number" min={0} max={100} step={1} value={newTopicPriority} onChange={(e) => onSetNewTopicPriority(e.target.value)} />
|
|
</div>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
<div className="row-between ops-config-footer">
|
|
<span className="field-label">{labels.topicAddHint}</span>
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
|
<button
|
|
className="btn btn-secondary btn-sm"
|
|
disabled={isSavingTopic}
|
|
onClick={() => {
|
|
onSetNewTopicPanelOpen(false);
|
|
onSetTopicPresetMenuOpen(false);
|
|
onResetNewTopicDraft();
|
|
}}
|
|
>
|
|
{labels.cancel}
|
|
</button>
|
|
<button className="btn btn-primary btn-sm" disabled={isSavingTopic || !hasSelectedBot} onClick={() => void onAddTopic()}>
|
|
<Save size={14} />
|
|
<span style={{ marginLeft: 6 }}>{labels.save}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</DrawerShell>
|
|
);
|
|
}
|