fix:完善门店和总部管理端

This commit is contained in:
ljy
2026-07-06 20:58:38 +08:00
parent 67b5a580c5
commit fcf1d45522
25 changed files with 1273 additions and 118 deletions
+7 -27
View File
@@ -1,4 +1,5 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from './weixin';
import { request, saveAuth } from './api';
@@ -31,37 +32,16 @@ export async function handlePartnerWechatCallback(): Promise<WechatLoginResult |
return weixinSdk.handleOAuthCallback();
}
let clientConfigCache: { mockWechat?: boolean } | null = null;
async function fetchClientConfig(): Promise<{ mockWechat?: boolean }> {
if (clientConfigCache) return clientConfigCache;
try {
clientConfigCache = await request<{ mockWechat?: boolean }>('PARTNER_H5', '/common/client-config');
} catch {
clientConfigCache = {};
}
return clientConfigCache;
}
/**
* 微信授权登录( C 端一致:微信内走公众号 OAuth)。
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginPartnerWithWechat(): Promise<boolean | void> {
if (isWechatEnv()) {
const result = await weixinSdk.login();
if (result) return handlePartnerWechatLoginResult(result);
return;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const cfg = await fetchClientConfig();
if (!cfg.mockWechat) {
throw new Error('请在微信内打开以使用微信一键授权');
}
const result = await request<WechatLoginResult>('PARTNER_H5', '/partner/auth/login/wechat', {
method: 'POST',
body: JSON.stringify({ code: `mockcode_${Date.now()}`, platform: 'h5' }),
});
return handlePartnerWechatLoginResult(result);
const result = await weixinSdk.login();
if (result) return handlePartnerWechatLoginResult(result);
}
/**
@@ -74,7 +54,7 @@ export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
return weixinSdk.login();
}
+6 -1
View File
@@ -8,6 +8,7 @@ import {
loginPartnerWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
@@ -132,6 +133,10 @@ export default function LoginPage() {
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
setWxLoading(true);
try {
const ok = await loginPartnerWithWechat();
@@ -230,7 +235,7 @@ export default function LoginPage() {
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
</button>
<label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
+61 -12
View File
@@ -6,6 +6,10 @@ html, body, #root {
max-width: 100%;
}
html {
scrollbar-gutter: stable;
}
/* ── Partner auth ── */
.partner-auth-page {
min-height: 100vh;
@@ -733,30 +737,75 @@ html, body, #root {
flex-shrink: 0;
}
/* ── Partner bottom tabbar ── */
.app-tabbar {
display: grid;
grid-template-columns: repeat(3, 1fr);
/* ── Partner bottom tabbar(锁定尺寸,仅切换颜色)── */
nav.app-tabbar {
display: flex;
flex-direction: row;
align-items: stretch;
justify-items: stretch;
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
justify-content: stretch;
padding: 0;
padding-bottom: env(safe-area-inset-bottom, 0px);
height: calc(56px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
}
.app-tabbar-item {
flex: 1;
nav.app-tabbar .app-tabbar-item {
flex: 1 1 0;
min-width: 0;
width: 100%;
padding: 4px 2px;
max-width: 33.333%;
height: 56px;
padding: 6px 0 4px;
border-radius: 0;
gap: 2px;
color: var(--color-subtle-gray);
text-decoration: none;
transition: color 0.15s ease;
transform: none;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
user-select: none;
}
nav.app-tabbar .app-tabbar-item.active {
color: var(--color-heritage-red);
}
nav.app-tabbar .app-tabbar-item:active,
nav.app-tabbar .app-tabbar-item:focus,
nav.app-tabbar .app-tabbar-item:focus-visible {
transform: none;
outline: none;
}
nav.app-tabbar .app-tabbar-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
font-size: 24px;
line-height: 1;
overflow: hidden;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.app-tabbar-label {
max-width: 100%;
nav.app-tabbar .app-tabbar-label {
display: block;
flex-shrink: 0;
width: 100%;
height: 14px;
line-height: 14px;
margin: 0;
padding: 0 1px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-sizing: border-box;
}
/* ── Store create stepper ── */
+11
View File
@@ -1,4 +1,5 @@
import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import TabLayout from './layouts/TabLayout';
import LoginPage from './pages/LoginPage';
import HomePage from './pages/HomePage';
@@ -18,10 +19,20 @@ import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import { UserSessionProvider } from './contexts/UserSessionContext';
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
function PromoBootstrap() {
useEffect(() => {
capturePromoFromUrl();
void touchPromoIfNeeded();
}, []);
return null;
}
export default function App() {
return (
<UserSessionProvider>
<PromoBootstrap />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<TabLayout />}>
@@ -16,6 +16,7 @@ import {
type SessionPayload,
type UserProfile,
} from '../lib/api';
import { touchPromoIfNeeded } from '../lib/promo';
type UserSessionContextValue = {
ready: boolean;
@@ -59,6 +60,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
if (!session.user) {
await refreshProfile();
}
await touchPromoIfNeeded();
} catch {
if (!cancelled) {
try {
+53
View File
@@ -0,0 +1,53 @@
import { apiBase } from './api';
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
function readPromoFromSearch(search: string): string | null {
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
const code = params.get('promo')?.trim();
return code ? code.toUpperCase() : null;
}
/** 解析 URL 中的 ?promo= 并写入 sessionStorage */
export function capturePromoFromUrl(): string | null {
if (typeof window === 'undefined') return null;
let code = readPromoFromSearch(window.location.search);
if (!code && window.location.hash.includes('?')) {
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
code = readPromoFromSearch(hashQuery);
}
if (code) {
sessionStorage.setItem(PROMO_STORAGE_KEY, code);
}
return code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
}
export function getStoredPromoCode(): string | null {
if (typeof window === 'undefined') return null;
return sessionStorage.getItem(PROMO_STORAGE_KEY);
}
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count */
export async function touchPromoIfNeeded(): Promise<void> {
const promoCode = getStoredPromoCode();
if (!promoCode) return;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': 'USER_H5',
};
const token = localStorage.getItem('accessToken');
if (token) headers.Authorization = `Bearer ${token}`;
try {
const res = await fetch(`${apiBase}/promo/touch`, {
method: 'POST',
headers,
body: JSON.stringify({ promoCode }),
});
const json = await res.json();
if (json.code !== 0) return;
} catch {
/* 静默失败,不阻断用户流程 */
}
}
+8 -2
View File
@@ -5,6 +5,12 @@ import type { WechatLoginResult } from '@dukang/shared-types';
import { request, saveSession, type UserProfile } from '../lib/api';
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { touchPromoIfNeeded } from '../lib/promo';
async function finishLogin(navigate: (path: string) => void, returnTo: string) {
await touchPromoIfNeeded();
navigate(returnTo.startsWith('/') ? returnTo : '/');
}
export default function LoginPage() {
const navigate = useNavigate();
@@ -45,7 +51,7 @@ export default function LoginPage() {
phoneVerified: !!result.phoneVerified,
user: result.user as never,
});
navigate(returnTo.startsWith('/') ? returnTo : '/');
void finishLogin(navigate, returnTo);
}
}
@@ -113,7 +119,7 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveSession(data);
navigate(returnTo.startsWith('/') ? returnTo : '/');
await finishLogin(navigate, returnTo);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
+1
View File
@@ -26,6 +26,7 @@
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"qrcode": "^1.5.4",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
+3
View File
@@ -11,6 +11,9 @@ export default defineAppConfig({
'pages/cities/index',
'pages/products/index',
'pages/reports/index',
'pages/promo/index',
'pages/promo/generate',
'pages/promo/detail',
'pages/refund/index',
],
window: {
+14
View File
@@ -0,0 +1,14 @@
import QRCode from 'qrcode';
export async function buildPromoQrDataUrl(landingUrl: string): Promise<string> {
return QRCode.toDataURL(landingUrl, {
width: 400,
margin: 1,
color: { dark: '#1f1a17', light: '#ffffff' },
});
}
export function promoConversion(scan: number, orders: number): string {
if (scan <= 0) return '0%';
return `${Math.round((orders / scan) * 1000) / 10}%`;
}
+6 -27
View File
@@ -1,20 +1,9 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import Taro from '@tarojs/taro';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { request, saveToken } from './api';
import { isWechatEnv, weixinSdk } from './weixin';
let clientConfigCache: { mockWechat?: boolean } | null = null;
async function fetchClientConfig(): Promise<{ mockWechat?: boolean }> {
if (clientConfigCache) return clientConfigCache;
try {
clientConfigCache = await request<{ mockWechat?: boolean }>('/common/client-config');
} catch {
clientConfigCache = {};
}
return clientConfigCache;
}
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
export function handleHqWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
@@ -32,7 +21,7 @@ export async function handleHqWechatCallback(): Promise<WechatLoginResult | null
}
/**
* 微信一键登录(与合伙人端一致:微信内走公众号 OAuth,浏览器 Mock 演示)。
* 微信授权登录(对齐 C 端:H5 仅微信内置浏览器走 OAuth)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginHqWithWechat(): Promise<boolean | void> {
@@ -45,21 +34,11 @@ export async function loginHqWithWechat(): Promise<boolean | void> {
return handleHqWechatLoginResult(result);
}
if (isWechatEnv()) {
const result = await weixinSdk.login();
if (result) return handleHqWechatLoginResult(result);
return;
if (!isWechatEnv()) {
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const cfg = await fetchClientConfig();
if (!cfg.mockWechat) {
throw new Error('请在微信内打开以使用微信一键登录');
}
const result = await request<WechatLoginResult>('/admin/auth/login/wechat', {
method: 'POST',
data: { code: `mockcode_${Date.now()}`, platform: 'h5' },
});
return handleHqWechatLoginResult(result);
const result = await weixinSdk.login();
if (result) return handleHqWechatLoginResult(result);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
@@ -46,6 +46,14 @@ const CORE_MODULES = [
wide: true,
filledIcon: true,
},
{
key: 'promo',
icon: 'qr_code_2',
title: '推广码',
desc: '渠道推广链路跟踪',
url: '/pages/promo/index',
wide: false,
},
{
key: 'reports',
icon: 'analytics',
+6 -1
View File
@@ -9,6 +9,7 @@ import {
loginHqWithWechat,
} from '../../lib/wechat';
import { isWechatEnv } from '../../lib/weixin';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { clearHqAccountCache } from '../../lib/session';
import './index.css';
@@ -148,6 +149,10 @@ export default function LoginPage() {
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
setWxLoading(true);
try {
const ok = await loginHqWithWechat();
@@ -222,7 +227,7 @@ export default function LoginPage() {
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键登录'}</Text>
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
+179
View File
@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import { View, Text, Button } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import type { PromoCodeItem, PromoCodeStats } from '@dukang/shared-types';
import HqHeader from '../../components/HqHeader';
import { request, toast } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { buildPromoQrDataUrl, promoConversion } from '../../lib/promo-qr';
import './index.css';
import './index.css';
type PromoDetail = PromoCodeItem & { stats?: PromoCodeStats };
export default function PromoDetailPage() {
useHqSession();
const router = useRouter();
const id = router.params.id ?? '';
const [row, setRow] = useState<PromoDetail | null>(null);
const [loading, setLoading] = useState(true);
const [updating, setUpdating] = useState(false);
const [qrUrl, setQrUrl] = useState('');
useEffect(() => {
if (!id) return;
setLoading(true);
request<PromoDetail>(`/admin/promo-codes/${id}`)
.then(setRow)
.catch(() => setRow(null))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (!row?.landingUrl) {
setQrUrl('');
return;
}
void buildPromoQrDataUrl(row.landingUrl).then(setQrUrl).catch(() => setQrUrl(''));
}, [row?.landingUrl]);
async function toggleStatus() {
if (!row) return;
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
const label = next === 'DISABLED' ? '停用' : '启用';
const ok = await Taro.showModal({
title: `确认${label}`,
content: next === 'DISABLED' ? '停用后扫码将不再追踪数据' : '启用后恢复追踪',
});
if (!ok.confirm) return;
setUpdating(true);
try {
const updated = await request<PromoCodeItem>(`/admin/promo-codes/${id}/status`, {
method: 'PUT',
data: { status: next },
});
setRow((prev) => (prev ? { ...prev, ...updated } : updated));
toast(`${label}`, 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '操作失败');
} finally {
setUpdating(false);
}
}
function copyLink() {
if (!row?.landingUrl) return;
Taro.setClipboardData({ data: row.landingUrl }).then(() => toast('推广链接已复制', 'success'));
}
function downloadQr() {
if (!qrUrl) return;
if (process.env.TARO_ENV === 'h5') {
const a = document.createElement('a');
a.href = qrUrl;
a.download = `promo-${row?.code ?? 'qr'}.png`;
a.click();
toast('已开始下载', 'success');
return;
}
Taro.previewImage({ urls: [qrUrl] });
}
const stats = row?.stats ?? (row ? {
scanCount: row.scanCount,
orderCount: row.orderCount,
conversionRate: parseFloat(promoConversion(row.scanCount, row.orderCount)) || 0,
} : null);
if (loading) {
return (
<View className="hq-page promo-page">
<HqHeader title="推广码详情" back />
<View className="promo-empty"></View>
</View>
);
}
if (!row) {
return (
<View className="hq-page promo-page">
<HqHeader title="推广码详情" back />
<View className="promo-empty">广</View>
</View>
);
}
return (
<View className="hq-page promo-page">
<HqHeader title="推广码详情" back />
<View className="promo-detail-card">
<View className="promo-detail-card__qr">
{qrUrl ? (
<View className="promo-result__qr" style={{ backgroundImage: `url(${qrUrl})` }} />
) : null}
</View>
<View className="promo-detail-card__info">
<Text className="promo-detail-card__name">{row.name}</Text>
<Text
className={`promo-card__badge ${
row.status === 'ACTIVE' ? 'promo-card__badge--active' : 'promo-card__badge--disabled'
}`}
>
{row.status === 'ACTIVE' ? '运行中' : '已停用'}
</Text>
<Text className="promo-detail-card__meta"> {row.code}</Text>
</View>
</View>
{stats && (
<View className="promo-stats-grid">
<View className="promo-stat-card">
<Text className="material-symbols-outlined promo-stat-card__icon">visibility</Text>
<Text className="promo-stat-card__label">UV</Text>
<Text className="promo-stat-card__value">{stats.scanCount}</Text>
</View>
<View className="promo-stat-card">
<Text className="material-symbols-outlined promo-stat-card__icon">shopping_cart</Text>
<Text className="promo-stat-card__label"></Text>
<Text className="promo-stat-card__value">{stats.orderCount}</Text>
</View>
<View className="promo-stat-card">
<Text className="material-symbols-outlined promo-stat-card__icon">trending_up</Text>
<Text className="promo-stat-card__label"></Text>
<Text className="promo-stat-card__value">{stats.conversionRate}%</Text>
</View>
<View className="promo-stat-card">
<Text className="material-symbols-outlined promo-stat-card__icon">schedule</Text>
<Text className="promo-stat-card__label"></Text>
<Text className="promo-stat-card__value promo-stat-card__value--sm">
{new Date(row.createdAt).toLocaleDateString('zh-CN')}
</Text>
</View>
</View>
)}
<View className="hq-card" style="margin:0 16px">
<Text className="hq-muted" style="display:block;font-size:12px;margin-bottom:8px"></Text>
<Text style="display:block;font-size:13px;word-break:break-all">{row.landingUrl}</Text>
</View>
<View className="promo-detail-actions">
<Button className="hq-btn hq-btn--outline hq-btn--block" onClick={copyLink}>
广
</Button>
<Button className="hq-btn hq-btn--primary hq-btn--block" onClick={downloadQr}>
</Button>
<Button
className="hq-btn hq-btn--ghost hq-btn--block"
loading={updating}
disabled={updating}
onClick={toggleStatus}
>
{row.status === 'ACTIVE' ? '停用推广码' : '启用推广码'}
</Button>
</View>
</View>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import type { PromoCodeItem } from '@dukang/shared-types';
import HqHeader from '../../components/HqHeader';
import { request, toast } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { navTo } from '../../lib/session';
import { buildPromoQrDataUrl } from '../../lib/promo-qr';
import './index.css';
import './index.css';
export default function PromoGeneratePage() {
useHqSession();
const [name, setName] = useState('');
const [code, setCode] = useState('');
const [creating, setCreating] = useState(false);
const [created, setCreated] = useState<PromoCodeItem | null>(null);
const [qrUrl, setQrUrl] = useState('');
useEffect(() => {
if (!created?.landingUrl) {
setQrUrl('');
return;
}
void buildPromoQrDataUrl(created.landingUrl).then(setQrUrl).catch(() => setQrUrl(''));
}, [created]);
async function handleGenerate() {
const trimmed = name.trim();
if (!trimmed) {
toast('请填写渠道名称');
return;
}
setCreating(true);
try {
const payload: { name: string; code?: string } = { name: trimmed };
if (code.trim()) payload.code = code.trim().toUpperCase();
const row = await request<PromoCodeItem>('/admin/promo-codes', {
method: 'POST',
data: payload,
});
setCreated(row);
toast('推广码已生成', 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '生成失败');
} finally {
setCreating(false);
}
}
function copyLink() {
if (!created?.landingUrl) return;
Taro.setClipboardData({ data: created.landingUrl }).then(() => toast('推广链接已复制', 'success'));
}
return (
<View className="hq-page promo-page">
<HqHeader title="生成推广码" back />
<View className="hq-card" style="margin:16px">
<Text style="display:block;font-size:14px;color:var(--hq-muted);margin-bottom:16px">
广
</Text>
<Text className="hq-label"> *</Text>
<Input
className="hq-input"
placeholder="如:郑州品鉴会、门店地推"
value={name}
onInput={(e) => setName(e.detail.value)}
/>
<Text className="hq-label" style="margin-top:16px"></Text>
<Input
className="hq-input"
placeholder="留空则系统自动生成,如 DKDEMO1"
value={code}
onInput={(e) => setCode(e.detail.value.toUpperCase())}
/>
<Button
className="hq-btn hq-btn--primary hq-btn--block"
style="margin-top:20px"
loading={creating}
disabled={creating}
onClick={handleGenerate}
>
广
</Button>
</View>
{created && (
<View className="promo-result">
<View className="promo-result__qr-wrap">
{qrUrl ? (
<View
className="promo-result__qr"
style={{ backgroundImage: `url(${qrUrl})` }}
/>
) : (
<View className="promo-result__qr promo-result__qr--loading">
<Text className="material-symbols-outlined">hourglass_top</Text>
</View>
)}
</View>
<Text className="promo-result__title">{created.name}</Text>
<Text className="promo-result__code">{created.code}</Text>
<View className="promo-result__actions">
<View className="promo-card__btn promo-card__btn--outline" onClick={copyLink}>
<Text className="material-symbols-outlined" style="font-size:18px">link</Text>
<Text></Text>
</View>
<View
className="promo-card__btn promo-card__btn--ghost"
onClick={() => navTo(`/pages/promo/detail?id=${created.id}`)}
>
<Text></Text>
</View>
</View>
</View>
)}
</View>
);
}
+412
View File
@@ -0,0 +1,412 @@
.promo-page {
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
.promo-hero {
margin: 12px 16px 16px;
padding: 20px 16px;
border-radius: var(--hq-radius);
background: linear-gradient(135deg, var(--hq-red) 0%, var(--hq-red-dark) 100%);
color: #fff;
box-shadow: var(--hq-shadow);
position: relative;
overflow: hidden;
}
.promo-hero__label {
display: block;
font-size: 12px;
opacity: 0.85;
margin-bottom: 4px;
}
.promo-hero__value {
display: block;
font-size: 28px;
font-weight: 700;
margin-bottom: 16px;
}
.promo-hero__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.2);
}
.promo-hero__stat-label {
display: block;
font-size: 10px;
opacity: 0.75;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.promo-hero__stat-value {
display: block;
font-size: 16px;
font-weight: 700;
margin-top: 2px;
}
.promo-toolbar {
display: flex;
gap: 8px;
margin: 0 16px 12px;
}
.promo-search {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border-radius: 12px;
padding: 0 12px;
box-shadow: var(--hq-shadow);
}
.promo-search .material-symbols-outlined {
font-size: 20px;
color: var(--hq-muted);
}
.promo-search__input {
flex: 1;
border: none;
background: transparent;
padding: 10px 0;
font-size: 14px;
}
.promo-filter {
width: 44px;
height: 44px;
border-radius: 12px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: var(--hq-shadow);
}
.promo-tabs {
display: flex;
gap: 8px;
margin: 0 16px 12px;
}
.promo-tab {
padding: 6px 14px;
border-radius: 999px;
font-size: 12px;
background: #fff;
color: var(--hq-muted);
border: 1px solid var(--hq-line);
}
.promo-tab--active {
background: rgba(166, 29, 36, 0.1);
color: var(--hq-red);
border-color: rgba(166, 29, 36, 0.2);
font-weight: 600;
}
.promo-card {
margin: 0 16px 12px;
padding: 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
border: 1px solid rgba(142, 112, 110, 0.08);
}
.promo-card__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
}
.promo-card__left {
display: flex;
gap: 12px;
align-items: flex-start;
min-width: 0;
}
.promo-card__icon {
width: 48px;
height: 48px;
border-radius: 10px;
background: #f4f3f1;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.promo-card__icon .material-symbols-outlined {
font-size: 24px;
color: var(--hq-red);
}
.promo-card__name {
display: block;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.promo-card__code {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-top: 2px;
}
.promo-card__badge {
font-size: 10px;
padding: 2px 8px;
border-radius: 999px;
font-weight: 600;
}
.promo-card__badge--active {
background: rgba(45, 106, 79, 0.12);
color: var(--hq-green);
}
.promo-card__badge--disabled {
background: rgba(141, 112, 110, 0.12);
color: var(--hq-muted);
}
.promo-card__stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
background: #faf9f7;
border-radius: 10px;
padding: 10px 8px;
margin-bottom: 12px;
}
.promo-card__stat {
text-align: center;
}
.promo-card__stat-label {
display: block;
font-size: 10px;
color: var(--hq-muted);
margin-bottom: 2px;
}
.promo-card__stat-value {
display: block;
font-size: 14px;
font-weight: 600;
color: #1a1a1a;
}
.promo-card__stat-value--red {
color: var(--hq-red);
}
.promo-card__actions {
display: flex;
gap: 8px;
}
.promo-card__btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 10px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
}
.promo-card__btn--outline {
border: 1px solid var(--hq-red);
color: var(--hq-red);
background: #fff;
}
.promo-card__btn--ghost {
background: #f4f3f1;
color: #1a1a1a;
}
.promo-fab {
position: fixed;
right: 20px;
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
z-index: 50;
display: flex;
align-items: center;
gap: 6px;
padding: 12px 18px;
border-radius: 999px;
background: var(--hq-red);
color: #fff;
font-size: 14px;
font-weight: 600;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.35);
}
.promo-fab .material-symbols-outlined {
font-size: 20px;
}
.promo-empty {
text-align: center;
padding: 48px 16px;
color: var(--hq-muted);
font-size: 14px;
}
.promo-result {
margin: 16px;
padding: 24px 16px;
background: #fff;
border-radius: var(--hq-radius);
box-shadow: var(--hq-shadow);
text-align: center;
}
.promo-result__qr-wrap {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.promo-result__qr {
width: 200px;
height: 200px;
border-radius: 12px;
border: 2px solid rgba(166, 29, 36, 0.1);
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: #fff;
}
.promo-result__qr--loading {
display: flex;
align-items: center;
justify-content: center;
background: #f4f3f1;
}
.promo-result__title {
display: block;
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.promo-result__code {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-top: 4px;
letter-spacing: 0.05em;
}
.promo-result__actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
.promo-detail-card {
margin: 12px 16px;
padding: 20px;
background: #fff;
border-radius: var(--hq-radius);
box-shadow: var(--hq-shadow);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.promo-detail-card__info {
text-align: center;
}
.promo-detail-card__name {
display: block;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
}
.promo-detail-card__meta {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-top: 8px;
}
.promo-stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 0 16px 16px;
}
.promo-stat-card {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
}
.promo-stat-card__icon {
font-size: 20px;
color: var(--hq-red);
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.promo-stat-card__label {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin: 8px 0 4px;
}
.promo-stat-card__value {
display: block;
font-size: 22px;
font-weight: 700;
color: var(--hq-red);
}
.promo-stat-card__value--sm {
font-size: 14px;
color: #1a1a1a;
}
.promo-detail-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin: 16px;
padding-bottom: 24px;
}
.hq-label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--hq-muted);
margin-bottom: 8px;
letter-spacing: 0.05em;
}
+161
View File
@@ -0,0 +1,161 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { View, Text, Input } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import type { PromoCodeItem, PromoCodeStatus } from '@dukang/shared-types';
import HqHeader from '../../components/HqHeader';
import { request, type Paginated } from '../../lib/api';
import { useHqSession, navTo } from '../../lib/session';
import { promoConversion } from '../../lib/promo-qr';
import './index.css';
type StatusFilter = 'ALL' | PromoCodeStatus;
const STATUS_TABS: { key: StatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'ACTIVE', label: '运行中' },
{ key: 'DISABLED', label: '已停用' },
];
export default function PromoListPage() {
useHqSession();
const [allRows, setAllRows] = useState<PromoCodeItem[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState<StatusFilter>('ALL');
const load = useCallback(() => {
setLoading(true);
const qs = new URLSearchParams({ pageSize: '100' });
if (status !== 'ALL') qs.set('status', status);
request<Paginated<PromoCodeItem>>(`/admin/promo-codes?${qs}`)
.then((d) => setAllRows(d.items))
.catch(() => setAllRows([]))
.finally(() => setLoading(false));
}, [status]);
useEffect(load, [load]);
useDidShow(load);
const rows = useMemo(() => {
const q = keyword.trim().toLowerCase();
if (!q) return allRows;
return allRows.filter(
(r) => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q),
);
}, [allRows, keyword]);
const summary = useMemo(() => {
const scanTotal = rows.reduce((s, r) => s + r.scanCount, 0);
const orderTotal = rows.reduce((s, r) => s + r.orderCount, 0);
return { scanTotal, orderTotal, conversion: promoConversion(scanTotal, orderTotal) };
}, [rows]);
return (
<View className="hq-page promo-page">
<HqHeader title="推广码管理" back />
<View className="promo-hero">
<Text className="promo-hero__label">广</Text>
<Text className="promo-hero__value">{rows.length} 广</Text>
<View className="promo-hero__grid">
<View>
<Text className="promo-hero__stat-label"></Text>
<Text className="promo-hero__stat-value">{summary.scanTotal}</Text>
</View>
<View>
<Text className="promo-hero__stat-label"></Text>
<Text className="promo-hero__stat-value">{summary.orderTotal}</Text>
</View>
<View>
<Text className="promo-hero__stat-label"></Text>
<Text className="promo-hero__stat-value">{summary.conversion}</Text>
</View>
</View>
</View>
<View className="promo-toolbar">
<View className="promo-search">
<Text className="material-symbols-outlined">search</Text>
<Input
className="promo-search__input"
placeholder="搜索渠道名称或码值"
value={keyword}
onInput={(e) => setKeyword(e.detail.value)}
confirmType="search"
onConfirm={load}
/>
</View>
</View>
<View className="promo-tabs">
{STATUS_TABS.map((tab) => (
<View
key={tab.key}
className={`promo-tab${status === tab.key ? ' promo-tab--active' : ''}`}
onClick={() => setStatus(tab.key)}
>
<Text>{tab.label}</Text>
</View>
))}
</View>
{loading && <View className="promo-empty"></View>}
{!loading && rows.length === 0 && <View className="promo-empty">广</View>}
{rows.map((row) => (
<View key={row.id} className="promo-card">
<View className="promo-card__head">
<View className="promo-card__left">
<View className="promo-card__icon">
<Text className="material-symbols-outlined">qr_code_2</Text>
</View>
<View>
<Text className="promo-card__name">{row.name}</Text>
<Text className="promo-card__code">ID: {row.code}</Text>
</View>
</View>
<Text
className={`promo-card__badge ${
row.status === 'ACTIVE' ? 'promo-card__badge--active' : 'promo-card__badge--disabled'
}`}
>
{row.status === 'ACTIVE' ? '运行中' : '已停用'}
</Text>
</View>
<View className="promo-card__stats">
<View className="promo-card__stat">
<Text className="promo-card__stat-label">UV</Text>
<Text className="promo-card__stat-value">{row.scanCount}</Text>
</View>
<View className="promo-card__stat">
<Text className="promo-card__stat-label"></Text>
<Text className="promo-card__stat-value">{row.orderCount}</Text>
</View>
<View className="promo-card__stat">
<Text className="promo-card__stat-label"></Text>
<Text className="promo-card__stat-value promo-card__stat-value--red">
{promoConversion(row.scanCount, row.orderCount)}
</Text>
</View>
</View>
<View className="promo-card__actions">
<View
className="promo-card__btn promo-card__btn--outline"
onClick={() => navTo(`/pages/promo/detail?id=${row.id}`)}
>
<Text className="material-symbols-outlined" style="font-size:18px">visibility</Text>
<Text></Text>
</View>
</View>
</View>
))}
<View className="promo-fab" onClick={() => navTo('/pages/promo/generate')}>
<Text className="material-symbols-outlined">add_circle</Text>
<Text>广</Text>
</View>
</View>
);
}
+1 -2
View File
@@ -374,11 +374,10 @@ button, input, select, textarea { font: inherit; }
border-radius: 8px;
color: var(--color-subtle-gray);
text-decoration: none;
transition: transform 0.1s;
transition: color 0.15s;
}
.app-tabbar-item.active { color: var(--color-heritage-red); }
.app-tabbar-item:active { transform: scale(0.9); }
.app-tabbar-icon { font-size: 24px; line-height: 1; }
+5 -2
View File
@@ -2,6 +2,9 @@ import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-type
import { getRuntimePlatform, isWechatBrowser } from './env';
import type { WeixinSdkConfig } from './types';
/** 非微信内置浏览器时提示(与 C 端 LoginPage 文案一致) */
export const WECHAT_INAPP_REQUIRED_MSG = '请在微信内打开以使用微信一键授权';
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
function randomState() {
@@ -43,7 +46,7 @@ export async function getWechatOAuthUrl(
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
if (!isWechatBrowser()) {
throw new Error('请在微信内打开');
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
const uri = redirectUri ?? window.location.href.split('#')[0];
const url = await getWechatOAuthUrl(config, uri);
@@ -142,5 +145,5 @@ export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginR
await startWechatOAuthLogin(config);
return;
}
throw new Error('请在微信内打开以使用微信登录');
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
}
+1
View File
@@ -18,6 +18,7 @@ export {
bindWechatPhone,
getWechatPhoneNumber,
wechatLogin,
WECHAT_INAPP_REQUIRED_MSG,
} from './auth';
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
export { DEFAULT_JS_API_LIST } from './types';
+5 -2
View File
@@ -77,11 +77,14 @@
| 结算中心 | `pages/settlement/index`Tab | `/admin/redeem-records`(打款为 preV1 Mock | done(打款 Mock |
| 客服中心 | `pages/tickets/index`Tab | `/common/tickets``/common/tickets/:id/status` | done |
| 数据报表 | `pages/reports/index` | `/admin/dashboard/stats` | done |
| 推广码管理 | `pages/promo/index` | `GET /admin/promo-codes` | done |
| 推广码生成 | `pages/promo/generate` | `POST /admin/promo-codes` | done |
| 推广码详情 | `pages/promo/detail` | `GET /admin/promo-codes/:id``PUT .../status` | done |
| 补发 / 退款 | `pages/refund/index` | `/admin/orders?status=REFUNDING``/admin/orders/:id/status` | done |
> **preV1 裁剪**:总部端不含推广码模块(Stitch 原型中的推广码入口已移除)
> C 端落地:`h5-user` 解析 `?promo=` 并调用 `POST /promo/touch`;下单时 `trade.createOrder` 绑定 `user_promo_attribution`
> 微信授权登录:H5 普通浏览器走 `MOCK_WECHAT` Mock;小程序端 `Taro.login` 拿 code;正式接入填 `WX_APP_ID/SECRET` 并置 `MOCK_WECHAT=false`。
> 微信授权登录:H5 须在微信内置浏览器内 OAuth;本地 Mock 联调见 `MOCK_WECHAT`。
## 跳过(preV1 归档)
+126
View File
@@ -212,6 +212,9 @@ importers:
'@tarojs/taro':
specifier: 4.2.0
version: 4.2.0(@tarojs/components@4.2.0(@tarojs/helper@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15)))(@tarojs/helper@4.2.0)(@tarojs/shared@4.2.0)(@types/react@18.3.31)(postcss@8.5.15)(rollup@3.30.0)(webpack@5.97.1(@swc/core@1.3.96)(postcss@8.5.15))
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.3.1
version: 18.3.1
@@ -2806,6 +2809,10 @@ packages:
camel-case@4.1.2:
resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001799:
resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==}
@@ -2894,6 +2901,9 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@@ -3078,6 +3088,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decode-uri-component@0.4.1:
resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==}
engines: {node: '>=14.16'}
@@ -3171,6 +3185,9 @@ packages:
resolution: {integrity: sha512-glXVh42vz40yZb9Cq2oMOt70FIoWiv+vxNvdKdU8CwjLad25qHM3trLxhl9bVjdr6WaslIXhWpn0NO8T/67Qjg==}
engines: {node: '>= 8.0.0'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dingtalk-jsapi@2.15.2:
resolution: {integrity: sha512-ZN1zSPAb3BdHQviz7xO0j865QyKbOT+/YhDC9aOa89dPgVKrAO7RGzw5v1CxZPtOaWXSRDy1R07GJTeBXkUNHA==}
@@ -3477,6 +3494,10 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -4093,6 +4114,10 @@ packages:
resolution: {integrity: sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==}
engines: {node: '>=14'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -4477,6 +4502,10 @@ packages:
resolution: {integrity: sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==}
engines: {node: '>=4'}
p-limit@2.2.0:
resolution: {integrity: sha512-pZbTJpoUsCzV48Mc9Nh51VbwO0X9cuPFE8gYwx9BTCt9SF8/b7Zljd2fVgOxhIF/HDTKgpVzs+GPhyKfjLLFRQ==}
engines: {node: '>=6'}
p-limit@3.0.2:
resolution: {integrity: sha512-iwqZSOoWIW+Ew4kAGUlN16J4M7OB3ysMLSZtnhmqx7njIHFPlxWBX8xo3lVTyFVq6mI/lL9qt2IsN1sHwaxJkg==}
engines: {node: '>=10'}
@@ -4485,6 +4514,10 @@ packages:
resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==}
engines: {node: '>=18'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -4633,6 +4666,10 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-css-variables@0.19.0:
resolution: {integrity: sha512-Hr0WEYKLK9VCrY15anHXOd4RCvJy/xRvCnWdplGBeLInwEj6Z14hgzTb2W/39dYTCnS8hnHUfU4/F1zxX0IZuQ==}
peerDependencies:
@@ -4785,6 +4822,11 @@ packages:
resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==}
engines: {node: '>=20'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -5158,6 +5200,9 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resize-observer-polyfill@1.5.1:
resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==}
@@ -5314,6 +5359,9 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -5968,6 +6016,9 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
@@ -6024,6 +6075,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -6036,6 +6090,10 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@@ -6044,6 +6102,10 @@ packages:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.3.1:
resolution: {integrity: sha512-92O1HWEjw27sBfgmXiixJWT5hRBp2eobqXicLtPBIDBhYB+1HpwZlXmbW2luivBJHBzki+7VyCLRtAkScbTBQA==}
engines: {node: '>=8'}
yargs@16.2.2:
resolution: {integrity: sha512-Nt9ZJjXTv5R8MHbqby/wXQ6Gi0Bb3TcYZkR1bzuL4yB2OxWPkXknz513gEF0GoA6tn00UpbPvERW8rzCuWCA6w==}
engines: {node: '>=10'}
@@ -8856,6 +8918,8 @@ snapshots:
pascal-case: 3.1.2
tslib: 2.8.1
camelcase@5.3.1: {}
caniuse-lite@1.0.30001799: {}
capital-case@1.0.4:
@@ -8973,6 +9037,12 @@ snapshots:
cli-width@4.1.0: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@@ -9137,6 +9207,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
decode-uri-component@0.4.1: {}
decompress-response@3.3.0:
@@ -9230,6 +9302,8 @@ snapshots:
digest-header@1.1.0: {}
dijkstrajs@1.0.3: {}
dingtalk-jsapi@2.15.2:
dependencies:
promise-polyfill: 7.1.2
@@ -9620,6 +9694,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -10297,6 +10376,10 @@ snapshots:
mlly: 1.8.2
pkg-types: 1.3.1
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -10630,6 +10713,10 @@ snapshots:
p-is-promise@1.1.0: {}
p-limit@2.2.0:
dependencies:
p-try: 2.2.0
p-limit@3.0.2:
dependencies:
p-try: 2.2.0
@@ -10638,6 +10725,10 @@ snapshots:
dependencies:
yocto-queue: 1.2.2
p-locate@4.1.0:
dependencies:
p-limit: 2.2.0
p-locate@5.0.0:
dependencies:
p-limit: 3.0.2
@@ -10762,6 +10853,8 @@ snapshots:
pluralize@8.0.0: {}
pngjs@5.0.0: {}
postcss-css-variables@0.19.0(postcss@8.5.15):
dependencies:
balanced-match: 1.0.2
@@ -10895,6 +10988,12 @@ snapshots:
dependencies:
hookified: 2.2.0
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.3.1
qs@6.14.2:
dependencies:
side-channel: 1.1.1
@@ -11368,6 +11467,8 @@ snapshots:
require-from-string@2.0.2: {}
require-main-filename@2.0.0: {}
resize-observer-polyfill@1.5.1: {}
resolve-from@4.0.0: {}
@@ -11576,6 +11677,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -12278,6 +12381,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {}
which@1.3.1:
dependencies:
isexe: 2.0.0
@@ -12333,16 +12438,37 @@ snapshots:
xtend@4.0.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yaml@2.9.0: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
yargs@15.3.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@16.2.2:
dependencies:
cliui: 7.0.4
+4 -2
View File
@@ -13,7 +13,8 @@ MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# preV1 Mock 微信授权登录:点击授权按钮走 Mock 流程直接登录(不接真实微信)
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 且 WECHAT_AUTH_ENABLED=true。
MOCK_WECHAT=true
# C 端 H5 落地页(推广码二维码链接前缀)
@@ -22,7 +23,8 @@ USER_H5_URL=http://localhost:5173
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
# 微信SDKWECHAT_AUTH_ENABLED=true 时生效
# 微信 SDK生产:WECHAT_AUTH_ENABLED=true,配置 WX_APP_ID / WX_APP_SECRET
# OAuth 授权页由 /common/wechat/oauth-url 生成;C/合伙人/总部 H5 均须在微信内置浏览器内授权。
WX_APP_ID=
WX_APP_SECRET=
WECHAT_AUTH_ENABLED=false
+8
View File
@@ -216,6 +216,14 @@ async function main() {
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKDEMO1',
name: '郑州品鉴会演示',
status: 'ACTIVE',
},
});
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
@@ -114,46 +114,65 @@ export class TradeService {
body.clientLocation,
);
const order = await this.prisma.order.create({
data: {
orderNo,
userId,
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
receiverProvince: address.province,
receiverCity: address.city,
receiverDistrict: address.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
gpsProvince: location.gpsProvince,
gpsCity: location.gpsCity,
gpsDistrict: location.gpsDistrict,
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
payExpireAt,
},
include: { product: true, imageResource: true },
const attribution = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
include: { promoCode: true },
});
const promoCodeId =
attribution?.promoCode?.status === 'ACTIVE' ? attribution.promoCodeId : undefined;
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNo,
userId,
cityId: city.id,
status: 'PENDING_PAY',
payStatus: 'UNPAID',
deliveryType: preview.deliveryType as 'LOCAL' | 'CROSS_CITY',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
imageResourceId: product.coverResourceId,
quantity: body.quantity,
listUnitPrice: product.price,
listAmount: preview.productAmount,
productAmount: preview.productAmount,
receiverName: address.receiverName,
receiverPhone: address.phone,
receiverAddress: `${address.province}${address.city}${address.district}${address.detail}`,
receiverProvince: address.province,
receiverCity: address.city,
receiverDistrict: address.district,
clientIp: location.clientIp,
ipProvince: location.ipProvince,
ipCity: location.ipCity,
ipDistrict: location.ipDistrict,
gpsProvince: location.gpsProvince,
gpsCity: location.gpsCity,
gpsDistrict: location.gpsDistrict,
gpsLatitude: location.gpsLatitude,
gpsLongitude: location.gpsLongitude,
gpsAddress: location.gpsAddress,
freightAmount: preview.freightAmount,
freightPayType: preview.freightPayType,
payAmount: preview.payAmount,
benefitAmount: preview.benefitAmount,
payExpireAt,
promoCodeId,
},
include: { product: true, imageResource: true },
});
if (promoCodeId) {
await tx.commonPromoCode.update({
where: { id: promoCodeId },
data: { orderCount: { increment: 1 } },
});
}
return created;
});
return serializeBigInt(mapOrderCompat(order));