diff --git a/server/app.mjs b/server/app.mjs index c1e2d12..efb482c 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -453,6 +453,23 @@ export function createAdminApp(services) { adminApi.put('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); adminApi.patch('/wechat/cursor-executor/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.get('/cursor-executor-channel/config', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getAdminConfig) { + return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getAdminConfig()); + }); + + adminApi.get('/cursor-executor-channel/runtime', requireAdmin, async (_req, res) => { + if (!wechatCursorExecutorPolicyService?.getRuntimeState) { + return res.status(503).json({ message: 'TKMind 智趣体验通道未启用' }); + } + return res.json(await wechatCursorExecutorPolicyService.getRuntimeState()); + }); + + adminApi.put('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.patch('/cursor-executor-channel/config', requireAdmin, updateWechatCursorExecutorConfig); + adminApi.get('/mindspace/config', requireAdmin, async (_req, res) => { if (!loadMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' }); const config = await loadMindSpaceConfig(pool); diff --git a/src/App.tsx b/src/App.tsx index 0179015..5fbdadd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage'; import { UserDetailPage } from './admin/pages/UserDetailPage'; import { UsersPage } from './admin/pages/UsersPage'; import { WechatPage } from './admin/pages/WechatPage'; +import { CursorChannelPage } from './admin/pages/CursorChannelPage'; import { AssetGatewayPage } from './admin/pages/AssetGatewayPage'; import { BlockedWordsPage } from './admin/pages/BlockedWordsPage'; import { AnalyticsConfigPage } from './admin/pages/AnalyticsConfigPage'; @@ -137,6 +138,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index 11ea138..12ce1a9 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -30,6 +30,7 @@ const NAV_SECTIONS: NavSection[] = [ { label: '平台配置', items: [ + { to: '/cursor-channel', label: '智趣体验通道' }, { to: '/wechat', label: '服务号' }, { to: '/mindspace', label: 'MindSpace 配置' }, { to: '/analytics', label: 'Analytics 配置' }, diff --git a/src/admin/pages/CursorChannelPage.tsx b/src/admin/pages/CursorChannelPage.tsx new file mode 100644 index 0000000..2bbdc4e --- /dev/null +++ b/src/admin/pages/CursorChannelPage.tsx @@ -0,0 +1,540 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + getWechatCursorExecutorConfig, + getWechatCursorExecutorRuntime, + listAdminUsers, + patchWechatCursorExecutorConfig, +} from '../../api/client'; +import type { + AdminUserRow, + WechatCursorExecutorAdminConfig, + WechatCursorExecutorRuntimeState, +} from '../../types'; +import { formatTime } from '../utils/format'; + +type CursorChannelForm = { + enabled: boolean; + h5Enabled: boolean; + wechatEnabled: boolean; + selectedUserIds: string[]; + manualAllowlistEntries: string[]; + intentAllowlist: string; + fallbackToDeepseek: boolean; +}; + +const USER_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function userMatchesAllowlistToken(user: AdminUserRow, token: string) { + const normalized = token.trim().toLowerCase(); + if (!normalized) return false; + const identities = [user.id, user.username, user.slug, user.publishSlug, user.displayName] + .map((value) => String(value ?? '').trim().toLowerCase()) + .filter(Boolean); + return identities.includes(normalized); +} + +function resolveAllowlistTokens(tokens: string[], users: AdminUserRow[]) { + const selectedUserIds: string[] = []; + const manualAllowlistEntries: string[] = []; + const seenIds = new Set(); + + for (const rawToken of tokens) { + const token = String(rawToken ?? '').trim(); + if (!token) continue; + const matchedUser = users.find((user) => userMatchesAllowlistToken(user, token)); + if (matchedUser) { + if (!seenIds.has(matchedUser.id)) { + seenIds.add(matchedUser.id); + selectedUserIds.push(matchedUser.id); + } + continue; + } + if (USER_ID_PATTERN.test(token)) { + if (!seenIds.has(token)) { + seenIds.add(token); + selectedUserIds.push(token); + } + continue; + } + manualAllowlistEntries.push(token); + } + + return { selectedUserIds, manualAllowlistEntries }; +} + +function configToForm( + config: WechatCursorExecutorAdminConfig | undefined, + users: AdminUserRow[] = [], +): CursorChannelForm { + const tokens = Array.isArray(config?.userAllowlist) ? config.userAllowlist : []; + const channels = Array.isArray(config?.channelAllowlist) ? config.channelAllowlist : ['h5', 'wechat_mp']; + const { selectedUserIds, manualAllowlistEntries } = resolveAllowlistTokens(tokens, users); + return { + enabled: Boolean(config?.enabled), + h5Enabled: channels.includes('h5'), + wechatEnabled: channels.includes('wechat_mp'), + selectedUserIds, + manualAllowlistEntries, + intentAllowlist: Array.isArray(config?.intentAllowlist) + ? config.intentAllowlist.join('\n') + : 'page.generate', + fallbackToDeepseek: config?.fallbackToDeepseek !== false, + }; +} + +function formToAllowlist(form: CursorChannelForm) { + const manual = form.manualAllowlistEntries.map((item) => item.trim()).filter(Boolean); + return [...new Set([...form.selectedUserIds, ...manual])]; +} + +function formToChannelAllowlist(form: CursorChannelForm) { + const channels: string[] = []; + if (form.h5Enabled) channels.push('h5'); + if (form.wechatEnabled) channels.push('wechat_mp'); + return channels; +} + +function CursorAllowlistPickerModal({ + open, + users, + selectedUserIds, + busy, + onClose, + onConfirm, +}: { + open: boolean; + users: AdminUserRow[]; + selectedUserIds: string[]; + busy?: boolean; + onClose: () => void; + onConfirm: (nextIds: string[]) => void; +}) { + const [search, setSearch] = useState(''); + const [draftIds, setDraftIds] = useState(selectedUserIds); + const [pickerUsers, setPickerUsers] = useState(users); + const [loadingUsers, setLoadingUsers] = useState(false); + + useEffect(() => { + if (!open) return; + setDraftIds(selectedUserIds); + setSearch(''); + setPickerUsers(users); + }, [open, selectedUserIds, users]); + + useEffect(() => { + if (!open) return; + const timer = window.setTimeout(() => { + void (async () => { + setLoadingUsers(true); + try { + const result = await listAdminUsers({ + page: 1, + pageSize: 500, + status: 'active', + search: search.trim() || undefined, + }); + setPickerUsers(result.items); + } catch { + setPickerUsers(users); + } finally { + setLoadingUsers(false); + } + })(); + }, search.trim() ? 250 : 0); + return () => window.clearTimeout(timer); + }, [open, search, users]); + + if (!open) return null; + + const visibleIds = pickerUsers.map((user) => user.id); + const allVisibleSelected = + visibleIds.length > 0 && visibleIds.every((id) => draftIds.includes(id)); + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
event.stopPropagation()} + > +
+
+

