Files
memind/src/components/MindSpacePageDataOpsPanel.tsx
T
john 8d629e7a4e feat(page-data): owner CRUD, public read, and role policy UI
Expose owner PATCH/soft-delete routes, allow anonymous read/stats on
public pages when policy enables read, and add login_required role
editor to the ops panel with tests and doc updates.

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

467 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, useState, type ReactNode } from 'react';
import {
buildPageDataExportUrl,
closePageDataDataset,
getPageDataOpsOverview,
resetPageDataPassword,
restorePageDataRow,
revokePageDataTokens,
savePageDataPolicy,
} from '../api/client';
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
type RowPolicyScope = 'all_rows' | 'own_rows' | 'owner_only';
function formatVisitorsText(visitors: Record<string, 'viewer' | 'editor'> | undefined) {
return Object.entries(visitors ?? {})
.map(([userId, role]) => `${userId} ${role}`)
.join('\n');
}
function parseVisitorsText(text: string) {
const visitors: Record<string, 'viewer' | 'editor'> = {};
for (const line of text.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^(\S+)[\s:]+(viewer|editor)$/i);
if (!match) continue;
visitors[match[1]] = match[2].toLowerCase() as 'viewer' | 'editor';
}
return visitors;
}
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 [defaultVisitorRole, setDefaultVisitorRole] = useState<'deny' | 'viewer' | 'editor'>('deny');
const [visitorsText, setVisitorsText] = useState('');
const [rowPolicies, setRowPolicies] = useState<
Record<string, { scope: RowPolicyScope; ownerColumn: string }>
>({});
const syncRoleForm = useCallback((policy: PageDataOpsOverview['policy']) => {
setDefaultVisitorRole(policy.defaultVisitorRole ?? 'deny');
setVisitorsText(formatVisitorsText(policy.visitors));
const next: Record<string, { scope: RowPolicyScope; ownerColumn: string }> = {};
for (const [name, dataset] of Object.entries(policy.datasets ?? {})) {
next[name] = {
scope: dataset.rowPolicy?.scope ?? 'all_rows',
ownerColumn: dataset.rowPolicy?.ownerColumn ?? 'created_by_user_id',
};
}
setRowPolicies(next);
}, []);
const loadOverview = useCallback(async () => {
setLoading(true);
setError(null);
try {
const next = await getPageDataOpsOverview(pageId);
setOverview(next);
syncRoleForm(next.policy);
} 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, syncRoleForm]);
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}
{overview.publication.accessMode === 'login_required' ? (
<div className="mindspace-page-data-ops-section mindspace-page-data-ops-role-editor">
<h4>访 · </h4>
<label>
访
<select
value={defaultVisitorRole}
disabled={busyAction != null}
onChange={(event) =>
setDefaultVisitorRole(event.target.value as 'deny' | 'viewer' | 'editor')
}
>
<option value="deny"></option>
<option value="viewer"> (viewer)</option>
<option value="editor"> (editor)</option>
</select>
</label>
<label>
访 ID + + viewer/editor
<textarea
value={visitorsText}
disabled={busyAction != null}
rows={4}
placeholder={'user-a editor\nuser-b viewer'}
onChange={(event) => setVisitorsText(event.target.value)}
/>
</label>
{overview.datasets.length > 0 ? (
<div className="mindspace-page-data-ops-role-datasets">
<span>Dataset </span>
{overview.datasets.map((dataset) => (
<div key={dataset.name} className="mindspace-page-data-ops-role-dataset-row">
<code>{dataset.name}</code>
<select
value={rowPolicies[dataset.name]?.scope ?? 'all_rows'}
disabled={busyAction != null}
onChange={(event) =>
setRowPolicies((current) => ({
...current,
[dataset.name]: {
scope: event.target.value as RowPolicyScope,
ownerColumn: current[dataset.name]?.ownerColumn ?? 'created_by_user_id',
},
}))
}
>
<option value="all_rows"></option>
<option value="own_rows"></option>
<option value="owner_only"> owner</option>
</select>
{(rowPolicies[dataset.name]?.scope ?? 'all_rows') === 'own_rows' ? (
<input
value={rowPolicies[dataset.name]?.ownerColumn ?? 'created_by_user_id'}
disabled={busyAction != null}
placeholder="owner 列名"
onChange={(event) =>
setRowPolicies((current) => ({
...current,
[dataset.name]: {
scope: current[dataset.name]?.scope ?? 'own_rows',
ownerColumn: event.target.value,
},
}))
}
/>
) : null}
</div>
))}
</div>
) : null}
<button
type="button"
disabled={busyAction != null}
onClick={() =>
void runAction('save-role-policy', async () => {
if (!overview?.policy) return;
const datasets = { ...overview.policy.datasets };
for (const [name, rowPolicy] of Object.entries(rowPolicies)) {
if (!datasets[name]) continue;
datasets[name] = {
...datasets[name],
rowPolicy:
rowPolicy.scope === 'all_rows'
? undefined
: {
scope: rowPolicy.scope,
ownerColumn:
rowPolicy.scope === 'own_rows'
? rowPolicy.ownerColumn.trim() || 'created_by_user_id'
: undefined,
},
};
}
await savePageDataPolicy(pageId, {
defaultVisitorRole,
visitors: parseVisitorsText(visitorsText),
datasets,
});
setMessage('角色与行级策略已保存');
})
}
>
{busyAction === 'save-role-policy' ? '保存中…' : '保存角色策略'}
</button>
</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>
);
}