8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出 - AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表) - AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序 - AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它 - AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间 - 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列 - 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable - 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心 - 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染 - docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑 - 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
692 lines
19 KiB
TypeScript
692 lines
19 KiB
TypeScript
import {
|
|
AdminAlert,
|
|
AdminButton,
|
|
AdminEmptyState,
|
|
AdminField,
|
|
AdminPage,
|
|
AdminPageHeading,
|
|
AdminPanel,
|
|
} from '@genarrative/shared/components';
|
|
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 (
|
|
<AdminPage wide>
|
|
<AdminPageHeading
|
|
title="Dashboard"
|
|
description={dashboard?.range.periodLabel ?? '运营指标'}
|
|
actions={
|
|
<div className="admin-dashboard-actions">
|
|
<div className="admin-dashboard-period-fields">
|
|
<AdminField
|
|
label="起始日期"
|
|
className="admin-dashboard-date-field"
|
|
>
|
|
<input
|
|
type="date"
|
|
max={today}
|
|
value={dateRange.startDate}
|
|
onChange={(event) =>
|
|
handleDateRangeChange('startDate', event.target.value)
|
|
}
|
|
/>
|
|
</AdminField>
|
|
<AdminField
|
|
label="终止日期"
|
|
className="admin-dashboard-date-field"
|
|
>
|
|
<input
|
|
type="date"
|
|
max={today}
|
|
value={dateRange.endDate}
|
|
onChange={(event) =>
|
|
handleDateRangeChange('endDate', event.target.value)
|
|
}
|
|
/>
|
|
</AdminField>
|
|
</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>
|
|
<AdminButton
|
|
variant="secondary"
|
|
disabled={isLoading}
|
|
type="button"
|
|
onClick={() => void loadDashboard()}
|
|
>
|
|
<RefreshCw size={17} aria-hidden="true" />
|
|
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
|
</AdminButton>
|
|
</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 ? <AdminAlert>{errorMessage}</AdminAlert> : null}
|
|
|
|
{dashboard?.warnings.length ? (
|
|
<AdminPanel
|
|
tone="warning"
|
|
title="读取告警"
|
|
count={dashboard.warnings.length}
|
|
>
|
|
<ul className="admin-error-list">
|
|
{dashboard.warnings.map((message) => (
|
|
<li key={message}>{message}</li>
|
|
))}
|
|
</ul>
|
|
</AdminPanel>
|
|
) : null}
|
|
|
|
{activeTab === 'metrics' ? (
|
|
<>
|
|
<AdminPanel title="总计数据" count="全站累计">
|
|
<div className="admin-dashboard-metric-grid">
|
|
{totalMetricCards.map((metric) => (
|
|
<MetricCard
|
|
key={metric.id}
|
|
label={metric.label}
|
|
value={metric.value}
|
|
unit={metric.unit}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</AdminPanel>
|
|
|
|
<AdminPanel
|
|
title="时段数据"
|
|
count={dashboard?.range.periodLabel ?? '-'}
|
|
>
|
|
<div className="admin-dashboard-metric-grid">
|
|
{periodMetricCards.map((metric) => (
|
|
<MetricCard
|
|
key={metric.id}
|
|
label={metric.label}
|
|
value={metric.value}
|
|
unit={metric.unit}
|
|
compact
|
|
/>
|
|
))}
|
|
</div>
|
|
</AdminPanel>
|
|
|
|
<AdminPanel
|
|
title="新增用户转化与留存"
|
|
count={dashboard?.range.periodLabel ?? '-'}
|
|
>
|
|
<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>
|
|
</AdminPanel>
|
|
|
|
<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 ? (
|
|
<AdminEmptyState>暂无图表</AdminEmptyState>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="admin-dashboard-operations">
|
|
<AdminPanel
|
|
title="运营指标"
|
|
count={dashboard?.range.periodLabel ?? '-'}
|
|
>
|
|
<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>
|
|
</AdminPanel>
|
|
|
|
<BreakdownPanel
|
|
title="素材类型分布"
|
|
rows={dashboard?.operations.assetKindBreakdown ?? []}
|
|
unit="个"
|
|
/>
|
|
<BreakdownPanel
|
|
title="访问模块分布"
|
|
rows={dashboard?.operations.moduleVisitBreakdown ?? []}
|
|
unit="次"
|
|
/>
|
|
</div>
|
|
)}
|
|
</AdminPage>
|
|
);
|
|
|
|
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 (
|
|
<AdminPanel data-compact={compact} className="admin-dashboard-metric-card">
|
|
<span>{label}</span>
|
|
<strong>{formatNumber(value)}</strong>
|
|
<small>{unit}</small>
|
|
</AdminPanel>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<AdminPanel
|
|
title={chart.title}
|
|
count={
|
|
<>
|
|
{chart.id === 'visit-users' ? '时段去重 ' : ''}
|
|
{formatNumber(chart.total)} {chart.unit}
|
|
</>
|
|
}
|
|
className="admin-dashboard-chart-card"
|
|
>
|
|
<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>
|
|
</AdminPanel>
|
|
);
|
|
}
|
|
|
|
function BreakdownPanel({
|
|
title,
|
|
rows,
|
|
unit,
|
|
}: {
|
|
title: string;
|
|
rows: AdminDashboardBreakdownRowPayload[];
|
|
unit: string;
|
|
}) {
|
|
const maxValue = Math.max(1, ...rows.map((row) => row.value));
|
|
return (
|
|
<AdminPanel
|
|
title={title}
|
|
count={rows.length}
|
|
className="admin-dashboard-breakdown-panel"
|
|
>
|
|
<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 ? <AdminEmptyState>暂无数据</AdminEmptyState> : null}
|
|
</div>
|
|
</AdminPanel>
|
|
);
|
|
}
|
|
|
|
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}`;
|
|
}
|