💱 feat(settings): introduce site-wide quota display type (USD/CNY/TOKENS/CUSTOM)

Replace the legacy boolean “DisplayInCurrencyEnabled” with an injected, type-safe
configuration `general_setting.quota_display_type`, and wire it through the
backend and frontend.

Backend
- Add `QuotaDisplayType` to `operation_setting.GeneralSetting` with injected
  registration via `config.GlobalConfig.Register("general_setting", ...)`.
  Helpers: `IsCurrencyDisplay()`, `IsCNYDisplay()`, `GetQuotaDisplayType()`.
- Expose `quota_display_type` in `/api/status` and keep legacy
  `display_in_currency` for backward compatibility.
- Logger: update `LogQuota` and `FormatQuota` to support USD/CNY/TOKENS. When
  CNY is selected, convert using `operation_setting.USDExchangeRate`.
- Controllers:
  - `billing`: compute subscription/usage amounts based on the selected type
    (USD: divide by `QuotaPerUnit`; CNY: USD→CNY; TOKENS: keep raw tokens).
  - `topup` / `topup_stripe`: treat inputs as “amount” for USD/CNY and as
    token-count for TOKENS; adjust min topup and pay money accordingly.
  - `misc`: include `quota_display_type` in status payload.
- Compatibility: in `model/option.UpdateOption`, map updates to
  `DisplayInCurrencyEnabled` → `general_setting.quota_display_type`
  (true→USD, false→TOKENS). Keep exporting the legacy key in `OptionMap`.

Frontend
- Settings: replace the “display in currency” switch with a Select
  (`general_setting.quota_display_type`) offering USD / CNY / Tokens.
  Provide fallback mapping from legacy `DisplayInCurrencyEnabled`.
- Persist `quota_display_type` to localStorage (keep `display_in_currency`
  for legacy components).
- Rendering helpers: base all quota/price rendering on `quota_display_type`;
  use `usd_exchange_rate` for CNY symbol/values.
- Pricing page: default view currency follows site display type (USD/CNY),
  while TOKENS mode still allows per-view currency toggling when needed.

Notes
- No database migrations required.
- Legacy clients remain functional via compatibility fields.
This commit is contained in:
t0ng7u
2025-09-29 23:23:31 +08:00
parent 41ea93883b
commit 8294a76bc2
46 changed files with 1268 additions and 601 deletions

View File

