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>
This commit is contained in:
john
2026-07-08 14:52:49 +08:00
parent 8d01553469
commit 6b0c633a75
40 changed files with 4056 additions and 60 deletions
@@ -0,0 +1,319 @@
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>
);
}