diff --git a/backend/internal/model/distribution.go b/backend/internal/model/distribution.go index f611162..6593b0c 100644 --- a/backend/internal/model/distribution.go +++ b/backend/internal/model/distribution.go @@ -4,29 +4,31 @@ 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"` - 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"` + 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"` + // StorefrontTextJSON 首页文案(tagline/hero_badge/hero_desc/card_sub),空字段继承前台默认。 + StorefrontTextJSON string `gorm:"type:text" json:"storefront_text_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/distribution/dto.go b/backend/internal/modules/distribution/dto.go index 07ea5d1..87756af 100644 --- a/backend/internal/modules/distribution/dto.go +++ b/backend/internal/modules/distribution/dto.go @@ -16,6 +16,7 @@ type SiteDTO struct { FooterInfoJSON string `json:"footer_info_json"` AWRecycleJSON string `json:"aw_recycle_json"` CooperationFeedbackJSON string `json:"cooperation_feedback_json"` + StorefrontTextJSON string `json:"storefront_text_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ListingQrcodeURL string `json:"listing_qrcode_url"` ReceiveRatioDelta float64 `json:"receive_ratio_delta"` @@ -260,6 +261,7 @@ type PublicSiteInfoDTO struct { BannersJSON string `json:"banners_json"` AnnouncementsJSON string `json:"announcements_json"` FooterInfoJSON string `json:"footer_info_json"` + StorefrontTextJSON string `json:"storefront_text_json"` DefaultSupportGroupID *uint64 `json:"default_support_group_id,omitempty"` ReceiveRatioDelta float64 `json:"receive_ratio_delta"` ReceiveCostDelta float64 `json:"receive_cost_delta"` @@ -428,6 +430,7 @@ type UpdateSiteBrandingRequest struct { FooterInfoJSON *string `json:"footer_info_json"` AWRecycleJSON *string `json:"aw_recycle_json"` CooperationFeedbackJSON *string `json:"cooperation_feedback_json"` + StorefrontTextJSON *string `json:"storefront_text_json"` ListingQrcodeURL *string `json:"listing_qrcode_url"` } diff --git a/backend/internal/modules/distribution/repository.go b/backend/internal/modules/distribution/repository.go index 370a46c..c97ffc2 100644 --- a/backend/internal/modules/distribution/repository.go +++ b/backend/internal/modules/distribution/repository.go @@ -887,6 +887,7 @@ func siteDTO(row model.DistributionSite) SiteDTO { FooterInfoJSON: row.FooterInfoJSON, AWRecycleJSON: row.AWRecycleJSON, CooperationFeedbackJSON: row.CooperationFeedbackJSON, + StorefrontTextJSON: row.StorefrontTextJSON, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } @@ -2657,6 +2658,7 @@ func (r *Repository) GetPublicSiteInfo(ctx context.Context, code, domain string) BannersJSON: site.BannersJSON, AnnouncementsJSON: site.AnnouncementsJSON, FooterInfoJSON: site.FooterInfoJSON, + StorefrontTextJSON: site.StorefrontTextJSON, ReceiveRatioDelta: site.ReceiveRatioDelta, ReceiveCostDelta: site.ReceiveCostDelta, } @@ -2744,6 +2746,10 @@ func (r *Repository) UpdateSiteBranding(ctx context.Context, adminID, siteID uin site.CooperationFeedbackJSON = strings.TrimSpace(*req.CooperationFeedbackJSON) updates["cooperation_feedback_json"] = site.CooperationFeedbackJSON } + if req.StorefrontTextJSON != nil { + site.StorefrontTextJSON = strings.TrimSpace(*req.StorefrontTextJSON) + updates["storefront_text_json"] = site.StorefrontTextJSON + } if req.ListingQrcodeURL != nil { site.ListingQrcodeURL = strings.TrimSpace(*req.ListingQrcodeURL) updates["listing_qrcode_url"] = site.ListingQrcodeURL diff --git a/backend/internal/modules/distribution/repository_test.go b/backend/internal/modules/distribution/repository_test.go index f83cfe4..b9ce63e 100644 --- a/backend/internal/modules/distribution/repository_test.go +++ b/backend/internal/modules/distribution/repository_test.go @@ -637,12 +637,14 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { banners := `[{"image_url":"https://cdn.example.com/b1.jpg"}]` announcements := `[{"title":"开业大吉"}]` footer := `{"copyright":"2026 Brand"}` + storefrontText := `{"tagline":"天域商行 · 专注回收","hero_badge":"正品保障"}` updated, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ - LogoURL: &logo, - FaviconURL: &favicon, - BannersJSON: &banners, - AnnouncementsJSON: &announcements, - FooterInfoJSON: &footer, + LogoURL: &logo, + FaviconURL: &favicon, + BannersJSON: &banners, + AnnouncementsJSON: &announcements, + FooterInfoJSON: &footer, + StorefrontTextJSON: &storefrontText, }) if err != nil { t.Fatalf("UpdateSiteBranding failed: %v", err) @@ -650,6 +652,30 @@ func TestDistributionBrandingAndMarkup(t *testing.T) { if updated.LogoURL != "https://cdn.example.com/logo.png" || updated.FaviconURL != "https://cdn.example.com/favicon.ico" { t.Fatalf("unexpected branding: %+v", updated) } + if updated.StorefrontTextJSON != storefrontText { + t.Fatalf("storefront_text_json = %q, want %q", updated.StorefrontTextJSON, storefrontText) + } + + // 公开站点信息必须透出首页文案,前台才能读到站长自定义内容。 + publicInfo, err := repo.GetPublicSiteInfo(t.Context(), site.SiteCode, "") + if err != nil { + t.Fatalf("GetPublicSiteInfo failed: %v", err) + } + if publicInfo.StorefrontTextJSON != storefrontText { + t.Fatalf("public storefront_text_json = %q, want %q", publicInfo.StorefrontTextJSON, storefrontText) + } + + // 传空字符串表示清除自定义文案,前台回落内置默认值。 + emptyText := "" + textCleared, err := repo.UpdateSiteBranding(t.Context(), 0, site.ID, UpdateSiteBrandingRequest{ + StorefrontTextJSON: &emptyText, + }) + if err != nil { + t.Fatalf("clearing storefront text failed: %v", err) + } + if textCleared.StorefrontTextJSON != "" { + t.Fatalf("storefront_text_json should be cleared, got %q", textCleared.StorefrontTextJSON) + } qrURL := "/api/files/object?key=qrcode/brand-site.png" qrUpdated, err := repo.UpdateSite(t.Context(), 0, site.ID, UpdateSiteRequest{ListingQrcodeURL: &qrURL}) diff --git a/backend/migrations/000076_distribution_site_storefront_text.sql b/backend/migrations/000076_distribution_site_storefront_text.sql new file mode 100644 index 0000000..c9a2891 --- /dev/null +++ b/backend/migrations/000076_distribution_site_storefront_text.sql @@ -0,0 +1,15 @@ +-- +goose Up + +-- 分站首页文案(站长可在分销后台「品牌装修」自定义)。JSON 字段: +-- tagline 头部品牌区副标题(PC + 移动) +-- hero_badge 首页主视觉徽标(PC Hero + 移动认证卡片) +-- hero_desc 首页主视觉描述(PC) +-- card_sub 移动端认证卡片副标题 +-- 空值或空字符串表示沿用前台内置默认文案,因此不影响存量站点。 +ALTER TABLE distribution_sites + ADD COLUMN storefront_text_json TEXT NULL COMMENT '分站首页文案 JSON(tagline/hero_badge/hero_desc/card_sub),空字段继承默认' AFTER cooperation_feedback_json; + +-- +goose Down + +ALTER TABLE distribution_sites + DROP COLUMN storefront_text_json; diff --git a/distribution-admin/src/api.ts b/distribution-admin/src/api.ts index edf8e22..7e82651 100644 --- a/distribution-admin/src/api.ts +++ b/distribution-admin/src/api.ts @@ -129,6 +129,7 @@ export interface Site { footer_info_json?: string aw_recycle_json?: string cooperation_feedback_json?: string + storefront_text_json?: string listing_qrcode_url?: string receive_ratio_delta?: number receive_cost_delta?: number @@ -651,6 +652,7 @@ export interface SiteBrandingPayload { footer_info_json?: string aw_recycle_json?: string cooperation_feedback_json?: string + storefront_text_json?: string listing_qrcode_url?: string } diff --git a/distribution-admin/src/pages/SiteSettings.tsx b/distribution-admin/src/pages/SiteSettings.tsx index 9a54231..62d6bb9 100644 --- a/distribution-admin/src/pages/SiteSettings.tsx +++ b/distribution-admin/src/pages/SiteSettings.tsx @@ -62,6 +62,21 @@ interface AnnouncementItem { content?: string } +interface StorefrontTextForm { + tagline: string + hero_badge: string + hero_desc: string + card_sub: string +} + +// 与前台 shared/utils/storefrontText.ts 的默认文案保持一致(留空即回落到这些值)。 +const DEFAULT_STOREFRONT_TEXT: StorefrontTextForm = { + tagline: '安全交易 · 全程留痕', + hero_badge: '品质保障', + hero_desc: '账号均经过严格验号与安全质检,支持自动交付与全程留痕,为您提供安全无忧的租号体验。', + card_sub: '严格验号 · 统一质检交付 · 专属独立订单', +} + interface FooterInfo { copyright?: string icp?: string @@ -121,6 +136,9 @@ export default function SiteSettings() { const [faviconUrl, setFaviconUrl] = useState('') const [banners, setBanners] = useState([]) const [announcements, setAnnouncements] = useState([]) + const [storefrontText, setStorefrontText] = useState({ + ...DEFAULT_STOREFRONT_TEXT, + }) const [footerInfo, setFooterInfo] = useState({ copyright: '', icp: '', @@ -187,6 +205,20 @@ export default function SiteSettings() { } catch { setFooterInfo({}) } + + try { + const parsed = found.storefront_text_json + ? (JSON.parse(found.storefront_text_json) as Partial) + : {} + setStorefrontText({ + tagline: parsed.tagline?.trim() || '', + hero_badge: parsed.hero_badge?.trim() || '', + hero_desc: parsed.hero_desc?.trim() || '', + card_sub: parsed.card_sub?.trim() || '', + }) + } catch { + setStorefrontText({ tagline: '', hero_badge: '', hero_desc: '', card_sub: '' }) + } setReceiveCostDelta(Math.max(0, found.receive_cost_delta || 0)) const aw = parseHomeEntry(found.aw_recycle_json, defaultAWEntry) setAwUseMain(aw.useMain) @@ -468,6 +500,17 @@ export default function SiteSettings() { ) } + /** 全部留空时清除该配置(前台回落默认文案),否则按 JSON 保存。 */ + function storefrontTextFilled(value: StorefrontTextForm): string { + const filled = { + tagline: value.tagline.trim(), + hero_badge: value.hero_badge.trim(), + hero_desc: value.hero_desc.trim(), + card_sub: value.card_sub.trim(), + } + return Object.values(filled).some(Boolean) ? JSON.stringify(filled) : '' + } + async function handleSave() { if (!siteID) return setSaving(true) @@ -480,6 +523,7 @@ export default function SiteSettings() { footer_info_json: JSON.stringify(footerInfo), aw_recycle_json: awUseMain ? '' : JSON.stringify(awEntry), cooperation_feedback_json: coopUseMain ? '' : JSON.stringify(coopEntry), + storefront_text_json: storefrontTextFilled(storefrontText), listing_qrcode_url: listingQrcode.trim(), } const updated = await updateSiteBranding(siteID, payload) @@ -1001,6 +1045,58 @@ export default function SiteSettings() { ), }, + { + key: 'storefront_text', + label: '首页文案', + children: ( + + + 留空则使用默认文案(占位提示即默认值)。这些文案出现在商城头部与首页主视觉,可按本站品牌口径自由填写。 + +
+ 头部品牌区副标题 + + setStorefrontText({ ...storefrontText, tagline: e.target.value }) + } + /> +
+
+ 首页主视觉徽标 + + setStorefrontText({ ...storefrontText, hero_badge: e.target.value }) + } + /> +
+
+ 首页主视觉描述 + + setStorefrontText({ ...storefrontText, hero_desc: e.target.value }) + } + /> +
+
+ 移动端认证卡片副标题 + + setStorefrontText({ ...storefrontText, card_sub: e.target.value }) + } + /> +
+
+ ), + }, { key: 'footer', label: '页脚与客服备案', @@ -1126,11 +1222,18 @@ export default function SiteSettings() { {logoUrl ? ( Logo ) : ( - - {currentSite?.site_name || 'HFB 分销商城'} - + + + {currentSite?.site_name || 'HFB 分销商城'} + + + {storefrontText.tagline.trim() || DEFAULT_STOREFRONT_TEXT.tagline} + + )} - 分销前台 + + {storefrontText.hero_badge.trim() || DEFAULT_STOREFRONT_TEXT.hero_badge} + {/* Simulated Announcements Marquee */} diff --git a/docs/分销网管理后台设计方案.md b/docs/分销网管理后台设计方案.md index f960a15..fb596bd 100644 --- a/docs/分销网管理后台设计方案.md +++ b/docs/分销网管理后台设计方案.md @@ -867,3 +867,8 @@ distribution.hao858.com { - 生产与测试口径一致:结算批次入账、站点提现与冷却期自动释放不再按 `APP_ENV` 冻结(原 `liveMoneyMovementEnabled` 生产保护已移除)。 - 新建站点的默认结算冷却天数由系统配置 `distribution.default_settlement_hold_days` 统一控制(默认 7 天,0=立即释放,最大 365),管理端可在系统配置页调整;站点级 `settlement_hold_days` 仍可单独覆盖。 - `distribution-admin` 基于 React + TypeScript + Vite + Ant Design,覆盖经营大盘、双视图资产管理、选品货架、订单中心、品牌装修与钱包明细。 +- 分站前台统一按「一手独立商城」呈现:不向顾客暴露分销身份(无「官方分销」标识), + 登录/注册页与首页品牌区的站名、Logo 均取自站点配置。 +- 首页文案支持站长自定义:`distribution_sites.storefront_text_json` + (`tagline` 头部副标题 / `hero_badge` 主视觉徽标 / `hero_desc` 主视觉描述 / `card_sub` 移动端卡片副标题), + 在分销后台「品牌装修 → 首页文案」编辑;字段留空或整段为空时回落到前台内置默认文案。 diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 1710bb8..a1dbc66 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -6,10 +6,12 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { fetchAuthCaptcha, sendSmsCode, type AuthCaptcha } from '@/features/auth/api/auth' +import { useDistributionSiteStore } from '@/stores/distributionSite' import { useSessionStore } from '@/stores/session' const router = useRouter() const session = useSessionStore() +const siteStore = useDistributionSiteStore() const loading = ref(false) const sending = ref(false) const captchaLoading = ref(false) @@ -177,8 +179,9 @@ const modeSubtitle = computed(() =>