src/vendor 语义是'第三方/参考',却装着线上氢能看板并反向 import
src/modules,依赖方向倒挂。本次把生产代码归位并建立氢能分层:
src/modules/energy/hydrogen/
index.tsx 导航入口(原 HydrogenModule.tsx)
api.ts types.ts HTTP 与 DTO
dev-mock-api.ts 仅 dev 的 vite 插件(仍由 vite.config.ts 引用)
model/ 纯逻辑(format / bearing-labels / daily-detail-format)
board/ 经营看板 UI(原 vendor energy-h2-bi-board)
station-daily/ 单站日报 UI(原 vendor energy-h2-station-daily)
drill/ 下钻弹层 UI(原 hydrogen-bi-v2 的 UI 部分)
common/ 工具与共享组件(原 vendor common + prototype-download)
fonts/ JetBrains Mono(原 vendor resources/design-system)
- UI 与 CSS 逐字节保留,仅移动位置与改写相对 import。
- 删除自挂载原型入口后遗留的 2 个 annotation-source.json。
- vite.config.ts 与 tsconfig 的路径/exclude 同步清理。
- independent-entry.test.ts 不再硬编码路径,改为断言'两个入口解析到同一文件'。
导入改写用一次性 codemod 完成(按旧位置解析、按新位置重写),
lint / test(131) / build 全绿,可达性分析仍为 0 未引用文件。
183 lines
6.0 KiB
TypeScript
183 lines
6.0 KiB
TypeScript
import { fetchJson } from '../../../auth/api-client';
|
|
import type {
|
|
H2BiDailyResponse,
|
|
H2BiDailyTreeQuery,
|
|
H2BiDailyTreeResponse,
|
|
H2BiDrillReadOptions,
|
|
H2BiFullDrillOptions,
|
|
H2BiFullDrillResponse,
|
|
H2BiDrillQuery,
|
|
H2BiDrillResponse,
|
|
H2BiMetaResponse,
|
|
H2BiOverviewResponse,
|
|
H2BiQuery,
|
|
} from './types';
|
|
|
|
const BASE = '/api/energy/h2/v2';
|
|
|
|
function queryString(query: Record<string, unknown>) {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(query)) {
|
|
if (value !== undefined && value !== null && value !== '') params.set(key, String(value));
|
|
}
|
|
return params.toString();
|
|
}
|
|
|
|
function request<T>(path: string, query: object = {}, options?: RequestInit) {
|
|
const qs = queryString(query as Record<string, unknown>);
|
|
return fetchJson<T>(`${BASE}/${path}${qs ? `?${qs}` : ''}`, options);
|
|
}
|
|
|
|
export function fetchH2BiMeta() {
|
|
return request<H2BiMetaResponse>('meta');
|
|
}
|
|
|
|
export function fetchH2BiOverview(query: H2BiQuery) {
|
|
return request<H2BiOverviewResponse>('overview', query);
|
|
}
|
|
|
|
export function fetchH2BiDaily(query: H2BiQuery) {
|
|
return request<H2BiDailyResponse>('daily', query).catch(async () => {
|
|
// The date-group drill is backed by the same read-only ledger and has a
|
|
// simpler query plan. Keep the date view usable when the aggregate daily
|
|
// endpoint times out/fails, without substituting mock data.
|
|
const drill = await request<H2BiDrillResponse>('drill', {
|
|
...query,
|
|
groupBy: 'date',
|
|
pageSize: 400,
|
|
});
|
|
const startDate = query.startDate ?? `${query.year}-01-01`;
|
|
const endDate = query.endDate ?? new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date());
|
|
const byDate = new Map(drill.groups.map((row) => [row.name, row]));
|
|
const days = [] as H2BiDailyResponse['trend'];
|
|
for (let cursor = new Date(`${startDate}T00:00:00Z`); cursor <= new Date(`${endDate}T00:00:00Z`); cursor.setUTCDate(cursor.getUTCDate() + 1)) {
|
|
const date = cursor.toISOString().slice(0, 10);
|
|
const row = byDate.get(date);
|
|
days.push({
|
|
date,
|
|
kg: Number(row?.kg ?? 0),
|
|
lingniuKg: Number(row?.lingniuKg ?? 0),
|
|
externalKg: Number(row?.externalKg ?? 0),
|
|
cost: Number(row?.cost ?? 0),
|
|
recordCount: Number(row?.recordCount ?? 0),
|
|
});
|
|
}
|
|
const totalKg = days.reduce((sum, row) => sum + row.kg, 0);
|
|
const totalCost = days.reduce((sum, row) => sum + row.cost, 0);
|
|
return {
|
|
range: { startDate, endDate },
|
|
watermark: { ledgerAt: null, paymentAt: null },
|
|
filters: query,
|
|
kpis: {
|
|
totalKg,
|
|
totalCost,
|
|
averageDailyKg: totalKg / Math.max(1, days.length),
|
|
stationCount: Number(drill.summary.stationCount ?? 0),
|
|
activeDays: days.filter((row) => row.kg > 0).length,
|
|
},
|
|
trend: days,
|
|
days: [...days].reverse(),
|
|
};
|
|
});
|
|
}
|
|
|
|
export function fetchH2BiDailyTree(date: string, query: H2BiDailyTreeQuery) {
|
|
return request<H2BiDailyTreeResponse>('daily-tree', { date, ...query });
|
|
}
|
|
|
|
export async function fetchH2BiDrill(
|
|
query: H2BiDrillQuery,
|
|
options?: H2BiDrillReadOptions,
|
|
) {
|
|
const page = query.page ?? 1;
|
|
const pageSize = Math.min(Math.max(1, query.pageSize ?? 100), 200);
|
|
const response = await request<H2BiDrillResponse>(
|
|
'drill',
|
|
{ ...query, page, pageSize },
|
|
options,
|
|
);
|
|
const itemCount = query.groupBy === 'record'
|
|
? response.records.length
|
|
: response.groups.length;
|
|
// Older servers only set hasMore for record pages. A full page is therefore
|
|
// also treated as potentially incomplete; the final empty/short page proves
|
|
// completion without silently dropping grouped rows.
|
|
return {
|
|
...response,
|
|
page: {
|
|
...response.page,
|
|
page,
|
|
pageSize,
|
|
itemCount,
|
|
hasMore: Boolean(response.page?.hasMore) || itemCount === pageSize,
|
|
},
|
|
} satisfies H2BiDrillResponse;
|
|
}
|
|
|
|
function abortError() {
|
|
const error = new Error('已取消全量读取');
|
|
error.name = 'AbortError';
|
|
return error;
|
|
}
|
|
|
|
/**
|
|
* Reads every page only after a caller explicitly asks for a complete result.
|
|
* It never returns a partial collection: server errors, cancellation, and the
|
|
* page guard all reject before an export can be created.
|
|
*/
|
|
export async function fetchAllH2BiDrill(
|
|
query: Omit<H2BiDrillQuery, 'page' | 'pageSize'>,
|
|
options: H2BiFullDrillOptions = {},
|
|
): Promise<H2BiFullDrillResponse> {
|
|
const pageSize = Math.min(Math.max(1, options.pageSize ?? 200), 200);
|
|
const maxPages = Math.max(1, options.maxPages ?? 250);
|
|
const maxRows = Math.max(1, options.maxRows ?? 50_000);
|
|
const groups: H2BiDrillResponse['groups'] = [];
|
|
const records: H2BiDrillResponse['records'] = [];
|
|
let first: H2BiDrillResponse | null = null;
|
|
|
|
for (let page = 1; page <= maxPages; page += 1) {
|
|
if (options.signal?.aborted) throw abortError();
|
|
const result = await fetchH2BiDrill(
|
|
{ ...query, page, pageSize },
|
|
{ signal: options.signal },
|
|
);
|
|
if (!first) first = result;
|
|
groups.push(...result.groups);
|
|
records.push(...result.records);
|
|
const itemCount = query.groupBy === 'record' ? records.length : groups.length;
|
|
if (itemCount > maxRows)
|
|
throw new Error(`全量读取超过 ${maxRows} 条保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
|
if (!result.page.hasMore) {
|
|
return {
|
|
...first,
|
|
groups,
|
|
records,
|
|
page: {
|
|
...result.page,
|
|
page: 1,
|
|
pageSize,
|
|
itemCount,
|
|
hasMore: false,
|
|
pagesRead: page,
|
|
complete: true,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
throw new Error(`全量读取超过 ${maxPages} 页保护上限,未生成不完整结果;请缩小日期范围后重新导出`);
|
|
}
|
|
|
|
/** Reusable contract for station-detail consumers that need every raw record. */
|
|
export function fetchAllH2BiDrillRecords(
|
|
query: Omit<H2BiDrillQuery, 'groupBy' | 'page' | 'pageSize'>,
|
|
options?: H2BiFullDrillOptions,
|
|
) {
|
|
return fetchAllH2BiDrill({ ...query, groupBy: 'record' }, options);
|
|
}
|