Fix Android WebView API access and mobile shell layout

This commit is contained in:
yoyuzh
2026-04-03 14:37:21 +08:00
parent f02ff9342f
commit 56f2a9fe0d
121 changed files with 4751 additions and 700 deletions

View File

@@ -3,6 +3,7 @@ import test from 'node:test';
import {
buildRequestLineChartModel,
buildRequestLineChartXAxisPoints,
formatMetricValue,
getInviteCodePanelState,
parseStorageLimitInput,
@@ -19,6 +20,7 @@ test('getInviteCodePanelState returns a copyable invite code when summary contai
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
dailyActiveUsers: [],
requestTimeline: [],
inviteCode: ' AbCd1234 ',
}),
@@ -40,6 +42,7 @@ test('getInviteCodePanelState falls back to a placeholder when summary has no in
transferUsageBytes: 0,
offlineTransferStorageBytes: 0,
offlineTransferStorageLimitBytes: 0,
dailyActiveUsers: [],
requestTimeline: [],
inviteCode: ' ',
}),
@@ -87,3 +90,38 @@ test('buildRequestLineChartModel converts hourly request data into chart coordin
assert.deepEqual(model.yAxisTicks, [0, 15, 30, 45, 60]);
assert.equal(model.peakPoint?.label, '02:00');
});
test('buildRequestLineChartModel stretches only the available hours across the chart width', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 2 },
{ hour: 1, label: '01:00', requestCount: 4 },
{ hour: 2, label: '02:00', requestCount: 3 },
{ hour: 3, label: '03:00', requestCount: 6 },
{ hour: 4, label: '04:00', requestCount: 5 },
{ hour: 5, label: '05:00', requestCount: 1 },
{ hour: 6, label: '06:00', requestCount: 2 },
{ hour: 7, label: '07:00', requestCount: 4 },
]);
assert.equal(model.points[0]?.x, 0);
assert.equal(model.points.at(-1)?.x, 100);
assert.equal(model.points.length, 8);
});
test('buildRequestLineChartXAxisPoints only shows elapsed-hour labels plus start and end', () => {
const model = buildRequestLineChartModel([
{ hour: 0, label: '00:00', requestCount: 2 },
{ hour: 1, label: '01:00', requestCount: 4 },
{ hour: 2, label: '02:00', requestCount: 3 },
{ hour: 3, label: '03:00', requestCount: 6 },
{ hour: 4, label: '04:00', requestCount: 5 },
{ hour: 5, label: '05:00', requestCount: 1 },
{ hour: 6, label: '06:00', requestCount: 2 },
{ hour: 7, label: '07:00', requestCount: 4 },
]);
assert.deepEqual(
buildRequestLineChartXAxisPoints(model.points).map((point) => point.label),
['00:00', '06:00', '07:00'],
);
});

View File

