feat(distribution): 分站前台按独立商城呈现并支持首页文案自定义

分站前台原本对顾客暴露分销身份,且登录等页面写死了主站品牌,
与「一手独立商城」的定位不符;同时首页文案全部硬编码,站长无法按自己口径调整。

去分销身份(顾客可见处):
- 头部品牌区、首页主视觉、移动端认证卡去掉「官方分销/官方分销认证/官方分销站」标识
- 会员徽标「分销专享」→「会员专享」,「所属商城」兜底「专属分销站/商城」→「官方商城」
- 订单页说明改为「当前为【X】商城,仅展示在本站产生的订单记录」,保留订单按站隔离的必要说明
- 无公告时的兜底跑马灯、轮播角标去掉分销措辞
- 清理随之失效的 pc-brand-badge / mobile-brand-badge 样式

品牌跟随站点配置(即可自定义):
- PC/移动登录页、移动注册页、加桌面引导页的品牌改为 站点名称 + Logo(无 Logo 取站名首字),
  不再写死主站「大锤商行」;主站行为不变(新增 store getter brandMark:分站取站名首字,主站仍为「锤」)

首页文案可配置:
- 新增迁移 000076 与 distribution_sites.storefront_text_json
  (tagline 头部副标题 / hero_badge 主视觉徽标 / hero_desc 主视觉描述 / card_sub 移动端卡片副标题)
- 分销后台「品牌装修 → 首页文案」可编辑,占位提示即默认值,留空或整段清空回落内置默认
- 贯通 UpdateSiteBranding → 公开站点信息接口(含 Redis 缓存失效)→ 前台 store 逐字段合并默认值
- 装修预览同步展示站长填写的副标题与徽标

