feat;提交管理端和城市合伙人端
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user