forked from hangshuo652/aurak
0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
510 lines
25 KiB
TypeScript
510 lines
25 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
|
import { Plus, Trash2, Copy, Check, RefreshCcw, ToggleLeft, ToggleRight, ExternalLink, Wifi, WifiOff, Loader2 } from 'lucide-react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { useLanguage } from '../../../contexts/LanguageContext';
|
|
|
|
interface FeishuBotInfo {
|
|
id: string;
|
|
appId: string;
|
|
botName?: string;
|
|
enabled: boolean;
|
|
webhookUrl: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
type WsState = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
|
|
interface WsStatus {
|
|
botId: string;
|
|
state: WsState;
|
|
connectedAt?: string;
|
|
lastHeartbeat?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export const FeishuPluginConfig: React.FC = () => {
|
|
const { apiKey } = useAuth();
|
|
const { t } = useLanguage();
|
|
const [bots, setBots] = useState<FeishuBotInfo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
const [wsStatuses, setWsStatuses] = useState<Record<string, WsStatus>>({});
|
|
const [wsLoading, setWsLoading] = useState<Record<string, boolean>>({});
|
|
const [form, setForm] = useState({
|
|
appId: '',
|
|
appSecret: '',
|
|
botName: '',
|
|
verificationToken: '',
|
|
encryptKey: '',
|
|
});
|
|
|
|
const fetchBots = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch('/api/feishu/bots', {
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setBots(data);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch Feishu bots', e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [apiKey]);
|
|
|
|
const fetchWsStatuses = useCallback(async () => {
|
|
try {
|
|
const res = await fetch('/api/feishu/ws/status', {
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
const map: Record<string, WsStatus> = {};
|
|
for (const s of (data.connections ?? [])) {
|
|
map[s.botId] = s;
|
|
}
|
|
setWsStatuses(map);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch WS statuses', e);
|
|
}
|
|
}, [apiKey]);
|
|
|
|
useEffect(() => {
|
|
fetchBots();
|
|
}, [fetchBots]);
|
|
|
|
useEffect(() => {
|
|
fetchWsStatuses();
|
|
}, [fetchWsStatuses]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!form.appId || !form.appSecret) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const res = await fetch('/api/feishu/bots', {
|
|
method: 'POST',
|
|
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form),
|
|
});
|
|
if (res.ok) {
|
|
await fetchBots();
|
|
setShowForm(false);
|
|
setForm({ appId: '', appSecret: '', botName: '', verificationToken: '', encryptKey: '' });
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to create bot', e);
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (botId: string) => {
|
|
if (!window.confirm(t('feishuConfirmDelete'))) return;
|
|
try {
|
|
await fetch(`/api/feishu/bots/${botId}`, {
|
|
method: 'DELETE',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
await fetchBots();
|
|
} catch (e) {
|
|
console.error('Failed to delete bot', e);
|
|
}
|
|
};
|
|
|
|
const handleToggle = async (bot: FeishuBotInfo) => {
|
|
try {
|
|
await fetch(`/api/feishu/bots/${bot.id}/toggle`, {
|
|
method: 'PATCH',
|
|
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled: !bot.enabled }),
|
|
});
|
|
await fetchBots();
|
|
} catch (e) {
|
|
console.error('Failed to toggle bot', e);
|
|
}
|
|
};
|
|
|
|
const handleWsConnect = async (botId: string) => {
|
|
setWsLoading((prev) => ({ ...prev, [botId]: true }));
|
|
try {
|
|
const res = await fetch(`/api/feishu/bots/${botId}/ws/connect`, {
|
|
method: 'POST',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
if (res.ok) {
|
|
setWsStatuses((prev) => ({
|
|
...prev,
|
|
[botId]: { botId, state: 'connecting' },
|
|
}));
|
|
// Poll for status after a short delay
|
|
setTimeout(async () => {
|
|
await fetchWsStatuses();
|
|
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
|
}, 2000);
|
|
} else {
|
|
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to connect WS', e);
|
|
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
|
}
|
|
};
|
|
|
|
const handleWsDisconnect = async (botId: string) => {
|
|
setWsLoading((prev) => ({ ...prev, [botId]: true }));
|
|
try {
|
|
const res = await fetch(`/api/feishu/bots/${botId}/ws/disconnect`, {
|
|
method: 'POST',
|
|
headers: { 'x-api-key': apiKey },
|
|
});
|
|
if (res.ok) {
|
|
setWsStatuses((prev) => ({
|
|
...prev,
|
|
[botId]: { botId, state: 'disconnected' },
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to disconnect WS', e);
|
|
} finally {
|
|
setWsLoading((prev) => ({ ...prev, [botId]: false }));
|
|
}
|
|
};
|
|
|
|
const copyWebhookUrl = (url: string, id: string) => {
|
|
const fullUrl = `${window.location.origin}${url}`;
|
|
navigator.clipboard.writeText(fullUrl);
|
|
setCopiedId(id);
|
|
setTimeout(() => setCopiedId(null), 2000);
|
|
};
|
|
|
|
const getWsStateColor = (state: WsState) => {
|
|
switch (state) {
|
|
case 'connected': return 'text-emerald-500';
|
|
case 'connecting': return 'text-amber-500';
|
|
case 'error': return 'text-red-500';
|
|
default: return 'text-slate-400';
|
|
}
|
|
};
|
|
|
|
const getWsStateBg = (state: WsState) => {
|
|
switch (state) {
|
|
case 'connected': return 'bg-emerald-50 text-emerald-700 border-emerald-200';
|
|
case 'connecting': return 'bg-amber-50 text-amber-700 border-amber-200';
|
|
case 'error': return 'bg-red-50 text-red-700 border-red-200';
|
|
default: return 'bg-slate-50 text-slate-500 border-slate-200';
|
|
}
|
|
};
|
|
|
|
const getWsStateLabel = (state: WsState) => {
|
|
switch (state) {
|
|
case 'connected': return t('feishuWsConnected');
|
|
case 'connecting': return t('feishuWsConnecting');
|
|
case 'error': return t('feishuWsError');
|
|
default: return t('feishuWsDisconnected');
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="max-w-3xl">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-3xl">🪶</span>
|
|
<div>
|
|
<h2 className="text-xl font-bold text-slate-900">{t('pluginFeishuName')}</h2>
|
|
<p className="text-sm text-slate-500 mt-0.5">{t('pluginFeishuDesc')}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => { fetchBots(); fetchWsStatuses(); }}
|
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
title={t('refresh')}
|
|
>
|
|
<RefreshCcw size={16} />
|
|
</button>
|
|
<button
|
|
onClick={() => setShowForm(!showForm)}
|
|
className="flex items-center gap-2 px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors"
|
|
>
|
|
<Plus size={15} />
|
|
{t('feishuAddBot')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Setup Guide */}
|
|
<div className="bg-indigo-50 border border-indigo-200 rounded-xl p-4 mb-6 text-sm text-indigo-800">
|
|
<p className="font-semibold mb-2">📌 {t('feishuSetupGuide')}</p>
|
|
<ol className="list-decimal list-inside space-y-1 text-indigo-700">
|
|
<li>{t('feishuStep1')}</li>
|
|
<li>{t('feishuStep2')}</li>
|
|
<li>{t('feishuStep3')}</li>
|
|
<li>{t('feishuStep4')}</li>
|
|
</ol>
|
|
<a
|
|
href="https://open.feishu.cn/document/faq/bot"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 mt-2 text-indigo-600 hover:underline font-medium"
|
|
>
|
|
{t('feishuDocs')} <ExternalLink size={12} />
|
|
</a>
|
|
</div>
|
|
|
|
{/* Add Bot Form */}
|
|
{showForm && (
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="bg-white border border-slate-200 rounded-xl p-6 mb-6 shadow-sm"
|
|
>
|
|
<h3 className="font-semibold text-slate-800 mb-4">{t('feishuAddBot')}</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
|
App ID <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="text"
|
|
required
|
|
value={form.appId}
|
|
onChange={(e) => setForm((f) => ({ ...f, appId: e.target.value }))}
|
|
placeholder="cli_xxxxxxxxxxxxxxxx"
|
|
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
|
App Secret <span className="text-red-500">*</span>
|
|
</label>
|
|
<input
|
|
type="password"
|
|
required
|
|
value={form.appSecret}
|
|
onChange={(e) => setForm((f) => ({ ...f, appSecret: e.target.value }))}
|
|
placeholder="••••••••••••••••"
|
|
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
|
{t('feishuBotDisplayName')}
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.botName}
|
|
onChange={(e) => setForm((f) => ({ ...f, botName: e.target.value }))}
|
|
placeholder={t('feishuBotNamePlaceholder')}
|
|
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
|
Verification Token
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={form.verificationToken}
|
|
onChange={(e) => setForm((f) => ({ ...f, verificationToken: e.target.value }))}
|
|
placeholder={t('feishuTokenPlaceholder')}
|
|
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-2">
|
|
<label className="block text-xs font-semibold text-slate-600 mb-1">
|
|
Encrypt Key ({t('optional')})
|
|
</label>
|
|
<input
|
|
type="password"
|
|
value={form.encryptKey}
|
|
onChange={(e) => setForm((f) => ({ ...f, encryptKey: e.target.value }))}
|
|
placeholder={t('feishuEncryptKeyPlaceholder')}
|
|
className="w-full h-9 px-3 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 outline-none transition"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-3 mt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowForm(false)}
|
|
className="px-4 h-9 text-sm text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
|
|
>
|
|
{t('cancel')}
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={submitting}
|
|
className="px-4 h-9 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-semibold transition-colors disabled:opacity-50"
|
|
>
|
|
{submitting ? t('saving') : t('save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Bot List */}
|
|
{loading ? (
|
|
<div className="text-center py-12 text-slate-400">{t('loading')}</div>
|
|
) : bots.length === 0 ? (
|
|
<div className="text-center py-12 bg-white rounded-xl border border-dashed border-slate-200">
|
|
<span className="text-4xl mb-3 block">🪶</span>
|
|
<p className="text-slate-500 text-sm">{t('feishuNoBots')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{bots.map((bot) => {
|
|
const wsStatus = wsStatuses[bot.id];
|
|
const wsState: WsState = wsStatus?.state ?? 'disconnected';
|
|
const isWsLoading = wsLoading[bot.id] ?? false;
|
|
const isWsConnected = wsState === 'connected';
|
|
const isWsConnecting = wsState === 'connecting';
|
|
|
|
return (
|
|
<div
|
|
key={bot.id}
|
|
className="bg-white rounded-xl border border-slate-200 shadow-sm p-5"
|
|
>
|
|
{/* Bot Header */}
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div>
|
|
<p className="font-semibold text-slate-800">
|
|
{bot.botName || bot.appId}
|
|
</p>
|
|
<p className="text-xs text-slate-400 mt-0.5">App ID: {bot.appId}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleToggle(bot)}
|
|
className="flex items-center gap-1.5 text-xs font-medium transition-colors"
|
|
title={bot.enabled ? t('feishuDisableBot') : t('feishuEnableBot')}
|
|
>
|
|
{bot.enabled ? (
|
|
<ToggleRight size={22} className="text-emerald-500" />
|
|
) : (
|
|
<ToggleLeft size={22} className="text-slate-300" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(bot.id)}
|
|
className="p-1.5 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
|
>
|
|
<Trash2 size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Webhook URL */}
|
|
<div className="bg-slate-50 rounded-lg p-3 flex items-center justify-between gap-2 mb-3">
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-[11px] font-semibold text-slate-500 uppercase tracking-wide mb-0.5">
|
|
{t('feishuWebhookUrl')}
|
|
</p>
|
|
<code className="text-xs text-slate-700 break-all">
|
|
{window.location.origin}{bot.webhookUrl}
|
|
</code>
|
|
</div>
|
|
<button
|
|
onClick={() => copyWebhookUrl(bot.webhookUrl, bot.id)}
|
|
className="shrink-0 p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors"
|
|
>
|
|
{copiedId === bot.id ? (
|
|
<Check size={15} className="text-emerald-500" />
|
|
) : (
|
|
<Copy size={15} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* WebSocket Mode Panel */}
|
|
<div className={`rounded-lg border p-3 ${isWsConnected ? 'border-emerald-200 bg-emerald-50/50' : 'border-slate-200 bg-slate-50'}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-2 flex-1 min-w-0">
|
|
{isWsConnected ? (
|
|
<Wifi size={15} className="mt-0.5 shrink-0 text-emerald-500" />
|
|
) : (
|
|
<WifiOff size={15} className="mt-0.5 shrink-0 text-slate-400" />
|
|
)}
|
|
<div>
|
|
<p className="text-xs font-semibold text-slate-700">
|
|
{t('feishuWsMode')}
|
|
</p>
|
|
<p className="text-[11px] text-slate-500 mt-0.5">
|
|
{t('feishuWsModeDesc')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* WS State Badge + Button */}
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold border ${getWsStateBg(wsState)}`}>
|
|
{getWsStateLabel(wsState)}
|
|
</span>
|
|
{isWsConnected || isWsConnecting ? (
|
|
<button
|
|
onClick={() => handleWsDisconnect(bot.id)}
|
|
disabled={isWsLoading || isWsConnecting}
|
|
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-red-600 bg-red-50 hover:bg-red-100 rounded-lg transition-colors disabled:opacity-50"
|
|
>
|
|
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <WifiOff size={12} />}
|
|
{t('feishuWsDisconnect')}
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={() => handleWsConnect(bot.id)}
|
|
disabled={isWsLoading || !bot.enabled}
|
|
className="flex items-center gap-1.5 px-3 h-7 text-xs font-medium text-indigo-600 bg-indigo-50 hover:bg-indigo-100 rounded-lg transition-colors disabled:opacity-50"
|
|
title={!bot.enabled ? t('feishuEnableBot') : undefined}
|
|
>
|
|
{isWsLoading ? <Loader2 size={12} className="animate-spin" /> : <Wifi size={12} />}
|
|
{t('feishuWsConnect')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Hint text */}
|
|
{!isWsConnected && (
|
|
<p className="text-[11px] text-slate-400 mt-2 pl-5">
|
|
💡 {t('feishuWsConnectHint')}
|
|
</p>
|
|
)}
|
|
|
|
{/* Connected At info */}
|
|
{isWsConnected && wsStatus?.connectedAt && (
|
|
<p className="text-[11px] text-emerald-600 mt-2 pl-5">
|
|
✓ {t('feishuWsConnected')} · {new Date(wsStatus.connectedAt).toLocaleTimeString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer badges */}
|
|
<div className="mt-2 flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-semibold ${
|
|
bot.enabled
|
|
? 'bg-emerald-50 text-emerald-600'
|
|
: 'bg-slate-100 text-slate-500'
|
|
}`}
|
|
>
|
|
{bot.enabled ? t('statusRunning') : t('statusStopped')}
|
|
</span>
|
|
<span className="text-[11px] text-slate-400">
|
|
{t('createdAt')}: {new Date(bot.createdAt).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|