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>
);
}