feat(mini-user): add configurable home banner and footer
CI / verify (pull_request) Has been cancelled

HQ system settings WeChat mini config with OSS swiper/footer uploads; client-config exposes miniHome.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 12:15:15 +08:00
parent cebeefdb22
commit 479de04f7c
8 changed files with 292 additions and 66 deletions
@@ -0,0 +1,114 @@
import { Button, Space } from 'antd';
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import {
parseMiniHomeBanners,
serializeMiniHomeBanners,
} from '@dukang/shared-types';
import OssUpload from './OssUpload';
const MAX_BANNERS = 8;
/** Ant Form 控件:单图 URL 字符串(默认 OSS 路径 footer */
export function ConfigImageField({
value,
onChange,
bizType = 'footer',
}: {
value?: string;
onChange?: (url: string) => void;
bizType?: string;
}) {
return (
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={value}
onChange={onChange}
placeholder="上传或粘贴图片 URL"
/>
);
}
/** Ant Form 控件:多图 JSON 字符串(默认 OSS 路径 swiper */
export function ConfigImageListField({
value,
onChange,
bizType = 'swiper',
}: {
value?: string;
onChange?: (json: string) => void;
bizType?: string;
}) {
const urls = parseMiniHomeBanners(value);
function commit(next: string[]) {
onChange?.(serializeMiniHomeBanners(next));
}
function updateAt(index: number, url: string) {
const next = [...urls];
next[index] = url;
commit(next);
}
function removeAt(index: number) {
commit(urls.filter((_, i) => i !== index));
}
function move(index: number, delta: number) {
const target = index + delta;
if (target < 0 || target >= urls.length) return;
const next = [...urls];
const tmp = next[index];
next[index] = next[target];
next[target] = tmp;
commit(next);
}
function add() {
if (urls.length >= MAX_BANNERS) return;
commit([...urls, '']);
}
return (
<Space direction="vertical" style={{ width: '100%' }} size="middle">
{urls.map((url, index) => (
<Space key={`${index}-${url || 'empty'}`} align="start" style={{ width: '100%' }} wrap>
<div style={{ flex: 1, minWidth: 240 }}>
<OssUpload
bizType={bizType}
mediaType="IMAGE"
value={url}
onChange={(u) => updateAt(index, u)}
placeholder="上传或粘贴图片 URL"
/>
</div>
<Space>
<Button
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => move(index, -1)}
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
disabled={index === urls.length - 1}
onClick={() => move(index, 1)}
/>
<Button type="text" danger icon={<DeleteOutlined />} onClick={() => removeAt(index)} />
</Space>
</Space>
))}
<Button
type="dashed"
block
icon={<PlusOutlined />}
disabled={urls.length >= MAX_BANNERS}
onClick={add}
>
{urls.length}/{MAX_BANNERS}
</Button>
</Space>
);
}
+25 -61
View File
@@ -31,10 +31,8 @@ import {
} from 'antd';
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
import { request } from '../lib/api';
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
const { TextArea } = Input;
@@ -131,123 +129,89 @@ function renderField(
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
if (field.type === 'boolean') {
return (
<div key={field.key}>
<Form.Item
name={field.key}
label={
<Space size={4}>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space>
}
tooltip={field.description}
valuePropName="checked"
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
>
<Switch />
</Form.Item>
{extra}
</div>
);
}
if (field.type === 'image' || field.type === 'imageList') {
return (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={4} wrap>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
</Space>
}
tooltip={field.description}
>
{field.type === 'image' ? (
<ConfigImageField bizType="footer" />
) : (
<ConfigImageListField bizType="swiper" />
)}
</Form.Item>
);
}
const input =
field.type === 'textarea' ? (
<TextArea rows={3} placeholder={field.placeholder} />
) : field.type === 'number' ? (
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
) : field.secret ? (
<Input.Password
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
autoComplete="new-password"
/>
) : (
<Input placeholder={field.placeholder} />
);
return (
<Form.Item
key={field.key}
name={field.key}
label={
<Space size={4} wrap>
<span>{field.label}</span>
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
{field.key}
</Typography.Text>
{field.requiresRestart ? <Tag color="orange"></Tag> : <Tag color="green"></Tag>}
{isConfiguredSecret ? <Tag></Tag> : null}
</Space>
}
tooltip={field.description}
>
{input}
</Form.Item>
);
}
+56 -4
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { View, Text, Image, Swiper, SwiperItem } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
@@ -12,6 +12,7 @@ import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductImages } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = {
id: string;
name: string;
@@ -24,6 +25,11 @@ type Product = {
allowOnSitePickup?: boolean;
};
type MiniHomeConfig = {
banners: string[];
footerUrl: string | null;
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
@@ -36,10 +42,29 @@ export default function HomePage() {
const [loading, setLoading] = useState(true);
const [displayCity, setDisplayCity] = useState('郑州市');
const [cityCode, setCityCode] = useState('410100');
const [miniHome, setMiniHome] = useState<MiniHomeConfig>({ banners: [], footerUrl: null });
const loadMiniHome = useCallback(() => {
return request<{ miniHome?: MiniHomeConfig }>('/common/client-config')
.then((cfg) => {
const banners = Array.isArray(cfg.miniHome?.banners)
? cfg.miniHome!.banners.filter((u) => typeof u === 'string' && !!u.trim())
: [];
const footerUrl =
typeof cfg.miniHome?.footerUrl === 'string' && cfg.miniHome.footerUrl.trim()
? cfg.miniHome.footerUrl.trim()
: null;
setMiniHome({ banners, footerUrl });
})
.catch(() => {
/* 首页装饰图失败不阻断商品列表 */
});
}, []);
useDidShow(() => {
syncTabBarSelected(0);
void capturePromoSceneAndTouchScan();
void loadMiniHome();
void resolveUserCity().then((resolved) => {
setDisplayCity(resolved.displayCity);
setCityCode(getCityCodeForCatalog(resolved));
@@ -66,9 +91,10 @@ export default function HomePage() {
const nextCode = getCityCodeForCatalog(resolved);
setCityCode(nextCode);
setLoading(true);
const list = await request<Product[]>(
`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
);
const [list] = await Promise.all([
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
loadMiniHome(),
]);
setProducts(Array.isArray(list) ? list : []);
} catch (e) {
toast(e instanceof Error ? e.message : '加载失败');
@@ -110,6 +136,8 @@ export default function HomePage() {
}
const filtered = products.filter((p) => p.aromaType === tab);
const banners = miniHome.banners;
const footerUrl = miniHome.footerUrl;
return (
<PageShell variant="tab" className="home-page no-tab-header">
@@ -130,6 +158,24 @@ export default function HomePage() {
<Text className="home-aroma-city">{displayCity}</Text>
</View>
{banners.length > 0 ? (
<View className="home-promo-banner">
<Swiper
className="home-promo-banner-swiper"
indicatorDots={banners.length > 1}
autoplay={banners.length > 1}
circular={banners.length > 1}
interval={4000}
>
{banners.map((url) => (
<SwiperItem key={url}>
<Image className="home-promo-banner-img" src={url} mode="aspectFill" />
</SwiperItem>
))}
</Swiper>
</View>
) : null}
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? (
@@ -173,6 +219,12 @@ export default function HomePage() {
})}
</View>
{footerUrl ? (
<View className="home-promo-footer">
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
</View>
) : null}
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
</PageShell>
);
+36
View File
@@ -81,6 +81,42 @@
border-bottom-color: var(--color-heritage-red);
}
.home-promo-banner {
margin: 16px var(--space-page) 0;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
box-shadow: var(--shadow-card);
aspect-ratio: 15 / 8;
}
.home-promo-banner-swiper {
width: 100%;
height: 100%;
}
.home-promo-banner-img {
width: 100%;
height: 100%;
display: block;
}
.home-promo-footer {
margin: 0 var(--space-page) 16px;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
box-shadow: var(--shadow-card);
aspect-ratio: 15 / 4;
}
.home-promo-footer-img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.home-product-list {
display: flex;
flex-direction: column;