fix:完善门店和总部管理端
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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 ── */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
/* 静默失败,不阻断用户流程 */
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}%`;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user