Files
hfb_jysh/distribution-admin/src/pages/SiteSettings.tsx
T

1417 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react'
import {
Card,
Typography,
Space,
Select,
message,
Button,
Form,
Input,
InputNumber,
Row,
Col,
Divider,
Image,
Tag,
Tabs,
Collapse,
Switch,
Alert,
Popconfirm,
Upload,
} from 'antd'
import {
SaveOutlined,
PictureOutlined,
NotificationOutlined,
EyeOutlined,
PlusOutlined,
DeleteOutlined,
InfoCircleOutlined,
SkinOutlined,
GlobalOutlined,
MessageOutlined,
SendOutlined,
} from '@ant-design/icons'
import {
listSites,
updateSiteBranding,
updateSite,
getMe,
fetchSiteSMSConfig,
updateSiteSMSConfig,
testSiteSMSConfig,
uploadSiteImage,
type Site,
type SiteBrandingPayload,
type SiteSMSConfig,
type UpdateSiteSMSConfigPayload,
type TestSiteSMSConfigPayload,
} from '../api'
import { useMember } from '../MemberContext'
import BannerVisualEditor from '../components/BannerVisualEditor'
import {
HOME_BANNER_MOBILE_RATIO,
HOME_BANNER_PC_RATIO,
createEmptyDistributionBanner,
parseDistributionBanners,
serializeDistributionBanners,
type DistributionBannerConfig,
} from '../homeBanner'
interface AnnouncementItem {
title: string
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
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<HomeEntryConfig>
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<Site[]>([])
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [currentSite, setCurrentSite] = useState<Site | null>(null)
// Visual state for customizer
const [logoUrl, setLogoUrl] = useState<string>('')
const [faviconUrl, setFaviconUrl] = useState<string>('')
const [banners, setBanners] = useState<DistributionBannerConfig[]>([])
const [announcements, setAnnouncements] = useState<AnnouncementItem[]>([])
const [storefrontText, setStorefrontText] = useState<StorefrontTextForm>({
...DEFAULT_STOREFRONT_TEXT,
})
const [footerInfo, setFooterInfo] = useState<FooterInfo>({
copyright: '',
icp: '',
contact: '',
})
const [receiveCostDelta, setReceiveCostDelta] = useState(0)
const [awUseMain, setAwUseMain] = useState(true)
const [awEntry, setAwEntry] = useState<HomeEntryConfig>(defaultAWEntry)
const [coopUseMain, setCoopUseMain] = useState(true)
const [coopEntry, setCoopEntry] = useState<HomeEntryConfig>(defaultCoopEntry)
const [savingRatio, setSavingRatio] = useState(false)
// SMS config state
const [smsConfig, setSmsConfig] = useState<SiteSMSConfig>({
enabled: false,
provider: 'aliyun',
access_key_id: '',
masked_secret: '',
has_secret: false,
endpoint: 'dysmsapi.aliyuncs.com',
sign_name: '',
login_template_code: '',
})
const [smsSecretInput, setSmsSecretInput] = useState('')
const [savingSMS, setSavingSMS] = useState(false)
const [testPhone, setTestPhone] = useState('')
const [testingSMS, setTestingSMS] = useState(false)
const [testCooldown, setTestCooldown] = useState(0)
useEffect(() => {
if (testCooldown <= 0) return
const timer = setInterval(() => {
setTestCooldown((prev) => (prev > 0 ? prev - 1 : 0))
}, 1000)
return () => clearInterval(timer)
}, [testCooldown])
const loadSite = useCallback(async (id: number) => {
setLoading(true)
try {
const rows = await listSites()
setSites(rows)
const found = rows.find((s) => s.id === id)
if (found) {
setCurrentSite(found)
setLogoUrl(found.logo_url || '')
setFaviconUrl(found.favicon_url || '')
setBanners(parseDistributionBanners(found.banners_json, found.site_name))
try {
setAnnouncements(found.announcements_json ? JSON.parse(found.announcements_json) : [])
} catch {
setAnnouncements([])
}
try {
setFooterInfo(found.footer_info_json ? JSON.parse(found.footer_info_json) : {})
} catch {
setFooterInfo({})
}
try {
const parsed = found.storefront_text_json
? (JSON.parse(found.storefront_text_json) as Partial<StorefrontTextForm>)
: {}
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)
setAwEntry(aw.value)
const coop = parseHomeEntry(found.cooperation_feedback_json, defaultCoopEntry)
setCoopUseMain(coop.useMain)
setCoopEntry(coop.value)
try {
const sms = await fetchSiteSMSConfig(id)
setSmsConfig(sms)
setSmsSecretInput('')
} catch {
// ignore
}
}
} catch {
message.error('加载站点设置失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
async function init() {
let currentMember = member
if (!currentMember) {
try {
currentMember = await getMe()
} catch {
// ignore
}
}
const activeSiteID = currentMember?.site_id
if (activeSiteID) {
setSiteID(activeSiteID)
void loadSite(activeSiteID)
}
if (currentMember?.is_superadmin) {
try {
const rows = await listSites()
setSites(rows)
if (!activeSiteID && rows.length > 0) {
setSiteID(rows[0].id)
void loadSite(rows[0].id)
}
} catch {
// ignore
}
}
}
void init()
}, [member, loadSite])
function handleAddBanner() {
setBanners([
...banners,
{
...createEmptyDistributionBanner(),
eyebrow: '首页推荐',
},
])
}
function handleUpdateBanner(index: number, patch: Partial<DistributionBannerConfig>) {
setBanners((current) =>
current.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)),
)
}
function handleMoveBanner(index: number, step: number) {
const target = index + step
if (target < 0 || target >= banners.length) return
const next = [...banners]
const [item] = next.splice(index, 1)
if (!item) return
next.splice(target, 0, item)
setBanners(next)
}
function handleDeleteBanner(index: number) {
setBanners(banners.filter((_, i) => i !== index))
}
function handleAddAnnouncement() {
setAnnouncements([
...announcements,
{
title: '平台全新上线精选商品,免押畅玩!',
content: '',
},
])
}
function handleUpdateAnnouncement(index: number, field: keyof AnnouncementItem, value: string) {
const next = [...announcements]
next[index] = { ...next[index], [field]: value }
setAnnouncements(next)
}
function handleDeleteAnnouncement(index: number) {
setAnnouncements(announcements.filter((_, i) => i !== index))
}
async function handleSaveReceiveRatio() {
if (!siteID) return
setSavingRatio(true)
try {
const updated = await updateSite(siteID, { receive_cost_delta: receiveCostDelta })
setCurrentSite(updated)
setReceiveCostDelta(Math.max(0, updated.receive_cost_delta || 0))
message.success('收号压价已保存,本站收号价比主站更低,售价与主站一致')
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage || '保存收号比例失败')
} finally {
setSavingRatio(false)
}
}
async function handleSaveSMS() {
if (!siteID) return
if (smsConfig.enabled) {
if (!smsConfig.access_key_id?.trim()) {
message.warning('启用独立短信时,阿里云 AccessKey ID 不能为空')
return
}
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
message.warning('启用独立短信时,阿里云 AccessKey Secret 不能为空')
return
}
if (!smsConfig.sign_name?.trim()) {
message.warning('启用独立短信时,短信签名不能为空')
return
}
if (!smsConfig.login_template_code?.trim()) {
message.warning('启用独立短信时,登录验证码模板 Code 不能为空')
return
}
}
setSavingSMS(true)
try {
const payload: UpdateSiteSMSConfigPayload = {
enabled: smsConfig.enabled,
provider: smsConfig.provider || 'aliyun',
access_key_id: smsConfig.access_key_id.trim(),
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
sign_name: smsConfig.sign_name.trim(),
login_template_code: smsConfig.login_template_code.trim(),
}
if (smsSecretInput.trim()) {
payload.access_key_secret = smsSecretInput.trim()
}
const updated = await updateSiteSMSConfig(siteID, payload)
setSmsConfig(updated)
setSmsSecretInput('')
message.success('短信服务配置保存成功')
} catch (err: unknown) {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: '保存短信配置失败'
message.error(msg || '保存短信配置失败')
} finally {
setSavingSMS(false)
}
}
async function handleTestSMS() {
if (!siteID) return
const phone = testPhone.trim()
if (!phone) {
message.warning('请输入接收测试短信的手机号')
return
}
if (!/^1[3-9]\d{9}$/.test(phone)) {
message.warning('手机号格式不正确,请输入有效的11位手机号码')
return
}
if (!smsConfig.access_key_id?.trim()) {
message.warning('请先填写阿里云 AccessKey ID')
return
}
if (!smsConfig.has_secret && !smsSecretInput.trim()) {
message.warning('请先填写阿里云 AccessKey Secret')
return
}
if (!smsConfig.sign_name?.trim()) {
message.warning('请先填写短信签名')
return
}
if (!smsConfig.login_template_code?.trim()) {
message.warning('请先填写登录验证码模板 Code')
return
}
setTestingSMS(true)
try {
const payload: TestSiteSMSConfigPayload = {
phone,
enabled: true,
provider: smsConfig.provider || 'aliyun',
access_key_id: smsConfig.access_key_id.trim(),
endpoint: smsConfig.endpoint.trim() || 'dysmsapi.aliyuncs.com',
sign_name: smsConfig.sign_name.trim(),
login_template_code: smsConfig.login_template_code.trim(),
}
if (smsSecretInput.trim()) {
payload.access_key_secret = smsSecretInput.trim()
}
await testSiteSMSConfig(siteID, payload)
message.success('测试短信发送成功,请查收手机短信')
setTestCooldown(60)
} catch (err: unknown) {
const msg =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: '测试短信发送失败'
message.error(msg || '测试短信发送失败')
} finally {
setTestingSMS(false)
}
}
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 (
<Space direction="vertical" style={{ width: '100%' }} size="small">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text>继承主站配置</Typography.Text>
<Switch checked={useMain} onChange={setUseMain} />
</div>
{useMain ? (
<Typography.Text type="secondary">当前展示主站的标题、说明和图片。</Typography.Text>
) : (
<>
<Input
placeholder="标题"
value={entry.title}
onChange={(e) => setEntry({ ...entry, title: e.target.value })}
/>
<Input
placeholder="副标题"
value={entry.subtitle}
onChange={(e) => setEntry({ ...entry, subtitle: e.target.value })}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text>前台显示该入口</Typography.Text>
<Switch checked={entry.enabled} onChange={(checked) => setEntry({ ...entry, enabled: checked })} />
</div>
{entry.image_url ? (
<Image src={entry.image_url} width={160} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传图片</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, scene, (url) => setEntry({ ...entry, image_url: url }))}
>
<Button icon={<PictureOutlined />}>上传图片</Button>
</Upload>
<Button disabled={!entry.image_url} onClick={() => setEntry({ ...entry, image_url: '' })}>
清除图片
</Button>
</Space>
</>
)}
</Space>
)
}
/** 全部留空时清除该配置(前台回落默认文案),否则按 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)
try {
const payload: SiteBrandingPayload = {
logo_url: logoUrl.trim(),
favicon_url: faviconUrl.trim(),
banners_json: serializeDistributionBanners(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),
storefront_text_json: storefrontTextFilled(storefrontText),
}
const updated = await updateSiteBranding(siteID, payload)
setCurrentSite(updated)
message.success('分销站点品牌与装修配置保存成功!')
} catch (error: unknown) {
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
message.error(respMessage || '保存装修配置失败')
} finally {
setSaving(false)
}
}
const previewBanner = banners.find((item) => item.enabled) ?? banners[0]
const previewBannerImage =
previewBanner?.mobile.image_url || previewBanner?.pc.image_url || ''
return (
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<Typography.Title level={3} style={{ marginBottom: 4 }}>
品牌与站点可视化装修
</Typography.Title>
<Typography.Text type="secondary">
可视化配置当前分销独立站的 Logo、首页入口、客服二维码、轮播海报和页脚信息。未单独配置的回收与合作入口继续使用主站。
</Typography.Text>
</div>
<Space>
{member?.is_superadmin && sites.length > 1 && (
<Select
placeholder="切换分销网站"
style={{ width: 220 }}
value={siteID}
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
onChange={(value) => {
setSiteID(value)
void loadSite(value)
}}
/>
)}
{currentSite?.site_code && (
<Button
icon={<EyeOutlined />}
onClick={() => {
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
const targetUrl = isLocal
? `http://${window.location.hostname}:5189/?site=${currentSite.site_code}`
: currentSite.domain
? `https://${currentSite.domain}`
: `/?site=${currentSite.site_code}`
window.open(targetUrl, '_blank')
}}
>
预览分销前台
</Button>
)}
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
保存全部装修
</Button>
</Space>
</div>
<Card loading={loading}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Title level={5} style={{ marginBottom: 4 }}>
本站收号压价
</Typography.Title>
<Typography.Text type="secondary">
压价点数越大,号主在本站拿到的收号价越低。买家售价保持与主站一致,压价差进入三方分成(本站 35% / 卖出方 40% / 平台 25%)。0 表示与主站同价收号。
</Typography.Text>
</div>
<Space wrap align="center">
<Typography.Text>收号价比主站低</Typography.Text>
<InputNumber
min={0}
max={20}
step={0.1}
precision={1}
value={receiveCostDelta}
onChange={(value) => setReceiveCostDelta(Math.max(0, Number(value || 0)))}
addonAfter="点"
style={{ width: 160 }}
/>
<Button type="primary" icon={<SaveOutlined />} loading={savingRatio} onClick={() => void handleSaveReceiveRatio()}>
保存收号压价
</Button>
</Space>
</Space>
</Card>
<Card loading={loading}>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Space align="center">
<MessageOutlined style={{ fontSize: 18, color: '#1e4d42' }} />
<Typography.Title level={5} style={{ margin: 0 }}>
独立短信验证码配置
</Typography.Title>
</Space>
<Typography.Paragraph type="secondary" style={{ marginTop: 4, marginBottom: 0 }}>
开启后,本分站访客登录/注册时将调用分站专属的阿里云短信模板与签名,并直接计入本站自己的阿里云短信额度;未开启时自动回退使用主站默认短信通道。
</Typography.Paragraph>
</div>
<Alert
type={smsConfig.enabled ? 'info' : 'warning'}
showIcon
message={
smsConfig.enabled
? '当前已启用独立短信服务:分站登录验证码将消耗您配置的阿里云额度,展示您的专属短信签名。'
: '当前未启用独立短信服务:分站将免费使用主站平台额度发送验证码(展示主站默认签名)。'
}
/>
<Form layout="vertical">
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="启用独立短信服务">
<Space>
<Switch
checked={smsConfig.enabled}
onChange={(val) => setSmsConfig({ ...smsConfig, enabled: val })}
/>
<Typography.Text type="secondary">
{smsConfig.enabled ? '已开启(优先使用分站凭据)' : '已停用(回退使用主站额度)'}
</Typography.Text>
</Space>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="短信服务商">
<Select
value={smsConfig.provider || 'aliyun'}
onChange={(val) => setSmsConfig({ ...smsConfig, provider: val })}
options={[{ label: '阿里云短信服务 (Aliyun Dysms)', value: 'aliyun' }]}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="短信签名 (SignName)" required={smsConfig.enabled} tooltip="在阿里云短信控制台申请并已通过审核的签名,例如:【XX租号】">
<Input
placeholder="例如:XX租号"
value={smsConfig.sign_name}
onChange={(e) => setSmsConfig({ ...smsConfig, sign_name: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="验证码模板 Code (TemplateCode)" required={smsConfig.enabled} tooltip="在阿里云申请的验证码模板,必须包含 ${code} 变量,例如:SMS_123456789">
<Input
placeholder="例如:SMS_123456789"
value={smsConfig.login_template_code}
onChange={(e) => setSmsConfig({ ...smsConfig, login_template_code: e.target.value })}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="阿里云 AccessKey ID" required={smsConfig.enabled} tooltip="具备 Dysms 权限的阿里云 RAM 用户 AccessKey ID">
<Input
placeholder="例如:LTAI5t..."
value={smsConfig.access_key_id}
onChange={(e) => setSmsConfig({ ...smsConfig, access_key_id: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
label="阿里云 AccessKey Secret"
required={smsConfig.enabled && !smsConfig.has_secret}
tooltip={smsConfig.has_secret ? `已保存密钥 (${smsConfig.masked_secret}),如需更换请输入新密钥,留空则保持不变` : '请输入阿里云 AccessKey Secret'}
>
<Input.Password
placeholder={smsConfig.has_secret ? '已设置密钥,留空保持不变' : '请输入 AccessKey Secret'}
value={smsSecretInput}
onChange={(e) => setSmsSecretInput(e.target.value)}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item label="API Endpoint (可选)" tooltip="默认 dysmsapi.aliyuncs.com">
<Input
placeholder="dysmsapi.aliyuncs.com"
value={smsConfig.endpoint}
onChange={(e) => setSmsConfig({ ...smsConfig, endpoint: e.target.value })}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item label="连通性测试">
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入手机号测试接收"
value={testPhone}
onChange={(e) => setTestPhone(e.target.value)}
/>
<Popconfirm
title="确认发送测试短信?"
description="将向填写的手机号发送一条随机验证码,并消耗该站点的阿里云短信额度。"
okText="确认发送"
cancelText="取消"
disabled={testingSMS || testCooldown > 0}
onConfirm={() => void handleTestSMS()}
>
<Button
icon={<SendOutlined />}
loading={testingSMS}
disabled={testCooldown > 0}
>
{testCooldown > 0 ? `重新发送(${testCooldown}s)` : '发送测试'}
</Button>
</Popconfirm>
</Space.Compact>
</Form.Item>
</Col>
</Row>
<Button
type="primary"
icon={<SaveOutlined />}
loading={savingSMS}
onClick={() => void handleSaveSMS()}
>
保存短信配置
</Button>
</Form>
</Space>
</Card>
<Row gutter={24}>
{/* Left Visual Form Customizer */}
<Col xs={24} lg={14}>
<Card loading={loading}>
<Tabs
defaultActiveKey="branding"
items={[
{
key: 'branding',
label: '品牌基础资料',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong>站点 Logo:</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 6 }} size="small">
{logoUrl ? (
<Image src={logoUrl} height={48} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传 Logo</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'avatar', setLogoUrl)}
>
<Button icon={<PictureOutlined />}>上传 Logo</Button>
</Upload>
<Button disabled={!logoUrl} onClick={() => setLogoUrl('')}>
清除
</Button>
</Space>
<Input
placeholder="或粘贴图片链接 https://.../logo.png"
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
allowClear
/>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
建议尺寸 240x60 像素,透明底色 PNG
</Typography.Text>
</div>
<div>
<Typography.Text strong>浏览器标签页 Favicon:</Typography.Text>
<Space direction="vertical" style={{ width: '100%', marginTop: 6 }} size="small">
{faviconUrl ? (
<Image src={faviconUrl} width={32} height={32} style={{ objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">尚未上传图标</Typography.Text>
)}
<Space>
<Upload
accept="image/*"
showUploadList={false}
beforeUpload={(file) => uploadEntryImage(file, 'avatar', setFaviconUrl)}
>
<Button icon={<PictureOutlined />}>上传图标</Button>
</Upload>
<Button disabled={!faviconUrl} onClick={() => setFaviconUrl('')}>
清除
</Button>
</Space>
<Input
placeholder="或粘贴图片链接 https://.../favicon.png"
value={faviconUrl}
onChange={(e) => setFaviconUrl(e.target.value)}
allowClear
/>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 4 }}>
浏览器选项卡显示的小图标,建议 32x32 像素
</Typography.Text>
</div>
</Space>
),
},
{
key: 'banners',
label: `首页轮播横幅 (${banners.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Alert
type="info"
showIcon
message="PC 与移动端分开配置"
description="出图规范:PC 端 1600×400(4:1)、移动端 750×375(2:1),两端容器严格等比。每端可单独上传图片、配置整图跳转和热区;某一端留空时,该端前台回退为文字卡片。"
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
为分销商城首页配置轮播海报,后台坐标为百分比,换分辨率也不会跑位。
</Typography.Text>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddBanner}>
添加轮播
</Button>
</div>
{banners.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
暂未添加轮播横幅,点击上方按钮新增
</div>
) : (
banners.map((item, idx) => (
<div
key={idx}
style={{
background: '#ffffff',
border: '1px solid #eef1f5',
borderRadius: 10,
padding: '12px 14px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 12,
marginBottom: 10,
}}
>
<Space>
<strong style={{ color: '#ff6a00' }}>轮播 #{idx + 1}</strong>
<Switch
size="small"
checked={item.enabled}
checkedChildren="启用"
unCheckedChildren="停用"
onChange={(enabled) => handleUpdateBanner(idx, { enabled })}
/>
</Space>
<Space size={4}>
<Button
size="small"
disabled={idx === 0}
onClick={() => handleMoveBanner(idx, -1)}
>
上移
</Button>
<Button
size="small"
disabled={idx === banners.length - 1}
onClick={() => handleMoveBanner(idx, 1)}
>
下移
</Button>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteBanner(idx)}
>
删除
</Button>
</Space>
</div>
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col xs={24} sm={12}>
<Input
placeholder="眉标,如 三角洲行动账号专区"
value={item.eyebrow}
onChange={(e) => handleUpdateBanner(idx, { eyebrow: e.target.value })}
/>
</Col>
<Col xs={24} sm={12}>
<Input
placeholder="主标题(无图时展示)"
value={item.title}
onChange={(e) => handleUpdateBanner(idx, { title: e.target.value })}
/>
</Col>
<Col xs={12} sm={6}>
<Input
placeholder="角标,如 HOT"
value={item.badge}
onChange={(e) => handleUpdateBanner(idx, { badge: e.target.value })}
/>
</Col>
<Col xs={12} sm={10}>
<Input
placeholder="胶囊文案(可选)"
value={item.pill}
onChange={(e) => handleUpdateBanner(idx, { pill: e.target.value })}
/>
</Col>
<Col xs={24} sm={8}>
<Select
style={{ width: '100%' }}
value={item.tone}
onChange={(tone) => handleUpdateBanner(idx, { tone })}
options={[
{ label: '金色', value: 'gold' },
{ label: '蓝色', value: 'blue' },
{ label: '绿色', value: 'green' },
{ label: '橙色', value: 'orange' },
]}
/>
</Col>
</Row>
<Tabs
size="small"
items={[
{
key: 'pc',
label: 'PC 端 1600×400(4:1)',
children: (
<BannerVisualEditor
value={item.pc}
onChange={(pc) => handleUpdateBanner(idx, { pc })}
ratio={HOME_BANNER_PC_RATIO}
label="PC 端展示"
standard="1600×400(4:1)"
/>
),
},
{
key: 'mobile',
label: '移动端 750×375(2:1)',
children: (
<BannerVisualEditor
value={item.mobile}
onChange={(mobile) => handleUpdateBanner(idx, { mobile })}
ratio={HOME_BANNER_MOBILE_RATIO}
label="移动端展示"
standard="750×375(2:1)"
/>
),
},
]}
/>
</div>
))
)}
</Space>
),
},
{
key: 'announcements',
label: `跑马灯公告 (${announcements.length})`,
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography.Text type="secondary">
在商城顶部轮播滚动的最新活动或服务保障公告。
</Typography.Text>
<Button type="dashed" icon={<PlusOutlined />} onClick={handleAddAnnouncement}>
添加公告
</Button>
</div>
{announcements.length === 0 ? (
<div style={{ textAlign: 'center', padding: '32px 0', color: '#999' }}>
暂无公告,点击上方按钮新增
</div>
) : (
announcements.map((item, idx) => (
<div
key={idx}
style={{
background: '#fbfbfb',
border: '1px solid #e8e8e8',
borderRadius: 8,
padding: '12px 16px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<strong>公告 #{idx + 1}</strong>
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleDeleteAnnouncement(idx)}
>
删除
</Button>
</div>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Input
placeholder="公告标题"
value={item.title}
onChange={(e) => handleUpdateAnnouncement(idx, 'title', e.target.value)}
/>
<Input.TextArea
placeholder="公告详细内容 (可选)"
rows={2}
value={item.content}
onChange={(e) => handleUpdateAnnouncement(idx, 'content', e.target.value)}
/>
</Space>
</div>
))
)}
</Space>
),
},
{
key: 'entries',
label: '首页入口',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Alert
type="info"
showIcon
message="回收和合作入口默认继承主站。关掉继承后,只影响当前分销站首页。发布群进群码由主站在分站二维码池维护。"
/>
<div>
<Typography.Text strong>AW炫彩回收</Typography.Text>
<div style={{ marginTop: 8 }}>
{renderEntryEditor(awUseMain, setAwUseMain, awEntry, setAwEntry, 'aw-recycle')}
</div>
</div>
<div>
<Typography.Text strong>合作与反馈</Typography.Text>
<div style={{ marginTop: 8 }}>
{renderEntryEditor(
coopUseMain,
setCoopUseMain,
coopEntry,
setCoopEntry,
'cooperation-feedback',
)}
</div>
</div>
</Space>
),
},
{
key: 'storefront_text',
label: '首页文案',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Typography.Text type="secondary">
留空则使用默认文案(占位提示即默认值)。这些文案出现在商城头部与首页主视觉,可按本站品牌口径自由填写。
</Typography.Text>
<div>
<Typography.Text strong>头部品牌区副标题</Typography.Text>
<Input
placeholder={DEFAULT_STOREFRONT_TEXT.tagline}
value={storefrontText.tagline}
onChange={(e) =>
setStorefrontText({ ...storefrontText, tagline: e.target.value })
}
/>
</div>
<div>
<Typography.Text strong>首页主视觉徽标</Typography.Text>
<Input
placeholder={DEFAULT_STOREFRONT_TEXT.hero_badge}
value={storefrontText.hero_badge}
onChange={(e) =>
setStorefrontText({ ...storefrontText, hero_badge: e.target.value })
}
/>
</div>
<div>
<Typography.Text strong>首页主视觉描述</Typography.Text>
<Input.TextArea
rows={2}
placeholder={DEFAULT_STOREFRONT_TEXT.hero_desc}
value={storefrontText.hero_desc}
onChange={(e) =>
setStorefrontText({ ...storefrontText, hero_desc: e.target.value })
}
/>
</div>
<div>
<Typography.Text strong>移动端认证卡片副标题</Typography.Text>
<Input
placeholder={DEFAULT_STOREFRONT_TEXT.card_sub}
value={storefrontText.card_sub}
onChange={(e) =>
setStorefrontText({ ...storefrontText, card_sub: e.target.value })
}
/>
</div>
</Space>
),
},
{
key: 'footer',
label: '页脚与客服备案',
children: (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<Typography.Text strong>商城版权文案:</Typography.Text>
<Input
placeholder="例如:© 2026 游戏账号分销商城 版权所有"
value={footerInfo.copyright}
onChange={(e) => setFooterInfo({ ...footerInfo, copyright: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
<div>
<Typography.Text strong>ICP 备案编号:</Typography.Text>
<Input
placeholder="例如:京ICP备20260001号-1"
value={footerInfo.icp}
onChange={(e) => setFooterInfo({ ...footerInfo, icp: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
<div>
<Typography.Text strong>官方客服微信 / QQ:</Typography.Text>
<Input
placeholder="例如:客服微信: hfb_kf01 / QQ群: 987654321"
value={footerInfo.contact}
onChange={(e) => setFooterInfo({ ...footerInfo, contact: e.target.value })}
style={{ marginTop: 6 }}
/>
</div>
</Space>
),
},
]}
/>
<Divider style={{ margin: '16px 0' }} />
{/* Advanced Raw JSON Inspector (Collapsed) */}
<Collapse
size="small"
items={[
{
key: 'raw',
label: '开发者底包 JSON 数据(自动同步生成,无需手写)',
children: (
<pre style={{ fontSize: 11, background: '#f5f5f5', padding: 8, borderRadius: 4, maxHeight: 120, overflow: 'auto' }}>
{JSON.stringify(
{
banners,
announcements,
footer_info: footerInfo,
},
null,
2
)}
</pre>
),
},
]}
/>
</Card>
</Col>
{/* Right Live Visual Simulation Card */}
<Col xs={24} lg={10}>
<Card title="分销商城前台实时模拟效果" style={{ background: '#f8fafc' }}>
<div
style={{
background: '#fff',
border: '1px solid #d9d9d9',
borderRadius: 8,
overflow: 'hidden',
boxShadow: '0 4px 12px rgba(0,0,0,0.05)',
}}
>
{/* Simulated Browser Bar */}
<div
style={{
background: '#f0f2f5',
padding: '6px 12px',
borderBottom: '1px solid #e8e8e8',
display: 'flex',
alignItems: 'center',
gap: 8,
fontSize: 12,
color: '#666',
}}
>
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ff4d4f' }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#faad14' }} />
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#52c41a' }} />
</div>
<div
style={{
flex: 1,
background: '#fff',
borderRadius: 4,
padding: '2px 8px',
fontSize: 11,
textAlign: 'center',
}}
>
https://{currentSite?.domain || `${currentSite?.site_code || 'demo'}.hfb.com`}
</div>
</div>
{/* Simulated Header Nav */}
<div
style={{
padding: '10px 14px',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
{logoUrl ? (
<img src={logoUrl} alt="Logo" style={{ height: 26, objectFit: 'contain' }} />
) : (
<span style={{ display: 'flex', flexDirection: 'column' }}>
<strong style={{ fontSize: 14, color: '#1e4d42' }}>
{currentSite?.site_name || '解忧分销商城'}
</strong>
<small style={{ fontSize: 10, color: '#94a3b8' }}>
{storefrontText.tagline.trim() || DEFAULT_STOREFRONT_TEXT.tagline}
</small>
</span>
)}
<Tag color="blue">
{storefrontText.hero_badge.trim() || DEFAULT_STOREFRONT_TEXT.hero_badge}
</Tag>
</div>
{/* Simulated Announcements Marquee */}
{announcements.length > 0 && (
<div
style={{
background: '#fffbe6',
padding: '4px 12px',
fontSize: 12,
color: '#d46b08',
borderBottom: '1px solid #ffe58f',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
<NotificationOutlined />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{announcements[0]?.title || announcements[0]?.content}
</span>
</div>
)}
{/* Simulated Banner Carousel */}
<div
style={{
aspectRatio: '2 / 1',
background: 'repeating-conic-gradient(#f1f5f9 0% 25%, #ffffff 0% 50%) 50% / 16px 16px',
position: 'relative',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{previewBannerImage ? (
<img
src={previewBannerImage}
alt="Banner"
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
) : previewBanner ? (
<div
style={{
width: '100%',
height: '100%',
padding: 16,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
background: 'linear-gradient(135deg, #1e293b, #334155)',
color: '#fff',
}}
>
{previewBanner.eyebrow && (
<span style={{ fontSize: 11, opacity: 0.8 }}>{previewBanner.eyebrow}</span>
)}
<strong style={{ fontSize: 16, marginTop: 4 }}>
{previewBanner.title || '精选游戏账号 · 随租随玩'}
</strong>
{previewBanner.pill && (
<span style={{ fontSize: 11, marginTop: 6, color: '#fbbf24' }}>
{previewBanner.pill}
</span>
)}
</div>
) : (
<div style={{ color: '#999', fontSize: 12 }}>暂无轮播横幅,请在左侧添加</div>
)}
{previewBanner?.badge && (
<span
style={{
position: 'absolute',
top: 8,
right: 8,
padding: '1px 6px',
borderRadius: 4,
background: 'rgba(15, 23, 42, 0.72)',
color: '#fff',
fontSize: 10,
}}
>
{previewBanner.badge}
</span>
)}
</div>
{/* Simulated Account Listings Grid Preview */}
<div style={{ padding: 12 }}>
<Typography.Text strong style={{ fontSize: 13, display: 'block', marginBottom: 8 }}>
精选租号货架
</Typography.Text>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 4, padding: 8, fontSize: 11 }}>
<strong>三角洲全金典藏号</strong>
<div style={{ color: '#cf1322', marginTop: 4, fontWeight: 'bold' }}>¥35.00 /小时</div>
</div>
<div style={{ border: '1px solid #f0f0f0', borderRadius: 4, padding: 8, fontSize: 11 }}>
<strong>无畏契约全套幻化</strong>
<div style={{ color: '#cf1322', marginTop: 4, fontWeight: 'bold' }}>¥28.00 /小时</div>
</div>
</div>
</div>
{/* Simulated Footer */}
<div
style={{
background: '#fafafa',
borderTop: '1px solid #f0f0f0',
padding: '10px 12px',
fontSize: 11,
color: '#8c8c8c',
textAlign: 'center',
}}
>
<div>{footerInfo.copyright || `© 2026 ${currentSite?.site_name || '分销网'}`}</div>
{footerInfo.icp && <div>{footerInfo.icp}</div>}
{footerInfo.contact && <div style={{ color: '#1e4d42' }}>{footerInfo.contact}</div>}
</div>
</div>
</Card>
</Col>
</Row>
</Space>
)
}