Files
memind/page-data-browser-client.mjs
T
john 6b0c633a75 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>
2026-07-08 14:52:49 +08:00

114 lines
4.0 KiB
JavaScript

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