Files
memind/src/components/MindSpacePageDataOpsPanel.tsx
T
john 6b0c633a75 feat(page-data): complete Phase 4-5, ops UI, and publish integration
Add visitor roles, row-level scope, owner ops APIs, MySQL policy index,
Turnstile captcha, browser client SDK, publish-panel dataset binding,
acceptance tests, and usage documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:52:49 +08:00

320 lines
12 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, useState, type ReactNode } from 'react';
import {
buildPageDataExportUrl,
closePageDataDataset,
getPageDataOpsOverview,
resetPageDataPassword,
restorePageDataRow,
revokePageDataTokens,
} from '../api/client';
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
type Props = {
pageId: string;
pageTitle: string;
accessMode: MindSpacePublishCheck['accessMode'];
onClose: () => void;
};
function formatLogTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClose }: Props) {
const [overview, setOverview] = useState<PageDataOpsOverview | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [newPassword, setNewPassword] = useState('');
const [message, setMessage] = useState<string | null>(null);
const loadOverview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const next = await getPageDataOpsOverview(pageId);
setOverview(next);
} catch (err) {
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
if (text.includes('policy_not_found') || text.includes('策略未配置')) {
setOverview(null);
setError('当前页面尚未配置 Page Data 策略。请让 Agent 注册 dataset 并调用 private_data_set_page_policy。');
} else {
setError(text);
}
} finally {
setLoading(false);
}
}, [pageId]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const runAction = async (key: string, fn: () => Promise<void>) => {
setBusyAction(key);
setMessage(null);
try {
await fn();
await loadOverview();
} catch (err) {
setMessage(err instanceof Error ? err.message : '操作失败');
} finally {
setBusyAction(null);
}
};
const recentDeletions =
overview?.recentLogs.filter(
(entry) => entry.action === 'soft_delete' && entry.dataset && entry.rowId != null,
) ?? [];
return (
<section className="mindspace-page-data-ops-panel">
<div className="mindspace-page-data-ops-header">
<div>
<p className="mindspace-eyebrow">PAGE DATA</p>
<h3></h3>
<small>
{pageTitle} · ID {pageId}
</small>
</div>
<button type="button" onClick={onClose}>
</button>
</div>
{loading ? <p className="mindspace-page-data-ops-muted"></p> : null}
{error ? <p className="mindspace-page-data-ops-error">{error}</p> : null}
{message ? <p className="mindspace-page-data-ops-message">{message}</p> : null}
{overview ? (
<>
<div className="mindspace-page-data-ops-summary">
<div>
<span>访</span>
<strong>{overview.publication.accessMode}</strong>
</div>
<div>
<span></span>
<strong>{overview.index?.scopeHash ?? '—'}</strong>
</div>
<div>
<span></span>
<strong>{overview.activeSessionCount}</strong>
</div>
<div>
<span>Dataset </span>
<strong>{overview.datasets.length}</strong>
</div>
<div>
<span></span>
<strong>{overview.recentSubmissions.length}</strong>
</div>
</div>
{overview.unconfiguredDatasets && overview.unconfiguredDatasets.length > 0 ? (
<div className="mindspace-page-data-ops-section">
<h4> Dataset</h4>
<p className="mindspace-page-data-ops-muted">
{overview.unconfiguredDatasets.map((name) => (
<code key={name}>{name}</code>
)).reduce<ReactNode[]>((acc, node, index) => (
index === 0 ? [node] : [...acc, '、', node]
), [])}
{' '} Agent Page Data 访
</p>
</div>
) : null}
<div className="mindspace-page-data-ops-section">
<h4>Dataset </h4>
{overview.datasets.length === 0 ? (
<p className="mindspace-page-data-ops-muted"> dataset</p>
) : (
<div className="mindspace-page-data-ops-table-wrap">
<table className="mindspace-page-data-ops-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th />
</tr>
</thead>
<tbody>
{overview.datasets.map((dataset) => (
<tr key={dataset.name}>
<td>
<code>{dataset.name}</code>
{dataset.closed ? <span className="mindspace-page-data-ops-badge"></span> : null}
</td>
<td>{dataset.read ? '是' : '否'}</td>
<td>{dataset.insert ? '是' : '否'}</td>
<td>{dataset.update ? '是' : '否'}</td>
<td>{dataset.softDelete ? '是' : '否'}</td>
<td>{dataset.stats?.total ?? '—'}</td>
<td>
<div className="mindspace-page-data-ops-row-actions">
{!dataset.closed ? (
<button
type="button"
disabled={busyAction != null}
onClick={() =>
void runAction(`close-${dataset.name}`, async () => {
await closePageDataDataset(pageId, dataset.name);
setMessage(`已关闭 dataset${dataset.name}`);
})
}
>
{busyAction === `close-${dataset.name}` ? '处理中…' : '关闭'}
</button>
) : null}
<a
href={buildPageDataExportUrl(dataset.name, 'json')}
target="_blank"
rel="noreferrer"
>
JSON
</a>
<a
href={buildPageDataExportUrl(dataset.name, 'csv')}
target="_blank"
rel="noreferrer"
>
CSV
</a>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mindspace-page-data-ops-section">
<h4></h4>
{overview.recentSubmissions.length === 0 ? (
<p className="mindspace-page-data-ops-muted"></p>
) : (
<ul className="mindspace-page-data-ops-log-list">
{overview.recentSubmissions.map((entry, index) => (
<li key={`${entry.at}-${entry.rowId ?? index}`}>
<span>{formatLogTime(entry.at)}</span>
<span>{entry.dataset}</span>
<span>#{entry.rowId ?? '—'}</span>
</li>
))}
</ul>
)}
</div>
<div className="mindspace-page-data-ops-section">
<h4></h4>
{overview.recentLogs.length === 0 ? (
<p className="mindspace-page-data-ops-muted"></p>
) : (
<ul className="mindspace-page-data-ops-log-list">
{overview.recentLogs.map((entry, index) => (
<li key={`${entry.at}-${entry.action}-${index}`}>
<span>{formatLogTime(entry.at)}</span>
<span>{entry.action}</span>
<span>{entry.dataset ?? '—'}</span>
<span>{entry.actor?.role ?? entry.actor?.type ?? '—'}</span>
</li>
))}
</ul>
)}
</div>
<div className="mindspace-page-data-ops-actions">
<button
type="button"
disabled={busyAction != null || overview.activeSessionCount === 0}
onClick={() =>
void runAction('revoke-all', async () => {
const result = await revokePageDataTokens(pageId, { revokeAll: true });
setMessage(`已撤销 ${result.revokedCount} 个活跃会话令牌。`);
})
}
>
{busyAction === 'revoke-all' ? '撤销中…' : '撤销全部数据令牌'}
</button>
{accessMode === 'password' ? (
<div className="mindspace-page-data-ops-password">
<input
type="password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
placeholder="新页面口令(至少 8 位)"
autoComplete="new-password"
/>
<button
type="button"
disabled={busyAction != null || newPassword.trim().length < 8}
onClick={() =>
void runAction('reset-password', async () => {
const result = await resetPageDataPassword(pageId, newPassword.trim());
setNewPassword('');
setMessage(
`口令已重置,并撤销 ${result.revokedSessions} 个旧会话。`,
);
})
}
>
{busyAction === 'reset-password' ? '重置中…' : '重置页面口令'}
</button>
</div>
) : null}
</div>
<div className="mindspace-page-data-ops-section">
<h4></h4>
{recentDeletions.length === 0 ? (
<p className="mindspace-page-data-ops-muted"></p>
) : (
<ul className="mindspace-page-data-ops-log-list">
{recentDeletions.map((entry, index) => (
<li key={`${entry.at}-restore-${index}`}>
<span>{formatLogTime(entry.at)}</span>
<span>{entry.dataset}</span>
<span>#{entry.rowId}</span>
<button
type="button"
disabled={busyAction != null}
onClick={() =>
void runAction(`restore-${entry.dataset}-${entry.rowId}`, async () => {
if (!entry.dataset || entry.rowId == null) return;
await restorePageDataRow(entry.dataset, entry.rowId);
setMessage(`已恢复 ${entry.dataset} #${entry.rowId}`);
})
}
>
{busyAction === `restore-${entry.dataset}-${entry.rowId}` ? '恢复中…' : '恢复'}
</button>
</li>
))}
</ul>
)}
</div>
<p className="mindspace-page-data-ops-hint">
HTML <code>/assets/page-data-client.js</code>{' '}
<code>MindSpacePageData.createClient({'{'} pageId {'}'})</code> API
Cloudflare Turnstile{' '}
<code>insertRow(dataset, row, {'{'} turnstileToken {'}'})</code>
</p>
</>
) : null}
</section>
);
}