diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json
index bc7dc02..ada3104 100644
--- a/contracts/admin-openapi.json
+++ b/contracts/admin-openapi.json
@@ -270,6 +270,122 @@
"x-permissions": ["first-recharge-reward:view"]
}
},
+ "/admin/activity/cumulative-recharge-reward/grants": {
+ "get": {
+ "operationId": "listCumulativeRechargeRewardGrants",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "cumulative-recharge-reward:view",
+ "x-permissions": ["cumulative-recharge-reward:view"]
+ }
+ },
+ "/admin/activity/room-turnover-reward/settlements": {
+ "get": {
+ "operationId": "listRoomTurnoverRewardSettlements",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-turnover-reward:view",
+ "x-permissions": ["room-turnover-reward:view"]
+ }
+ },
+ "/admin/activity/room-turnover-reward/settlements/{settlement_id}/retry": {
+ "post": {
+ "operationId": "retryRoomTurnoverRewardSettlement",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-turnover-reward:retry",
+ "x-permissions": ["room-turnover-reward:retry"]
+ }
+ },
+ "/admin/activity/weekly-star/cycles": {
+ "get": {
+ "operationId": "listWeeklyStarCycles",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:view",
+ "x-permissions": ["weekly-star:view"]
+ },
+ "post": {
+ "operationId": "createWeeklyStarCycle",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:create",
+ "x-permissions": ["weekly-star:create"]
+ }
+ },
+ "/admin/activity/weekly-star/cycles/{cycle_id}": {
+ "get": {
+ "operationId": "getWeeklyStarCycle",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:view",
+ "x-permissions": ["weekly-star:view"]
+ },
+ "put": {
+ "operationId": "updateWeeklyStarCycle",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:update",
+ "x-permissions": ["weekly-star:update"]
+ }
+ },
+ "/admin/activity/weekly-star/cycles/{cycle_id}/status": {
+ "patch": {
+ "operationId": "setWeeklyStarCycleStatus",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:update",
+ "x-permissions": ["weekly-star:update"]
+ }
+ },
+ "/admin/activity/weekly-star/cycles/{cycle_id}/leaderboard": {
+ "get": {
+ "operationId": "listWeeklyStarLeaderboard",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:view",
+ "x-permissions": ["weekly-star:view"]
+ }
+ },
+ "/admin/activity/weekly-star/cycles/{cycle_id}/settlements": {
+ "get": {
+ "operationId": "listWeeklyStarSettlements",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "weekly-star:settle",
+ "x-permissions": ["weekly-star:settle"]
+ }
+ },
"/admin/activity/registration-reward/claims": {
"get": {
"operationId": "listRegistrationRewardClaims",
@@ -278,6 +394,52 @@
"$ref": "#/components/responses/EmptyResponse"
}
},
+ "parameters": [
+ {
+ "in": "query",
+ "name": "page",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "page_size",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "keyword",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "status",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "claimed_start_ms",
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ },
+ {
+ "in": "query",
+ "name": "claimed_end_ms",
+ "schema": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ ],
"x-permission": "registration-reward:view",
"x-permissions": ["registration-reward:view"]
}
@@ -304,6 +466,50 @@
"x-permissions": ["first-recharge-reward:update"]
}
},
+ "/admin/activity/cumulative-recharge-reward/config": {
+ "get": {
+ "operationId": "getCumulativeRechargeRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "cumulative-recharge-reward:view",
+ "x-permissions": ["cumulative-recharge-reward:view"]
+ },
+ "put": {
+ "operationId": "updateCumulativeRechargeRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "cumulative-recharge-reward:update",
+ "x-permissions": ["cumulative-recharge-reward:update"]
+ }
+ },
+ "/admin/activity/room-turnover-reward/config": {
+ "get": {
+ "operationId": "getRoomTurnoverRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-turnover-reward:view",
+ "x-permissions": ["room-turnover-reward:view"]
+ },
+ "put": {
+ "operationId": "updateRoomTurnoverRewardConfig",
+ "responses": {
+ "200": {
+ "$ref": "#/components/responses/EmptyResponse"
+ }
+ },
+ "x-permission": "room-turnover-reward:update",
+ "x-permissions": ["room-turnover-reward:update"]
+ }
+ },
"/admin/activity/registration-reward/config": {
"get": {
"operationId": "getRegistrationRewardConfig",
diff --git a/databi/src/DatabiApp.jsx b/databi/src/DatabiApp.jsx
index 9abaf81..e8b1c0a 100644
--- a/databi/src/DatabiApp.jsx
+++ b/databi/src/DatabiApp.jsx
@@ -8,7 +8,7 @@ import { LuckyGiftPoolModal } from "./components/LuckyGiftPoolModal.jsx";
import { MetricCard } from "./components/MetricCard.jsx";
import { RevenueTrendPanel } from "./components/RevenueTrendPanel.jsx";
import { createDashboardModel } from "./data/createDashboardModel.js";
-import { rangeEndMs, rangeStartMs, todayRange } from "./utils/time.js";
+import { lastDaysRange, rangeEndMs, rangeStartMs, readStoredTimeZone, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange, writeStoredTimeZone } from "./utils/time.js";
const initialFilterOptions = {
appOptions: [],
@@ -17,7 +17,9 @@ const initialFilterOptions = {
};
export function DatabiApp() {
- const initialRange = useMemo(() => todayRange(), []);
+ const initialTimeZone = useMemo(() => readStoredTimeZone(), []);
+ const initialRange = useMemo(() => todayRange(initialTimeZone), [initialTimeZone]);
+ const [timeZone, setTimeZone] = useState(initialTimeZone);
const [range, setRange] = useState(initialRange);
const [countryId, setCountryId] = useState(0);
const [regionId, setRegionId] = useState("all");
@@ -29,6 +31,7 @@ export function DatabiApp() {
const [error, setError] = useState("");
const [poolModalOpen, setPoolModalOpen] = useState(false);
const overviewRequestIdRef = useRef(0);
+ const overviewRef = useRef(null);
const scopedCountries = useMemo(() => {
return filterOptions.countryOptions.filter((country) => Number(country.id) > 0 && countryBelongsToRegion(country, regionId));
}, [filterOptions.countryOptions, regionId]);
@@ -42,6 +45,15 @@ export function DatabiApp() {
setCountryId(0);
}, []);
+ const handleTimeZoneChange = useCallback((value) => {
+ const nextTimeZone = writeStoredTimeZone(value);
+ setRange((currentRange) => {
+ const currentPreset = resolveRangePresetValue(currentRange, timeZone);
+ return currentPreset ? rangeForPreset(currentPreset, nextTimeZone) : currentRange;
+ });
+ setTimeZone(nextTimeZone);
+ }, [timeZone]);
+
const loadFilterOptions = useCallback(async () => {
setFiltersLoading(true);
try {
@@ -60,15 +72,22 @@ export function DatabiApp() {
}
}, [appCode]);
- const loadOverview = useCallback(async () => {
+ useEffect(() => {
+ overviewRef.current = overview;
+ }, [overview]);
+
+ const loadOverview = useCallback(async ({ preserveData = false } = {}) => {
const requestId = overviewRequestIdRef.current + 1;
overviewRequestIdRef.current = requestId;
+ const keepCurrentOverview = preserveData && overviewRef.current;
setLoading(true);
setError("");
- setOverview(null);
+ if (!keepCurrentOverview) {
+ setOverview(null);
+ }
try {
- const startMs = rangeStartMs(range);
- const endMs = rangeEndMs(range);
+ const startMs = rangeStartMs(range, timeZone);
+ const endMs = rangeEndMs(range, timeZone);
const data = await fetchStatisticsOverview({
appCode,
countryId,
@@ -94,7 +113,9 @@ export function DatabiApp() {
}
} catch (err) {
if (requestId === overviewRequestIdRef.current) {
- setOverview(null);
+ if (!keepCurrentOverview) {
+ setOverview(null);
+ }
setError(err.message || "请求失败");
}
} finally {
@@ -102,7 +123,7 @@ export function DatabiApp() {
setLoading(false);
}
}
- }, [appCode, countryId, range, regionId, scopedCountries]);
+ }, [appCode, countryId, range, regionId, scopedCountries, timeZone]);
useEffect(() => {
void loadFilterOptions();
@@ -110,11 +131,15 @@ export function DatabiApp() {
useEffect(() => {
void loadOverview();
- const timer = window.setInterval(() => void loadOverview(), 60_000);
+ const timer = window.setInterval(() => void loadOverview({ preserveData: true }), 60_000);
return () => window.clearInterval(timer);
}, [loadOverview]);
- const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range }), [appCode, countryId, overview, range]);
+ const handleRefresh = useCallback(() => {
+ void loadOverview({ preserveData: true });
+ }, [loadOverview]);
+
+ const model = useMemo(() => createDashboardModel(overview, { appCode, countryId, range, timeZone }), [appCode, countryId, overview, range, timeZone]);
const showSkeleton = loading && !overview;
const openLuckyGiftPools = useCallback(() => {
setPoolModalOpen(true);
@@ -133,12 +158,15 @@ export function DatabiApp() {
loading={loading}
onAppChange={handleAppChange}
onCountryChange={setCountryId}
- onRefresh={loadOverview}
+ onRefresh={handleRefresh}
onRangeChange={setRange}
onRegionChange={handleRegionChange}
+ onTimeZoneChange={handleTimeZoneChange}
range={range}
regionId={regionId}
regionOptions={filterOptions.regionOptions}
+ timeZone={timeZone}
+ timeZoneOptions={TIME_ZONE_OPTIONS}
updatedAt={model.updatedAt}
/>
@@ -184,6 +212,34 @@ function hasCountryBreakdown(data) {
return Array.isArray(data?.country_breakdown) && data.country_breakdown.length > 0;
}
+function resolveRangePresetValue(range, timeZone) {
+ const presets = [
+ { range: todayRange(timeZone), value: "today" },
+ { range: lastDaysRange(7, timeZone), value: "7d" },
+ { range: lastDaysRange(14, timeZone), value: "14d" },
+ { range: lastDaysRange(30, timeZone), value: "30d" },
+ { range: thisMonthRange(timeZone), value: "month" }
+ ];
+ return presets.find((preset) => sameRange(preset.range, range))?.value || "";
+}
+
+function rangeForPreset(value, timeZone) {
+ switch (value) {
+ case "today":
+ return todayRange(timeZone);
+ case "7d":
+ return lastDaysRange(7, timeZone);
+ case "14d":
+ return lastDaysRange(14, timeZone);
+ case "30d":
+ return lastDaysRange(30, timeZone);
+ case "month":
+ return thisMonthRange(timeZone);
+ default:
+ return null;
+ }
+}
+
function countryBelongsToRegion(country, regionId) {
if (!regionId || regionId === "all") {
return true;
diff --git a/databi/src/DatabiApp.test.jsx b/databi/src/DatabiApp.test.jsx
new file mode 100644
index 0000000..6300f21
--- /dev/null
+++ b/databi/src/DatabiApp.test.jsx
@@ -0,0 +1,62 @@
+import { act, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, expect, test, vi } from "vitest";
+import { DatabiApp } from "./DatabiApp.jsx";
+import { fetchCountryStatisticsBreakdown, fetchFilterOptions, fetchStatisticsOverview } from "./api.js";
+
+vi.mock("./api.js", () => ({
+ fetchCountryStatisticsBreakdown: vi.fn(),
+ fetchFilterOptions: vi.fn(),
+ fetchStatisticsOverview: vi.fn(),
+ getCurrentAppCode: vi.fn(() => "lalu"),
+ setCurrentAppCode: vi.fn((value) => String(value || "lalu").toLowerCase())
+}));
+
+vi.mock("./charts/EChart.jsx", () => ({
+ EChart: () =>
+}));
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-06T08:00:00Z"));
+ fetchFilterOptions.mockResolvedValue({
+ appOptions: [{ code: "lalu", label: "Lalu", value: "lalu" }],
+ countryOptions: [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }],
+ regionOptions: [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }]
+ });
+ fetchCountryStatisticsBreakdown.mockResolvedValue([]);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.clearAllMocks();
+});
+
+test("keeps current data visible during scheduled refresh", async () => {
+ const overview = { active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 };
+ fetchStatisticsOverview
+ .mockResolvedValueOnce(overview)
+ .mockResolvedValueOnce(overview)
+ .mockImplementationOnce(() => new Promise(() => {}));
+
+ render();
+
+ await flushEffects();
+ expect(screen.getByText("$1")).toBeInTheDocument();
+ expect(document.querySelector(".metric-card.is-loading")).toBeNull();
+
+ await act(async () => {
+ vi.advanceTimersByTime(60_000);
+ });
+
+ expect(fetchStatisticsOverview).toHaveBeenCalledTimes(3);
+ expect(screen.getByText("$1")).toBeInTheDocument();
+ expect(document.querySelector(".metric-card.is-loading")).toBeNull();
+});
+
+async function flushEffects() {
+ await act(async () => {
+ for (let index = 0; index < 10; index += 1) {
+ await Promise.resolve();
+ }
+ });
+}
diff --git a/databi/src/components/DatabiHeader.jsx b/databi/src/components/DatabiHeader.jsx
index db39bf9..86eac9c 100644
--- a/databi/src/components/DatabiHeader.jsx
+++ b/databi/src/components/DatabiHeader.jsx
@@ -2,16 +2,16 @@ import { useEffect, useMemo, useRef, useState } from "react";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import PublicOutlined from "@mui/icons-material/PublicOutlined";
-import { lastDaysRange, todayRange } from "../utils/time.js";
+import { getTimeZoneLabel, lastDaysRange, rangeEndMs, rangeStartMs, sameRange, thisMonthRange, TIME_ZONE_OPTIONS, todayRange } from "../utils/time.js";
const defaultCountryOptions = [{ countryCode: "", id: 0, label: "全部国家", regionIds: [], searchText: "全部国家 all", value: 0 }];
const defaultRegionOptions = [{ countryCodes: [], id: "all", label: "全部区域", value: "all" }];
const rangeOptions = [
- { label: "今日", value: "today", getRange: () => todayRange() },
- { label: "近 7 日", value: "7d", getRange: () => lastDaysRange(7) },
- { label: "近 14 日", value: "14d", getRange: () => lastDaysRange(14) },
- { label: "近 30 日", value: "30d", getRange: () => lastDaysRange(30) },
- { label: "本月", value: "month", getRange: thisMonthRange }
+ { label: "今日", value: "today", getRange: (timeZone) => todayRange(timeZone) },
+ { label: "近 7 日", value: "7d", getRange: (timeZone) => lastDaysRange(7, timeZone) },
+ { label: "近 14 日", value: "14d", getRange: (timeZone) => lastDaysRange(14, timeZone) },
+ { label: "近 30 日", value: "30d", getRange: (timeZone) => lastDaysRange(30, timeZone) },
+ { label: "本月", value: "month", getRange: (timeZone) => thisMonthRange(timeZone) }
];
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const hours = Array.from({ length: 24 }, (_, index) => pad2(index));
@@ -28,14 +28,18 @@ export function DatabiHeader({
onCountryChange,
onRangeChange,
onRegionChange,
+ onTimeZoneChange,
range,
regionId,
- regionOptions = defaultRegionOptions
+ regionOptions = defaultRegionOptions,
+ timeZone,
+ timeZoneOptions = TIME_ZONE_OPTIONS
}) {
const [openControl, setOpenControl] = useState("");
const headerRef = useRef(null);
const appLabel = appOptions.find((item) => item.code === appCode || item.value === appCode)?.label || appCode;
const regionLabel = regionOptions.find((item) => String(item.id) === String(regionId) || String(item.value) === String(regionId))?.label || "全部区域";
+ const timeZoneLabel = getTimeZoneLabel(timeZone);
const filteredCountries = useMemo(() => {
if (regionId === "all" || !regionOptions.some((item) => item.countryCodes?.length)) {
return countryOptions;
@@ -44,7 +48,7 @@ export function DatabiHeader({
}, [countryOptions, regionId, regionOptions]);
const countryLabel = filteredCountries.find((item) => Number(item.id) === Number(countryId))?.label || "全部国家";
const activeRange = rangeOptions.find((item) => {
- const next = item.getRange();
+ const next = item.getRange(timeZone);
return sameRange(next, range);
});
@@ -77,6 +81,18 @@ export function DatabiHeader({
TZ}
+ isOpen={openControl === "timezone"}
+ label="时区"
+ onSelect={(value) => selectItem(onTimeZoneChange, value)}
+ onToggle={() => toggleControl("timezone")}
+ options={timeZoneOptions}
+ value={timeZone}
+ valueLabel={timeZoneLabel}
+ />
+ APP}
isOpen={openControl === "app"}
label="App"
@@ -93,6 +109,7 @@ export function DatabiHeader({
onSelect={(nextRange) => selectItem(onRangeChange, nextRange)}
onToggle={() => toggleControl("range")}
range={range}
+ timeZone={timeZone}
/>
}
@@ -122,9 +139,9 @@ export function DatabiHeader({
);
}
-function FilterMenu({ icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
+function FilterMenu({ className = "", icon, isOpen, label, loading = false, onSelect, onToggle, options, value, valueLabel }) {
return (
-
+