diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index 48038a3..3608c55 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -4,27 +4,29 @@ import "time" // DistributionSite 表示一个独立分销网站主体。 type DistributionSite struct { - ID uint64 `gorm:"primaryKey" json:"id"` - SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"` - SiteName string `gorm:"size:128;not null" json:"site_name"` - Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"` - Status string `gorm:"size:16;not null;default:'active';index" json:"status"` - DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"` - LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"` - FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"` - BannersJSON string `gorm:"type:text" json:"banners_json"` - AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"` - FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` - DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` - ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` - SMSConfigJSON string `gorm:"type:text" json:"-"` - ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"` - ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"` - TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` - TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` - SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `gorm:"primaryKey" json:"id"` + SiteCode string `gorm:"size:64;not null;uniqueIndex" json:"site_code"` + SiteName string `gorm:"size:128;not null" json:"site_name"` + Domain string `gorm:"size:255;not null;uniqueIndex" json:"domain"` + Status string `gorm:"size:16;not null;default:'active';index" json:"status"` + DisplayAccountEnabled bool `gorm:"not null;default:true" json:"display_account_enabled"` + LogoURL string `gorm:"size:512;not null;default:''" json:"logo_url"` + FaviconURL string `gorm:"size:512;not null;default:''" json:"favicon_url"` + BannersJSON string `gorm:"type:text" json:"banners_json"` + AnnouncementsJSON string `gorm:"type:text" json:"announcements_json"` + FooterInfoJSON string `gorm:"type:text" json:"footer_info_json"` + AWRecycleJSON string `gorm:"type:text" json:"aw_recycle_json"` + CooperationFeedbackJSON string `gorm:"type:text" json:"cooperation_feedback_json"` + DefaultSupportGroupID *uint64 `gorm:"index" json:"default_support_group_id,omitempty"` + ListingQrcodeURL string `gorm:"size:512;not null;default:''" json:"listing_qrcode_url"` + SMSConfigJSON string `gorm:"type:text" json:"-"` + ReceiveRatioDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_ratio_delta"` + ReceiveCostDelta float64 `gorm:"type:decimal(10,2);not null;default:0" json:"receive_cost_delta"` + TaxEnabled bool `gorm:"not null;default:false" json:"tax_enabled"` + TaxRateBPS int64 `gorm:"not null;default:100" json:"tax_rate_bps"` + SettlementHoldDays int `gorm:"not null;default:7" json:"settlement_hold_days"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func (DistributionSite) TableName() string { return "distribution_sites" } diff --git a/backend/internal/modules/chat/listing_group.go b/backend/internal/modules/chat/listing_group.go index e0265fd..d898550 100644 --- a/backend/internal/modules/chat/listing_group.go +++ b/backend/internal/modules/chat/listing_group.go @@ -356,7 +356,12 @@ func resolveListingQrCode(tx *gorm.DB, siteID uint64) (*model.ChatQrCode, string if qrcode != nil { return qrcode, qrcode.ImageURL, nil } - return nil, "", nil + // 分站没有固定码、也没有自己的码池时,沿用主站二维码池。 + mainQR, err := fetchUnusedQrCode(tx) + if err != nil || mainQR == nil { + return nil, "", err + } + return mainQR, mainQR.ImageURL, nil } qrcode, err := fetchUnusedQrCodeForSite(tx, nil) if err != nil { diff --git a/backend/internal/modules/distribution/dto.go b/backend/internal/modules/distribution/dto.go index a8922eb..d812bde 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -3,30 +3,32 @@ package distribution import "time" type SiteDTO struct { - ID uint64 `json:"id"` - SiteCode string `json:"site_code"` - SiteName string `json:"site_name"` - Domain string `json:"domain"` - Status string `json:"status"` - DisplayAccountEnabled bool `json:"display_account_enabled"` - LogoURL string `json:"logo_url"` - FaviconURL string `json:"favicon_url"` - BannersJSON string `json:"banners_json"` - AnnouncementsJSON string `json:"announcements_json"` - FooterInfoJSON string `json:"footer_info_json"` - DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` - ListingQrcodeURL string `json:"listing_qrcode_url"` - ReceiveRatioDelta float64 `json:"receive_ratio_delta"` - ReceiveCostDelta float64 `json:"receive_cost_delta"` - TaxEnabled bool `json:"tax_enabled"` - TaxRateBPS int64 `json:"tax_rate_bps"` - SettlementHoldDays int `json:"settlement_hold_days"` - AdminUserID *uint64 `json:"admin_user_id,omitempty"` - AdminUsername string `json:"admin_username,omitempty"` - AdminNickname string `json:"admin_nickname,omitempty"` - MemberType string `json:"member_type,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint64 `json:"id"` + SiteCode string `json:"site_code"` + SiteName string `json:"site_name"` + Domain string `json:"domain"` + Status string `json:"status"` + DisplayAccountEnabled bool `json:"display_account_enabled"` + LogoURL string `json:"logo_url"` + FaviconURL string `json:"favicon_url"` + BannersJSON string `json:"banners_json"` + AnnouncementsJSON string `json:"announcements_json"` + FooterInfoJSON string `json:"footer_info_json"` + AWRecycleJSON string `json:"aw_recycle_json"` + CooperationFeedbackJSON string `json:"cooperation_feedback_json"` + DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` + ListingQrcodeURL string `json:"listing_qrcode_url"` + ReceiveRatioDelta float64 `json:"receive_ratio_delta"` + ReceiveCostDelta float64 `json:"receive_cost_delta"` + TaxEnabled bool `json:"tax_enabled"` + TaxRateBPS int64 `json:"tax_rate_bps"` + SettlementHoldDays int `json:"settlement_hold_days"` + AdminUserID *uint64 `json:"admin_user_id,omitempty"` + AdminUsername string `json:"admin_username,omitempty"` + AdminNickname string `json:"admin_nickname,omitempty"` + MemberType string `json:"member_type,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ResetSiteAdminPasswordRequest struct { @@ -418,11 +420,14 @@ type UpdateReceivedListingStatusRequest struct { // 站点品牌与装修 DTO。指针字段未传则保持原值,避免空字符串把装修清掉。 type UpdateSiteBrandingRequest struct { - LogoURL *string `json:"logo_url"` - FaviconURL *string `json:"favicon_url"` - BannersJSON *string `json:"banners_json"` - AnnouncementsJSON *string `json:"announcements_json"` - FooterInfoJSON *string `json:"footer_info_json"` + LogoURL *string `json:"logo_url"` + FaviconURL *string `json:"favicon_url"` + BannersJSON *string `json:"banners_json"` + AnnouncementsJSON *string `json:"announcements_json"` + FooterInfoJSON *string `json:"footer_info_json"` + AWRecycleJSON *string `json:"aw_recycle_json"` + CooperationFeedbackJSON *string `json:"cooperation_feedback_json"` + ListingQrcodeURL *string `json:"listing_qrcode_url"` } // 自主加价 DTO:增加该站「1 元兑换哈夫币」比例,不是人民币。 diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 5848e1a..e6a0d53 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -855,26 +855,28 @@ func (r *Repository) UpdateRuleStatus(ctx context.Context, adminID, id uint64, s func siteDTO(row model.DistributionSite) SiteDTO { return SiteDTO{ - ID: row.ID, - SiteCode: row.SiteCode, - SiteName: row.SiteName, - Domain: row.Domain, - Status: row.Status, - DisplayAccountEnabled: row.DisplayAccountEnabled, - DefaultSupportGroupID: row.DefaultSupportGroupID, - ListingQrcodeURL: row.ListingQrcodeURL, - ReceiveRatioDelta: row.ReceiveRatioDelta, - ReceiveCostDelta: row.ReceiveCostDelta, - TaxEnabled: row.TaxEnabled, - TaxRateBPS: row.TaxRateBPS, - SettlementHoldDays: row.SettlementHoldDays, - LogoURL: row.LogoURL, - FaviconURL: row.FaviconURL, - BannersJSON: row.BannersJSON, - AnnouncementsJSON: row.AnnouncementsJSON, - FooterInfoJSON: row.FooterInfoJSON, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.ID, + SiteCode: row.SiteCode, + SiteName: row.SiteName, + Domain: row.Domain, + Status: row.Status, + DisplayAccountEnabled: row.DisplayAccountEnabled, + DefaultSupportGroupID: row.DefaultSupportGroupID, + ListingQrcodeURL: row.ListingQrcodeURL, + ReceiveRatioDelta: row.ReceiveRatioDelta, + ReceiveCostDelta: row.ReceiveCostDelta, + TaxEnabled: row.TaxEnabled, + TaxRateBPS: row.TaxRateBPS, + SettlementHoldDays: row.SettlementHoldDays, + LogoURL: row.LogoURL, + FaviconURL: row.FaviconURL, + BannersJSON: row.BannersJSON, + AnnouncementsJSON: row.AnnouncementsJSON, + FooterInfoJSON: row.FooterInfoJSON, + AWRecycleJSON: row.AWRecycleJSON, + CooperationFeedbackJSON: row.CooperationFeedbackJSON, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } @@ -2700,6 +2702,18 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin site.FooterInfoJSON = strings.TrimSpace(*req.FooterInfoJSON) updates["footer_info_json"] = site.FooterInfoJSON } + if req.AWRecycleJSON != nil { + site.AWRecycleJSON = strings.TrimSpace(*req.AWRecycleJSON) + updates["aw_recycle_json"] = site.AWRecycleJSON + } + if req.CooperationFeedbackJSON != nil { + site.CooperationFeedbackJSON = strings.TrimSpace(*req.CooperationFeedbackJSON) + updates["cooperation_feedback_json"] = site.CooperationFeedbackJSON + } + if req.ListingQrcodeURL != nil { + site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) + updates["listing_qrcode_url"] = site.ListingQrcodeURL + } if len(updates) > 0 { if err := r.db.WithContext(ctx).Model(&site).Updates(updates).Error; err != nil { return nil, err diff --git a/backend/internal/modules/file/handler.go b/backend/internal/modules/file/handler.go index 4a13220..f82586c 100644 --- a/backend/internal/modules/file/handler.go +++ b/backend/internal/modules/file/handler.go @@ -101,7 +101,8 @@ func (h *Handler) writeObject(c *gin.Context, publicOnly bool) { !strings.HasPrefix(key, "mohong/") && !strings.HasPrefix(key, "crash/") && !strings.HasPrefix(key, "aw-recycle/") && - !strings.HasPrefix(key, "cooperation-feedback/") { + !strings.HasPrefix(key, "cooperation-feedback/") && + !strings.HasPrefix(key, "support-qrcode/") { response.Error(c, http.StatusNotFound, "not_found", "文件不存在或暂不可访问") return } diff --git a/backend/internal/modules/file/service.go b/backend/internal/modules/file/service.go index 8a7c37b..3a24eb6 100644 --- a/backend/internal/modules/file/service.go +++ b/backend/internal/modules/file/service.go @@ -109,7 +109,7 @@ func normalizeContentType(contentType string, data []byte) string { func fileURLForScene(scene string, key string) string { fileURL := "/api/files/object?key=" + url.QueryEscape(key) - if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" { + if scene == "home-banner" || scene == "avatar" || scene == "announcement" || scene == "mohong" || scene == "crash" || scene == "aw-recycle" || scene == "cooperation-feedback" || scene == "support-qrcode" { fileURL = "/api/public/files/object?key=" + url.QueryEscape(key) } return fileURL @@ -118,7 +118,7 @@ func fileURLForScene(scene string, key string) string { func normalizeScene(scene string) string { scene = strings.TrimSpace(strings.ToLower(scene)) switch scene { - case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense": + case "listing", "handoff", "dispute", "realname", "avatar", "home-banner", "chat", "payment-cert", "announcement", "qrcode", "support-qrcode", "mohong", "crash", "aw-recycle", "cooperation-feedback", "manual-disbursement", "operating-expense": return scene case "listing_screenshot", "listing-screenshot": return "listing" diff --git a/backend/internal/modules/file/storage_test.go b/backend/internal/modules/file/storage_test.go index 30fe416..e1c5d12 100644 --- a/backend/internal/modules/file/storage_test.go +++ b/backend/internal/modules/file/storage_test.go @@ -77,6 +77,16 @@ func TestCooperationFeedbackSceneUsesPublicFileURL(t *testing.T) { } } +func TestSupportQrcodeSceneUsesPublicFileURL(t *testing.T) { + if got := normalizeScene("support-qrcode"); got != "support-qrcode" { + t.Fatalf("normalize support-qrcode scene = %q", got) + } + got := fileURLForScene("support-qrcode", "support-qrcode/2026/09/25/example.png") + if !strings.HasPrefix(got, "/api/public/files/object?") { + t.Fatalf("support-qrcode file url = %q, want public file endpoint", got) + } +} + func TestNormalizeSceneListingScreenshotAndOperatingExpense(t *testing.T) { if got := normalizeScene("listing_screenshot"); got != "listing" { t.Fatalf("normalize listing_screenshot scene = %q, want listing", got) diff --git a/backend/internal/modules/systemconfig/handler.go b/backend/internal/modules/systemconfig/handler.go index c4829fa..8d9a580 100644 --- a/backend/internal/modules/systemconfig/handler.go +++ b/backend/internal/modules/systemconfig/handler.go @@ -5,6 +5,7 @@ import ( "net/http" "hfb_sys/backend/internal/middleware" + "hfb_sys/backend/internal/sitecode" "hfb_sys/backend/pkg/response" "github.com/gin-gonic/gin" @@ -82,7 +83,7 @@ func (h *Handler) HomeAnnouncements(c *gin.Context) { } func (h *Handler) HomeConfig(c *gin.Context) { - config, err := h.service.HomeConfig(c.Request.Context()) + config, err := h.service.HomeConfig(c.Request.Context(), sitecode.FromRequest(c)) if err != nil { writeConfigError(c, err) return diff --git a/backend/internal/modules/systemconfig/home_override_test.go b/backend/internal/modules/systemconfig/home_override_test.go new file mode 100644 index 0000000..9ba594e --- /dev/null +++ b/backend/internal/modules/systemconfig/home_override_test.go @@ -0,0 +1,23 @@ +package systemconfig + +import "testing" + +func TestParseHomeEntryOverrideInheritsWhenEmpty(t *testing.T) { + if _, ok := parseAWRecycleOverride(" "); ok { + t.Fatal("空配置应继承主站") + } + if _, ok := parseCooperationFeedbackOverride(""); ok { + t.Fatal("空配置应继承主站") + } +} + +func TestParseHomeEntryOverrideUsesSiteJSON(t *testing.T) { + aw, ok := parseAWRecycleOverride(`{"title":"分站回收","subtitle":"看图","image_url":"/aw.png","enabled":true}`) + if !ok || aw.Title != "分站回收" || aw.ImageURL != "/aw.png" { + t.Fatalf("回收覆盖 = %+v ok=%v", aw, ok) + } + coop, ok := parseCooperationFeedbackOverride(`{"title":"分站合作","subtitle":"联系","image_url":"/coop.png","enabled":false}`) + if !ok || coop.Title != "分站合作" || coop.Enabled { + t.Fatalf("合作覆盖 = %+v ok=%v", coop, ok) + } +} diff --git a/backend/internal/modules/systemconfig/service_home.go b/backend/internal/modules/systemconfig/service_home.go index 945e582..a07f5d1 100644 --- a/backend/internal/modules/systemconfig/service_home.go +++ b/backend/internal/modules/systemconfig/service_home.go @@ -3,6 +3,9 @@ package systemconfig import ( "context" "encoding/json" + "strings" + + "hfb_sys/backend/internal/model" ) func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, error) { @@ -16,7 +19,7 @@ func (s *Service) HomeAnnouncements(ctx context.Context) (*HomeAnnouncementsDTO, return &HomeAnnouncementsDTO{Items: items}, nil } -func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) { +func (s *Service) HomeConfig(ctx context.Context, siteCode string) (*HomeConfigDTO, error) { if s.repo == nil { return nil, ErrDependencyUnavailable } @@ -40,13 +43,61 @@ func (s *Service) HomeConfig(ctx context.Context) (*HomeConfigDTO, error) { if err != nil { return nil, err } - return &HomeConfigDTO{ + config := &HomeConfigDTO{ Announcements: announcements, Banners: banners, PublishOptions: publishOptions, AWRecycle: awRecycle, CooperationFeedback: cooperationFeedback, - }, nil + } + s.applySiteHomeOverrides(ctx, siteCode, config) + return config, nil +} + +// applySiteHomeOverrides 用分站自己的首页入口覆盖主站配置。字段为空表示继续继承主站。 +func (s *Service) applySiteHomeOverrides(ctx context.Context, siteCode string, config *HomeConfigDTO) { + siteCode = strings.TrimSpace(siteCode) + if s == nil || s.repo == nil || s.repo.db == nil || config == nil || siteCode == "" { + return + } + var site model.DistributionSite + err := s.repo.db.WithContext(ctx). + Select("aw_recycle_json", "cooperation_feedback_json"). + Where("site_code = ? AND status = ?", siteCode, "active"). + First(&site).Error + if err != nil { + return + } + if item, ok := parseAWRecycleOverride(site.AWRecycleJSON); ok { + config.AWRecycle = item + } + if item, ok := parseCooperationFeedbackOverride(site.CooperationFeedbackJSON); ok { + config.CooperationFeedback = item + } +} + +func parseAWRecycleOverride(raw string) (AWRecycleConfig, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return AWRecycleConfig{}, false + } + var item AWRecycleConfig + if err := json.Unmarshal([]byte(raw), &item); err != nil { + return AWRecycleConfig{}, false + } + return normalizeAWRecycle(item), true +} + +func parseCooperationFeedbackOverride(raw string) (CooperationFeedbackConfig, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return CooperationFeedbackConfig{}, false + } + var item CooperationFeedbackConfig + if err := json.Unmarshal([]byte(raw), &item); err != nil { + return CooperationFeedbackConfig{}, false + } + return normalizeCooperationFeedback(item), true } func (s *Service) homeAnnouncements(ctx context.Context) ([]string, error) { diff --git a/backend/migrations/000073_distribution_site_home_entries.sql b/backend/migrations/000073_distribution_site_home_entries.sql new file mode 100644 index 0000000..0d3b28b --- /dev/null +++ b/backend/migrations/000073_distribution_site_home_entries.sql @@ -0,0 +1,12 @@ +-- +goose Up + +-- 分站首页入口。空字符串表示继承主站 mobile.aw_recycle / mobile.cooperation_feedback。 +ALTER TABLE distribution_sites + ADD COLUMN aw_recycle_json TEXT NULL COMMENT '分站 AW炫彩回收 JSON,空则继承主站' AFTER footer_info_json, + ADD COLUMN cooperation_feedback_json TEXT NULL COMMENT '分站合作与反馈 JSON,空则继承主站' AFTER aw_recycle_json; + +-- +goose Down + +ALTER TABLE distribution_sites + DROP COLUMN cooperation_feedback_json, + DROP COLUMN aw_recycle_json; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index d1a7847..8c8c69e 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -127,6 +127,8 @@ export interface Site { banners_json?: string announcements_json?: string footer_info_json?: string + aw_recycle_json?: string + cooperation_feedback_json?: string listing_qrcode_url?: string receive_ratio_delta?: number receive_cost_delta?: number @@ -646,6 +648,19 @@ export interface SiteBrandingPayload { banners_json?: string announcements_json?: string footer_info_json?: string + aw_recycle_json?: string + cooperation_feedback_json?: string + listing_qrcode_url?: string +} + +export async function uploadSiteImage(file: File, scene: string): Promise { + const form = new FormData() + form.append('file', file) + form.append('scene', scene) + const resp = await api.post>('/admin/files/upload', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return extract(resp).url } export async function updateSiteBranding(siteID: number, payload: SiteBrandingPayload): Promise { diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 4063420..efdb296 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -19,6 +19,7 @@ import { Switch, Alert, Popconfirm, + Upload, } from 'antd' import { SaveOutlined, @@ -41,6 +42,7 @@ import { fetchSiteSMSConfig, updateSiteSMSConfig, testSiteSMSConfig, + uploadSiteImage, type Site, type SiteBrandingPayload, type SiteSMSConfig, @@ -66,6 +68,46 @@ interface FooterInfo { contact?: string } +interface HomeEntryConfig { + title: string + subtitle: string + image_url: string + enabled: boolean +} + +const defaultAWEntry: HomeEntryConfig = { + title: 'AW炫彩回收', + subtitle: '点击查看回收说明', + image_url: '', + enabled: true, +} + +const defaultCoopEntry: HomeEntryConfig = { + title: '合作与反馈', + subtitle: '点击查看联系方式', + image_url: '', + enabled: true, +} + +function parseHomeEntry(raw: string | undefined, fallback: HomeEntryConfig) { + const text = (raw || '').trim() + if (!text) return { useMain: true, value: fallback } + try { + const parsed = JSON.parse(text) as Partial + return { + useMain: false, + value: { + title: parsed.title || fallback.title, + subtitle: parsed.subtitle || fallback.subtitle, + image_url: parsed.image_url || '', + enabled: parsed.enabled !== false, + }, + } + } catch { + return { useMain: true, value: fallback } + } +} + export default function SiteSettings() { const member = useMember() const [sites, setSites] = useState([]) @@ -85,6 +127,11 @@ export default function SiteSettings() { contact: '', }) const [receiveCostDelta, setReceiveCostDelta] = useState(0) + const [awUseMain, setAwUseMain] = useState(true) + const [awEntry, setAwEntry] = useState(defaultAWEntry) + const [coopUseMain, setCoopUseMain] = useState(true) + const [coopEntry, setCoopEntry] = useState(defaultCoopEntry) + const [listingQrcode, setListingQrcode] = useState('') const [savingRatio, setSavingRatio] = useState(false) // SMS config state @@ -141,6 +188,13 @@ export default function SiteSettings() { setFooterInfo({}) } setReceiveCostDelta(Math.max(0, found.receive_cost_delta || 0)) + const aw = parseHomeEntry(found.aw_recycle_json, defaultAWEntry) + setAwUseMain(aw.useMain) + setAwEntry(aw.value) + const coop = parseHomeEntry(found.cooperation_feedback_json, defaultCoopEntry) + setCoopUseMain(coop.useMain) + setCoopEntry(coop.value) + setListingQrcode(found.listing_qrcode_url || '') try { const sms = await fetchSiteSMSConfig(id) @@ -193,7 +247,7 @@ export default function SiteSettings() { setBanners([ ...banners, { - image_url: 'https://images.unsplash.com/photo-1542751371-adc38448a05e?w=1200&q=80', + image_url: '', title: '热门推荐活动', link: '', }, @@ -349,6 +403,71 @@ export default function SiteSettings() { } } + async function uploadEntryImage(file: File, scene: string, apply: (url: string) => void) { + try { + const url = await uploadSiteImage(file, scene) + apply(url) + message.success('图片已上传,保存后才会在前台生效') + } catch { + message.error('图片上传失败') + } + return false + } + + function renderEntryEditor( + useMain: boolean, + setUseMain: (value: boolean) => void, + entry: HomeEntryConfig, + setEntry: (value: HomeEntryConfig) => void, + scene: string, + ) { + return ( + +
+ 继承主站配置 + +
+ {useMain ? ( + 当前展示主站的标题、说明和图片。 + ) : ( + <> + setEntry({ ...entry, title: e.target.value })} + /> + setEntry({ ...entry, subtitle: e.target.value })} + /> +
+ 前台显示该入口 + setEntry({ ...entry, enabled: checked })} /> +
+ {entry.image_url ? ( + + ) : ( + 尚未上传图片 + )} + + uploadEntryImage(file, scene, (url) => setEntry({ ...entry, image_url: url }))} + > + + + + + + )} +
+ ) + } + async function handleSave() { if (!siteID) return setSaving(true) @@ -359,6 +478,9 @@ export default function SiteSettings() { banners_json: JSON.stringify(banners), announcements_json: JSON.stringify(announcements), footer_info_json: JSON.stringify(footerInfo), + aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry), + cooperation_feedback_json: coopUseMain ? '' : JSON.stringify(coopEntry), + listing_qrcode_url: listingQrcode.trim(), } const updated = await updateSiteBranding(siteID, payload) setCurrentSite(updated) @@ -379,7 +501,7 @@ export default function SiteSettings() { 品牌与站点可视化装修 - 可视化配置当前分销独立站的 Logo、首页轮播海报、跑马灯公告及页脚客服信息。 + 可视化配置当前分销独立站的 Logo、首页入口、客服二维码、轮播海报和页脚信息。未单独配置的回收与合作入口继续使用主站。 @@ -606,28 +728,64 @@ export default function SiteSettings() { children: (
- 站点 Logo 图片链接: - setLogoUrl(e.target.value)} - allowClear - style={{ marginTop: 6 }} - /> + 站点 Logo: + + {logoUrl ? ( + + ) : ( + 尚未上传 Logo + )} + + uploadEntryImage(file, 'avatar', setLogoUrl)} + > + + + + + setLogoUrl(e.target.value)} + allowClear + /> + - 建议尺寸 240x60 像素,透明底色 PNG 或 SVG 格式 + 建议尺寸 240x60 像素,透明底色 PNG
- 浏览器标签页 Favicon 图标: - setFaviconUrl(e.target.value)} - allowClear - style={{ marginTop: 6 }} - /> + 浏览器标签页 Favicon: + + {faviconUrl ? ( + + ) : ( + 尚未上传图标 + )} + + uploadEntryImage(file, 'avatar', setFaviconUrl)} + > + + + + + setFaviconUrl(e.target.value)} + allowClear + /> + 浏览器选项卡显示的小图标,建议 32x32 像素 @@ -693,8 +851,21 @@ export default function SiteSettings() { + + + uploadEntryImage(file, 'home-banner', (url) => + handleUpdateBanner(idx, 'image_url', url), + ) + } + > + + + handleUpdateBanner(idx, 'image_url', e.target.value)} /> @@ -777,6 +948,59 @@ export default function SiteSettings() { ), }, + { + key: 'entries', + label: '首页入口与客服二维码', + children: ( + + +
+ AW炫彩回收 +
+ {renderEntryEditor(awUseMain, setAwUseMain, awEntry, setAwEntry, 'aw-recycle')} +
+
+
+ 合作与反馈 +
+ {renderEntryEditor( + coopUseMain, + setCoopUseMain, + coopEntry, + setCoopEntry, + 'cooperation-feedback', + )} +
+
+
+ 客服二维码 + + {listingQrcode ? ( + + ) : ( + 未配置,发布群使用主站二维码池。 + )} + + uploadEntryImage(file, 'support-qrcode', setListingQrcode)} + > + + + + + +
+
+ ), + }, { key: 'footer', label: '页脚与客服备案',