diff --git a/soul-admin/src/components/ui/dialog.tsx b/soul-admin/src/components/ui/dialog.tsx index f7b1ec05..763985ee 100644 --- a/soul-admin/src/components/ui/dialog.tsx +++ b/soul-admin/src/components/ui/dialog.tsx @@ -11,42 +11,43 @@ function DialogPortal(props: React.ComponentProps return } -function DialogOverlay({ className, ...props }: React.ComponentProps) { - return ( - - ) -} +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = 'DialogOverlay' -function DialogContent({ - className, - children, - showCloseButton = true, - ...props -}: React.ComponentProps & { showCloseButton?: boolean }) { - return ( - - - - {children} - {showCloseButton && ( - - - Close - - )} - - - ) -} +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { showCloseButton?: boolean } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + +)) +DialogContent.displayName = 'DialogContent' function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) { return
diff --git a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx index 61dd68c2..1b42015d 100644 --- a/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx +++ b/soul-admin/src/pages/referral-settings/ReferralSettingsPage.tsx @@ -31,10 +31,10 @@ export function ReferralSettingsPage() { const [saving, setSaving] = useState(false) useEffect(() => { - get<{ success?: boolean; data?: ReferralConfig }>('/api/db/config/full?key=referral_config') - .then((data) => { - const c = (data as { data?: ReferralConfig; config?: ReferralConfig })?.data ?? (data as { config?: ReferralConfig })?.config - if (c) { + get<{ success?: boolean; data?: ReferralConfig }>('/api/admin/referral-settings') + .then((res) => { + const c = (res as { data?: ReferralConfig })?.data + if (c && typeof c === 'object') { setConfig({ distributorShare: c.distributorShare ?? 90, minWithdrawAmount: c.minWithdrawAmount ?? 10, @@ -51,19 +51,14 @@ export function ReferralSettingsPage() { const handleSave = async () => { setSaving(true) try { - const safeConfig = { + const body = { distributorShare: Number(config.distributorShare) || 0, minWithdrawAmount: Number(config.minWithdrawAmount) || 0, bindingDays: Number(config.bindingDays) || 0, userDiscount: Number(config.userDiscount) || 0, enableAutoWithdraw: Boolean(config.enableAutoWithdraw), } - const body = { - key: 'referral_config', - value: safeConfig, - description: '分销 / 推广规则配置', - } - const res = await post<{ success?: boolean; error?: string }>('/api/db/config', body) + const res = await post<{ success?: boolean; error?: string }>('/api/admin/referral-settings', body) if (!res || (res as { success?: boolean }).success === false) { alert('保存失败: ' + (res && typeof res === 'object' && 'error' in res ? (res as { error?: string }).error : '')) return diff --git a/soul-admin/src/pages/settings/SettingsPage.tsx b/soul-admin/src/pages/settings/SettingsPage.tsx index 5f620e03..6241dab2 100644 --- a/soul-admin/src/pages/settings/SettingsPage.tsx +++ b/soul-admin/src/pages/settings/SettingsPage.tsx @@ -9,6 +9,14 @@ import { import { Label } from '@/components/ui/label' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' import { @@ -23,7 +31,6 @@ import { Gift, X, Plus, - Smartphone, } from 'lucide-react' import { get, post } from '@/api/client' @@ -43,14 +50,6 @@ interface LocalSettings { authorInfo: AuthorInfo } -interface MpConfig { - appId: string - apiDomain: string - buyerDiscount: number - referralBindDays: number - minWithdraw: number -} - interface FeatureConfig { matchEnabled: boolean referralEnabled: boolean @@ -74,14 +73,6 @@ const defaultSettings: LocalSettings = { authorInfo: { ...defaultAuthor }, } -const defaultMp: MpConfig = { - appId: 'wxb8bbb2b10dec74aa', - apiDomain: 'https://soul.quwanzhi.com', - buyerDiscount: 5, - referralBindDays: 30, - minWithdraw: 10, -} - const defaultFeatures: FeatureConfig = { matchEnabled: true, referralEnabled: true, @@ -89,54 +80,6 @@ const defaultFeatures: FeatureConfig = { aboutEnabled: true, } -function parseConfigResponse(raw: unknown): { - freeChapters?: string[] - mpConfig?: Partial - features?: Partial - sectionPrice?: number - baseBookPrice?: number - distributorShare?: number - authorInfo?: AuthorInfo -} { - if (!raw || typeof raw !== 'object') return {} - const o = raw as Record - const out: ReturnType = {} - if (Array.isArray(o.freeChapters)) out.freeChapters = o.freeChapters as string[] - if (o.mpConfig && typeof o.mpConfig === 'object') out.mpConfig = o.mpConfig as Partial - if (o.features && typeof o.features === 'object') out.features = o.features as Partial - if (typeof o.sectionPrice === 'number') out.sectionPrice = o.sectionPrice - if (typeof o.baseBookPrice === 'number') out.baseBookPrice = o.baseBookPrice - if (typeof o.distributorShare === 'number') out.distributorShare = o.distributorShare - if (o.authorInfo && typeof o.authorInfo === 'object') out.authorInfo = o.authorInfo as AuthorInfo - return out -} - -function mergeFromConfigList(list: unknown[]): ReturnType { - const out: ReturnType = {} - for (const item of list) { - if (!item || typeof item !== 'object') continue - const row = item as { configKey?: string; configValue?: string } - const key = row.configKey - let val: unknown - try { - val = typeof row.configValue === 'string' ? JSON.parse(row.configValue) : row.configValue - } catch { - val = row.configValue - } - if (key === 'feature_config' && val && typeof val === 'object') out.features = val as Partial - if (key === 'mp_config' && val && typeof val === 'object') out.mpConfig = val as Partial - if (key === 'free_chapters' && Array.isArray(val)) out.freeChapters = val as string[] - if (key === 'site_settings' && val && typeof val === 'object') { - const s = val as Record - if (typeof s.sectionPrice === 'number') out.sectionPrice = s.sectionPrice - if (typeof s.baseBookPrice === 'number') out.baseBookPrice = s.baseBookPrice - if (typeof s.distributorShare === 'number') out.distributorShare = s.distributorShare - if (s.authorInfo && typeof s.authorInfo === 'object') out.authorInfo = s.authorInfo as AuthorInfo - } - } - return out -} - export function SettingsPage() { const [localSettings, setLocalSettings] = useState(defaultSettings) const [freeChapters, setFreeChapters] = useState([ @@ -148,53 +91,47 @@ export function SettingsPage() { 'appendix-3', ]) const [newFreeChapter, setNewFreeChapter] = useState('') - const [mpConfig, setMpConfig] = useState(defaultMp) const [featureConfig, setFeatureConfig] = useState(defaultFeatures) const [isSaving, setIsSaving] = useState(false) const [loading, setLoading] = useState(true) + const [dialogOpen, setDialogOpen] = useState(false) + const [dialogTitle, setDialogTitle] = useState('') + const [dialogMessage, setDialogMessage] = useState('') + const [dialogIsError, setDialogIsError] = useState(false) + const [featureSwitchSaving, setFeatureSwitchSaving] = useState(false) + + const showResult = (title: string, message: string, isError = false) => { + setDialogTitle(title) + setDialogMessage(message) + setDialogIsError(isError) + setDialogOpen(true) + } useEffect(() => { const load = async () => { try { - const [configRes, appConfigRes] = await Promise.all([ - get<{ success?: boolean; data?: unknown } | Record>('/api/db/config/full'), - get>('/api/config').catch(() => null), - ]) - let parsed = parseConfigResponse( - (configRes as { data?: unknown })?.data ?? configRes, - ) - const data = (configRes as { data?: unknown })?.data - if (Array.isArray(data)) parsed = { ...parsed, ...mergeFromConfigList(data) } - if (parsed.freeChapters?.length) setFreeChapters(parsed.freeChapters) - if (parsed.mpConfig && Object.keys(parsed.mpConfig).length) - setMpConfig((prev) => ({ ...prev, ...parsed.mpConfig })) - if (parsed.features && Object.keys(parsed.features).length) - setFeatureConfig((prev) => ({ ...prev, ...parsed.features })) - if ( - appConfigRes?.authorInfo && - typeof appConfigRes.authorInfo === 'object' - ) { + const res = await get<{ + success?: boolean + freeChapters?: string[] + featureConfig?: Partial + siteSettings?: { sectionPrice?: number; baseBookPrice?: number; distributorShare?: number; authorInfo?: AuthorInfo } + }>('/api/admin/settings') + if (!res || (res as { success?: boolean }).success === false) return + if (Array.isArray(res.freeChapters) && res.freeChapters.length) setFreeChapters(res.freeChapters) + if (res.featureConfig && Object.keys(res.featureConfig).length) + setFeatureConfig((prev) => ({ ...prev, ...res.featureConfig })) + if (res.siteSettings && typeof res.siteSettings === 'object') { + const s = res.siteSettings setLocalSettings((prev) => ({ ...prev, - authorInfo: { ...prev.authorInfo, ...(appConfigRes.authorInfo as AuthorInfo) }, - })) - } - if ( - typeof parsed.sectionPrice === 'number' || - typeof parsed.baseBookPrice === 'number' || - typeof parsed.distributorShare === 'number' || - (parsed.authorInfo && Object.keys(parsed.authorInfo).length) - ) { - setLocalSettings((prev) => ({ - ...prev, - ...(typeof parsed.sectionPrice === 'number' && { sectionPrice: parsed.sectionPrice }), - ...(typeof parsed.baseBookPrice === 'number' && { baseBookPrice: parsed.baseBookPrice }), - ...(typeof parsed.distributorShare === 'number' && { distributorShare: parsed.distributorShare }), - ...(parsed.authorInfo && { authorInfo: { ...prev.authorInfo, ...parsed.authorInfo } }), + ...(typeof s.sectionPrice === 'number' && { sectionPrice: s.sectionPrice }), + ...(typeof s.baseBookPrice === 'number' && { baseBookPrice: s.baseBookPrice }), + ...(typeof s.distributorShare === 'number' && { distributorShare: s.distributorShare }), + ...(s.authorInfo && typeof s.authorInfo === 'object' && { authorInfo: { ...prev.authorInfo, ...s.authorInfo } }), })) } } catch (e) { - console.error('Load config error:', e) + console.error('Load settings error:', e) } finally { setLoading(false) } @@ -202,37 +139,58 @@ export function SettingsPage() { load() }, []) + const saveFeatureConfigOnly = async ( + nextConfig: FeatureConfig, + onFailRevert: () => void, + ) => { + setFeatureSwitchSaving(true) + try { + const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', { + featureConfig: nextConfig, + }) + if (!res || (res as { success?: boolean }).success === false) { + onFailRevert() + showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true) + return + } + showResult('已保存', '功能开关已更新,相关入口将随之显示或隐藏。') + } catch (error) { + console.error('Save feature config error:', error) + onFailRevert() + showResult('保存失败', error instanceof Error ? error.message : String(error), true) + } finally { + setFeatureSwitchSaving(false) + } + } + + const handleFeatureSwitch = (field: keyof FeatureConfig, checked: boolean) => { + const prev = featureConfig + const next = { ...prev, [field]: checked } + setFeatureConfig(next) + saveFeatureConfigOnly(next, () => setFeatureConfig(prev)) + } + const handleSave = async () => { setIsSaving(true) try { - await post('/api/db/config', { - key: 'free_chapters', - value: freeChapters, - description: '免费章节ID列表', - }).catch(() => {}) - await post('/api/db/config', { - key: 'mp_config', - value: mpConfig, - description: '小程序配置', - }).catch(() => {}) - await post('/api/db/config', { - key: 'feature_config', - value: featureConfig, - description: '功能开关配置', + const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', { + freeChapters, + featureConfig, + siteSettings: { + sectionPrice: localSettings.sectionPrice, + baseBookPrice: localSettings.baseBookPrice, + distributorShare: localSettings.distributorShare, + authorInfo: localSettings.authorInfo, + }, }) - const verifyRes = await get<{ features?: FeatureConfig }>('/api/db/config/full').catch(() => ({})) - const verifyData = Array.isArray((verifyRes as { data?: unknown })?.data) - ? mergeFromConfigList((verifyRes as { data: unknown[] }).data) - : parseConfigResponse((verifyRes as { data?: unknown })?.data ?? verifyRes) - if (verifyData.features) - setFeatureConfig((prev) => ({ ...prev, ...verifyData.features })) - alert( - '设置已保存!\n\n找伙伴功能:' + - (verifyData.features?.matchEnabled ? '✅ 开启' : '❌ 关闭'), - ) + if (!res || (res as { success?: boolean }).success === false) { + showResult('保存失败', (res as { error?: string })?.error ?? '未知错误', true) + return + } + showResult('已保存', '设置已保存成功。') } catch (error) { console.error('Save settings error:', error) - alert('保存失败: ' + (error instanceof Error ? error.message : String(error))) + showResult('保存失败', error instanceof Error ? error.message : String(error), true) } finally { setIsSaving(false) } @@ -523,9 +481,8 @@ export function SettingsPage() { - setFeatureConfig((prev) => ({ ...prev, matchEnabled: checked })) - } + disabled={featureSwitchSaving} + onCheckedChange={(checked) => handleFeatureSwitch('matchEnabled', checked)} />
@@ -541,9 +498,8 @@ export function SettingsPage() { - setFeatureConfig((prev) => ({ ...prev, referralEnabled: checked })) - } + disabled={featureSwitchSaving} + onCheckedChange={(checked) => handleFeatureSwitch('referralEnabled', checked)} />
@@ -559,9 +515,8 @@ export function SettingsPage() { - setFeatureConfig((prev) => ({ ...prev, searchEnabled: checked })) - } + disabled={featureSwitchSaving} + onCheckedChange={(checked) => handleFeatureSwitch('searchEnabled', checked)} />
@@ -577,9 +532,8 @@ export function SettingsPage() { - setFeatureConfig((prev) => ({ ...prev, aboutEnabled: checked })) - } + disabled={featureSwitchSaving} + onCheckedChange={(checked) => handleFeatureSwitch('aboutEnabled', checked)} />
@@ -590,72 +544,31 @@ export function SettingsPage() { - - - - - - 小程序配置 - - 微信小程序相关参数设置 - - -
-
- - setMpConfig((prev) => ({ ...prev, appId: e.target.value }))} - /> -
-
- - setMpConfig((prev) => ({ ...prev, apiDomain: e.target.value }))} - /> -
-
-
-
- - - setMpConfig((prev) => ({ ...prev, buyerDiscount: Number(e.target.value) })) - } - /> -
-
- - - setMpConfig((prev) => ({ ...prev, referralBindDays: Number(e.target.value) })) - } - /> -
-
- - - setMpConfig((prev) => ({ ...prev, minWithdraw: Number(e.target.value) })) - } - /> -
-
-
-
+ + + + + + {dialogTitle} + + + {dialogMessage} + + + + + + + ) } diff --git a/soul-api/internal/handler/db.go b/soul-api/internal/handler/db.go index 7cdc16f3..1b04849f 100644 --- a/soul-api/internal/handler/db.go +++ b/soul-api/internal/handler/db.go @@ -122,6 +122,167 @@ func DBConfigGet(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"success": true, "data": data}) } +// AdminSettingsGet GET /api/admin/settings 系统设置页专用:仅返回免费章节、功能开关、站点/作者与价格 +func AdminSettingsGet(c *gin.Context) { + db := database.DB() + out := gin.H{ + "success": true, + "freeChapters": []string{"preface", "epilogue", "1.1", "appendix-1", "appendix-2", "appendix-3"}, + "featureConfig": gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true, "aboutEnabled": true}, + "siteSettings": gin.H{"sectionPrice": float64(1), "baseBookPrice": 9.9, "distributorShare": float64(90), "authorInfo": gin.H{}}, + } + keys := []string{"free_chapters", "feature_config", "site_settings"} + for _, k := range keys { + var row model.SystemConfig + if err := db.Where("config_key = ?", k).First(&row).Error; err != nil { + continue + } + var val interface{} + if err := json.Unmarshal(row.ConfigValue, &val); err != nil { + continue + } + switch k { + case "free_chapters": + if arr, ok := val.([]interface{}); ok && len(arr) > 0 { + ss := make([]string, 0, len(arr)) + for _, x := range arr { + if s, ok := x.(string); ok { + ss = append(ss, s) + } + } + if len(ss) > 0 { + out["freeChapters"] = ss + } + } + case "feature_config": + if m, ok := val.(map[string]interface{}); ok && len(m) > 0 { + out["featureConfig"] = m + } + case "site_settings": + if m, ok := val.(map[string]interface{}); ok && len(m) > 0 { + out["siteSettings"] = m + } + } + } + c.JSON(http.StatusOK, out) +} + +// AdminSettingsPost POST /api/admin/settings 系统设置页专用:一次性保存免费章节、功能开关、站点/作者与价格(不包含小程序配置,该配置已移除) +func AdminSettingsPost(c *gin.Context) { + var body struct { + FreeChapters []string `json:"freeChapters"` + FeatureConfig map[string]interface{} `json:"featureConfig"` + SiteSettings map[string]interface{} `json:"siteSettings"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + db := database.DB() + saveKey := func(key, desc string, value interface{}) error { + valBytes, err := json.Marshal(value) + if err != nil { + return err + } + var row model.SystemConfig + err = db.Where("config_key = ?", key).First(&row).Error + if err != nil { + row = model.SystemConfig{ConfigKey: key, ConfigValue: valBytes, Description: &desc} + return db.Create(&row).Error + } + row.ConfigValue = valBytes + if desc != "" { + row.Description = &desc + } + return db.Save(&row).Error + } + if body.FreeChapters != nil { + if err := saveKey("free_chapters", "免费章节ID列表", body.FreeChapters); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存免费章节失败: " + err.Error()}) + return + } + } + if body.FeatureConfig != nil { + if err := saveKey("feature_config", "功能开关配置", body.FeatureConfig); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存功能开关失败: " + err.Error()}) + return + } + } + if body.SiteSettings != nil { + if err := saveKey("site_settings", "站点与作者配置", body.SiteSettings); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存站点设置失败: " + err.Error()}) + return + } + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"}) +} + +// AdminReferralSettingsGet GET /api/admin/referral-settings 推广设置页专用:仅返回 referral_config +func AdminReferralSettingsGet(c *gin.Context) { + db := database.DB() + defaultConfig := gin.H{ + "distributorShare": float64(90), + "minWithdrawAmount": float64(10), + "bindingDays": float64(30), + "userDiscount": float64(5), + "enableAutoWithdraw": false, + } + var row model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil { + c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig}) + return + } + var val map[string]interface{} + if err := json.Unmarshal(row.ConfigValue, &val); err != nil || len(val) == 0 { + c.JSON(http.StatusOK, gin.H{"success": true, "data": defaultConfig}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "data": val}) +} + +// AdminReferralSettingsPost POST /api/admin/referral-settings 推广设置页专用:仅保存 referral_config(请求体为完整配置对象) +func AdminReferralSettingsPost(c *gin.Context) { + var body struct { + DistributorShare float64 `json:"distributorShare"` + MinWithdrawAmount float64 `json:"minWithdrawAmount"` + BindingDays float64 `json:"bindingDays"` + UserDiscount float64 `json:"userDiscount"` + EnableAutoWithdraw bool `json:"enableAutoWithdraw"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": "请求体无效"}) + return + } + val := gin.H{ + "distributorShare": body.DistributorShare, + "minWithdrawAmount": body.MinWithdrawAmount, + "bindingDays": body.BindingDays, + "userDiscount": body.UserDiscount, + "enableAutoWithdraw": body.EnableAutoWithdraw, + } + valBytes, err := json.Marshal(val) + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + db := database.DB() + desc := "分销 / 推广规则配置" + var row model.SystemConfig + if err := db.Where("config_key = ?", "referral_config").First(&row).Error; err != nil { + row = model.SystemConfig{ConfigKey: "referral_config", ConfigValue: valBytes, Description: &desc} + err = db.Create(&row).Error + } else { + row.ConfigValue = valBytes + row.Description = &desc + err = db.Save(&row).Error + } + if err != nil { + c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"}) +} + // DBConfigPost POST /api/db/config func DBConfigPost(c *gin.Context) { var body struct { diff --git a/soul-api/internal/router/router.go b/soul-api/internal/router/router.go index 1adb4ef3..8ae26604 100644 --- a/soul-api/internal/router/router.go +++ b/soul-api/internal/router/router.go @@ -59,6 +59,10 @@ func Setup(cfg *config.Config) *gin.Engine { admin.DELETE("/referral", handler.AdminReferral) admin.GET("/withdrawals", handler.AdminWithdrawalsList) admin.PUT("/withdrawals", handler.AdminWithdrawalsAction) + admin.GET("/settings", handler.AdminSettingsGet) + admin.POST("/settings", handler.AdminSettingsPost) + admin.GET("/referral-settings", handler.AdminReferralSettingsGet) + admin.POST("/referral-settings", handler.AdminReferralSettingsPost) } // ----- 鉴权 ----- diff --git a/soul-api/tmp/main.exe b/soul-api/tmp/main.exe index 042744c9..a6b60fe6 100644 Binary files a/soul-api/tmp/main.exe and b/soul-api/tmp/main.exe differ