选择体验通道用户

+

+ 仅勾选用户会进入 TKMind 智趣独立通道;其余用户完全不受影响,仍走原有 DeepSeek/Goose 链路。 +

+
+ +
+ +
+ setSearch(event.target.value)} + placeholder="搜索用户名、昵称或 userId" + style={{ flex: 1, minWidth: 220 }} + autoFocus + /> + +
+ +
+ {loadingUsers ?

加载用户…

: null} + {!loadingUsers && pickerUsers.length === 0 ? ( +

没有匹配的用户

+ ) : null} + {pickerUsers.map((user) => ( + + ))} +
+ +
+ + +
+
+
+ ); +} + +export function CursorChannelPage() { + const [users, setUsers] = useState([]); + const [form, setForm] = useState(null); + const [savedForm, setSavedForm] = useState(null); + const [runtime, setRuntime] = useState(null); + const [pickerOpen, setPickerOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const dirty = useMemo(() => { + if (!form || !savedForm) return false; + return JSON.stringify(form) !== JSON.stringify(savedForm); + }, [form, savedForm]); + + const selectedUsers = useMemo(() => { + if (!form) return []; + const byId = new Map(users.map((user) => [user.id, user])); + return form.selectedUserIds.map((userId) => ({ + userId, + user: byId.get(userId) ?? null, + })); + }, [form, users]); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextUsers, nextConfig, nextRuntime] = await Promise.all([ + listAdminUsers({ page: 1, pageSize: 200, status: 'active' }), + getWechatCursorExecutorConfig().catch(() => null), + getWechatCursorExecutorRuntime().catch(() => null), + ]); + setUsers(nextUsers.items); + const nextForm = configToForm(nextConfig?.config, nextUsers.items); + setForm(nextForm); + setSavedForm(nextForm); + setRuntime(nextRuntime); + } catch (err) { + setError(err instanceof Error ? err.message : '加载失败'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const handleSave = async () => { + if (!form) return; + setSaving(true); + setError(null); + setNotice(null); + try { + const channelAllowlist = formToChannelAllowlist(form); + if (form.enabled && channelAllowlist.length === 0) { + throw new Error('启用体验通道时,至少选择一个接入渠道(H5 或服务号)'); + } + if (form.enabled && formToAllowlist(form).length === 0) { + throw new Error('启用体验通道时,至少选择一名白名单用户'); + } + const result = await patchWechatCursorExecutorConfig({ + enabled: form.enabled, + userAllowlist: formToAllowlist(form), + channelAllowlist, + intentAllowlist: form.intentAllowlist + .split(/[\s,]+/u) + .map((item) => item.trim()) + .filter(Boolean), + fallbackToDeepseek: form.fallbackToDeepseek, + }); + const nextForm = configToForm(result.config, users); + setForm(nextForm); + setSavedForm(nextForm); + setRuntime(await getWechatCursorExecutorRuntime().catch(() => null)); + setNotice('TKMind 智趣体验通道已保存。未在白名单内的用户不受影响。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setSaving(false); + } + }; + + return ( +
+
+

TKMind 智趣体验通道

+

+ 独立于现有 DeepSeek/Goose 链路的 Cursor 执行通道。仅对白名单用户生效,支持 H5 与服务号;其余用户完全不受影响。 +

+
+ + {error &&

{error}

} + {notice &&

{notice}

} + +
+

通道说明

+
    +
  • + H5:白名单用户在页面生成、问卷(Page Data)、Excel 分析等任务时,走 Cursor 独立执行。 +
  • +
  • + 服务号:白名单用户在指定意图(默认 page.generate)下走 Cursor;其余意图仍走原有链路。 +
  • +
  • 非白名单用户:H5 与服务号均保持原有 DeepSeek/Goose 行为,不会被 Cursor 路由影响。
  • +
+

+ 服务端需开启 MEMIND_CURSOR_EXECUTOR_ENABLED=1 与 Tool Gateway;详见 Memind{' '} + .env.example。 +

+
+ + {form ? ( +
+
+
+

通道配置

+

+ 默认关闭。开启后仅白名单用户进入智趣通道,不影响其他用户。 +

+
+ +
+ +
+ +
+ + 接入渠道 + + + +
+ + +