mirror of
https://gitee.com/wanwujie/sub2api-mobile
synced 2026-04-03 15:02:13 +08:00
feat: refine admin UI and add EAS release workflow
This commit is contained in:
86
src/components/bar-chart-card.tsx
Normal file
86
src/components/bar-chart-card.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { CircleHelp } from 'lucide-react-native';
|
||||
import { useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
|
||||
type BarChartItem = {
|
||||
label: string;
|
||||
value: number;
|
||||
color?: string;
|
||||
meta?: string;
|
||||
hint?: string;
|
||||
};
|
||||
|
||||
type BarChartCardProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
items: BarChartItem[];
|
||||
formatValue?: (value: number) => string;
|
||||
};
|
||||
|
||||
export function BarChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
items,
|
||||
formatValue = (value) => `${value}`,
|
||||
}: BarChartCardProps) {
|
||||
const [activeHint, setActiveHint] = useState<string | null>(null);
|
||||
const maxValue = Math.max(...items.map((item) => item.value), 1);
|
||||
|
||||
return (
|
||||
<View className="rounded-[18px] bg-[#fbf8f2] p-4">
|
||||
<Text className="text-xs uppercase tracking-[1.6px] text-[#7d7468]">{title}</Text>
|
||||
<Text numberOfLines={1} className="mt-1 text-xs text-[#8a8072]">{subtitle}</Text>
|
||||
|
||||
<View className="mt-4 gap-3">
|
||||
{items.map((item) => {
|
||||
const barWidth = `${Math.max((item.value / maxValue) * 100, item.value > 0 ? 8 : 0)}%` as `${number}%`;
|
||||
|
||||
return (
|
||||
<View key={item.label} className="w-full">
|
||||
<View className="w-full flex-row items-center justify-between gap-3">
|
||||
<View className="flex-1 flex-row items-center gap-1.5 pr-3">
|
||||
<Text numberOfLines={1} className="text-sm font-semibold text-[#16181a]">
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.hint ? (
|
||||
<Pressable
|
||||
className="h-4 w-4 items-center justify-center rounded-full bg-[#efe7d9]"
|
||||
onPress={() => setActiveHint(activeHint === item.label ? null : item.label)}
|
||||
>
|
||||
<CircleHelp color="#7d7468" size={11} />
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
<Text className="text-sm font-semibold text-[#4e463e]">{formatValue(item.value)}</Text>
|
||||
</View>
|
||||
|
||||
{item.hint && activeHint === item.label ? (
|
||||
<View className="mt-2 rounded-[10px] bg-[#f1ece2] px-3 py-2">
|
||||
<Text className="text-[11px] leading-4 text-[#6f665c]">{item.hint}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<View className="mt-1 flex-row items-end justify-between gap-3">
|
||||
<View className="flex-1 pr-3">
|
||||
{item.meta ? <Text numberOfLines={1} className="text-[11px] text-[#7d7468]">{item.meta}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-2 h-[10px] overflow-hidden rounded-full bg-[#ece4d6]">
|
||||
<View
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: barWidth,
|
||||
backgroundColor: item.color || '#1d5f55',
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
|
||||
{items.length === 0 ? <Text className="text-sm text-[#7d7468]">暂无可视化数据</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
99
src/components/donut-chart-card.tsx
Normal file
99
src/components/donut-chart-card.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Text, View } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
|
||||
type DonutSegment = {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
};
|
||||
|
||||
type DonutChartCardProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
segments: DonutSegment[];
|
||||
centerLabel: string;
|
||||
centerValue: string;
|
||||
};
|
||||
|
||||
export function DonutChartCard({
|
||||
title,
|
||||
subtitle,
|
||||
segments,
|
||||
centerLabel,
|
||||
centerValue,
|
||||
}: DonutChartCardProps) {
|
||||
const total = Math.max(
|
||||
segments.reduce((sum, segment) => sum + segment.value, 0),
|
||||
1
|
||||
);
|
||||
const size = 152;
|
||||
const strokeWidth = 16;
|
||||
const radius = (size - strokeWidth) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<View className="rounded-[18px] bg-[#fbf8f2] p-4">
|
||||
<Text className="text-xs uppercase tracking-[1.6px] text-[#7d7468]">{title}</Text>
|
||||
<Text numberOfLines={1} className="mt-1 text-xs text-[#8a8072]">{subtitle}</Text>
|
||||
|
||||
<View className="mt-4 items-center justify-center">
|
||||
<View className="items-center justify-center">
|
||||
<Svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke="#ece4d6"
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
/>
|
||||
{segments.map((segment) => {
|
||||
const length = (segment.value / total) * circumference;
|
||||
const circleOffset = circumference - offset;
|
||||
offset += length;
|
||||
|
||||
return (
|
||||
<Circle
|
||||
key={segment.label}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={segment.color}
|
||||
strokeWidth={strokeWidth}
|
||||
fill="none"
|
||||
strokeDasharray={`${length} ${circumference - length}`}
|
||||
strokeDashoffset={circleOffset}
|
||||
strokeLinecap="round"
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Svg>
|
||||
|
||||
<View className="absolute items-center">
|
||||
<Text className="text-xs uppercase tracking-[1.4px] text-[#7d7468]">{centerLabel}</Text>
|
||||
<Text className="mt-1 text-[28px] font-bold text-[#16181a]">{centerValue}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mt-4 gap-2.5">
|
||||
{segments.map((segment) => {
|
||||
const percentage = Math.round((segment.value / total) * 100);
|
||||
|
||||
return (
|
||||
<View key={segment.label} className="flex-row items-center justify-between rounded-[12px] bg-[#f4efe4] px-3 py-2.5">
|
||||
<View className="flex-row items-center gap-3">
|
||||
<View className="h-3 w-3 rounded-full" style={{ backgroundColor: segment.color }} />
|
||||
<Text className="text-sm font-semibold text-[#16181a]">{segment.label}</Text>
|
||||
</View>
|
||||
<Text className="text-xs text-[#5d564d]">{segment.value} · {percentage}%</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ type LineTrendChartProps = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
formatValue?: (value: number) => string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function LineTrendChart({
|
||||
@@ -20,9 +21,10 @@ export function LineTrendChart({
|
||||
title,
|
||||
subtitle,
|
||||
formatValue = (value) => `${value}`,
|
||||
compact = false,
|
||||
}: LineTrendChartProps) {
|
||||
const width = 320;
|
||||
const height = 160;
|
||||
const height = compact ? 104 : 144;
|
||||
const maxValue = Math.max(...points.map((point) => point.value), 1);
|
||||
const minValue = Math.min(...points.map((point) => point.value), 0);
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
@@ -38,14 +40,17 @@ export function LineTrendChart({
|
||||
|
||||
const area = `${line} L ${width} ${height} L 0 ${height} Z`;
|
||||
const latest = points[points.length - 1]?.value ?? 0;
|
||||
const maxTicks = compact ? 6 : 7;
|
||||
const tickStep = Math.max(Math.ceil(points.length / maxTicks), 1);
|
||||
const tickPoints = points.filter((_, index) => index === 0 || index === points.length - 1 || index % tickStep === 0);
|
||||
|
||||
return (
|
||||
<View className="rounded-[28px] bg-[#fbf8f2] p-5">
|
||||
<View className="rounded-[18px] bg-[#fbf8f2] p-4">
|
||||
<Text className="text-xs uppercase tracking-[1.6px] text-[#7d7468]">{title}</Text>
|
||||
<Text className="mt-2 text-3xl font-bold text-[#16181a]">{formatValue(latest)}</Text>
|
||||
<Text className="mt-1 text-sm text-[#7d7468]">{subtitle}</Text>
|
||||
<Text className={`mt-1 font-bold text-[#16181a] ${compact ? 'text-[22px]' : 'text-[28px]'}`}>{formatValue(latest)}</Text>
|
||||
<Text numberOfLines={1} className="mt-1 text-xs text-[#8a8072]">{subtitle}</Text>
|
||||
|
||||
<View className="mt-5 overflow-hidden rounded-[20px] bg-[#f4efe4] p-3">
|
||||
<View className={`overflow-hidden rounded-[14px] bg-[#f4efe4] p-3 ${compact ? 'mt-3' : 'mt-4'}`}>
|
||||
<Svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`}>
|
||||
<Defs>
|
||||
<LinearGradient id="trendFill" x1="0" x2="0" y1="0" y2="1">
|
||||
@@ -57,9 +62,9 @@ export function LineTrendChart({
|
||||
<Path d={line} fill="none" stroke={color} strokeWidth="3" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</Svg>
|
||||
|
||||
<View className="mt-3 flex-row justify-between">
|
||||
{points.map((point) => (
|
||||
<Text key={point.label} className="text-xs text-[#7d7468]">
|
||||
<View className="mt-2 flex-row justify-between">
|
||||
{tickPoints.map((point) => (
|
||||
<Text key={point.label} className={`text-[#7d7468] ${compact ? 'text-[10px]' : 'text-xs'}`}>
|
||||
{point.label}
|
||||
</Text>
|
||||
))}
|
||||
|
||||
@@ -12,22 +12,22 @@ type ListCardProps = {
|
||||
|
||||
export function ListCard({ title, meta, badge, children, icon: Icon }: ListCardProps) {
|
||||
return (
|
||||
<View className="rounded-[24px] bg-[#fbf8f2] p-4">
|
||||
<View className="rounded-[16px] border border-[#efe7d9] bg-[#fbf8f2] p-3.5">
|
||||
<View className="flex-row items-start justify-between gap-3">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center gap-2">
|
||||
{Icon ? <Icon color="#7d7468" size={16} /> : null}
|
||||
<Text className="text-lg font-semibold text-[#16181a]">{title}</Text>
|
||||
<Text className="text-base font-semibold text-[#16181a]">{title}</Text>
|
||||
</View>
|
||||
{meta ? <Text className="mt-1 text-sm text-[#7d7468]">{meta}</Text> : null}
|
||||
{meta ? <Text numberOfLines={1} className="mt-1 text-xs text-[#7d7468]">{meta}</Text> : null}
|
||||
</View>
|
||||
{badge ? (
|
||||
<View className="rounded-full bg-[#e7dfcf] px-3 py-1">
|
||||
<Text className="text-xs font-semibold uppercase tracking-[1.2px] text-[#5d564d]">{badge}</Text>
|
||||
<View className="rounded-full bg-[#e7dfcf] px-2.5 py-1">
|
||||
<Text className="text-[10px] font-semibold uppercase tracking-[1px] text-[#5d564d]">{badge}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
{children ? <View className="mt-4">{children}</View> : null}
|
||||
{children ? <View className="mt-3">{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,23 +5,84 @@ import { ScrollView, Text, View } from 'react-native';
|
||||
type ScreenShellProps = PropsWithChildren<{
|
||||
title: string;
|
||||
subtitle: string;
|
||||
titleAside?: ReactNode;
|
||||
right?: ReactNode;
|
||||
variant?: 'card' | 'minimal';
|
||||
scroll?: boolean;
|
||||
bottomInsetClassName?: string;
|
||||
horizontalInsetClassName?: string;
|
||||
contentGapClassName?: string;
|
||||
}>;
|
||||
|
||||
export function ScreenShell({ title, subtitle, right, children }: ScreenShellProps) {
|
||||
function ScreenHeader({
|
||||
title,
|
||||
subtitle,
|
||||
titleAside,
|
||||
right,
|
||||
variant,
|
||||
}: Pick<ScreenShellProps, 'title' | 'subtitle' | 'titleAside' | 'right' | 'variant'>) {
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<View className="mt-4 flex-row items-start justify-between gap-4 px-1 py-1">
|
||||
<View className="flex-1">
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-[20px] font-bold tracking-tight text-[#16181a]">{title}</Text>
|
||||
{titleAside}
|
||||
</View>
|
||||
{subtitle ? (
|
||||
<Text numberOfLines={1} className="mt-1 text-[11px] leading-4 text-[#a2988a]">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
{right ? <View className="items-end justify-start">{right}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="mt-4 rounded-[24px] border border-[#e6dece] bg-[#fbf8f2] px-4 py-4">
|
||||
<View className="flex-row items-start justify-between gap-4">
|
||||
<View className="flex-1">
|
||||
<Text className="text-[24px] font-bold tracking-tight text-[#16181a]">{title}</Text>
|
||||
<Text numberOfLines={1} className="mt-1 text-xs leading-4 text-[#9a9082]">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</View>
|
||||
{right}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScreenShell({
|
||||
title,
|
||||
subtitle,
|
||||
titleAside,
|
||||
right,
|
||||
children,
|
||||
variant = 'card',
|
||||
scroll = true,
|
||||
bottomInsetClassName = 'pb-24',
|
||||
horizontalInsetClassName = 'px-5',
|
||||
contentGapClassName = 'mt-4 gap-4',
|
||||
}: ScreenShellProps) {
|
||||
if (!scroll) {
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-[#f4efe4]">
|
||||
<View className={`flex-1 ${horizontalInsetClassName} ${bottomInsetClassName}`}>
|
||||
<ScreenHeader title={title} subtitle={subtitle} titleAside={titleAside} right={right} variant={variant} />
|
||||
<View className={`flex-1 ${contentGapClassName}`}>{children}</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView className="flex-1 bg-[#f4efe4]">
|
||||
<ScrollView className="flex-1" contentContainerClassName="px-5 pb-24">
|
||||
<View className="mt-4 rounded-[24px] border border-[#e6dece] bg-[#fbf8f2] px-4 py-4">
|
||||
<View className="flex-row items-start justify-between gap-4">
|
||||
<View className="flex-1">
|
||||
<Text className="text-[24px] font-bold tracking-tight text-[#16181a]">{title}</Text>
|
||||
<Text className="mt-1 text-sm leading-6 text-[#7d7468]">{subtitle}</Text>
|
||||
</View>
|
||||
{right}
|
||||
</View>
|
||||
</View>
|
||||
<View className="mt-4 gap-4">{children}</View>
|
||||
<ScrollView className="flex-1" contentContainerClassName={`${horizontalInsetClassName} ${bottomInsetClassName}`}>
|
||||
<ScreenHeader title={title} subtitle={subtitle} titleAside={titleAside} right={right} variant={variant} />
|
||||
<View className={contentGapClassName}>{children}</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
13
src/hooks/use-debounced-value.ts
Normal file
13
src/hooks/use-debounced-value.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay = 250) {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => setDebouncedValue(value), delay);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [delay, value]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
15
src/hooks/use-screen-interactive.ts
Normal file
15
src/hooks/use-screen-interactive.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { markPerformance } from '@/src/lib/performance';
|
||||
|
||||
export function useScreenInteractive(
|
||||
name: 'login_interactive' | 'dashboard_interactive' | 'users_interactive' | 'monitor_interactive'
|
||||
) {
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
markPerformance(name);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [name]);
|
||||
}
|
||||
45
src/lib/formatters.ts
Normal file
45
src/lib/formatters.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export function formatCompactNumber(value: number, digits = 1) {
|
||||
const abs = Math.abs(value);
|
||||
|
||||
if (abs >= 1_000_000_000_000) {
|
||||
return `${(value / 1_000_000_000_000).toFixed(digits).replace(/\.0$/, '')}T`;
|
||||
}
|
||||
|
||||
if (abs >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(digits).replace(/\.0$/, '')}B`;
|
||||
}
|
||||
|
||||
if (abs >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(digits).replace(/\.0$/, '')}M`;
|
||||
}
|
||||
|
||||
if (abs >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(digits).replace(/\.0$/, '')}K`;
|
||||
}
|
||||
|
||||
return `${Math.round(value)}`;
|
||||
}
|
||||
|
||||
export function formatTokenValue(value: number) {
|
||||
return formatCompactNumber(value, 1);
|
||||
}
|
||||
|
||||
export function formatDisplayTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, '0');
|
||||
const day = `${date.getDate()}`.padStart(2, '0');
|
||||
const hours = `${date.getHours()}`.padStart(2, '0');
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
57
src/lib/performance.ts
Normal file
57
src/lib/performance.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
type PerfMarkName =
|
||||
| 'app_start'
|
||||
| 'config_hydrated'
|
||||
| 'login_interactive'
|
||||
| 'dashboard_interactive'
|
||||
| 'users_interactive'
|
||||
| 'monitor_interactive';
|
||||
|
||||
declare global {
|
||||
var __xSapiPerfStartedAt: number | undefined;
|
||||
var __xSapiPerfMarks: Partial<Record<PerfMarkName, number>> | undefined;
|
||||
}
|
||||
|
||||
function now() {
|
||||
if (typeof globalThis !== 'undefined' && globalThis.performance?.now) {
|
||||
return globalThis.performance.now();
|
||||
}
|
||||
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function ensureStore() {
|
||||
if (!globalThis.__xSapiPerfMarks) {
|
||||
globalThis.__xSapiPerfMarks = {};
|
||||
}
|
||||
|
||||
return globalThis.__xSapiPerfMarks;
|
||||
}
|
||||
|
||||
export function markAppStart() {
|
||||
if (!globalThis.__xSapiPerfStartedAt) {
|
||||
globalThis.__xSapiPerfStartedAt = now();
|
||||
ensureStore().app_start = globalThis.__xSapiPerfStartedAt;
|
||||
}
|
||||
}
|
||||
|
||||
export function markPerformance(name: PerfMarkName) {
|
||||
ensureStore()[name] = now();
|
||||
|
||||
if (__DEV__) {
|
||||
reportPerformance(name);
|
||||
}
|
||||
}
|
||||
|
||||
export function reportPerformance(name: PerfMarkName) {
|
||||
const startedAt = globalThis.__xSapiPerfStartedAt;
|
||||
const mark = ensureStore()[name];
|
||||
|
||||
if (!startedAt || !mark) {
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.round(mark - startedAt);
|
||||
console.info(`[perf] ${name}: ${duration}ms since app_start`);
|
||||
}
|
||||
|
||||
markAppStart();
|
||||
@@ -3,6 +3,61 @@ const { proxy } = require('valtio');
|
||||
|
||||
const BASE_URL_KEY = 'sub2api_base_url';
|
||||
const ADMIN_KEY_KEY = 'sub2api_admin_api_key';
|
||||
const ACCOUNTS_KEY = 'sub2api_accounts';
|
||||
const ACTIVE_ACCOUNT_ID_KEY = 'sub2api_active_account_id';
|
||||
|
||||
export type AdminAccountProfile = {
|
||||
id: string;
|
||||
label: string;
|
||||
baseUrl: string;
|
||||
adminApiKey: string;
|
||||
updatedAt: string;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
function createAccountId() {
|
||||
return `acct_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function getAccountLabel(baseUrl: string) {
|
||||
try {
|
||||
const url = new URL(baseUrl);
|
||||
return url.host || baseUrl;
|
||||
} catch {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(input: { baseUrl: string; adminApiKey: string }) {
|
||||
return {
|
||||
baseUrl: input.baseUrl.trim().replace(/\/$/, ''),
|
||||
adminApiKey: input.adminApiKey.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function sortAccounts(accounts: AdminAccountProfile[]) {
|
||||
return [...accounts].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
}
|
||||
|
||||
function normalizeAccount(account: AdminAccountProfile): AdminAccountProfile {
|
||||
return {
|
||||
...account,
|
||||
enabled: account.enabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
function getNextActiveAccount(accounts: AdminAccountProfile[], activeAccountId?: string) {
|
||||
const enabledAccounts = accounts.filter((account) => account.enabled !== false);
|
||||
|
||||
if (activeAccountId) {
|
||||
const preferred = enabledAccounts.find((account) => account.id === activeAccountId);
|
||||
if (preferred) {
|
||||
return preferred;
|
||||
}
|
||||
}
|
||||
|
||||
return enabledAccounts[0];
|
||||
}
|
||||
|
||||
export function getDefaultAdminConfig() {
|
||||
return {
|
||||
@@ -35,37 +90,197 @@ async function setItem(key: string, value: string) {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
}
|
||||
|
||||
async function deleteItem(key: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
|
||||
export const adminConfigState = proxy({
|
||||
...getDefaultAdminConfig(),
|
||||
accounts: [] as AdminAccountProfile[],
|
||||
activeAccountId: '',
|
||||
hydrated: false,
|
||||
saving: false,
|
||||
});
|
||||
|
||||
export async function hydrateAdminConfig() {
|
||||
const defaults = getDefaultAdminConfig();
|
||||
const [baseUrl, adminApiKey] = await Promise.all([
|
||||
const [baseUrl, adminApiKey, rawAccounts, activeAccountId] = await Promise.all([
|
||||
getItem(BASE_URL_KEY),
|
||||
getItem(ADMIN_KEY_KEY),
|
||||
getItem(ACCOUNTS_KEY),
|
||||
getItem(ACTIVE_ACCOUNT_ID_KEY),
|
||||
]);
|
||||
|
||||
adminConfigState.baseUrl = baseUrl ?? defaults.baseUrl;
|
||||
adminConfigState.adminApiKey = adminApiKey ?? defaults.adminApiKey;
|
||||
let accounts: AdminAccountProfile[] = [];
|
||||
|
||||
if (rawAccounts) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawAccounts) as AdminAccountProfile[];
|
||||
accounts = Array.isArray(parsed) ? parsed.map((account) => normalizeAccount(account)) : [];
|
||||
} catch {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0 && baseUrl) {
|
||||
const legacyConfig = normalizeConfig({
|
||||
baseUrl,
|
||||
adminApiKey: adminApiKey ?? defaults.adminApiKey,
|
||||
});
|
||||
|
||||
accounts = [
|
||||
{
|
||||
id: createAccountId(),
|
||||
label: getAccountLabel(legacyConfig.baseUrl),
|
||||
...legacyConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const sortedAccounts = sortAccounts(accounts);
|
||||
const activeAccount = getNextActiveAccount(sortedAccounts, activeAccountId ?? undefined);
|
||||
const nextActiveAccountId = activeAccount?.id || '';
|
||||
|
||||
adminConfigState.accounts = sortedAccounts;
|
||||
adminConfigState.activeAccountId = nextActiveAccountId;
|
||||
adminConfigState.baseUrl = activeAccount?.baseUrl ?? defaults.baseUrl;
|
||||
adminConfigState.adminApiKey = activeAccount?.adminApiKey ?? defaults.adminApiKey;
|
||||
|
||||
adminConfigState.hydrated = true;
|
||||
|
||||
await Promise.all([
|
||||
setItem(ACCOUNTS_KEY, JSON.stringify(sortedAccounts)),
|
||||
nextActiveAccountId ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccountId) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
|
||||
setItem(BASE_URL_KEY, activeAccount?.baseUrl ?? defaults.baseUrl),
|
||||
setItem(ADMIN_KEY_KEY, activeAccount?.adminApiKey ?? defaults.adminApiKey),
|
||||
]);
|
||||
}
|
||||
|
||||
export async function saveAdminConfig(input: { baseUrl: string; adminApiKey: string }) {
|
||||
adminConfigState.saving = true;
|
||||
|
||||
const nextBaseUrl = input.baseUrl.trim().replace(/\/$/, '');
|
||||
const nextAdminApiKey = input.adminApiKey.trim();
|
||||
|
||||
await Promise.all([
|
||||
setItem(BASE_URL_KEY, nextBaseUrl),
|
||||
setItem(ADMIN_KEY_KEY, nextAdminApiKey),
|
||||
const normalized = normalizeConfig(input);
|
||||
const nextUpdatedAt = new Date().toISOString();
|
||||
const existingAccount = adminConfigState.accounts.find(
|
||||
(account: AdminAccountProfile) => account.baseUrl === normalized.baseUrl && account.adminApiKey === normalized.adminApiKey
|
||||
);
|
||||
const nextAccount: AdminAccountProfile = existingAccount
|
||||
? {
|
||||
...existingAccount,
|
||||
label: getAccountLabel(normalized.baseUrl),
|
||||
updatedAt: nextUpdatedAt,
|
||||
}
|
||||
: {
|
||||
id: createAccountId(),
|
||||
label: getAccountLabel(normalized.baseUrl),
|
||||
...normalized,
|
||||
updatedAt: nextUpdatedAt,
|
||||
enabled: true,
|
||||
};
|
||||
const nextAccounts = sortAccounts([
|
||||
nextAccount,
|
||||
...adminConfigState.accounts.filter((account: AdminAccountProfile) => account.id !== nextAccount.id),
|
||||
]);
|
||||
|
||||
adminConfigState.baseUrl = nextBaseUrl;
|
||||
adminConfigState.adminApiKey = nextAdminApiKey;
|
||||
await Promise.all([
|
||||
setItem(BASE_URL_KEY, normalized.baseUrl),
|
||||
setItem(ADMIN_KEY_KEY, normalized.adminApiKey),
|
||||
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
|
||||
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
|
||||
]);
|
||||
|
||||
adminConfigState.accounts = nextAccounts;
|
||||
adminConfigState.activeAccountId = nextAccount.id;
|
||||
adminConfigState.baseUrl = normalized.baseUrl;
|
||||
adminConfigState.adminApiKey = normalized.adminApiKey;
|
||||
adminConfigState.saving = false;
|
||||
}
|
||||
|
||||
export async function switchAdminAccount(accountId: string) {
|
||||
const account = adminConfigState.accounts.find((item: AdminAccountProfile) => item.id === accountId);
|
||||
|
||||
if (!account) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (account.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextAccount = {
|
||||
...account,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
const nextAccounts = sortAccounts([
|
||||
nextAccount,
|
||||
...adminConfigState.accounts.filter((item: AdminAccountProfile) => item.id !== accountId),
|
||||
]);
|
||||
|
||||
await Promise.all([
|
||||
setItem(BASE_URL_KEY, nextAccount.baseUrl),
|
||||
setItem(ADMIN_KEY_KEY, nextAccount.adminApiKey),
|
||||
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
|
||||
setItem(ACTIVE_ACCOUNT_ID_KEY, nextAccount.id),
|
||||
]);
|
||||
|
||||
adminConfigState.accounts = nextAccounts;
|
||||
adminConfigState.activeAccountId = nextAccount.id;
|
||||
adminConfigState.baseUrl = nextAccount.baseUrl;
|
||||
adminConfigState.adminApiKey = nextAccount.adminApiKey;
|
||||
}
|
||||
|
||||
export async function removeAdminAccount(accountId: string) {
|
||||
const nextAccounts = adminConfigState.accounts.filter((item: AdminAccountProfile) => item.id !== accountId);
|
||||
const nextActiveAccount = getNextActiveAccount(nextAccounts, adminConfigState.activeAccountId === accountId ? '' : adminConfigState.activeAccountId);
|
||||
|
||||
await Promise.all([
|
||||
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
|
||||
nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
|
||||
setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''),
|
||||
setItem(ADMIN_KEY_KEY, nextActiveAccount?.adminApiKey ?? ''),
|
||||
]);
|
||||
|
||||
adminConfigState.accounts = nextAccounts;
|
||||
adminConfigState.activeAccountId = nextActiveAccount?.id ?? '';
|
||||
adminConfigState.baseUrl = nextActiveAccount?.baseUrl ?? '';
|
||||
adminConfigState.adminApiKey = nextActiveAccount?.adminApiKey ?? '';
|
||||
}
|
||||
|
||||
export async function logoutAdminAccount() {
|
||||
await Promise.all([setItem(BASE_URL_KEY, ''), setItem(ADMIN_KEY_KEY, ''), deleteItem(ACTIVE_ACCOUNT_ID_KEY)]);
|
||||
|
||||
adminConfigState.activeAccountId = '';
|
||||
adminConfigState.baseUrl = '';
|
||||
adminConfigState.adminApiKey = '';
|
||||
}
|
||||
|
||||
export async function setAdminAccountEnabled(accountId: string, enabled: boolean) {
|
||||
const nextAccounts = sortAccounts(
|
||||
adminConfigState.accounts.map((account: AdminAccountProfile) =>
|
||||
account.id === accountId ? { ...account, enabled, updatedAt: new Date().toISOString() } : account
|
||||
)
|
||||
);
|
||||
const nextActiveAccount = getNextActiveAccount(nextAccounts, enabled ? accountId : adminConfigState.activeAccountId);
|
||||
|
||||
await Promise.all([
|
||||
setItem(ACCOUNTS_KEY, JSON.stringify(nextAccounts)),
|
||||
nextActiveAccount ? setItem(ACTIVE_ACCOUNT_ID_KEY, nextActiveAccount.id) : deleteItem(ACTIVE_ACCOUNT_ID_KEY),
|
||||
setItem(BASE_URL_KEY, nextActiveAccount?.baseUrl ?? ''),
|
||||
setItem(ADMIN_KEY_KEY, nextActiveAccount?.adminApiKey ?? ''),
|
||||
]);
|
||||
|
||||
adminConfigState.accounts = nextAccounts;
|
||||
adminConfigState.activeAccountId = nextActiveAccount?.id ?? '';
|
||||
adminConfigState.baseUrl = nextActiveAccount?.baseUrl ?? '';
|
||||
adminConfigState.adminApiKey = nextActiveAccount?.adminApiKey ?? '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user