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:
@@ -0,0 +1,113 @@
|
||||
function normalizeApiBase(apiBase) {
|
||||
const base = String(apiBase ?? '/api').trim() || '/api';
|
||||
return base.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export function buildPageDataAuthPath(apiBase, pageId) {
|
||||
const base = normalizeApiBase(apiBase);
|
||||
return `${base}/public/pages/${encodeURIComponent(String(pageId ?? '').trim())}/data-auth`;
|
||||
}
|
||||
|
||||
export function buildPageDataPublicPath(apiBase, pageId, dataset, suffix = '') {
|
||||
const base = normalizeApiBase(apiBase);
|
||||
const safePageId = encodeURIComponent(String(pageId ?? '').trim());
|
||||
const safeDataset = encodeURIComponent(String(dataset ?? '').trim());
|
||||
const tail = suffix ? (suffix.startsWith('/') ? suffix : `/${suffix}`) : '';
|
||||
return `${base}/public/pages/${safePageId}/data/${safeDataset}${tail}`;
|
||||
}
|
||||
|
||||
function buildAuthHeaders(token) {
|
||||
const headers = { accept: 'application/json' };
|
||||
if (token) headers['x-page-data-token'] = token;
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const error = payload?.error ?? {};
|
||||
throw Object.assign(new Error(error.message ?? '页面数据请求失败'), {
|
||||
code: error.code ?? 'page_data_failed',
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
return payload?.data ?? payload;
|
||||
}
|
||||
|
||||
export function createPageDataBrowserClient({
|
||||
pageId,
|
||||
apiBase = '/api',
|
||||
token = null,
|
||||
fetchImpl = globalThis.fetch,
|
||||
} = {}) {
|
||||
if (!pageId) throw new Error('pageId 不能为空');
|
||||
if (typeof fetchImpl !== 'function') throw new Error('fetch 不可用');
|
||||
|
||||
let sessionToken = token;
|
||||
|
||||
async function request(method, path, { body, query, auth = true, extraHeaders = {} } = {}) {
|
||||
const url = new URL(path, typeof window !== 'undefined' ? window.location.origin : 'http://localhost');
|
||||
if (query && typeof query === 'object') {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
const headers = {
|
||||
...(body ? { 'content-type': 'application/json' } : {}),
|
||||
...(auth ? buildAuthHeaders(sessionToken) : { accept: 'application/json' }),
|
||||
...(extraHeaders && typeof extraHeaders === 'object' ? extraHeaders : {}),
|
||||
};
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return parseJsonResponse(response);
|
||||
}
|
||||
|
||||
return {
|
||||
get pageId() {
|
||||
return pageId;
|
||||
},
|
||||
get token() {
|
||||
return sessionToken;
|
||||
},
|
||||
setToken(nextToken) {
|
||||
sessionToken = nextToken ? String(nextToken) : null;
|
||||
},
|
||||
async authenticate(password) {
|
||||
const result = await request('POST', buildPageDataAuthPath(apiBase, pageId), {
|
||||
body: { password },
|
||||
auth: false,
|
||||
});
|
||||
if (result?.token) sessionToken = result.token;
|
||||
return result;
|
||||
},
|
||||
async listRows(dataset, query = {}) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset), { query });
|
||||
},
|
||||
async getSchema(dataset) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'schema'));
|
||||
},
|
||||
async getStats(dataset) {
|
||||
return request('GET', buildPageDataPublicPath(apiBase, pageId, dataset, 'stats'));
|
||||
},
|
||||
async insertRow(dataset, row, options = {}) {
|
||||
const captchaToken =
|
||||
options.captchaToken ?? options.turnstileToken ?? options.captcha_token ?? null;
|
||||
const extraHeaders = captchaToken ? { 'x-page-data-captcha': String(captchaToken) } : {};
|
||||
return request('POST', buildPageDataPublicPath(apiBase, pageId, dataset, 'rows'), {
|
||||
body: row,
|
||||
extraHeaders,
|
||||
});
|
||||
},
|
||||
async updateRow(dataset, rowId, row) {
|
||||
return request('PATCH', buildPageDataPublicPath(apiBase, pageId, dataset, `rows/${rowId}`), { body: row });
|
||||
},
|
||||
async deleteRow(dataset, rowId) {
|
||||
return request('DELETE', buildPageDataPublicPath(apiBase, pageId, dataset, `rows/${rowId}`));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user