From ef01c0cde824c9f128f3e1df60491c12161eefe0 Mon Sep 17 00:00:00 2001 From: an9xyz Date: Sat, 6 Jun 2026 14:53:56 +0800 Subject: [PATCH 1/3] chore: ignore local .env file .env holds the dev-only vite proxy target and must not be committed; only .env.example is tracked as the template. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4419ed7..4d22a85 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ dist/ index-legacy.html # local env files +.env .env.local .env.*.local From c4a662b565131ea01a1d13ee3696bd66dab0bb89 Mon Sep 17 00:00:00 2001 From: an9xyz Date: Sat, 6 Jun 2026 14:54:08 +0800 Subject: [PATCH 2/3] feat(system-setting): redesign page layout and information hierarchy Reduce visual noise on the System Settings page by pulling apart the three equal-weight layers (label / control / meta) into a clear hierarchy, and fix several layout issues. - Replace the ragged Row/Col grid with a CSS-column masonry so cards pack tightly without large vertical gaps. - Render each setting as a horizontal row: strong primary label, an inline DB/default source badge, and a de-emphasised monospace key, replacing the long redundant "effective value" sentence. - Use proper controls per type: a tri-state Switch for booleans (with a "reset to default" affordance that preserves the follow-default semantics) and a number stepper for integers. - Show the default value as an input placeholder when a field is unset, so non-bool defaults are visible. - Move the "Test email" action into the Email Service card header so it stays tied to the email config as settings grow. - Add per-category item counts, localized space/sidebar category titles, and a "saved at" hint in the toolbar. - Split the page into focused modules: helpers.ts (pure data helpers) and SettingRow.tsx (row rendering + custom controls). --- src/i18n/locales/en-US/systemSetting.json | 9 + src/i18n/locales/zh-CN/systemSetting.json | 9 + src/pages/SystemSetting/SettingRow.tsx | 116 +++++++++++++ src/pages/SystemSetting/helpers.ts | 69 ++++++++ src/pages/SystemSetting/index.tsx | 193 ++++++---------------- src/styles/admin.css | 137 +++++++++++++++ 6 files changed, 390 insertions(+), 143 deletions(-) create mode 100644 src/pages/SystemSetting/SettingRow.tsx create mode 100644 src/pages/SystemSetting/helpers.ts diff --git a/src/i18n/locales/en-US/systemSetting.json b/src/i18n/locales/en-US/systemSetting.json index cf720ab..9df07ab 100644 --- a/src/i18n/locales/en-US/systemSetting.json +++ b/src/i18n/locales/en-US/systemSetting.json @@ -3,9 +3,16 @@ "subtitle": "Login, registration policy, and email service configuration", "action.testEmail": "Test Email", "action.save": "Save Settings", + "action.followDefault": "Reset to default", + "savedAt": "Saved · {{time}}", + "countItems": "{{count}} items", + "badge.db": "DB", + "badge.default": "Default", "tooltip.saveBeforeTest": "Please save the settings before sending a test email", "category.login": "Login Settings", "category.register": "Registration Settings", + "category.space": "Space Settings", + "category.sidebar": "Sidebar Settings", "category.support": "Email Service", "category.generic": "{{category}} Settings", "bool.yes": "Yes", @@ -15,6 +22,8 @@ "source.default": "Default config", "input.boolDefault": "Follow default config", "input.boolDefaultWithCurrent": "Follow default config (current: {{value}})", + "input.followDefault": "Follow default config", + "input.followDefaultWithCurrent": "Follow default config (current: {{value}})", "input.encryptedKeep": "Leave blank to keep the current value", "input.encryptedDefault": "Leave blank to follow the default config", "extra.effectiveEncrypted": "{{identity}}. Currently effective: configured ({{source}})", diff --git a/src/i18n/locales/zh-CN/systemSetting.json b/src/i18n/locales/zh-CN/systemSetting.json index 83c8fd4..628f4b4 100644 --- a/src/i18n/locales/zh-CN/systemSetting.json +++ b/src/i18n/locales/zh-CN/systemSetting.json @@ -3,9 +3,16 @@ "subtitle": "登录、注册策略与邮件服务配置", "action.testEmail": "测试邮件", "action.save": "保存配置", + "action.followDefault": "恢复默认", + "savedAt": "已保存 · {{time}}", + "countItems": "{{count}} 项", + "badge.db": "DB", + "badge.default": "默认", "tooltip.saveBeforeTest": "请先保存配置后再发送测试邮件", "category.login": "登录配置", "category.register": "注册配置", + "category.space": "空间配置", + "category.sidebar": "侧边栏配置", "category.support": "邮件服务", "category.generic": "{{category}} 配置", "bool.yes": "是", @@ -15,6 +22,8 @@ "source.default": "默认配置", "input.boolDefault": "跟随默认配置", "input.boolDefaultWithCurrent": "跟随默认配置(当前:{{value}})", + "input.followDefault": "跟随默认配置", + "input.followDefaultWithCurrent": "跟随默认配置(当前:{{value}})", "input.encryptedKeep": "留空保留现有值", "input.encryptedDefault": "留空跟随默认配置", "extra.effectiveEncrypted": "{{identity}}。当前生效:已配置({{source}})", diff --git a/src/pages/SystemSetting/SettingRow.tsx b/src/pages/SystemSetting/SettingRow.tsx new file mode 100644 index 0000000..8d5dc7d --- /dev/null +++ b/src/pages/SystemSetting/SettingRow.tsx @@ -0,0 +1,116 @@ +import type { TFunction } from 'i18next' +import { Button, Form, Input, InputNumber, Switch } from 'antd' +import type { SystemSettingItem } from '../../api/system-setting' +import { + normaliseBoolValue, + settingFormName, + settingLabel, + type BoolFormValue, +} from './helpers' + +interface BoolSwitchProps { + item: SystemSettingItem + t: TFunction + // value / onChange 由外层 Form.Item 注入 + value?: BoolFormValue + onChange?: (value: BoolFormValue) => void +} + +/** + * 三态布尔控件:Switch 负责 是/否,未显式配置时跟随默认值(form value 为空串)。 + * 一旦显式拨动,出现「恢复默认」链接,可把值清回空串以继续跟随默认。 + */ +function BoolSwitch({ item, t, value, onChange }: BoolSwitchProps) { + const isDefault = !value + const effectiveOn = normaliseBoolValue(item.effective_value) === '1' + const checked = isDefault ? effectiveOn : value === '1' + + return ( +
+ {!isDefault && ( + + )} + onChange?.(next ? '1' : '0')} /> +
+ ) +} + +function renderControl(item: SystemSettingItem, t: TFunction) { + const name = settingFormName(item.category, item.key) + + if (item.value_type === 'bool') { + return ( + + + + ) + } + + if (item.value_type === 'encrypted') { + return ( + + + + ) + } + + if (item.value_type === 'int') { + const placeholder = item.configured + ? undefined + : item.effective_value + ? t('input.followDefaultWithCurrent', { value: item.effective_value }) + : t('input.followDefault') + return ( + + + + ) + } + + const placeholder = item.configured + ? undefined + : item.effective_value + ? t('input.followDefaultWithCurrent', { value: item.effective_value }) + : t('input.followDefault') + return ( + + + + ) +} + +interface SettingRowProps { + item: SystemSettingItem + t: TFunction +} + +export default function SettingRow({ item, t }: SettingRowProps) { + // bool / int 控件紧凑,行内右对齐;string / encrypted 较宽,标签在上、控件整行 + const inline = item.value_type === 'bool' || item.value_type === 'int' + const badgeKey = item.configured ? 'badge.db' : 'badge.default' + const badgeClass = item.configured ? 'setting-badge--db' : 'setting-badge--default' + + return ( +
+
+
+ {settingLabel(item)} + {t(badgeKey)} +
+ {settingFormName(item.category, item.key)} +
+
{renderControl(item, t)}
+
+ ) +} diff --git a/src/pages/SystemSetting/helpers.ts b/src/pages/SystemSetting/helpers.ts new file mode 100644 index 0000000..824ae4d --- /dev/null +++ b/src/pages/SystemSetting/helpers.ts @@ -0,0 +1,69 @@ +import type { TFunction } from 'i18next' +import { + SECRET_MASK, + type SystemSettingItem, + type SystemSettingUpdateItem, +} from '../../api/system-setting' + +export type BoolFormValue = '' | '0' | '1' +export type SettingFormValue = string | number | null | undefined +export type SystemSettingFormValues = Record + +export const settingMapKey = (category: string, key: string) => `${category}.${key}` +export const settingFormName = (category: string, key: string) => settingMapKey(category, key) + +const categoryTitleKeys: Record = { + login: 'category.login', + register: 'category.register', + space: 'category.space', + sidebar: 'category.sidebar', + support: 'category.support', +} + +export function normaliseBoolValue(value: string): BoolFormValue { + if (value === '1' || value === 'true' || value === 'TRUE') return '1' + if (value === '0' || value === 'false' || value === 'FALSE') return '0' + return '' +} + +export function formValueToString(value: SettingFormValue) { + if (value === null || value === undefined) return '' + return String(value) +} + +export function valuesToPayload( + values: SystemSettingFormValues, + items: SystemSettingItem[], +): SystemSettingUpdateItem[] { + return items.map((item) => { + const value = formValueToString(values[settingFormName(item.category, item.key)]) + const keepEncryptedValue = + item.value_type === 'encrypted' && !value && (item.configured || item.value === SECRET_MASK) + + return { + category: item.category, + key: item.key, + value: keepEncryptedValue ? SECRET_MASK : value, + } + }) +} + +export function valuesFromSettings(items: SystemSettingItem[]): SystemSettingFormValues { + return items.reduce((values, item) => { + const fieldName = settingFormName(item.category, item.key) + values[fieldName] = item.value_type === 'encrypted' ? '' : item.value ?? '' + if (item.value_type === 'bool') { + values[fieldName] = normaliseBoolValue(formValueToString(values[fieldName])) + } + return values + }, {}) +} + +export function categoryTitle(t: TFunction, category: string) { + const titleKey = categoryTitleKeys[category] + return titleKey ? t(titleKey) : t('category.generic', { category }) +} + +export function settingLabel(item: SystemSettingItem) { + return item.description || item.key +} diff --git a/src/pages/SystemSetting/index.tsx b/src/pages/SystemSetting/index.tsx index 58e22ea..1cf99d6 100644 --- a/src/pages/SystemSetting/index.tsx +++ b/src/pages/SystemSetting/index.tsx @@ -1,16 +1,14 @@ import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' -import type { TFunction } from 'i18next' +import dayjs from 'dayjs' import { Alert, Button, Card, - Col, Form, Input, Modal, - Row, - Select, Tooltip, message, } from 'antd' @@ -20,132 +18,23 @@ import { SendOutlined, } from '@ant-design/icons' import { - SECRET_MASK, getSystemSettings, testSystemSettingEmail, updateSystemSettings, type SystemSettingItem, - type SystemSettingUpdateItem, } from '../../api/system-setting' - -type BoolFormValue = '' | '0' | '1' -type SettingFormValue = string | number | null | undefined - -type SystemSettingFormValues = Record +import SettingRow from './SettingRow' +import { + categoryTitle, + valuesFromSettings, + valuesToPayload, + type SystemSettingFormValues, +} from './helpers' interface TestEmailFormValues { to: string } -const settingMapKey = (category: string, key: string) => `${category}.${key}` -const settingFormName = (category: string, key: string) => settingMapKey(category, key) - -const categoryTitleKeys: Record = { - login: 'category.login', - register: 'category.register', - support: 'category.support', -} - -function normaliseBoolValue(value: string): BoolFormValue { - if (value === '1' || value === 'true' || value === 'TRUE') return '1' - if (value === '0' || value === 'false' || value === 'FALSE') return '0' - return '' -} - -function boolText(t: TFunction, value: string | undefined) { - const normalised = normaliseBoolValue(value ?? '') - if (normalised === '1') return t('bool.yes') - if (normalised === '0') return t('bool.no') - return t('bool.unset') -} - -function formValueToString(value: SettingFormValue) { - if (value === null || value === undefined) return '' - return String(value) -} - -function valuesToPayload(values: SystemSettingFormValues, items: SystemSettingItem[]): SystemSettingUpdateItem[] { - return items.map((item) => { - const value = formValueToString(values[settingFormName(item.category, item.key)]) - const keepEncryptedValue = item.value_type === 'encrypted' && !value && (item.configured || item.value === SECRET_MASK) - - return { - category: item.category, - key: item.key, - value: keepEncryptedValue ? SECRET_MASK : value, - } - }) -} - -function valuesFromSettings(items: SystemSettingItem[]): SystemSettingFormValues { - return items.reduce((values, item) => { - const fieldName = settingFormName(item.category, item.key) - values[fieldName] = item.value_type === 'encrypted' ? '' : item.value ?? '' - if (item.value_type === 'bool') { - values[fieldName] = normaliseBoolValue(formValueToString(values[fieldName])) - } - return values - }, {}) -} - -function categoryTitle(t: TFunction, category: string) { - const titleKey = categoryTitleKeys[category] - return titleKey ? t(titleKey) : t('category.generic', { category }) -} - -function settingSource(t: TFunction, item: SystemSettingItem) { - return item.configured ? t('source.db') : t('source.default') -} - -function genericSettingExtra(t: TFunction, item: SystemSettingItem) { - const identity = settingMapKey(item.category, item.key) - if (!item.effective_value) return identity - const source = settingSource(t, item) - if (item.value_type === 'encrypted') { - return t('extra.effectiveEncrypted', { identity, source }) - } - if (item.value_type === 'bool') { - return t('extra.effectiveValue', { identity, value: boolText(t, item.effective_value), source }) - } - return t('extra.effectiveValue', { identity, value: item.effective_value, source }) -} - -function genericBoolDefaultLabel(t: TFunction, item: SystemSettingItem) { - return item.effective_value - ? t('input.boolDefaultWithCurrent', { value: boolText(t, item.effective_value) }) - : t('input.boolDefault') -} - -function settingLabel(item: SystemSettingItem) { - return item.description || item.key -} - -function renderSettingInput(t: TFunction, item: SystemSettingItem) { - if (item.value_type === 'bool') { - return ( - -} - export default function SystemSetting() { const { t } = useTranslation(['systemSetting', 'common']) const [form] = Form.useForm() @@ -156,6 +45,7 @@ export default function SystemSetting() { const [testModalOpen, setTestModalOpen] = useState(false) const [settings, setSettings] = useState([]) const [dirty, setDirty] = useState(false) + const [savedAt, setSavedAt] = useState(null) const settingGroups = useMemo(() => { const groups = new Map() @@ -193,6 +83,7 @@ export default function SystemSetting() { try { await updateSystemSettings(valuesToPayload(values, settings)) message.success(t('toast.saved')) + setSavedAt(dayjs().format('HH:mm')) await fetchSettings() } catch (error) { message.error(t('toast.saveFailed', { message: (error as Error).message })) @@ -216,6 +107,24 @@ export default function SystemSetting() { } } + const testEmailButton = ( + + + + ) + + // 各分类卡片头部的关联操作;后续其他分类需要专属动作时在此扩展即可 + const categoryActions: Record = { + support: testEmailButton, + } + return (

