Files
memind_adm/src/admin/pages/CursorChannelPage.tsx
T
john ec38ee086d Add dedicated admin page for TKMind Cursor experience channel (H5 + WeChat).
Centralize whitelist and channel toggles away from the WeChat page so Cursor stays an opt-in path for selected users only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 15:27:10 +08:00

541 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string>();
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<string[]>(selectedUserIds);
const [pickerUsers, setPickerUsers] = useState<AdminUserRow[]>(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 (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className="modal-box"
role="dialog"
aria-modal="true"
aria-labelledby="cursor-allowlist-picker-title"
style={{ maxWidth: 720, width: 'min(720px, calc(100vw - 32px))' }}
onMouseDown={(event) => event.stopPropagation()}
>
<div className="modal-head">
<div>
<h3 id="cursor-allowlist-picker-title"></h3>
<p className="muted" style={{ margin: '6px 0 0', fontSize: 13 }}>
TKMind DeepSeek/Goose
</p>
</div>
<button type="button" className="modal-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
<div className="wechat-toolbar" style={{ marginBottom: 12 }}>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="搜索用户名、昵称或 userId"
style={{ flex: 1, minWidth: 220 }}
autoFocus
/>
<button
type="button"
className="ghost-btn"
disabled={loadingUsers || pickerUsers.length === 0}
onClick={() => {
setDraftIds((current) => {
if (allVisibleSelected) {
return current.filter((id) => !visibleIds.includes(id));
}
return [...new Set([...current, ...visibleIds])];
});
}}
>
{allVisibleSelected ? '取消全选当前列表' : '全选当前列表'}
</button>
</div>
<div className="cursor-allowlist-picker-list">
{loadingUsers ? <p className="muted"></p> : null}
{!loadingUsers && pickerUsers.length === 0 ? (
<p className="muted"></p>
) : null}
{pickerUsers.map((user) => (
<label key={user.id} className="cursor-allowlist-picker-row">
<input
type="checkbox"
checked={draftIds.includes(user.id)}
onChange={() => {
setDraftIds((current) =>
current.includes(user.id)
? current.filter((id) => id !== user.id)
: [...current, user.id],
);
}}
/>
<span>
{user.displayName || user.username}
<span className="muted"> @{user.username}</span>
</span>
</label>
))}
</div>
<div className="modal-actions">
<button type="button" className="ghost-btn" onClick={onClose} disabled={busy}>
</button>
<button
type="button"
className="primary-btn"
disabled={busy}
onClick={() => onConfirm(draftIds)}
>
({draftIds.length})
</button>
</div>
</div>
</div>
);
}
export function CursorChannelPage() {
const [users, setUsers] = useState<AdminUserRow[]>([]);
const [form, setForm] = useState<CursorChannelForm | null>(null);
const [savedForm, setSavedForm] = useState<CursorChannelForm | null>(null);
const [runtime, setRuntime] = useState<WechatCursorExecutorRuntimeState | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(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 (
<div className="admin-page">
<div className="admin-page-head">
<h2>TKMind </h2>
<p className="muted">
DeepSeek/Goose Cursor H5
</p>
</div>
{error && <p className="banner banner-error">{error}</p>}
{notice && <p className="banner banner-info">{notice}</p>}
<section className="admin-card">
<h3></h3>
<ul className="muted" style={{ margin: '8px 0 0', paddingLeft: 20, lineHeight: 1.7 }}>
<li>
<strong>H5</strong>(Page Data)Excel Cursor
</li>
<li>
<strong></strong> page.generate Cursor
</li>
<li>H5 DeepSeek/Goose Cursor </li>
</ul>
<p className="muted" style={{ marginTop: 12 }}>
<code>MEMIND_CURSOR_EXECUTOR_ENABLED=1</code> Tool Gateway Memind{' '}
<code>.env.example</code>
</p>
</section>
{form ? (
<section className="admin-card">
<div className="admin-card-head">
<div>
<h2></h2>
<p className="muted" style={{ marginTop: 6 }}>
</p>
</div>
<button
type="button"
className="ghost-btn"
onClick={() => void handleSave()}
disabled={loading || saving || !dirty}
>
{saving ? '保存中…' : dirty ? '保存配置' : '已保存'}
</button>
</div>
<div className="admin-form" style={{ marginTop: 16 }}>
<label>
<input
type="checkbox"
checked={form.enabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, enabled: event.target.checked })
}
/>{' '}
</label>
<fieldset style={{ border: 'none', padding: 0, margin: '12px 0' }}>
<legend className="muted" style={{ marginBottom: 8 }}>
</legend>
<label style={{ display: 'block', marginBottom: 8 }}>
<input
type="checkbox"
checked={form.h5Enabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, h5Enabled: event.target.checked })
}
/>{' '}
H5
</label>
<label style={{ display: 'block' }}>
<input
type="checkbox"
checked={form.wechatEnabled}
disabled={loading || saving}
onChange={(event) =>
setForm((current) => current && { ...current, wechatEnabled: event.target.checked })
}
/>{' '}
</label>
</fieldset>
<label>
<input
type="checkbox"
checked={form.fallbackToDeepseek}
disabled={loading || saving}
onChange={(event) =>
setForm((current) =>
current && { ...current, fallbackToDeepseek: event.target.checked },
)
}
/>{' '}
Cursor 退 DeepSeek
</label>
<label>
<div className="wechat-toolbar" style={{ marginTop: 8, marginBottom: 8 }}>
<button
type="button"
className="ghost-btn"
disabled={loading || saving}
onClick={() => setPickerOpen(true)}
>
</button>
{form.selectedUserIds.length > 0 ? (
<button
type="button"
className="ghost-btn"
disabled={loading || saving}
onClick={() =>
setForm((current) => current && { ...current, selectedUserIds: [] })
}
>
</button>
) : null}
</div>
{selectedUsers.length > 0 ? (
<div className="cursor-allowlist-chips">
{selectedUsers.map(({ userId, user }) => (
<span key={userId} className="cursor-allowlist-chip">
<span>
{user ? user.displayName || user.username : userId}
{user ? <span className="muted"> @{user.username}</span> : null}
</span>
<button
type="button"
className="cursor-allowlist-chip-remove"
aria-label="移出白名单"
disabled={loading || saving}
onClick={() =>
setForm((current) =>
current
? {
...current,
selectedUserIds: current.selectedUserIds.filter((id) => id !== userId),
}
: current,
)
}
>
×
</button>
</span>
))}
</div>
) : (
<p className="muted" style={{ margin: 0 }}>
<Link to="/users"></Link>
</p>
)}
</label>
<label>
H5 page.generate
<textarea
value={form.intentAllowlist}
disabled={loading || saving}
rows={2}
placeholder="page.generate"
onChange={(event) =>
setForm((current) => current && { ...current, intentAllowlist: event.target.value })
}
/>
</label>
</div>
{runtime ? (
<p className="muted" style={{ marginTop: 12 }}>
{runtime.policy.enabled ? '已开启' : '已关闭'}
{' · '}
{runtime.policy.userAllowlist.length}
{' · '}
{(runtime.policy.channelAllowlist ?? []).join('、') || '无'}
{' · '}
{runtime.source}
{runtime.updatedAt ? ` · 更新 ${formatTime(runtime.updatedAt)}` : ''}
</p>
) : null}
</section>
) : null}
{form ? (
<CursorAllowlistPickerModal
open={pickerOpen}
users={users}
selectedUserIds={form.selectedUserIds}
busy={loading || saving}
onClose={() => setPickerOpen(false)}
onConfirm={(nextIds) => {
setForm((current) => current && { ...current, selectedUserIds: nextIds });
setPickerOpen(false);
}}
/>
) : null}
</div>
);
}