feat(mini-user): add configurable home banner and footer
CI / verify (pull_request) Has been cancelled
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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,10 +31,8 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
|
||||||
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
||||||
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
|
||||||
|
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
@@ -131,123 +129,89 @@ function renderField(
|
|||||||
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
const isConfiguredSecret = field.secret && configuredSecrets.includes(field.key);
|
||||||
|
|
||||||
if (field.type === 'boolean') {
|
if (field.type === 'boolean') {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<div key={field.key}>
|
<div key={field.key}>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
||||||
name={field.key}
|
name={field.key}
|
||||||
|
|
||||||
label={
|
label={
|
||||||
|
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
|
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
|
|
||||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
{field.key}
|
{field.key}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip={field.description}
|
tooltip={field.description}
|
||||||
|
|
||||||
valuePropName="checked"
|
valuePropName="checked"
|
||||||
|
|
||||||
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
getValueFromEvent={(checked: boolean) => (checked ? 'true' : 'false')}
|
||||||
|
|
||||||
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
getValueProps={(v: string) => ({ checked: v === 'true' || v === '1' })}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
<Switch />
|
<Switch />
|
||||||
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{extra}
|
{extra}
|
||||||
|
|
||||||
</div>
|
</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 =
|
const input =
|
||||||
|
|
||||||
field.type === 'textarea' ? (
|
field.type === 'textarea' ? (
|
||||||
|
|
||||||
<TextArea rows={3} placeholder={field.placeholder} />
|
<TextArea rows={3} placeholder={field.placeholder} />
|
||||||
|
|
||||||
) : field.type === 'number' ? (
|
) : field.type === 'number' ? (
|
||||||
|
|
||||||
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
<InputNumber style={{ width: '100%' }} placeholder={field.placeholder} />
|
||||||
|
|
||||||
) : field.secret ? (
|
) : field.secret ? (
|
||||||
|
|
||||||
<Input.Password
|
<Input.Password
|
||||||
|
|
||||||
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
placeholder={isConfiguredSecret ? '已配置,留空则不修改' : field.placeholder}
|
||||||
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
) : (
|
) : (
|
||||||
|
|
||||||
<Input placeholder={field.placeholder} />
|
<Input placeholder={field.placeholder} />
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
|
|
||||||
key={field.key}
|
key={field.key}
|
||||||
|
|
||||||
name={field.key}
|
name={field.key}
|
||||||
|
|
||||||
label={
|
label={
|
||||||
|
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
|
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
|
|
||||||
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
<Typography.Text type="secondary" code style={{ fontSize: 11 }}>
|
||||||
|
|
||||||
{field.key}
|
{field.key}
|
||||||
|
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
|
||||||
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
{field.requiresRestart ? <Tag color="orange">需重启</Tag> : <Tag color="green">即时</Tag>}
|
||||||
|
|
||||||
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
{isConfiguredSecret ? <Tag>已配置</Tag> : null}
|
||||||
|
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip={field.description}
|
tooltip={field.description}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{input}
|
{input}
|
||||||
|
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
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 Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
@@ -12,6 +12,7 @@ import { ensurePayReady } from '../../lib/pay-ready';
|
|||||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||||
import { getProductImages } from '../../lib/product-images';
|
import { getProductImages } from '../../lib/product-images';
|
||||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,6 +25,11 @@ type Product = {
|
|||||||
allowOnSitePickup?: boolean;
|
allowOnSitePickup?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MiniHomeConfig = {
|
||||||
|
banners: string[];
|
||||||
|
footerUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
const AROMA_TABS = [
|
const AROMA_TABS = [
|
||||||
{ key: 'QINGXIANG', label: '清香型' },
|
{ key: 'QINGXIANG', label: '清香型' },
|
||||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||||
@@ -36,10 +42,29 @@ export default function HomePage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [displayCity, setDisplayCity] = useState('郑州市');
|
const [displayCity, setDisplayCity] = useState('郑州市');
|
||||||
const [cityCode, setCityCode] = useState('410100');
|
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(() => {
|
useDidShow(() => {
|
||||||
syncTabBarSelected(0);
|
syncTabBarSelected(0);
|
||||||
void capturePromoSceneAndTouchScan();
|
void capturePromoSceneAndTouchScan();
|
||||||
|
void loadMiniHome();
|
||||||
void resolveUserCity().then((resolved) => {
|
void resolveUserCity().then((resolved) => {
|
||||||
setDisplayCity(resolved.displayCity);
|
setDisplayCity(resolved.displayCity);
|
||||||
setCityCode(getCityCodeForCatalog(resolved));
|
setCityCode(getCityCodeForCatalog(resolved));
|
||||||
@@ -66,9 +91,10 @@ export default function HomePage() {
|
|||||||
const nextCode = getCityCodeForCatalog(resolved);
|
const nextCode = getCityCodeForCatalog(resolved);
|
||||||
setCityCode(nextCode);
|
setCityCode(nextCode);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const list = await request<Product[]>(
|
const [list] = await Promise.all([
|
||||||
`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
|
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`),
|
||||||
);
|
loadMiniHome(),
|
||||||
|
]);
|
||||||
setProducts(Array.isArray(list) ? list : []);
|
setProducts(Array.isArray(list) ? list : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '加载失败');
|
toast(e instanceof Error ? e.message : '加载失败');
|
||||||
@@ -110,6 +136,8 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
|
const banners = miniHome.banners;
|
||||||
|
const footerUrl = miniHome.footerUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="tab" className="home-page no-tab-header">
|
<PageShell variant="tab" className="home-page no-tab-header">
|
||||||
@@ -130,6 +158,24 @@ export default function HomePage() {
|
|||||||
<Text className="home-aroma-city">{displayCity}</Text>
|
<Text className="home-aroma-city">{displayCity}</Text>
|
||||||
</View>
|
</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">
|
<View className="home-product-list">
|
||||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||||
{!loading && products.length === 0 ? (
|
{!loading && products.length === 0 ? (
|
||||||
@@ -173,6 +219,12 @@ export default function HomePage() {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{footerUrl ? (
|
||||||
|
<View className="home-promo-footer">
|
||||||
|
<Image className="home-promo-footer-img" src={footerUrl} mode="aspectFill" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={0} /> : null}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,6 +81,42 @@
|
|||||||
border-bottom-color: var(--color-heritage-red);
|
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 {
|
.home-product-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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 {
|
export interface SystemConfigFieldMeta {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -51,3 +58,25 @@ export interface SystemConfigSyncResult {
|
|||||||
requiresRestartKeys: string[];
|
requiresRestartKeys: string[];
|
||||||
message: 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export type ClientRuntimeConfig = {
|
|||||||
mockWechat?: boolean;
|
mockWechat?: boolean;
|
||||||
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
|
/** false 时三端跳过微信 SDK OAuth 授权(由 MOCK_WECHAT 或真实凭证推导) */
|
||||||
wxAuthorize?: boolean;
|
wxAuthorize?: boolean;
|
||||||
|
/** 小程序首页轮播 / 底部图 */
|
||||||
|
miniHome?: {
|
||||||
|
banners: string[];
|
||||||
|
footerUrl: string | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 是否展示微信授权入口 */
|
/** 是否展示微信授权入口 */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
|||||||
{ key: 'feature', label: '功能开关' },
|
{ key: 'feature', label: '功能开关' },
|
||||||
{ key: 'sms', label: '短信' },
|
{ key: 'sms', label: '短信' },
|
||||||
{ key: 'wechat', label: '微信' },
|
{ key: 'wechat', label: '微信' },
|
||||||
|
{ key: 'wechat_mini', label: '微信小程序配置' },
|
||||||
{ key: 'oss', label: '对象存储 OSS' },
|
{ key: 'oss', label: '对象存储 OSS' },
|
||||||
{ key: 'app', label: '应用链接' },
|
{ key: 'app', label: '应用链接' },
|
||||||
{ key: 'deploy', label: '发布部署' },
|
{ key: 'deploy', label: '发布部署' },
|
||||||
@@ -14,6 +15,7 @@ const G = {
|
|||||||
feature: 'feature',
|
feature: 'feature',
|
||||||
sms: 'sms',
|
sms: 'sms',
|
||||||
wechat: 'wechat',
|
wechat: 'wechat',
|
||||||
|
wechat_mini: 'wechat_mini',
|
||||||
oss: 'oss',
|
oss: 'oss',
|
||||||
app: 'app',
|
app: 'app',
|
||||||
deploy: 'deploy',
|
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_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: '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_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_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 },
|
{ key: 'OSS_BUCKET', label: 'OSS Bucket', group: G.oss, type: 'string', requiresRestart: true },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { parseMiniHomeBanners } from '@dukang/shared-types';
|
||||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
|
|
||||||
@Controller('common')
|
@Controller('common')
|
||||||
@@ -8,12 +9,18 @@ export class ClientConfigController {
|
|||||||
@Get('client-config')
|
@Get('client-config')
|
||||||
clientConfig() {
|
clientConfig() {
|
||||||
const cfg = this.systemConfig.getAppConfig();
|
const cfg = this.systemConfig.getAppConfig();
|
||||||
|
const env = this.systemConfig.getMergedEnv();
|
||||||
|
const footer = (env.MINI_HOME_FOOTER_URL ?? '').trim();
|
||||||
return {
|
return {
|
||||||
mockPay: cfg.mockPay,
|
mockPay: cfg.mockPay,
|
||||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||||
mockSms: cfg.mockSms,
|
mockSms: cfg.mockSms,
|
||||||
mockWechat: cfg.mockWechat,
|
mockWechat: cfg.mockWechat,
|
||||||
wxAuthorize: cfg.wxAuthorize,
|
wxAuthorize: cfg.wxAuthorize,
|
||||||
|
miniHome: {
|
||||||
|
banners: parseMiniHomeBanners(env.MINI_HOME_BANNERS),
|
||||||
|
footerUrl: footer || null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user