feat;提交管理端和城市合伙人端

This commit is contained in:
ljy
2026-07-05 23:48:20 +08:00
parent fecfc61ec5
commit 569becedaa
73 changed files with 10150 additions and 80 deletions
+6 -1
View File
@@ -5,6 +5,11 @@ export default defineConfig({
plugins: [react()],
server: {
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: {
'/api': {
target: process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top',
changeOrigin: true,
},
},
},
});
+4 -3
View File
@@ -13,7 +13,8 @@ import BillsPage from './pages/BillsPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
import { handlePartnerWechatCallback, savePartnerWechatAuth } from './lib/wechat-auth';
import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth';
import { isLoggedIn } from './lib/api';
import { isWechatEnv } from './lib/weixin';
function WechatOAuthHandler() {
@@ -24,7 +25,7 @@ function WechatOAuthHandler() {
if (!isWechatEnv() || !location.search.includes('code=')) return;
void handlePartnerWechatCallback()
.then((result) => {
if (!result || !savePartnerWechatAuth(result)) return;
if (!result || !handlePartnerWechatLoginResult(result)) return;
const params = new URLSearchParams(location.search);
params.delete('code');
params.delete('state');
@@ -58,7 +59,7 @@ export default function App() {
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to={isLoggedIn() ? '/' : '/login'} replace />} />
</Routes>
</>
);
@@ -0,0 +1,66 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
import { clearAuth, isLoggedIn, request } from '../lib/api';
export type PartnerAccount = {
id: string;
name: string;
phone: string;
isPrimary?: boolean;
companyName?: string;
hasWechat?: boolean;
};
type PartnerSessionValue = {
account: PartnerAccount | null;
loading: boolean;
loggedIn: boolean;
refresh: () => Promise<void>;
logout: () => void;
};
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
const [account, setAccount] = useState<PartnerAccount | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
if (!isLoggedIn()) {
setAccount(null);
setLoading(false);
return;
}
try {
const data = await request<PartnerAccount>('PARTNER_H5', '/partner/me');
setAccount(data);
} catch {
setAccount(null);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
clearAuth();
setAccount(null);
window.location.href = '/login';
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<PartnerSessionContext.Provider
value={{ account, loading, loggedIn: isLoggedIn(), refresh, logout }}
>
{children}
</PartnerSessionContext.Provider>
);
}
export function usePartnerSession(): PartnerSessionValue {
const ctx = useContext(PartnerSessionContext);
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
return ctx;
}
+2 -4
View File
@@ -1,4 +1,4 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { NavLink, Navigate, Outlet } from 'react-router-dom';
import { isLoggedIn } from '../lib/api';
const TABS = [
@@ -8,10 +8,8 @@ const TABS = [
] as const;
export default function TabLayout() {
const navigate = useNavigate();
if (!isLoggedIn()) {
navigate('/login');
return null;
return <Navigate to="/login" replace />;
}
return (
<>
+8 -1
View File
@@ -9,7 +9,14 @@ export async function request<T>(clientApp: string, path: string, options: Reque
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
if (res.status === 401 || json.code === 401) {
clearAuth();
if (typeof window !== 'undefined' && !window.location.pathname.startsWith('/login')) {
window.location.href = '/login';
}
throw new Error(json.message || '登录已过期,请重新登录');
}
if (json.code !== 0) throw new Error(json.message || '请求失败');
return json.data as T;
}
+51 -4
View File
@@ -19,12 +19,59 @@ export function needsWechatAuth(profile: PartnerProfile | null): boolean {
return isWechatEnv() && !!profile && !profile.hasWechat;
}
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean {
if (!result.accessToken) return false;
saveAuth({ accessToken: result.accessToken });
return true;
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
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)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginPartnerWithWechat(): Promise<boolean | void> {
if (isWechatEnv()) {
const result = await weixinSdk.login();
if (result) return handlePartnerWechatLoginResult(result);
return;
}
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);
}
/**
* 短信登录成功后于微信内自动发起 OAuth,将 openId 绑定到当前合伙人账号(便于同一微信后续免登)。
*/
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
if (!isWechatEnv()) return;
await weixinSdk.login();
}
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
if (!isWechatEnv()) {
throw new Error('请在微信内打开以完成授权');
@@ -32,7 +79,7 @@ export async function authorizePartnerWechat(): Promise<WechatLoginResult | void
return weixinSdk.login();
}
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
/** @deprecated 使用 handlePartnerWechatLoginResult */
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
return handlePartnerWechatLoginResult(result);
}
+8 -1
View File
@@ -2,8 +2,15 @@ import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { PartnerSessionProvider } from './contexts/PartnerSessionContext';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
<React.StrictMode>
<BrowserRouter>
<PartnerSessionProvider>
<App />
</PartnerSessionProvider>
</BrowserRouter>
</React.StrictMode>,
);
+5 -4
View File
@@ -1,15 +1,16 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { clearAuth, isLoggedIn, request } from '../lib/api';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
export default function CenterPage() {
const navigate = useNavigate();
const [me, setMe] = useState<Record<string, unknown> | null>(null);
const { account, logout } = usePartnerSession();
const me = account as unknown as Record<string, unknown> | null;
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/me').then(setMe);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate]);
@@ -134,7 +135,7 @@ export default function CenterPage() {
</div>
</section>
<button type="button" className="partner-logout-btn" onClick={() => { clearAuth(); navigate('/login'); }}>
<button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span>
退
</button>
+128 -22
View File
@@ -1,18 +1,109 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { request, saveAuth } from '../lib/api';
import {
bindPartnerWechatAfterSmsLogin,
handlePartnerWechatCallback,
handlePartnerWechatLoginResult,
loginPartnerWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
function loadRememberedPhone(): { phone: string; remember: boolean } {
try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
const phone = remember ? localStorage.getItem(REMEMBER_PHONE_KEY) || '' : '';
return { phone, remember };
} catch {
return { phone: '', remember: false };
}
}
export default function LoginPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const [phone, setPhone] = useState('13700000001');
const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || '13700000001');
const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
useEffect(() => {
if (!isWechatEnv()) return;
void handlePartnerWechatCallback()
.then((result) => {
if (!result) return;
if (handlePartnerWechatLoginResult(result)) navigate('/');
})
.catch((e) => setMsg(formatWechatError(e)));
}, [navigate]);
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
return false;
}
return true;
}
function persistRememberAccount(nextPhone: string) {
try {
if (rememberAccount) {
localStorage.setItem(REMEMBER_FLAG_KEY, '1');
localStorage.setItem(REMEMBER_PHONE_KEY, nextPhone);
} else {
localStorage.removeItem(REMEMBER_FLAG_KEY);
localStorage.removeItem(REMEMBER_PHONE_KEY);
}
} catch {
/* ignore */
}
}
async function sendCode() {
if (!ensureAgreed()) return;
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
});
setMsg('验证码已发送(Mock: 123456');
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function login() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
await request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
@@ -23,26 +114,33 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
persistRememberAccount(phone);
// 微信内:短信登录后自动发起 OAuth 绑定同一微信,下次可一键授权登录
if (isWechatEnv()) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
navigate('/');
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
function sendCode() {
if (codeCooldown > 0) return;
request('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
}).then(() => {
setCodeCooldown(60);
const timer = setInterval(() => {
setCodeCooldown((s) => {
if (s <= 1) { clearInterval(timer); return 0; }
return s - 1;
});
}, 1000);
});
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
setWxLoading(true);
try {
const ok = await loginPartnerWithWechat();
if (ok) navigate('/');
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
@@ -70,6 +168,7 @@ export default function LoginPage() {
</section>
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '一键登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
@@ -113,21 +212,28 @@ export default function LoginPage() {
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
</button>
</div>
<label className="partner-checkbox-row">
<input type="checkbox" defaultChecked />
<label className="partner-remember-row">
<input
type="checkbox"
checked={rememberAccount}
onChange={(e) => setRememberAccount(e.target.checked)}
/>
<span></span>
</label>
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-outline" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8 }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="#07C160"><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>
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
<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>
</button>
<label className="partner-checkbox-row">
<input type="checkbox" />
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>
<span className="text-primary" style={{ fontWeight: 600 }}></span> <span className="text-primary" style={{ fontWeight: 600 }}></span>
</span>
+79
View File
@@ -277,6 +277,58 @@ html, body, #root {
cursor: pointer;
}
.partner-btn-wechat {
width: 100%;
padding: 16px;
border: 1px solid rgba(226, 190, 188, 0.2);
border-radius: var(--radius-md);
background: var(--color-surface-container-low);
color: var(--color-on-surface);
font-family: var(--font-headline);
font-size: 18px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 0.2s, transform 0.1s;
}
.partner-btn-wechat:hover {
background: var(--color-surface-container-high);
}
.partner-btn-wechat:active {
transform: scale(0.98);
}
.partner-btn-wechat:disabled {
opacity: 0.6;
pointer-events: none;
}
.partner-btn-wechat svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.partner-remember-row {
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
font-size: 12px;
color: var(--color-on-surface-variant);
}
.partner-remember-row input {
width: 16px;
height: 16px;
accent-color: var(--color-heritage-red);
}
.partner-link {
display: block;
text-align: center;
@@ -1997,3 +2049,30 @@ html, body, #root {
justify-content: center;
gap: 8px;
}
/* ─────────────────────────────────────────────
iOS / Android 机型适配:顶部安全区(刘海 / 状态栏)
viewport-fit=cover 下内容会延伸到状态栏,需为吸顶头部补 inset。
───────────────────────────────────────────── */
.partner-home-header,
.header.app-page-header,
.app-page-header,
.page-header {
padding-top: env(safe-area-inset-top, 0px);
height: auto;
min-height: calc(56px + env(safe-area-inset-top, 0px));
box-sizing: border-box;
}
/* 登录 / 快捷登录页顶部留出状态栏空间,避免品牌区贴到刘海 */
.partner-auth-page {
padding-top: calc(48px + env(safe-area-inset-top, 0px));
}
/* 全屏容器铺满机身背景,安全区外也保持底色一致 */
html,
body {
min-height: 100vh;
min-height: 100dvh;
background: var(--color-background);
}
+3 -1
View File
@@ -11,6 +11,8 @@ export default defineConfig({
},
server: {
port: 5175,
proxy: { '/api': 'http://localhost:3000' },
proxy: {
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3000',
},
},
});
+6
View File
@@ -0,0 +1,6 @@
// babel-preset-taro 用于 weapp 等平台编译;H5 由 vite + @vitejs/plugin-react 处理
module.exports = {
presets: [
['taro', { framework: 'react', ts: true, compiler: 'vite' }],
],
};
+58
View File
@@ -0,0 +1,58 @@
import { defineConfig } from '@tarojs/cli';
// 总部管理端 Taro 配置:先 H5,后续 weapp(微信小程序)
export default defineConfig(async () => ({
projectName: 'mini-hq',
date: '2026-7-5',
designWidth: 375,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2,
},
sourceRoot: 'src',
outputRoot: 'dist',
plugins: ['@tarojs/plugin-html'],
defineConstants: {
/** H5 静态托管无 /api 代理时直连后端;dev 构建可通过 VITE_API_TARGET 覆盖 */
TARO_APP_API_ORIGIN: JSON.stringify(process.env.VITE_API_TARGET ?? 'http://localhost:3000'),
},
copy: {
patterns: [],
options: {},
},
framework: 'react',
compiler: {
type: 'vite',
vitePlugins: [],
},
cache: {
enable: false,
},
mini: {
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
h5: {
publicPath: '/',
staticDirectory: 'static',
devServer: {
port: 5176,
host: '0.0.0.0',
proxy: {
'/api': {
target: process.env.VITE_API_TARGET ?? 'http://localhost:3000',
changeOrigin: true,
},
},
},
postcss: {
autoprefixer: { enable: true, config: {} },
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
},
},
}));
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@dukang/mini-hq",
"version": "0.1.0",
"private": true,
"description": "杜康好客 · 总部管理端(Taro,先 H5 后小程序)",
"scripts": {
"dev": "taro build --type h5 --watch",
"dev:weapp": "taro build --type weapp --watch",
"build": "taro build --type h5",
"build:weapp": "taro build --type weapp",
"preview": "node ../../scripts/preview-hq.mjs",
"lint": "echo ok"
},
"dependencies": {
"@babel/runtime": "^7.24.4",
"@dukang/shared-types": "workspace:*",
"@dukang/weixin-sdk": "workspace:*",
"@tarojs/components": "4.2.0",
"@tarojs/helper": "4.2.0",
"@tarojs/plugin-framework-react": "4.2.0",
"@tarojs/plugin-html": "4.2.0",
"@tarojs/plugin-platform-h5": "4.2.0",
"@tarojs/plugin-platform-weapp": "4.2.0",
"@tarojs/react": "4.2.0",
"@tarojs/router": "4.2.0",
"@tarojs/runtime": "4.2.0",
"@tarojs/shared": "4.2.0",
"@tarojs/taro": "4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/preset-react": "^7.24.1",
"@tarojs/cli": "4.2.0",
"@tarojs/vite-runner": "4.2.0",
"@types/react": "^18.3.3",
"@vitejs/plugin-react": "^4.3.1",
"babel-preset-taro": "4.2.0",
"typescript": "^5.4.5",
"vite": "^5.4.0"
},
"browserslist": [
"last 3 versions",
"Android >= 4.1",
"ios >= 8"
]
}
+14
View File
@@ -0,0 +1,14 @@
{
"miniprogramRoot": "dist/",
"projectname": "mini-hq",
"description": "杜康好客总部管理端",
"appid": "touristappid",
"setting": {
"urlCheck": true,
"es6": false,
"enhance": false,
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
}
+36
View File
@@ -0,0 +1,36 @@
export default defineAppConfig({
pages: [
'pages/dashboard/index',
'pages/stores/index',
'pages/settlement/index',
'pages/tickets/index',
'pages/login/index',
'pages/stores/detail',
'pages/orders/index',
'pages/orders/detail',
'pages/cities/index',
'pages/products/index',
'pages/reports/index',
'pages/refund/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#A61D24',
navigationBarTitleText: '杜康好客·总部',
navigationBarTextStyle: 'white',
navigationStyle: 'custom',
},
tabBar: {
custom: true,
color: '#8D706E',
selectedColor: '#A61D24',
backgroundColor: '#FFFFFF',
borderStyle: 'black',
list: [
{ pagePath: 'pages/dashboard/index', text: '管理中心' },
{ pagePath: 'pages/stores/index', text: '门店审核' },
{ pagePath: 'pages/settlement/index', text: '结算中心' },
{ pagePath: 'pages/tickets/index', text: '客服中心' },
],
},
});
+429
View File
@@ -0,0 +1,429 @@
/* 杜康好客 · 总部管理端全局样式(Taro H5 / 小程序通用) */
:root {
--hq-red: #a61d24;
--hq-red-dark: #820012;
--hq-amber: #c9a227;
--hq-bg: #f5f3f0;
--hq-surface: #ffffff;
--hq-text: #1f1a17;
--hq-muted: #8d706e;
--hq-line: #ece7e2;
--hq-green: #2d6a4f;
--hq-blue: #2a6ebb;
--hq-orange: #c26a1b;
--hq-radius: 16px;
--hq-shadow: 0 2px 12px rgba(93, 64, 55, 0.08);
--hq-safe-top: env(safe-area-inset-top, 0px);
--hq-safe-bottom: env(safe-area-inset-bottom, 0px);
}
page,
body {
background: var(--hq-bg);
color: var(--hq-text);
font-family: 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'PingFang SC', sans-serif;
margin: 0;
-ms-overflow-style: none;
scrollbar-width: none;
}
/* 隐藏滚动条,保留触摸/滚轮滚动 */
html,
body,
page,
#app,
.taro_page,
.hq-page,
scroll-view,
.taro-scroll,
.taro-scroll-view,
.taro-scroll-view__scroll-x,
.taro-scroll-view__scroll-y {
-ms-overflow-style: none;
scrollbar-width: none;
}
html::-webkit-scrollbar,
body::-webkit-scrollbar,
page::-webkit-scrollbar,
#app::-webkit-scrollbar,
.hq-page::-webkit-scrollbar,
scroll-view::-webkit-scrollbar,
.taro-scroll::-webkit-scrollbar,
.taro-scroll-view::-webkit-scrollbar,
.taro-scroll-view__scroll-x::-webkit-scrollbar,
.taro-scroll-view__scroll-y::-webkit-scrollbar,
*::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
background: transparent;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined';
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
/* 页面骨架 */
.hq-page {
min-height: 100vh;
padding-bottom: calc(24px + var(--hq-safe-bottom));
background: var(--hq-bg);
box-sizing: border-box;
}
.hq-page--tab {
padding-bottom: calc(96px + var(--hq-safe-bottom));
}
/* 顶部安全区头部(刘海 / 状态栏适配) */
.hq-header {
position: sticky;
top: 0;
z-index: 50;
padding-top: var(--hq-safe-top);
background: linear-gradient(135deg, var(--hq-red), var(--hq-red-dark));
color: #fff;
box-shadow: var(--hq-shadow);
}
.hq-header__bar {
height: 52px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
padding: 0 16px;
}
.hq-header__title {
font-size: 17px;
font-weight: 700;
}
.hq-header__back,
.hq-header__action {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
color: #fff;
font-size: 22px;
}
.hq-header__back {
left: 12px;
}
.hq-header__action {
right: 12px;
font-size: 14px;
gap: 4px;
}
.hq-header--plain {
background: var(--hq-surface);
color: var(--hq-text);
}
.hq-header--plain .hq-header__title {
color: var(--hq-red);
}
.hq-header--plain .hq-header__back,
.hq-header--plain .hq-header__action {
color: var(--hq-red);
}
/* 卡片 */
.hq-card {
background: var(--hq-surface);
border-radius: var(--hq-radius);
margin: 12px 16px;
padding: 16px;
box-shadow: var(--hq-shadow);
box-sizing: border-box;
}
.hq-section-title {
font-size: 15px;
font-weight: 700;
margin: 20px 16px 8px;
display: flex;
align-items: center;
justify-content: space-between;
}
.hq-muted {
color: var(--hq-muted);
}
.hq-row {
display: flex;
align-items: center;
justify-content: space-between;
}
/* 统计网格 */
.hq-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin: 12px 16px;
}
.hq-stat {
background: var(--hq-surface);
border-radius: var(--hq-radius);
padding: 16px;
box-shadow: var(--hq-shadow);
}
.hq-stat__label {
font-size: 12px;
color: var(--hq-muted);
}
.hq-stat__value {
font-size: 24px;
font-weight: 800;
color: var(--hq-red);
margin-top: 6px;
font-variant-numeric: tabular-nums;
}
.hq-stat__sub {
font-size: 11px;
color: var(--hq-muted);
margin-top: 4px;
}
/* 列表项 */
.hq-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--hq-surface);
border-bottom: 1px solid var(--hq-line);
}
.hq-list-item:active {
background: #faf8f6;
}
.hq-avatar {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
/* 徽标 / 状态 */
.hq-badge {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.hq-badge--warn {
background: rgba(194, 106, 27, 0.12);
color: var(--hq-orange);
}
.hq-badge--ok {
background: rgba(45, 106, 79, 0.12);
color: var(--hq-green);
}
.hq-badge--info {
background: rgba(42, 110, 187, 0.12);
color: var(--hq-blue);
}
.hq-badge--danger {
background: rgba(166, 29, 36, 0.12);
color: var(--hq-red);
}
/* 按钮 */
.hq-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: none;
border-radius: 12px;
padding: 12px 18px;
font-size: 15px;
font-weight: 600;
line-height: 1.2;
}
.hq-btn--primary {
background: var(--hq-red);
color: #fff;
}
.hq-btn--ghost {
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
}
.hq-btn--outline {
background: transparent;
border: 1px solid var(--hq-line);
color: var(--hq-text);
}
.hq-btn--block {
width: 100%;
}
.hq-btn:active {
opacity: 0.85;
}
.hq-btn[disabled] {
opacity: 0.5;
}
/* 快捷入口宫格 */
.hq-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin: 12px 16px;
background: var(--hq-surface);
border-radius: var(--hq-radius);
padding: 16px 8px;
box-shadow: var(--hq-shadow);
}
.hq-grid__item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0;
}
.hq-grid__icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: rgba(166, 29, 36, 0.08);
color: var(--hq-red);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.hq-grid__label {
font-size: 12px;
color: var(--hq-text);
}
/* 分段 Tab */
.hq-tabs {
display: flex;
gap: 4px;
overflow-x: auto;
overflow-y: hidden;
padding: 8px 16px;
background: var(--hq-surface);
border-bottom: 1px solid var(--hq-line);
position: sticky;
top: calc(52px + var(--hq-safe-top));
z-index: 40;
-ms-overflow-style: none;
scrollbar-width: none;
}
.hq-tabs::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
}
.hq-tab {
flex-shrink: 0;
padding: 6px 14px;
border-radius: 999px;
font-size: 13px;
color: var(--hq-muted);
background: transparent;
}
.hq-tab--active {
background: var(--hq-red);
color: #fff;
font-weight: 600;
}
/* 空态 / 加载 */
.hq-empty {
text-align: center;
padding: 60px 24px;
color: var(--hq-muted);
font-size: 14px;
}
/* 表单 */
.hq-field {
margin: 12px 16px;
}
.hq-field__label {
font-size: 13px;
color: var(--hq-muted);
margin-bottom: 6px;
display: block;
}
.hq-input {
width: 100%;
box-sizing: border-box;
background: var(--hq-surface);
border: 1px solid var(--hq-line);
border-radius: 12px;
padding: 12px 14px;
font-size: 15px;
color: var(--hq-text);
}
.store-cover {
width: 100%;
height: 180px;
display: block;
}
/* 底部固定操作栏 */
.hq-footer-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 12px;
padding: 12px 16px calc(12px + var(--hq-safe-bottom));
background: var(--hq-surface);
border-top: 1px solid var(--hq-line);
z-index: 60;
}
+8
View File
@@ -0,0 +1,8 @@
import { PropsWithChildren } from 'react';
import './app.css';
function App({ children }: PropsWithChildren) {
return children;
}
export default App;
+31
View File
@@ -0,0 +1,31 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
type Props = {
title: string;
back?: boolean;
plain?: boolean;
actionText?: string;
onAction?: () => void;
};
/** 带顶部安全区(刘海/状态栏)适配的通用头部 */
export default function HqHeader({ title, back, plain, actionText, onAction }: Props) {
return (
<View className={`hq-header${plain ? ' hq-header--plain' : ''}`}>
<View className="hq-header__bar">
{back && (
<View className="hq-header__back" onClick={() => Taro.navigateBack()}>
<Text className="material-symbols-outlined">arrow_back_ios_new</Text>
</View>
)}
<Text className="hq-header__title">{title}</Text>
{actionText && (
<View className="hq-header__action" onClick={onAction}>
<Text>{actionText}</Text>
</View>
)}
</View>
</View>
);
}
+51
View File
@@ -0,0 +1,51 @@
.hq-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
justify-content: space-around;
align-items: center;
padding: 10px 12px calc(10px + var(--hq-safe-bottom));
background: #fff;
border-top: 1px solid rgba(226, 190, 188, 0.2);
border-radius: 16px 16px 0 0;
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
box-sizing: border-box;
}
.hq-tabbar__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 6px 4px;
border-radius: 12px;
color: #4e5852;
opacity: 0.75;
transition: background 0.15s, color 0.15s, opacity 0.15s;
}
.hq-tabbar__item--active {
color: var(--hq-red);
background: rgba(255, 218, 215, 0.35);
opacity: 1;
}
.hq-tabbar__icon {
font-size: 24px;
line-height: 1;
}
.hq-tabbar__icon--active {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.hq-tabbar__label {
font-size: 12px;
font-weight: 500;
line-height: 1.2;
}
+41
View File
@@ -0,0 +1,41 @@
import { View, Text } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './HqTabBar.css';
export const HQ_TABS = [
{ pagePath: '/pages/dashboard/index', text: '管理中心', icon: 'dashboard' },
{ pagePath: '/pages/stores/index', text: '门店审核', icon: 'fact_check' },
{ pagePath: '/pages/settlement/index', text: '结算中心', icon: 'account_balance_wallet' },
{ pagePath: '/pages/tickets/index', text: '客服中心', icon: 'support_agent' },
] as const;
type HqTabBarProps = {
selected: number;
};
/** 总部底栏(H5 需页面内显式渲染;Taro custom-tab-bar 在 H5 不自动挂载) */
export default function HqTabBar({ selected }: HqTabBarProps) {
return (
<View className="hq-tabbar">
{HQ_TABS.map((tab, index) => {
const active = selected === index;
return (
<View
key={tab.pagePath}
className={`hq-tabbar__item${active ? ' hq-tabbar__item--active' : ''}`}
onClick={() => {
if (!active) Taro.switchTab({ url: tab.pagePath });
}}
>
<Text
className={`material-symbols-outlined hq-tabbar__icon${active ? ' hq-tabbar__icon--active' : ''}`}
>
{tab.icon}
</Text>
<Text className="hq-tabbar__label">{tab.text}</Text>
</View>
);
})}
</View>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { Component } from 'react';
import HqTabBar from '../components/HqTabBar';
/** 小程序 custom-tab-bar 入口;H5 由各 Tab 页直接渲染 HqTabBar */
export default class CustomTabBar extends Component {
state = { selected: 0 };
setSelected(index: number) {
this.setState({ selected: index });
}
render() {
return <HqTabBar selected={this.state.selected} />;
}
}
+21
View File
@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>杜康好客·总部管理端</title>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&family=Noto+Sans+SC:wght@400;500;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="app"></div>
<script><%= htmlWebpackPlugin.options.script %></script>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
import Taro from '@tarojs/taro';
function resolveApiBase(): string {
const origin =
typeof TARO_APP_API_ORIGIN !== 'undefined' && TARO_APP_API_ORIGIN
? TARO_APP_API_ORIGIN
: process.env.TARO_ENV === 'h5'
? 'http://localhost:3000'
: '';
if (origin) {
return `${origin.replace(/\/$/, '')}/api/v1`;
}
return '/api/v1';
}
export const API_BASE = resolveApiBase();
const TOKEN_KEY = 'hq_access_token';
const CLIENT_APP = 'HQ_WEB';
export function getToken(): string {
try {
return Taro.getStorageSync(TOKEN_KEY) || '';
} catch {
return '';
}
}
export function saveToken(token: string) {
Taro.setStorageSync(TOKEN_KEY, token);
}
export function clearToken() {
Taro.removeStorageSync(TOKEN_KEY);
}
export function isLoggedIn(): boolean {
return !!getToken();
}
export function redirectToLogin() {
Taro.reLaunch({ url: '/pages/login/index' });
}
export function logout() {
clearToken();
redirectToLogin();
}
type ReqOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
data?: Record<string, unknown> | unknown;
auth?: boolean;
};
function parseBody(data: unknown): { code?: number; message?: string } {
if (data && typeof data === 'object') {
return data as { code?: number; message?: string };
}
return {};
}
/** 统一请求:注入 X-Client-App + Bearer,解包 { code, message, data }401 自动回登录 */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': CLIENT_APP,
};
const token = getToken();
if (token) header.Authorization = `Bearer ${token}`;
const res = await Taro.request({
url: `${API_BASE}${path}`,
method: options.method ?? 'GET',
data: options.data as Record<string, unknown>,
header,
});
const status = res.statusCode;
const body = parseBody(res.data);
if (status === 401 || body?.code === 401) {
clearToken();
redirectToLogin();
throw new Error(body?.message || '登录已过期,请重新登录');
}
if (status === 404 && body.code === undefined) {
throw new Error('接口不可达,请确认 API 服务已启动');
}
if (status >= 400 || body.code !== 0) {
throw new Error(body?.message || `请求失败(${status})`);
}
return (res.data as { data: T }).data;
}
export type Paginated<T> = { items: T[]; total: number };
export function toast(title: string, icon: 'success' | 'error' | 'none' = 'none') {
Taro.showToast({ title, icon, duration: 1800 });
}
+45
View File
@@ -0,0 +1,45 @@
export const ORDER_STATUS_LABELS: Record<string, string> = {
PENDING_PAY: '待付款',
PENDING_SHIP: '待发货',
OUT_WAREHOUSE: '已出库',
SHIPPING: '配送中',
PENDING_RECEIVE: '待收货',
COMPLETED: '已完成',
CANCELLED: '已取消',
REFUNDING: '退款中',
REFUNDED: '已退款',
};
export const STORE_STATUS_LABELS: Record<string, string> = {
OPEN: '营业中',
PAUSED: '暂停',
CLOSED: '已关闭',
};
export const CITY_STATUS_LABELS: Record<string, string> = {
PENDING: '待开城',
ACTIVE: '已开城',
PAUSED: '已暂停',
};
export const PRODUCT_STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
ON_SALE: '在售',
OFF_SALE: '下架',
};
export function badgeClass(status: string): string {
if (['OPEN', 'ACTIVE', 'ON_SALE', 'COMPLETED', 'PAID', 'CONFIRMED'].includes(status)) return 'hq-badge--ok';
if (['PENDING', 'PENDING_PAY', 'PENDING_SHIP', 'DRAFT', 'PENDING_RECEIVE'].includes(status)) return 'hq-badge--warn';
if (['CLOSED', 'CANCELLED', 'REFUNDED', 'OFF_SALE', 'VOID'].includes(status)) return 'hq-badge--danger';
return 'hq-badge--info';
}
export function fmtTime(v?: string | null): string {
return v ? new Date(v).toLocaleString('zh-CN') : '—';
}
export function fmtMoney(v?: number | string | null): string {
const n = Number(v ?? 0);
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro';
import { isLoggedIn, redirectToLogin, request } from './api';
export type HqAccount = {
id: string;
name: string;
phone: string;
adminRole: string;
status: string;
};
let cache: HqAccount | null = null;
export async function fetchHqAccount(force = false): Promise<HqAccount | null> {
if (cache && !force) return cache;
if (!isLoggedIn()) return null;
try {
cache = await request<HqAccount>('/admin/auth/me');
return cache;
} catch {
return null;
}
}
export function clearHqAccountCache() {
cache = null;
}
/** 页面级会话守卫:未登录跳登录页;返回当前 HQ 账号 */
export function useHqSession(guard = true) {
const [account, setAccount] = useState<HqAccount | null>(cache);
const [loading, setLoading] = useState(!cache);
useEffect(() => {
if (guard && !isLoggedIn()) {
redirectToLogin();
return;
}
let alive = true;
void fetchHqAccount().then((acc) => {
if (!alive) return;
setAccount(acc);
setLoading(false);
});
return () => {
alive = false;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { account, loading };
}
export function roleLabel(role?: string): string {
switch (role) {
case 'SUPER_ADMIN':
return '超级管理员';
case 'OPS':
return '运营';
case 'FINANCE':
return '财务';
case 'SUPPORT':
return '客服';
default:
return role || '管理员';
}
}
export function navTo(url: string) {
Taro.navigateTo({ url });
}
export function switchToTab(url: string) {
Taro.switchTab({ url });
}
+78
View File
@@ -0,0 +1,78 @@
import type { WechatLoginResult } from '@dukang/shared-types';
import Taro from '@tarojs/taro';
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;
saveToken(result.accessToken);
return true;
}
/** 微信内 OAuth 回调:URL 带 code 时兑换 token */
export async function handleHqWechatCallback(): Promise<WechatLoginResult | null> {
if (process.env.TARO_ENV === 'weapp') {
return null;
}
if (!isWechatEnv()) return null;
return weixinSdk.handleOAuthCallback();
}
/**
* 微信一键登录(与合伙人端一致:微信内走公众号 OAuth,浏览器 Mock 演示)。
* 返回 true = 已登录;void = 已跳转授权页等待回调。
*/
export async function loginHqWithWechat(): Promise<boolean | void> {
if (process.env.TARO_ENV === 'weapp') {
const res = await Taro.login();
const result = await request<WechatLoginResult>('/admin/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
return handleHqWechatLoginResult(result);
}
if (isWechatEnv()) {
const result = await weixinSdk.login();
if (result) return handleHqWechatLoginResult(result);
return;
}
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);
}
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
export async function bindHqWechatAfterSmsLogin(): Promise<void> {
if (process.env.TARO_ENV === 'weapp') {
const res = await Taro.login();
const result = await request<WechatLoginResult>('/admin/auth/login/wechat', {
method: 'POST',
data: { code: res.code, platform: 'mini' },
});
handleHqWechatLoginResult(result);
return;
}
if (!isWechatEnv()) return;
await weixinSdk.login();
}
+11
View File
@@ -0,0 +1,11 @@
import { createWeixinSdk, isWechatEnv } from '@dukang/weixin-sdk';
import { API_BASE, getToken } from './api';
export const weixinSdk = createWeixinSdk({
apiBase: API_BASE,
clientApp: 'HQ_WEB',
getAccessToken: () => getToken(),
wechatLoginPath: '/admin/auth/login/wechat',
});
export { isWechatEnv };
+65
View File
@@ -0,0 +1,65 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, type Paginated } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { CITY_STATUS_LABELS, badgeClass } from '../../lib/constants';
type CityRow = {
id: string;
code: string;
name: string;
province?: string;
status: string;
storeCount?: number;
orderCount?: number;
partner?: { companyName: string };
};
export default function CitiesPage() {
useHqSession();
const [rows, setRows] = useState<CityRow[]>([]);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
request<Paginated<CityRow>>('/admin/cities?pageSize=100')
.then((d) => setRows(d.items))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, []);
useDidShow(load);
return (
<View className="hq-page">
<HqHeader title="开城管理" back />
<Text className="hq-section-title">
<Text></Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {rows.length} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((c) => (
<View key={c.id} className="hq-card" style="margin-top:8px;margin-bottom:0">
<View className="hq-row">
<Text style="font-size:16px;font-weight:700">{c.name}<Text className="hq-muted" style="font-size:12px;font-weight:400"> · {c.code}</Text></Text>
<Text className={`hq-badge ${badgeClass(c.status)}`}>{CITY_STATUS_LABELS[c.status] || c.status}</Text>
</View>
<Text className="hq-muted" style="display:block;margin-top:6px;font-size:13px">
{c.province || ''} · {c.partner?.companyName || '—'}
</Text>
<View className="hq-row" style="margin-top:10px">
<Text className="hq-muted" style="font-size:13px"> <Text style="color:var(--hq-red);font-weight:700">{c.storeCount ?? 0}</Text></Text>
<Text className="hq-muted" style="font-size:13px"> <Text style="color:var(--hq-red);font-weight:700">{c.orderCount ?? 0}</Text></Text>
</View>
</View>
))}
</View>
);
}
+323
View File
@@ -0,0 +1,323 @@
.dash-page {
padding-top: 0;
}
.dash-topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(env(safe-area-inset-top, 0px) + 12px) 16px 12px;
background: var(--hq-bg);
box-shadow: 0 1px 0 rgba(166, 29, 36, 0.06);
}
.dash-topbar__title {
font-size: 20px;
font-weight: 700;
color: var(--hq-red);
}
.dash-topbar__notify {
width: 40px;
height: 40px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
}
.dash-topbar__notify .material-symbols-outlined {
font-size: 24px;
color: var(--hq-red);
}
.dash-section {
margin: 16px 16px 0;
}
.dash-section--last {
padding-bottom: 8px;
}
.dash-section__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.dash-section__title {
font-size: 18px;
font-weight: 600;
color: #1a1a1a;
}
.dash-section__title--solo {
display: block;
margin-bottom: 12px;
}
.dash-section__meta {
font-size: 12px;
color: var(--hq-muted);
}
.dash-section__link {
display: flex;
align-items: center;
font-size: 12px;
color: var(--hq-red);
}
.dash-section__arrow {
font-size: 16px;
margin-left: 2px;
}
.dash-mini-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 12px;
}
.dash-mini-card {
background: #fff;
border-radius: 12px;
border: 1px solid rgba(142, 112, 110, 0.1);
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
padding: 12px 4px;
text-align: center;
}
.dash-mini-card__label {
display: block;
font-size: 10px;
color: var(--hq-muted);
margin-bottom: 4px;
}
.dash-mini-card__value {
display: block;
font-size: 14px;
font-weight: 700;
color: #1a1a1a;
}
.dash-gmv-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.dash-gmv-card {
background: #fff;
border-radius: 12px;
border: 1px solid rgba(142, 112, 110, 0.1);
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
padding: 12px 8px;
text-align: center;
}
.dash-gmv-card__label {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-bottom: 4px;
}
.dash-gmv-card__value {
display: block;
font-size: 16px;
font-weight: 700;
color: #1a1a1a;
}
.dash-gmv-card__value--red {
color: var(--hq-red);
}
.dash-alert {
background: rgba(255, 218, 214, 0.35);
border: 1px solid rgba(186, 26, 26, 0.1);
border-radius: 12px;
padding: 14px;
display: flex;
align-items: center;
justify-content: space-between;
}
.dash-alert--ok {
justify-content: flex-start;
gap: 8px;
background: #f4f3f1;
border-color: rgba(142, 112, 110, 0.12);
}
.dash-alert__main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.dash-alert__icon {
font-size: 22px;
color: #ba1a1a;
flex-shrink: 0;
}
.dash-alert--ok .dash-alert__icon {
color: #2d6a4f;
}
.dash-alert__title {
display: block;
font-size: 14px;
font-weight: 700;
color: #1a1a1a;
}
.dash-alert__desc {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-top: 2px;
}
.dash-alert__dots {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.dash-alert__dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: rgba(186, 26, 26, 0.25);
}
.dash-alert__dot--active {
background: #ba1a1a;
}
.dash-bento {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.dash-bento__item {
position: relative;
background: #fff;
border-radius: 12px;
border: 1px solid rgba(142, 112, 110, 0.1);
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
padding: 16px;
text-align: left;
}
.dash-bento__item--wide {
grid-column: span 2;
}
.dash-bento__badge {
position: absolute;
top: 12px;
right: 12px;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: var(--hq-red);
color: #fff;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.dash-bento__icon {
width: 40px;
height: 40px;
border-radius: 10px;
background: rgba(255, 179, 174, 0.25);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.dash-bento__icon--lg {
width: 48px;
height: 48px;
margin-bottom: 0;
}
.dash-bento__icon .material-symbols-outlined {
font-size: 22px;
color: var(--hq-red);
}
.dash-bento__icon--fill .material-symbols-outlined {
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.dash-bento__row {
display: flex;
align-items: center;
gap: 12px;
}
.dash-bento__text {
flex: 1;
min-width: 0;
}
.dash-bento__title {
display: block;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.dash-bento__desc {
display: block;
font-size: 12px;
color: var(--hq-muted);
margin-top: 4px;
}
.dash-bento__chevron {
color: var(--hq-muted);
font-size: 22px;
}
.dash-status-card {
margin: 0;
}
.dash-order-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--hq-line);
font-size: 14px;
}
.dash-order-row:last-child {
border-bottom: none;
}
.dash-order-count {
font-weight: 700;
color: var(--hq-red);
}
+250
View File
@@ -0,0 +1,250 @@
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import HqTabBar from '../../components/HqTabBar';
import { request } from '../../lib/api';
import { navTo } from '../../lib/session';
import { ORDER_STATUS_LABELS } from '../../lib/constants';
import './index.css';
type Stats = {
usersTotal: number;
guestUsers: number;
verifiedUsers: number;
ordersToday: number;
storesTotal: number;
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
mergedUsers: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
const CORE_MODULES = [
{
key: 'cities',
icon: 'location_city',
title: '开城管理',
desc: '区域拓展与商圈管理',
url: '/pages/cities/index',
wide: false,
},
{
key: 'orders',
icon: 'receipt_long',
title: '订单中心',
desc: '全链路订单监控',
url: '/pages/orders/index',
wide: false,
badge: true,
},
{
key: 'products',
icon: 'liquor',
title: '商品管理',
desc: '杜康系列酒品与餐券库',
url: '/pages/products/index',
wide: true,
filledIcon: true,
},
{
key: 'reports',
icon: 'analytics',
title: '数据报表',
desc: '全链路经营数据看板',
url: '/pages/reports/index',
wide: false,
},
] as const;
function fmtMoney(n: number): string {
if (n >= 10000) return `¥${(n / 1000).toFixed(1)}k`;
return `¥${n.toLocaleString('zh-CN')}`;
}
function countByStatus(rows: Stats['ordersByStatus'], ...keys: string[]): number {
return rows.filter((r) => keys.includes(r.status)).reduce((s, r) => s + r.count, 0);
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [updatedAt, setUpdatedAt] = useState('');
useEffect(() => {
request<Stats>('/admin/dashboard/stats')
.then((data) => {
setStats(data);
const now = new Date();
setUpdatedAt(`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`);
})
.catch(() => undefined);
}, []);
const pendingOrders = useMemo(
() => countByStatus(stats?.ordersByStatus ?? [], 'PENDING_PAY', 'PENDING_SHIP', 'PENDING_RECEIVE'),
[stats],
);
const totalOrders = useMemo(
() => (stats?.ordersByStatus ?? []).reduce((s, r) => s + r.count, 0),
[stats],
);
const alert = useMemo(() => {
const pendingShip = countByStatus(stats?.ordersByStatus ?? [], 'PENDING_SHIP');
if (pendingShip > 0) {
return {
title: `${pendingShip} 笔订单待发货`,
desc: '请尽快处理待发货订单',
};
}
const pendingPay = countByStatus(stats?.ordersByStatus ?? [], 'PENDING_PAY');
if (pendingPay > 0) {
return {
title: `${pendingPay} 笔订单待支付`,
desc: '请关注超时未支付订单',
};
}
return null;
}, [stats]);
const todayGmv = (stats?.ordersToday ?? 0) * 599;
const totalGmv = totalOrders * 599;
const redeemAmount = (stats?.redeemToday ?? 0) * 500;
return (
<View className="hq-page hq-page--tab dash-page">
<View className="dash-topbar">
<Text className="dash-topbar__title"></Text>
<View className="dash-topbar__notify">
<Text className="material-symbols-outlined">notifications</Text>
</View>
</View>
<View className="dash-section">
<View className="dash-section__head">
<Text className="dash-section__title"></Text>
<Text className="dash-section__meta">{updatedAt ? `更新于 ${updatedAt}` : '—'}</Text>
</View>
<View className="dash-mini-grid">
<View className="dash-mini-card">
<Text className="dash-mini-card__label"></Text>
<Text className="dash-mini-card__value">{stats?.ordersToday ?? 0}</Text>
</View>
<View className="dash-mini-card">
<Text className="dash-mini-card__label"></Text>
<Text className="dash-mini-card__value">{stats?.usersTotal ?? 0}</Text>
</View>
<View className="dash-mini-card">
<Text className="dash-mini-card__label"></Text>
<Text className="dash-mini-card__value">{stats?.redeemToday ?? 0}</Text>
</View>
<View className="dash-mini-card">
<Text className="dash-mini-card__label"></Text>
<Text className="dash-mini-card__value">{stats?.storesTotal ?? 0}</Text>
</View>
</View>
<View className="dash-gmv-grid">
<View className="dash-gmv-card">
<Text className="dash-gmv-card__label">GMV</Text>
<Text className="dash-gmv-card__value dash-gmv-card__value--red">{fmtMoney(todayGmv)}</Text>
</View>
<View className="dash-gmv-card">
<Text className="dash-gmv-card__label">GMV</Text>
<Text className="dash-gmv-card__value">{fmtMoney(totalGmv)}</Text>
</View>
<View className="dash-gmv-card">
<Text className="dash-gmv-card__label"></Text>
<Text className="dash-gmv-card__value dash-gmv-card__value--red">{fmtMoney(redeemAmount)}</Text>
</View>
</View>
</View>
<View className="dash-section">
<View className="dash-section__head">
<Text className="dash-section__title"></Text>
<View className="dash-section__link" onClick={() => navTo('/pages/orders/index')}>
<Text></Text>
<Text className="material-symbols-outlined dash-section__arrow">arrow_forward</Text>
</View>
</View>
{alert ? (
<View className="dash-alert" onClick={() => navTo('/pages/orders/index')}>
<View className="dash-alert__main">
<Text className="material-symbols-outlined dash-alert__icon">warning</Text>
<View>
<Text className="dash-alert__title">{alert.title}</Text>
<Text className="dash-alert__desc">{alert.desc}</Text>
</View>
</View>
<View className="dash-alert__dots">
<View className="dash-alert__dot dash-alert__dot--active" />
<View className="dash-alert__dot" />
<View className="dash-alert__dot" />
</View>
</View>
) : (
<View className="dash-alert dash-alert--ok">
<Text className="material-symbols-outlined dash-alert__icon">check_circle</Text>
<Text className="dash-alert__desc"></Text>
</View>
)}
</View>
<View className="dash-section">
<Text className="dash-section__title dash-section__title--solo"></Text>
<View className="dash-bento">
{CORE_MODULES.map((m) => (
<View
key={m.key}
className={`dash-bento__item${m.wide ? ' dash-bento__item--wide' : ''}`}
onClick={() => navTo(m.url)}
>
{m.badge && pendingOrders > 0 ? (
<View className="dash-bento__badge">{pendingOrders > 99 ? '99+' : pendingOrders}</View>
) : null}
{m.wide ? (
<View className="dash-bento__row">
<View className={`dash-bento__icon dash-bento__icon--lg${m.filledIcon ? ' dash-bento__icon--fill' : ''}`}>
<Text className="material-symbols-outlined">{m.icon}</Text>
</View>
<View className="dash-bento__text">
<Text className="dash-bento__title">{m.title}</Text>
<Text className="dash-bento__desc">{m.desc}</Text>
</View>
<Text className="material-symbols-outlined dash-bento__chevron">chevron_right</Text>
</View>
) : (
<>
<View className="dash-bento__icon">
<Text className="material-symbols-outlined">{m.icon}</Text>
</View>
<Text className="dash-bento__title">{m.title}</Text>
<Text className="dash-bento__desc">{m.desc}</Text>
</>
)}
</View>
))}
</View>
</View>
{(stats?.ordersByStatus ?? []).length > 0 ? (
<View className="dash-section dash-section--last">
<Text className="dash-section__title dash-section__title--solo"></Text>
<View className="hq-card dash-status-card">
{stats!.ordersByStatus.map((row) => (
<View key={row.status} className="dash-order-row">
<Text>{ORDER_STATUS_LABELS[row.status] || row.status}</Text>
<Text className="dash-order-count">{row.count}</Text>
</View>
))}
</View>
</View>
) : null}
<HqTabBar selected={0} />
</View>
);
}
+296
View File
@@ -0,0 +1,296 @@
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: calc(64px + var(--hq-safe-top)) 24px calc(24px + var(--hq-safe-bottom));
background: linear-gradient(160deg, #7a0f16 0%, #a61d24 45%, #f5f3f0 45%, #f5f3f0 100%);
box-sizing: border-box;
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
color: #fff;
margin-bottom: 32px;
}
.login-logo {
width: 72px;
height: 72px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.16);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.login-logo .material-symbols-outlined {
font-size: 40px;
color: #fff;
}
.login-title {
font-size: 26px;
font-weight: 800;
letter-spacing: 2px;
}
.login-subtitle {
font-size: 13px;
opacity: 0.85;
margin-top: 4px;
letter-spacing: 4px;
}
.login-card {
width: 100%;
max-width: 400px;
background: #fff;
border-radius: 20px;
padding: 24px 20px;
box-shadow: 0 8px 30px rgba(93, 64, 55, 0.15);
display: flex;
flex-direction: column;
gap: 14px;
box-sizing: border-box;
}
.login-card-title {
font-size: 18px;
font-weight: 700;
text-align: center;
margin-bottom: 6px;
}
.login-input-wrap {
position: relative;
display: flex;
align-items: center;
width: 100%;
height: 48px;
border-radius: 12px;
background: var(--hq-bg, #faf9f7);
box-sizing: border-box;
overflow: hidden;
}
.login-input-icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--hq-muted);
font-size: 20px;
line-height: 1;
pointer-events: none;
z-index: 1;
}
.login-input {
flex: 1;
width: 100%;
height: 48px;
min-height: 48px;
padding: 0 12px 0 40px;
font-size: 15px;
line-height: 48px;
box-sizing: border-box;
display: flex;
align-items: center;
}
/* Taro H5 输入框内部垂直居中 */
.login-input-wrap .taro-input,
.login-input-wrap taro-input-core {
flex: 1;
width: 100%;
height: 100%;
min-height: 48px;
display: flex;
align-items: center;
background: transparent;
}
.login-input-wrap input,
.login-input-wrap .weui-input {
width: 100%;
height: 48px;
min-height: 48px;
line-height: 48px;
padding: 0;
margin: 0;
border: none;
background: transparent;
font-size: 15px;
box-sizing: border-box;
}
.login-input-row {
display: flex;
align-items: stretch;
gap: 8px;
width: 100%;
}
.login-input-row .login-input-wrap {
flex: 1;
min-width: 0;
}
.login-code-btn {
flex-shrink: 0;
min-width: 96px;
height: 48px;
padding: 0 12px;
border-radius: 12px;
border: 1px solid rgba(226, 190, 188, 0.2);
background: var(--hq-surface-low, #f4f3f1);
color: var(--hq-red);
font-size: 13px;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.login-code-btn.is-disabled {
color: var(--hq-muted);
}
.login-submit {
margin-top: 0;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
}
.login-remember,
.login-agreement {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
color: var(--hq-muted);
}
.login-remember {
align-items: center;
padding: 0 4px;
}
.login-checkbox {
width: 16px;
height: 16px;
border: 1px solid var(--hq-line);
border-radius: 4px;
flex-shrink: 0;
box-sizing: border-box;
}
.login-checkbox.is-checked {
background: var(--hq-red);
border-color: var(--hq-red);
position: relative;
}
.login-checkbox.is-checked::after {
content: '';
position: absolute;
left: 4px;
top: 1px;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.login-msg {
font-size: 13px;
color: #d33;
text-align: center;
line-height: 1.4;
}
.login-agreement {
padding: 0 4px;
}
.login-agreement-text {
line-height: 1.5;
}
.login-agreement-link {
color: var(--hq-red);
font-weight: 600;
}
.login-divider {
display: flex;
align-items: center;
text-align: center;
color: var(--hq-muted);
font-size: 12px;
margin: 6px 0 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--hq-line);
margin: 0 12px;
}
.login-wechat-btn {
width: 100%;
height: 48px;
border-radius: 12px;
border: 1px solid rgba(226, 190, 188, 0.2);
background: var(--hq-surface-low, #f4f3f1);
color: var(--hq-ink, #1a1c1b);
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
}
.login-wechat-btn.is-disabled {
opacity: 0.6;
pointer-events: none;
}
.login-wechat-svg {
width: 24px;
height: 24px;
flex-shrink: 0;
}
.login-wechat-fallback {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
color: #07c160;
font-weight: 700;
}
.login-footer {
margin-top: auto;
padding-top: 32px;
display: flex;
align-items: center;
gap: 6px;
color: var(--hq-muted);
font-size: 12px;
}
+245
View File
@@ -0,0 +1,245 @@
import { useEffect, useState } from 'react';
import { View, Text, Input, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { request, saveToken, toast } from '../../lib/api';
import {
bindHqWechatAfterSmsLogin,
handleHqWechatCallback,
handleHqWechatLoginResult,
loginHqWithWechat,
} from '../../lib/wechat';
import { isWechatEnv } from '../../lib/weixin';
import { clearHqAccountCache } from '../../lib/session';
import './index.css';
const DEMO_PHONE = '13600000001';
const REMEMBER_PHONE_KEY = 'hq_remember_phone';
const REMEMBER_FLAG_KEY = 'hq_remember_account';
function loadRememberedPhone(): { phone: string; remember: boolean } {
try {
const remember = Taro.getStorageSync(REMEMBER_FLAG_KEY) === '1';
const phone = remember ? Taro.getStorageSync(REMEMBER_PHONE_KEY) || '' : '';
return { phone, remember };
} catch {
return { phone: '', remember: false };
}
}
function WechatIcon() {
if (process.env.TARO_ENV === 'h5') {
return (
<svg className="login-wechat-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>
);
}
return <Text className="login-wechat-fallback"></Text>;
}
export default function LoginPage() {
const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || DEMO_PHONE);
const [code, setCode] = useState('123456');
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(true);
const [cooldown, setCooldown] = useState(0);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
if (!isWechatEnv()) return;
void handleHqWechatCallback()
.then((result) => {
if (!result) return;
if (handleHqWechatLoginResult(result)) enterApp();
})
.catch((e) => setMsg(formatWechatError(e)));
}, []);
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定管理员账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
function ensureAgreed(): boolean {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
return false;
}
return true;
}
function persistRememberAccount(nextPhone: string) {
try {
if (rememberAccount) {
Taro.setStorageSync(REMEMBER_FLAG_KEY, '1');
Taro.setStorageSync(REMEMBER_PHONE_KEY, nextPhone);
} else {
Taro.removeStorageSync(REMEMBER_FLAG_KEY);
Taro.removeStorageSync(REMEMBER_PHONE_KEY);
}
} catch {
/* ignore */
}
}
function enterApp() {
clearHqAccountCache();
Taro.reLaunch({ url: '/pages/dashboard/index' });
}
async function sendCode() {
if (!ensureAgreed()) return;
if (cooldown > 0) return;
setMsg('');
try {
await request('/admin/auth/sms/send', {
method: 'POST',
data: { phone, scene: 'HQ_LOGIN' },
});
toast('验证码已发送(Mock:123456', 'success');
setCooldown(60);
const t = setInterval(() => {
setCooldown((s) => {
if (s <= 1) {
clearInterval(t);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function smsLogin() {
if (!ensureAgreed()) return;
setLoading(true);
setMsg('');
try {
const data = await request<{ accessToken: string }>('/admin/auth/login/sms', {
method: 'POST',
data: { phone, code },
});
saveToken(data.accessToken);
persistRememberAccount(phone);
if (isWechatEnv() || process.env.TARO_ENV === 'weapp') {
setMsg('登录成功,正在关联微信…');
await bindHqWechatAfterSmsLogin();
if (process.env.TARO_ENV === 'weapp') {
enterApp();
}
return;
}
enterApp();
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
setWxLoading(true);
try {
const ok = await loginHqWithWechat();
if (ok) enterApp();
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
return (
<View className="login-page">
<View className="login-brand">
<View className="login-logo">
<Text className="material-symbols-outlined">local_bar</Text>
</View>
<Text className="login-title"></Text>
<Text className="login-subtitle"></Text>
</View>
<View className="login-card">
<Text className="login-card-title"></Text>
<View className="login-input-wrap">
<Text className="material-symbols-outlined login-input-icon">smartphone</Text>
<Input
className="login-input"
type="number"
placeholder="请输入手机号"
value={phone}
onInput={(e) => setPhone(e.detail.value)}
/>
</View>
<View className="login-input-row">
<View className="login-input-wrap">
<Text className="material-symbols-outlined login-input-icon">shield</Text>
<Input
className="login-input"
type="number"
placeholder="验证码"
value={code}
onInput={(e) => setCode(e.detail.value)}
/>
</View>
<View
className={`login-code-btn${cooldown > 0 ? ' is-disabled' : ''}`}
onClick={sendCode}
>
<Text>{cooldown > 0 ? `${cooldown}s 后重发` : '获取验证码'}</Text>
</View>
</View>
<View className="login-remember" onClick={() => setRememberAccount((v) => !v)}>
<View className={`login-checkbox${rememberAccount ? ' is-checked' : ''}`} />
<Text></Text>
</View>
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
</Button>
{msg ? <Text className="login-msg">{msg}</Text> : null}
<View className="login-divider">
<Text></Text>
</View>
<View
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键登录'}</Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text">
<Text className="login-agreement-link"></Text>
<Text className="login-agreement-link"></Text>
</Text>
</View>
</View>
<View className="login-footer">
<Text className="material-symbols-outlined" style="font-size:16px">verified_user</Text>
<Text> · </Text>
</View>
</View>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { useEffect, useState } from 'react';
import { View, Text, Button } from '@tarojs/components';
import { useRouter } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, toast } from '../../lib/api';
import { ORDER_STATUS_LABELS, badgeClass, fmtMoney, fmtTime } from '../../lib/constants';
type StatusLog = { fromStatus?: string; toStatus?: string; createdAt: string };
type OrderDetail = {
id: string;
orderNo: string;
status: string;
payAmount: number | string;
totalAmount?: number | string;
quantity?: number;
receiverName?: string;
receiverPhone?: string;
receiverAddress?: string;
createdAt: string;
product?: { name: string; skuCode: string };
city?: { name: string };
delivery?: { provider?: string; trackingNo?: string } | null;
statusLogs?: StatusLog[];
};
const NEXT: Record<string, Array<{ status: string; label: string }>> = {
PENDING_SHIP: [{ status: 'OUT_WAREHOUSE', label: '标记出库' }],
OUT_WAREHOUSE: [{ status: 'SHIPPING', label: '标记配送中' }],
SHIPPING: [{ status: 'PENDING_RECEIVE', label: '标记待收货' }],
PENDING_RECEIVE: [{ status: 'COMPLETED', label: '标记已完成' }],
};
export default function OrderDetailPage() {
const router = useRouter();
const id = router.params.id;
const [order, setOrder] = useState<OrderDetail | null>(null);
const [saving, setSaving] = useState(false);
function load() {
if (!id) return;
request<OrderDetail>(`/admin/orders/${id}`).then(setOrder).catch(() => undefined);
}
useEffect(load, [id]);
async function transition(status: string) {
if (!id || saving) return;
setSaving(true);
try {
const d = await request<OrderDetail>(`/admin/orders/${id}/status`, { method: 'PUT', data: { status } });
setOrder(d);
toast('订单状态已更新', 'success');
} catch (e) {
toast(e instanceof Error ? e.message : '操作失败');
} finally {
setSaving(false);
}
}
const actions = order ? NEXT[order.status] ?? [] : [];
return (
<View className="hq-page" style={actions.length ? 'padding-bottom:calc(96px + var(--hq-safe-bottom))' : ''}>
<HqHeader title="订单详情" back />
{!order && <View className="hq-empty"></View>}
{order && (
<>
<View className="hq-card">
<View className="hq-row">
<Text style="font-size:13px;color:var(--hq-muted)">{order.orderNo}</Text>
<Text className={`hq-badge ${badgeClass(order.status)}`}>
{ORDER_STATUS_LABELS[order.status] || order.status}
</Text>
</View>
<Text style="display:block;margin-top:12px;font-size:15px;font-weight:600">{order.product?.name || '—'}</Text>
<Text className="hq-muted" style="font-size:12px">SKU {order.product?.skuCode || '—'} · {order.quantity ?? 1}</Text>
<View className="hq-row" style="margin-top:12px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:18px;font-weight:700;color:var(--hq-red)">¥{fmtMoney(order.payAmount)}</Text>
</View>
</View>
<View className="hq-card">
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{order.receiverName || '—'} {order.receiverPhone || ''}</Text>
</View>
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px;text-align:right;max-width:60%">{order.receiverAddress || '—'}</Text>
</View>
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{order.city?.name || '—'}</Text>
</View>
<View className="hq-row">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{order.delivery?.provider || '—'} {order.delivery?.trackingNo || ''}</Text>
</View>
</View>
<Text className="hq-section-title"></Text>
<View className="hq-card">
{(order.statusLogs ?? []).length === 0 && <Text className="hq-muted"></Text>}
{(order.statusLogs ?? []).map((log, i) => (
<View key={i} className="hq-row" style="padding:8px 0;border-bottom:1px solid var(--hq-line)">
<Text style="font-size:13px">
{ORDER_STATUS_LABELS[log.toStatus || ''] || log.toStatus}
</Text>
<Text className="hq-muted" style="font-size:12px">{fmtTime(log.createdAt)}</Text>
</View>
))}
</View>
{actions.length > 0 && (
<View className="hq-footer-bar">
{actions.map((a) => (
<Button
key={a.status}
className="hq-btn hq-btn--primary hq-btn--block"
disabled={saving}
onClick={() => transition(a.status)}
>
{a.label}
</Button>
))}
</View>
)}
</>
)}
</View>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, type Paginated } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { ORDER_STATUS_LABELS, badgeClass, fmtMoney, fmtTime } from '../../lib/constants';
type OrderRow = {
id: string;
orderNo: string;
status: string;
deliveryType?: string;
payAmount: number | string;
receiverName?: string;
receiverPhone?: string;
createdAt: string;
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'PENDING_PAY', label: '待付款' },
{ key: 'PENDING_SHIP', label: '待发货' },
{ key: 'SHIPPING', label: '配送中' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'REFUNDING', label: '退款中' },
];
export default function OrdersPage() {
useHqSession();
const [status, setStatus] = useState('');
const [rows, setRows] = useState<OrderRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
const qs = new URLSearchParams({ pageSize: '50' });
if (status) qs.set('status', status);
request<Paginated<OrderRow>>(`/admin/orders?${qs.toString()}`)
.then((d) => {
setRows(d.items);
setTotal(d.total);
})
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, [status]);
useDidShow(load);
return (
<View className="hq-page">
<HqHeader title="订单中心" back />
<ScrollView scrollX enhanced showScrollbar={false} className="hq-tabs">
{TABS.map((t) => (
<View
key={t.key}
className={`hq-tab${status === t.key ? ' hq-tab--active' : ''}`}
onClick={() => setStatus(t.key)}
>
<Text>{t.label}</Text>
</View>
))}
</ScrollView>
<Text className="hq-section-title">
<Text></Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {total} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((o) => (
<View
key={o.id}
className="hq-card"
style="margin-top:8px;margin-bottom:0"
onClick={() => Taro.navigateTo({ url: `/pages/orders/detail?id=${o.id}` })}
>
<View className="hq-row">
<Text style="font-size:13px;color:var(--hq-muted)">{o.orderNo}</Text>
<Text className={`hq-badge ${badgeClass(o.status)}`}>{ORDER_STATUS_LABELS[o.status] || o.status}</Text>
</View>
<View className="hq-row" style="margin-top:10px">
<Text style="font-size:14px">{o.receiverName || '—'} · {o.receiverPhone || ''}</Text>
<Text style="font-size:16px;font-weight:700;color:var(--hq-red)">¥{fmtMoney(o.payAmount)}</Text>
</View>
<Text className="hq-muted" style="font-size:11px;display:block;margin-top:6px">{fmtTime(o.createdAt)}</Text>
</View>
))}
</View>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, type Paginated } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { PRODUCT_STATUS_LABELS, badgeClass, fmtMoney } from '../../lib/constants';
type ProductRow = {
id: string;
skuCode: string;
name: string;
spec?: string;
price: number | string;
benefitAmount?: number | string;
status: string;
sortOrder?: number;
};
export default function ProductsPage() {
useHqSession();
const [rows, setRows] = useState<ProductRow[]>([]);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
request<Paginated<ProductRow>>('/admin/products?pageSize=100')
.then((d) => setRows(d.items))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, []);
useDidShow(load);
return (
<View className="hq-page">
<HqHeader title="商品管理" back />
<Text className="hq-section-title">
<Text></Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {rows.length} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((p) => (
<View key={p.id} className="hq-card" style="margin-top:8px;margin-bottom:0">
<View className="hq-row">
<Text style="font-size:15px;font-weight:700;max-width:70%">{p.name}</Text>
<Text className={`hq-badge ${badgeClass(p.status)}`}>{PRODUCT_STATUS_LABELS[p.status] || p.status}</Text>
</View>
<Text className="hq-muted" style="display:block;margin-top:4px;font-size:12px">SKU {p.skuCode} · {p.spec || ''}</Text>
<View className="hq-row" style="margin-top:10px">
<Text style="font-size:16px;font-weight:700;color:var(--hq-red)">¥{fmtMoney(p.price)}</Text>
<Text className="hq-muted" style="font-size:13px"> ¥{fmtMoney(p.benefitAmount ?? p.price)}</Text>
</View>
</View>
))}
</View>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { View, Text, Button } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, type Paginated, toast } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { ORDER_STATUS_LABELS, badgeClass, fmtMoney, fmtTime } from '../../lib/constants';
type OrderRow = {
id: string;
orderNo: string;
status: string;
payAmount: number | string;
receiverName?: string;
receiverPhone?: string;
createdAt: string;
};
export default function RefundPage() {
useHqSession();
const [rows, setRows] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState('');
function load() {
setLoading(true);
request<Paginated<OrderRow>>('/admin/orders?status=REFUNDING&pageSize=50')
.then((d) => setRows(d.items))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, []);
useDidShow(load);
async function confirmRefund(id: string) {
setBusy(id);
try {
await request(`/admin/orders/${id}/status`, { method: 'PUT', data: { status: 'REFUNDED' } });
toast('退款已确认', 'success');
load();
} catch (e) {
toast(e instanceof Error ? e.message : '操作失败');
} finally {
setBusy('');
}
}
return (
<View className="hq-page">
<HqHeader title="补发 / 退款" back />
<Text className="hq-section-title">
<Text>退</Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {rows.length} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty">退</View>}
{rows.map((o) => (
<View key={o.id} className="hq-card" style="margin-top:8px;margin-bottom:0">
<View className="hq-row">
<Text style="font-size:13px;color:var(--hq-muted)">{o.orderNo}</Text>
<Text className={`hq-badge ${badgeClass(o.status)}`}>{ORDER_STATUS_LABELS[o.status] || o.status}</Text>
</View>
<View className="hq-row" style="margin-top:10px">
<Text style="font-size:14px">{o.receiverName || '—'} · {o.receiverPhone || ''}</Text>
<Text style="font-size:16px;font-weight:700;color:var(--hq-red)">¥{fmtMoney(o.payAmount)}</Text>
</View>
<View className="hq-row" style="margin-top:10px">
<Text className="hq-muted" style="font-size:11px">{fmtTime(o.createdAt)}</Text>
<Button
className="hq-btn hq-btn--primary"
style="padding:6px 14px;font-size:13px"
disabled={busy === o.id}
onClick={() => confirmRefund(o.id)}
>
退
</Button>
</View>
</View>
))}
</View>
);
}
+35
View File
@@ -0,0 +1,35 @@
.report-bar-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
}
.report-bar-label {
width: 64px;
font-size: 12px;
color: var(--hq-muted);
flex-shrink: 0;
}
.report-bar-track {
flex: 1;
height: 10px;
border-radius: 999px;
background: var(--hq-line);
overflow: hidden;
}
.report-bar-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--hq-amber), var(--hq-red));
}
.report-bar-count {
width: 36px;
text-align: right;
font-size: 13px;
font-weight: 700;
color: var(--hq-red);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components';
import HqHeader from '../../components/HqHeader';
import { request } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { ORDER_STATUS_LABELS } from '../../lib/constants';
import './index.css';
type Stats = {
usersTotal: number;
guestUsers: number;
verifiedUsers: number;
ordersToday: number;
storesTotal: number;
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
mergedUsers: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
export default function ReportsPage() {
useHqSession();
const [stats, setStats] = useState<Stats | null>(null);
useEffect(() => {
request<Stats>('/admin/dashboard/stats').then(setStats).catch(() => undefined);
}, []);
const dist = stats?.ordersByStatus ?? [];
const max = Math.max(1, ...dist.map((d) => d.count));
return (
<View className="hq-page">
<HqHeader title="数据报表" back />
<Text className="hq-section-title"></Text>
<View className="hq-stat-grid">
<View className="hq-stat">
<Text className="hq-stat__label"></Text>
<Text className="hq-stat__value">{stats?.usersTotal ?? 0}</Text>
<Text className="hq-stat__sub"> {stats?.verifiedUsers ?? 0}</Text>
</View>
<View className="hq-stat">
<Text className="hq-stat__label"></Text>
<Text className="hq-stat__value">{stats?.ordersToday ?? 0}</Text>
<Text className="hq-stat__sub"> {stats?.redeemToday ?? 0}</Text>
</View>
<View className="hq-stat">
<Text className="hq-stat__label"> / </Text>
<Text className="hq-stat__value">{stats?.storesTotal ?? 0}/{stats?.partnersTotal ?? 0}</Text>
<Text className="hq-stat__sub"> {stats?.deliveriesTotal ?? 0}</Text>
</View>
<View className="hq-stat">
<Text className="hq-stat__label">访</Text>
<Text className="hq-stat__value">{stats?.guestUsers ?? 0}</Text>
<Text className="hq-stat__sub"> {stats?.mergedUsers ?? 0}</Text>
</View>
</View>
<Text className="hq-section-title"></Text>
<View className="hq-card">
{dist.length === 0 && <Text className="hq-muted"></Text>}
{dist.map((d) => (
<View key={d.status} className="report-bar-row">
<Text className="report-bar-label">{ORDER_STATUS_LABELS[d.status] || d.status}</Text>
<View className="report-bar-track">
<View className="report-bar-fill" style={`width:${(d.count / max) * 100}%`} />
</View>
<Text className="report-bar-count">{d.count}</Text>
</View>
))}
</View>
</View>
);
}
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import { View, Text, Button } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import HqTabBar from '../../components/HqTabBar';
import { request, type Paginated, toast } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { fmtMoney, fmtTime } from '../../lib/constants';
type RedeemRow = {
id: string;
redeemNo: string;
amount: number | string;
createdAt: string;
store?: { name: string; cityName?: string };
user?: { nickname?: string; phone?: string };
payout?: unknown;
};
// 门店核销到账比例(V2 手册:门店核销结算 60%)
const STORE_PAYOUT_RATE = 0.6;
export default function SettlementPage() {
useHqSession();
const [rows, setRows] = useState<RedeemRow[]>([]);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
request<Paginated<RedeemRow>>('/admin/redeem-records?pageSize=50')
.then((d) => setRows(d.items))
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, []);
useDidShow(load);
const totalRedeem = rows.reduce((s, r) => s + Number(r.amount || 0), 0);
const totalPayout = totalRedeem * STORE_PAYOUT_RATE;
const pendingCount = rows.filter((r) => !r.payout).length;
return (
<View className="hq-page hq-page--tab">
<HqHeader title="结算中心" />
<View className="hq-stat-grid">
<View className="hq-stat">
<Text className="hq-stat__label"></Text>
<Text className="hq-stat__value">¥{fmtMoney(totalRedeem)}</Text>
<Text className="hq-stat__sub"> {rows.length} </Text>
</View>
<View className="hq-stat">
<Text className="hq-stat__label">(60%)</Text>
<Text className="hq-stat__value">¥{fmtMoney(totalPayout)}</Text>
<Text className="hq-stat__sub"> {pendingCount} </Text>
</View>
</View>
<View style="margin:12px 16px">
<Button
className="hq-btn hq-btn--primary hq-btn--block"
onClick={() => toast('preV1 阶段批量打款为演示,暂不实际出款')}
>
</Button>
</View>
<Text className="hq-section-title"></Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((r) => (
<View key={r.id} className="hq-card" style="margin-top:8px;margin-bottom:0">
<View className="hq-row">
<Text style="font-size:14px;font-weight:600">{r.store?.name || '门店'}</Text>
<Text className={`hq-badge ${r.payout ? 'hq-badge--ok' : 'hq-badge--warn'}`}>
{r.payout ? '已结算' : '待结算'}
</Text>
</View>
<Text className="hq-muted" style="display:block;margin-top:4px;font-size:12px">
{r.store?.cityName || ''} · {r.redeemNo}
</Text>
<View className="hq-row" style="margin-top:8px">
<Text className="hq-muted" style="font-size:12px">{fmtTime(r.createdAt)}</Text>
<Text style="font-size:15px;font-weight:700;color:var(--hq-red)">
¥{fmtMoney(r.amount)} · ¥{fmtMoney(Number(r.amount || 0) * STORE_PAYOUT_RATE)}
</Text>
</View>
</View>
))}
<HqTabBar selected={2} />
</View>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useState } from 'react';
import { View, Text, Image, Button } from '@tarojs/components';
import Taro, { useRouter } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import { request, toast } from '../../lib/api';
import { STORE_STATUS_LABELS, badgeClass, fmtTime } from '../../lib/constants';
type StoreDetail = {
id: string;
name: string;
phone: string;
status: string;
province?: string;
cityName?: string;
district?: string;
address?: string;
intro?: string | null;
coverUrl?: string | null;
redeemCount?: number;
createdAt: string;
partner?: { companyName: string };
account?: { name: string; phone: string };
};
const ACTIONS: Array<{ status: string; label: string }> = [
{ status: 'OPEN', label: '通过 / 营业' },
{ status: 'PAUSED', label: '暂停营业' },
{ status: 'CLOSED', label: '关闭门店' },
];
export default function StoreDetailPage() {
const router = useRouter();
const id = router.params.id;
const [store, setStore] = useState<StoreDetail | null>(null);
const [saving, setSaving] = useState(false);
function load() {
if (!id) return;
request<StoreDetail>(`/admin/stores/${id}`).then(setStore).catch(() => undefined);
}
useEffect(load, [id]);
async function changeStatus(status: string) {
if (!id || saving) return;
setSaving(true);
try {
await request(`/admin/stores/${id}/status`, { method: 'PUT', data: { status } });
toast('状态已更新', 'success');
setStore((s) => (s ? { ...s, status } : s));
} catch (e) {
toast(e instanceof Error ? e.message : '更新失败');
} finally {
setSaving(false);
}
}
return (
<View className="hq-page" style="padding-bottom:calc(96px + var(--hq-safe-bottom))">
<HqHeader title="门店详情" back />
{!store && <View className="hq-empty"></View>}
{store && (
<>
{store.coverUrl ? (
<Image className="store-cover" src={store.coverUrl} mode="aspectFill" />
) : null}
<View className="hq-card">
<View className="hq-row">
<Text style="font-size:18px;font-weight:700">{store.name}</Text>
<Text className={`hq-badge ${badgeClass(store.status)}`}>
{STORE_STATUS_LABELS[store.status] || store.status}
</Text>
</View>
<Text className="hq-muted" style="display:block;margin-top:8px;font-size:13px">
{store.province || ''}{store.cityName || ''}{store.district || ''}{store.address || ''}
</Text>
<Text className="hq-muted" style="display:block;margin-top:4px;font-size:13px">{store.phone}</Text>
{store.intro ? (
<Text style="display:block;margin-top:8px;font-size:13px;line-height:1.6">{store.intro}</Text>
) : null}
</View>
<View className="hq-card">
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{store.partner?.companyName || '—'}</Text>
</View>
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{store.account?.name || '—'} {store.account?.phone || ''}</Text>
</View>
<View className="hq-row" style="margin-bottom:8px">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{store.redeemCount ?? 0} </Text>
</View>
<View className="hq-row">
<Text className="hq-muted" style="font-size:13px"></Text>
<Text style="font-size:14px">{fmtTime(store.createdAt)}</Text>
</View>
</View>
<Text className="hq-section-title"></Text>
<View className="hq-card" style="display:flex;flex-direction:column;gap:10px">
{ACTIONS.map((a) => (
<Button
key={a.status}
className={`hq-btn hq-btn--block ${a.status === 'OPEN' ? 'hq-btn--primary' : 'hq-btn--outline'}`}
disabled={saving || store.status === a.status}
onClick={() => changeStatus(a.status)}
>
{a.label}
</Button>
))}
</View>
</>
)}
</View>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from 'react';
import { View, Text, ScrollView } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import HqTabBar from '../../components/HqTabBar';
import { request, type Paginated } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { STORE_STATUS_LABELS, badgeClass, fmtTime } from '../../lib/constants';
type StoreRow = {
id: string;
name: string;
phone: string;
status: string;
cityName?: string;
createdAt: string;
partner?: { companyName: string };
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'OPEN', label: '营业中' },
{ key: 'PAUSED', label: '暂停' },
{ key: 'CLOSED', label: '已关闭' },
];
export default function StoresPage() {
useHqSession();
const [status, setStatus] = useState('');
const [rows, setRows] = useState<StoreRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
const qs = new URLSearchParams({ pageSize: '50' });
if (status) qs.set('status', status);
request<Paginated<StoreRow>>(`/admin/stores?${qs.toString()}`)
.then((d) => {
setRows(d.items);
setTotal(d.total);
})
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, [status]);
useDidShow(load);
return (
<View className="hq-page hq-page--tab">
<HqHeader title="门店审核" />
<ScrollView scrollX enhanced showScrollbar={false} className="hq-tabs">
{TABS.map((t) => (
<View
key={t.key}
className={`hq-tab${status === t.key ? ' hq-tab--active' : ''}`}
onClick={() => setStatus(t.key)}
>
<Text>{t.label}</Text>
</View>
))}
</ScrollView>
<Text className="hq-section-title">
<Text></Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {total} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((s) => (
<View
key={s.id}
className="hq-list-item"
onClick={() => Taro.navigateTo({ url: `/pages/stores/detail?id=${s.id}` })}
>
<View className="hq-avatar">
<Text className="material-symbols-outlined">storefront</Text>
</View>
<View style="flex:1;min-width:0">
<View className="hq-row">
<Text style="font-weight:600;font-size:15px">{s.name}</Text>
<Text className={`hq-badge ${badgeClass(s.status)}`}>{STORE_STATUS_LABELS[s.status] || s.status}</Text>
</View>
<Text className="hq-muted" style="font-size:12px;display:block;margin-top:4px">
{s.partner?.companyName || '—'} · {s.cityName || ''} · {s.phone}
</Text>
<Text className="hq-muted" style="font-size:11px">{fmtTime(s.createdAt)}</Text>
</View>
<Text className="material-symbols-outlined hq-muted">chevron_right</Text>
</View>
))}
<HqTabBar selected={1} />
</View>
);
}
+115
View File
@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react';
import { View, Text, ScrollView, Button } from '@tarojs/components';
import { useDidShow } from '@tarojs/taro';
import HqHeader from '../../components/HqHeader';
import HqTabBar from '../../components/HqTabBar';
import { request, type Paginated, toast } from '../../lib/api';
import { useHqSession } from '../../lib/session';
import { badgeClass, fmtTime } from '../../lib/constants';
type TicketRow = {
id: string;
ticketNo: string;
ticketType?: string;
refType?: string;
status: string;
remark?: string;
createdAt: string;
};
const STATUS_LABELS: Record<string, string> = {
PENDING: '待处理',
PROCESSING: '处理中',
RESOLVED: '已解决',
COMPLETED: '已完成',
CLOSED: '已关闭',
};
const TABS = [
{ key: '', label: '全部' },
{ key: 'PENDING', label: '待处理' },
{ key: 'PROCESSING', label: '处理中' },
{ key: 'COMPLETED', label: '已完成' },
];
export default function TicketsPage() {
useHqSession();
const [status, setStatus] = useState('');
const [rows, setRows] = useState<TicketRow[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
function load() {
setLoading(true);
const qs = new URLSearchParams({ pageSize: '50' });
if (status) qs.set('status', status);
request<Paginated<TicketRow>>(`/common/tickets?${qs.toString()}`)
.then((d) => {
setRows(d.items);
setTotal(d.total);
})
.catch(() => setRows([]))
.finally(() => setLoading(false));
}
useEffect(load, [status]);
useDidShow(load);
async function markProcessing(id: string) {
try {
await request(`/common/tickets/${id}/status`, { method: 'PUT', data: { status: 'PROCESSING' } });
toast('已受理', 'success');
load();
} catch (e) {
toast(e instanceof Error ? e.message : '操作失败');
}
}
return (
<View className="hq-page hq-page--tab">
<HqHeader title="客服中心" />
<ScrollView scrollX enhanced showScrollbar={false} className="hq-tabs">
{TABS.map((t) => (
<View
key={t.key}
className={`hq-tab${status === t.key ? ' hq-tab--active' : ''}`}
onClick={() => setStatus(t.key)}
>
<Text>{t.label}</Text>
</View>
))}
</ScrollView>
<Text className="hq-section-title">
<Text></Text>
<Text className="hq-muted" style="font-size:12px;font-weight:400"> {total} </Text>
</Text>
{loading && <View className="hq-empty"></View>}
{!loading && rows.length === 0 && <View className="hq-empty"></View>}
{rows.map((t) => (
<View key={t.id} className="hq-card" style="margin-top:8px;margin-bottom:0">
<View className="hq-row">
<Text style="font-size:13px;color:var(--hq-muted)">{t.ticketNo}</Text>
<Text className={`hq-badge ${badgeClass(t.status)}`}>{STATUS_LABELS[t.status] || t.status}</Text>
</View>
<Text style="display:block;margin-top:8px;font-size:14px">
{t.ticketType || '工单'} · {t.refType || ''}
</Text>
{t.remark ? <Text className="hq-muted" style="display:block;margin-top:4px;font-size:13px">{t.remark}</Text> : null}
<View className="hq-row" style="margin-top:10px">
<Text className="hq-muted" style="font-size:11px">{fmtTime(t.createdAt)}</Text>
{t.status === 'PENDING' && (
<Button className="hq-btn hq-btn--ghost" style="padding:6px 14px;font-size:13px" onClick={() => markProcessing(t.id)}>
</Button>
)}
</View>
</View>
))}
<HqTabBar selected={3} />
</View>
);
}
+27
View File
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"module": "ESNext",
"moduleResolution": "node",
"removeComments": false,
"preserveConstEnums": true,
"moduleDetection": "force",
"useDefineForClassFields": true,
"outDir": "lib",
"sourceMap": true,
"baseUrl": ".",
"rootDir": ".",
"jsx": "react-jsx",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true,
"strict": true,
"resolveJsonModule": true,
"typeRoots": ["node_modules/@types"],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["./src", "./types", "./config"],
"compileOnSave": false
}
+21
View File
@@ -0,0 +1,21 @@
/// <reference types="@tarojs/taro" />
declare module '*.png';
declare module '*.gif';
declare module '*.jpg';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.css';
declare module '*.less';
declare module '*.scss';
declare const defineAppConfig: (config: Record<string, unknown>) => Record<string, unknown>;
declare const TARO_APP_API_ORIGIN: string;
declare namespace NodeJS {
interface ProcessEnv {
TARO_ENV: 'weapp' | 'h5' | string;
VITE_API_TARGET?: string;
}
}
+2
View File
@@ -8,6 +8,8 @@
"dev:shop": "pnpm --filter @dukang/h5-shop dev",
"dev:partner": "pnpm --filter @dukang/h5-partner dev",
"dev:admin": "pnpm --filter @dukang/admin-web dev",
"dev:hq": "pnpm --filter @dukang/mini-hq dev",
"preview:hq": "pnpm --filter @dukang/mini-hq build && pnpm --filter @dukang/mini-hq preview",
"build": "pnpm -r build",
"lint": "pnpm -r lint",
"test": "pnpm -r test",
+3
View File
@@ -4,6 +4,8 @@ export interface AppConfig {
mockPay: boolean;
mockDeliveryAuto: boolean;
autoApproveStore: boolean;
/** preV1 Mock 微信授权登录:点击授权按钮走 Mock 流程直接登录,不接真实微信 */
mockWechat: boolean;
wechatAuthEnabled: boolean;
wechatPayEnabled: boolean;
wxAppId: string;
@@ -23,6 +25,7 @@ export function loadAppConfig(env?: Record<string, string | undefined>): AppConf
mockPay: e.MOCK_PAY !== 'false',
mockDeliveryAuto: e.MOCK_DELIVERY_AUTO !== 'false',
autoApproveStore: e.AUTO_APPROVE_STORE !== 'false',
mockWechat: e.MOCK_WECHAT === 'true',
wechatAuthEnabled: e.WECHAT_AUTH_ENABLED === 'true',
wechatPayEnabled: e.WECHAT_PAY_ENABLED === 'true' || e.MOCK_PAY === 'false',
wxAppId: e.WX_APP_ID ?? '',
+1
View File
@@ -2,3 +2,4 @@ export * from './enums';
export * from './api';
export * from './config';
export * from './wechat';
export * from './promo';
+24
View File
@@ -0,0 +1,24 @@
export type PromoCodeStatus = 'ACTIVE' | 'DISABLED';
export type PromoCodeItem = {
id: string;
code: string;
name: string;
status: PromoCodeStatus;
scanCount: number;
orderCount: number;
landingUrl: string;
createdAt: string;
};
export type PromoCodeStats = {
scanCount: number;
orderCount: number;
conversionRate: number;
};
export type PromoTouchResult = {
promoCode: string;
channelName: string;
attributed: boolean;
};
+26 -2
View File
@@ -59,6 +59,30 @@
| P1 | 合伙人中心 | c31c874b | `/center` | partner/04_* | ui-aligned |
| P1 | 合伙人待确认账单页 | f9b208b8 | `/center/bills` | partner/14_* | ui-aligned |
## 跳过(preV1 / HQ
## 总部管理端 mini-hqTaro,先 H5 后小程序
总部端、推广码、拦截配送、开城管理等 40 个 screen 已归档至 `pages/_archive/`
`apps/mini-hq` 使用 Taro(React+TS) 编译 H5`taro build --type h5`),后续可 `--type weapp` 打包小程序
`X-Client-App: HQ_WEB`,复用 `/admin/*``/common/*` 接口。原型参照 `pages/_archive/`HQ 归档屏)。
| 模块 | Route | 后端接口 | 状态 |
|------|-------|----------|------|
| 登录(短信 + 微信授权 Mock) | `pages/login/index` | `/admin/auth/login/sms``/admin/auth/login/wechat` | done |
| 管理中心首页看板 | `pages/dashboard/index`Tab | `/admin/dashboard/stats` | done |
| 门店审核列表 | `pages/stores/index`Tab | `/admin/stores` | done |
| 门店详情 / 审核 | `pages/stores/detail` | `/admin/stores/:id``/admin/stores/:id/status` | done |
| 订单中心 | `pages/orders/index` | `/admin/orders` | done |
| 订单详情 / 状态流转 | `pages/orders/detail` | `/admin/orders/:id``/admin/orders/:id/status` | done |
| 开城管理 | `pages/cities/index` | `/admin/cities` | done |
| 商品管理 | `pages/products/index` | `/admin/products` | done |
| 结算中心 | `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/refund/index` | `/admin/orders?status=REFUNDING``/admin/orders/:id/status` | done |
> **preV1 裁剪**:总部端不含推广码模块(Stitch 原型中的推广码入口已移除)。
> 微信授权登录:H5 普通浏览器走 `MOCK_WECHAT` Mock;小程序端 `Taro.login` 拿 code;正式接入填 `WX_APP_ID/SECRET` 并置 `MOCK_WECHAT=false`。
## 跳过(preV1 归档)
拦截配送等剩余归档 screen 保留在 `pages/_archive/`,按需再提升。
+5911 -29
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -4,8 +4,14 @@ packages:
- 'server/*'
allowBuilds:
'@nestjs/core': true
'@parcel/watcher': true
'@prisma/client': true
'@prisma/engines': true
'@swc/core': true
'@tarojs/binding': true
'@tarojs/cli': true
core-js: true
core-js-pure: true
esbuild: true
msgpackr-extract: true
prisma: true
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* 总部 H5 静态预览:托管 dist 并将 /api 代理到后端(与 admin-web vite proxy 行为一致)
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DIST = path.resolve(__dirname, '../apps/mini-hq/dist');
const PORT = Number(process.env.HQ_PREVIEW_PORT || 5176);
const API_TARGET = (process.env.VITE_API_TARGET || 'http://localhost:3000').replace(/\/$/, '');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 释放预览端口(pnpm preview:hq 重复执行时自动重启) */
function freePort(port) {
try {
if (platform() === 'win32') {
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8' });
const pids = new Set();
for (const line of out.split('\n')) {
if (!line.includes('LISTENING')) continue;
const pid = line.trim().split(/\s+/).pop();
if (pid && /^\d+$/.test(pid)) pids.add(pid);
}
for (const pid of pids) {
try {
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
} catch {
/* ignore */
}
}
return;
}
execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null || true`, {
shell: true,
stdio: 'ignore',
});
} catch {
/* 端口可能本就空闲 */
}
}
function sendFile(res, filePath) {
const ext = path.extname(filePath);
const type = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': type });
res.end(data);
});
}
function proxyApi(req, res) {
const target = new URL(req.url, API_TARGET);
const headers = { ...req.headers, host: target.host };
const proxyReq = http.request(
{
hostname: target.hostname,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
path: target.pathname + target.search,
method: req.method,
headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', () => {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 502, message: 'API 不可达,请先启动 pnpm dev:api' }));
});
req.pipe(proxyReq);
}
function createServer() {
return http.createServer((req, res) => {
const urlPath = req.url?.split('?')[0] || '/';
if (urlPath.startsWith('/api')) {
proxyApi(req, res);
return;
}
let filePath = path.join(DIST, urlPath === '/' ? 'index.html' : urlPath);
if (!filePath.startsWith(DIST)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.stat(filePath, (err, stat) => {
if (!err && stat.isFile()) {
sendFile(res, filePath);
return;
}
sendFile(res, path.join(DIST, 'index.html'));
});
});
}
function listen(server, port) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, () => {
server.off('error', reject);
resolve();
});
});
}
async function main() {
if (!fs.existsSync(DIST)) {
console.error('未找到 apps/mini-hq/dist,请先执行:pnpm --filter @dukang/mini-hq build');
process.exit(1);
}
freePort(PORT);
await sleep(400);
const server = createServer();
try {
await listen(server, PORT);
} catch (err) {
if (err && err.code === 'EADDRINUSE') {
console.error(`端口 ${PORT} 仍被占用,请手动结束进程后重试,或设置 HQ_PREVIEW_PORT 换端口。`);
process.exit(1);
}
throw err;
}
console.log(`mini-hq preview: http://localhost:${PORT}`);
console.log(`API proxy: ${API_TARGET}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+5
View File
@@ -13,6 +13,11 @@ MOCK_SMS_CODE=123456
MOCK_PAY=true
MOCK_DELIVERY_AUTO=true
AUTO_APPROVE_STORE=true
# preV1 Mock 微信授权登录:点击授权按钮走 Mock 流程直接登录(不接真实微信)
MOCK_WECHAT=true
# C 端 H5 落地页(推广码二维码链接前缀)
USER_H5_URL=http://localhost:5173
# 反向代理后提取真实客户端 IP(下单 IP 定位)
TRUST_PROXY=true
+8
View File
@@ -208,6 +208,14 @@ async function main() {
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKHQ001',
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);
@@ -7,6 +7,7 @@ import { PayWechatProvider } from './pay/pay.wechat.provider';
import { DeliveryMockProvider } from './delivery/delivery.mock.provider';
import { WechatApiProvider } from './wechat/wechat.api.provider';
import { WechatDisabledProvider } from './wechat/wechat.disabled.provider';
import { WechatMockProvider } from './wechat/wechat.mock.provider';
import { OssMockProvider } from './oss/oss.mock.provider';
import { OssAliyunProvider } from './oss/oss.aliyun.provider';
import {
@@ -27,16 +28,24 @@ import type { IOssProvider } from './oss/oss.interface';
{ provide: SMS_PROVIDER, useClass: SmsMockProvider },
WechatApiProvider,
WechatDisabledProvider,
WechatMockProvider,
{
provide: WECHAT_PROVIDER,
useFactory: (api: WechatApiProvider, disabled: WechatDisabledProvider): IWechatProvider => {
useFactory: (
api: WechatApiProvider,
disabled: WechatDisabledProvider,
mock: WechatMockProvider,
): IWechatProvider => {
const cfg = loadAppConfig();
const enabled =
(cfg.wechatAuthEnabled || cfg.wechatPayEnabled) &&
(!!cfg.wxAppId || !!process.env.WX_MCH_ID);
return enabled ? api : disabled;
if (enabled) return api;
// preV1:真实微信未配置但开启 Mock 授权登录
if (cfg.mockWechat) return mock;
return disabled;
},
inject: [WechatApiProvider, WechatDisabledProvider],
inject: [WechatApiProvider, WechatDisabledProvider, WechatMockProvider],
},
PayMockProvider,
PayWechatProvider,
@@ -34,6 +34,10 @@ export class WechatApiProvider implements IWechatProvider {
return this.config.wechatAuthEnabled && !!this.appId && !!this.appSecret;
}
isMock() {
return false;
}
isPayEnabled() {
return (
this.config.wechatPayEnabled &&
@@ -7,6 +7,10 @@ export class WechatDisabledProvider implements IWechatProvider {
return false;
}
isMock() {
return false;
}
isPayEnabled() {
return false;
}
@@ -24,6 +24,9 @@ export type WechatPayNotifyResult = {
export interface IWechatProvider {
isEnabled(): boolean;
/** 是否为 preV1 Mock 实现(登录时可回落到演示账号) */
isMock(): boolean;
/** 微信支付是否已配置(商户号 + 证书) */
isPayEnabled(): boolean;
@@ -0,0 +1,76 @@
import { createHash } from 'crypto';
import { Injectable, NotImplementedException } from '@nestjs/common';
import type {
IWechatProvider,
WechatCodeSession,
WechatOAuthSession,
} from './wechat.interface';
/**
* preV1 Mock Provider
*
* OAuth
* oauth-url codeMock URL openId
* WX_APP_ID/WX_APP_SECRET MOCK_WECHAT=false
*/
@Injectable()
export class WechatMockProvider implements IWechatProvider {
isEnabled() {
return true;
}
isMock() {
return true;
}
isPayEnabled() {
return false;
}
getMchId() {
return '';
}
/** 由 code 派生稳定 openId,保证同一 code 多次授权指向同一账号 */
private openIdFromCode(code: string): string {
return `mockwx_${createHash('md5').update(code).digest('hex').slice(0, 24)}`;
}
async code2Session(code: string): Promise<WechatCodeSession> {
return { openId: this.openIdFromCode(code), sessionKey: 'mock-session-key' };
}
async oauth2AccessToken(code: string): Promise<WechatOAuthSession> {
return { openId: this.openIdFromCode(code), accessToken: 'mock-access-token' };
}
async createJssdkConfig(url: string) {
return {
appId: 'mock-appid',
timestamp: Math.floor(Date.now() / 1000),
nonceStr: 'mocknonce',
signature: 'mocksignature',
url,
jsApiList: ['getLocation', 'scanQRCode', 'chooseImage'],
} as unknown as Awaited<ReturnType<IWechatProvider['createJssdkConfig']>>;
}
/** 直接把授权链接回跳到 redirectUri 并附带 mock code,模拟微信授权完成 */
buildOAuthUrl(redirectUri: string, state: string): string {
const sep = redirectUri.includes('?') ? '&' : '?';
const code = `mockcode_${state || 'default'}`;
return `${redirectUri}${sep}code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
}
async getPhoneNumberByCode(): Promise<string> {
throw new NotImplementedException('Mock 微信不支持获取手机号,请用短信绑定');
}
createJsapiPrepay(): never {
throw new NotImplementedException('FEATURE_DISABLED');
}
parsePayNotification(): never {
throw new NotImplementedException('FEATURE_DISABLED');
}
}
@@ -1,15 +1,30 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { AnalyticsService } from './analytics.service';
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { PromoTouchDto } from './dto/promo.dto';
import { ActorType } from '@dukang/shared-types';
@Controller('analytics')
@UseGuards(JwtAuthGuard)
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('events')
@UseGuards(JwtAuthGuard)
track(@CurrentUser() user: AuthUser, @Body() body: { events: Array<{ eventName: string; params?: Record<string, unknown> }> }) {
return this.analyticsService.trackBatch(user.actorId, user.clientApp, body.events);
}
}
@Controller('promo')
export class PromoController {
constructor(private readonly analyticsService: AnalyticsService) {}
@Post('touch')
@UseGuards(OptionalJwtAuthGuard)
touch(@CurrentUser() user: AuthUser | undefined, @Body() dto: PromoTouchDto) {
const userId = user?.actorType === ActorType.USER ? user.actorId : undefined;
return this.analyticsService.touchPromo(dto.promoCode, userId);
}
}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsController } from './analytics.controller';
import { AnalyticsController, PromoController } from './analytics.controller';
import { AnalyticsService } from './analytics.service';
@Module({
imports: [IamModule],
controllers: [AnalyticsController],
controllers: [AnalyticsController, PromoController],
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule {}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import type { ClientApp } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
@Injectable()
export class AnalyticsService {
@@ -22,4 +23,43 @@ export class AnalyticsService {
});
return { count: events.length };
}
/** 扫码归因:始终累加 scan_count;已登录用户首次写入 user_promo_attribution */
async touchPromo(promoCode: string, userId?: bigint) {
const code = promoCode.trim().toUpperCase();
const promo = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (!promo || promo.status !== 'ACTIVE') {
throw new NotFoundException('推广码无效或已停用');
}
await this.prisma.commonPromoCode.update({
where: { id: promo.id },
data: { scanCount: { increment: 1 } },
});
let attributed = false;
if (userId) {
const existing = await this.prisma.userPromoAttribution.findUnique({
where: { userId },
});
if (!existing) {
await this.prisma.userPromoAttribution.create({
data: {
userId,
promoCodeId: promo.id,
channelName: promo.name,
firstTouchAt: new Date(),
},
});
attributed = true;
}
}
return serializeBigInt({
promoCode: promo.code,
channelName: promo.name,
attributed,
});
}
}
@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class PromoTouchDto {
@IsString()
@IsNotEmpty()
promoCode: string;
}
@@ -9,6 +9,7 @@ export class ClientConfigController {
return {
mockPay: cfg.mockPay,
wechatPayEnabled: cfg.wechatPayEnabled,
mockWechat: cfg.mockWechat,
};
}
}
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginSmsDto, SendSmsDto } from './dto/auth.dto';
import { LoginSmsDto, LoginWechatDto, SendSmsDto } from './dto/auth.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { AuthUser } from '../../common/guards/jwt-auth.guard';
@@ -20,6 +20,11 @@ export class AdminAuthController {
return this.authService.loginHq(dto.phone, dto.code, ClientApp.HQ_WEB);
}
@Post('login/wechat')
wechatLogin(@Body() dto: LoginWechatDto) {
return this.authService.loginHqWechat(dto.code, ClientApp.HQ_WEB, dto.platform ?? 'h5');
}
@Get('me')
@UseGuards(JwtAuthGuard)
me(@CurrentUser() user: AuthUser) {
@@ -268,6 +268,48 @@ export class AuthService {
});
}
async loginHqWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
this.assertWechatEnabled();
const session =
platform === 'mini'
? await this.wechatProvider.code2Session(code)
: await this.wechatProvider.oauth2AccessToken(code);
let account = await this.prisma.hqAccount.findFirst({
where: { wxOpenId: session.openId },
});
if (!account && this.wechatProvider.isMock()) {
// preV1 Mock:无绑定微信时回落到演示超管账号
account = await this.prisma.hqAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ adminRole: 'asc' }, { id: 'asc' }],
});
}
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
if (account.status !== 'ACTIVE') throw new BadRequestException('账号已停用');
account = await this.prisma.hqAccount.update({
where: { id: account.id },
data: {
wxOpenId: session.openId,
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
});
return this.issueToken('HQ', account.id, clientApp, false, undefined, undefined, undefined, undefined, {
id: account.id.toString(),
phone: account.phone,
name: account.name,
adminRole: account.adminRole,
status: account.status,
});
}
async getMe(actorType: string, actorId: bigint) {
if (actorType === 'USER') {
const user = await this.assertActiveUser(actorId);
@@ -555,6 +597,15 @@ export class AuthService {
include: { partner: true },
});
if (!account && this.wechatProvider.isMock()) {
// preV1 Mock:无绑定微信时回落到演示主账号,方便一键授权登录
account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
include: { partner: true },
});
}
if (!account) {
throw new BadRequestException('首次登录请使用手机验证码,登录后将自动关联微信');
}
@@ -0,0 +1,36 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
import { CreatePromoCodeDto, UpdatePromoCodeStatusDto } from './dto/admin-mutate.dto';
@Controller('admin/promo-codes')
@UseGuards(HqAuthGuard)
export class AdminPromoCodesController {
constructor(private readonly service: AdminPromoCodesService) {}
@Get()
list(@Query() query: AdminPromoCodesQueryDto) {
return this.service.list(query);
}
@Get(':id/stats')
stats(@Param('id') id: string) {
return this.service.stats(BigInt(id));
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreatePromoCodeDto) {
return this.service.create(dto);
}
@Put(':id/status')
updateStatus(@Param('id') id: string, @Body() dto: UpdatePromoCodeStatusDto) {
return this.service.updateStatus(BigInt(id), dto.status);
}
}
@@ -0,0 +1,133 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CreatePromoCodeDto } from './dto/admin-mutate.dto';
import { AdminPromoCodesQueryDto } from './dto/admin-query.dto';
function userH5Base(): string {
return (process.env.USER_H5_URL || 'http://localhost:5173').replace(/\/$/, '');
}
function buildLandingUrl(code: string): string {
return `${userH5Base()}/?promo=${encodeURIComponent(code)}`;
}
function randomCode(): string {
const n = Math.random().toString(36).slice(2, 8).toUpperCase();
return `DK${n}`;
}
@Injectable()
export class AdminPromoCodesService {
constructor(private readonly prisma: PrismaService) {}
private mapRow(row: {
id: bigint;
code: string;
name: string;
status: string;
scanCount: number;
orderCount: number;
createdAt: Date;
}) {
return serializeBigInt({
id: row.id,
code: row.code,
name: row.name,
status: row.status,
scanCount: row.scanCount,
orderCount: row.orderCount,
landingUrl: buildLandingUrl(row.code),
createdAt: row.createdAt,
});
}
async list(query: AdminPromoCodesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: {
status?: 'ACTIVE' | 'DISABLED';
name?: { contains: string };
code?: { contains: string };
} = {};
if (query.status) where.status = query.status as 'ACTIVE' | 'DISABLED';
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
const [items, total] = await Promise.all([
this.prisma.commonPromoCode.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonPromoCode.count({ where }),
]);
return serializeBigInt({
items: items.map((r) => this.mapRow(r)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
const stats = this.statsFromRow(row);
return serializeBigInt({ ...this.mapRow(row), stats });
}
async create(dto: CreatePromoCodeDto) {
let code = dto.code?.trim().toUpperCase();
if (code) {
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code } });
if (exists) throw new BadRequestException('推广码已存在');
} else {
for (let i = 0; i < 5; i++) {
const candidate = randomCode();
const exists = await this.prisma.commonPromoCode.findUnique({ where: { code: candidate } });
if (!exists) {
code = candidate;
break;
}
}
if (!code) throw new BadRequestException('生成推广码失败,请重试');
}
const row = await this.prisma.commonPromoCode.create({
data: {
code,
name: dto.name.trim(),
status: 'ACTIVE',
},
});
return this.mapRow(row);
}
async updateStatus(id: bigint, status: 'ACTIVE' | 'DISABLED') {
const row = await this.prisma.commonPromoCode.update({
where: { id },
data: { status },
});
return this.mapRow(row);
}
async stats(id: bigint) {
const row = await this.prisma.commonPromoCode.findUnique({ where: { id } });
if (!row) throw new NotFoundException('推广码不存在');
return serializeBigInt(this.statsFromRow(row));
}
private statsFromRow(row: { scanCount: number; orderCount: number }) {
const scanCount = row.scanCount;
const orderCount = row.orderCount;
const conversionRate =
scanCount > 0 ? Math.round((orderCount / scanCount) * 1000) / 10 : 0;
return { scanCount, orderCount, conversionRate };
}
}
@@ -426,3 +426,19 @@ export class UpdateProductDto {
@IsString()
coverUrl?: string;
}
export class CreatePromoCodeDto {
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
code?: string;
}
export class UpdatePromoCodeStatusDto {
@IsString()
@IsIn(['ACTIVE', 'DISABLED'])
status: 'ACTIVE' | 'DISABLED';
}
@@ -236,3 +236,17 @@ export class AdminStoreMediaQueryDto extends PaginationQueryDto {
@IsString()
mediaType?: string;
}
export class AdminPromoCodesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
@@ -21,6 +21,8 @@ import { AdminHqAccountsController } from './admin-hq-accounts.controller';
import { AdminHqAccountsService } from './admin-hq-accounts.service';
import { AdminProductsController } from './admin-products.controller';
import { AdminProductsService } from './admin-products.service';
import { AdminPromoCodesController } from './admin-promo-codes.controller';
import { AdminPromoCodesService } from './admin-promo-codes.service';
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
@Module({
@@ -41,6 +43,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesController,
AdminHqAccountsController,
AdminProductsController,
AdminPromoCodesController,
],
providers: [
AdminDashboardService,
@@ -54,6 +57,7 @@ import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
AdminDeliveriesService,
AdminHqAccountsService,
AdminProductsService,
AdminPromoCodesService,
SuperAdminGuard,
],
})