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>
This commit is contained in:
@@ -23,11 +23,10 @@
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
GET /api/page-data # 列出已注册 dataset
|
GET /api/page-data # 列出已注册 dataset
|
||||||
GET /api/page-data/:dataset # 读行
|
GET /api/page-data/:dataset
|
||||||
POST /api/page-data/:dataset/rows # 插入
|
POST /api/page-data/:dataset/rows
|
||||||
GET /api/page-data/:dataset/schema
|
PATCH /api/page-data/:dataset/rows/:id
|
||||||
GET /api/page-data/:dataset/stats
|
DELETE /api/page-data/:dataset/rows/:id # soft delete
|
||||||
GET /api/page-data/:dataset/export?format=json|csv
|
|
||||||
POST /api/page-data/:dataset/rows/:id/restore
|
POST /api/page-data/:dataset/rows/:id/restore
|
||||||
|
|
||||||
GET /api/page-data/policies
|
GET /api/page-data/policies
|
||||||
@@ -53,7 +52,7 @@ GET /api/public/pages/:pageId/data/:dataset/schema
|
|||||||
GET /api/public/pages/:pageId/data/:dataset/stats
|
GET /api/public/pages/:pageId/data/:dataset/stats
|
||||||
```
|
```
|
||||||
|
|
||||||
公开请求可带 `x-page-data-token`(口令/登录会话)。**公开页不能直接传 SQL**,只能提交策略白名单字段。
|
公开请求可带 `x-page-data-token`(口令/登录会话)。**完全公开**模式下:默认仅 insert;若策略显式开启 `read`,匿名访问者可读白名单字段并查看 stats。
|
||||||
|
|
||||||
## HTML 页面嵌入
|
## HTML 页面嵌入
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,34 @@ test('acceptance: public insert only when authorized; read/update/delete denied
|
|||||||
assert.equal(deleteDenied.status, 403);
|
assert.equal(deleteDenied.status, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('acceptance: public read and stats when policy enables read', async () => {
|
||||||
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-public-read-'));
|
||||||
|
const ownerService = await setupWorkspace(workspaceRoot);
|
||||||
|
await ownerService.insertDatasetRow('leads', { name: '展示项', note: 'secret' });
|
||||||
|
writePageAccessPolicy(workspaceRoot, {
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
ownerUserId: OWNER_ID,
|
||||||
|
accessMode: 'public',
|
||||||
|
datasets: {
|
||||||
|
leads: {
|
||||||
|
read: true,
|
||||||
|
columns: { read: ['id', 'name', 'created_at'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const app = buildApp(workspaceRoot);
|
||||||
|
|
||||||
|
const listed = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads?limit=10`);
|
||||||
|
assert.equal(listed.status, 200);
|
||||||
|
assert.equal(listed.body.data.rows.length, 1);
|
||||||
|
assert.equal(listed.body.data.rows[0].name, '展示项');
|
||||||
|
assert.equal(listed.body.data.rows[0].note, undefined);
|
||||||
|
|
||||||
|
const stats = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/leads/stats`);
|
||||||
|
assert.equal(stats.status, 200);
|
||||||
|
assert.equal(stats.body.data.total, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => {
|
test('acceptance: unauthorized dataset/action/column and SQL-like keys are rejected', async () => {
|
||||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-'));
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-acc-deny-'));
|
||||||
await setupWorkspace(workspaceRoot);
|
await setupWorkspace(workspaceRoot);
|
||||||
|
|||||||
@@ -344,6 +344,14 @@ test('integration: apply-publish route binds dataset after publication', async (
|
|||||||
assert.equal(applied.status, 200);
|
assert.equal(applied.status, 200);
|
||||||
assert.equal(applied.body.data.policy.datasets.signups.read, true);
|
assert.equal(applied.body.data.policy.datasets.signups.read, true);
|
||||||
|
|
||||||
|
const publicReadEmpty = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
|
||||||
|
assert.equal(publicReadEmpty.status, 200);
|
||||||
|
assert.equal(publicReadEmpty.body.data.rows.length, 0);
|
||||||
|
|
||||||
|
await request(app, 'POST', `/api/public/pages/${PAGE_ID}/data/signups/rows`, {
|
||||||
|
body: { name: '发布后公开读', phone: '13800000000' },
|
||||||
|
});
|
||||||
const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
|
const publicRead = await request(app, 'GET', `/api/public/pages/${PAGE_ID}/data/signups`);
|
||||||
assert.equal(publicRead.status, 403);
|
assert.equal(publicRead.status, 200);
|
||||||
|
assert.equal(publicRead.body.data.rows.length, 1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -209,9 +209,15 @@ export function createPageDataPublicService(deps = {}) {
|
|||||||
function resolveAccessContext({ publication, policy, req, action, datasetName }) {
|
function resolveAccessContext({ publication, policy, req, action, datasetName }) {
|
||||||
const accessMode = publication.access_mode;
|
const accessMode = publication.access_mode;
|
||||||
if (accessMode === 'public') {
|
if (accessMode === 'public') {
|
||||||
if (action === 'read' || action === 'update' || action === 'soft_delete') {
|
if (action === 'update' || action === 'soft_delete') {
|
||||||
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
||||||
}
|
}
|
||||||
|
if (action === 'read') {
|
||||||
|
if (!policyAllowsAction(policy, datasetName, 'read')) {
|
||||||
|
throw Object.assign(new Error('当前页面未开放此数据操作'), { code: 'action_not_allowed' });
|
||||||
|
}
|
||||||
|
return { accessMode, session: null };
|
||||||
|
}
|
||||||
if (!policyAllowsAction(policy, datasetName, action)) {
|
if (!policyAllowsAction(policy, datasetName, action)) {
|
||||||
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
|
throw Object.assign(new Error(`dataset 未授权 ${action}`), { code: 'action_not_allowed' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ test('public page read is denied in public access mode by default', async () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('public page read works when policy explicitly enables read', async () => {
|
||||||
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-public-read-ok-'));
|
||||||
|
const ownerService = await setupPublicWorkspace(workspaceRoot, { accessMode: 'public', withRead: true });
|
||||||
|
await ownerService.insertRowForDataset(ownerService.getDataset('signups'), {
|
||||||
|
name: '公开可读',
|
||||||
|
phone: '13900000002',
|
||||||
|
});
|
||||||
|
const service = createPublicService(workspaceRoot, { accessMode: 'public' });
|
||||||
|
const rows = await service.listRows(PAGE_ID, 'signups', { headers: {} });
|
||||||
|
assert.equal(rows.rows.length, 1);
|
||||||
|
assert.equal(rows.rows[0].name, '公开可读');
|
||||||
|
});
|
||||||
|
|
||||||
test('login_required page read requires login and works for authenticated visitor', async () => {
|
test('login_required page read requires login and works for authenticated visitor', async () => {
|
||||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-'));
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-login-required-'));
|
||||||
const ownerService = await setupPublicWorkspace(workspaceRoot, {
|
const ownerService = await setupPublicWorkspace(workspaceRoot, {
|
||||||
|
|||||||
@@ -286,6 +286,36 @@ export function attachPageDataRoutes(api, deps) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
api.patch('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||||
|
const user = requireUser(req, res, sendError);
|
||||||
|
if (!user) return;
|
||||||
|
const service = getPageDataService?.();
|
||||||
|
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||||
|
const payload = parseRowPayload(req, { stripMeta: true });
|
||||||
|
if (!payload) return sendError(res, req, 400, 'invalid_request', '更新数据必须是 JSON 对象');
|
||||||
|
try {
|
||||||
|
const result = await service.updateRow(user, req.params.dataset, req.params.rowId, payload);
|
||||||
|
return sendData(res, req, { dataset: result.dataset.name, row: result.row });
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.status ?? 400;
|
||||||
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '更新失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
api.delete('/page-data/:dataset/rows/:rowId', async (req, res) => {
|
||||||
|
const user = requireUser(req, res, sendError);
|
||||||
|
if (!user) return;
|
||||||
|
const service = getPageDataService?.();
|
||||||
|
if (!service) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||||
|
try {
|
||||||
|
const result = await service.softDeleteRow(user, req.params.dataset, req.params.rowId);
|
||||||
|
return sendData(res, req, { dataset: result.dataset.name, row: result.row, deleted: true });
|
||||||
|
} catch (error) {
|
||||||
|
const status = error?.status ?? 400;
|
||||||
|
return sendError(res, req, status, error?.code ?? 'page_data_failed', error?.message ?? '删除失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
api.post('/public/pages/:pageId/data-auth', async (req, res) => {
|
api.post('/public/pages/:pageId/data-auth', async (req, res) => {
|
||||||
const publicService = getPageDataPublicService?.();
|
const publicService = getPageDataPublicService?.();
|
||||||
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
if (!publicService) return sendError(res, req, 503, 'feature_disabled', 'Page Data API 未启用');
|
||||||
|
|||||||
@@ -101,6 +101,54 @@ test('page data routes allow logged-in owner to read and insert dataset rows', a
|
|||||||
assert.equal(stats.body.data.total, 1);
|
assert.equal(stats.body.data.total, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('page data routes allow owner update and soft delete', async () => {
|
||||||
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-crud-'));
|
||||||
|
const service = createUserDataSpaceService({ workspaceRoot });
|
||||||
|
await service.executeSql(`CREATE TABLE tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT DEFAULT 'open',
|
||||||
|
created_at TEXT,
|
||||||
|
deleted_at TEXT,
|
||||||
|
deleted_by TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
);`);
|
||||||
|
await service.upsertDataset({
|
||||||
|
name: 'tasks',
|
||||||
|
table: 'tasks',
|
||||||
|
actions: ['read', 'insert', 'update', 'soft_delete'],
|
||||||
|
columns: {
|
||||||
|
read: ['id', 'title', 'status', 'created_at'],
|
||||||
|
insert: ['title'],
|
||||||
|
update: ['status'],
|
||||||
|
soft_delete: ['id'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = createApiApp({
|
||||||
|
workspaceRoot,
|
||||||
|
user: { id: 'user-1', workspaceRoot },
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = await requestJson(app, 'POST', '/api/page-data/tasks/rows', { title: '待办' });
|
||||||
|
assert.equal(inserted.status, 201);
|
||||||
|
const rowId = inserted.body.data.row.id;
|
||||||
|
|
||||||
|
const updated = await requestJson(app, 'PATCH', `/api/page-data/tasks/rows/${rowId}`, {
|
||||||
|
status: 'done',
|
||||||
|
});
|
||||||
|
assert.equal(updated.status, 200);
|
||||||
|
assert.equal(updated.body.data.row.status, 'done');
|
||||||
|
|
||||||
|
const deleted = await requestJson(app, 'DELETE', `/api/page-data/tasks/rows/${rowId}`);
|
||||||
|
assert.equal(deleted.status, 200);
|
||||||
|
assert.equal(deleted.body.data.deleted, true);
|
||||||
|
|
||||||
|
const listed = await requestJson(app, 'GET', '/api/page-data/tasks?limit=10');
|
||||||
|
assert.equal(listed.status, 200);
|
||||||
|
assert.equal(listed.body.data.rows.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
test('page data routes reject unauthorized dataset action', async () => {
|
test('page data routes reject unauthorized dataset action', async () => {
|
||||||
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-'));
|
const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'page-data-api-deny-'));
|
||||||
const service = createUserDataSpaceService({ workspaceRoot });
|
const service = createUserDataSpaceService({ workspaceRoot });
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ export function createPageDataService(deps = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateRow(user, datasetName, rowId, payload) {
|
||||||
|
const { service } = await createServiceForUser(user);
|
||||||
|
try {
|
||||||
|
return service.updateDatasetRow(datasetName, rowId, payload);
|
||||||
|
} catch (error) {
|
||||||
|
throw mapServiceError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function softDeleteRow(user, datasetName, rowId) {
|
||||||
|
const { service } = await createServiceForUser(user);
|
||||||
|
try {
|
||||||
|
return service.softDeleteDatasetRow(datasetName, rowId, { deletedBy: user.id });
|
||||||
|
} catch (error) {
|
||||||
|
throw mapServiceError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreRow(user, datasetName, rowId) {
|
async function restoreRow(user, datasetName, rowId) {
|
||||||
const { service } = await createServiceForUser(user);
|
const { service } = await createServiceForUser(user);
|
||||||
try {
|
try {
|
||||||
@@ -164,6 +182,8 @@ export function createPageDataService(deps = {}) {
|
|||||||
getSchema,
|
getSchema,
|
||||||
getStats,
|
getStats,
|
||||||
insertRow,
|
insertRow,
|
||||||
|
updateRow,
|
||||||
|
softDeleteRow,
|
||||||
restoreRow,
|
restoreRow,
|
||||||
exportDataset,
|
exportDataset,
|
||||||
listDatasets,
|
listDatasets,
|
||||||
|
|||||||
@@ -6,9 +6,30 @@ import {
|
|||||||
resetPageDataPassword,
|
resetPageDataPassword,
|
||||||
restorePageDataRow,
|
restorePageDataRow,
|
||||||
revokePageDataTokens,
|
revokePageDataTokens,
|
||||||
|
savePageDataPolicy,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import type { MindSpacePublishCheck, PageDataOpsOverview } from '../types';
|
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 = {
|
type Props = {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
pageTitle: string;
|
pageTitle: string;
|
||||||
@@ -29,6 +50,24 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
|||||||
const [busyAction, setBusyAction] = useState<string | null>(null);
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
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 () => {
|
const loadOverview = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -36,6 +75,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
|||||||
try {
|
try {
|
||||||
const next = await getPageDataOpsOverview(pageId);
|
const next = await getPageDataOpsOverview(pageId);
|
||||||
setOverview(next);
|
setOverview(next);
|
||||||
|
syncRoleForm(next.policy);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
|
const text = err instanceof Error ? err.message : '加载页面数据运维信息失败';
|
||||||
if (text.includes('policy_not_found') || text.includes('策略未配置')) {
|
if (text.includes('policy_not_found') || text.includes('策略未配置')) {
|
||||||
@@ -47,7 +87,7 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [pageId]);
|
}, [pageId, syncRoleForm]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadOverview();
|
void loadOverview();
|
||||||
@@ -129,6 +169,113 @@ export function MindSpacePageDataOpsPanel({ pageId, pageTitle, accessMode, onClo
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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">
|
<div className="mindspace-page-data-ops-section">
|
||||||
<h4>Dataset 状态</h4>
|
<h4>Dataset 状态</h4>
|
||||||
{overview.datasets.length === 0 ? (
|
{overview.datasets.length === 0 ? (
|
||||||
|
|||||||
@@ -8410,6 +8410,40 @@ body,
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-editor {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-editor label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-editor textarea {
|
||||||
|
min-height: 88px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-datasets {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-datasets > span {
|
||||||
|
color: rgba(24, 33, 29, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-data-ops-role-dataset-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(80px, 120px) 140px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.mindspace-page-data-ops-error {
|
.mindspace-page-data-ops-error {
|
||||||
color: #9b2c2c;
|
color: #9b2c2c;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,6 +563,22 @@ export function createUserDataSpaceService(options = {}) {
|
|||||||
return { dataset, row };
|
return { dataset, row };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateDatasetRow(datasetName, rowId, payload, meta = {}) {
|
||||||
|
const dataset = getDataset(datasetName);
|
||||||
|
if (!dataset) {
|
||||||
|
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
|
||||||
|
}
|
||||||
|
return updateRowForDataset(dataset, rowId, payload, meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function softDeleteDatasetRow(datasetName, rowId, meta = {}) {
|
||||||
|
const dataset = getDataset(datasetName);
|
||||||
|
if (!dataset) {
|
||||||
|
throw Object.assign(new Error('dataset 不存在'), { code: 'dataset_not_found' });
|
||||||
|
}
|
||||||
|
return softDeleteRowForDataset(dataset, rowId, meta);
|
||||||
|
}
|
||||||
|
|
||||||
async function insertDatasetRow(datasetName, payload, meta = {}) {
|
async function insertDatasetRow(datasetName, payload, meta = {}) {
|
||||||
const dataset = getDataset(datasetName);
|
const dataset = getDataset(datasetName);
|
||||||
if (!dataset) {
|
if (!dataset) {
|
||||||
@@ -751,7 +767,9 @@ export function createUserDataSpaceService(options = {}) {
|
|||||||
getStatsForDataset,
|
getStatsForDataset,
|
||||||
insertDatasetRow,
|
insertDatasetRow,
|
||||||
insertRowForDataset,
|
insertRowForDataset,
|
||||||
|
updateDatasetRow,
|
||||||
updateRowForDataset,
|
updateRowForDataset,
|
||||||
|
softDeleteDatasetRow,
|
||||||
softDeleteRowForDataset,
|
softDeleteRowForDataset,
|
||||||
restoreSoftDeletedRowForDataset,
|
restoreSoftDeletedRowForDataset,
|
||||||
restoreSoftDeletedRow,
|
restoreSoftDeletedRow,
|
||||||
|
|||||||
Reference in New Issue
Block a user