Settings hub with appearance, accounts, alerts and data pages; relay shown as 中转站; hint to set a quota on unlimited relay keys

This commit is contained in:
Jude.Chen committed 2026-10-07 03:49:43 +08:00
1 parent dec7f5ddaa
commit f6eedbbe7b
11 files changed
+460 -258

No files matched your search

+81 -256
View File
@@ -1,79 +1,95 @@
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { Alert, Linking, Platform, View } from 'react-native';
import { useRouter, type Href } from 'expo-router';
import { SymbolView, type SymbolViewProps } from 'expo-symbols';
import { Linking, View } from 'react-native';
import Animated from 'react-native-reanimated';
import { Screen } from '@/components/screen';
import { Section, Row } from '@/components/section';
import { Row, Section } from '@/components/section';
import { ScreenHeader } from '@/components/screen-header';
import { ThemedText } from '@/components/themed-text';
import { Accents, Radius, Spacing, type AccentName } from '@/constants/theme';
import {
useAccountActions,
useAccountViews,
useAlertSettings,
useBackgroundState,
useNotificationPermission,
} from '@/data/hooks';
import { Radius, Spacing } from '@/constants/theme';
import { useAccountViews, useAlertSettings } from '@/data/hooks';
import { useTheme } from '@/hooks/use-theme';
import { useT, type LocaleSetting, type ThemeSetting } from '@/i18n';
import { useT } from '@/i18n';
import { usePreferences } from '@/providers/preferences';
import { Button, ColorSwatches, Segmented } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
import { ProviderIcon } from '@/ui/provider-icon';
import { MultiChips, ToggleRow } from '@/ui/toggles';
import { Button } from '@/ui/controls';
import { enterItem } from '@/ui/motion';
import { ACCENT_LABELS } from '@/ui/settings-labels';
const GITHUB_HANDLE = 'nextroad-dev';
const GITHUB_URL = 'https://github.com/nextroad-dev';
const SPONSOR_URL = 'https://afdian.com/a/nextroad';
/** Spoken and shown names of the theme colours (English keys, translated with t()). */
const ACCENT_LABELS: Record<AccentName, string> = {
blue: 'Blue',
indigo: 'Indigo',
violet: 'Violet',
pink: 'Pink',
orange: 'Orange',
green: 'Green',
teal: 'Teal',
};
/** Small filled square with a white symbol, the familiar settings-menu affordance. */
function MenuIcon({ name }: { name: SymbolViewProps['name'] }) {
const theme = useTheme();
return (
<View
style={{
width: 30,
height: 30,
borderRadius: Radius.control - 4,
backgroundColor: theme.primary,
alignItems: 'center',
justifyContent: 'center',
}}
>
<SymbolView name={name} size={17} tintColor="#FFFFFF" />
</View>
);
}
/**
* Settings hub: each area opens its own page, and the row shows its current state so most visits
* need no tap at all.
*/
export default function SettingsScreen() {
const router = useRouter();
const t = useT();
const theme = useTheme();
const accounts = useAccountViews();
const views = accounts.data ?? [];
const actions = useAccountActions();
const { settings, update } = useAlertSettings();
const permission = useNotificationPermission();
const background = useBackgroundState(settings.backgroundRefresh);
const {
localeSetting,
themeSetting,
accentSetting,
setLocaleSetting,
setThemeSetting,
setAccentSetting,
} = usePreferences();
const { settings } = useAlertSettings();
const { localeSetting, themeSetting, accentSetting } = usePreferences();
const confirmClear = () =>
Alert.alert(
t('Delete all data?'),
t('This removes every account, credential and usage history from this device.'),
[
{ text: t('Cancel'), style: 'cancel' },
{
text: t('Delete everything'),
style: 'destructive',
onPress: () => {
haptics.warning();
actions.removeAll();
},
},
],
);
const language =
localeSetting === 'system'
? t('Follow system')
: localeSetting === 'zh'
? t('Chinese')
: t('English');
const appearance =
themeSetting === 'system'
? t('Follow system')
: themeSetting === 'light'
? t('Light')
: t('Dark');
const menu: { icon: SymbolViewProps['name']; title: string; value?: string; href: Href }[] = [
{
icon: { ios: 'paintbrush.fill', android: 'palette', web: 'palette' },
title: t('Language & appearance'),
value: `${language} · ${appearance} · ${t(ACCENT_LABELS[accentSetting])}`,
href: '/settings/appearance',
},
{
icon: { ios: 'person.2.fill', android: 'group', web: 'group' },
title: t('Accounts'),
value: accounts.data ? String(accounts.data.length) : undefined,
href: '/settings/accounts',
},
{
icon: { ios: 'bell.fill', android: 'notifications', web: 'notifications' },
title: t('Alerts'),
value: settings.enabled ? t('On') : t('Off'),
href: '/settings/alerts',
},
{
icon: { ios: 'externaldrive.fill', android: 'storage', web: 'storage' },
title: t('Data'),
href: '/settings/data',
},
];
return (
<Screen>
@@ -98,208 +114,17 @@ export default function SettingsScreen() {
<Button title={t('Support on Afdian')} onPress={() => Linking.openURL(SPONSOR_URL)} />
</Animated.View>
<Section title={t('Language & appearance')} bandStyle={{ gap: Spacing.three }}>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Language')}
</ThemedText>
<Segmented<LocaleSetting>
options={[
{ value: 'system', label: t('Follow system') },
{ value: 'zh', label: t('Chinese') },
{ value: 'en', label: t('English') },
]}
label={t('Language')}
value={localeSetting}
onChange={(v) => setLocaleSetting(v)}
/>
</View>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Appearance')}
</ThemedText>
<Segmented<ThemeSetting>
options={[
{ value: 'system', label: t('Follow system') },
{ value: 'light', label: t('Light') },
{ value: 'dark', label: t('Dark') },
]}
label={t('Appearance')}
value={themeSetting}
onChange={(v) => setThemeSetting(v)}
/>
</View>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Theme color')}
</ThemedText>
<ColorSwatches<AccentName>
label={t('Theme color')}
options={(Object.keys(Accents) as AccentName[]).map((name) => ({
value: name,
color: Accents[name],
label: t(ACCENT_LABELS[name]),
}))}
value={accentSetting}
onChange={setAccentSetting}
/>
</View>
</Section>
<Section title={t('Accounts')}>
{accounts.isLoading && !accounts.data ? (
<ThemedText type="small" themeColor="textSecondary">
{t('Loading…')}
</ThemedText>
) : views.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary">
{t('No accounts yet')}
</ThemedText>
) : (
views.map((v, i) => (
<Animated.View key={v.account.id} entering={enterItem(i)} exiting={exitFade}>
<Row
leading={
<ProviderIcon
providerId={v.account.providerId}
label={v.meta?.name ?? v.account.providerId}
/>
}
title={v.meta?.name ?? v.account.providerId}
hint={v.account.label !== v.meta?.name ? v.account.label : undefined}
onPress={() => router.push(`/account/${v.account.id}`)}
/>
</Animated.View>
))
)}
<Button title={t('Add account')} onPress={() => router.push('/add')} />
</Section>
<Section title={t('Alerts')} bandStyle={{ gap: Spacing.three }}>
<ToggleRow
label={t('Usage alerts')}
value={settings.enabled}
onChange={(v) => update({ enabled: v })}
/>
{settings.enabled && (
<Animated.View
entering={enterFade}
exiting={exitFade}
layout={layoutShift}
style={{ gap: Spacing.three }}
>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Notify when usage reaches')}
</ThemedText>
<MultiChips
options={[0.7, 0.8, 0.9, 0.95].map((v) => ({
value: v,
label: `${Math.round(v * 100)}%`,
}))}
values={settings.thresholds}
onChange={(thresholds) =>
update({ thresholds: [...thresholds].sort((a, b) => a - b) })
}
/>
</View>
<ToggleRow
label={t('Remind me before a reset')}
value={settings.resetSoon.enabled}
onChange={(v) => update({ resetSoon: { ...settings.resetSoon, enabled: v } })}
/>
{settings.resetSoon.enabled && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Segmented
options={[3, 6, 12, 24].map((h) => ({ value: h, label: t('{n}h', { n: h }) }))}
value={settings.resetSoon.hours}
onChange={(hours) => update({ resetSoon: { ...settings.resetSoon, hours } })}
/>
</Animated.View>
)}
<Animated.View layout={layoutShift}>
<ToggleRow
label={t('Tell me when a window resets')}
hint={t('Only for windows that were nearly used up.')}
value={settings.windowReset}
onChange={(v) => update({ windowReset: v })}
/>
</Animated.View>
<Animated.View layout={layoutShift}>
<ToggleRow
label={t('Remind me before a plan ends')}
value={settings.renewal.enabled}
onChange={(v) => update({ renewal: { ...settings.renewal, enabled: v } })}
/>
</Animated.View>
{settings.renewal.enabled && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Segmented
label={t('Remind me before a plan ends')}
options={[1, 3, 7].map((d) => ({
value: d,
label: t('{n} days before', { n: d }),
}))}
value={settings.renewal.days}
onChange={(days) => update({ renewal: { ...settings.renewal, days } })}
/>
</Animated.View>
)}
<Animated.View layout={layoutShift}>
<ToggleRow
label={t('Tell me when a login expires')}
value={settings.authExpired}
onChange={(v) => update({ authExpired: v })}
/>
</Animated.View>
</Animated.View>
)}
{permission.state !== 'granted' && (
<Animated.View
entering={enterFade}
exiting={exitFade}
layout={layoutShift}
style={{ gap: Spacing.two }}
>
<ThemedText type="small" themeColor="textSecondary">
{permission.state === 'denied'
? Platform.OS === 'ios'
? t('Notifications are off. Turn them on in iOS Settings.')
: t('Notifications are off. Turn them on in system settings.')
: t('Allow notifications to get alerts.')}
</ThemedText>
<Button
title={
permission.state === 'denied'
? Platform.OS === 'ios'
? t('Open iOS Settings')
: t('Open system settings')
: t('Allow notifications')
}
onPress={() =>
permission.state === 'denied' ? Linking.openSettings() : permission.request()
}
<Section title={t('General')}>
{menu.map((m, i) => (
<Animated.View key={m.title} entering={enterItem(i + 1)}>
<Row
leading={<MenuIcon name={m.icon} />}
title={m.title}
value={m.value}
onPress={() => router.push(m.href)}
/>
</Animated.View>
)}
<Animated.View layout={layoutShift} style={{ gap: Spacing.three }}>
<ToggleRow
label={t('Background refresh')}
value={settings.backgroundRefresh}
onChange={(v) => update({ backgroundRefresh: v })}
/>
{background.data === 'restricted' && (
<Animated.View entering={enterFade}>
<ThemedText type="small" themeColor="textSecondary">
{t('Background refresh is restricted on this device.')}
</ThemedText>
</Animated.View>
)}
</Animated.View>
</Section>
<Section title={t('Data')}>
<Button title={t('Delete all data')} kind="destructive" onPress={confirmClear} />
))}
</Section>
<Section title={t('About')}>
+4
View File
@@ -27,6 +27,10 @@ function ThemedRoot() {
<Stack.Screen name="add/index" options={{ title: t('Add account') }} />
<Stack.Screen name="add/[providerId]" options={{ title: t('Add account') }} />
<Stack.Screen name="add/relay" options={{ title: t('API relay') }} />
<Stack.Screen name="settings/appearance" options={{ title: t('Language & appearance') }} />
<Stack.Screen name="settings/accounts" options={{ title: t('Accounts') }} />
<Stack.Screen name="settings/alerts" options={{ title: t('Alerts') }} />
<Stack.Screen name="settings/data" options={{ title: t('Data') }} />
</Stack>
</ThemeProvider>
);
+11 -1
View File
@@ -26,6 +26,7 @@ import { meterTitle, renewalInfo } from '@/ui/account-card';
import { Button, Field, Segmented } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
import { MeterRow } from '@/ui/meter-row';
import { unlimitedKeyHint } from '@/ui/relay-hints';
import { RefreshButton } from '@/ui/refresh-button';
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
import { ProviderIcon } from '@/ui/provider-icon';
@@ -236,7 +237,16 @@ export default function AccountDetailScreen() {
</Band>
{meters.length > 0 && (
<Section title={t('Meters')}>
<Section
title={t('Meters')}
footer={
// a relay key without a ceiling only reports spending: explain how to get the rest
v.account.providerId === 'relay' &&
meters.some((m) => m.kind.type === 'amount' && m.kind.limit === undefined)
? unlimitedKeyHint(v.snapshot?.plan === 'New API' ? 'new-api' : 'unknown', t)
: undefined
}
>
{meters.map((m, i) => (
<Animated.View key={keyOf(m)} entering={enterItem(i)}>
<MeterRow meter={m} now={now} view={v} />
+11
View File
@@ -21,6 +21,7 @@ import {
type RelayInspection,
} from '@/providers/relay/inspect';
import { Button, Field } from '@/ui/controls';
import { unlimitedKeyHint } from '@/ui/relay-hints';
import { haptics } from '@/ui/haptics';
import { enterFade, enterItem, exitFade, layoutShift } from '@/ui/motion';
@@ -219,6 +220,16 @@ export default function AddRelayScreen() {
</ThemedText>
)}
{/* an unlimited key has no ceiling to measure against: say how to set one */}
{b?.supported && b.unlimited ? (
<Animated.View entering={enterItem(2)}>
<ThemedText type="small" style={{ color: theme.warn }}>
{'▲ '}
{unlimitedKeyHint(result.provider.type, t)}
</ThemedText>
</Animated.View>
) : null}
<Animated.View entering={enterItem(2)}>
<ThemedText
type="small"
+50
View File
@@ -0,0 +1,50 @@
import { useRouter } from 'expo-router';
import Animated from 'react-native-reanimated';
import { Screen } from '@/components/screen';
import { Row, Section } from '@/components/section';
import { ThemedText } from '@/components/themed-text';
import { useAccountViews } from '@/data/hooks';
import { useT } from '@/i18n';
import { Button } from '@/ui/controls';
import { enterItem, exitFade } from '@/ui/motion';
import { ProviderIcon } from '@/ui/provider-icon';
export default function AccountsSettingsScreen() {
const router = useRouter();
const t = useT();
const accounts = useAccountViews();
const views = accounts.data ?? [];
return (
<Screen safeTop={false}>
<Section>
{accounts.isLoading && !accounts.data ? (
<ThemedText type="small" themeColor="textSecondary">
{t('Loading…')}
</ThemedText>
) : views.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary">
{t('No accounts yet')}
</ThemedText>
) : (
views.map((v, i) => {
const name =
v.meta?.id === 'relay' ? v.account.label : (v.meta?.name ?? v.account.providerId);
return (
<Animated.View key={v.account.id} entering={enterItem(i)} exiting={exitFade}>
<Row
leading={<ProviderIcon providerId={v.account.providerId} label={name} />}
title={name}
hint={v.account.label !== name ? v.account.label : undefined}
onPress={() => router.push(`/account/${v.account.id}`)}
/>
</Animated.View>
);
})
)}
<Button title={t('Add account')} onPress={() => router.push('/add')} />
</Section>
</Screen>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { Linking, Platform, View } from 'react-native';
import Animated from 'react-native-reanimated';
import { Screen } from '@/components/screen';
import { Section } from '@/components/section';
import { ThemedText } from '@/components/themed-text';
import { Spacing } from '@/constants/theme';
import { useAlertSettings, useBackgroundState, useNotificationPermission } from '@/data/hooks';
import { useT } from '@/i18n';
import { Button, Segmented } from '@/ui/controls';
import { enterFade, exitFade, layoutShift } from '@/ui/motion';
import { MultiChips, ToggleRow } from '@/ui/toggles';
export default function AlertSettingsScreen() {
const t = useT();
const { settings, update } = useAlertSettings();
const permission = useNotificationPermission();
const background = useBackgroundState(settings.backgroundRefresh);
return (
<Screen safeTop={false}>
{permission.state !== 'granted' && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Section bandStyle={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{permission.state === 'denied'
? Platform.OS === 'ios'
? t('Notifications are off. Turn them on in iOS Settings.')
: t('Notifications are off. Turn them on in system settings.')
: t('Allow notifications to get alerts.')}
</ThemedText>
<Button
title={
permission.state === 'denied'
? Platform.OS === 'ios'
? t('Open iOS Settings')
: t('Open system settings')
: t('Allow notifications')
}
onPress={() =>
permission.state === 'denied' ? Linking.openSettings() : permission.request()
}
/>
</Section>
</Animated.View>
)}
<Section title={t('Usage')} bandStyle={{ gap: Spacing.three }}>
<ToggleRow
label={t('Usage alerts')}
value={settings.enabled}
onChange={(v) => update({ enabled: v })}
/>
{settings.enabled && (
<Animated.View
entering={enterFade}
exiting={exitFade}
layout={layoutShift}
style={{ gap: Spacing.three }}
>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Notify when usage reaches')}
</ThemedText>
<MultiChips
options={[0.7, 0.8, 0.9, 0.95].map((v) => ({
value: v,
label: `${Math.round(v * 100)}%`,
}))}
values={settings.thresholds}
onChange={(thresholds) =>
update({ thresholds: [...thresholds].sort((a, b) => a - b) })
}
/>
</View>
<ToggleRow
label={t('Remind me before a reset')}
value={settings.resetSoon.enabled}
onChange={(v) => update({ resetSoon: { ...settings.resetSoon, enabled: v } })}
/>
{settings.resetSoon.enabled && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Segmented
options={[3, 6, 12, 24].map((h) => ({ value: h, label: t('{n}h', { n: h }) }))}
value={settings.resetSoon.hours}
onChange={(hours) => update({ resetSoon: { ...settings.resetSoon, hours } })}
/>
</Animated.View>
)}
<Animated.View layout={layoutShift}>
<ToggleRow
label={t('Tell me when a window resets')}
hint={t('Only for windows that were nearly used up.')}
value={settings.windowReset}
onChange={(v) => update({ windowReset: v })}
/>
</Animated.View>
</Animated.View>
)}
</Section>
{settings.enabled && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Section title={t('Account')} bandStyle={{ gap: Spacing.three }}>
<ToggleRow
label={t('Remind me before a plan ends')}
value={settings.renewal.enabled}
onChange={(v) => update({ renewal: { ...settings.renewal, enabled: v } })}
/>
{settings.renewal.enabled && (
<Animated.View entering={enterFade} exiting={exitFade} layout={layoutShift}>
<Segmented
label={t('Remind me before a plan ends')}
options={[1, 3, 7].map((d) => ({
value: d,
label: t('{n} days before', { n: d }),
}))}
value={settings.renewal.days}
onChange={(days) => update({ renewal: { ...settings.renewal, days } })}
/>
</Animated.View>
)}
<Animated.View layout={layoutShift}>
<ToggleRow
label={t('Tell me when a login expires')}
value={settings.authExpired}
onChange={(v) => update({ authExpired: v })}
/>
</Animated.View>
</Section>
</Animated.View>
)}
<Animated.View layout={layoutShift}>
<Section title={t('Background')} bandStyle={{ gap: Spacing.three }}>
<ToggleRow
label={t('Background refresh')}
value={settings.backgroundRefresh}
onChange={(v) => update({ backgroundRefresh: v })}
/>
{background.data === 'restricted' && (
<Animated.View entering={enterFade}>
<ThemedText type="small" themeColor="textSecondary">
{t('Background refresh is restricted on this device.')}
</ThemedText>
</Animated.View>
)}
</Section>
</Animated.View>
</Screen>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { View } from 'react-native';
import { Screen } from '@/components/screen';
import { Section } from '@/components/section';
import { ThemedText } from '@/components/themed-text';
import { Accents, Spacing, type AccentName } from '@/constants/theme';
import { useT, type LocaleSetting, type ThemeSetting } from '@/i18n';
import { usePreferences } from '@/providers/preferences';
import { ColorSwatches, Segmented } from '@/ui/controls';
import { ACCENT_LABELS } from '@/ui/settings-labels';
export default function AppearanceSettingsScreen() {
const t = useT();
const {
localeSetting,
themeSetting,
accentSetting,
setLocaleSetting,
setThemeSetting,
setAccentSetting,
} = usePreferences();
return (
<Screen safeTop={false}>
<Section title={t('Language')}>
<Segmented<LocaleSetting>
options={[
{ value: 'system', label: t('Follow system') },
{ value: 'zh', label: t('Chinese') },
{ value: 'en', label: t('English') },
]}
label={t('Language')}
value={localeSetting}
onChange={(v) => setLocaleSetting(v)}
/>
</Section>
<Section title={t('Appearance')} bandStyle={{ gap: Spacing.three }}>
<Segmented<ThemeSetting>
options={[
{ value: 'system', label: t('Follow system') },
{ value: 'light', label: t('Light') },
{ value: 'dark', label: t('Dark') },
]}
label={t('Appearance')}
value={themeSetting}
onChange={(v) => setThemeSetting(v)}
/>
<View style={{ gap: Spacing.two }}>
<ThemedText type="small" themeColor="textSecondary">
{t('Theme color')}
</ThemedText>
<ColorSwatches<AccentName>
label={t('Theme color')}
options={(Object.keys(Accents) as AccentName[]).map((name) => ({
value: name,
color: Accents[name],
label: t(ACCENT_LABELS[name]),
}))}
value={accentSetting}
onChange={setAccentSetting}
/>
</View>
</Section>
</Screen>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { useRouter } from 'expo-router';
import { Alert } from 'react-native';
import { Screen } from '@/components/screen';
import { Section } from '@/components/section';
import { useAccountActions } from '@/data/hooks';
import { useT } from '@/i18n';
import { Button } from '@/ui/controls';
import { haptics } from '@/ui/haptics';
export default function DataSettingsScreen() {
const router = useRouter();
const t = useT();
const actions = useAccountActions();
const confirmClear = () =>
Alert.alert(
t('Delete all data?'),
t('This removes every account, credential and usage history from this device.'),
[
{ text: t('Cancel'), style: 'cancel' },
{
text: t('Delete everything'),
style: 'destructive',
onPress: async () => {
haptics.warning();
await actions.removeAll();
router.back();
},
},
],
);
return (
<Screen safeTop={false}>
<Section
footer={t(
'Accounts, credentials and usage history live only on this device. Nothing is sent to any server of ours.',
)}
>
<Button title={t('Delete all data')} kind="destructive" onPress={confirmClear} />
</Section>
</Screen>
);
}
+11 -1
View File
@@ -28,8 +28,18 @@ export const zh: Record<string, string> = {
'Open this account to sign in again.': '请打开此账号重新登录。',
'Tap refresh to load usage.': '点击刷新以加载用量。',
Refresh: '刷新',
General: '通用',
On: '开启',
Account: '账号',
Background: '后台',
'Accounts, credentials and usage history live only on this device. Nothing is sent to any server of ours.':
'账号、凭据和用量历史只保存在这台设备上,不会发送到我们的任何服务器。',
// API relays
'API relay': 'API 中转站',
'API relay': '中转站',
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota for this key on the Tokens page of the relay site, then detect again.':
'这个 Key 没有设置额度上限,只能显示已用金额:无法计算剩余额度,也无法在快用完时提醒你。请到中转站后台的「令牌」页面为该 Key 设置额度上限,然后重新识别。',
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota limit for this key in the relay site, then detect again.':
'这个 Key 没有设置额度上限,只能显示已用金额:无法计算剩余额度,也无法在快用完时提醒你。请到中转站后台为该 Key 设置额度上限,然后重新识别。',
'Price currency': '月费货币',
'New API, Sub2API, One API and other OpenAI-compatible relays: enter the site address and key, the rest is detected.':
'支持 New API、Sub2API、One API 及其他 OpenAI 兼容中转站:只需填写站点地址和 Key,其余自动识别。',
+16
View File
@@ -0,0 +1,16 @@
import type { Translator } from '@/i18n';
import type { RelayType } from '@/providers/relay/inspect';
/**
* A relay key without a quota ceiling only reports what it has spent: there is no remaining
* amount, no usage share and nothing to alert on. Tell the user where to set the ceiling.
*/
export function unlimitedKeyHint(type: RelayType, t: Translator): string {
return type === 'new-api'
? t(
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota for this key on the Tokens page of the relay site, then detect again.',
)
: t(
'This key has no quota limit, so only spending can be shown: no remaining amount and no alerts. Set a quota limit for this key in the relay site, then detect again.',
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { AccentName } from '@/constants/theme';
/** Spoken and shown names of the theme colours (English keys, translated with t()). */
export const ACCENT_LABELS: Record<AccentName, string> = {
blue: 'Blue',
indigo: 'Indigo',
violet: 'Violet',
pink: 'Pink',
orange: 'Orange',
green: 'Green',
teal: 'Teal',
};