0dbd279dfc
Project CI / AI game creator shell Rust crates (push) Successful in 1m22s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m1s
Project CI / Backend tests (push) Successful in 5m16s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 6m42s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m21s
Project CI / Native shell tests (push) Successful in 6m43s
Project CI / Frontend tests (push) Successful in 2m22s
Project CI / Repository checks (push) Successful in 2m45s
Project CI / AI game creator shell web tests (push) Successful in 2m7s
- consumedMudPoints 改为净额:按北京时间业务日先抵当日消耗,不足再回溯抵扣最近仍有净额的业务日,抵扣不完的丢弃,每日净额非负且区间合计严格等于「消耗 − 退还」 - 退还口径并入 asset_operation_refund 正向流水(生成失败退还、精选审核返还)与 llm_router_consume 正向冲正流水;充值退款追回、余额重置、赠送和 hold 仍不计入 - 契约新增 refundedMudPoints,后端新增「退还泥点」趋势图,后台时段数据并列「退还泥点数」卡,毛消耗可由「消耗 + 退还」核出 - 新增 4 条净额单测(同日对冲 / 回溯抵扣 / 超额归零 / 无退还保原值)与 Dashboard 用例断言 - 同步 Dashboard 运营看板方案指标口径与决策记录 - 验证:cargo test -p api-server --bin api-server admin(136 passed / 0 failed / 1 ignored)、npm run admin-web:typecheck、npx vitest run apps/admin-web/src(218 passed)、cargo fmt --all --check、npm run check:encoding、npm run check:doc-index、git diff --check 全部通过
689 lines
19 KiB
TypeScript
689 lines
19 KiB
TypeScript
import { RefreshCw } from 'lucide-react';
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { getAdminDashboard } from '../api/adminApiClient';
|
|
import type {
|
|
AdminDashboardBreakdownRowPayload,
|
|
AdminDashboardChartPayload,
|
|
AdminDashboardGranularity,
|
|
AdminDashboardResponse,
|
|
} from '../api/adminApiTypes';
|
|
import { handlePageError } from './pageUtils';
|
|
|
|
interface AdminDashboardPageProps {
|
|
token: string;
|
|
onUnauthorized: (message?: string) => void;
|
|
}
|
|
|
|
type AdminDashboardTab = 'metrics' | 'operations';
|
|
|
|
const granularityOptions: Array<{
|
|
id: Exclude<AdminDashboardGranularity, 'period'>;
|
|
label: string;
|
|
}> = [
|
|
{ id: 'day', label: '本日' },
|
|
{ id: 'week', label: '本周' },
|
|
{ id: 'month', label: '本月' },
|
|
];
|
|
|
|
export function AdminDashboardPage({
|
|
token,
|
|
onUnauthorized,
|
|
}: AdminDashboardPageProps) {
|
|
const [dashboard, setDashboard] = useState<AdminDashboardResponse | null>(
|
|
null,
|
|
);
|
|
const [granularity, setGranularity] =
|
|
useState<AdminDashboardGranularity>('day');
|
|
const [dateRange, setDateRange] = useState(() => {
|
|
const today = formatBeijingDateInput(new Date());
|
|
return { startDate: today, endDate: today };
|
|
});
|
|
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [chartScrollLeft, setChartScrollLeft] = useState(0);
|
|
const today = formatBeijingDateInput(new Date());
|
|
|
|
const loadDashboard = useCallback(
|
|
async (range = dateRange) => {
|
|
setIsLoading(true);
|
|
setErrorMessage('');
|
|
try {
|
|
const response = await getAdminDashboard(token, {
|
|
granularity: 'period',
|
|
anchor: undefined,
|
|
startDate: range.startDate,
|
|
endDate: range.endDate,
|
|
});
|
|
setDashboard(response);
|
|
} catch (error: unknown) {
|
|
handlePageError(error, onUnauthorized, setErrorMessage);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
},
|
|
[dateRange, onUnauthorized, token],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void loadDashboard();
|
|
}, [loadDashboard]);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setInterval(
|
|
() => {
|
|
void loadDashboard();
|
|
},
|
|
5 * 60 * 1000,
|
|
);
|
|
return () => window.clearInterval(timer);
|
|
}, [loadDashboard]);
|
|
|
|
useEffect(() => {
|
|
setChartScrollLeft(0);
|
|
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
|
|
|
|
const metrics = dashboard?.metrics;
|
|
const totalMetricCards = useMemo(
|
|
() => [
|
|
{
|
|
id: 'total-registered-users',
|
|
label: '总注册用户',
|
|
value: metrics?.totalRegisteredUsers ?? 0,
|
|
unit: '人',
|
|
},
|
|
{
|
|
id: 'total-visit-users',
|
|
label: '总访问人数',
|
|
value: metrics?.totalVisitUsers ?? 0,
|
|
unit: '人',
|
|
},
|
|
{
|
|
id: 'total-visit-count',
|
|
label: '总访问次数',
|
|
value: metrics?.totalVisitCount ?? 0,
|
|
unit: '次',
|
|
},
|
|
{
|
|
id: 'current-users',
|
|
label: '近 5 分钟活跃用户',
|
|
value: metrics?.currentUsers ?? 0,
|
|
unit: '人',
|
|
},
|
|
],
|
|
[metrics],
|
|
);
|
|
const periodMetricCards = useMemo(
|
|
() => [
|
|
{
|
|
id: 'generated-assets',
|
|
label: `${rangePrefix(granularity)}生产素材数`,
|
|
value: metrics?.generatedAssets ?? 0,
|
|
unit: '个',
|
|
},
|
|
{
|
|
id: 'consumed-mud-points',
|
|
label: `${rangePrefix(granularity)}消耗泥点数`,
|
|
value: metrics?.consumedMudPoints ?? 0,
|
|
unit: '泥点',
|
|
},
|
|
{
|
|
id: 'refunded-mud-points',
|
|
label: `${rangePrefix(granularity)}退还泥点数`,
|
|
value: metrics?.refundedMudPoints ?? 0,
|
|
unit: '泥点',
|
|
},
|
|
{
|
|
id: 'new-registered-users',
|
|
label: `${rangePrefix(granularity)}新增用户数`,
|
|
value: metrics?.newRegisteredUsers ?? 0,
|
|
unit: '人',
|
|
},
|
|
{
|
|
id: 'visit-users',
|
|
label: `${rangePrefix(granularity)}访问人数`,
|
|
value: metrics?.visitUsers ?? 0,
|
|
unit: '人',
|
|
},
|
|
{
|
|
id: 'visit-count',
|
|
label: `${rangePrefix(granularity)}访问次数`,
|
|
value: metrics?.visitCount ?? 0,
|
|
unit: '次',
|
|
},
|
|
],
|
|
[granularity, metrics],
|
|
);
|
|
|
|
return (
|
|
<section className="admin-page admin-page-wide">
|
|
<div className="admin-page-heading admin-dashboard-heading">
|
|
<div>
|
|
<h2>Dashboard</h2>
|
|
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
|
|
</div>
|
|
<div className="admin-dashboard-actions">
|
|
<div className="admin-dashboard-period-fields">
|
|
<label className="admin-field admin-dashboard-date-field">
|
|
<span>起始日期</span>
|
|
<input
|
|
type="date"
|
|
max={today}
|
|
value={dateRange.startDate}
|
|
onChange={(event) =>
|
|
handleDateRangeChange('startDate', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="admin-field admin-dashboard-date-field">
|
|
<span>终止日期</span>
|
|
<input
|
|
type="date"
|
|
max={today}
|
|
value={dateRange.endDate}
|
|
onChange={(event) =>
|
|
handleDateRangeChange('endDate', event.target.value)
|
|
}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div
|
|
className="admin-segmented-control admin-dashboard-granularity"
|
|
role="tablist"
|
|
aria-label="统计周期"
|
|
>
|
|
{granularityOptions.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
data-active={option.id === granularity}
|
|
onClick={() => handleGranularityChange(option.id)}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button
|
|
className="admin-secondary-button"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={() => void loadDashboard()}
|
|
>
|
|
<RefreshCw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
className="admin-dashboard-tabs"
|
|
role="tablist"
|
|
aria-label="Dashboard"
|
|
>
|
|
<button
|
|
type="button"
|
|
data-active={activeTab === 'metrics'}
|
|
onClick={() => setActiveTab('metrics')}
|
|
>
|
|
数据看板
|
|
</button>
|
|
<button
|
|
type="button"
|
|
data-active={activeTab === 'operations'}
|
|
onClick={() => setActiveTab('operations')}
|
|
>
|
|
运营汇总
|
|
</button>
|
|
</div>
|
|
|
|
{errorMessage ? (
|
|
<div className="admin-alert" role="status">
|
|
{errorMessage}
|
|
</div>
|
|
) : null}
|
|
|
|
{dashboard?.warnings.length ? (
|
|
<section className="admin-panel admin-panel-warning">
|
|
<div className="admin-panel-heading">
|
|
<h3>读取告警</h3>
|
|
<span>{dashboard.warnings.length}</span>
|
|
</div>
|
|
<ul className="admin-error-list">
|
|
{dashboard.warnings.map((message) => (
|
|
<li key={message}>{message}</li>
|
|
))}
|
|
</ul>
|
|
</section>
|
|
) : null}
|
|
|
|
{activeTab === 'metrics' ? (
|
|
<>
|
|
<section className="admin-panel">
|
|
<div className="admin-panel-heading">
|
|
<h3>总计数据</h3>
|
|
<span>全站累计</span>
|
|
</div>
|
|
<div className="admin-dashboard-metric-grid">
|
|
{totalMetricCards.map((metric) => (
|
|
<MetricCard
|
|
key={metric.id}
|
|
label={metric.label}
|
|
value={metric.value}
|
|
unit={metric.unit}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="admin-panel">
|
|
<div className="admin-panel-heading">
|
|
<h3>时段数据</h3>
|
|
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
|
</div>
|
|
<div className="admin-dashboard-metric-grid">
|
|
{periodMetricCards.map((metric) => (
|
|
<MetricCard
|
|
key={metric.id}
|
|
label={metric.label}
|
|
value={metric.value}
|
|
unit={metric.unit}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="admin-panel">
|
|
<div className="admin-panel-heading">
|
|
<h3>新增用户转化与留存</h3>
|
|
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
|
</div>
|
|
<div className="admin-dashboard-retention-grid">
|
|
<RateCard
|
|
label={`${rangePrefix(granularity)}新增用户付费率`}
|
|
numeratorLabel="付费人数"
|
|
denominatorLabel="新增人数"
|
|
numerator={metrics?.newUserPaymentConversion?.paidUsers}
|
|
denominator={
|
|
metrics?.newUserPaymentConversion?.newRegisteredUsers
|
|
}
|
|
rateBasisPoints={
|
|
metrics?.newUserPaymentConversion?.rateBasisPoints
|
|
}
|
|
/>
|
|
<RateCard
|
|
label="次日留存"
|
|
numeratorLabel="留存人数"
|
|
denominatorLabel="可观察新增人数"
|
|
numerator={metrics?.day1Retention.retainedUsers}
|
|
denominator={metrics?.day1Retention.eligibleUsers}
|
|
rateBasisPoints={metrics?.day1Retention.rateBasisPoints}
|
|
/>
|
|
<RateCard
|
|
label="七日留存"
|
|
numeratorLabel="留存人数"
|
|
denominatorLabel="可观察新增人数"
|
|
numerator={metrics?.day7Retention.retainedUsers}
|
|
denominator={metrics?.day7Retention.eligibleUsers}
|
|
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="admin-dashboard-chart-grid">
|
|
{(dashboard?.charts ?? []).map((chart) => (
|
|
<ChartPanel
|
|
key={chart.id}
|
|
chart={chart}
|
|
scrollLeft={chartScrollLeft}
|
|
onScrollLeftChange={setChartScrollLeft}
|
|
/>
|
|
))}
|
|
{dashboard && dashboard.charts.length === 0 ? (
|
|
<div className="admin-empty-state">暂无图表</div>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="admin-dashboard-operations">
|
|
<section className="admin-panel">
|
|
<div className="admin-panel-heading">
|
|
<h3>运营指标</h3>
|
|
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
|
</div>
|
|
<div className="admin-dashboard-operation-grid">
|
|
{(dashboard?.operations.cards ?? []).map((card) => (
|
|
<MetricCard
|
|
key={card.id}
|
|
label={card.label}
|
|
value={card.value}
|
|
unit={card.unit}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</section>
|
|
|
|
<BreakdownPanel
|
|
title="素材类型分布"
|
|
rows={dashboard?.operations.assetKindBreakdown ?? []}
|
|
unit="个"
|
|
/>
|
|
<BreakdownPanel
|
|
title="访问模块分布"
|
|
rows={dashboard?.operations.moduleVisitBreakdown ?? []}
|
|
unit="次"
|
|
/>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
|
|
function handleGranularityChange(
|
|
nextGranularity: Exclude<AdminDashboardGranularity, 'period'>,
|
|
) {
|
|
const nextRange = buildPresetDateRange(
|
|
nextGranularity,
|
|
formatBeijingDateInput(new Date()),
|
|
);
|
|
const rangeChanged =
|
|
nextRange.startDate !== dateRange.startDate ||
|
|
nextRange.endDate !== dateRange.endDate;
|
|
setGranularity(nextGranularity);
|
|
if (rangeChanged) {
|
|
setDateRange(nextRange);
|
|
} else {
|
|
void loadDashboard(nextRange);
|
|
}
|
|
}
|
|
|
|
function handleDateRangeChange(
|
|
field: 'startDate' | 'endDate',
|
|
value: string,
|
|
) {
|
|
if (!parseDateValueAsUtc(value)) {
|
|
return;
|
|
}
|
|
const clampedValue = value > today ? today : value;
|
|
setGranularity('period');
|
|
setDateRange((current) =>
|
|
normalizeDateRange({ ...current, [field]: clampedValue }),
|
|
);
|
|
}
|
|
}
|
|
|
|
function MetricCard({
|
|
label,
|
|
value,
|
|
unit,
|
|
compact = false,
|
|
}: {
|
|
label: string;
|
|
value: number;
|
|
unit: string;
|
|
compact?: boolean;
|
|
}) {
|
|
return (
|
|
<section
|
|
className="admin-panel admin-dashboard-metric-card"
|
|
data-compact={compact}
|
|
>
|
|
<span>{label}</span>
|
|
<strong>{formatNumber(value)}</strong>
|
|
<small>{unit}</small>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function RateCard({
|
|
label,
|
|
numeratorLabel,
|
|
denominatorLabel,
|
|
numerator,
|
|
denominator,
|
|
rateBasisPoints,
|
|
}: {
|
|
label: string;
|
|
numeratorLabel: string;
|
|
denominatorLabel: string;
|
|
numerator?: number;
|
|
denominator?: number;
|
|
rateBasisPoints?: number;
|
|
}) {
|
|
const hasDenominator = Boolean(denominator);
|
|
return (
|
|
<article className="admin-dashboard-retention-card">
|
|
<span>{label}</span>
|
|
<strong>
|
|
{hasDenominator && rateBasisPoints !== undefined
|
|
? formatRateBasisPoints(rateBasisPoints)
|
|
: '-'}
|
|
</strong>
|
|
<div>
|
|
<small>
|
|
{numeratorLabel} / {denominatorLabel}
|
|
</small>
|
|
<b>
|
|
{numerator !== undefined && denominator !== undefined
|
|
? `${formatNumber(numerator)} / ${formatNumber(denominator)} 人`
|
|
: '-'}
|
|
</b>
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function ChartPanel({
|
|
chart,
|
|
scrollLeft,
|
|
onScrollLeftChange,
|
|
}: {
|
|
chart: AdminDashboardChartPayload;
|
|
scrollLeft: number;
|
|
onScrollLeftChange: (scrollLeft: number) => void;
|
|
}) {
|
|
const barsRef = useRef<HTMLDivElement>(null);
|
|
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
|
|
|
|
useEffect(() => {
|
|
const element = barsRef.current;
|
|
if (!element) {
|
|
return;
|
|
}
|
|
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
|
|
element.scrollLeft = scrollLeft;
|
|
}
|
|
}, [chart.buckets.length, scrollLeft]);
|
|
|
|
return (
|
|
<section className="admin-panel admin-dashboard-chart-card">
|
|
<div className="admin-panel-heading">
|
|
<h3>{chart.title}</h3>
|
|
<span>
|
|
{chart.id === 'visit-users' ? '时段去重 ' : ''}
|
|
{formatNumber(chart.total)} {chart.unit}
|
|
</span>
|
|
</div>
|
|
<div
|
|
ref={barsRef}
|
|
className="admin-dashboard-bars"
|
|
role="region"
|
|
aria-label={`${chart.title}趋势图`}
|
|
tabIndex={0}
|
|
onScroll={(event) => {
|
|
onScrollLeftChange(event.currentTarget.scrollLeft);
|
|
}}
|
|
>
|
|
{chart.buckets.map((bucket) => (
|
|
<div className="admin-dashboard-bar-item" key={bucket.key}>
|
|
<div
|
|
className="admin-dashboard-bar-track"
|
|
title={`${bucket.key}: ${formatNumber(bucket.value)} ${chart.unit}`}
|
|
>
|
|
<span
|
|
style={{
|
|
height:
|
|
bucket.value === 0
|
|
? '0%'
|
|
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
<small>{bucket.label}</small>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function BreakdownPanel({
|
|
title,
|
|
rows,
|
|
unit,
|
|
}: {
|
|
title: string;
|
|
rows: AdminDashboardBreakdownRowPayload[];
|
|
unit: string;
|
|
}) {
|
|
const maxValue = Math.max(1, ...rows.map((row) => row.value));
|
|
return (
|
|
<section className="admin-panel admin-dashboard-breakdown-panel">
|
|
<div className="admin-panel-heading">
|
|
<h3>{title}</h3>
|
|
<span>{rows.length}</span>
|
|
</div>
|
|
<div className="admin-dashboard-breakdown-list">
|
|
{rows.map((row) => (
|
|
<div className="admin-dashboard-breakdown-row" key={row.key}>
|
|
<div>
|
|
<strong>{row.label}</strong>
|
|
<span>
|
|
{formatNumber(row.value)} {unit}
|
|
</span>
|
|
</div>
|
|
<div className="admin-dashboard-breakdown-track">
|
|
<span style={{ width: `${(row.value / maxValue) * 100}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
{rows.length === 0 ? (
|
|
<div className="admin-empty-state">暂无数据</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function formatNumber(value: number) {
|
|
return new Intl.NumberFormat('zh-CN').format(value);
|
|
}
|
|
|
|
function formatRateBasisPoints(value: number) {
|
|
return new Intl.NumberFormat('zh-CN', {
|
|
style: 'percent',
|
|
maximumFractionDigits: 2,
|
|
}).format(value / 10_000);
|
|
}
|
|
|
|
function formatBeijingDateInput(date: Date) {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'Asia/Shanghai',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(date);
|
|
}
|
|
|
|
function rangePrefix(granularity: AdminDashboardGranularity) {
|
|
if (granularity === 'week') {
|
|
return '本周';
|
|
}
|
|
if (granularity === 'month') {
|
|
return '本月';
|
|
}
|
|
if (granularity === 'period') {
|
|
return '本时段';
|
|
}
|
|
return '本日';
|
|
}
|
|
|
|
function buildPresetDateRange(
|
|
granularity: AdminDashboardGranularity,
|
|
selectedDate: string,
|
|
) {
|
|
const fallbackDate = formatBeijingDateInput(new Date());
|
|
const anchor = parseDateValueAsUtc(selectedDate)
|
|
? selectedDate
|
|
: fallbackDate;
|
|
if (granularity === 'week') {
|
|
return buildWeekDateRange(anchor);
|
|
}
|
|
if (granularity === 'month') {
|
|
return buildMonthDateRange(anchor);
|
|
}
|
|
return { startDate: anchor, endDate: anchor };
|
|
}
|
|
|
|
function buildWeekDateRange(dateValue: string) {
|
|
const date = parseDateValueAsUtc(dateValue);
|
|
if (!date) {
|
|
return { startDate: dateValue, endDate: dateValue };
|
|
}
|
|
|
|
const weekday = date.getUTCDay() || 7;
|
|
const monday = new Date(date);
|
|
monday.setUTCDate(date.getUTCDate() - weekday + 1);
|
|
return { startDate: formatUtcDate(monday), endDate: dateValue };
|
|
}
|
|
|
|
function buildMonthDateRange(dateValue: string) {
|
|
const date = parseDateValueAsUtc(dateValue);
|
|
if (!date) {
|
|
return { startDate: dateValue, endDate: dateValue };
|
|
}
|
|
|
|
const firstDate = new Date(
|
|
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
|
|
);
|
|
return {
|
|
startDate: formatUtcDate(firstDate),
|
|
endDate: dateValue,
|
|
};
|
|
}
|
|
|
|
function normalizeDateRange(range: { startDate: string; endDate: string }) {
|
|
return range.startDate > range.endDate
|
|
? { startDate: range.endDate, endDate: range.startDate }
|
|
: range;
|
|
}
|
|
|
|
function parseDateValueAsUtc(value: string) {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const year = Number(match[1]);
|
|
const month = Number(match[2]);
|
|
const day = Number(match[3]);
|
|
const date = new Date(Date.UTC(year, month - 1, day));
|
|
if (
|
|
date.getUTCFullYear() !== year ||
|
|
date.getUTCMonth() + 1 !== month ||
|
|
date.getUTCDate() !== day
|
|
) {
|
|
return null;
|
|
}
|
|
return date;
|
|
}
|
|
|
|
function formatUtcDate(date: Date) {
|
|
const year = date.getUTCFullYear();
|
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
return `${year}-${month}-${day}`;
|
|
}
|