8d629e7a4e
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>
467 lines
19 KiB
TypeScript
467 lines
19 KiB
TypeScript
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>
|
||
);
|
||
}
|