测试:
- 后端补装修字段写入/公开接口透出/清空回落断言
- 前端新增 storefrontText 单测(默认合并、覆盖、空白与非法类型回落)
This commit is contained in:
yml2213
2026-10-01 09:24:56 +08:00
parent 53b40f8fad
commit 329d0f75bb
22 changed files with 358 additions and 89 deletions
+14 -3
View File
@@ -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(() =>
<div class="login-card">
<div class="login-left">
<div class="brand-lockup">
<div class="brand-logo">锤</div>
<strong>大锤商行</strong>
<img v-if="siteStore.logoUrl" :src="siteStore.logoUrl" alt="" class="brand-logo-img" />
<div v-else class="brand-logo">{{ siteStore.brandMark }}</div>
<strong>{{ siteStore.siteName }}</strong>
</div>
<div class="login-hero">
<h2>安全租号<br />畅享游戏</h2>
@@ -188,7 +191,7 @@ const modeSubtitle = computed(() =>
</p>
</div>
<div class="login-footer">
<span>© 哈夫币租号平台</span>
<span>© {{ siteStore.isDistribution ? siteStore.siteName : '哈夫币租号平台' }}</span>
</div>
</div>
@@ -488,6 +491,14 @@ const modeSubtitle = computed(() =>
font-weight: 900;
}
.brand-logo-img {
width: 36px;
height: 36px;
border-radius: 10px;
object-fit: cover;
box-shadow: 0 8px 24px rgba(23, 35, 61, 0.12);
}
.brand-lockup strong {
color: #0f172a;
font-size: 15px;
@@ -5,12 +5,14 @@ import { showToast, showDialog } from 'vant'
import { fetchAuthCaptcha, type AuthCaptcha } from '@/features/auth/api/auth'
import { useSessionStore } from '@/stores/session'
import { useDistributionSiteStore } from '@/stores/distributionSite'
import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const loading = ref(false)
const agreed = ref(false)
const captchaLoading = ref(false)
@@ -116,8 +118,9 @@ async function handlePasswordLogin() {
<section class="auth-body">
<div class="brand-lockup">
<div class="brand-logo">锤</div>
<strong>大锤商行</strong>
<img v-if="siteStore.logoUrl" :src="siteStore.logoUrl" alt="" class="brand-logo-img" />
<div v-else class="brand-logo">{{ siteStore.brandMark }}</div>
<strong>{{ siteStore.siteName }}</strong>
</div>
<div class="form-section">
@@ -293,6 +296,14 @@ async function handlePasswordLogin() {
font-weight: 900;
}
.brand-logo-img {
width: 44px;
height: 44px;
border-radius: 12px;
object-fit: cover;
box-shadow: 0 10px 28px rgba(23, 35, 61, 0.12);
}
.brand-lockup strong {
color: #0f172a;
font-size: 20px;
@@ -359,7 +359,7 @@ function resolveAvatarURL(url: string | undefined | null) {
<section v-if="siteStore.isDistribution" class="growth-card is-distribution">
<div>
<span>所属商城</span>
<strong>{{ siteStore.siteName || '专属分销商城' }}</strong>
<strong>{{ siteStore.siteName || '官方商城' }}</strong>
</div>
<div>
<span>会员特权</span>
@@ -5,12 +5,14 @@ import { showToast } from 'vant'
import { fetchAuthCaptcha, type AuthCaptcha } from '@/features/auth/api/auth'
import { useSessionStore } from '@/stores/session'
import { useDistributionSiteStore } from '@/stores/distributionSite'
import { useSmsCountdown } from '@/shared/composables/useSmsCountdown'
import { readError } from '@/shared/utils/error'
const router = useRouter()
const route = useRoute()
const session = useSessionStore()
const siteStore = useDistributionSiteStore()
const loading = ref(false)
const agreed = ref(false)
const captchaLoading = ref(false)
@@ -95,8 +97,9 @@ async function handleRegister() {
<section class="auth-body">
<div class="brand-lockup">
<div class="brand-logo">锤</div>
<strong>大锤商行</strong>
<img v-if="siteStore.logoUrl" :src="siteStore.logoUrl" alt="" class="brand-logo-img" />
<div v-else class="brand-logo">{{ siteStore.brandMark }}</div>
<strong>{{ siteStore.siteName }}</strong>
</div>
<div class="form-section">
@@ -267,6 +270,14 @@ async function handleRegister() {
font-weight: 900;
}
.brand-logo-img {
width: 46px;
height: 46px;
border-radius: 14px;
object-fit: cover;
box-shadow: 0 14px 32px rgba(23, 35, 61, 0.12);
}
.brand-lockup strong {
display: block;
color: #111827;
@@ -3,8 +3,10 @@ import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from 'vant'
import shareMenuIcon from '@/assets/share-menu-icon.png'
import { useDistributionSiteStore } from '@/stores/distributionSite'
const router = useRouter()
const siteStore = useDistributionSiteStore()
const homeUrl = computed(() => `${window.location.origin}/m`)
@@ -32,18 +34,22 @@ async function copyHomeUrl() {
<section class="guide-hero">
<div class="guide-hero-copy">
<span>大锤商行</span>
<span>{{ siteStore.siteName }}</span>
<h2>如何添加到手机</h2>
<p>添加后可从桌面直接进入,找号、下单和查看消息更顺手。</p>
</div>
<div class="hammer-badge" aria-hidden="true">锤</div>
<div class="hammer-badge" aria-hidden="true">{{ siteStore.brandMark }}</div>
</section>
<section class="guide-step">
<div class="step-index">1</div>
<div class="step-body">
<h3>点击浏览器「分享/共享」</h3>
<p>请先用 Safari、Chrome 或系统浏览器打开大锤商行,在底部工具栏或菜单里找到入口。</p>
<p>
请先用 Safari、Chrome 或系统浏览器打开{{
siteStore.siteName
}},在底部工具栏或菜单里找到入口。
</p>
<div class="browser-demo" aria-label="浏览器分享或共享按钮示意">
<div class="demo-nav">
<span>AA</span>
@@ -51,7 +51,7 @@ const displayAnnouncements = computed(() => {
return siteStore.announcements
}
return [
`【${siteStore.siteName}】官方认证独立分销站:账号实时同步主站,官方质检,安全秒交接!`,
`【${siteStore.siteName}】精选三角洲账号:官方验号,安全秒交接,售后有保障!`,
...announcements.value,
]
}
@@ -63,7 +63,7 @@ const displayBanners = computed<HomeBannerSlide[]>(() => {
return siteStore.banners.map(b => ({
eyebrow: siteStore.siteName,
title: b.title || '精选游戏账号 · 随租随玩',
badge: '分销精选',
badge: '本店精选',
pill: '官方保障',
tone: 'gold',
image_url: b.image_url,
@@ -441,17 +441,15 @@ if (!restored) {
<HomeAnnouncement :announcements="displayAnnouncements" />
<main class="home-content">
<!-- 分销站专属标识 Banner -->
<!-- 商城专属标识 Banner -->
<div v-if="siteStore.isDistribution" class="dist-portal-banner">
<div class="dist-portal-content">
<div class="dist-portal-tag">
<span class="dist-pulse"></span>
官方分销认证
{{ siteStore.storefrontText.hero_badge }}
</div>
<h2 class="dist-portal-title">{{ siteStore.siteName }} 专区</h2>
<p class="dist-portal-desc">
全站上架账号均由平台统一质检与自动交付,订单数据独立隔离,为您提供安全无忧的租号体验。
</p>
<h2 class="dist-portal-title">{{ siteStore.siteName }}</h2>
<p class="dist-portal-desc">{{ siteStore.storefrontText.hero_desc }}</p>
</div>
<div class="dist-portal-badges">
<div class="dist-badge-item">
@@ -71,19 +71,6 @@
font-size: 17px;
}
.mobile-brand-badge {
display: inline-flex;
align-items: center;
padding: 1px 5px;
font-size: 10px;
font-weight: 700;
color: #ff6a00;
background: rgba(255, 106, 0, 0.1);
border: 1px solid rgba(255, 106, 0, 0.25);
border-radius: 999px;
line-height: 1.3;
}
.mobile-brand small {
color: #64748b;
font-size: 13px;
@@ -90,7 +90,7 @@ const announcements = ref<string[]>(defaultHomeAnnouncements)
const displayAnnouncements = computed(() => {
if (siteStore.isDistribution) {
return [
`【${siteStore.siteName}】官方认证分销站,账号实时同步主站,售后有保障!`,
`【${siteStore.siteName}】精选三角洲账号:官方验号,安全秒交接,售后有保障!`,
...announcements.value,
]
}
@@ -841,9 +841,10 @@ if (!restoreHomeConfigFromCache()) {
<div>
<div class="mobile-brand-title-row">
<strong>{{ siteStore.siteName }}</strong>
<span v-if="siteStore.isDistribution" class="mobile-brand-badge">官方分销</span>
</div>
<small>{{ siteStore.isDistribution ? '官方分销·平台保障' : '哈夫币租号' }}</small>
<small>{{
siteStore.isDistribution ? siteStore.storefrontText.tagline : '哈夫币租号'
}}</small>
</div>
</div>
<button
@@ -898,13 +899,13 @@ if (!restoreHomeConfigFromCache()) {
<!-- ========== Content 区域 ========== -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<section class="mobile-content">
<!-- 分销站移动端专属认证卡片 -->
<!-- 商城移动端专属认证卡片 -->
<div v-if="siteStore.isDistribution" class="mobile-dist-card">
<div class="dist-card-top">
<span class="dist-card-badge">官方分销站</span>
<span class="dist-card-badge">{{ siteStore.storefrontText.hero_badge }}</span>
<strong class="dist-card-name">{{ siteStore.siteName }}</strong>
</div>
<p class="dist-card-sub">官方平台实时同步 · 统一质检交付 · 专属独立订单</p>
<p class="dist-card-sub">{{ siteStore.storefrontText.card_sub }}</p>
</div>
<!-- Banner 轮播 -->
@@ -313,7 +313,7 @@ function bizTypeLabel(bizType: BizType) {
<span class="orders-total-badge">{{ unifiedOrders.length }} 笔订单</span>
</div>
<p v-if="siteStore.isDistribution" class="hero-subtitle">
当前为【{{ siteStore.siteName }}】专属分销商城,仅展示在此分销站产生的订单记录
当前为【{{ siteStore.siteName }}】商城,仅展示在本站产生的订单记录
</p>
<p v-else class="hero-subtitle">租赁与撞车订单统一管理 · 实时交接与进度追踪</p>
</div>
-14
View File
@@ -81,20 +81,6 @@
color: #0f172a;
}
.pc-brand-badge {
display: inline-flex;
align-items: center;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
color: #ff6a00;
background: rgba(255, 106, 0, 0.1);
border: 1px solid rgba(255, 106, 0, 0.25);
border-radius: 999px;
line-height: 1.4;
letter-spacing: 0.2px;
}
.pc-brand-sub {
font-size: 11px;
color: #94a3b8;
+3 -4
View File
@@ -158,10 +158,9 @@ async function handleSupportClick() {
<div class="pc-brand-info">
<div class="pc-brand-title-row">
<span class="pc-brand-text">{{ siteStore.siteName }}</span>
<span v-if="siteStore.isDistribution" class="pc-brand-badge">官方分销</span>
</div>
<small class="pc-brand-sub">{{
siteStore.isDistribution ? '官方分销·平台保障' : '哈夫币租号'
siteStore.isDistribution ? siteStore.storefrontText.tagline : '哈夫币租号'
}}</small>
</div>
</RouterLink>
@@ -232,7 +231,7 @@ async function handleSupportClick() {
</span>
<span class="pc-user-name">{{ session.displayName }}</span>
<span v-if="siteStore.isDistribution" class="pc-growth-badge is-distribution">
分销专享
会员专享
</span>
<span v-else class="pc-growth-badge" :class="`is-${growthBadgeTone}`">
{{ session.renterGrowthLevelName }}
@@ -254,7 +253,7 @@ async function handleSupportClick() {
>
<div>
<span>所属商城</span>
<strong>{{ siteStore.siteName || '专属分销站' }}</strong>
<strong>{{ siteStore.siteName || '官方商城' }}</strong>
</div>
<div>
<span>会员权益</span>
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { defaultStorefrontText, mergeStorefrontText } from '../storefrontText'
describe('mergeStorefrontText', () => {
it('未配置时全部回落默认文案', () => {
expect(mergeStorefrontText()).toEqual(defaultStorefrontText)
expect(mergeStorefrontText(null)).toEqual(defaultStorefrontText)
expect(mergeStorefrontText({})).toEqual(defaultStorefrontText)
})
it('站长配置覆盖对应字段,其余保持默认', () => {
const merged = mergeStorefrontText({ tagline: '天域商行 · 专注回收', hero_badge: '正品保障' })
expect(merged.tagline).toBe('天域商行 · 专注回收')
expect(merged.hero_badge).toBe('正品保障')
expect(merged.hero_desc).toBe(defaultStorefrontText.hero_desc)
expect(merged.card_sub).toBe(defaultStorefrontText.card_sub)
})
it('空白配置按未配置处理(不允许出现空文案)', () => {
const merged = mergeStorefrontText({ tagline: ' ', hero_desc: '' })
expect(merged.tagline).toBe(defaultStorefrontText.tagline)
expect(merged.hero_desc).toBe(defaultStorefrontText.hero_desc)
})
it('去掉首尾空白并忽略非法类型', () => {
const merged = mergeStorefrontText({
tagline: ' 安全交易 · 全程留痕 ',
hero_badge: 123 as unknown as string,
})
expect(merged.tagline).toBe('安全交易 · 全程留痕')
expect(merged.hero_badge).toBe(defaultStorefrontText.hero_badge)
})
})
@@ -0,0 +1,38 @@
/**
* 分站首页文案(站长可在分销后台「品牌装修」自定义,留空则用这里的默认值)。
*
* 这些位置原本是写死的分销标识(如「官方分销认证」),改成可配置后:
* - 各站可以写自己的口号,前台看起来是独立商城
* - 不配置时行为与默认口径一致
*/
export interface StorefrontText {
/** 头部品牌区副标题(PC + 移动) */
tagline: string
/** 首页主视觉徽标(PC Hero + 移动认证卡片) */
hero_badge: string
/** 首页主视觉描述(PC) */
hero_desc: string
/** 移动端认证卡片副标题 */
card_sub: string
}
export const defaultStorefrontText: StorefrontText = {
tagline: '安全交易 · 全程留痕',
hero_badge: '品质保障',
hero_desc: '账号均经过严格验号与安全质检,支持自动交付与全程留痕,为您提供安全无忧的租号体验。',
card_sub: '严格验号 · 统一质检交付 · 专属独立订单',
}
/** 逐字段合并站长配置:未配置或配置为空的字段回落到默认文案。 */
export function mergeStorefrontText(raw?: Partial<StorefrontText> | null): StorefrontText {
return {
tagline: readText(raw?.tagline, defaultStorefrontText.tagline),
hero_badge: readText(raw?.hero_badge, defaultStorefrontText.hero_badge),
hero_desc: readText(raw?.hero_desc, defaultStorefrontText.hero_desc),
card_sub: readText(raw?.card_sub, defaultStorefrontText.card_sub),
}
}
function readText(value: unknown, fallback: string): string {
return typeof value === 'string' && value.trim() ? value.trim() : fallback
}
+25
View File
@@ -2,6 +2,11 @@ import { defineStore } from 'pinia'
import { apiClient } from '@/shared/api/client'
import type { ApiResponse } from '@/shared/types/types'
import { normalizeAnnouncements } from '@/shared/utils/announcements'
import {
defaultStorefrontText,
mergeStorefrontText,
type StorefrontText,
} from '@/shared/utils/storefrontText'
export interface DistributionBanner {
image_url: string
@@ -28,6 +33,7 @@ export interface DistributionSiteInfo {
banners_json?: string
announcements_json?: string
footer_info_json?: string
storefront_text_json?: string
receive_ratio_delta?: number
receive_cost_delta?: number
}
@@ -74,12 +80,20 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
banners: [] as DistributionBanner[],
announcements: [] as string[],
footerInfo: null as DistributionFooterInfo | null,
storefrontText: { ...defaultStorefrontText } as StorefrontText,
receiveRatioDelta: 0,
receiveCostDelta: 0,
initialized: false,
}
},
getters: {
/** 品牌标识字符:分站取站名首字,主站沿用「锤」。 */
brandMark(state): string {
return state.isDistribution && state.siteName ? state.siteName.slice(0, 1) : '锤'
},
},
actions: {
async initSite() {
const envCode = ((import.meta.env.VITE_DISTRIBUTION_SITE_CODE as string) || '').trim()
@@ -181,6 +195,16 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
this.footerInfo = null
}
if (info.storefront_text_json) {
try {
this.storefrontText = mergeStorefrontText(JSON.parse(info.storefront_text_json))
} catch {
this.storefrontText = { ...defaultStorefrontText }
}
} else {
this.storefrontText = { ...defaultStorefrontText }
}
this.receiveRatioDelta = Math.max(0, Number(info.receive_ratio_delta || 0))
this.receiveCostDelta = Math.max(0, Number(info.receive_cost_delta || 0))
@@ -221,6 +245,7 @@ export const useDistributionSiteStore = defineStore('distributionSite', {
this.banners = []
this.announcements = []
this.footerInfo = null
this.storefrontText = { ...defaultStorefrontText }
this.receiveRatioDelta = 0
this.receiveCostDelta = 0
sessionStorage.removeItem('hfb_site_code')