refactor: establish frontend request and state boundaries
This commit is contained in:
@@ -254,8 +254,8 @@
|
||||
"code_hotspots": [
|
||||
{
|
||||
"path": "static/app.js",
|
||||
"bytes": 449457,
|
||||
"lines": 9447
|
||||
"bytes": 447568,
|
||||
"lines": 9403
|
||||
},
|
||||
{
|
||||
"path": "static/styles.css",
|
||||
@@ -274,8 +274,8 @@
|
||||
},
|
||||
{
|
||||
"path": "static/index.html",
|
||||
"bytes": 133569,
|
||||
"lines": 1871
|
||||
"bytes": 133691,
|
||||
"lines": 1873
|
||||
},
|
||||
{
|
||||
"path": "database.py",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Stage 14: Frontend Request and State Boundaries
|
||||
|
||||
## Request Boundary
|
||||
|
||||
`static/shared/api.js` is now the only application file allowed to call `fetch`. It owns:
|
||||
|
||||
- JSON serialization and response parsing;
|
||||
- CSRF attachment for mutating requests;
|
||||
- expired-session notification;
|
||||
- abort signals;
|
||||
- NDJSON stream decoding and normalized stream errors.
|
||||
|
||||
The mentor and review-assistant streams use the same client as ordinary API requests. Existing
|
||||
function signatures and page interactions remain unchanged.
|
||||
|
||||
## State Boundary
|
||||
|
||||
`static/shared/state.js` stores mutable state in explicit domains: session, market, entity
|
||||
details, review, screener, mentor, and heaven. A compatibility proxy retains the existing flat
|
||||
access syntax while rejecting unregistered fields. New page modules can request their owned
|
||||
domain without depending on another page's data.
|
||||
|
||||
This stage establishes a migration boundary rather than splitting the build-free monolith in a
|
||||
single high-risk edit. Page extraction can now proceed domain by domain with no contract change.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Automated checks require that:
|
||||
|
||||
- only `shared/api.js` contains browser `fetch` calls;
|
||||
- shared state and API scripts load before `app.js`;
|
||||
- application state is created through the shared state boundary.
|
||||
|
||||
## Residual Risk
|
||||
|
||||
`static/app.js` still contains page renderers and event handlers in one file. The state domains
|
||||
make ownership explicit, but those functions should move into page modules only in later,
|
||||
independently verified stages.
|
||||
+193
-237
@@ -21,166 +21,185 @@ const THEME_STORAGE_KEY = "xiaobaiTheme";
|
||||
let activeThemeTransition = null;
|
||||
let themeSwitchSequence = 0;
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
csrfToken: "",
|
||||
authMode: "login",
|
||||
started: false,
|
||||
dashboard: null,
|
||||
filter: "all",
|
||||
query: "",
|
||||
sortKey: "streak",
|
||||
sortDirection: "desc",
|
||||
brokenQuery: "",
|
||||
brokenSortKey: "",
|
||||
brokenSortDirection: "desc",
|
||||
downQuery: "",
|
||||
downSortKey: "",
|
||||
downSortDirection: "asc",
|
||||
yesterdayFilter: "all",
|
||||
yesterdayQuery: "",
|
||||
yesterdaySortKey: "",
|
||||
yesterdaySortDirection: "desc",
|
||||
activeView: "sentimentCycleView",
|
||||
dragonTiger: null,
|
||||
dragonViewMode: "daily",
|
||||
dragonFilter: "all",
|
||||
dragonQuery: "",
|
||||
selectedDragonTraderId: "",
|
||||
hotMoneyProfiles: null,
|
||||
hotMoneyProfileQuery: "",
|
||||
selectedHotMoneyProfileId: "",
|
||||
rotationHistory: null,
|
||||
rotationHistoryKey: "",
|
||||
rotationSelectedSector: "",
|
||||
rotationSelectedDate: "",
|
||||
rotationMembers: null,
|
||||
rotationMembersKey: "",
|
||||
rotationMembersLoading: false,
|
||||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||||
rotationLoading: false,
|
||||
auctionData: null,
|
||||
auctionDataset: "focus",
|
||||
auctionFilter: "all",
|
||||
auctionQuery: "",
|
||||
auctionSortKey: "attention_score",
|
||||
auctionSortDirection: "desc",
|
||||
auctionLoading: false,
|
||||
auctionTimer: null,
|
||||
themeLibrary: null,
|
||||
themeQuery: "",
|
||||
selectedThemeCode: "",
|
||||
themeDetail: null,
|
||||
themeLoading: false,
|
||||
popularityData: null,
|
||||
popularitySource: "combined",
|
||||
popularityQuery: "",
|
||||
popularityLoading: false,
|
||||
expandedLadderLevels: new Set(),
|
||||
ladderSortMode: "time",
|
||||
stockDetail: null,
|
||||
activeStock: null,
|
||||
stockDetailChartMode: "daily",
|
||||
stockDetailIntraday: null,
|
||||
stockDetailRequestSequence: 0,
|
||||
entityDetailItem: null,
|
||||
entityDetailPayload: null,
|
||||
entityDetailChartMode: "daily",
|
||||
entityDetailIntraday: null,
|
||||
entityDetailRequestSequence: 0,
|
||||
stockPreviewCode: "",
|
||||
stockPreviewType: "stock",
|
||||
stockPreviewItem: null,
|
||||
stockPreviewPayload: null,
|
||||
stockPreviewChart: "daily",
|
||||
stockPreviewFallback: null,
|
||||
watchlist: [],
|
||||
watchlistSelection: null,
|
||||
watchlistSearchResults: [],
|
||||
watchlistSearchRequestSequence: 0,
|
||||
editingDailyNoteId: 0,
|
||||
notes: [],
|
||||
tradeEntries: [],
|
||||
tradeSummary: {},
|
||||
editingTradeId: 0,
|
||||
initialStockOpened: false,
|
||||
screenerSetup: null,
|
||||
screenerSetupKey: "",
|
||||
screenerSetupRequestKey: "",
|
||||
screenerSetupPromise: null,
|
||||
selectedRegime: "",
|
||||
selectedStrategy: null,
|
||||
customStrategyDraft: null,
|
||||
screenerRunning: false,
|
||||
screenerRunningMode: "",
|
||||
screenerResults: { smart: null, curated: null, quant: null },
|
||||
screenerResultContexts: { smart: null, curated: null, quant: null },
|
||||
screenerResultStore: {},
|
||||
screenerTracking: null,
|
||||
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
||||
? localStorage.getItem("xiaobaiScreenerMode")
|
||||
: "smart",
|
||||
curatedCategory: "全部",
|
||||
curatedSchool: "全部",
|
||||
curatedQuery: "",
|
||||
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||||
selectedCuratedStrategyId: 0,
|
||||
quantFilters: [],
|
||||
quantScores: [],
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
assistantMessages: [],
|
||||
assistantLoading: false,
|
||||
assistantController: null,
|
||||
screenerMobileView: "strategy",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
sentimentHistoryKey: "",
|
||||
sentimentLoading: false,
|
||||
mentorSetup: null,
|
||||
selectedMentorId: "",
|
||||
mentorMessages: [],
|
||||
mentorLoading: false,
|
||||
mentorQuery: "",
|
||||
mentorGrade: "all",
|
||||
mentorDirectoryOpen: false,
|
||||
mentorSortMode: false,
|
||||
mentorSavingPreferences: false,
|
||||
mentorController: null,
|
||||
heavenSetup: null,
|
||||
heavenManualData: null,
|
||||
personalField: null,
|
||||
heavenPanel: "trend",
|
||||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||||
heavenReadingMode: "trend",
|
||||
heavenReadingTab: "current",
|
||||
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||||
heavenReadingSelectedId: 0,
|
||||
heavenReadingLoading: false,
|
||||
heavenReadingError: "",
|
||||
heartStage: "intro",
|
||||
heartTimer: null,
|
||||
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
|
||||
heartBreathingEndsAt: 0,
|
||||
heartLines: [],
|
||||
heartThrows: [],
|
||||
heartHexagram: null,
|
||||
heartCurtainTimer: null,
|
||||
heartStageToken: 0,
|
||||
heartRevealToken: 0,
|
||||
heavenPerformanceKey: "",
|
||||
heavenPerformancePanels: new Set(),
|
||||
heavenPerformanceActive: "",
|
||||
dashboardLoading: false,
|
||||
dashboardRequestSequence: 0,
|
||||
heavenRequestSequence: 0,
|
||||
dashboardRequestDate: "",
|
||||
adminModels: [],
|
||||
globalSearchResults: [],
|
||||
globalSearchActiveIndex: -1,
|
||||
globalSearchRequestSequence: 0,
|
||||
};
|
||||
const state = window.XiaobaiState.create({
|
||||
session: {
|
||||
user: null,
|
||||
csrfToken: "",
|
||||
authMode: "login",
|
||||
started: false,
|
||||
activeView: "sentimentCycleView",
|
||||
dashboardLoading: false,
|
||||
dashboardRequestSequence: 0,
|
||||
dashboardRequestDate: "",
|
||||
adminModels: [],
|
||||
globalSearchResults: [],
|
||||
globalSearchActiveIndex: -1,
|
||||
globalSearchRequestSequence: 0,
|
||||
},
|
||||
market: {
|
||||
dashboard: null,
|
||||
filter: "all",
|
||||
query: "",
|
||||
sortKey: "streak",
|
||||
sortDirection: "desc",
|
||||
brokenQuery: "",
|
||||
brokenSortKey: "",
|
||||
brokenSortDirection: "desc",
|
||||
downQuery: "",
|
||||
downSortKey: "",
|
||||
downSortDirection: "asc",
|
||||
yesterdayFilter: "all",
|
||||
yesterdayQuery: "",
|
||||
yesterdaySortKey: "",
|
||||
yesterdaySortDirection: "desc",
|
||||
dragonTiger: null,
|
||||
dragonViewMode: "daily",
|
||||
dragonFilter: "all",
|
||||
dragonQuery: "",
|
||||
selectedDragonTraderId: "",
|
||||
hotMoneyProfiles: null,
|
||||
hotMoneyProfileQuery: "",
|
||||
selectedHotMoneyProfileId: "",
|
||||
rotationHistory: null,
|
||||
rotationHistoryKey: "",
|
||||
rotationSelectedSector: "",
|
||||
rotationSelectedDate: "",
|
||||
rotationMembers: null,
|
||||
rotationMembersKey: "",
|
||||
rotationMembersLoading: false,
|
||||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||||
rotationLoading: false,
|
||||
auctionData: null,
|
||||
auctionDataset: "focus",
|
||||
auctionFilter: "all",
|
||||
auctionQuery: "",
|
||||
auctionSortKey: "attention_score",
|
||||
auctionSortDirection: "desc",
|
||||
auctionLoading: false,
|
||||
auctionTimer: null,
|
||||
themeLibrary: null,
|
||||
themeQuery: "",
|
||||
selectedThemeCode: "",
|
||||
themeDetail: null,
|
||||
themeLoading: false,
|
||||
popularityData: null,
|
||||
popularitySource: "combined",
|
||||
popularityQuery: "",
|
||||
popularityLoading: false,
|
||||
expandedLadderLevels: new Set(),
|
||||
ladderSortMode: "time",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
sentimentHistoryKey: "",
|
||||
sentimentLoading: false,
|
||||
},
|
||||
details: {
|
||||
stockDetail: null,
|
||||
activeStock: null,
|
||||
stockDetailChartMode: "daily",
|
||||
stockDetailIntraday: null,
|
||||
stockDetailRequestSequence: 0,
|
||||
entityDetailItem: null,
|
||||
entityDetailPayload: null,
|
||||
entityDetailChartMode: "daily",
|
||||
entityDetailIntraday: null,
|
||||
entityDetailRequestSequence: 0,
|
||||
stockPreviewCode: "",
|
||||
stockPreviewType: "stock",
|
||||
stockPreviewItem: null,
|
||||
stockPreviewPayload: null,
|
||||
stockPreviewChart: "daily",
|
||||
stockPreviewFallback: null,
|
||||
initialStockOpened: false,
|
||||
},
|
||||
review: {
|
||||
watchlist: [],
|
||||
watchlistSelection: null,
|
||||
watchlistSearchResults: [],
|
||||
watchlistSearchRequestSequence: 0,
|
||||
editingDailyNoteId: 0,
|
||||
notes: [],
|
||||
tradeEntries: [],
|
||||
tradeSummary: {},
|
||||
editingTradeId: 0,
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
assistantMessages: [],
|
||||
assistantLoading: false,
|
||||
assistantController: null,
|
||||
},
|
||||
screener: {
|
||||
screenerSetup: null,
|
||||
screenerSetupKey: "",
|
||||
screenerSetupRequestKey: "",
|
||||
screenerSetupPromise: null,
|
||||
selectedRegime: "",
|
||||
selectedStrategy: null,
|
||||
customStrategyDraft: null,
|
||||
screenerRunning: false,
|
||||
screenerRunningMode: "",
|
||||
screenerResults: { smart: null, curated: null, quant: null },
|
||||
screenerResultContexts: { smart: null, curated: null, quant: null },
|
||||
screenerResultStore: {},
|
||||
screenerTracking: null,
|
||||
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
||||
? localStorage.getItem("xiaobaiScreenerMode")
|
||||
: "smart",
|
||||
curatedCategory: "全部",
|
||||
curatedSchool: "全部",
|
||||
curatedQuery: "",
|
||||
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||||
selectedCuratedStrategyId: 0,
|
||||
quantFilters: [],
|
||||
quantScores: [],
|
||||
screenerMobileView: "strategy",
|
||||
},
|
||||
mentor: {
|
||||
mentorSetup: null,
|
||||
selectedMentorId: "",
|
||||
mentorMessages: [],
|
||||
mentorLoading: false,
|
||||
mentorQuery: "",
|
||||
mentorGrade: "all",
|
||||
mentorDirectoryOpen: false,
|
||||
mentorSortMode: false,
|
||||
mentorSavingPreferences: false,
|
||||
mentorController: null,
|
||||
},
|
||||
heaven: {
|
||||
heavenSetup: null,
|
||||
heavenManualData: null,
|
||||
personalField: null,
|
||||
heavenPanel: "trend",
|
||||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||||
heavenReadingMode: "trend",
|
||||
heavenReadingTab: "current",
|
||||
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||||
heavenReadingSelectedId: 0,
|
||||
heavenReadingLoading: false,
|
||||
heavenReadingError: "",
|
||||
heartStage: "intro",
|
||||
heartTimer: null,
|
||||
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
|
||||
heartBreathingEndsAt: 0,
|
||||
heartLines: [],
|
||||
heartThrows: [],
|
||||
heartHexagram: null,
|
||||
heartCurtainTimer: null,
|
||||
heartStageToken: 0,
|
||||
heartRevealToken: 0,
|
||||
heavenPerformanceKey: "",
|
||||
heavenPerformancePanels: new Set(),
|
||||
heavenPerformanceActive: "",
|
||||
heavenRequestSequence: 0,
|
||||
},
|
||||
});
|
||||
|
||||
window.XiaobaiAPI.configure({
|
||||
csrfToken: () => state.csrfToken,
|
||||
onUnauthorized: () => showAuthGate("登录状态已失效,请重新登录。"),
|
||||
});
|
||||
|
||||
const elements = {
|
||||
tradeDate: document.querySelector("#tradeDate"),
|
||||
@@ -4698,39 +4717,16 @@ function scheduleMentorRender() {
|
||||
}
|
||||
|
||||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||||
const response = await fetch("/api/mentors/chat", {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body,
|
||||
signal,
|
||||
errorMessage: "问师暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "meta") onMeta(event);
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "问师暂不可用");
|
||||
}
|
||||
if (!response.body) throw new Error("当前浏览器不支持流式回答");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const consume = (line) => {
|
||||
if (!line.trim()) return;
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "meta") onMeta(event);
|
||||
if (event.type === "error") throw new Error(event.error || "问师回答失败");
|
||||
};
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
lines.forEach(consume);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) consume(buffer);
|
||||
}
|
||||
|
||||
function useMentorQuickPrompt(prompt) {
|
||||
@@ -7907,41 +7903,15 @@ async function sendAssistantQuestion(event) {
|
||||
}
|
||||
|
||||
async function streamAssistantRequest(question, signal, onDelta) {
|
||||
const response = await fetch("/api/assistant/chat", {
|
||||
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
|
||||
body: { question, trade_date: elements.tradeDate.value },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "复盘助手暂不可用");
|
||||
}
|
||||
if (!response.body) throw new Error("当前浏览器不支持流式回答");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const event = JSON.parse(line);
|
||||
errorMessage: "复盘助手暂不可用",
|
||||
onEvent: (event) => {
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const event = JSON.parse(buffer);
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function stopAssistantResponse() {
|
||||
@@ -9167,21 +9137,7 @@ function formatMoneyMillion(value) {
|
||||
}
|
||||
|
||||
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
||||
const options = { method, headers: {}, signal: requestOptions.signal };
|
||||
if (method !== "GET" && state.csrfToken) {
|
||||
options.headers["X-CSRF-Token"] = state.csrfToken;
|
||||
}
|
||||
if (body !== null) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json();
|
||||
if (response.status === 401 && !url.startsWith("/api/auth/")) {
|
||||
showAuthGate("登录状态已失效,请重新登录。");
|
||||
}
|
||||
if (!response.ok || payload.error) throw new Error(payload.error || "请求失败");
|
||||
return payload;
|
||||
return window.XiaobaiAPI.request(url, method, body, requestOptions);
|
||||
}
|
||||
|
||||
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
||||
|
||||
+3
-1
@@ -1865,7 +1865,9 @@
|
||||
|
||||
<script src="/vendor/lucide.min.js" defer></script>
|
||||
<script src="/ui-core.js" defer></script>
|
||||
<script src="/shared/state.js?v=20260729-1" defer></script>
|
||||
<script src="/shared/api.js?v=20260729-1" defer></script>
|
||||
<script src="/heaven-loading-v2.js?v=20260728-2" defer></script>
|
||||
<script src="/app.js?v=20260728-3" defer></script>
|
||||
<script src="/app.js?v=20260729-4" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
(function exposeApiClient(global) {
|
||||
"use strict";
|
||||
|
||||
let csrfTokenSupplier = () => "";
|
||||
let unauthorizedHandler = () => {};
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message, status = 0, payload = null) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
}
|
||||
}
|
||||
|
||||
function configure(options = {}) {
|
||||
if (typeof options.csrfToken === "function") csrfTokenSupplier = options.csrfToken;
|
||||
if (typeof options.onUnauthorized === "function") unauthorizedHandler = options.onUnauthorized;
|
||||
}
|
||||
|
||||
function requestOptions(method, body, signal) {
|
||||
const normalizedMethod = String(method || "GET").toUpperCase();
|
||||
const options = { method: normalizedMethod, headers: {}, signal };
|
||||
const csrfToken = csrfTokenSupplier();
|
||||
if (!["GET", "HEAD", "OPTIONS"].includes(normalizedMethod) && csrfToken) {
|
||||
options.headers["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
if (body !== null && body !== undefined) {
|
||||
options.headers["Content-Type"] = "application/json";
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function parseJson(response) {
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function handleUnauthorized(response, url) {
|
||||
if (response.status === 401 && !String(url).startsWith("/api/auth/")) {
|
||||
unauthorizedHandler({ response, url });
|
||||
}
|
||||
}
|
||||
|
||||
async function request(url, method = "GET", body = null, options = {}) {
|
||||
const response = await fetch(url, requestOptions(method, body, options.signal));
|
||||
const payload = await parseJson(response);
|
||||
handleUnauthorized(response, url);
|
||||
if (!response.ok || payload.error) {
|
||||
throw new ApiError(payload.error || "请求失败", response.status, payload);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function streamNdjson(url, options = {}) {
|
||||
const response = await fetch(
|
||||
url,
|
||||
requestOptions(options.method || "POST", options.body, options.signal),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const payload = await parseJson(response);
|
||||
handleUnauthorized(response, url);
|
||||
throw new ApiError(
|
||||
payload.error || options.errorMessage || "流式请求暂不可用",
|
||||
response.status,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
if (!response.body) throw new ApiError("当前浏览器不支持流式回答", response.status);
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const consume = (line) => {
|
||||
if (!line.trim()) return;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch (_error) {
|
||||
throw new ApiError("流式响应格式错误", response.status);
|
||||
}
|
||||
if (event.type === "error") {
|
||||
throw new ApiError(event.error || options.errorMessage || "流式请求失败", response.status, event);
|
||||
}
|
||||
options.onEvent?.(event);
|
||||
};
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
lines.forEach(consume);
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) consume(buffer);
|
||||
}
|
||||
|
||||
global.XiaobaiAPI = Object.freeze({ ApiError, configure, request, streamNdjson });
|
||||
})(window);
|
||||
@@ -0,0 +1,44 @@
|
||||
(function exposeStateStore(global) {
|
||||
"use strict";
|
||||
|
||||
function create(domains) {
|
||||
const owners = new Map();
|
||||
const stores = {};
|
||||
Object.entries(domains).forEach(([domain, values]) => {
|
||||
stores[domain] = { ...values };
|
||||
Object.keys(values).forEach((key) => {
|
||||
if (owners.has(key)) throw new Error(`Duplicate state field: ${key}`);
|
||||
owners.set(key, domain);
|
||||
});
|
||||
});
|
||||
|
||||
const proxy = new Proxy({}, {
|
||||
get(_target, key) {
|
||||
if (key === "domain") return (name) => stores[name];
|
||||
if (key === "domains") return Object.freeze({ ...stores });
|
||||
if (typeof key !== "string" || !owners.has(key)) return undefined;
|
||||
return stores[owners.get(key)][key];
|
||||
},
|
||||
set(_target, key, value) {
|
||||
if (typeof key !== "string" || !owners.has(key)) {
|
||||
throw new Error(`Unregistered application state field: ${String(key)}`);
|
||||
}
|
||||
stores[owners.get(key)][key] = value;
|
||||
return true;
|
||||
},
|
||||
has(_target, key) {
|
||||
return key === "domain" || key === "domains" || owners.has(key);
|
||||
},
|
||||
ownKeys() {
|
||||
return [...owners.keys()];
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, key) {
|
||||
if (!owners.has(key)) return undefined;
|
||||
return { enumerable: true, configurable: true };
|
||||
},
|
||||
});
|
||||
return proxy;
|
||||
}
|
||||
|
||||
global.XiaobaiState = Object.freeze({ create });
|
||||
})(window);
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
STATIC = ROOT / "static"
|
||||
|
||||
|
||||
class FrontendBoundaryTests(unittest.TestCase):
|
||||
def test_shared_api_is_the_only_application_fetch_exit(self) -> None:
|
||||
fetch_files = []
|
||||
for path in STATIC.rglob("*.js"):
|
||||
if "vendor" in path.parts:
|
||||
continue
|
||||
if re.search(r"\bfetch\s*\(", path.read_text(encoding="utf-8")):
|
||||
fetch_files.append(path.relative_to(STATIC).as_posix())
|
||||
self.assertEqual(fetch_files, ["shared/api.js"])
|
||||
|
||||
def test_shared_dependencies_load_before_application(self) -> None:
|
||||
html = (STATIC / "index.html").read_text(encoding="utf-8")
|
||||
state_position = html.index('/shared/state.js')
|
||||
api_position = html.index('/shared/api.js')
|
||||
app_position = html.index('/app.js')
|
||||
self.assertLess(state_position, api_position)
|
||||
self.assertLess(api_position, app_position)
|
||||
|
||||
def test_application_state_is_created_through_shared_boundary(self) -> None:
|
||||
app = (STATIC / "app.js").read_text(encoding="utf-8")
|
||||
self.assertIn("const state = window.XiaobaiState.create({", app)
|
||||
self.assertNotIn("const state = {", app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user