refactor(stage2): 生产代码移出 src/vendor,氢能收敛为分层 feature
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 未引用文件。
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user