@@ -22,6 +22,7 @@ export interface RequestLineChartModel {
type MetricValueKind = 'bytes' | 'count';
const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const REQUEST_CHART_X_AXIS_HOURS = [0, 6, 12, 18, 23];
export function formatMetricValue(value: number, kind: MetricValueKind): string {
if (kind === 'count') {
@@ -103,6 +104,23 @@ export function buildRequestLineChartModel(timeline: AdminRequestTimelinePoint[]
};
}
export function buildRequestLineChartXAxisPoints(points: RequestLineChartPoint[]): RequestLineChartPoint[] {
if (points.length === 0) {
return [];
}
const firstHour = points[0]?.hour ?? 0;
const lastHour = points.at(-1)?.hour ?? firstHour;
const visibleHours = new Set<number>([firstHour, lastHour]);
for (const hour of REQUEST_CHART_X_AXIS_HOURS) {
if (hour > firstHour && hour < lastHour) {
visibleHours.add(hour);
}
}
return points.filter((point) => visibleHours.has(point.hour));
}
export function getInviteCodePanelState(summary: AdminSummary | null | undefined): InviteCodePanelState {
const inviteCode = summary?.inviteCode?.trim() ?? '';
if (!inviteCode) {

View File

@@ -14,7 +14,13 @@ import { useNavigate } from 'react-router-dom';
import { apiRequest } from '@/src/lib/api';
import { readStoredSession } from '@/src/lib/session';
import type { AdminOfflineTransferStorageLimitResponse, AdminSummary } from '@/src/lib/types';
import { buildRequestLineChartModel, formatMetricValue, getInviteCodePanelState, parseStorageLimitInput } from './dashboard-state';
import {
buildRequestLineChartModel,
buildRequestLineChartXAxisPoints,
formatMetricValue,
getInviteCodePanelState,
parseStorageLimitInput,
} from './dashboard-state';
interface DashboardState {
summary: AdminSummary | null;
@@ -30,7 +36,6 @@ interface MetricCardDefinition {
helper: string;
}
const REQUEST_CHART_X_AXIS_HOURS = new Set([0, 6, 12, 18, 23]);
const DASHBOARD_CARD_BG = '#111827';
const DASHBOARD_CARD_BORDER = 'rgba(148, 163, 184, 0.22)';
const DASHBOARD_CARD_TEXT = '#f8fafc';
@@ -129,7 +134,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
const chart = buildRequestLineChartModel(summary.requestTimeline);
const currentHour = new Date().getHours();
const currentPoint = chart.points.find((point) => point.hour === currentHour) ?? chart.points.at(-1) ?? null;
const xAxisPoints = chart.points.filter((point) => REQUEST_CHART_X_AXIS_HOURS.has(point.hour));
const xAxisPoints = buildRequestLineChartXAxisPoints(chart.points);
const hasRequests = chart.maxValue > 0;
const scaleMax = chart.maxValue > 0 ? chart.maxValue : 4;
@@ -161,7 +166,7 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
`/api/**` 便
`/api/**` 线
</Typography>
</Stack>
@@ -340,21 +345,26 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
vectorEffect="non-scaling-stroke"
/>
)}
{chart.points.map((point) => (
<circle
key={point.label}
cx={point.x}
cy={point.y}
r={point.hour === currentPoint?.hour ? 2.35 : 1.45}
fill={point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb'}
stroke="#ffffff"
strokeWidth="1.2"
vectorEffect="non-scaling-stroke"
/>
))}
</Box>
{chart.points.map((point) => (
<Box
key={point.label}
sx={{
position: 'absolute',
left: `${point.x}%`,
top: `${point.y}%`,
width: point.hour === currentPoint?.hour ? 8 : 6,
height: point.hour === currentPoint?.hour ? 8 : 6,
borderRadius: '50%',
backgroundColor: point.hour === currentPoint?.hour ? '#0f172a' : '#2563eb',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
zIndex: 1,
}}
/>
))}
{!hasRequests && (
<Stack
spacing={0.4}
@@ -405,6 +415,141 @@ function RequestTrendChart({ summary }: { summary: AdminSummary }) {
);
}
function DailyActiveUsersCard({ summary }: { summary: AdminSummary }) {
const latestDay = summary.dailyActiveUsers.at(-1) ?? null;
return (
<Card
variant="outlined"
sx={(theme) => ({
borderColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BORDER : 'divider',
backgroundColor: theme.palette.mode === 'dark' ? DASHBOARD_CARD_BG : '#fff',
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : theme.palette.text.primary,
boxShadow: theme.palette.mode === 'dark' ? '0 20px 45px rgba(15, 23, 42, 0.28)' : 'none',
})}
>
<CardContent>
<Stack spacing={2}>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={1.5}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', md: 'center' }}
>
<Stack spacing={0.75}>
<Typography variant="h6" fontWeight={700}>
7 线
</Typography>
<Typography
variant="body2"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
JWT 线 7 便线
</Typography>
</Stack>
<Stack
spacing={0.35}
sx={{
minWidth: 156,
px: 1.5,
py: 1.25,
borderRadius: 2,
backgroundColor: (theme) => theme.palette.mode === 'dark' ? 'rgba(16, 185, 129, 0.12)' : '#ecfdf5',
border: (theme) => theme.palette.mode === 'dark' ? '1px solid rgba(52, 211, 153, 0.18)' : '1px solid transparent',
}}
>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
fontWeight={700}
>
线
</Typography>
<Typography
variant="h6"
fontWeight={800}
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
})}
>
{formatMetricValue(latestDay?.userCount ?? 0, 'count')}
</Typography>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{latestDay?.label ?? '--'}
</Typography>
</Stack>
</Stack>
<Stack spacing={1.2}>
{summary.dailyActiveUsers.slice().reverse().map((day) => (
<Box
key={day.metricDate}
sx={(theme) => ({
px: 1.5,
py: 1.25,
borderRadius: 2,
border: theme.palette.mode === 'dark' ? '1px solid rgba(148, 163, 184, 0.16)' : '1px solid rgba(148, 163, 184, 0.24)',
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(255, 255, 255, 0.03)' : '#f8fafc',
})}
>
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={1.25}
justifyContent="space-between"
alignItems={{ xs: 'flex-start', md: 'center' }}
>
<Stack direction="row" spacing={1} alignItems="center" flexWrap="wrap" useFlexGap>
<Typography fontWeight={700}>{day.label}</Typography>
<Typography
variant="caption"
sx={(theme) => ({
px: 0.9,
py: 0.3,
borderRadius: 99,
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_TEXT : 'text.primary',
backgroundColor: theme.palette.mode === 'dark' ? 'rgba(59, 130, 246, 0.18)' : '#dbeafe',
})}
>
{formatMetricValue(day.userCount, 'count')}
</Typography>
<Typography
variant="caption"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{day.metricDate}
</Typography>
</Stack>
<Typography
variant="body2"
sx={(theme) => ({
color: theme.palette.mode === 'dark' ? DASHBOARD_CARD_MUTED_TEXT : 'text.secondary',
})}
>
{day.usernames.length > 0 ? day.usernames.join('、') : '当天无人上线'}
</Typography>
</Stack>
</Box>
))}
</Stack>
</Stack>
</CardContent>
</Card>
);
}
export function PortalAdminDashboard() {
const [state, setState] = useState<DashboardState>({
summary: null,
@@ -586,7 +731,12 @@ export function PortalAdminDashboard() {
))}
</Grid>
{summary && <RequestTrendChart summary={summary} />}
{summary && (
<Stack spacing={2}>
<RequestTrendChart summary={summary} />
<DailyActiveUsersCard summary={summary} />
</Stack>
)}
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 4 }}>