{t('title')}

@@ -226,15 +135,9 @@ export default function SystemSetting() { {t('common:action.refresh')}
- - - + {savedAt && !dirty && ( + {t('savedAt', { time: savedAt })} + )} @@ -246,24 +149,28 @@ export default function SystemSetting() { initialValues={{}} onValuesChange={() => setDirty(true)} > - +
{settingGroups.map(([category, items]) => ( - - + + {categoryTitle(t, category)} + {t('countItems', { count: items.length })} + + } + extra={categoryActions[category]} + loading={loading} + > +
{items.map((item) => ( - - {renderSettingInput(t, item)} - + ))} - - +
+
))} - +
Date: Sat, 6 Jun 2026 15:13:54 +0800 Subject: [PATCH 3/3] refactor(system-setting): address review feedback Follow-up to the System Settings redesign, covering non-blocking review nits. - Remove 9 orphaned i18n keys left after dropping the old renderers (bool.*, source.*, input.boolDefault*, extra.*); keep the single followDefault* pair now shared by int/string placeholders. - Accessibility: give every control an aria-label so screen readers announce a name (the noStyle Form.Item dropped the implicit label link). - Mute the boolean Switch while it follows the default, so it reads differently from an explicitly-set value at a glance. - Constrain int inputs to whole numbers (precision={0}). - Clear the "saved at" hint on a manual refresh so it never implies the page was just saved; keep it across the post-save reload. - Extend the page subtitle to mention the space/sidebar categories. - Translate the new implementation comments to English; add -webkit-/page-break-inside fallbacks for the masonry cards. - Add helpers.test.ts covering the bool tri-state round-trip (load -> toggle -> reset) and encrypted keep-current behavior. --- src/i18n/locales/en-US/systemSetting.json | 11 +- src/i18n/locales/zh-CN/systemSetting.json | 11 +- src/pages/SystemSetting/SettingRow.tsx | 49 +++++---- src/pages/SystemSetting/helpers.test.ts | 116 ++++++++++++++++++++++ src/pages/SystemSetting/index.tsx | 11 +- src/styles/admin.css | 33 +++--- 6 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 src/pages/SystemSetting/helpers.test.ts diff --git a/src/i18n/locales/en-US/systemSetting.json b/src/i18n/locales/en-US/systemSetting.json index 9df07ab..8e1459f 100644 --- a/src/i18n/locales/en-US/systemSetting.json +++ b/src/i18n/locales/en-US/systemSetting.json @@ -1,6 +1,6 @@ { "title": "System Settings", - "subtitle": "Login, registration policy, and email service configuration", + "subtitle": "System policies for login, registration, space, sidebar, and the email service", "action.testEmail": "Test Email", "action.save": "Save Settings", "action.followDefault": "Reset to default", @@ -15,19 +15,10 @@ "category.sidebar": "Sidebar Settings", "category.support": "Email Service", "category.generic": "{{category}} Settings", - "bool.yes": "Yes", - "bool.no": "No", - "bool.unset": "Not configured", - "source.db": "DB config", - "source.default": "Default config", - "input.boolDefault": "Follow default config", - "input.boolDefaultWithCurrent": "Follow default config (current: {{value}})", "input.followDefault": "Follow default config", "input.followDefaultWithCurrent": "Follow default config (current: {{value}})", "input.encryptedKeep": "Leave blank to keep the current value", "input.encryptedDefault": "Leave blank to follow the default config", - "extra.effectiveEncrypted": "{{identity}}. Currently effective: configured ({{source}})", - "extra.effectiveValue": "{{identity}}. Currently effective: {{value}} ({{source}})", "toast.fetchFailed": "Failed to load system settings: {{message}}", "toast.saved": "Settings saved", "toast.saveFailed": "Failed to save: {{message}}", diff --git a/src/i18n/locales/zh-CN/systemSetting.json b/src/i18n/locales/zh-CN/systemSetting.json index 628f4b4..1af6109 100644 --- a/src/i18n/locales/zh-CN/systemSetting.json +++ b/src/i18n/locales/zh-CN/systemSetting.json @@ -1,6 +1,6 @@ { "title": "系统配置", - "subtitle": "登录、注册策略与邮件服务配置", + "subtitle": "登录、注册、空间、侧边栏与邮件服务等系统策略配置", "action.testEmail": "测试邮件", "action.save": "保存配置", "action.followDefault": "恢复默认", @@ -15,19 +15,10 @@ "category.sidebar": "侧边栏配置", "category.support": "邮件服务", "category.generic": "{{category}} 配置", - "bool.yes": "是", - "bool.no": "否", - "bool.unset": "未配置", - "source.db": "DB 配置", - "source.default": "默认配置", - "input.boolDefault": "跟随默认配置", - "input.boolDefaultWithCurrent": "跟随默认配置(当前:{{value}})", "input.followDefault": "跟随默认配置", "input.followDefaultWithCurrent": "跟随默认配置(当前:{{value}})", "input.encryptedKeep": "留空保留现有值", "input.encryptedDefault": "留空跟随默认配置", - "extra.effectiveEncrypted": "{{identity}}。当前生效:已配置({{source}})", - "extra.effectiveValue": "{{identity}}。当前生效:{{value}}({{source}})", "toast.fetchFailed": "获取系统配置失败: {{message}}", "toast.saved": "配置已保存", "toast.saveFailed": "保存失败: {{message}}", diff --git a/src/pages/SystemSetting/SettingRow.tsx b/src/pages/SystemSetting/SettingRow.tsx index 8d5dc7d..bff3c21 100644 --- a/src/pages/SystemSetting/SettingRow.tsx +++ b/src/pages/SystemSetting/SettingRow.tsx @@ -11,14 +11,16 @@ import { interface BoolSwitchProps { item: SystemSettingItem t: TFunction - // value / onChange 由外层 Form.Item 注入 + // value / onChange are injected by the wrapping Form.Item value?: BoolFormValue onChange?: (value: BoolFormValue) => void } /** - * 三态布尔控件:Switch 负责 是/否,未显式配置时跟随默认值(form value 为空串)。 - * 一旦显式拨动,出现「恢复默认」链接,可把值清回空串以继续跟随默认。 + * Tri-state boolean control. The Switch toggles yes/no; while no explicit + * value is set (form value is an empty string) the field follows the default. + * Once toggled explicitly, a "reset to default" link appears so the value can + * be cleared back to the empty string and keep following the default. */ function BoolSwitch({ item, t, value, onChange }: BoolSwitchProps) { const isDefault = !value @@ -37,13 +39,21 @@ function BoolSwitch({ item, t, value, onChange }: BoolSwitchProps) { {t('action.followDefault')} )} - onChange?.(next ? '1' : '0')} /> + onChange?.(next ? '1' : '0')} + />
) } function renderControl(item: SystemSettingItem, t: TFunction) { const name = settingFormName(item.category, item.key) + const ariaLabel = settingLabel(item) if (item.value_type === 'bool') { return ( @@ -59,33 +69,37 @@ function renderControl(item: SystemSettingItem, t: TFunction) { ) } + // Show the effective default as a placeholder only when the field is unset. + const placeholder = item.configured + ? undefined + : item.effective_value + ? t('input.followDefaultWithCurrent', { value: item.effective_value }) + : t('input.followDefault') + if (item.value_type === 'int') { - const placeholder = item.configured - ? undefined - : item.effective_value - ? t('input.followDefaultWithCurrent', { value: item.effective_value }) - : t('input.followDefault') return ( - + ) } - const placeholder = item.configured - ? undefined - : item.effective_value - ? t('input.followDefaultWithCurrent', { value: item.effective_value }) - : t('input.followDefault') return ( - + ) } @@ -96,7 +110,8 @@ interface SettingRowProps { } export default function SettingRow({ item, t }: SettingRowProps) { - // bool / int 控件紧凑,行内右对齐;string / encrypted 较宽,标签在上、控件整行 + // bool / int controls are compact and sit inline on the right; string / + // encrypted are wider, so the label stays on top and the control spans the row. const inline = item.value_type === 'bool' || item.value_type === 'int' const badgeKey = item.configured ? 'badge.db' : 'badge.default' const badgeClass = item.configured ? 'setting-badge--db' : 'setting-badge--default' diff --git a/src/pages/SystemSetting/helpers.test.ts b/src/pages/SystemSetting/helpers.test.ts new file mode 100644 index 0000000..a331114 --- /dev/null +++ b/src/pages/SystemSetting/helpers.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest' +import { SECRET_MASK, type SystemSettingItem } from '../../api/system-setting' +import { + settingFormName, + valuesFromSettings, + valuesToPayload, + type SystemSettingFormValues, +} from './helpers' + +function makeItem(partial: Partial & Pick): SystemSettingItem { + return { + configured: false, + value: '', + effective_value: '', + description: '', + ...partial, + } +} + +const name = (item: SystemSettingItem) => settingFormName(item.category, item.key) + +describe('valuesFromSettings', () => { + it('normalises bool values and never derives a value from effective_value', () => { + const configuredOn = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1', effective_value: '1' }) + const followDefault = makeItem({ category: 'register', key: 'only_china', value_type: 'bool', configured: false, value: '', effective_value: '0' }) + const legacyTrue = makeItem({ category: 'register', key: 'username_on', value_type: 'bool', configured: true, value: 'true', effective_value: '1' }) + + const values = valuesFromSettings([configuredOn, followDefault, legacyTrue]) + + expect(values[name(configuredOn)]).toBe('1') + // Not configured stays empty (follow default) — viewing must not pre-fill from effective_value. + expect(values[name(followDefault)]).toBe('') + expect(values[name(legacyTrue)]).toBe('1') + }) + + it('always blanks encrypted fields so the mask is never echoed back', () => { + const secret = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK, effective_value: SECRET_MASK }) + const values = valuesFromSettings([secret]) + expect(values[name(secret)]).toBe('') + }) + + it('keeps int/string values verbatim', () => { + const intItem = makeItem({ category: 'sidebar', key: 'recent_filter_group_days', value_type: 'int', configured: true, value: '3', effective_value: '3' }) + const strItem = makeItem({ category: 'support', key: 'email', value_type: 'string', configured: true, value: 'a@b.com', effective_value: 'a@b.com' }) + const values = valuesFromSettings([intItem, strItem]) + expect(values[name(intItem)]).toBe('3') + expect(values[name(strItem)]).toBe('a@b.com') + }) +}) + +describe('valuesToPayload', () => { + it('preserves the bool tri-state: follow-default vs explicit yes/no', () => { + const followDefault = makeItem({ category: 'register', key: 'only_china', value_type: 'bool', configured: false, effective_value: '0' }) + const explicitOn = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1' }) + const explicitOff = makeItem({ category: 'login', key: 'local_off', value_type: 'bool', configured: true, value: '0' }) + const items = [followDefault, explicitOn, explicitOff] + + const values: SystemSettingFormValues = { + [name(followDefault)]: '', + [name(explicitOn)]: '1', + [name(explicitOff)]: '0', + } + + const payload = valuesToPayload(values, items) + expect(payload.find((p) => p.key === 'only_china')?.value).toBe('') + expect(payload.find((p) => p.key === 'off')?.value).toBe('1') + expect(payload.find((p) => p.key === 'local_off')?.value).toBe('0') + }) + + it('keeps the current encrypted value when left blank, but follows default when never configured', () => { + const keep = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK }) + const neverSet = makeItem({ category: 'support', key: 'email_pwd2', value_type: 'encrypted', configured: false }) + const items = [keep, neverSet] + + const payload = valuesToPayload({ [name(keep)]: '', [name(neverSet)]: '' }, items) + expect(payload.find((p) => p.key === 'email_pwd')?.value).toBe(SECRET_MASK) + expect(payload.find((p) => p.key === 'email_pwd2')?.value).toBe('') + }) + + it('writes a new encrypted value when provided', () => { + const secret = makeItem({ category: 'support', key: 'email_pwd', value_type: 'encrypted', configured: true, value: SECRET_MASK }) + const payload = valuesToPayload({ [name(secret)]: 'new-secret' }, [secret]) + expect(payload[0].value).toBe('new-secret') + }) + + it('stringifies int values and treats empty as follow-default', () => { + const intItem = makeItem({ category: 'sidebar', key: 'recent_filter_group_days', value_type: 'int' }) + const set = valuesToPayload({ [name(intItem)]: 5 }, [intItem]) + expect(set[0].value).toBe('5') + const cleared = valuesToPayload({ [name(intItem)]: '' }, [intItem]) + expect(cleared[0].value).toBe('') + }) +}) + +describe('bool round-trip (load → toggle → reset)', () => { + const item = makeItem({ category: 'register', key: 'off', value_type: 'bool', configured: true, value: '1', effective_value: '1' }) + const field = name(item) + + it('round-trips an explicit value through load and save', () => { + const loaded = valuesFromSettings([item]) + expect(loaded[field]).toBe('1') + expect(valuesToPayload(loaded, [item])[0].value).toBe('1') + }) + + it('a toggle (onChange) produces the opposite explicit value', () => { + // BoolSwitch.onChange(next ? '1' : '0') — toggling off + const toggledOff: SystemSettingFormValues = { [field]: '0' } + expect(valuesToPayload(toggledOff, [item])[0].value).toBe('0') + }) + + it('reset-to-default clears the override back to follow-default', () => { + // BoolSwitch reset link calls onChange('') + const reset: SystemSettingFormValues = { [field]: '' } + expect(valuesToPayload(reset, [item])[0].value).toBe('') + }) +}) diff --git a/src/pages/SystemSetting/index.tsx b/src/pages/SystemSetting/index.tsx index 1cf99d6..b7d0738 100644 --- a/src/pages/SystemSetting/index.tsx +++ b/src/pages/SystemSetting/index.tsx @@ -58,7 +58,9 @@ export default function SystemSetting() { return Array.from(groups.entries()) }, [settings]) - const fetchSettings = async () => { + // keepSavedHint is set by the post-save reload so the "saved at" hint stays; + // a manual refresh clears it to avoid implying the page was just saved. + const fetchSettings = async (options?: { keepSavedHint?: boolean }) => { setLoading(true) try { const data = await getSystemSettings() @@ -66,6 +68,7 @@ export default function SystemSetting() { setSettings(items) form.setFieldsValue(valuesFromSettings(items)) setDirty(false) + if (!options?.keepSavedHint) setSavedAt(null) } catch (error) { message.error(t('toast.fetchFailed', { message: (error as Error).message })) } finally { @@ -84,7 +87,7 @@ export default function SystemSetting() { await updateSystemSettings(valuesToPayload(values, settings)) message.success(t('toast.saved')) setSavedAt(dayjs().format('HH:mm')) - await fetchSettings() + await fetchSettings({ keepSavedHint: true }) } catch (error) { message.error(t('toast.saveFailed', { message: (error as Error).message })) } finally { @@ -120,7 +123,7 @@ export default function SystemSetting() { ) - // 各分类卡片头部的关联操作;后续其他分类需要专属动作时在此扩展即可 + // Per-category card-header actions; extend here when another category needs one. const categoryActions: Record = { support: testEmailButton, } @@ -131,7 +134,7 @@ export default function SystemSetting() {

{t('subtitle')}

-
diff --git a/src/styles/admin.css b/src/styles/admin.css index 1c5d579..e7cc17b 100644 --- a/src/styles/admin.css +++ b/src/styles/admin.css @@ -1091,8 +1091,8 @@ } /* =========== System Setting masonry =========== */ -/* 用 CSS 多列做瀑布流:卡片按列从上往下紧贴排列, - 避免 Row/Col 两两配对时高度不齐留下的大片空白。 */ +/* CSS multi-column masonry: cards pack column by column, top to bottom, + avoiding the large gaps the old paired Row/Col grid left between rows. */ .admin-shell .setting-masonry { column-count: 2; column-gap: 16px; @@ -1100,20 +1100,22 @@ .admin-shell .setting-masonry .setting-card { break-inside: avoid; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; display: inline-block; width: 100%; margin-bottom: 16px; } -/* 对齐原来的 lg 断点:窄屏单列 */ +/* Match the previous lg breakpoint: single column on narrow screens */ @media (max-width: 991.98px) { .admin-shell .setting-masonry { column-count: 1; } } -/* =========== System Setting rows(三层信息分级) =========== */ -/* 卡片标题 + 计数徽标 */ +/* =========== System Setting rows (label / control / meta hierarchy) =========== */ +/* Card title + item-count badge */ .admin-shell .setting-card-title { display: inline-flex; align-items: center; @@ -1128,7 +1130,7 @@ padding: 1px 8px; } -/* 行容器:行与行之间用细分隔线,弱化为背景结构 */ +/* Row container: thin dividers between rows act as quiet background structure */ .admin-shell .setting-rows { display: flex; flex-direction: column; @@ -1142,7 +1144,7 @@ border-top: 0; } -/* inline:标签在左、紧凑控件在右;block:标签在上、宽控件整行 */ +/* inline: label left, compact control right; block: label on top, full-width control */ .admin-shell .setting-row--inline { display: flex; align-items: center; @@ -1164,7 +1166,7 @@ width: 100%; } -/* 第一层:标签(主,最强权重) */ +/* Layer 1: label (primary, strongest weight) */ .admin-shell .setting-row-head { display: flex; align-items: center; @@ -1177,7 +1179,7 @@ line-height: 1.4; } -/* 第三层:key(末,极淡,仅供排查定位) */ +/* Layer 3: key (tertiary, very faint, for troubleshooting/locating only) */ .admin-shell .setting-row-key { display: block; margin-top: 3px; @@ -1187,7 +1189,7 @@ letter-spacing: 0.01em; } -/* 来源徽标:DB(已配置,品牌色)/ 默认(跟随默认,中性灰) */ +/* Source badge: DB (configured, brand color) / Default (following default, neutral gray) */ .admin-shell .setting-badge { flex-shrink: 0; font-size: 11px; @@ -1205,7 +1207,7 @@ background: var(--a-bg-muted); } -/* 第二层:控件 */ +/* Layer 2: controls */ .admin-shell .setting-switch { display: inline-flex; align-items: center; @@ -1216,11 +1218,18 @@ height: auto; font-size: 12px; } +/* Following-default state: muted so it reads differently from an explicit value */ +.admin-shell .setting-switch-toggle--default { + opacity: 0.55; +} +.admin-shell .setting-switch-toggle--default:hover { + opacity: 0.8; +} .admin-shell .setting-number { width: 130px; } -/* 工具栏已保存提示 */ +/* Toolbar "saved at" hint */ .admin-shell .setting-saved-hint { font-size: 13px; color: var(--a-text-tertiary);