diff --git a/apps/admin-web/src/components/PartnerAssocPanel.tsx b/apps/admin-web/src/components/PartnerAssocPanel.tsx index 8045ad1..8c2136e 100644 --- a/apps/admin-web/src/components/PartnerAssocPanel.tsx +++ b/apps/admin-web/src/components/PartnerAssocPanel.tsx @@ -3,7 +3,7 @@ import { Link } from 'react-router-dom'; import { Button, Popconfirm, Space, Table, Typography, message } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import type { PartnerAssocSummary, PartnerAssocUserItem } from '@dukang/shared-types'; -import { request, type HqProfile, type Paginated } from '../lib/api'; +import { request, requestDownload, type HqProfile, type Paginated } from '../lib/api'; import { fmtTime } from '../lib/constants'; import ActivityPosterDownloadModal from './ActivityPosterDownloadModal'; @@ -27,6 +27,7 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) const [canEditAssoc, setCanEditAssoc] = useState(false); const [canDownloadPosters, setCanDownloadPosters] = useState(false); const [posterDownloadOpen, setPosterDownloadOpen] = useState(false); + const [downloadingQr, setDownloadingQr] = useState(false); const loadSummary = useCallback(async () => { const data = await request(`/admin/partners/${partnerId}/assoc`); @@ -78,6 +79,26 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) } } + async function downloadBareQrcode() { + if (!summary?.qrcodeUrl) { + message.warning('尚未生成关联码'); + return; + } + setDownloadingQr(true); + try { + await requestDownload( + `/admin/partners/${partnerId}/assoc/qrcode`, + {}, + `partner-assoc-${partnerId}.png`, + ); + message.success('已开始下载二维码'); + } catch (e) { + message.error(e instanceof Error ? e.message : '下载二维码失败'); + } finally { + setDownloadingQr(false); + } + } + async function unbind(userId: string) { try { await request(`/admin/partners/${partnerId}/assoc/users/${userId}/unbind`, { method: 'POST' }); @@ -137,12 +158,19 @@ export default function PartnerAssocPanel({ partnerId }: PartnerAssocPanelProps) 关联用户 {summary?.userCount ?? 0} 人 - - {canDownloadPosters ? ( - - ) : null} + + + {summary?.qrcodeUrl ? ( + + ) : null} + {canDownloadPosters ? ( + + ) : null} + 0; +} diff --git a/apps/mini-user/src/pages/store-detail/index.tsx b/apps/mini-user/src/pages/store-detail/index.tsx index ba3f393..9a8f1f2 100644 --- a/apps/mini-user/src/pages/store-detail/index.tsx +++ b/apps/mini-user/src/pages/store-detail/index.tsx @@ -17,11 +17,13 @@ import StoreRedeemMarquee, { type StoreRedeemMarqueeItem } from '../../component import BenefitIntroCard from '../../components/BenefitIntroCard'; import WechatShareReady from '../../components/WechatShareReady'; import { request, toast, isLoggedIn } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { toMoneyNumber } from '../../lib/money'; import { maskPhone, toDialablePhone } from '../../lib/phone'; import { track } from '../../lib/analytics'; import { fullStoreAddress, + shouldShowStoreRedeemCount, storeCategoryTags, storeStarCount, type StoreCategoryTreeNode, @@ -190,6 +192,7 @@ export default function StoreDetailPage() { const [loadError, setLoadError] = useState(''); const [headerSolid, setHeaderSolid] = useState(false); const [pendingRatingId, setPendingRatingId] = useState(null); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const storeRef = useRef(null); storeRef.current = store; @@ -197,6 +200,12 @@ export default function StoreDetailPage() { setHeaderSolid(scrollTop > 100); }); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => setShowStoreRedeemCount(cfg.showStoreRedeemCount !== false)) + .catch(() => undefined); + }, []); + const loadStore = useCallback(async (id: string) => { if (!id) { setLoading(false); @@ -440,7 +449,7 @@ export default function StoreDetailPage() { ))} - {Number(store.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, store.redeemCount) ? ( 核销{store.redeemCount}次 ) : null} diff --git a/apps/mini-user/src/pages/stores/index.tsx b/apps/mini-user/src/pages/stores/index.tsx index 2a5b162..73ec286 100644 --- a/apps/mini-user/src/pages/stores/index.tsx +++ b/apps/mini-user/src/pages/stores/index.tsx @@ -28,6 +28,7 @@ import { import { FALLBACK_CITY_CODE } from '../../lib/product-images'; import { formatDistanceMeters } from '../../lib/geo'; import { getToken, request, toast } from '../../lib/api'; +import { fetchClientConfig } from '../../lib/pay-wechat'; import { getStoresListCache, isStoresSessionBootstrapped, @@ -42,7 +43,7 @@ import { toWeappShareTimeline, } from '../../lib/wechat-share'; import BenefitSloganBar from '../../components/BenefitSloganBar'; -import { fullStoreAddress, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; +import { fullStoreAddress, shouldShowStoreRedeemCount, storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display'; import openBadgeImg from '../../assets/icons/store-open-badge.png'; type Store = { @@ -123,13 +124,21 @@ export default function StoresPage() { const [categoryTree, setCategoryTree] = useState([]); const [sort, setSort] = useState(() => cached?.sort ?? 'nearby'); const [sortOpen, setSortOpen] = useState(false); + const [showStoreRedeemCount, setShowStoreRedeemCount] = useState(true); const fetchCityKeyRef = useRef(cached?.cityKey ?? null); const fetchSeqRef = useRef(0); const regionRef = useRef(region); regionRef.current = region; const regionLabel = formatRegionLabel(region); const categoryLabel = formatCategoryLabel(category); - const sortLabel = STORE_SORT_OPTIONS.find((o) => o.key === sort)?.label ?? '附近优先'; + const sortOptions = useMemo( + () => + showStoreRedeemCount + ? STORE_SORT_OPTIONS + : STORE_SORT_OPTIONS.filter((o) => o.key !== 'redeem'), + [showStoreRedeemCount], + ); + const sortLabel = sortOptions.find((o) => o.key === sort)?.label ?? '附近优先'; const showBootLoading = loading && stores.length === 0; const childIdsByParent = useMemo(() => { @@ -143,6 +152,22 @@ export default function StoresPage() { return map; }, [categoryTree]); + useEffect(() => { + void fetchClientConfig() + .then((cfg) => { + const enabled = cfg.showStoreRedeemCount !== false; + setShowStoreRedeemCount(enabled); + if (!enabled) { + setSort((prev) => { + if (prev !== 'redeem') return prev; + patchStoresFilterCache({ sort: 'nearby' }); + return 'nearby'; + }); + } + }) + .catch(() => undefined); + }, []); + async function fetchStores( nextCode: string, coords: UserCoords | null, @@ -329,7 +354,7 @@ export default function StoresPage() { if (sort === 'rating') { const diff = storeStarCount(b.rating) - storeStarCount(a.rating); if (diff !== 0) return diff; - } else if (sort === 'redeem') { + } else if (sort === 'redeem' && showStoreRedeemCount) { const diff = (b.redeemCount ?? 0) - (a.redeemCount ?? 0); if (diff !== 0) return diff; } @@ -338,7 +363,7 @@ export default function StoresPage() { return da - db; }); return next; - }, [stores, region, category, keyword, sort, childIdsByParent]); + }, [stores, region, category, keyword, sort, childIdsByParent, showStoreRedeemCount]); function applySearch() { const next = keywordInput.trim(); @@ -497,7 +522,7 @@ export default function StoresPage() { ))} - {Number(s.redeemCount) > 0 ? ( + {shouldShowStoreRedeemCount(showStoreRedeemCount, s.redeemCount) ? ( 核销{s.redeemCount}次 ) : null} @@ -551,7 +576,7 @@ export default function StoresPage() { - {STORE_SORT_OPTIONS.map((opt) => ( + {sortOptions.map((opt) => ( return code || MOCK_SMS_FIXED_CODE; } +/** C 端门店是否展示核销次数(系统设置 SHOW_STORE_REDEEM_COUNT;未配置默认开) */ +export function isShowStoreRedeemCountEnabled(env?: Record): boolean { + const raw = (readEnv(env).SHOW_STORE_REDEEM_COUNT ?? '').trim().toLowerCase(); + if (!raw) return true; + return raw === 'true' || raw === '1'; +} + /** 小程序 / H5 默认分享文案与引导(系统设置「小程序分享配置」可覆盖) */ export const DEFAULT_SHARE_TITLE = '你吃饭,我买单'; export const DEFAULT_SHARE_DESC = '杜康好客 · 买美酒,享好礼,全城门店可用'; diff --git a/packages/shared-types/src/wechat.ts b/packages/shared-types/src/wechat.ts index 7f8796e..6f5827d 100644 --- a/packages/shared-types/src/wechat.ts +++ b/packages/shared-types/src/wechat.ts @@ -74,6 +74,8 @@ export type ClientRuntimeConfig = { partnerOnboardCsHint?: string | null; /** 小程序各场景分享文案/图 */ share?: MiniShareRuntime; + /** C 端门店列表/详情是否展示核销次数;未下发时按开启处理 */ + showStoreRedeemCount?: boolean; }; /** 是否展示微信授权入口 */ 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 900b9fb..5a18e59 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 @@ -83,6 +83,14 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [ requiresRestart: false, description: '总开关。开启后连接 HQ「企微机器人 → 智能机器人」中已启用且配置完整的 Bot(每 Bot 同时仅 1 条长连接)', }, + { + key: 'SHOW_STORE_REDEEM_COUNT', + label: 'C 端展示门店核销次数', + group: G.feature, + type: 'boolean', + requiresRestart: false, + description: '关闭后小程序门店列表/详情不再展示「核销N次」,接口也不再返回核销次数', + }, { key: 'ALIYUN_SMS_SIGN_NAME', label: '短信签名', group: G.sms, type: 'string', requiresRestart: false }, { key: 'ALIYUN_SMS_TEMPLATE_CODE', label: '默认短信模板', group: G.sms, type: 'string', requiresRestart: false }, @@ -612,6 +620,7 @@ export const SYSTEM_CONFIG_KEY_SET = new Set(SYSTEM_CONFIG_FIELDS.map((f) => f.k /** 表单空值时展示 / 启动补种的默认值(与 shared-types 常量对齐) */ export const SYSTEM_CONFIG_DEFAULTS: Record = { + SHOW_STORE_REDEEM_COUNT: 'true', USER_H5_URL: DEFAULT_USER_H5_URL.replace(/\/$/, ''), SHOP_H5_URL: DEFAULT_SHOP_H5_URL.replace(/\/$/, ''), BRAND_LOGO_OSS_BASE: BRAND_LOGO_OSS_BASE, 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 8dbe02d..2dd51f1 100644 --- a/server/dukang-api/src/modules/common/client-config.controller.ts +++ b/server/dukang-api/src/modules/common/client-config.controller.ts @@ -1,5 +1,6 @@ import { Controller, Get } from '@nestjs/common'; import { + isShowStoreRedeemCountEnabled, parseMiniHomeBanners, resolveClientBrandRuntime, resolveMiniShareRuntime, @@ -44,6 +45,7 @@ export class ClientConfigController { (env.PARTNER_ONBOARD_CS_HINT ?? '').trim() || '使用问题、提现问题等随时可联系【杜康好客】客服', share, + showStoreRedeemCount: isShowStoreRedeemCountEnabled(env), }; } } diff --git a/server/dukang-api/src/modules/ops/admin-partners.controller.ts b/server/dukang-api/src/modules/ops/admin-partners.controller.ts index a49798b..ace49de 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.controller.ts @@ -1,4 +1,5 @@ -import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Post, Put, Query, Res, UseGuards } from '@nestjs/common'; +import type { Response } from 'express'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqPermissionGuard, @@ -80,6 +81,17 @@ export class AdminPartnersController { return this.assoc.listUsers(BigInt(id), Number(page) || 1, Number(pageSize) || 20); } + @Get(':id/assoc/qrcode') + async assocQrcode(@Param('id') id: string, @Res() res: Response) { + const { buffer, fileName } = await this.assoc.getQrcodeBuffer(BigInt(id)); + res.setHeader('Content-Type', 'image/png'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + res.send(buffer); + } + @Post(':id/assoc/qrcode') regenQrcode(@Param('id') id: string) { return this.assoc.ensureQrcode(BigInt(id), true); diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index aad112b..114a7cd 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -5,7 +5,7 @@ import { Logger, NotFoundException, } from '@nestjs/common'; -import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types'; +import { loadAppConfig, ClientApp, SmsScene, isShowStoreRedeemCountEnabled } from '@dukang/shared-types'; import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole, type PartnerLeaderboardPeriod } from '@dukang/shared-types'; import { normalizeStorePackageImageUrls } from '@dukang/shared-types'; import { @@ -36,6 +36,7 @@ import { } from '../../common/test-whitelist/test-whitelist.service'; import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service'; import { formatPartnerWecomLabel } from './wecom-submitter-label'; +import { SystemConfigService } from '../../common/system-config/system-config.service'; function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number { const toRad = (d: number) => (d * Math.PI) / 180; @@ -86,6 +87,7 @@ export class StoreService { private readonly tencentLbs: TencentLbsProvider, private readonly testWhitelist: TestWhitelistService, private readonly wecomPush: WecomMessagePushService, + private readonly systemConfig: SystemConfigService, ) {} /** 门店进入 PENDING 或 HQ 新建时通知企微(失败不挡业务) */ @@ -218,11 +220,12 @@ export class StoreService { latitude?: unknown; longitude?: unknown; sortOrder?: number; - redeemCount: number; + redeemCount?: number; }; + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const redeemGroups = - visible.length === 0 + !showRedeemCount || visible.length === 0 ? [] : await this.prisma.redeemRecord.groupBy({ by: ['storeId'], @@ -252,7 +255,9 @@ export class StoreService { items.push({ ...mapped, distanceMeters, - redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0, + ...(showRedeemCount + ? { redeemCount: redeemCountByStore.get(store.id.toString()) ?? 0 } + : {}), }); } @@ -286,6 +291,7 @@ export class StoreService { throw new NotFoundException('门店不存在'); } const coords = await this.ensureStoreCoordinates(store); + const showRedeemCount = isShowStoreRedeemCountEnabled(this.systemConfig.getMergedEnv()); const [media, packageRows, redeemCount] = await Promise.all([ this.prisma.commonResource.findMany({ where: { ownerType: 'STORE', ownerId: id, status: 'ACTIVE', bizType: 'ENV' }, @@ -295,7 +301,7 @@ export class StoreService { where: { storeId: id }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }), - this.prisma.redeemRecord.count({ where: { storeId: id } }), + showRedeemCount ? this.prisma.redeemRecord.count({ where: { storeId: id } }) : Promise.resolve(null), ]); const { visibilityWhitelistEnabled: _wl, ...rest } = store; return serializeBigInt( @@ -304,7 +310,7 @@ export class StoreService { ...rest, latitude: coords?.latitude ?? store.latitude, longitude: coords?.longitude ?? store.longitude, - redeemCount, + ...(showRedeemCount && redeemCount != null ? { redeemCount } : {}), media, packages: packageRows.map((p) => { const imageUrls = normalizeStorePackageImageUrls({