258 lines
7.8 KiB
JavaScript
258 lines
7.8 KiB
JavaScript
import { API_OPERATIONS, apiEndpointPath } from "../../src/shared/api/generated/endpoints.ts";
|
|
import { countryFlag, stripCountryFlagPrefix } from "./data/countryMeta.js";
|
|
|
|
const API_BASE_URL = import.meta.env?.VITE_API_BASE_URL || "/api";
|
|
const TOKEN_KEY = "hyapp-admin.access-token";
|
|
const APP_CODE_KEY = "hyapp-admin.app-code";
|
|
const APP_CODE_HEADER = "X-App-Code";
|
|
|
|
export function getCurrentAppCode() {
|
|
return normalizeAppCode(window.localStorage.getItem(APP_CODE_KEY) || "lalu");
|
|
}
|
|
|
|
export function setCurrentAppCode(value) {
|
|
const appCode = normalizeAppCode(value) || "lalu";
|
|
window.localStorage.setItem(APP_CODE_KEY, appCode);
|
|
return appCode;
|
|
}
|
|
|
|
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, startMs }) {
|
|
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
|
if (startMs) {
|
|
query.start_ms = String(startMs);
|
|
}
|
|
if (endMs) {
|
|
query.end_ms = String(endMs);
|
|
}
|
|
if (countryId) {
|
|
query.country_id = String(countryId);
|
|
}
|
|
if (regionId && regionId !== "all") {
|
|
query.region_id = String(regionId);
|
|
}
|
|
|
|
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
|
}
|
|
|
|
export async function fetchCountryStatisticsBreakdown({ appCode, countries = [], endMs, regionId, startMs }) {
|
|
return mapWithConcurrency(
|
|
countries.filter((country) => Number(country.id) > 0),
|
|
8,
|
|
async (country) => {
|
|
const data = await fetchStatisticsOverview({
|
|
appCode,
|
|
countryId: country.id,
|
|
endMs,
|
|
regionId,
|
|
startMs
|
|
});
|
|
return normalizeCountryStatisticsRow(data, country);
|
|
}
|
|
);
|
|
}
|
|
|
|
export async function fetchFilterOptions({ appCode } = {}) {
|
|
const [apps, regions, countries] = await Promise.all([
|
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listApps), { appCode }),
|
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listRegions), { appCode, query: { status: "active" } }),
|
|
fetchDatabiData(apiEndpointPath(API_OPERATIONS.listCountries), { appCode, query: { enabled: true } })
|
|
]);
|
|
|
|
return normalizeFilterOptions({ apps, countries, regions });
|
|
}
|
|
|
|
async function fetchDatabiData(path, { appCode, query } = {}) {
|
|
const response = await fetch(buildURL(path, query), {
|
|
credentials: "include",
|
|
headers: requestHeaders(appCode)
|
|
});
|
|
const payload = await readJSON(response);
|
|
if (isAuthExpired(response, payload)) {
|
|
redirectToLogin(window);
|
|
throw new Error(payload.message || "访问凭证已失效");
|
|
}
|
|
if (!response.ok || payload.code !== 0) {
|
|
throw new Error(payload.message || response.statusText || "请求失败");
|
|
}
|
|
return payload.data;
|
|
}
|
|
|
|
function buildURL(path, query) {
|
|
const url = new URL(`${API_BASE_URL}${path}`, window.location.origin);
|
|
if (query) {
|
|
Object.entries(query).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null && value !== "") {
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
});
|
|
}
|
|
return url.toString();
|
|
}
|
|
|
|
function requestHeaders(appCode) {
|
|
const headers = {};
|
|
const token = window.localStorage.getItem(TOKEN_KEY);
|
|
if (token) {
|
|
headers.Authorization = `Bearer ${token}`;
|
|
}
|
|
const normalized = normalizeAppCode(appCode);
|
|
if (normalized) {
|
|
headers[APP_CODE_HEADER] = normalized;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
async function readJSON(response) {
|
|
const text = await response.text();
|
|
if (!text) {
|
|
return {};
|
|
}
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch {
|
|
return { code: response.ok ? 0 : response.status, message: text };
|
|
}
|
|
}
|
|
|
|
export function isAuthExpired(response, payload) {
|
|
return response.status === 401 || Number(payload?.code) === 40100;
|
|
}
|
|
|
|
export function redirectToLogin(targetWindow = window) {
|
|
targetWindow.localStorage.removeItem(TOKEN_KEY);
|
|
if (targetWindow.location.pathname !== "/login") {
|
|
targetWindow.location.replace("/login");
|
|
}
|
|
}
|
|
|
|
function normalizeAppCode(value) {
|
|
return String(value || "").trim().toLowerCase();
|
|
}
|
|
|
|
function normalizeFilterOptions({ apps, countries, regions }) {
|
|
const regionItems = normalizeList(regions);
|
|
const regionOptions = [
|
|
{ countryCodes: [], id: "all", label: "全部区域", value: "all" },
|
|
...regionItems
|
|
.filter((region) => String(region.regionCode || "").toUpperCase() !== "GLOBAL")
|
|
.map((region) => {
|
|
const id = String(region.regionId ?? region.id ?? region.regionCode ?? "");
|
|
return {
|
|
countryCodes: normalizeCodes(region.countries),
|
|
id,
|
|
label: [region.name, region.regionCode].filter(Boolean).join(" · ") || `区域 ${id}`,
|
|
value: id
|
|
};
|
|
})
|
|
.filter((region) => region.id)
|
|
];
|
|
const countryRegionIds = buildCountryRegionIndex(regionOptions);
|
|
const countryOptions = [
|
|
{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 },
|
|
...normalizeList(countries)
|
|
.map((country) => normalizeCountryOption(country, countryRegionIds))
|
|
.filter(Boolean)
|
|
];
|
|
const appOptions = normalizeList(apps)
|
|
.map((app) => {
|
|
const code = normalizeAppCode(app.appCode ?? app.app_code ?? app.code);
|
|
return {
|
|
code,
|
|
label: app.appName || app.app_name || app.name || code,
|
|
value: code
|
|
};
|
|
})
|
|
.filter((app) => app.code);
|
|
|
|
return {
|
|
appOptions,
|
|
countryOptions,
|
|
regionOptions
|
|
};
|
|
}
|
|
|
|
function normalizeList(value) {
|
|
if (Array.isArray(value)) {
|
|
return value;
|
|
}
|
|
if (Array.isArray(value?.items)) {
|
|
return value.items;
|
|
}
|
|
if (Array.isArray(value?.list)) {
|
|
return value.list;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function normalizeCodes(value) {
|
|
return Array.isArray(value) ? value.map((item) => String(item || "").trim().toUpperCase()).filter(Boolean) : [];
|
|
}
|
|
|
|
function buildCountryRegionIndex(regions) {
|
|
const index = new Map();
|
|
regions.forEach((region) => {
|
|
if (region.id === "all") {
|
|
return;
|
|
}
|
|
region.countryCodes.forEach((code) => {
|
|
const ids = index.get(code) || [];
|
|
ids.push(region.id);
|
|
index.set(code, ids);
|
|
});
|
|
});
|
|
return index;
|
|
}
|
|
|
|
function normalizeCountryOption(country, countryRegionIds) {
|
|
const countryCode = String(country.countryCode ?? country.country_code ?? country.code ?? "").trim().toUpperCase();
|
|
const id = Number(country.countryId ?? country.country_id ?? country.id ?? 0);
|
|
if (!countryCode && !id) {
|
|
return null;
|
|
}
|
|
const rawName = country.countryDisplayName || country.country_display_name || country.countryName || country.country_name || country.name || countryCode;
|
|
const name = stripCountryFlagPrefix(rawName);
|
|
const flag = country.flag || countryFlag(countryCode);
|
|
const label = [flag, name].filter(Boolean).join(" ");
|
|
const searchText = [flag, rawName, name, countryCode, country.isoAlpha3, country.iso_alpha3, country.phoneCountryCode, country.phone_country_code]
|
|
.filter(Boolean)
|
|
.join(" ")
|
|
.toLowerCase();
|
|
return {
|
|
countryCode,
|
|
flag,
|
|
id,
|
|
label,
|
|
name,
|
|
regionIds: countryRegionIds.get(countryCode) || [],
|
|
searchText,
|
|
value: id
|
|
};
|
|
}
|
|
|
|
function normalizeCountryStatisticsRow(data, country) {
|
|
const source = data || {};
|
|
const row = Array.isArray(source.country_breakdown) && source.country_breakdown.length ? source.country_breakdown[0] : source;
|
|
return {
|
|
...row,
|
|
country: stripCountryFlagPrefix(row.country || row.country_name || country.name || country.label),
|
|
country_code: row.country_code || row.countryCode || country.countryCode,
|
|
country_id: row.country_id || row.countryId || country.id
|
|
};
|
|
}
|
|
|
|
async function mapWithConcurrency(items, limit, callback) {
|
|
const results = [];
|
|
let index = 0;
|
|
|
|
async function worker() {
|
|
while (index < items.length) {
|
|
const currentIndex = index;
|
|
index += 1;
|
|
results[currentIndex] = await callback(items[currentIndex], currentIndex);
|
|
}
|
|
}
|
|
|
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
|
|
return results.filter(Boolean);
|
|
}
|