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
+163
View File
@@ -34,6 +34,10 @@ import type {
MindSpacePublication,
MindSpacePublicationStats,
MindSpacePublishCheck,
PageDataAccessPolicy,
PageDataDatasetSummary,
PageDataLogEntry,
PageDataOpsOverview,
PlazaCategory,
PlazaPostBrief,
MindSpaceRedactedCopyResult,
@@ -1458,6 +1462,165 @@ export async function updatePublicationStatus(
return result.data;
}
export async function listOwnerPageDataPolicies(): Promise<
Array<{
pageId: string;
ownerUserId: string;
accessMode: string;
datasetCount: number;
scopeHash: string;
updatedAt: number;
}>
> {
const result = await apiFetch<{
data: {
policies: Array<{
pageId: string;
ownerUserId: string;
accessMode: string;
datasetCount: number;
scopeHash: string;
updatedAt: number;
}>;
};
}>('/page-data/policies');
return result.data.policies;
}
export function buildPageDataExportUrl(dataset: string, format: 'json' | 'csv' = 'json') {
const params = new URLSearchParams({ format });
return `/api/page-data/${encodeURIComponent(dataset)}/export?${params.toString()}`;
}
export async function listPageDataDatasets(): Promise<PageDataDatasetSummary[]> {
const result = await apiFetch<{ data: { datasets: PageDataDatasetSummary[] } }>('/page-data');
return result.data.datasets;
}
export async function getPageDataPolicy(pageId: string): Promise<PageDataAccessPolicy | null> {
try {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}`,
);
return result.data.policy;
} catch (error) {
if (error instanceof ApiError && error.status === 404) return null;
throw error;
}
}
export async function applyPageDataPublishPolicy(
pageId: string,
input: {
datasetName: string;
capabilities?: {
read?: boolean;
insert?: boolean;
update?: boolean;
softDelete?: boolean;
};
},
): Promise<PageDataAccessPolicy> {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/apply-publish`,
{
method: 'POST',
body: JSON.stringify({
datasetName: input.datasetName,
capabilities: input.capabilities ?? {},
}),
},
);
return result.data.policy;
}
export async function savePageDataPolicy(
pageId: string,
policy: Partial<PageDataAccessPolicy>,
): Promise<PageDataAccessPolicy> {
const result = await apiFetch<{ data: { policy: PageDataAccessPolicy } }>(
`/page-data/policies/${encodeURIComponent(pageId)}`,
{
method: 'PUT',
body: JSON.stringify(policy),
},
);
return result.data.policy;
}
export async function getPageDataOpsOverview(pageId: string): Promise<PageDataOpsOverview> {
const result = await apiFetch<{ data: PageDataOpsOverview }>(
`/page-data/policies/${encodeURIComponent(pageId)}/ops`,
);
return result.data;
}
export async function listPageDataLogs(
pageId: string,
input: { limit?: number; offset?: number } = {},
): Promise<{ pageId: string; logs: PageDataLogEntry[]; count: number }> {
const params = new URLSearchParams();
if (input.limit != null) params.set('limit', String(input.limit));
if (input.offset != null) params.set('offset', String(input.offset));
const query = params.toString();
const result = await apiFetch<{ data: { pageId: string; logs: PageDataLogEntry[]; count: number } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/logs${query ? `?${query}` : ''}`,
);
return result.data;
}
export async function revokePageDataTokens(
pageId: string,
input: { revokeAll?: boolean; token?: string },
): Promise<{ pageId: string; revokedCount: number; revokeAll: boolean }> {
const result = await apiFetch<{ data: { pageId: string; revokedCount: number; revokeAll: boolean } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/tokens/revoke`,
{
method: 'POST',
body: JSON.stringify({
revokeAll: Boolean(input.revokeAll),
token: input.token,
}),
},
);
return result.data;
}
export async function resetPageDataPassword(
pageId: string,
password: string,
): Promise<{ pageId: string; passwordReset: boolean; revokedSessions: number }> {
const result = await apiFetch<{
data: { pageId: string; passwordReset: boolean; revokedSessions: number };
}>(`/page-data/policies/${encodeURIComponent(pageId)}/password/reset`, {
method: 'POST',
body: JSON.stringify({ password }),
});
return result.data;
}
export async function closePageDataDataset(
pageId: string,
dataset: string,
): Promise<{ pageId: string; dataset: string; closed: boolean }> {
const result = await apiFetch<{ data: { pageId: string; dataset: string; closed: boolean } }>(
`/page-data/policies/${encodeURIComponent(pageId)}/datasets/${encodeURIComponent(dataset)}/close`,
{ method: 'POST' },
);
return result.data;
}
export async function restorePageDataRow(
dataset: string,
rowId: number | string,
): Promise<{ restored: boolean; row: Record<string, unknown> }> {
const result = await apiFetch<{ data: { restored: boolean; row: Record<string, unknown> } }>(
`/page-data/${encodeURIComponent(dataset)}/rows/${encodeURIComponent(String(rowId))}/restore`,
{ method: 'POST' },
);
return result.data;
}
export async function redactMindSpacePage(
pageId: string,
input: {
@@ -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>
);
}
+227 -1
View File
@@ -1,12 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory';
import {
applyPageDataPublishPolicy,
checkMindSpacePagePublication,
deleteMindSpacePage,
fixMindSpacePagePublication,
getMindSpacePageDeletePreview,
getMindSpacePage,
getMindSpacePublicationStats,
getPageDataPolicy,
listPageDataDatasets,
listPlazaCategories,
offlineMindSpacePublication,
publishMindSpacePage,
@@ -23,6 +26,7 @@ import type {
MindSpacePublishCheck,
MindSpaceQuota,
MindSpaceRedactionChange,
PageDataDatasetSummary,
PlazaCategory,
PlazaPostBrief,
} from '../types';
@@ -35,6 +39,7 @@ import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { MindSpacePublishSuccess } from './MindSpacePublishSuccess';
import { MindSpacePageDataOpsPanel } from './MindSpacePageDataOpsPanel';
import { prepareHtmlPageBrandMarkers } from '../../mindspace-page-tag.mjs';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -67,6 +72,21 @@ const ACCESS_MODE_LABELS: Record<AccessMode, string> = {
owner_only: '仅自己可见',
};
const PAGE_DATA_PUBLISH_MODES = new Set<AccessMode>(['public', 'password', 'login_required']);
function defaultPageDataCapabilities(mode: AccessMode) {
if (mode === 'public') {
return { read: false, insert: true, update: false, softDelete: false };
}
if (mode === 'password') {
return { read: true, insert: true, update: false, softDelete: false };
}
if (mode === 'login_required') {
return { read: true, insert: true, update: true, softDelete: false };
}
return { read: false, insert: false, update: false, softDelete: false };
}
function slugFromTitle(title: string, pageId?: string) {
const ascii = title
.normalize('NFKC')
@@ -274,6 +294,16 @@ export function MindSpacePageDetail({
const [isExportingImage, setIsExportingImage] = useState(false);
const [exportFeedback, setExportFeedback] = useState<string | null>(null);
const [plazaOpen, setPlazaOpen] = useState(false);
const [pageDataOpsOpen, setPageDataOpsOpen] = useState(false);
const [pageDataEnabled, setPageDataEnabled] = useState(false);
const [pageDataDataset, setPageDataDataset] = useState('');
const [pageDataRead, setPageDataRead] = useState(false);
const [pageDataInsert, setPageDataInsert] = useState(true);
const [pageDataUpdate, setPageDataUpdate] = useState(false);
const [pageDataSoftDelete, setPageDataSoftDelete] = useState(false);
const [pageDataDatasets, setPageDataDatasets] = useState<PageDataDatasetSummary[]>([]);
const [pageDataDatasetsLoading, setPageDataDatasetsLoading] = useState(false);
const [pageDataDatasetsError, setPageDataDatasetsError] = useState<string | null>(null);
const [plazaCategories, setPlazaCategories] = useState<PlazaCategory[]>([]);
const [plazaCategoryId, setPlazaCategoryId] = useState('');
const [plazaTags, setPlazaTags] = useState('');
@@ -292,6 +322,67 @@ export function MindSpacePageDetail({
const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false);
const [statusConfirming, setStatusConfirming] = useState(false);
useEffect(() => {
if (!publishOpen) return;
let cancelled = false;
setPageDataDatasetsLoading(true);
setPageDataDatasetsError(null);
void listPageDataDatasets()
.then((datasets) => {
if (cancelled) return;
setPageDataDatasets(datasets);
setPageDataDataset((current) => current || datasets[0]?.name || '');
})
.catch((err) => {
if (cancelled) return;
setPageDataDatasetsError(err instanceof Error ? err.message : '加载 dataset 失败');
})
.finally(() => {
if (!cancelled) setPageDataDatasetsLoading(false);
});
return () => {
cancelled = true;
};
}, [publishOpen]);
useEffect(() => {
if (!publishOpen || !page?.id) return;
let cancelled = false;
void getPageDataPolicy(page.id)
.then((policy) => {
if (cancelled || !policy) return;
const datasetNames = Object.keys(policy.datasets ?? {});
if (datasetNames.length === 0) return;
const primaryDataset = datasetNames[0];
const datasetPolicy = policy.datasets[primaryDataset];
if (!datasetPolicy) return;
setPageDataEnabled(true);
setPageDataDataset(primaryDataset);
setPageDataRead(Boolean(datasetPolicy.read));
setPageDataInsert(Boolean(datasetPolicy.insert));
setPageDataUpdate(Boolean(datasetPolicy.update));
setPageDataSoftDelete(Boolean(datasetPolicy.softDelete));
})
.catch(() => {
// 无策略时保持默认关闭
});
return () => {
cancelled = true;
};
}, [publishOpen, page?.id]);
useEffect(() => {
if (!PAGE_DATA_PUBLISH_MODES.has(accessMode)) {
setPageDataEnabled(false);
return;
}
const defaults = defaultPageDataCapabilities(accessMode);
setPageDataRead(defaults.read);
setPageDataInsert(defaults.insert);
setPageDataUpdate(defaults.update);
setPageDataSoftDelete(defaults.softDelete);
}, [accessMode]);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
setPage(next);
@@ -427,6 +518,20 @@ export function MindSpacePageDetail({
password: accessPassword || undefined,
expiresAt: expiresAt ? new Date(expiresAt).getTime() : null,
});
if (pageDataEnabled && PAGE_DATA_PUBLISH_MODES.has(accessMode)) {
if (!pageDataDataset) {
throw new Error('已启用页面数据,请先选择 dataset');
}
await applyPageDataPublishPolicy(page.id, {
datasetName: pageDataDataset,
capabilities: {
read: pageDataRead,
insert: pageDataInsert,
update: pageDataUpdate,
softDelete: pageDataSoftDelete,
},
});
}
let nextPlazaPost: PlazaPostBrief | null = plazaPost;
if (pushToPlaza) {
nextPlazaPost = await publishToPlazaWithDefaults(publication.id);
@@ -604,6 +709,11 @@ export function MindSpacePageDetail({
const publishBlockedBySecurity = Boolean(
publishCheck && publishCheck.findings.some((finding) => finding.blocking),
);
const publishPageDataBlocked = Boolean(
pageDataEnabled &&
PAGE_DATA_PUBLISH_MODES.has(accessMode) &&
(pageDataDatasetsLoading || !pageDataDataset),
);
const publishWarningFindings =
publishCheck?.findings.filter((finding) => !finding.blocking) ?? [];
@@ -1006,6 +1116,16 @@ export function MindSpacePageDetail({
广
</button>
) : null}
<button
type="button"
onClick={() => {
setPageDataOpsOpen((open) => !open);
setPlazaOpen(false);
}}
disabled={publishing}
>
</button>
<button type="button" onClick={() => void offline()} disabled={publishing}>
线
</button>
@@ -1032,6 +1152,15 @@ export function MindSpacePageDetail({
</div>
) : null}
{pageDataOpsOpen && page.publication ? (
<MindSpacePageDataOpsPanel
pageId={page.id}
pageTitle={page.title}
accessMode={page.publication.accessMode}
onClose={() => setPageDataOpsOpen(false)}
/>
) : null}
{plazaOpen ? (
<section className="mindspace-publish-panel">
<div>
@@ -1130,6 +1259,103 @@ export function MindSpacePageDetail({
))}
</select>
</label>
{PAGE_DATA_PUBLISH_MODES.has(accessMode) ? (
<div className="mindspace-page-data-publish-panel">
<label className="mindspace-page-data-publish-toggle">
<input
type="checkbox"
checked={pageDataEnabled}
disabled={publishing}
onChange={(event) => setPageDataEnabled(event.target.checked)}
/>
Page Data API
</label>
{pageDataEnabled ? (
<>
<label>
dataset
<select
value={pageDataDataset}
disabled={publishing || pageDataDatasetsLoading}
onChange={(event) => setPageDataDataset(event.target.value)}
>
{pageDataDatasets.length === 0 ? (
<option value=""> dataset</option>
) : (
pageDataDatasets.map((dataset) => (
<option value={dataset.name} key={dataset.name}>
{dataset.name}
{dataset.description ? ` · ${dataset.description}` : ''}
</option>
))
)}
</select>
</label>
{pageDataDatasetsLoading ? (
<p className="mindspace-page-data-publish-hint"> dataset</p>
) : null}
{pageDataDatasetsError ? (
<p className="mindspace-page-data-ops-error">{pageDataDatasetsError}</p>
) : null}
{!pageDataDatasetsLoading && pageDataDatasets.length === 0 ? (
<p className="mindspace-page-data-publish-hint">
Agent dataset
</p>
) : null}
<div className="mindspace-page-data-publish-capabilities">
<span></span>
<label>
<input
type="checkbox"
checked={pageDataRead}
disabled={publishing}
onChange={(event) => setPageDataRead(event.target.checked)}
/>
</label>
<label>
<input
type="checkbox"
checked={pageDataInsert}
disabled={publishing}
onChange={(event) => setPageDataInsert(event.target.checked)}
/>
</label>
<label>
<input
type="checkbox"
checked={pageDataUpdate}
disabled={publishing}
onChange={(event) => setPageDataUpdate(event.target.checked)}
/>
</label>
<label>
<input
type="checkbox"
checked={pageDataSoftDelete}
disabled={publishing}
onChange={(event) => setPageDataSoftDelete(event.target.checked)}
/>
</label>
</div>
<p className="mindspace-page-data-publish-hint">
Page Data 访
</p>
</>
) : (
<p className="mindspace-page-data-publish-hint">
Agent dataset
</p>
)}
</div>
) : (
<p className="mindspace-page-data-publish-hint">
访
</p>
)}
{accessMode === 'password' && (
<label>
访
@@ -1276,7 +1502,7 @@ export function MindSpacePageDetail({
type="button"
className="mindspace-primary"
onClick={() => void publish()}
disabled={publishing || !publishCheck?.allowed}
disabled={publishing || !publishCheck?.allowed || publishPageDataBlocked}
>
{publishing ? (pushToPlaza ? '发布并推送…' : '发布中…') : '确认发布'}
</button>
+202 -1
View File
@@ -8255,12 +8255,213 @@ body,
}
.mindspace-publication-live,
.mindspace-publish-panel {
.mindspace-publish-panel,
.mindspace-page-data-ops-panel {
margin-bottom: 22px;
border: 1px solid rgba(24, 33, 29, 0.12);
border-radius: 22px;
}
.mindspace-page-data-ops-panel {
padding: 20px 22px;
background: #f8fbf9;
}
.mindspace-page-data-ops-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.mindspace-page-data-ops-header h3 {
margin: 4px 0;
}
.mindspace-page-data-ops-header small {
color: rgba(24, 33, 29, 0.62);
}
.mindspace-page-data-ops-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
margin-bottom: 18px;
}
.mindspace-page-data-ops-summary div {
padding: 12px 14px;
border-radius: 14px;
background: #fff;
border: 1px solid rgba(24, 33, 29, 0.08);
}
.mindspace-page-data-ops-summary span {
display: block;
font-size: 12px;
color: rgba(24, 33, 29, 0.58);
}
.mindspace-page-data-ops-summary strong {
display: block;
margin-top: 4px;
font-size: 20px;
}
.mindspace-page-data-ops-section + .mindspace-page-data-ops-section {
margin-top: 18px;
}
.mindspace-page-data-ops-section h4 {
margin: 0 0 10px;
font-size: 15px;
}
.mindspace-page-data-ops-table-wrap {
overflow-x: auto;
}
.mindspace-page-data-ops-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.mindspace-page-data-ops-table th,
.mindspace-page-data-ops-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(24, 33, 29, 0.08);
text-align: left;
}
.mindspace-page-data-ops-badge {
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
color: #7a2f2f;
background: rgba(194, 73, 73, 0.12);
}
.mindspace-page-data-ops-log-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.mindspace-page-data-ops-row-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.mindspace-page-data-ops-row-actions a {
font-size: 13px;
}
.mindspace-page-data-ops-log-list li {
display: grid;
grid-template-columns: 160px 90px 1fr auto;
gap: 10px;
align-items: center;
padding: 10px 12px;
border-radius: 12px;
background: #fff;
border: 1px solid rgba(24, 33, 29, 0.08);
font-size: 13px;
}
.mindspace-page-data-ops-log-list li button {
margin-left: auto;
padding: 4px 10px;
border: 1px solid rgba(24, 33, 29, 0.16);
border-radius: 999px;
background: #fff;
font-size: 12px;
cursor: pointer;
}
.mindspace-page-data-ops-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-top: 18px;
}
.mindspace-page-data-ops-password {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.mindspace-page-data-ops-password input {
min-width: 220px;
}
.mindspace-page-data-ops-muted,
.mindspace-page-data-ops-hint {
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-page-data-ops-error {
color: #9b2c2c;
}
.mindspace-page-data-ops-message {
color: #24553f;
}
.mindspace-page-data-publish-panel {
display: grid;
gap: 12px;
margin: 0 0 14px;
padding: 14px 16px;
border: 1px solid rgba(24, 33, 29, 0.1);
border-radius: 14px;
background: rgba(255, 253, 248, 0.88);
}
.mindspace-page-data-publish-toggle {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.mindspace-page-data-publish-capabilities {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.mindspace-page-data-publish-capabilities > span {
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-page-data-publish-capabilities label {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 13px;
}
.mindspace-page-data-publish-hint {
margin: 0;
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
line-height: 1.5;
}
.mindspace-publication-live {
display: flex;
align-items: center;
+80
View File
@@ -601,6 +601,86 @@ export type MindSpacePublicationStats = {
sources: Array<{ source: string; views: number }>;
};
export type PageDataPolicyDataset = {
name?: string;
read: boolean;
insert: boolean;
update: boolean;
softDelete: boolean;
hardDelete?: boolean;
closed?: boolean;
closedAt?: string | null;
columns?: Record<string, string[]>;
rowPolicy?: { scope: 'all_rows' | 'own_rows' | 'owner_only'; ownerColumn?: string };
};
export type PageDataAccessPolicy = {
pageId: string;
ownerUserId: string;
accessMode: 'public' | 'password' | 'login_required';
defaultVisitorRole?: 'deny' | 'viewer' | 'editor';
visitors?: Record<string, 'viewer' | 'editor'>;
datasets: Record<string, PageDataPolicyDataset>;
updatedAt?: string;
};
export type PageDataLogEntry = {
at: string;
pageId?: string;
dataset?: string | null;
action: string;
rowId?: number | null;
accessMode?: string;
actor?: {
type?: string;
userId?: string | null;
visitorUserId?: string | null;
role?: string | null;
sessionId?: string | null;
};
summary?: Record<string, unknown> | null;
};
export type PageDataOpsOverview = {
pageId: string;
publication: {
id: string;
pageId: string;
accessMode: string;
status: string;
};
policy: PageDataAccessPolicy;
datasets: Array<{
name: string;
read: boolean;
insert: boolean;
update: boolean;
softDelete: boolean;
closed: boolean;
closedAt: string | null;
stats: { dataset: { name: string; table: string }; total: number } | null;
}>;
recentLogs: PageDataLogEntry[];
recentSubmissions: PageDataLogEntry[];
activeSessionCount: number;
index?: {
pageId: string;
accessMode: string;
datasetCount: number;
scopeHash: string;
updatedAt: number;
} | null;
registeredDatasets?: string[];
unconfiguredDatasets?: string[];
};
export type PageDataDatasetSummary = {
name: string;
table: string;
description: string;
actions: string[];
};
export type PlazaCategory = {
id: string;
name: string;