Merge #18 into dev from dev_jacy
合伙人端登录接口优化 * dev_jacy: (11 commits) feat;提交管理端和城市合伙人端 fix:修改样式 fix:完善门店和总部管理端 配置服务器 核销成功提示 webadmin端增加商铺日志 Merge commit 'fcf1d45522e3ba6e6faf2590b60f2db1ef90e73c' into dev_jacy api端报错修正 合伙人端登录验证调整还有日志落地 合伙人端登录页面调整 合伙人端登录接口优化 Signed-off-by: jacy <moonjie444@163.com> Reviewed-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/18
This commit is contained in:
@@ -27,6 +27,8 @@ import TicketsPage from './pages/TicketsPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
import StoreLogsPage from './pages/StoreLogsPage';
|
||||
import PartnerLogsPage from './pages/PartnerLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -65,6 +67,8 @@ export default function App() {
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||
<Route path="/logs/hq" element={<HqLogsPage />} />
|
||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
|
||||
@@ -73,6 +73,8 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '日志',
|
||||
children: [
|
||||
{ key: '/logs/users', label: '用户日志' },
|
||||
{ key: '/logs/stores', label: '商户日志' },
|
||||
{ key: '/logs/partners', label: '合伙人日志' },
|
||||
{ key: '/logs/hq', label: 'HQ 操作日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
STORE_LOG_CATEGORY_OPTIONS,
|
||||
STORE_LOG_CATEGORY_LABELS,
|
||||
resolveStoreLogCategory,
|
||||
type StoreLogCategory,
|
||||
} from '@dukang/shared-types';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
@@ -28,6 +28,7 @@ type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
@@ -50,6 +51,22 @@ export default function PartnerAccountsPage() {
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
async function openAccount(id: string) {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, status: d.status });
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
@@ -58,12 +75,12 @@ export default function PartnerAccountsPage() {
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作', width: 120,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request<Detail>(`/admin/partner-accounts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -100,27 +117,41 @@ export default function PartnerAccountsPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="开城合伙人账户" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Select defaultValue={detail.status} style={{ width: 100 }}
|
||||
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
onChange={async (status) => {
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||
message.success('已更新');
|
||||
void reload();
|
||||
}} />
|
||||
)}>
|
||||
<Drawer
|
||||
title="编辑开城合伙人账户"
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void saveAccount()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="开城合伙人">{detail.partner?.companyName}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与开城合伙人主体的「联系电话」可不同。
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -155,7 +186,16 @@ export default function PartnerAccountsPage() {
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
PARTNER_LOG_CATEGORY_OPTIONS,
|
||||
PARTNER_LOG_CATEGORY_LABELS,
|
||||
resolvePartnerLogCategory,
|
||||
type PartnerLogCategory,
|
||||
} from '../lib/partner-log';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function PartnerLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
partnerId: searchParams.get('partnerId') ?? '',
|
||||
partnerAccountId: searchParams.get('partnerAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
companyName: searchParams.get('companyName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
if (filters.partnerAccountId) qs.set('partnerAccountId', filters.partnerAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '合伙人',
|
||||
width: 180,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.companyName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.partnerId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号',
|
||||
width: 150,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.accountName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.partnerAccountId || '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类',
|
||||
dataIndex: 'category',
|
||||
width: 100,
|
||||
render: (v: PartnerLogCategory | null, r) => (
|
||||
<Tag>{PARTNER_LOG_CATEGORY_LABELS[v ?? ''] || resolvePartnerLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 180 },
|
||||
{
|
||||
title: '关联',
|
||||
width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要',
|
||||
ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginBottom: 16 }}>
|
||||
合伙人日志
|
||||
</Typography.Title>
|
||||
<Segmented
|
||||
options={PARTNER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, flexWrap: 'wrap', gap: 8 }}
|
||||
onFinish={(values) => {
|
||||
setFilters(values);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="phone" label="手机号">
|
||||
<Input placeholder="账号手机号" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input placeholder="合伙人公司" allowClear style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" label="合伙人ID">
|
||||
<Input placeholder="partnerId" allowClear style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="eventName" label="事件名">
|
||||
<Input placeholder="partner_sms_login" allowClear style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setFilters({
|
||||
partnerId: '',
|
||||
partnerAccountId: '',
|
||||
phone: '',
|
||||
companyName: '',
|
||||
eventName: '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer title="日志详情" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
{Object.entries(detail).map(([k, v]) => (
|
||||
<Descriptions.Item key={k} label={k}>
|
||||
{typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v ?? '—')}
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,24 @@ type Row = {
|
||||
storeCount: number; accountCount: number; cityCount: number; createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
cities?: Array<{ name: string; code: string; status: string }>;
|
||||
};
|
||||
|
||||
function pickPartnerFormValues(d: PartnerDetail) {
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone,
|
||||
address: d.address,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
@@ -27,10 +45,26 @@ export default function PartnersPage() {
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(pickPartnerFormValues(d));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 },
|
||||
@@ -42,15 +76,8 @@ export default function PartnersPage() {
|
||||
title: '操作', width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/partners/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => {
|
||||
editForm.setFieldsValue(row);
|
||||
setDetail(row as unknown as Record<string, unknown>);
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>编辑</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -69,30 +96,29 @@ export default function PartnersPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="开城合伙人详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
<Drawer
|
||||
title="编辑开城合伙人"
|
||||
width={560}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{Array.isArray(detail.cities) && (
|
||||
{Array.isArray(detail.cities) && detail.cities.length > 0 && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市">
|
||||
{(detail.cities as Array<{ name: string; code: string; status: string }>).map((c) => (
|
||||
{detail.cities.map((c) => (
|
||||
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical" initialValues={detail as Record<string, unknown>}>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
STORE_LOG_CATEGORY_OPTIONS,
|
||||
STORE_LOG_CATEGORY_LABELS,
|
||||
resolveStoreLogCategory,
|
||||
type StoreLogCategory,
|
||||
} from '../lib/store-log';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
source: 'analytics' | 'redeem_record' | 'store_payout';
|
||||
storeId: string;
|
||||
storeAccountId: string | null;
|
||||
storeName: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
category: StoreLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<Row['source'], string> = {
|
||||
analytics: '行为埋点',
|
||||
redeem_record: '核销记录',
|
||||
store_payout: '打款记录',
|
||||
};
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
function parseCompositeId(id: string) {
|
||||
const idx = id.indexOf(':');
|
||||
if (idx <= 0) return null;
|
||||
return { source: id.slice(0, idx), rawId: id.slice(idx + 1) };
|
||||
}
|
||||
|
||||
export default function StoreLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
storeId: searchParams.get('storeId') ?? '',
|
||||
storeAccountId: searchParams.get('storeAccountId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
storeName: searchParams.get('storeName') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/admin/logs/stores',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.storeAccountId) qs.set('storeAccountId', filters.storeAccountId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.storeName) qs.set('storeName', filters.storeName);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '门店', width: 180,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.storeName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.storeId}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账号', width: 150,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.accountName || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.accountPhone || r.storeAccountId || '系统/HQ'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类', dataIndex: 'category', width: 100,
|
||||
render: (v: StoreLogCategory | null, r) => (
|
||||
<Tag>{STORE_LOG_CATEGORY_LABELS[v ?? ''] || resolveStoreLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||
{
|
||||
title: '来源', dataIndex: 'source', width: 100,
|
||||
render: (v: Row['source']) => SOURCE_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '关联', width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要', ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const parsed = parseCompositeId(row.id);
|
||||
if (!parsed) return;
|
||||
setDetail(await request(`/admin/logs/stores/${parsed.source}/${parsed.rawId}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>商户日志</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: -8 }}>
|
||||
门店登录、微信授权、核销、打款与营业状态等操作记录;历史核销/打款数据来自业务表归档。
|
||||
</Typography.Paragraph>
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={STORE_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
setSearchParams(next);
|
||||
}}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const key of ['storeId', 'storeAccountId', 'phone', 'storeName', 'eventName'] as const) {
|
||||
if (v[key]) next.set(key, v[key]);
|
||||
else next.delete(key);
|
||||
}
|
||||
setSearchParams(next);
|
||||
}}>
|
||||
<Form.Item name="storeId" label="门店ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||
<Form.Item name="storeName" label="门店名称"><Input allowClear style={{ width: 140 }} /></Form.Item>
|
||||
<Form.Item name="storeAccountId" label="账号ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="store_login_success" /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="商户日志详情" width={560} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店">{String(detail.storeName || detail.storeId || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="账号">{String(detail.accountName || detail.storeAccountId || '系统/HQ')}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{String(detail.accountPhone || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">
|
||||
{(STORE_LOG_CATEGORY_LABELS as Record<string, string>)[String(detail.category ?? '')] || String(detail.category || '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="来源">{SOURCE_LABELS[String(detail.source) as Row['source']] || String(detail.source || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="参数">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -61,6 +62,7 @@ type CityOption = {
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
@@ -321,6 +323,11 @@ export default function StoresPage() {
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{detail.coverUrl ? (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
|
||||
@@ -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() {
|
||||
@@ -25,7 +26,7 @@ function WechatOAuthHandler() {
|
||||
if (location.pathname === '/login') 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');
|
||||
@@ -59,7 +60,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;
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveAuth } from './api';
|
||||
|
||||
@@ -19,20 +20,46 @@ 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 authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error('请在微信内打开以完成授权');
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
if (!isWechatEnv()) return null;
|
||||
return weixinSdk.handleOAuthCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
|
||||
* 返回 true = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) 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(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,84 +2,164 @@ import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request, saveAuth } from '../lib/api';
|
||||
import {
|
||||
authorizePartnerWechat,
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
handlePartnerWechatCallback,
|
||||
savePartnerWechatAuth,
|
||||
handlePartnerWechatLoginResult,
|
||||
loginPartnerWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
|
||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
function formatPartnerError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '操作失败';
|
||||
if (text.includes('合伙人账号不存在') || text.includes('未找到合伙人账号')) {
|
||||
return '未找到合伙人账号';
|
||||
}
|
||||
if (text.includes('合伙人账号已停用')) {
|
||||
return '合伙人账号已停用';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const [phone, setPhone] = useState('13700000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const remembered = loadRememberedPhone();
|
||||
const [phone, setPhone] = useState(remembered.phone || '13700000001');
|
||||
const [code, setCode] = useState('');
|
||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wechatLoading, setWechatLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
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 (savePartnerWechatAuth(result)) {
|
||||
navigate('/');
|
||||
}
|
||||
if (handlePartnerWechatLoginResult(result)) navigate('/');
|
||||
})
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '微信登录失败'));
|
||||
.catch((e) => setMsg(formatWechatError(e)));
|
||||
}, [navigate]);
|
||||
|
||||
async function login() {
|
||||
setLoading(true);
|
||||
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('验证码已发送');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((s) => {
|
||||
if (s <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return s - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
}
|
||||
}
|
||||
|
||||
async function login(options?: { quick?: boolean }) {
|
||||
if (!options?.quick && !ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
if (options?.quick) {
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
}
|
||||
const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
persistRememberAccount(phone);
|
||||
if (isWechatEnv()) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindPartnerWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键登录');
|
||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||
return;
|
||||
}
|
||||
setWechatLoading(true);
|
||||
setWxLoading(true);
|
||||
try {
|
||||
await authorizePartnerWechat();
|
||||
const ok = await loginPartnerWithWechat();
|
||||
if (ok) navigate('/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '微信登录失败');
|
||||
setWechatLoading(false);
|
||||
setMsg(formatWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(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);
|
||||
});
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
return (
|
||||
<div className="partner-auth-page partner-auth-page--quick">
|
||||
@@ -105,7 +185,8 @@ export default function LoginPage() {
|
||||
</section>
|
||||
|
||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||
<button type="button" className="partner-btn-primary" onClick={login} disabled={loading}>
|
||||
{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={() => void login({ quick: true })} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
@@ -137,39 +218,62 @@ export default function LoginPage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">smartphone</span>
|
||||
<input className="partner-input" type="tel" placeholder="请输入手机号" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<input
|
||||
className="partner-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value);
|
||||
setMsg('');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="partner-input-row">
|
||||
<div className="partner-input-wrap">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
<input className="partner-input" type="text" placeholder="验证码" value={code} onChange={(e) => setCode(e.target.value)} />
|
||||
<input
|
||||
className="partner-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="partner-code-btn" onClick={sendCode} disabled={codeCooldown > 0}>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重发` : '获取验证码'}
|
||||
{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}>
|
||||
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
<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 }}
|
||||
disabled={wechatLoading || loading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<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>
|
||||
{wechatLoading ? '跳转授权中…' : '微信一键登录'}
|
||||
<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>
|
||||
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
|
||||
|
||||
<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>
|
||||
|
||||
+140
-12
@@ -6,6 +6,10 @@ html, body, #root {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* ── Partner auth ── */
|
||||
.partner-auth-page {
|
||||
min-height: 100vh;
|
||||
@@ -277,6 +281,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;
|
||||
@@ -681,30 +737,75 @@ html, body, #root {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Partner bottom tabbar ── */
|
||||
.app-tabbar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* ── Partner bottom tabbar(锁定尺寸,仅切换颜色)── */
|
||||
nav.app-tabbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-items: stretch;
|
||||
padding: 8px 0 calc(8px + env(safe-area-inset-bottom, 0px));
|
||||
justify-content: stretch;
|
||||
padding: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
height: calc(56px + env(safe-area-inset-bottom, 0px));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-tabbar-item {
|
||||
flex: 1;
|
||||
nav.app-tabbar .app-tabbar-item {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
padding: 4px 2px;
|
||||
max-width: 33.333%;
|
||||
height: 56px;
|
||||
padding: 6px 0 4px;
|
||||
border-radius: 0;
|
||||
gap: 2px;
|
||||
color: var(--color-subtle-gray);
|
||||
text-decoration: none;
|
||||
transition: color 0.15s ease;
|
||||
transform: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: manipulation;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
nav.app-tabbar .app-tabbar-item.active {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
nav.app-tabbar .app-tabbar-item:active,
|
||||
nav.app-tabbar .app-tabbar-item:focus,
|
||||
nav.app-tabbar .app-tabbar-item:focus-visible {
|
||||
transform: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav.app-tabbar .app-tabbar-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
.app-tabbar-label {
|
||||
max-width: 100%;
|
||||
nav.app-tabbar .app-tabbar-label {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
margin: 0;
|
||||
padding: 0 1px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Store create stepper ── */
|
||||
@@ -1997,3 +2098,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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
@@ -19,10 +20,20 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
import { capturePromoFromUrl, touchPromoIfNeeded } from './lib/promo';
|
||||
|
||||
function PromoBootstrap() {
|
||||
useEffect(() => {
|
||||
capturePromoFromUrl();
|
||||
void touchPromoIfNeeded();
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<UserSessionProvider>
|
||||
<PromoBootstrap />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type SessionPayload,
|
||||
type UserProfile,
|
||||
} from '../lib/api';
|
||||
import { touchPromoIfNeeded } from '../lib/promo';
|
||||
|
||||
type UserSessionContextValue = {
|
||||
ready: boolean;
|
||||
@@ -62,6 +63,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||
if (!session.user) {
|
||||
await refreshProfile();
|
||||
}
|
||||
await touchPromoIfNeeded();
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export const PROMO_STORAGE_KEY = 'dukang_promo_code';
|
||||
|
||||
function readPromoFromSearch(search: string): string | null {
|
||||
const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
|
||||
const code = params.get('promo')?.trim();
|
||||
return code ? code.toUpperCase() : null;
|
||||
}
|
||||
|
||||
/** 解析 URL 中的 ?promo= 并写入 sessionStorage */
|
||||
export function capturePromoFromUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
let code = readPromoFromSearch(window.location.search);
|
||||
if (!code && window.location.hash.includes('?')) {
|
||||
const hashQuery = window.location.hash.slice(window.location.hash.indexOf('?'));
|
||||
code = readPromoFromSearch(hashQuery);
|
||||
}
|
||||
if (code) {
|
||||
sessionStorage.setItem(PROMO_STORAGE_KEY, code);
|
||||
}
|
||||
return code ?? sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getStoredPromoCode(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return sessionStorage.getItem(PROMO_STORAGE_KEY);
|
||||
}
|
||||
|
||||
/** 调用 /promo/touch 完成扫码归因(OptionalJwt:未登录也累加 scan_count) */
|
||||
export async function touchPromoIfNeeded(): Promise<void> {
|
||||
const promoCode = getStoredPromoCode();
|
||||
if (!promoCode) return;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/promo/touch`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ promoCode }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) return;
|
||||
} catch {
|
||||
/* 静默失败,不阻断用户流程 */
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { useSmsCode } from '../lib/use-sms-code';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { touchPromoIfNeeded } from '../lib/promo';
|
||||
|
||||
async function finishLogin(navigate: (path: string) => void, returnTo: string) {
|
||||
await touchPromoIfNeeded();
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -50,7 +56,8 @@ export default function LoginPage() {
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as SessionPayload['user'],
|
||||
});
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
void finishLogin(navigate, returnTo);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +104,7 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
applySession(data);
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
await finishLogin(navigate, returnTo);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||
const POLL_INTERVAL_MS = 2500;
|
||||
|
||||
type BenefitSummary = {
|
||||
totalBalance: number;
|
||||
@@ -10,6 +11,21 @@ type BenefitSummary = {
|
||||
activeCouponCount: number;
|
||||
};
|
||||
|
||||
type RedeemTokenStatus =
|
||||
| { status: 'PENDING'; expireInSeconds: number; amount: number }
|
||||
| {
|
||||
status: 'CONSUMED';
|
||||
record: {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
| { status: 'EXPIRED' };
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -37,10 +53,9 @@ export default function RedeemPage() {
|
||||
const [timerSec, setTimerSec] = useState(REDEEM_TOKEN_TTL_SECONDS);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const presetApplied = useRef(false);
|
||||
const successHandled = useRef(false);
|
||||
|
||||
const redeemableMax = couponId
|
||||
? (couponBalance ?? 0)
|
||||
: (summary?.totalBalance ?? 0);
|
||||
const redeemableMax = couponId ? (couponBalance ?? 0) : (summary?.totalBalance ?? 0);
|
||||
|
||||
useEffect(() => {
|
||||
request<BenefitSummary>('USER_H5', '/benefit/summary').then(setSummary).catch(() => {});
|
||||
@@ -77,12 +92,41 @@ export default function RedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
const closeModal = useCallback(() => {
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
setTimerSec(REDEEM_TOKEN_TTL_SECONDS);
|
||||
}
|
||||
successHandled.current = false;
|
||||
}, []);
|
||||
|
||||
const handleRedeemExpired = useCallback(() => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
closeModal();
|
||||
sessionStorage.removeItem('redeemToken');
|
||||
sessionStorage.removeItem('redeemAmount');
|
||||
setMsg('核销码已失效,请重新生成');
|
||||
}, [closeModal]);
|
||||
|
||||
const handleRedeemSuccess = useCallback(
|
||||
(record: NonNullable<Extract<RedeemTokenStatus, { status: 'CONSUMED' }>['record']>) => {
|
||||
if (successHandled.current) return;
|
||||
successHandled.current = true;
|
||||
stopTimer();
|
||||
setModalOpen(false);
|
||||
setToken('');
|
||||
sessionStorage.setItem('lastRedeemRecordId', record.id);
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(record));
|
||||
sessionStorage.removeItem('redeemToken');
|
||||
sessionStorage.removeItem('redeemAmount');
|
||||
navigate('/redeem/success', {
|
||||
replace: true,
|
||||
state: { record },
|
||||
});
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
function startTimer() {
|
||||
stopTimer();
|
||||
@@ -91,10 +135,7 @@ export default function RedeemPage() {
|
||||
setTimerSec((prev) => {
|
||||
if (prev <= 1) {
|
||||
stopTimer();
|
||||
window.setTimeout(() => {
|
||||
window.alert('核销码已失效,请重新生成');
|
||||
closeModal();
|
||||
}, 0);
|
||||
window.setTimeout(() => handleRedeemExpired(), 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
@@ -102,6 +143,35 @@ export default function RedeemPage() {
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen || !token) return undefined;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const status = await request<RedeemTokenStatus>('USER_H5', `/redeem/tokens/${token}/status`);
|
||||
if (cancelled || successHandled.current) return;
|
||||
if (status.status === 'CONSUMED' && status.record) {
|
||||
handleRedeemSuccess(status.record);
|
||||
} else if (status.status === 'EXPIRED') {
|
||||
handleRedeemExpired();
|
||||
} else if (status.status === 'PENDING' && status.expireInSeconds > 0) {
|
||||
setTimerSec((prev) => Math.min(prev, status.expireInSeconds));
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败忽略,下次重试 */
|
||||
}
|
||||
}
|
||||
|
||||
void pollStatus();
|
||||
const pollId = window.setInterval(() => void pollStatus(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(pollId);
|
||||
};
|
||||
}, [modalOpen, token, handleRedeemSuccess, handleRedeemExpired]);
|
||||
|
||||
function parseAmount() {
|
||||
const value = Number(amountInput);
|
||||
return Number.isFinite(value) ? Math.round(value * 100) / 100 : 0;
|
||||
@@ -129,6 +199,7 @@ export default function RedeemPage() {
|
||||
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
successHandled.current = false;
|
||||
try {
|
||||
const body: { amount: number; couponId?: string } = { amount };
|
||||
if (couponId) body.couponId = couponId;
|
||||
|
||||
@@ -1,53 +1,128 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RedeemRecord = {
|
||||
id: string;
|
||||
redeemNo: string;
|
||||
amount: number;
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function StarRating({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (score: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="redeem-rating-row">
|
||||
<span className="redeem-rating-label">{label}</span>
|
||||
<div className="redeem-star-row" role="group" aria-label={label}>
|
||||
{[1, 2, 3, 4, 5].map((score) => (
|
||||
<button
|
||||
key={score}
|
||||
type="button"
|
||||
className={`redeem-star-btn${score <= value ? ' active' : ''}`}
|
||||
aria-label={`${score} 星`}
|
||||
onClick={() => onChange(score)}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RedeemSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [serviceScore, setServiceScore] = useState(5);
|
||||
const [envScore, setEnvScore] = useState(5);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const record = useMemo<RedeemRecord | null>(() => {
|
||||
const fromState = (location.state as { record?: RedeemRecord })?.record;
|
||||
if (fromState) return fromState;
|
||||
try {
|
||||
const cached = sessionStorage.getItem('lastRedeemResult');
|
||||
return cached ? (JSON.parse(cached) as RedeemRecord) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [location.state]);
|
||||
|
||||
const amount = Number(record?.amount ?? 0);
|
||||
const storeName = record?.storeName || '门店';
|
||||
const redeemNo = record?.redeemNo || '—';
|
||||
const redeemedAt = record?.createdAt
|
||||
? new Date(record.createdAt).toLocaleString('zh-CN')
|
||||
: new Date().toLocaleString('zh-CN');
|
||||
|
||||
async function submit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const redeemRecordId = sessionStorage.getItem('lastRedeemRecordId');
|
||||
const redeemRecordId = record?.id || sessionStorage.getItem('lastRedeemRecordId');
|
||||
if (redeemRecordId) {
|
||||
await request('USER_H5', '/redeem/ratings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
redeemRecordId,
|
||||
serviceScore,
|
||||
environmentScore: envScore,
|
||||
envScore,
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
/* preV1: allow navigate even if rating fails */
|
||||
/* 评价失败不阻塞返回 */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
navigate('/benefit');
|
||||
sessionStorage.removeItem('lastRedeemRecordId');
|
||||
sessionStorage.removeItem('lastRedeemResult');
|
||||
navigate('/benefit', { replace: true });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab redeem-success-page">
|
||||
<PageHeader title="核销成功" onBack={() => navigate('/benefit')} />
|
||||
<div style={{ textAlign: 'center', padding: '24px 0' }}>
|
||||
<PageHeader title="核销成功" onBack={() => navigate('/benefit', { replace: true })} />
|
||||
<div className="redeem-success-hero">
|
||||
<div className="success-icon">✓</div>
|
||||
<h2 className="headline-lg text-primary">核销成功</h2>
|
||||
<p className="text-variant body-md" style={{ marginTop: 8 }}>请为门店服务评分</p>
|
||||
<p className="redeem-success-amount">¥ {formatMoney(amount)}</p>
|
||||
<p className="text-variant body-md">已在 {storeName} 完成核销</p>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="form-group">
|
||||
<label>服务评分 (1-5)</label>
|
||||
<input type="number" min={1} max={5} value={serviceScore} onChange={(e) => setServiceScore(Number(e.target.value))} />
|
||||
|
||||
<div className="card redeem-success-details">
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销门店</span>
|
||||
<span>{storeName}</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>环境评分 (1-5)</label>
|
||||
<input type="number" min={1} max={5} value={envScore} onChange={(e) => setEnvScore(Number(e.target.value))} />
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销时间</span>
|
||||
<span>{redeemedAt}</span>
|
||||
</div>
|
||||
<div className="redeem-success-detail-row">
|
||||
<span>核销单号</span>
|
||||
<span className="redeem-success-mono">{redeemNo}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card redeem-success-rating">
|
||||
<p className="redeem-success-rating-title">为门店服务评分</p>
|
||||
<StarRating label="服务态度" value={serviceScore} onChange={setServiceScore} />
|
||||
<StarRating label="用餐环境" value={envScore} onChange={setEnvScore} />
|
||||
<button type="button" className="btn btn-primary btn-block" disabled={loading} onClick={submit}>
|
||||
{loading ? '提交中...' : '完成'}
|
||||
</button>
|
||||
|
||||
@@ -1223,6 +1223,95 @@
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.redeem-success-page {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.redeem-success-hero {
|
||||
text-align: center;
|
||||
padding: 24px 16px 8px;
|
||||
}
|
||||
|
||||
.redeem-success-amount {
|
||||
margin-top: 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary, #a02d30);
|
||||
}
|
||||
|
||||
.redeem-success-details {
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
.redeem-success-detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.redeem-success-detail-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.redeem-success-detail-row span:first-child {
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.redeem-success-detail-row span:last-child {
|
||||
text-align: right;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.redeem-success-mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.redeem-success-rating {
|
||||
margin: 0 16px;
|
||||
}
|
||||
|
||||
.redeem-success-rating-title {
|
||||
margin: 0 0 16px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.redeem-rating-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.redeem-rating-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--color-on-surface-variant, #666);
|
||||
}
|
||||
|
||||
.redeem-star-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.redeem-star-btn {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 32px;
|
||||
line-height: 1;
|
||||
color: #ddd;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.redeem-star-btn.active {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
/* ── 登录页(stitch user_用户登录页_C端小程序) ── */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,6 @@
|
||||
// babel-preset-taro 用于 weapp 等平台编译;H5 由 vite + @vitejs/plugin-react 处理
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', { framework: 'react', ts: true, compiler: 'vite' }],
|
||||
],
|
||||
};
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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",
|
||||
"qrcode": "^1.5.4",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
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/promo/index',
|
||||
'pages/promo/generate',
|
||||
'pages/promo/detail',
|
||||
'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: '客服中心' },
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import './app.css';
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export async function buildPromoQrDataUrl(landingUrl: string): Promise<string> {
|
||||
return QRCode.toDataURL(landingUrl, {
|
||||
width: 400,
|
||||
margin: 1,
|
||||
color: { dark: '#1f1a17', light: '#ffffff' },
|
||||
});
|
||||
}
|
||||
|
||||
export function promoConversion(scan: number, orders: number): string {
|
||||
if (scan <= 0) return '0%';
|
||||
return `${Math.round((orders / scan) * 1000) / 10}%`;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { request, saveToken } from './api';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
|
||||
/** 处理微信登录/绑定结果,返回是否已拿到 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权登录(对齐 C 端:H5 仅微信内置浏览器走 OAuth)。
|
||||
* 返回 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()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const result = await weixinSdk.login();
|
||||
if (result) 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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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,258 @@
|
||||
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: 'promo',
|
||||
icon: 'qr_code_2',
|
||||
title: '推广码',
|
||||
desc: '渠道推广链路跟踪',
|
||||
url: '/pages/promo/index',
|
||||
wide: false,
|
||||
},
|
||||
{
|
||||
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,294 @@
|
||||
.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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--hq-bg, #faf9f7);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-input-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--hq-muted);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-input {
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
height: 48px;
|
||||
min-height: 48px;
|
||||
padding: 0;
|
||||
font-size: 15px;
|
||||
line-height: 48px;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Taro H5 输入框内部垂直居中 */
|
||||
.login-input-wrap .taro-input,
|
||||
.login-input-wrap taro-input-core {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
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;
|
||||
outline: 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,250 @@
|
||||
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 { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
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('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||
return;
|
||||
}
|
||||
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,179 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Button } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import type { PromoCodeItem, PromoCodeStats } from '@dukang/shared-types';
|
||||
import HqHeader from '../../components/HqHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { useHqSession } from '../../lib/session';
|
||||
import { buildPromoQrDataUrl, promoConversion } from '../../lib/promo-qr';
|
||||
import './index.css';
|
||||
import './index.css';
|
||||
|
||||
type PromoDetail = PromoCodeItem & { stats?: PromoCodeStats };
|
||||
|
||||
export default function PromoDetailPage() {
|
||||
useHqSession();
|
||||
const router = useRouter();
|
||||
const id = router.params.id ?? '';
|
||||
const [row, setRow] = useState<PromoDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [qrUrl, setQrUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
request<PromoDetail>(`/admin/promo-codes/${id}`)
|
||||
.then(setRow)
|
||||
.catch(() => setRow(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!row?.landingUrl) {
|
||||
setQrUrl('');
|
||||
return;
|
||||
}
|
||||
void buildPromoQrDataUrl(row.landingUrl).then(setQrUrl).catch(() => setQrUrl(''));
|
||||
}, [row?.landingUrl]);
|
||||
|
||||
async function toggleStatus() {
|
||||
if (!row) return;
|
||||
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
|
||||
const label = next === 'DISABLED' ? '停用' : '启用';
|
||||
const ok = await Taro.showModal({
|
||||
title: `确认${label}`,
|
||||
content: next === 'DISABLED' ? '停用后扫码将不再追踪数据' : '启用后恢复追踪',
|
||||
});
|
||||
if (!ok.confirm) return;
|
||||
setUpdating(true);
|
||||
try {
|
||||
const updated = await request<PromoCodeItem>(`/admin/promo-codes/${id}/status`, {
|
||||
method: 'PUT',
|
||||
data: { status: next },
|
||||
});
|
||||
setRow((prev) => (prev ? { ...prev, ...updated } : updated));
|
||||
toast(`已${label}`, 'success');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
if (!row?.landingUrl) return;
|
||||
Taro.setClipboardData({ data: row.landingUrl }).then(() => toast('推广链接已复制', 'success'));
|
||||
}
|
||||
|
||||
function downloadQr() {
|
||||
if (!qrUrl) return;
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
const a = document.createElement('a');
|
||||
a.href = qrUrl;
|
||||
a.download = `promo-${row?.code ?? 'qr'}.png`;
|
||||
a.click();
|
||||
toast('已开始下载', 'success');
|
||||
return;
|
||||
}
|
||||
Taro.previewImage({ urls: [qrUrl] });
|
||||
}
|
||||
|
||||
const stats = row?.stats ?? (row ? {
|
||||
scanCount: row.scanCount,
|
||||
orderCount: row.orderCount,
|
||||
conversionRate: parseFloat(promoConversion(row.scanCount, row.orderCount)) || 0,
|
||||
} : null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View className="hq-page promo-page">
|
||||
<HqHeader title="推广码详情" back />
|
||||
<View className="promo-empty">加载中…</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return (
|
||||
<View className="hq-page promo-page">
|
||||
<HqHeader title="推广码详情" back />
|
||||
<View className="promo-empty">推广码不存在</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="hq-page promo-page">
|
||||
<HqHeader title="推广码详情" back />
|
||||
|
||||
<View className="promo-detail-card">
|
||||
<View className="promo-detail-card__qr">
|
||||
{qrUrl ? (
|
||||
<View className="promo-result__qr" style={{ backgroundImage: `url(${qrUrl})` }} />
|
||||
) : null}
|
||||
</View>
|
||||
<View className="promo-detail-card__info">
|
||||
<Text className="promo-detail-card__name">{row.name}</Text>
|
||||
<Text
|
||||
className={`promo-card__badge ${
|
||||
row.status === 'ACTIVE' ? 'promo-card__badge--active' : 'promo-card__badge--disabled'
|
||||
}`}
|
||||
>
|
||||
{row.status === 'ACTIVE' ? '运行中' : '已停用'}
|
||||
</Text>
|
||||
<Text className="promo-detail-card__meta">码值 {row.code}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{stats && (
|
||||
<View className="promo-stats-grid">
|
||||
<View className="promo-stat-card">
|
||||
<Text className="material-symbols-outlined promo-stat-card__icon">visibility</Text>
|
||||
<Text className="promo-stat-card__label">扫码UV</Text>
|
||||
<Text className="promo-stat-card__value">{stats.scanCount}</Text>
|
||||
</View>
|
||||
<View className="promo-stat-card">
|
||||
<Text className="material-symbols-outlined promo-stat-card__icon">shopping_cart</Text>
|
||||
<Text className="promo-stat-card__label">下单数</Text>
|
||||
<Text className="promo-stat-card__value">{stats.orderCount}</Text>
|
||||
</View>
|
||||
<View className="promo-stat-card">
|
||||
<Text className="material-symbols-outlined promo-stat-card__icon">trending_up</Text>
|
||||
<Text className="promo-stat-card__label">转化率</Text>
|
||||
<Text className="promo-stat-card__value">{stats.conversionRate}%</Text>
|
||||
</View>
|
||||
<View className="promo-stat-card">
|
||||
<Text className="material-symbols-outlined promo-stat-card__icon">schedule</Text>
|
||||
<Text className="promo-stat-card__label">创建时间</Text>
|
||||
<Text className="promo-stat-card__value promo-stat-card__value--sm">
|
||||
{new Date(row.createdAt).toLocaleDateString('zh-CN')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="hq-card" style="margin:0 16px">
|
||||
<Text className="hq-muted" style="display:block;font-size:12px;margin-bottom:8px">落地链接</Text>
|
||||
<Text style="display:block;font-size:13px;word-break:break-all">{row.landingUrl}</Text>
|
||||
</View>
|
||||
|
||||
<View className="promo-detail-actions">
|
||||
<Button className="hq-btn hq-btn--outline hq-btn--block" onClick={copyLink}>
|
||||
复制推广链接
|
||||
</Button>
|
||||
<Button className="hq-btn hq-btn--primary hq-btn--block" onClick={downloadQr}>
|
||||
下载二维码
|
||||
</Button>
|
||||
<Button
|
||||
className="hq-btn hq-btn--ghost hq-btn--block"
|
||||
loading={updating}
|
||||
disabled={updating}
|
||||
onClick={toggleStatus}
|
||||
>
|
||||
{row.status === 'ACTIVE' ? '停用推广码' : '启用推广码'}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Input, Button } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import type { PromoCodeItem } from '@dukang/shared-types';
|
||||
import HqHeader from '../../components/HqHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { useHqSession } from '../../lib/session';
|
||||
import { navTo } from '../../lib/session';
|
||||
import { buildPromoQrDataUrl } from '../../lib/promo-qr';
|
||||
import './index.css';
|
||||
import './index.css';
|
||||
|
||||
export default function PromoGeneratePage() {
|
||||
useHqSession();
|
||||
const [name, setName] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [created, setCreated] = useState<PromoCodeItem | null>(null);
|
||||
const [qrUrl, setQrUrl] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!created?.landingUrl) {
|
||||
setQrUrl('');
|
||||
return;
|
||||
}
|
||||
void buildPromoQrDataUrl(created.landingUrl).then(setQrUrl).catch(() => setQrUrl(''));
|
||||
}, [created]);
|
||||
|
||||
async function handleGenerate() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
toast('请填写渠道名称');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const payload: { name: string; code?: string } = { name: trimmed };
|
||||
if (code.trim()) payload.code = code.trim().toUpperCase();
|
||||
const row = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||
method: 'POST',
|
||||
data: payload,
|
||||
});
|
||||
setCreated(row);
|
||||
toast('推广码已生成', 'success');
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '生成失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyLink() {
|
||||
if (!created?.landingUrl) return;
|
||||
Taro.setClipboardData({ data: created.landingUrl }).then(() => toast('推广链接已复制', 'success'));
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="hq-page promo-page">
|
||||
<HqHeader title="生成推广码" back />
|
||||
|
||||
<View className="hq-card" style="margin:16px">
|
||||
<Text style="display:block;font-size:14px;color:var(--hq-muted);margin-bottom:16px">
|
||||
填写渠道信息,生成专属溯源推广码与落地链接。
|
||||
</Text>
|
||||
|
||||
<Text className="hq-label">渠道名称 *</Text>
|
||||
<Input
|
||||
className="hq-input"
|
||||
placeholder="如:郑州品鉴会、门店地推"
|
||||
value={name}
|
||||
onInput={(e) => setName(e.detail.value)}
|
||||
/>
|
||||
|
||||
<Text className="hq-label" style="margin-top:16px">自定义码值(选填)</Text>
|
||||
<Input
|
||||
className="hq-input"
|
||||
placeholder="留空则系统自动生成,如 DKDEMO1"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.toUpperCase())}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="hq-btn hq-btn--primary hq-btn--block"
|
||||
style="margin-top:20px"
|
||||
loading={creating}
|
||||
disabled={creating}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
生成推广码
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{created && (
|
||||
<View className="promo-result">
|
||||
<View className="promo-result__qr-wrap">
|
||||
{qrUrl ? (
|
||||
<View
|
||||
className="promo-result__qr"
|
||||
style={{ backgroundImage: `url(${qrUrl})` }}
|
||||
/>
|
||||
) : (
|
||||
<View className="promo-result__qr promo-result__qr--loading">
|
||||
<Text className="material-symbols-outlined">hourglass_top</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text className="promo-result__title">{created.name}</Text>
|
||||
<Text className="promo-result__code">码值:{created.code}</Text>
|
||||
|
||||
<View className="promo-result__actions">
|
||||
<View className="promo-card__btn promo-card__btn--outline" onClick={copyLink}>
|
||||
<Text className="material-symbols-outlined" style="font-size:18px">link</Text>
|
||||
<Text>复制链接</Text>
|
||||
</View>
|
||||
<View
|
||||
className="promo-card__btn promo-card__btn--ghost"
|
||||
onClick={() => navTo(`/pages/promo/detail?id=${created.id}`)}
|
||||
>
|
||||
<Text>查看详情</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
.promo-page {
|
||||
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.promo-hero {
|
||||
margin: 12px 16px 16px;
|
||||
padding: 20px 16px;
|
||||
border-radius: var(--hq-radius);
|
||||
background: linear-gradient(135deg, var(--hq-red) 0%, var(--hq-red-dark) 100%);
|
||||
color: #fff;
|
||||
box-shadow: var(--hq-shadow);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.promo-hero__label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.promo-hero__value {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.promo-hero__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.promo-hero__stat-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
opacity: 0.75;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.promo-hero__stat-value {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.promo-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.promo-search {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 0 12px;
|
||||
box-shadow: var(--hq-shadow);
|
||||
}
|
||||
|
||||
.promo-search .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: var(--hq-muted);
|
||||
}
|
||||
|
||||
.promo-search__input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 10px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.promo-filter {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: var(--hq-shadow);
|
||||
}
|
||||
|
||||
.promo-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 16px 12px;
|
||||
}
|
||||
|
||||
.promo-tab {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: var(--hq-muted);
|
||||
border: 1px solid var(--hq-line);
|
||||
}
|
||||
|
||||
.promo-tab--active {
|
||||
background: rgba(166, 29, 36, 0.1);
|
||||
color: var(--hq-red);
|
||||
border-color: rgba(166, 29, 36, 0.2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-card {
|
||||
margin: 0 16px 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||
border: 1px solid rgba(142, 112, 110, 0.08);
|
||||
}
|
||||
|
||||
.promo-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.promo-card__left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.promo-card__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 10px;
|
||||
background: #f4f3f1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.promo-card__icon .material-symbols-outlined {
|
||||
font-size: 24px;
|
||||
color: var(--hq-red);
|
||||
}
|
||||
|
||||
.promo-card__name {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-card__code {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--hq-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.promo-card__badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-card__badge--active {
|
||||
background: rgba(45, 106, 79, 0.12);
|
||||
color: var(--hq-green);
|
||||
}
|
||||
|
||||
.promo-card__badge--disabled {
|
||||
background: rgba(141, 112, 110, 0.12);
|
||||
color: var(--hq-muted);
|
||||
}
|
||||
|
||||
.promo-card__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
background: #faf9f7;
|
||||
border-radius: 10px;
|
||||
padding: 10px 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.promo-card__stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promo-card__stat-label {
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
color: var(--hq-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.promo-card__stat-value {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-card__stat-value--red {
|
||||
color: var(--hq-red);
|
||||
}
|
||||
|
||||
.promo-card__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.promo-card__btn {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.promo-card__btn--outline {
|
||||
border: 1px solid var(--hq-red);
|
||||
color: var(--hq-red);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.promo-card__btn--ghost {
|
||||
background: #f4f3f1;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-fab {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: calc(24px + env(safe-area-inset-bottom, 0px));
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
background: var(--hq-red);
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.35);
|
||||
}
|
||||
|
||||
.promo-fab .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.promo-empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
color: var(--hq-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.promo-result {
|
||||
margin: 16px;
|
||||
padding: 24px 16px;
|
||||
background: #fff;
|
||||
border-radius: var(--hq-radius);
|
||||
box-shadow: var(--hq-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promo-result__qr-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.promo-result__qr {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 12px;
|
||||
border: 2px solid rgba(166, 29, 36, 0.1);
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.promo-result__qr--loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f4f3f1;
|
||||
}
|
||||
|
||||
.promo-result__title {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-result__code {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--hq-muted);
|
||||
margin-top: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.promo-result__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.promo-detail-card {
|
||||
margin: 12px 16px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: var(--hq-radius);
|
||||
box-shadow: var(--hq-shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.promo-detail-card__info {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.promo-detail-card__name {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-detail-card__meta {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--hq-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.promo-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin: 0 16px 16px;
|
||||
}
|
||||
|
||||
.promo-stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||
}
|
||||
|
||||
.promo-stat-card__icon {
|
||||
font-size: 20px;
|
||||
color: var(--hq-red);
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
}
|
||||
|
||||
.promo-stat-card__label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--hq-muted);
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
|
||||
.promo-stat-card__value {
|
||||
display: block;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--hq-red);
|
||||
}
|
||||
|
||||
.promo-stat-card__value--sm {
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.promo-detail-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 16px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.hq-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--hq-muted);
|
||||
margin-bottom: 8px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Input } from '@tarojs/components';
|
||||
import { useDidShow } from '@tarojs/taro';
|
||||
import type { PromoCodeItem, PromoCodeStatus } from '@dukang/shared-types';
|
||||
import HqHeader from '../../components/HqHeader';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { useHqSession, navTo } from '../../lib/session';
|
||||
import { promoConversion } from '../../lib/promo-qr';
|
||||
import './index.css';
|
||||
|
||||
type StatusFilter = 'ALL' | PromoCodeStatus;
|
||||
|
||||
const STATUS_TABS: { key: StatusFilter; label: string }[] = [
|
||||
{ key: 'ALL', label: '全部' },
|
||||
{ key: 'ACTIVE', label: '运行中' },
|
||||
{ key: 'DISABLED', label: '已停用' },
|
||||
];
|
||||
|
||||
export default function PromoListPage() {
|
||||
useHqSession();
|
||||
const [allRows, setAllRows] = useState<PromoCodeItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [status, setStatus] = useState<StatusFilter>('ALL');
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
const qs = new URLSearchParams({ pageSize: '100' });
|
||||
if (status !== 'ALL') qs.set('status', status);
|
||||
request<Paginated<PromoCodeItem>>(`/admin/promo-codes?${qs}`)
|
||||
.then((d) => setAllRows(d.items))
|
||||
.catch(() => setAllRows([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [status]);
|
||||
|
||||
useEffect(load, [load]);
|
||||
useDidShow(load);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = keyword.trim().toLowerCase();
|
||||
if (!q) return allRows;
|
||||
return allRows.filter(
|
||||
(r) => r.name.toLowerCase().includes(q) || r.code.toLowerCase().includes(q),
|
||||
);
|
||||
}, [allRows, keyword]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const scanTotal = rows.reduce((s, r) => s + r.scanCount, 0);
|
||||
const orderTotal = rows.reduce((s, r) => s + r.orderCount, 0);
|
||||
return { scanTotal, orderTotal, conversion: promoConversion(scanTotal, orderTotal) };
|
||||
}, [rows]);
|
||||
|
||||
return (
|
||||
<View className="hq-page promo-page">
|
||||
<HqHeader title="推广码管理" back />
|
||||
|
||||
<View className="promo-hero">
|
||||
<Text className="promo-hero__label">渠道推广汇总</Text>
|
||||
<Text className="promo-hero__value">{rows.length} 个推广码</Text>
|
||||
<View className="promo-hero__grid">
|
||||
<View>
|
||||
<Text className="promo-hero__stat-label">总扫码</Text>
|
||||
<Text className="promo-hero__stat-value">{summary.scanTotal}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="promo-hero__stat-label">总订单</Text>
|
||||
<Text className="promo-hero__stat-value">{summary.orderTotal}</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="promo-hero__stat-label">转化率</Text>
|
||||
<Text className="promo-hero__stat-value">{summary.conversion}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="promo-toolbar">
|
||||
<View className="promo-search">
|
||||
<Text className="material-symbols-outlined">search</Text>
|
||||
<Input
|
||||
className="promo-search__input"
|
||||
placeholder="搜索渠道名称或码值"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
confirmType="search"
|
||||
onConfirm={load}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="promo-tabs">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<View
|
||||
key={tab.key}
|
||||
className={`promo-tab${status === tab.key ? ' promo-tab--active' : ''}`}
|
||||
onClick={() => setStatus(tab.key)}
|
||||
>
|
||||
<Text>{tab.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{loading && <View className="promo-empty">加载中…</View>}
|
||||
{!loading && rows.length === 0 && <View className="promo-empty">暂无推广码,点击下方生成</View>}
|
||||
|
||||
{rows.map((row) => (
|
||||
<View key={row.id} className="promo-card">
|
||||
<View className="promo-card__head">
|
||||
<View className="promo-card__left">
|
||||
<View className="promo-card__icon">
|
||||
<Text className="material-symbols-outlined">qr_code_2</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text className="promo-card__name">{row.name}</Text>
|
||||
<Text className="promo-card__code">ID: {row.code}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text
|
||||
className={`promo-card__badge ${
|
||||
row.status === 'ACTIVE' ? 'promo-card__badge--active' : 'promo-card__badge--disabled'
|
||||
}`}
|
||||
>
|
||||
{row.status === 'ACTIVE' ? '运行中' : '已停用'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View className="promo-card__stats">
|
||||
<View className="promo-card__stat">
|
||||
<Text className="promo-card__stat-label">扫码UV</Text>
|
||||
<Text className="promo-card__stat-value">{row.scanCount}</Text>
|
||||
</View>
|
||||
<View className="promo-card__stat">
|
||||
<Text className="promo-card__stat-label">订单数</Text>
|
||||
<Text className="promo-card__stat-value">{row.orderCount}</Text>
|
||||
</View>
|
||||
<View className="promo-card__stat">
|
||||
<Text className="promo-card__stat-label">转化率</Text>
|
||||
<Text className="promo-card__stat-value promo-card__stat-value--red">
|
||||
{promoConversion(row.scanCount, row.orderCount)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="promo-card__actions">
|
||||
<View
|
||||
className="promo-card__btn promo-card__btn--outline"
|
||||
onClick={() => navTo(`/pages/promo/detail?id=${row.id}`)}
|
||||
>
|
||||
<Text className="material-symbols-outlined" style="font-size:18px">visibility</Text>
|
||||
<Text>查看详情</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
|
||||
<View className="promo-fab" onClick={() => navTo('/pages/promo/generate')}>
|
||||
<Text className="material-symbols-outlined">add_circle</Text>
|
||||
<Text>生成新推广码</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Vendored
+21
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# runxian.top 裸域名申请 Let's Encrypt 并启用 HTTPS
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
DOMAIN=runxian.top
|
||||
EMAIL="${CERTBOT_EMAIL:-admin@runxian.top}"
|
||||
|
||||
echo "==> 检查 DNS: $DOMAIN"
|
||||
ip="$(dig +short "$DOMAIN" A @223.5.5.5 | tail -1)"
|
||||
if [[ -z "$ip" ]]; then
|
||||
echo "ERROR: $DOMAIN 无 A 记录,请先在 DNS 添加指向本机公网 IP(当前服务器: $(curl -sf ifconfig.me || echo unknown))"
|
||||
exit 1
|
||||
fi
|
||||
echo " $DOMAIN -> $ip"
|
||||
|
||||
mkdir -p /var/www/certbot /var/log/nginx/dukang
|
||||
|
||||
# 若尚未有证书,先部署仅 HTTP 的配置以便 ACME 校验
|
||||
if [[ ! -f /etc/letsencrypt/live/runxian.top/fullchain.pem ]]; then
|
||||
echo "==> 临时 HTTP 配置(用于 ACME)..."
|
||||
cat > /etc/nginx/conf.d/dukang-runxian-apex.conf <<'EOF'
|
||||
server {
|
||||
listen 80;
|
||||
server_name runxian.top;
|
||||
root /opt/dukang-haoke/public;
|
||||
index index.html;
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type "text/plain";
|
||||
}
|
||||
location ~ ^/MP_verify_.*\.txt$ {
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
fi
|
||||
|
||||
echo "==> 申请证书..."
|
||||
certbot certonly --webroot -w /var/www/certbot \
|
||||
--cert-name runxian.top \
|
||||
-d runxian.top \
|
||||
--non-interactive --agree-tos -m "$EMAIL"
|
||||
|
||||
echo "==> 切换 HTTPS 配置..."
|
||||
install -m 644 "$SCRIPT_DIR/nginx-runxian-apex.conf" /etc/nginx/conf.d/dukang-runxian-apex.conf
|
||||
nginx -t
|
||||
systemctl reload nginx
|
||||
|
||||
echo "==> 验证..."
|
||||
code="$(curl -sf -o /dev/null -w '%{http_code}' "https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt" || echo fail)"
|
||||
echo " https://$DOMAIN/MP_verify_ayPJ4CQqbUcec3jX.txt -> $code"
|
||||
echo "==> runxian.top HTTPS 已启用"
|
||||
@@ -0,0 +1,53 @@
|
||||
# runxian.top 裸域名 — 静态公共资源(微信域名校验等)
|
||||
# HTTP: 保留 MP_verify + ACME;其余跳转 HTTPS
|
||||
# HTTPS: deploy/enable-runxian-apex-ssl.sh 申请证书后启用
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name runxian.top;
|
||||
|
||||
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
|
||||
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
|
||||
|
||||
root /opt/dukang-haoke/public;
|
||||
index index.html;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /var/www/certbot;
|
||||
default_type "text/plain";
|
||||
}
|
||||
|
||||
location ~ ^/MP_verify_.*\.txt$ {
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name runxian.top;
|
||||
|
||||
access_log /var/log/nginx/dukang/runxian-apex.access.log main;
|
||||
error_log /var/log/nginx/dukang/runxian-apex.error.log warn;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/runxian.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/runxian.top/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
root /opt/dukang-haoke/public;
|
||||
index index.html;
|
||||
|
||||
location ~ ^/MP_verify_.*\.txt$ {
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
}
|
||||
Generated
+1497
File diff suppressed because it is too large
Load Diff
+4
-1
@@ -14,6 +14,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": "eslint .",
|
||||
"typecheck": "pnpm -r exec tsc --noEmit",
|
||||
@@ -27,7 +29,8 @@
|
||||
"sync:stitch": "node scripts/sync-stitch.mjs",
|
||||
"smoke": "node scripts/smoke-v3.mjs",
|
||||
"smoke:v3": "node scripts/smoke-v3.mjs",
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs"
|
||||
"smoke:prev1": "node scripts/smoke-prev1.mjs",
|
||||
"smoke:partner-auth": "node scripts/test-partner-auth.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface AppConfig {
|
||||
mockPay: boolean;
|
||||
mockDeliveryAuto: boolean;
|
||||
autoApproveStore: boolean;
|
||||
/** preV1 Mock 微信授权登录:点击授权按钮走 Mock 流程直接登录,不接真实微信 */
|
||||
mockWechat: boolean;
|
||||
wechatAuthEnabled: boolean;
|
||||
wechatPayEnabled: boolean;
|
||||
wxAppId: string;
|
||||
@@ -32,6 +34,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 ?? '',
|
||||
|
||||
@@ -81,3 +81,5 @@ export const CLIENT_APP_ACTOR_MAP: Record<ClientApp, ActorType> = {
|
||||
export const REDEEM_DIRECT_LIMIT_POLICY = 'TOTAL_ACTIVE_BALANCE';
|
||||
export const REDEEM_DOCUMENT_LIMIT_POLICY = 'DOCUMENT_BALANCE';
|
||||
export const REDEEM_TOKEN_TTL_SECONDS = 300;
|
||||
/** 核销成功后供用户端轮询结果,略长于 token TTL */
|
||||
export const REDEEM_RESULT_TTL_SECONDS = 600;
|
||||
|
||||
@@ -10,3 +10,6 @@ export * from './settlement';
|
||||
export * from './ops';
|
||||
export * from './ticket';
|
||||
export * from './user-log';
|
||||
export * from './store-log';
|
||||
export * from './partner-log';
|
||||
export * from './promo';
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
| 'settlement';
|
||||
|
||||
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
|
||||
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
|
||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||
store_ops: ['partner_store_create', 'partner_store_status_change'],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
||||
};
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
{ value: 'settlement', label: '结算' },
|
||||
];
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
settlement: '结算',
|
||||
};
|
||||
|
||||
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
|
||||
for (const [category, events] of Object.entries(PARTNER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[PartnerLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForPartnerLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(PARTNER_LOG_EVENT_CATEGORIES[category as PartnerLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface PartnerLogRowDto {
|
||||
id: string;
|
||||
partnerId: string;
|
||||
partnerAccountId: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
companyName: string | null;
|
||||
category: PartnerLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
export type StoreLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'redeem'
|
||||
| 'payout'
|
||||
| 'store_ops';
|
||||
|
||||
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
|
||||
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
|
||||
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
|
||||
redeem: ['store_redeem_preview', 'store_redeem_confirm'],
|
||||
payout: ['store_payout_created', 'store_payout_paid'],
|
||||
store_ops: ['store_status_change'],
|
||||
};
|
||||
|
||||
export const STORE_LOG_CATEGORY_OPTIONS: Array<{ value: StoreLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '授权' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'payout', label: '提现/打款' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
];
|
||||
|
||||
export const STORE_LOG_CATEGORY_LABELS: Record<StoreLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '授权',
|
||||
redeem: '核销',
|
||||
payout: '提现/打款',
|
||||
store_ops: '门店操作',
|
||||
};
|
||||
|
||||
export function resolveStoreLogCategory(eventName: string): StoreLogCategory | null {
|
||||
for (const [category, events] of Object.entries(STORE_LOG_EVENT_CATEGORIES) as Array<
|
||||
[StoreLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function eventNamesForStoreLogCategory(category: string): string[] | undefined {
|
||||
if (!category) return undefined;
|
||||
return [...(STORE_LOG_EVENT_CATEGORIES[category as StoreLogCategory] ?? [])];
|
||||
}
|
||||
|
||||
export interface StoreLogRowDto {
|
||||
id: string;
|
||||
source: 'analytics' | 'redeem_record' | 'store_payout';
|
||||
storeId: string;
|
||||
storeAccountId: string | null;
|
||||
storeName: string | null;
|
||||
accountName: string | null;
|
||||
accountPhone: string | null;
|
||||
category: StoreLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -374,11 +374,10 @@ button, input, select, textarea { font: inherit; }
|
||||
border-radius: 8px;
|
||||
color: var(--color-subtle-gray);
|
||||
text-decoration: none;
|
||||
transition: transform 0.1s;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.app-tabbar-item.active { color: var(--color-heritage-red); }
|
||||
.app-tabbar-item:active { transform: scale(0.9); }
|
||||
|
||||
.app-tabbar-icon { font-size: 24px; line-height: 1; }
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { WechatLoginPlatform, WechatLoginResult } from '@dukang/shared-type
|
||||
import { getRuntimePlatform, isWechatBrowser } from './env';
|
||||
import type { WeixinSdkConfig } from './types';
|
||||
|
||||
/** 非微信内置浏览器时提示(与 C 端 LoginPage 文案一致) */
|
||||
export const WECHAT_INAPP_REQUIRED_MSG = '请在微信内打开以使用微信一键授权';
|
||||
|
||||
const OAUTH_STATE_KEY = 'dukang_wx_oauth_state';
|
||||
|
||||
function randomState() {
|
||||
@@ -43,7 +46,7 @@ export async function getWechatOAuthUrl(
|
||||
/** 发起微信 OAuth 登录(H5 公众号内跳转授权) */
|
||||
export async function startWechatOAuthLogin(config: WeixinSdkConfig, redirectUri?: string): Promise<void> {
|
||||
if (!isWechatBrowser()) {
|
||||
throw new Error('请在微信内打开');
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
const uri = redirectUri ?? window.location.href.split('#')[0];
|
||||
const url = await getWechatOAuthUrl(config, uri);
|
||||
@@ -142,5 +145,5 @@ export async function wechatLogin(config: WeixinSdkConfig): Promise<WechatLoginR
|
||||
await startWechatOAuthLogin(config);
|
||||
return;
|
||||
}
|
||||
throw new Error('请在微信内打开以使用微信登录');
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
bindWechatPhone,
|
||||
getWechatPhoneNumber,
|
||||
wechatLogin,
|
||||
WECHAT_INAPP_REQUIRED_MSG,
|
||||
} from './auth';
|
||||
export type { WeixinSdkConfig, WxApi, MiniProgramWx } from './types';
|
||||
export { DEFAULT_JS_API_LIST } from './types';
|
||||
|
||||
+29
-2
@@ -59,6 +59,33 @@
|
||||
| P1 | 合伙人中心 | c31c874b | `/center` | partner/04_* | ui-aligned |
|
||||
| P1 | 合伙人待确认账单页 | f9b208b8 | `/center/bills` | partner/14_* | ui-aligned |
|
||||
|
||||
## 跳过(preV1 / HQ)
|
||||
## 总部管理端 mini-hq(Taro,先 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/promo/index` | `GET /admin/promo-codes` | done |
|
||||
| 推广码生成 | `pages/promo/generate` | `POST /admin/promo-codes` | done |
|
||||
| 推广码详情 | `pages/promo/detail` | `GET /admin/promo-codes/:id`、`PUT .../status` | done |
|
||||
| 补发 / 退款 | `pages/refund/index` | `/admin/orders?status=REFUNDING`、`/admin/orders/:id/status` | done |
|
||||
|
||||
> C 端落地:`h5-user` 解析 `?promo=` 并调用 `POST /promo/touch`;下单时 `trade.createOrder` 绑定 `user_promo_attribution`。
|
||||
|
||||
> 微信授权登录:H5 须在微信内置浏览器内 OAuth;本地 Mock 联调见 `MOCK_WECHAT`。
|
||||
|
||||
## 跳过(preV1 归档)
|
||||
|
||||
拦截配送等剩余归档 screen 保留在 `pages/_archive/`,按需再提升。
|
||||
|
||||
Generated
+5902
-29
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,14 @@ packages:
|
||||
allowBuilds:
|
||||
'@alicloud/openapi-core': set this to true or false
|
||||
'@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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
+10
-1
@@ -193,7 +193,16 @@ async function main() {
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
console.log('11. Partner bill');
|
||||
console.log('11. Partner phone gate');
|
||||
const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999' }),
|
||||
});
|
||||
if (!unknownPartner.includes('未找到合伙人账号')) {
|
||||
throw new Error(`Expected partner phone gate, got: ${unknownPartner}`);
|
||||
}
|
||||
|
||||
console.log('12. Partner bill');
|
||||
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
|
||||
const partnerId = partners.items?.[0]?.id;
|
||||
if (partnerId) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-redis';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function fetchJson(path, options = {}) {
|
||||
const res = await fetch(`${API}${path}`, options);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function readSmsCodeFromRedis(phone, scene) {
|
||||
const key = `dukang:sms:${scene}:${phone}`;
|
||||
try {
|
||||
const code = execSync(`docker exec ${REDIS_CONTAINER} redis-cli GET "${key}"`, {
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (code && code !== '(nil)') return code;
|
||||
} catch {
|
||||
/* docker/redis unavailable */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function loadClientConfig() {
|
||||
const json = await fetchJson('/common/client-config');
|
||||
if (json.code !== 0) throw new Error(`client-config: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function resolveSmsCode(phone, scene) {
|
||||
const cfg = await loadClientConfig();
|
||||
if (cfg.mockSms) return DEFAULT_MOCK_CODE;
|
||||
|
||||
const fromRedis = readSmsCodeFromRedis(phone, scene);
|
||||
if (fromRedis) return fromRedis;
|
||||
|
||||
throw new Error(
|
||||
`SMS code not found for ${phone} (${scene}); enable MOCK_SMS or ensure Redis is reachable`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendSmsOnce(clientApp, sendPath, phone, scene) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
};
|
||||
const res = await fetch(`${API}${sendPath}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ phone, scene }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function sendSmsWithCooldown(clientApp, sendPath, phone, scene) {
|
||||
let json = await sendSmsOnce(clientApp, sendPath, phone, scene);
|
||||
if (json.code !== 0 && String(json.message).includes('过于频繁')) {
|
||||
await sleep(65_000);
|
||||
json = await sendSmsOnce(clientApp, sendPath, phone, scene);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const existing = readSmsCodeFromRedis(phone, scene);
|
||||
if (existing) return { reusedCode: true };
|
||||
throw new Error(`${sendPath}: ${json.message}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
export async function loginWithSms(clientApp, phone, scene, loginPath, sendPath) {
|
||||
await sendSmsWithCooldown(clientApp, sendPath, phone, scene);
|
||||
const code = await resolveSmsCode(phone, scene);
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
};
|
||||
const res = await fetch(`${API}${loginPath}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${loginPath}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 合伙人登录 / 手机号校验专项冒烟(需 API 已启动)
|
||||
* 用法: node scripts/test-partner-auth.mjs
|
||||
*/
|
||||
import { loginWithSms } from './sms-test-helper.mjs';
|
||||
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
|
||||
async function req(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||
return json.data;
|
||||
}
|
||||
|
||||
async function expectFail(clientApp, path, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||
};
|
||||
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||
const json = await res.json();
|
||||
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||
return json.message;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('1. PARTNER phone/check unknown');
|
||||
const unknownCheck = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999' }),
|
||||
});
|
||||
if (!unknownCheck.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownCheck}`);
|
||||
|
||||
console.log('2. PARTNER sms/send unknown');
|
||||
const unknownSms = await expectFail('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: '13899999999', scene: 'PARTNER_LOGIN' }),
|
||||
});
|
||||
if (!unknownSms.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownSms}`);
|
||||
|
||||
console.log('3. PARTNER sms/login bound phone');
|
||||
const login = await loginWithSms(
|
||||
'PARTNER_H5',
|
||||
'13700000001',
|
||||
'PARTNER_LOGIN',
|
||||
'/partner/auth/login/sms',
|
||||
'/partner/auth/sms/send',
|
||||
);
|
||||
if (!login.accessToken) throw new Error('login missing token');
|
||||
|
||||
console.log('4. Admin partner logs list');
|
||||
const admin = await req('HQ_WEB', '/admin/auth/login/password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
loginName: process.env.SUPER_ADMIN_LOGIN ?? 'admin',
|
||||
password: process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!',
|
||||
}),
|
||||
});
|
||||
const logs = await req('HQ_WEB', '/admin/logs/partners?page=1&pageSize=10&category=login', {
|
||||
token: admin.accessToken,
|
||||
});
|
||||
if (!Array.isArray(logs.items)) throw new Error('partner logs missing items');
|
||||
const hasLogin = logs.items.some((r) => r.eventName === 'partner_sms_login' || r.eventName === 'partner_login_success');
|
||||
if (!hasLogin) throw new Error('partner login log not found');
|
||||
|
||||
console.log('\n✅ partner-auth tests passed');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -20,11 +20,18 @@ ALIYUN_SMS_ACCESS_KEY_SECRET=
|
||||
MOCK_PAY=true
|
||||
MOCK_DELIVERY_AUTO=true
|
||||
AUTO_APPROVE_STORE=true
|
||||
# 微信 OAuth Mock(preV1 本地联调):须在微信内置浏览器内打开 H5,走 OAuth 回跳带 mock code;
|
||||
# 非微信浏览器不会发起 /login/wechat 请求。生产请 MOCK_WECHAT=false 且 WECHAT_AUTH_ENABLED=true。
|
||||
MOCK_WECHAT=true
|
||||
|
||||
# C 端 H5 落地页(推广码二维码链接前缀)
|
||||
USER_H5_URL=http://localhost:5173
|
||||
|
||||
# 反向代理后提取真实客户端 IP(下单 IP 定位)
|
||||
TRUST_PROXY=true
|
||||
|
||||
# 微信SDK(WECHAT_AUTH_ENABLED=true 时生效)
|
||||
# 微信 SDK(生产:WECHAT_AUTH_ENABLED=true,配置 WX_APP_ID / WX_APP_SECRET)
|
||||
# OAuth 授权页由 /common/wechat/oauth-url 生成;C/合伙人/总部 H5 均须在微信内置浏览器内授权。
|
||||
WX_APP_ID=
|
||||
WX_APP_SECRET=
|
||||
WECHAT_AUTH_ENABLED=false
|
||||
|
||||
@@ -603,4 +603,21 @@ CREATE TABLE log_user_analytics (
|
||||
KEY idx_log_user_analytics_ref (ref_type, ref_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户行为埋点日志';
|
||||
|
||||
DROP TABLE IF EXISTS log_store_analytics;
|
||||
CREATE TABLE log_store_analytics (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
store_account_id BIGINT UNSIGNED DEFAULT NULL COMMENT '门店账号ID,系统/HQ操作可为NULL',
|
||||
store_id BIGINT UNSIGNED NOT NULL COMMENT '门店ID',
|
||||
event_name VARCHAR(64) NOT NULL COMMENT '门店行为事件名',
|
||||
client_app VARCHAR(32) DEFAULT NULL COMMENT 'SHOP_H5|HQ_WEB|PARTNER_H5',
|
||||
ref_type VARCHAR(32) DEFAULT NULL COMMENT 'REDEEM_RECORD|STORE_PAYOUT|...',
|
||||
ref_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
extra_json JSON DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_log_store_analytics_store_created (store_id, created_at),
|
||||
KEY idx_log_store_analytics_account_created (store_account_id, created_at),
|
||||
KEY idx_log_store_analytics_event_created (event_name, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店商户行为日志';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
@@ -872,3 +872,37 @@ model LogUserAnalytics {
|
||||
@@index([refType, refId])
|
||||
@@map("log_user_analytics")
|
||||
}
|
||||
|
||||
model LogStoreAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeAccountId BigInt? @map("store_account_id") @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
clientApp ClientApp? @map("client_app")
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([storeId, createdAt])
|
||||
@@index([storeAccountId, createdAt])
|
||||
@@index([eventName, createdAt])
|
||||
@@map("log_store_analytics")
|
||||
}
|
||||
|
||||
model LogPartnerAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
clientApp ClientApp? @map("client_app")
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
refId BigInt? @map("ref_id") @db.UnsignedBigInt
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([partnerId, createdAt])
|
||||
@@index([partnerAccountId, createdAt])
|
||||
@@index([eventName, createdAt])
|
||||
@@map("log_partner_analytics")
|
||||
}
|
||||
|
||||
@@ -228,6 +228,22 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.commonPromoCode.create({
|
||||
data: {
|
||||
code: 'DKHQ001',
|
||||
name: '总部品鉴会',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.commonPromoCode.create({
|
||||
data: {
|
||||
code: 'DKDEMO1',
|
||||
name: '郑州品鉴会演示',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
@@ -9,6 +9,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 { TencentLbsProvider } from './map/tencent-lbs.provider';
|
||||
@@ -47,16 +48,24 @@ import type { ISmsProvider } from './sms/sms.interface';
|
||||
},
|
||||
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,
|
||||
|
||||
@@ -39,6 +39,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;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,9 @@ export type WechatPayNotifyResult = {
|
||||
export interface IWechatProvider {
|
||||
isEnabled(): boolean;
|
||||
|
||||
/** 是否为 preV1 Mock 实现(登录时可回落到演示账号) */
|
||||
isMock(): boolean;
|
||||
|
||||
/** 微信支付是否已配置(商户号 + 证书) */
|
||||
isPayEnabled(): boolean;
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { createHash } from 'crypto';
|
||||
import { Injectable, NotImplementedException } from '@nestjs/common';
|
||||
import type {
|
||||
IWechatProvider,
|
||||
WechatCodeSession,
|
||||
WechatOAuthSession,
|
||||
} from './wechat.interface';
|
||||
|
||||
/**
|
||||
* preV1 Mock 微信 Provider。
|
||||
*
|
||||
* 目的:让「微信授权登录」按钮在不接真实微信的情况下走通。前端仍按真实 OAuth 流程
|
||||
* (跳转 oauth-url → 回调携带 code),Mock 端将授权 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 fetchOAuthUserInfo(accessToken: string, openId: string) {
|
||||
return {
|
||||
openId,
|
||||
nickname: 'Mock微信用户',
|
||||
headImgUrl: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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,11 @@
|
||||
import { Module, forwardRef } 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: [forwardRef(() => IamModule)],
|
||||
controllers: [AnalyticsController],
|
||||
controllers: [AnalyticsController, PromoController],
|
||||
providers: [AnalyticsService],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
|
||||
@@ -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';
|
||||
|
||||
export type TrackEventInput = {
|
||||
eventName: string;
|
||||
@@ -10,6 +11,16 @@ export type TrackEventInput = {
|
||||
extraJson?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TrackStoreEventInput = TrackEventInput & {
|
||||
storeAccountId?: bigint;
|
||||
storeId: bigint;
|
||||
};
|
||||
|
||||
export type TrackPartnerEventInput = TrackEventInput & {
|
||||
partnerAccountId?: bigint;
|
||||
partnerId: bigint;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -42,6 +53,34 @@ export class AnalyticsService {
|
||||
void this.trackOne(userId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
async trackStoreOne(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||
await this.prisma.logStoreAnalytics.create({
|
||||
data: this.toStoreRow(storeAccountId, clientApp, event),
|
||||
});
|
||||
}
|
||||
|
||||
trackStoreOneSafe(storeAccountId: bigint | undefined, clientApp: ClientApp | string, event: TrackStoreEventInput) {
|
||||
void this.trackStoreOne(storeAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
async trackPartnerOne(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
await this.prisma.logPartnerAnalytics.create({
|
||||
data: this.toPartnerRow(partnerAccountId, clientApp, event),
|
||||
});
|
||||
}
|
||||
|
||||
trackPartnerOneSafe(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
|
||||
return {
|
||||
userId,
|
||||
@@ -53,4 +92,75 @@ export class AnalyticsService {
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
private toStoreRow(
|
||||
storeAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackStoreEventInput,
|
||||
) {
|
||||
return {
|
||||
storeAccountId,
|
||||
storeId: event.storeId,
|
||||
eventName: event.eventName,
|
||||
clientApp: clientApp as ClientApp,
|
||||
refType: event.refType,
|
||||
refId: event.refId,
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
private toPartnerRow(
|
||||
partnerAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
return {
|
||||
partnerAccountId,
|
||||
partnerId: event.partnerId,
|
||||
eventName: event.eventName,
|
||||
clientApp: clientApp as ClientApp,
|
||||
refType: event.refType,
|
||||
refId: event.refId,
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
/** 扫码归因:始终累加 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;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export class ClientConfigController {
|
||||
mockPay: cfg.mockPay,
|
||||
wechatPayEnabled: cfg.wechatPayEnabled,
|
||||
mockSms: cfg.mockSms,
|
||||
mockWechat: cfg.mockWechat,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user