diff --git a/apps/admin-web/src/components/ConfigMediaFields.tsx b/apps/admin-web/src/components/ConfigMediaFields.tsx
new file mode 100644
index 0000000..33c67af
--- /dev/null
+++ b/apps/admin-web/src/components/ConfigMediaFields.tsx
@@ -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 (
+
+ );
+}
+
+/** 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 (
+
+ {urls.map((url, index) => (
+
+
+ updateAt(index, u)}
+ placeholder="上传或粘贴图片 URL"
+ />
+
+
+ }
+ disabled={index === 0}
+ onClick={() => move(index, -1)}
+ />
+ }
+ disabled={index === urls.length - 1}
+ onClick={() => move(index, 1)}
+ />
+ } onClick={() => removeAt(index)} />
+
+
+ ))}
+ }
+ disabled={urls.length >= MAX_BANNERS}
+ onClick={add}
+ >
+ 添加轮播图({urls.length}/{MAX_BANNERS})
+
+
+ );
+}
diff --git a/apps/admin-web/src/pages/SystemSettingsPage.tsx b/apps/admin-web/src/pages/SystemSettingsPage.tsx
index 4b27e1d..b9e1fab 100644
--- a/apps/admin-web/src/pages/SystemSettingsPage.tsx
+++ b/apps/admin-web/src/pages/SystemSettingsPage.tsx
@@ -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 (
-
-
-
{field.label}
-
-
{field.key}
-
-
{field.requiresRestart ? 需重启 : 即时}
-
-
}
-
tooltip={field.description}
-
valuePropName="checked"
-
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
-
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
-
>
-
-
-
{extra}
-
-
);
-
}
-
+ if (field.type === 'image' || field.type === 'imageList') {
+ return (
+
+ {field.label}
+
+ {field.key}
+
+ {field.requiresRestart ? 需重启 : 即时}
+
+ }
+ tooltip={field.description}
+ >
+ {field.type === 'image' ? (
+
+ ) : (
+
+ )}
+
+ );
+ }
const input =
-
field.type === 'textarea' ? (
-
-
) : field.type === 'number' ? (
-
-
) : field.secret ? (
-
-
) : (
-
-
);
-
-
return (
-
-
{field.label}
-
-
{field.key}
-
-
{field.requiresRestart ? 需重启 : 即时}
-
{isConfiguredSecret ? 已配置 : null}
-
-
}
-
tooltip={field.description}
-
>
-
{input}
-
-
);
-
}
diff --git a/apps/mini-user/src/pages/home/index.tsx b/apps/mini-user/src/pages/home/index.tsx
index 11d54c5..2baf43e 100644
--- a/apps/mini-user/src/pages/home/index.tsx
+++ b/apps/mini-user/src/pages/home/index.tsx
@@ -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({ 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(
- `/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
- );
+ const [list] = await Promise.all([
+ request(`/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 (
@@ -130,6 +158,24 @@ export default function HomePage() {
{displayCity}
+ {banners.length > 0 ? (
+
+ 1}
+ autoplay={banners.length > 1}
+ circular={banners.length > 1}
+ interval={4000}
+ >
+ {banners.map((url) => (
+
+
+
+ ))}
+
+
+ ) : null}
+
{loading ? 加载中… : null}
{!loading && products.length === 0 ? (
@@ -173,6 +219,12 @@ export default function HomePage() {
})}
+ {footerUrl ? (
+
+
+
+ ) : null}
+
{shouldRenderPageTabBar() ? : null}
);
diff --git a/apps/mini-user/src/styles/home.css b/apps/mini-user/src/styles/home.css
index 7d792d7..6696150 100644
--- a/apps/mini-user/src/styles/home.css
+++ b/apps/mini-user/src/styles/home.css
@@ -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;
diff --git a/packages/shared-types/src/system-config.ts b/packages/shared-types/src/system-config.ts
index 1a23189..76e0e1f 100644
--- a/packages/shared-types/src/system-config.ts
+++ b/packages/shared-types/src/system-config.ts
@@ -1,4 +1,11 @@
-export type SystemConfigFieldType = 'string' | 'boolean' | 'number' | 'password' | 'textarea';
+export type SystemConfigFieldType =
+ | 'string'
+ | 'boolean'
+ | 'number'
+ | 'password'
+ | 'textarea'
+ | 'image'
+ | 'imageList';
export interface SystemConfigFieldMeta {
key: string;
@@ -51,3 +58,25 @@ export interface SystemConfigSyncResult {
requiresRestartKeys: string[];
message: string;
}
+
+/** 解析小程序首页轮播 JSON(最多 8 张) */
+export function parseMiniHomeBanners(raw?: string | null): string[] {
+ if (!raw?.trim()) return [];
+ const text = raw.trim();
+ try {
+ const parsed = JSON.parse(text) as unknown;
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .filter((u): u is string => typeof u === 'string' && !!u.trim())
+ .map((u) => u.trim())
+ .slice(0, 8);
+ } catch {
+ if (/^https?:\/\//i.test(text)) return [text];
+ return [];
+ }
+}
+
+export function serializeMiniHomeBanners(urls: string[]): string {
+ const cleaned = urls.filter((u) => !!u?.trim()).map((u) => u.trim()).slice(0, 8);
+ return JSON.stringify(cleaned);
+}
diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts
index 1acfdb2..3321cda 100644
--- a/packages/shared-types/src/wechat.ts
+++ b/packages/shared-types/src/wechat.ts
@@ -31,6 +31,11 @@ export type ClientRuntimeConfig = {
mockWechat?: boolean;
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
wxAuthorize?: boolean;
+ /** 小程序首页轮播 / 底部图 */
+ miniHome?: {
+ banners: string[];
+ footerUrl: string | null;
+ };
};
/** 是否展示微信授权入口 */
diff --git a/server/dukang-api/src/common/system-config/system-config.registry.ts b/server/dukang-api/src/common/system-config/system-config.registry.ts
index 701fa2f..b69d17c 100644
--- a/server/dukang-api/src/common/system-config/system-config.registry.ts
+++ b/server/dukang-api/src/common/system-config/system-config.registry.ts
@@ -5,6 +5,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'feature', label: '功能开关' },
{ key: 'sms', label: '短信' },
{ key: 'wechat', label: '微信' },
+ { key: 'wechat_mini', label: '微信小程序配置' },
{ key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
@@ -14,6 +15,7 @@ const G = {
feature: 'feature',
sms: 'sms',
wechat: 'wechat',
+ wechat_mini: 'wechat_mini',
oss: 'oss',
app: 'app',
deploy: 'deploy',
@@ -66,6 +68,23 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
{ key: 'WX_PLATFORM_CERT', label: '微信平台公钥证书', group: G.wechat, type: 'textarea', secret: true, requiresRestart: true },
{ key: 'WX_PAY_NOTIFY_URL', label: '支付回调 URL', group: G.wechat, type: 'string', requiresRestart: false },
+ {
+ key: 'MINI_HOME_BANNERS',
+ label: '首页轮播图',
+ group: G.wechat_mini,
+ type: 'imageList',
+ requiresRestart: false,
+ description: '小程序商品首页顶部轮播,建议比例 15:8,最多 8 张',
+ },
+ {
+ key: 'MINI_HOME_FOOTER_URL',
+ label: '首页底部图',
+ group: G.wechat_mini,
+ type: 'image',
+ requiresRestart: false,
+ description: '小程序商品首页底部 footer,建议比例 15:4',
+ },
+
{ key: 'OSS_ACCESS_KEY_ID', label: 'OSS AccessKey ID', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_ACCESS_KEY_SECRET', label: 'OSS AccessKey Secret', group: G.oss, type: 'password', secret: true, requiresRestart: true },
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
diff --git a/server/dukang-api/src/modules/common/client-config.controller.ts b/server/dukang-api/src/modules/common/client-config.controller.ts
index 226e5f2..0e76896 100644
--- a/server/dukang-api/src/modules/common/client-config.controller.ts
+++ b/server/dukang-api/src/modules/common/client-config.controller.ts
@@ -1,4 +1,5 @@
import { Controller, Get } from '@nestjs/common';
+import { parseMiniHomeBanners } from '@dukang/shared-types';
import { SystemConfigService } from '../../common/system-config/system-config.service';
@Controller('common')
@@ -8,12 +9,18 @@ export class ClientConfigController {
@Get('client-config')
clientConfig() {
const cfg = this.systemConfig.getAppConfig();
+ const env = this.systemConfig.getMergedEnv();
+ const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockSms: cfg.mockSms,
mockWechat: cfg.mockWechat,
wxAuthorize: cfg.wxAuthorize,
+ miniHome: {
+ banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
+ footerUrl: footer || null,
+ },
};
}
}