refactor: establish frontend request and state boundaries
This commit is contained in:
@@ -254,8 +254,8 @@
|
|||||||
"code_hotspots": [
|
"code_hotspots": [
|
||||||
{
|
{
|
||||||
"path": "static/app.js",
|
"path": "static/app.js",
|
||||||
"bytes": 449457,
|
"bytes": 447568,
|
||||||
"lines": 9447
|
"lines": 9403
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "static/styles.css",
|
"path": "static/styles.css",
|
||||||
@@ -274,8 +274,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "static/index.html",
|
"path": "static/index.html",
|
||||||
"bytes": 133569,
|
"bytes": 133691,
|
||||||
"lines": 1871
|
"lines": 1873
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "database.py",
|
"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 activeThemeTransition = null;
|
||||||
let themeSwitchSequence = 0;
|
let themeSwitchSequence = 0;
|
||||||
|
|
||||||
const state = {
|
const state = window.XiaobaiState.create({
|
||||||
user: null,
|
session: {
|
||||||
csrfToken: "",
|
user: null,
|
||||||
authMode: "login",
|
csrfToken: "",
|
||||||
started: false,
|
authMode: "login",
|
||||||
dashboard: null,
|
started: false,
|
||||||
filter: "all",
|
activeView: "sentimentCycleView",
|
||||||
query: "",
|
dashboardLoading: false,
|
||||||
sortKey: "streak",
|
dashboardRequestSequence: 0,
|
||||||
sortDirection: "desc",
|
dashboardRequestDate: "",
|
||||||
brokenQuery: "",
|
adminModels: [],
|
||||||
brokenSortKey: "",
|
globalSearchResults: [],
|
||||||
brokenSortDirection: "desc",
|
globalSearchActiveIndex: -1,
|
||||||
downQuery: "",
|
globalSearchRequestSequence: 0,
|
||||||
downSortKey: "",
|
},
|
||||||
downSortDirection: "asc",
|
market: {
|
||||||
yesterdayFilter: "all",
|
dashboard: null,
|
||||||
yesterdayQuery: "",
|
filter: "all",
|
||||||
yesterdaySortKey: "",
|
query: "",
|
||||||
yesterdaySortDirection: "desc",
|
sortKey: "streak",
|
||||||
activeView: "sentimentCycleView",
|
sortDirection: "desc",
|
||||||
dragonTiger: null,
|
brokenQuery: "",
|
||||||
dragonViewMode: "daily",
|
brokenSortKey: "",
|
||||||
dragonFilter: "all",
|
brokenSortDirection: "desc",
|
||||||
dragonQuery: "",
|
downQuery: "",
|
||||||
selectedDragonTraderId: "",
|
downSortKey: "",
|
||||||
hotMoneyProfiles: null,
|
downSortDirection: "asc",
|
||||||
hotMoneyProfileQuery: "",
|
yesterdayFilter: "all",
|
||||||
selectedHotMoneyProfileId: "",
|
yesterdayQuery: "",
|
||||||
rotationHistory: null,
|
yesterdaySortKey: "",
|
||||||
rotationHistoryKey: "",
|
yesterdaySortDirection: "desc",
|
||||||
rotationSelectedSector: "",
|
dragonTiger: null,
|
||||||
rotationSelectedDate: "",
|
dragonViewMode: "daily",
|
||||||
rotationMembers: null,
|
dragonFilter: "all",
|
||||||
rotationMembersKey: "",
|
dragonQuery: "",
|
||||||
rotationMembersLoading: false,
|
selectedDragonTraderId: "",
|
||||||
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
hotMoneyProfiles: null,
|
||||||
rotationLoading: false,
|
hotMoneyProfileQuery: "",
|
||||||
auctionData: null,
|
selectedHotMoneyProfileId: "",
|
||||||
auctionDataset: "focus",
|
rotationHistory: null,
|
||||||
auctionFilter: "all",
|
rotationHistoryKey: "",
|
||||||
auctionQuery: "",
|
rotationSelectedSector: "",
|
||||||
auctionSortKey: "attention_score",
|
rotationSelectedDate: "",
|
||||||
auctionSortDirection: "desc",
|
rotationMembers: null,
|
||||||
auctionLoading: false,
|
rotationMembersKey: "",
|
||||||
auctionTimer: null,
|
rotationMembersLoading: false,
|
||||||
themeLibrary: null,
|
rotationOrder: localStorage.getItem("xiaobaiRotationOrder") === "latest" ? "latest" : "oldest",
|
||||||
themeQuery: "",
|
rotationLoading: false,
|
||||||
selectedThemeCode: "",
|
auctionData: null,
|
||||||
themeDetail: null,
|
auctionDataset: "focus",
|
||||||
themeLoading: false,
|
auctionFilter: "all",
|
||||||
popularityData: null,
|
auctionQuery: "",
|
||||||
popularitySource: "combined",
|
auctionSortKey: "attention_score",
|
||||||
popularityQuery: "",
|
auctionSortDirection: "desc",
|
||||||
popularityLoading: false,
|
auctionLoading: false,
|
||||||
expandedLadderLevels: new Set(),
|
auctionTimer: null,
|
||||||
ladderSortMode: "time",
|
themeLibrary: null,
|
||||||
stockDetail: null,
|
themeQuery: "",
|
||||||
activeStock: null,
|
selectedThemeCode: "",
|
||||||
stockDetailChartMode: "daily",
|
themeDetail: null,
|
||||||
stockDetailIntraday: null,
|
themeLoading: false,
|
||||||
stockDetailRequestSequence: 0,
|
popularityData: null,
|
||||||
entityDetailItem: null,
|
popularitySource: "combined",
|
||||||
entityDetailPayload: null,
|
popularityQuery: "",
|
||||||
entityDetailChartMode: "daily",
|
popularityLoading: false,
|
||||||
entityDetailIntraday: null,
|
expandedLadderLevels: new Set(),
|
||||||
entityDetailRequestSequence: 0,
|
ladderSortMode: "time",
|
||||||
stockPreviewCode: "",
|
sentimentHistory: null,
|
||||||
stockPreviewType: "stock",
|
sentimentRange: 20,
|
||||||
stockPreviewItem: null,
|
sentimentHistoryKey: "",
|
||||||
stockPreviewPayload: null,
|
sentimentLoading: false,
|
||||||
stockPreviewChart: "daily",
|
},
|
||||||
stockPreviewFallback: null,
|
details: {
|
||||||
watchlist: [],
|
stockDetail: null,
|
||||||
watchlistSelection: null,
|
activeStock: null,
|
||||||
watchlistSearchResults: [],
|
stockDetailChartMode: "daily",
|
||||||
watchlistSearchRequestSequence: 0,
|
stockDetailIntraday: null,
|
||||||
editingDailyNoteId: 0,
|
stockDetailRequestSequence: 0,
|
||||||
notes: [],
|
entityDetailItem: null,
|
||||||
tradeEntries: [],
|
entityDetailPayload: null,
|
||||||
tradeSummary: {},
|
entityDetailChartMode: "daily",
|
||||||
editingTradeId: 0,
|
entityDetailIntraday: null,
|
||||||
initialStockOpened: false,
|
entityDetailRequestSequence: 0,
|
||||||
screenerSetup: null,
|
stockPreviewCode: "",
|
||||||
screenerSetupKey: "",
|
stockPreviewType: "stock",
|
||||||
screenerSetupRequestKey: "",
|
stockPreviewItem: null,
|
||||||
screenerSetupPromise: null,
|
stockPreviewPayload: null,
|
||||||
selectedRegime: "",
|
stockPreviewChart: "daily",
|
||||||
selectedStrategy: null,
|
stockPreviewFallback: null,
|
||||||
customStrategyDraft: null,
|
initialStockOpened: false,
|
||||||
screenerRunning: false,
|
},
|
||||||
screenerRunningMode: "",
|
review: {
|
||||||
screenerResults: { smart: null, curated: null, quant: null },
|
watchlist: [],
|
||||||
screenerResultContexts: { smart: null, curated: null, quant: null },
|
watchlistSelection: null,
|
||||||
screenerResultStore: {},
|
watchlistSearchResults: [],
|
||||||
screenerTracking: null,
|
watchlistSearchRequestSequence: 0,
|
||||||
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
editingDailyNoteId: 0,
|
||||||
? localStorage.getItem("xiaobaiScreenerMode")
|
notes: [],
|
||||||
: "smart",
|
tradeEntries: [],
|
||||||
curatedCategory: "全部",
|
tradeSummary: {},
|
||||||
curatedSchool: "全部",
|
editingTradeId: 0,
|
||||||
curatedQuery: "",
|
alerts: [],
|
||||||
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
alertFilter: "all",
|
||||||
selectedCuratedStrategyId: 0,
|
alertUnreadCount: 0,
|
||||||
quantFilters: [],
|
assistantMessages: [],
|
||||||
quantScores: [],
|
assistantLoading: false,
|
||||||
alerts: [],
|
assistantController: null,
|
||||||
alertFilter: "all",
|
},
|
||||||
alertUnreadCount: 0,
|
screener: {
|
||||||
assistantMessages: [],
|
screenerSetup: null,
|
||||||
assistantLoading: false,
|
screenerSetupKey: "",
|
||||||
assistantController: null,
|
screenerSetupRequestKey: "",
|
||||||
screenerMobileView: "strategy",
|
screenerSetupPromise: null,
|
||||||
sentimentHistory: null,
|
selectedRegime: "",
|
||||||
sentimentRange: 20,
|
selectedStrategy: null,
|
||||||
sentimentHistoryKey: "",
|
customStrategyDraft: null,
|
||||||
sentimentLoading: false,
|
screenerRunning: false,
|
||||||
mentorSetup: null,
|
screenerRunningMode: "",
|
||||||
selectedMentorId: "",
|
screenerResults: { smart: null, curated: null, quant: null },
|
||||||
mentorMessages: [],
|
screenerResultContexts: { smart: null, curated: null, quant: null },
|
||||||
mentorLoading: false,
|
screenerResultStore: {},
|
||||||
mentorQuery: "",
|
screenerTracking: null,
|
||||||
mentorGrade: "all",
|
screenerMode: ["smart", "curated", "quant"].includes(localStorage.getItem("xiaobaiScreenerMode"))
|
||||||
mentorDirectoryOpen: false,
|
? localStorage.getItem("xiaobaiScreenerMode")
|
||||||
mentorSortMode: false,
|
: "smart",
|
||||||
mentorSavingPreferences: false,
|
curatedCategory: "全部",
|
||||||
mentorController: null,
|
curatedSchool: "全部",
|
||||||
heavenSetup: null,
|
curatedQuery: "",
|
||||||
heavenManualData: null,
|
curatedViewMode: localStorage.getItem("xiaobaiCuratedViewMode") === "grid" ? "grid" : "list",
|
||||||
personalField: null,
|
selectedCuratedStrategyId: 0,
|
||||||
heavenPanel: "trend",
|
quantFilters: [],
|
||||||
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
quantScores: [],
|
||||||
heavenReadingMode: "trend",
|
screenerMobileView: "strategy",
|
||||||
heavenReadingTab: "current",
|
},
|
||||||
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
mentor: {
|
||||||
heavenReadingSelectedId: 0,
|
mentorSetup: null,
|
||||||
heavenReadingLoading: false,
|
selectedMentorId: "",
|
||||||
heavenReadingError: "",
|
mentorMessages: [],
|
||||||
heartStage: "intro",
|
mentorLoading: false,
|
||||||
heartTimer: null,
|
mentorQuery: "",
|
||||||
heartSeconds: HEART_BREATH_TOTAL_MS / 1000,
|
mentorGrade: "all",
|
||||||
heartBreathingEndsAt: 0,
|
mentorDirectoryOpen: false,
|
||||||
heartLines: [],
|
mentorSortMode: false,
|
||||||
heartThrows: [],
|
mentorSavingPreferences: false,
|
||||||
heartHexagram: null,
|
mentorController: null,
|
||||||
heartCurtainTimer: null,
|
},
|
||||||
heartStageToken: 0,
|
heaven: {
|
||||||
heartRevealToken: 0,
|
heavenSetup: null,
|
||||||
heavenPerformanceKey: "",
|
heavenManualData: null,
|
||||||
heavenPerformancePanels: new Set(),
|
personalField: null,
|
||||||
heavenPerformanceActive: "",
|
heavenPanel: "trend",
|
||||||
dashboardLoading: false,
|
heavenInterpretations: { trend: "", fortune: "", heart: "" },
|
||||||
dashboardRequestSequence: 0,
|
heavenReadingMode: "trend",
|
||||||
heavenRequestSequence: 0,
|
heavenReadingTab: "current",
|
||||||
dashboardRequestDate: "",
|
heavenReadingHistory: { trend: [], fortune: [], heart: [] },
|
||||||
adminModels: [],
|
heavenReadingSelectedId: 0,
|
||||||
globalSearchResults: [],
|
heavenReadingLoading: false,
|
||||||
globalSearchActiveIndex: -1,
|
heavenReadingError: "",
|
||||||
globalSearchRequestSequence: 0,
|
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 = {
|
const elements = {
|
||||||
tradeDate: document.querySelector("#tradeDate"),
|
tradeDate: document.querySelector("#tradeDate"),
|
||||||
@@ -4698,39 +4717,16 @@ function scheduleMentorRender() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
async function streamMentorRequest(body, signal, onDelta, onMeta) {
|
||||||
const response = await fetch("/api/mentors/chat", {
|
await window.XiaobaiAPI.streamNdjson("/api/mentors/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
body,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal,
|
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) {
|
function useMentorQuickPrompt(prompt) {
|
||||||
@@ -7907,41 +7903,15 @@ async function sendAssistantQuestion(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function streamAssistantRequest(question, signal, onDelta) {
|
async function streamAssistantRequest(question, signal, onDelta) {
|
||||||
const response = await fetch("/api/assistant/chat", {
|
await window.XiaobaiAPI.streamNdjson("/api/assistant/chat", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
body: { question, trade_date: elements.tradeDate.value },
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
|
|
||||||
signal,
|
signal,
|
||||||
});
|
errorMessage: "复盘助手暂不可用",
|
||||||
if (!response.ok) {
|
onEvent: (event) => {
|
||||||
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);
|
|
||||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
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() {
|
function stopAssistantResponse() {
|
||||||
@@ -9167,21 +9137,7 @@ function formatMoneyMillion(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
async function apiRequest(url, method = "GET", body = null, requestOptions = {}) {
|
||||||
const options = { method, headers: {}, signal: requestOptions.signal };
|
return window.XiaobaiAPI.request(url, method, body, requestOptions);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
function setLoading(loading, text = "正在加载复盘数据", context = "default") {
|
||||||
|
|||||||
+3
-1
@@ -1865,7 +1865,9 @@
|
|||||||
|
|
||||||
<script src="/vendor/lucide.min.js" defer></script>
|
<script src="/vendor/lucide.min.js" defer></script>
|
||||||
<script src="/ui-core.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="/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>
|
</body>
|
||||||
</html>
|
</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