@@ -42,7 +42,7 @@ const OperationSetting = () => {
QuotaPerUnit: 0,
USDExchangeRate: 0,
RetryTimes: 0,
DisplayInCurrencyEnabled: false,
'general_setting.quota_display_type': 'USD',
DisplayTokenStatEnabled: false,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,

View File

@@ -45,7 +45,6 @@ import { useTranslation } from 'react-i18next';
const SystemSetting = () => {
const { t } = useTranslation();
let [inputs, setInputs] = useState({
PasswordLoginEnabled: '',
PasswordRegisterEnabled: '',
EmailVerificationEnabled: '',
@@ -188,7 +187,9 @@ const SystemSetting = () => {
setInputs(newInputs);
setOriginInputs(newInputs);
// 同步模式布尔到本地状态
if (typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined') {
if (
typeof newInputs['fetch_setting.domain_filter_mode'] !== 'undefined'
) {
setDomainFilterMode(!!newInputs['fetch_setting.domain_filter_mode']);
}
if (typeof newInputs['fetch_setting.ip_filter_mode'] !== 'undefined') {
@@ -695,14 +696,17 @@ const SystemSetting = () => {
noLabel
extraText={t('SSRF防护开关详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.enable_ssrf_protection', e)
handleCheckboxChange(
'fetch_setting.enable_ssrf_protection',
e,
)
}
>
{t('启用SSRF防护推荐开启以保护服务器安全')}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -713,14 +717,19 @@ const SystemSetting = () => {
noLabel
extraText={t('私有IP访问详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.allow_private_ip', e)
handleCheckboxChange(
'fetch_setting.allow_private_ip',
e,
)
}
>
{t('允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址')}
{t(
'允许访问私有IP地址127.0.0.1、192.168.x.x等内网地址',
)}
</Form.Checkbox>
</Col>
</Row>
<Row
gutter={{ xs: 8, sm: 16, md: 24, lg: 24, xl: 24, xxl: 24 }}
style={{ marginTop: 16 }}
@@ -731,7 +740,10 @@ const SystemSetting = () => {
noLabel
extraText={t('域名IP过滤详细说明')}
onChange={(e) =>
handleCheckboxChange('fetch_setting.apply_ip_filter_for_domain', e)
handleCheckboxChange(
'fetch_setting.apply_ip_filter_for_domain',
e,
)
}
style={{ marginBottom: 8 }}
>
@@ -740,17 +752,23 @@ const SystemSetting = () => {
<Text strong>
{t(domainFilterMode ? '域名白名单' : '域名黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{t('支持通配符格式example.com, *.api.example.com')}
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t(
'支持通配符格式example.com, *.api.example.com',
)}
</Text>
<Radio.Group
type='button'
value={domainFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setDomainFilterMode(isWhitelist);
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.domain_filter_mode': isWhitelist,
}));
@@ -765,9 +783,9 @@ const SystemSetting = () => {
onChange={(value) => {
setDomainList(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.domain_list': value
'fetch_setting.domain_list': value,
}));
}}
placeholder={t('输入域名后回车example.com')}
@@ -784,17 +802,21 @@ const SystemSetting = () => {
<Text strong>
{t(ipFilterMode ? 'IP白名单' : 'IP黑名单')}
</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持CIDR格式8.8.8.8, 192.168.1.0/24')}
</Text>
<Radio.Group
type='button'
value={ipFilterMode ? 'whitelist' : 'blacklist'}
onChange={(val) => {
const selected = val && val.target ? val.target.value : val;
const selected =
val && val.target ? val.target.value : val;
const isWhitelist = selected === 'whitelist';
setIpFilterMode(isWhitelist);
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.ip_filter_mode': isWhitelist,
}));
@@ -809,9 +831,9 @@ const SystemSetting = () => {
onChange={(value) => {
setIpList(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.ip_list': value
'fetch_setting.ip_list': value,
}));
}}
placeholder={t('输入IP地址后回车8.8.8.8')}
@@ -826,7 +848,10 @@ const SystemSetting = () => {
>
<Col xs={24} sm={24} md={24} lg={24} xl={24}>
<Text strong>{t('允许的端口')}</Text>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('支持单个端口和端口范围80, 443, 8000-8999')}
</Text>
<TagInput
@@ -834,15 +859,18 @@ const SystemSetting = () => {
onChange={(value) => {
setAllowedPorts(value);
// 触发Form的onChange事件
setInputs(prev => ({
setInputs((prev) => ({
...prev,
'fetch_setting.allowed_ports': value
'fetch_setting.allowed_ports': value,
}));
}}
placeholder={t('输入端口后回车80 或 8000-8999')}
style={{ width: '100%' }}
/>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
<Text
type='secondary'
style={{ display: 'block', marginBottom: 8 }}
>
{t('端口配置详细说明')}
</Text>
</Col>

View File

@@ -85,7 +85,8 @@ const AccountManagement = ({
);
};
const isBound = (accountId) => Boolean(accountId);
const [showTelegramBindModal, setShowTelegramBindModal] = React.useState(false);
const [showTelegramBindModal, setShowTelegramBindModal] =
React.useState(false);
return (
<Card className='!rounded-2xl'>
@@ -226,7 +227,8 @@ const AccountManagement = ({
onGitHubOAuthClicked(status.github_client_id)
}
disabled={
isBound(userState.user?.github_id) || !status.github_oauth
isBound(userState.user?.github_id) ||
!status.github_oauth
}
>
{status.github_oauth ? t('绑定') : t('未启用')}
@@ -384,7 +386,8 @@ const AccountManagement = ({
onLinuxDOOAuthClicked(status.linuxdo_client_id)
}
disabled={
isBound(userState.user?.linux_do_id) || !status.linuxdo_oauth
isBound(userState.user?.linux_do_id) ||
!status.linuxdo_oauth
}
>
{status.linuxdo_oauth ? t('绑定') : t('未启用')}