dashboard-nanobot/frontend/src/modules/dashboard/components/DashboardChannelTopicModals...

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>
);
}