Compare commits
33 Commits
v3.3
...
85f2c156b0
| Author | SHA1 | Date | |
|---|---|---|---|
| 85f2c156b0 | |||
| addfcfb0b1 | |||
| b392c28787 | |||
| b534a569f8 | |||
| b0f565103f | |||
| 5e33dd9847 | |||
| 9a550cbaaf | |||
| 823e439101 | |||
| 6c299f1a1d | |||
| 7c9827875f | |||
| ae7c63c08d | |||
| 270cebc79a | |||
| ba21cc89c6 | |||
| 56dfffd126 | |||
| bdf80e577b | |||
| eb96b36d0b | |||
| 36bec94639 | |||
| c11c7647b9 | |||
| 83ed90ef67 | |||
| 73dbf6effb | |||
| b8adfe98a3 | |||
| 6c6fb10490 | |||
| 935ab0d1d4 | |||
| 350a086a73 | |||
| c5f526d51d | |||
| e1f5130c18 | |||
| bc0b0cafd6 | |||
| ade68972a9 | |||
| 47b39e3ba0 | |||
| cf73bbae23 | |||
| d1b207422c | |||
| 4a29d8297c | |||
| 792b543ba8 |
@@ -15,3 +15,4 @@ coverage/
|
||||
server/dukang-api/prisma/migrations/
|
||||
debug_v3.xlsx
|
||||
~$debug_v3.xlsx
|
||||
deploy/auto-release.env
|
||||
|
||||
@@ -20,6 +20,7 @@ import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import StoreCategoriesPage from './pages/StoreCategoriesPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
|
||||
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
|
||||
@@ -31,6 +32,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import InvoicesPage from './pages/InvoicesPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import HqLogsPage from './pages/HqLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
@@ -68,6 +70,7 @@ export default function App() {
|
||||
<Route path="/products" element={<ProductsPage />} />
|
||||
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/store-categories" element={<StoreCategoriesPage />} />
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
@@ -89,6 +92,7 @@ export default function App() {
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/invoices" element={<InvoicesPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/stores" element={<StoreLogsPage />} />
|
||||
<Route path="/logs/partners" element={<PartnerLogsPage />} />
|
||||
|
||||
@@ -92,9 +92,12 @@ function commissionSumError(
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '门店',
|
||||
children: [
|
||||
{ key: '/stores', label: '门店列表' },
|
||||
{ key: '/store-categories', label: '门店分类' },
|
||||
{ key: '/store-accounts', label: '门店账户' },
|
||||
{ key: '/store-media', label: '门店资源' },
|
||||
],
|
||||
@@ -91,6 +92,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
],
|
||||
},
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
|
||||
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
|
||||
{
|
||||
key: 'logs-group',
|
||||
|
||||
@@ -62,9 +62,11 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
CONFIRMED: '已确认',
|
||||
PAID: '已结算',
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
export const MEDIA_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
@@ -27,6 +27,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'STORE_AUDIT', label: '门店审核' },
|
||||
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
|
||||
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
|
||||
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
|
||||
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
|
||||
{ value: 'STORE_CATEGORY_DELETE', label: '删除门店分类' },
|
||||
{ value: 'STORE_CATEGORY_ENSURE', label: '初始化默认门店分类' },
|
||||
{ value: 'PRODUCT_CREATE', label: '新增商品' },
|
||||
{ value: 'PRODUCT_UPDATE', label: '编辑商品' },
|
||||
{ value: 'PRODUCT_DELETE', label: '删除商品' },
|
||||
@@ -36,9 +40,16 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||
{ value: 'STORE_BILL_BATCH_CONFIRM', label: '批量门店对账单打款' },
|
||||
{ value: 'PARTNER_BILL_GENERATE', label: '生成合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
|
||||
{ value: 'PARTNER_BILL_MARK_PAID', label: '合伙人账单结算' },
|
||||
{ value: 'PARTNER_BILL_BATCH_MARK_PAID', label: '批量合伙人账单结算' },
|
||||
{ value: 'WINERY_BILL_CONFIRM', label: '酒厂对账单确认打款' },
|
||||
{ value: 'WINERY_BILL_BATCH_CONFIRM', label: '批量酒厂对账单打款' },
|
||||
] as const;
|
||||
|
||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
|
||||
|
||||
@@ -98,9 +98,12 @@ function flattenDistrictCodes(values: string[] | string[][] | undefined): string
|
||||
}
|
||||
|
||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
// API 可能把 Prisma Decimal 序列化为字符串;`"0.05" + 1e-9` 会变成字符串拼接导致误判超限
|
||||
const max = Number(maxRate);
|
||||
const sum = Number(orderPercent) / 100 + Number(redeemPercent) / 100;
|
||||
if (!Number.isFinite(max) || !Number.isFinite(sum)) return '佣金比例无效';
|
||||
if (sum > max + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -149,7 +152,7 @@ export default function CityPartnersPage() {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
|
||||
setMaxCommissionRate(Number(d.maxPartnerCommissionRate ?? 0.05));
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
@@ -230,7 +233,7 @@ export default function CityPartnersPage() {
|
||||
|
||||
async function onCreateCityChange(cityId: string) {
|
||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
||||
setCreateMaxRate(Number(cityRes.maxPartnerCommissionRate ?? 0.05));
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceKind,
|
||||
type InvoiceStatus,
|
||||
type InvoiceTitleType,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
invoiceNo: string;
|
||||
orderNo?: string;
|
||||
titleType: InvoiceTitleType;
|
||||
invoiceKind: InvoiceKind;
|
||||
titleName: string;
|
||||
status: InvoiceStatus;
|
||||
overdue?: boolean;
|
||||
createdAt: string;
|
||||
fileUrl?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
payAmount?: string;
|
||||
userPhone?: string;
|
||||
remark?: string;
|
||||
};
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/invoices',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
setDetail(await request(`/admin/invoices/${id}`));
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function issueWithFile(file: File) {
|
||||
if (!detail) return false;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
|
||||
await request(`/admin/invoices/${detail.id}/issue`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ fileUrl: uploaded.url }),
|
||||
});
|
||||
message.success('已开票回传');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '开票失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function reject() {
|
||||
if (!detail) return;
|
||||
await request(`/admin/invoices/${detail.id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '驳回' }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||
{
|
||||
title: '抬头',
|
||||
width: 100,
|
||||
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
|
||||
},
|
||||
{
|
||||
title: '票种',
|
||||
width: 120,
|
||||
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
|
||||
},
|
||||
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_, r) => (
|
||||
<>
|
||||
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
|
||||
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
|
||||
</Tag>
|
||||
{r.overdue ? <Tag color="red">超时</Tag> : null}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>发票管理</Typography.Title>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'PENDING', label: '待开票' },
|
||||
{ value: 'ISSUED', label: '已开票' },
|
||||
{ value: 'REJECTED', label: '已驳回' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1000 }}
|
||||
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="发票详情"
|
||||
width={520}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Upload
|
||||
accept="image/*,.pdf"
|
||||
showUploadList={false}
|
||||
beforeUpload={(file) => {
|
||||
void issueWithFile(file);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
|
||||
上传并开票
|
||||
</Button>
|
||||
</Upload>
|
||||
<Button danger onClick={() => void reject()}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头类型">
|
||||
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发票类型">
|
||||
{INVOICE_KIND_LABELS[detail.invoiceKind]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
|
||||
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{INVOICE_STATUS_LABELS[detail.status]}
|
||||
{detail.overdue ? '(超 2 工作日)' : ''}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="发票文件">
|
||||
{detail.fileUrl ? (
|
||||
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
|
||||
查看/下载
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,15 +38,17 @@ type Row = {
|
||||
type PartnerOption = { id: string; companyName: string; phone?: string };
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '待合伙人确认',
|
||||
CONFIRMED: '待打款审核',
|
||||
PENDING_REVIEW: '待审核',
|
||||
AWAITING_CONFIRM: '待合伙人确认',
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
REJECTED: '已驳回',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
DRAFT: 'default',
|
||||
CONFIRMED: 'orange',
|
||||
PENDING_REVIEW: 'gold',
|
||||
AWAITING_CONFIRM: 'blue',
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
@@ -74,6 +76,7 @@ export default function PartnerBillsPage() {
|
||||
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
|
||||
const [rejectForm] = Form.useForm();
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -81,10 +84,10 @@ export default function PartnerBillsPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
|
||||
async function generateBill(values: { partnerId?: string; month: Dayjs }) {
|
||||
const year = values.month.year();
|
||||
const month = values.month.month() + 1;
|
||||
if (values.all || !values.partnerId) {
|
||||
if (!values.partnerId) {
|
||||
const result = await request<{ success: number; failed: number; total: number }>(
|
||||
'/admin/partner-bills/generate-all',
|
||||
{ method: 'POST', body: JSON.stringify({ year, month }) },
|
||||
@@ -95,31 +98,71 @@ export default function PartnerBillsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
message.success('账单已生成(待审核)');
|
||||
}
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function confirmBill(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||
message.success('已代确认,进入待打款审核');
|
||||
reload();
|
||||
function sendBills(ids: string[]) {
|
||||
Modal.confirm({
|
||||
title: '发送给合伙人?',
|
||||
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`,
|
||||
okText: '确认发送',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/send`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已发送');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function markPaid(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
function markPaid(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/partner-bills/${ids[0]}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
} else {
|
||||
await request('/admin/partner-bills/batch-mark-paid', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已标记打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
message.success('已通过并标记打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
function openReject(row: Row) {
|
||||
setRejectTarget(row);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
Modal.confirm({
|
||||
title: '驳回该账单?',
|
||||
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。',
|
||||
okText: '继续填写理由',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
setRejectTarget(row);
|
||||
rejectForm.resetFields();
|
||||
setRejectOpen(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function submitReject(values: { reason: string }) {
|
||||
@@ -130,7 +173,7 @@ export default function PartnerBillsPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: values.reason.trim() }),
|
||||
});
|
||||
message.success('已驳回打款申请');
|
||||
message.success('已驳回');
|
||||
setRejectOpen(false);
|
||||
setRejectTarget(null);
|
||||
reload();
|
||||
@@ -158,6 +201,10 @@ export default function PartnerBillsPage() {
|
||||
|
||||
const summary = data?.summary;
|
||||
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const canSend = selectedRows.filter((r) => r.status === 'PENDING_REVIEW' || r.status === 'REJECTED');
|
||||
const canPay = selectedRows.filter((r) => r.status === 'UNPAID');
|
||||
const payAmount = canPay.reduce((s, r) => s + Number(r.totalAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
|
||||
@@ -194,7 +241,7 @@ export default function PartnerBillsPage() {
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
width: 130,
|
||||
render: (s, row) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
|
||||
@@ -212,21 +259,26 @@ export default function PartnerBillsPage() {
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
{row.status === 'DRAFT' && (
|
||||
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
|
||||
代确认
|
||||
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && (
|
||||
<Button type="link" size="small" onClick={() => sendBills([row.id])}>
|
||||
发送
|
||||
</Button>
|
||||
)}
|
||||
{row.status === 'CONFIRMED' && (
|
||||
{row.status === 'UNPAID' && (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
|
||||
通过打款
|
||||
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{row.status === 'AWAITING_CONFIRM' && (
|
||||
<Button type="link" size="small" danger onClick={() => openReject(row)}>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -239,7 +291,7 @@ export default function PartnerBillsPage() {
|
||||
合伙人账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:合伙人确认并申请打款后,总部在此审核通过或驳回(驳回须填写理由)
|
||||
每月 1 日 8:00 自动生成上月账单(待审核)→ 发送合伙人确认 → 未打款 → 已打款
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
@@ -287,7 +339,7 @@ export default function PartnerBillsPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
style={{ width: 150 }}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
@@ -317,6 +369,20 @@ export default function PartnerBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button disabled={!canSend.length} onClick={() => sendBills(canSend.map((r) => r.id))}>
|
||||
批量发送 ({canSend.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canPay.length}
|
||||
onClick={() => markPaid(canPay.map((r) => r.id), payAmount)}
|
||||
>
|
||||
批量打款 ({canPay.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -325,6 +391,10 @@ export default function PartnerBillsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
}}
|
||||
scroll={{ x: 1280 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
@@ -338,7 +408,7 @@ export default function PartnerBillsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal title="生成合伙人账单(T+30)" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form
|
||||
form={genForm}
|
||||
layout="vertical"
|
||||
@@ -361,7 +431,7 @@ export default function PartnerBillsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
生成
|
||||
生成(待审核)
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -378,8 +448,6 @@ export default function PartnerBillsPage() {
|
||||
>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
账单 {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
|
||||
<br />
|
||||
驳回理由将展示在合伙人 H5 账单页。
|
||||
</Typography.Paragraph>
|
||||
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
|
||||
<Form.Item
|
||||
@@ -390,7 +458,7 @@ export default function PartnerBillsPage() {
|
||||
{ max: 500, message: '不超过 500 字' },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
|
||||
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
|
||||
确认驳回
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -23,26 +24,26 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
redeemCount: number;
|
||||
redeemAmount: number;
|
||||
payoutAmount: number;
|
||||
settlementRate: number;
|
||||
payoutAmount: number;
|
||||
status: string;
|
||||
expectedPayAt: string;
|
||||
paidAt?: string;
|
||||
createdAt: string;
|
||||
paidAt?: string | null;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
|
||||
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
||||
PENDING: '待打款',
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
||||
PENDING: 'orange',
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
@@ -51,7 +52,7 @@ export default function StoreBillsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-payouts',
|
||||
'/admin/store-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
@@ -65,6 +66,8 @@ export default function StoreBillsPage() {
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
@@ -72,13 +75,37 @@ export default function StoreBillsPage() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function confirmPayout(id: string) {
|
||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/store-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await request('/admin/store-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
message.success('已确认打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Record<string, unknown>>(`/admin/store-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
@@ -89,10 +116,8 @@ export default function StoreBillsPage() {
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(
|
||||
`/admin/store-payouts/export?${qs}`,
|
||||
);
|
||||
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`);
|
||||
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
@@ -100,62 +125,56 @@ export default function StoreBillsPage() {
|
||||
}
|
||||
|
||||
const summary = data?.summary;
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.payoutAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '核销日期',
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (_, r) =>
|
||||
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
},
|
||||
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
|
||||
{
|
||||
title: '核销单号',
|
||||
dataIndex: ['redeemRecord', 'redeemNo'],
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
title: '核销金额',
|
||||
dataIndex: 'redeemAmount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{
|
||||
title: '核销比例',
|
||||
title: '结算比例',
|
||||
dataIndex: 'settlementRate',
|
||||
width: 90,
|
||||
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
|
||||
render: (v) => `${Math.round(Number(v) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '应付门店',
|
||||
title: '应付金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
width: 100,
|
||||
render: (v) => `¥${v}`,
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '打款状态',
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'PENDING' && (
|
||||
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
@@ -168,19 +187,19 @@ export default function StoreBillsPage() {
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
门店账单
|
||||
门店对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+1 结算:按日列出门店核销订单,应付 = 核销面额 × 门店核销比例
|
||||
按核销日汇总(每日 8:00 自动出账);未打款红色、已打款绿色
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="明细笔数" value={summary.count} />
|
||||
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="核销金额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -189,16 +208,12 @@ export default function StoreBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: {
|
||||
storeId?: string;
|
||||
status?: string;
|
||||
dateRange?: [Dayjs, Dayjs];
|
||||
}) => {
|
||||
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => {
|
||||
setFilters({
|
||||
storeId: v.storeId || '',
|
||||
status: v.status || '',
|
||||
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
|
||||
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
@@ -210,21 +225,17 @@ export default function StoreBillsPage() {
|
||||
placeholder="全部门店"
|
||||
style={{ width: 200 }}
|
||||
optionFilterProp="label"
|
||||
options={stores.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}(${s.phone})`,
|
||||
}))}
|
||||
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="打款状态">
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
placeholder="全部"
|
||||
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="核销日期">
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
@@ -248,6 +259,16 @@ export default function StoreBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
loading={batchLoading}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
@@ -256,7 +277,12 @@ export default function StoreBillsPage() {
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1300 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -269,28 +295,44 @@ export default function StoreBillsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="门店">
|
||||
{String((detail.store as { name?: string })?.name ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销单号">
|
||||
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结算比例">
|
||||
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="实际打款">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">
|
||||
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
核销明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => String((r.redeemRecord as { redeemNo?: string })?.redeemNo || '—'),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'payoutAmount',
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s) => (s === 'PAID' ? '已打款' : '未打款'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type CategoryNode = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
sort: number;
|
||||
parentId: string | null;
|
||||
status: string;
|
||||
children?: CategoryNode[];
|
||||
};
|
||||
|
||||
type FlatRow = CategoryNode & { level: 1 | 2; parentName?: string };
|
||||
|
||||
function flattenTree(tree: CategoryNode[]): FlatRow[] {
|
||||
const rows: FlatRow[] = [];
|
||||
for (const root of tree) {
|
||||
rows.push({ ...root, level: 1, children: undefined });
|
||||
for (const child of root.children ?? []) {
|
||||
rows.push({
|
||||
...child,
|
||||
level: 2,
|
||||
parentName: root.name,
|
||||
children: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function StoreCategoriesPage() {
|
||||
const [tree, setTree] = useState<CategoryNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<FlatRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const rows = useMemo(() => flattenTree(tree), [tree]);
|
||||
const rootOptions = useMemo(
|
||||
() => tree.filter((n) => n.status === 'ACTIVE').map((n) => ({ value: n.id, label: n.name })),
|
||||
[tree],
|
||||
);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<CategoryNode[]>('/admin/store-categories');
|
||||
setTree(Array.isArray(data) ? data : []);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
function openCreate(parentId?: string) {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({
|
||||
code: '',
|
||||
name: '',
|
||||
sort: 0,
|
||||
parentId: parentId || undefined,
|
||||
status: 'ACTIVE',
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(row: FlatRow) {
|
||||
setEditing(row);
|
||||
form.setFieldsValue({
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
sort: row.sort,
|
||||
parentId: row.parentId || undefined,
|
||||
status: row.status,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
code: String(values.code).trim().toUpperCase(),
|
||||
name: String(values.name).trim(),
|
||||
sort: Number(values.sort ?? 0),
|
||||
parentId: values.parentId || null,
|
||||
status: values.status || 'ACTIVE',
|
||||
};
|
||||
if (editing) {
|
||||
await request(`/admin/store-categories/${editing.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已保存');
|
||||
} else {
|
||||
await request('/admin/store-categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
message.success('已创建');
|
||||
}
|
||||
setModalOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<FlatRow> = [
|
||||
{
|
||||
title: '层级',
|
||||
dataIndex: 'level',
|
||||
width: 80,
|
||||
render: (level) => (level === 1 ? <Tag color="blue">一级</Tag> : <Tag>二级</Tag>),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
render: (name, row) => (
|
||||
<span style={{ paddingLeft: row.level === 2 ? 24 : 0 }}>
|
||||
{row.level === 2 ? `${row.parentName || ''} / ` : ''}
|
||||
{name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||
{ title: '排序', dataIndex: 'sort', width: 80 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{row.level === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openCreate(row.id)}>加二级</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title={row.level === 1 ? '删除一级分类?若有门店占用将改为停用' : '删除该分类?若有门店占用将改为停用'}
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/store-categories/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已处理');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>门店分类</Typography.Title>
|
||||
<Typography.Text type="secondary">两级分类:一级(餐饮/住宿/娱乐)→ 二级业态,供合伙人开店选择</Typography.Text>
|
||||
</Space>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await request('/admin/store-categories/ensure-defaults', { method: 'POST' });
|
||||
message.success('已同步默认分类');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
同步默认分类
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => openCreate()}>新增一级</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
className="admin-table-nowrap"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑分类' : '新增分类'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||
<Input placeholder="如 DINING / HOTPOT" disabled={!!editing} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="分类名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="上级分类(空=一级)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不选则为一级分类"
|
||||
options={rootOptions.filter((o) => o.value !== editing?.id)}
|
||||
disabled={editing?.level === 1 && (tree.find((t) => t.id === editing.id)?.children?.length ?? 0) > 0}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort" label="排序" initialValue={0}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,15 @@ import {
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import {
|
||||
ADMIN_OPTIONS_PAGE_SIZE,
|
||||
RESOURCE_BIZ_TYPE_LABELS,
|
||||
STORE_AUDIT_STATUS_LABELS,
|
||||
STORE_STATUS_LABELS,
|
||||
fmtTime,
|
||||
} from '../lib/constants';
|
||||
import {
|
||||
validateStoreCreateStep1,
|
||||
validateStoreCreateStep3,
|
||||
@@ -37,6 +44,209 @@ const CREATE_STEPS = [
|
||||
{ title: '结算资质' },
|
||||
];
|
||||
|
||||
type StoreMediaItem = {
|
||||
id?: string;
|
||||
bizType?: string;
|
||||
mediaType?: string;
|
||||
url?: string | null;
|
||||
};
|
||||
|
||||
function isImageMedia(url: string, mediaType?: string) {
|
||||
if (mediaType === 'IMAGE') return true;
|
||||
if (mediaType === 'VIDEO' || mediaType === 'FILE') {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
return /\.(png|jpe?g|gif|webp|bmp|heic)(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function isPdfUrl(url: string) {
|
||||
return /\.pdf(\?|#|$)/i.test(url);
|
||||
}
|
||||
|
||||
function collectMediaUrls(detail: Record<string, unknown>) {
|
||||
const media = Array.isArray(detail.media) ? (detail.media as StoreMediaItem[]) : [];
|
||||
const byType = (bizType: string) =>
|
||||
media
|
||||
.filter((item) => String(item.bizType || '').toUpperCase() === bizType)
|
||||
.map((item) => ({
|
||||
id: String(item.id || item.url || ''),
|
||||
url: String(item.url || '').trim(),
|
||||
mediaType: item.mediaType ? String(item.mediaType) : undefined,
|
||||
}))
|
||||
.filter((item) => item.url);
|
||||
|
||||
const covers = byType('COVER');
|
||||
const coverUrl = detail.coverUrl ? String(detail.coverUrl).trim() : '';
|
||||
if (coverUrl && !covers.some((item) => item.url === coverUrl)) {
|
||||
covers.unshift({ id: 'cover', url: coverUrl, mediaType: 'IMAGE' });
|
||||
}
|
||||
|
||||
return {
|
||||
covers,
|
||||
envs: byType('ENV'),
|
||||
contracts: byType('CONTRACT'),
|
||||
};
|
||||
}
|
||||
|
||||
function StoreAuditMediaSection({ detail }: { detail: Record<string, unknown> }) {
|
||||
const { covers, envs, contracts } = collectMediaUrls(detail);
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const gallery = [...covers, ...envs].filter((item) => isImageMedia(item.url, item.mediaType));
|
||||
|
||||
if (covers.length === 0 && envs.length === 0 && contracts.length === 0) {
|
||||
return (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="暂无门头照 / 环境照 / 签约合同,请谨慎审核"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0, marginBottom: 12 }}>
|
||||
审核材料
|
||||
</Typography.Title>
|
||||
|
||||
{(covers.length > 0 || envs.length > 0) && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
门头照 / 环境照(点击可放大浏览)
|
||||
</Typography.Text>
|
||||
<Image.PreviewGroup>
|
||||
<Space wrap size={12}>
|
||||
{gallery.map((item) => (
|
||||
<div key={item.id} style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={112}
|
||||
height={84}
|
||||
style={{ objectFit: 'cover', borderRadius: 6, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c', marginTop: 4 }}>
|
||||
{covers.some((c) => c.id === item.id) ? '门头照' : '环境照'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contracts.length > 0 && (
|
||||
<div>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
|
||||
签约合同
|
||||
</Typography.Text>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
{contracts.map((item, index) => {
|
||||
const imageLike = isImageMedia(item.url, item.mediaType);
|
||||
const pdf = isPdfUrl(item.url);
|
||||
return (
|
||||
<div
|
||||
key={item.id || `${item.url}-${index}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
background: '#fafafa',
|
||||
}}
|
||||
>
|
||||
{imageLike ? (
|
||||
<Image.PreviewGroup>
|
||||
<Image
|
||||
src={item.url}
|
||||
width={96}
|
||||
height={72}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 96,
|
||||
height: 72,
|
||||
borderRadius: 6,
|
||||
background: '#fff',
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#cf1322',
|
||||
}}
|
||||
>
|
||||
<FilePdfOutlined style={{ fontSize: 28 }} />
|
||||
</div>
|
||||
)}
|
||||
<Space direction="vertical" size={4} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography.Text strong>
|
||||
{RESOURCE_BIZ_TYPE_LABELS.CONTRACT || '合同'}
|
||||
{contracts.length > 1 ? ` ${index + 1}` : ''}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis style={{ maxWidth: '100%' }}>
|
||||
{item.url}
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
{imageLike ? (
|
||||
<Typography.Text type="secondary">点击缩略图放大查看</Typography.Text>
|
||||
) : null}
|
||||
{pdf ? (
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => setPdfUrl(item.url)}>
|
||||
页内预览 PDF
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<LinkOutlined />}
|
||||
style={{ padding: 0 }}
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
新窗口打开
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="合同预览"
|
||||
open={!!pdfUrl}
|
||||
onCancel={() => setPdfUrl(null)}
|
||||
width={900}
|
||||
footer={[
|
||||
<Button key="open" href={pdfUrl || undefined} target="_blank" rel="noreferrer">
|
||||
新窗口打开
|
||||
</Button>,
|
||||
<Button key="close" type="primary" onClick={() => setPdfUrl(null)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
destroyOnClose
|
||||
>
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
title="合同 PDF 预览"
|
||||
src={pdfUrl}
|
||||
style={{ width: '100%', height: '70vh', border: 'none', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type StoreRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -307,7 +517,7 @@ export default function StoresPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" 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={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Space wrap>
|
||||
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
|
||||
@@ -367,6 +577,7 @@ export default function StoresPage() {
|
||||
)}>
|
||||
{detail && (
|
||||
<>
|
||||
<StoreAuditMediaSection detail={detail} />
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="审核状态">
|
||||
@@ -390,11 +601,6 @@ export default function StoresPage() {
|
||||
查看商户日志
|
||||
</Button>
|
||||
</Descriptions.Item>
|
||||
{detail.coverUrl ? (
|
||||
<Descriptions.Item label="封面">
|
||||
<Image src={String(detail.coverUrl)} width={120} />
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
{Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
|
||||
<Descriptions.Item label="审核记录">
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
|
||||
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
@@ -8,11 +9,12 @@ import { useAdminList } from '../lib/useAdminList';
|
||||
type Row = {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string;
|
||||
extraJson?: { evidenceUrls?: string[] };
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
@@ -28,7 +30,7 @@ export default function TicketsPage() {
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [detail, setDetail] = useState<Row | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function approve(id: string) {
|
||||
@@ -50,52 +52,124 @@ export default function TicketsPage() {
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'ticketType',
|
||||
width: 110,
|
||||
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'REFUND', label: '退款' },
|
||||
{ value: 'RESHIPMENT', label: '补发' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]} />
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'REFUND', label: '仅退款' },
|
||||
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Input allowClear placeholder="PENDING" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
筛选
|
||||
</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" 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={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
||||
</>
|
||||
) : null}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
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={480}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">
|
||||
{String(detail.refType)} #{String(detail.refId)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="凭证">
|
||||
{evidenceUrls.length ? (
|
||||
<Image.PreviewGroup>
|
||||
{evidenceUrls.map((url) => (
|
||||
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -3,7 +3,10 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Statistic,
|
||||
@@ -21,17 +24,34 @@ import { request } from '../lib/api';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
orderId: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
cityName?: string;
|
||||
receiverCity?: string;
|
||||
payAmount: number;
|
||||
id: string;
|
||||
billNo: string;
|
||||
billDate: string;
|
||||
orderCount: number;
|
||||
orderAmount: number;
|
||||
wineryRate: number;
|
||||
wineryAmount: number;
|
||||
status: string;
|
||||
paidAt?: string | null;
|
||||
};
|
||||
|
||||
type BillItem = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
deliveryType: string;
|
||||
payAmount: number;
|
||||
wineryAmount: number;
|
||||
paidAt: string;
|
||||
productName?: string;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
UNPAID: '未打款',
|
||||
PAID: '已打款',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
UNPAID: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
const DELIVERY_LABELS: Record<string, string> = {
|
||||
@@ -42,29 +62,64 @@ const DELIVERY_LABELS: Record<string, string> = {
|
||||
export default function WineryBillsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/winery-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
function confirmPay(ids: string[], amountHint?: number) {
|
||||
Modal.confirm({
|
||||
title: '确认打款?',
|
||||
content: `将确认 ${ids.length} 笔酒厂对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`,
|
||||
okText: '确认打款',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (ids.length === 1) {
|
||||
await request(`/admin/winery-bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('/admin/winery-bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
message.success('已确认打款');
|
||||
setSelectedKeys([]);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Row & { items?: BillItem[] }>(`/admin/winery-bills/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
setExporting(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.year) qs.set('year', filters.year);
|
||||
if (filters.month) qs.set('month', filters.month);
|
||||
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
|
||||
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
|
||||
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
|
||||
downloadExcelCsv(result.csv, `酒厂对账单_${suffix}.csv`);
|
||||
message.success(`已导出 ${result.count} 条`);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
@@ -73,23 +128,22 @@ export default function WineryBillsPage() {
|
||||
|
||||
const summary = data?.summary;
|
||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||
const selectedAmount = selectedRows.reduce((s, r) => s + Number(r.wineryAmount), 0);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
|
||||
{
|
||||
title: '配送类型',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 90,
|
||||
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
|
||||
},
|
||||
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
|
||||
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
|
||||
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
|
||||
{ title: '数量', dataIndex: 'quantity', width: 70 },
|
||||
{
|
||||
title: '酒单实付',
|
||||
dataIndex: 'payAmount',
|
||||
title: '账单日',
|
||||
dataIndex: 'billDate',
|
||||
width: 110,
|
||||
render: (v) => String(v || '').slice(0, 10),
|
||||
},
|
||||
{ title: '订单数', dataIndex: 'orderCount', width: 80 },
|
||||
{
|
||||
title: '酒单实付合计',
|
||||
dataIndex: 'orderAmount',
|
||||
width: 120,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
@@ -105,10 +159,27 @@ export default function WineryBillsPage() {
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 170,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row.id)}>
|
||||
明细
|
||||
</Button>
|
||||
{row.status === 'UNPAID' && (
|
||||
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.wineryAmount))}>
|
||||
确认打款
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -116,19 +187,19 @@ export default function WineryBillsPage() {
|
||||
<div>
|
||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
酒厂账单
|
||||
酒厂对账单
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
T+30 结算:按自然月列出同城/跨城已付酒单,应付 = 酒单实付 × {ratePct}%(暂定)
|
||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
{summary && (
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space size="large" wrap>
|
||||
<Statistic title="酒单数" value={summary.count} />
|
||||
<Statistic title="账单数" value={summary.count} />
|
||||
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
|
||||
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} />
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -137,28 +208,30 @@ export default function WineryBillsPage() {
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
|
||||
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => {
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
year: v.month ? String(v.month.year()) : '',
|
||||
month: v.month ? String(v.month.month() + 1) : '',
|
||||
deliveryType: v.deliveryType || '',
|
||||
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '',
|
||||
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
|
||||
});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deliveryType" label="配送类型">
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'LOCAL', label: '同城' },
|
||||
{ value: 'CROSS_CITY', label: '跨城' },
|
||||
]}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="month" label="账期月">
|
||||
<DatePicker picker="month" />
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="账单日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
@@ -180,15 +253,29 @@ export default function WineryBillsPage() {
|
||||
导出 Excel
|
||||
</Button>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!selectedKeys.length}
|
||||
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
|
||||
>
|
||||
批量确认打款 ({selectedKeys.length})
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="orderId"
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1200 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: setSelectedKeys,
|
||||
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
|
||||
}}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
@@ -200,6 +287,56 @@ export default function WineryBillsPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer title="酒厂对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={640}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="账单号">{detail.billNo}</Descriptions.Item>
|
||||
<Descriptions.Item label="账单日">{String(detail.billDate).slice(0, 10)}</Descriptions.Item>
|
||||
<Descriptions.Item label="酒厂应付">¥{Number(detail.wineryAmount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{STATUS_LABELS[detail.status] || detail.status}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款时间">{detail.paidAt ? fmtTime(detail.paidAt) : '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Typography.Title level={5} style={{ marginTop: 16 }}>
|
||||
订单明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.items ?? []}
|
||||
columns={[
|
||||
{ title: '订单号', dataIndex: 'orderNo', ellipsis: true },
|
||||
{
|
||||
title: '配送',
|
||||
dataIndex: 'deliveryType',
|
||||
width: 70,
|
||||
render: (v) => DELIVERY_LABELS[v] || v,
|
||||
},
|
||||
{
|
||||
title: '实付',
|
||||
dataIndex: 'payAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '酒厂应付',
|
||||
dataIndex: 'wineryAmount',
|
||||
width: 90,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '支付时间',
|
||||
dataIndex: 'paidAt',
|
||||
width: 150,
|
||||
render: (v) => (v ? fmtTime(v) : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
||||
import { getPartnerProfile } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
|
||||
@@ -18,10 +18,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getPartnerProfile();
|
||||
if (profile && hasPartnerWxSession() && profile.hasWechat) {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import {
|
||||
authorizePartnerWechat,
|
||||
fetchClientConfig,
|
||||
fetchPartnerProfile,
|
||||
needsWechatAuth,
|
||||
type PartnerProfile,
|
||||
} from '../lib/wechat-auth';
|
||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { toastError } from '../lib/toast';
|
||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
|
||||
type OssUploadFieldProps = {
|
||||
value?: string;
|
||||
@@ -22,27 +12,10 @@ type OssUploadFieldProps = {
|
||||
wide?: boolean;
|
||||
compact?: boolean;
|
||||
label?: string;
|
||||
/** 父级已确认微信授权时可跳过检查 */
|
||||
wechatReady?: boolean;
|
||||
onWechatReadyChange?: (ready: boolean) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_MB = 10;
|
||||
|
||||
function formatWechatUploadError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
const formatted = formatChooseImageFailMessage(msg);
|
||||
if (formatted) return formatted;
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function acceptsImages(accept: string) {
|
||||
return accept.includes('image');
|
||||
}
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -52,15 +25,10 @@ export default function OssUploadField({
|
||||
wide,
|
||||
compact,
|
||||
label,
|
||||
wechatReady,
|
||||
onWechatReadyChange,
|
||||
}: OssUploadFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [authorizing, setAuthorizing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [profile, setProfile] = useState<PartnerProfile | null>(null);
|
||||
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
|
||||
|
||||
function showUploadError(text: string) {
|
||||
setError(text);
|
||||
@@ -69,35 +37,6 @@ export default function OssUploadField({
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const inWechat = isWechatEnv();
|
||||
const useWechatPicker =
|
||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
||||
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
|
||||
onWechatReadyChangeRef.current = onWechatReadyChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (!useWechatPicker) return;
|
||||
void fetchClientConfig()
|
||||
.then(setClientConfig)
|
||||
.catch(() => {
|
||||
/* 未登录等场景由上传接口报错 */
|
||||
});
|
||||
if (wechatReady === true) return;
|
||||
void fetchPartnerProfile()
|
||||
.then(setProfile)
|
||||
.catch(() => {
|
||||
/* 未登录等场景由上传接口报错 */
|
||||
});
|
||||
}, [useWechatPicker, wechatReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!useWechatPicker || needsAuth) return;
|
||||
weixinSdk.reset();
|
||||
void weixinSdk.init().catch(() => {
|
||||
/* 点击上传时会再次初始化 */
|
||||
});
|
||||
}, [useWechatPicker, needsAuth]);
|
||||
|
||||
async function persistUpload(file: File) {
|
||||
if (!file.size) {
|
||||
@@ -125,112 +64,38 @@ export default function OssUploadField({
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechatAuth() {
|
||||
setAuthorizing(true);
|
||||
function pickFile() {
|
||||
if (uploading) return;
|
||||
setError('');
|
||||
try {
|
||||
const result = await authorizePartnerWechat();
|
||||
if (result) {
|
||||
const me = await fetchPartnerProfile();
|
||||
setProfile(me);
|
||||
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
|
||||
}
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '微信授权失败';
|
||||
showUploadError(text);
|
||||
} finally {
|
||||
setAuthorizing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
weixinSdk.reset();
|
||||
await weixinSdk.init();
|
||||
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
|
||||
await enqueueUpload(async () => {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (!files?.[0]) return;
|
||||
await persistUpload(files[0]);
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (/cancel/i.test(msg)) return;
|
||||
showUploadError(formatWechatUploadError(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
if (uploading || authorizing) return;
|
||||
setError('');
|
||||
|
||||
if (useWechatPicker && needsAuth) {
|
||||
const text = '请先完成微信授权后再上传照片';
|
||||
showUploadError(text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (useWechatPicker) {
|
||||
try {
|
||||
await pickWechatImage();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
||||
if (/cancel/i.test(msg)) return;
|
||||
showUploadError(formatWechatUploadError(e));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
const isFile = mediaType === 'FILE' && value;
|
||||
const busy = uploading || authorizing;
|
||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
||||
const busy = uploading;
|
||||
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
|
||||
|
||||
const triggerProps = {
|
||||
type: 'button' as const,
|
||||
disabled: busy || needsAuth,
|
||||
onClick: () => void pickFile(),
|
||||
disabled: busy,
|
||||
onClick: pickFile,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="partner-oss-upload">
|
||||
{needsAuth && (
|
||||
<div className="partner-wechat-auth-hint" role="status">
|
||||
<p className="body-md">上传照片需先完成微信授权绑定</p>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
style={{ marginTop: 8, width: '100%' }}
|
||||
disabled={authorizing}
|
||||
onClick={() => void startWechatAuth()}
|
||||
>
|
||||
{authorizing ? '跳转授权中…' : '微信授权绑定'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!useWechatPicker && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadSelectedFile(file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
void uploadSelectedFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isImage ? (
|
||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||
<img src={value} alt={label ?? '已上传'} />
|
||||
@@ -255,10 +120,10 @@ export default function OssUploadField({
|
||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
||||
{busy ? 'hourglass_top' : 'photo_library'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
|
||||
{uploading ? '上传中…' : pickerLabel}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
|
||||
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||
|
||||
/** 微信验证通过后的免登录时长 */
|
||||
/** 手机号或微信验证通过后的免登录时长 */
|
||||
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
@@ -77,7 +77,7 @@ export function isPartnerSessionExpired() {
|
||||
}
|
||||
|
||||
export function touchPartnerSession() {
|
||||
if (!hasPartnerWxSession()) return;
|
||||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
@@ -90,11 +90,16 @@ export function saveAuth(data: PartnerSessionPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||
export function saveRememberedSession(data: PartnerSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||
saveAuth(data);
|
||||
saveRememberedSession(data);
|
||||
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
|
||||
@@ -8,6 +8,10 @@ export type StoreDraftForm = {
|
||||
phone: string;
|
||||
storeSmsCode: string;
|
||||
address: string;
|
||||
openTime: string;
|
||||
closeTime: string;
|
||||
categoryParentId: string;
|
||||
categoryId: string;
|
||||
intro: string;
|
||||
coverUrl: string;
|
||||
envPhotoUrls: string[];
|
||||
@@ -37,6 +41,10 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
phone: '',
|
||||
storeSmsCode: '',
|
||||
address: '',
|
||||
openTime: '10:00',
|
||||
closeTime: '22:00',
|
||||
categoryParentId: '',
|
||||
categoryId: '',
|
||||
intro: '',
|
||||
coverUrl: '',
|
||||
envPhotoUrls: ['', '', ''],
|
||||
@@ -68,6 +76,10 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
phone: String(raw.phone ?? base.phone),
|
||||
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
|
||||
address: String(raw.address ?? base.address),
|
||||
openTime: String(raw.openTime ?? base.openTime),
|
||||
closeTime: String(raw.closeTime ?? base.closeTime),
|
||||
categoryParentId: String(raw.categoryParentId ?? base.categoryParentId),
|
||||
categoryId: String(raw.categoryId ?? base.categoryId),
|
||||
intro: String(raw.intro ?? base.intro),
|
||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||
@@ -119,21 +131,38 @@ export function clearAllStoreDrafts(accountId?: string) {
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
function timeToMinutes(value: string): number {
|
||||
const [h, m] = value.split(':').map(Number);
|
||||
return h * 60 + m;
|
||||
}
|
||||
|
||||
export function validateStoreStep1(
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
|
||||
| 'regionCodes'
|
||||
| 'cityId'
|
||||
| 'name'
|
||||
| 'address'
|
||||
| 'openTime'
|
||||
| 'closeTime'
|
||||
| 'categoryId'
|
||||
| 'intro'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
|
||||
if (!form.name.trim()) return '请填写门店名称';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
if (!form.address.trim()) return '请填写详细地址';
|
||||
if (!form.openTime.trim()) return '请填写营业开始时间';
|
||||
if (!TIME_RE.test(form.openTime.trim())) return '营业开始时间格式须为 HH:MM';
|
||||
if (!form.closeTime.trim()) return '请填写营业结束时间';
|
||||
if (!TIME_RE.test(form.closeTime.trim())) return '营业结束时间格式须为 HH:MM';
|
||||
if (timeToMinutes(form.openTime.trim()) >= timeToMinutes(form.closeTime.trim())) {
|
||||
return '营业结束时间须晚于开始时间';
|
||||
}
|
||||
if (!form.categoryId.trim()) return '请选择店铺类型';
|
||||
if (form.intro.trim()) {
|
||||
const len = form.intro.trim().length;
|
||||
if (len < 10 || len > 500) return '门店简介须为 10~500 字';
|
||||
@@ -158,11 +187,18 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
|
||||
}
|
||||
|
||||
export function validateStoreStep3(
|
||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||
form: Pick<
|
||||
StoreDraftForm,
|
||||
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
|
||||
>,
|
||||
): string | null {
|
||||
if (!form.bankAccountName.trim()) return '请填写户主姓名';
|
||||
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
|
||||
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
|
||||
if (!form.bankBranch.trim()) return '请填写开户支行';
|
||||
if (!form.phone.trim()) return '请填写联系电话';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
||||
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
|
||||
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
@@ -11,16 +12,16 @@ function fmtMoney(n: number) {
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '待确认';
|
||||
case 'CONFIRMED': return '审核中';
|
||||
case 'PAID': return '已结算';
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
|
||||
function canApplyPayment(status: string) {
|
||||
return status === 'DRAFT' || status === 'REJECTED';
|
||||
function canConfirm(status: string) {
|
||||
return status === 'AWAITING_CONFIRM';
|
||||
}
|
||||
|
||||
export default function BillsPage() {
|
||||
@@ -30,6 +31,7 @@ export default function BillsPage() {
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
async function loadBills() {
|
||||
setLoading(true);
|
||||
@@ -50,7 +52,7 @@ export default function BillsPage() {
|
||||
|
||||
const billId = searchParams.get('id');
|
||||
const actionable = useMemo(
|
||||
() => bills.filter((b) => canApplyPayment(b.status)),
|
||||
() => bills.filter((b) => canConfirm(b.status)),
|
||||
[bills],
|
||||
);
|
||||
const bill = billId
|
||||
@@ -58,20 +60,32 @@ export default function BillsPage() {
|
||||
: actionable[0] ?? bills[0];
|
||||
|
||||
const status = String(bill?.status || '');
|
||||
const showApplyForm = !!bill && canApplyPayment(status);
|
||||
const isReviewing = status === 'CONFIRMED';
|
||||
const showApplyForm = !!bill && canConfirm(status);
|
||||
const isUnpaid = status === 'UNPAID';
|
||||
const isRejected = status === 'REJECTED';
|
||||
const isPaid = status === 'PAID';
|
||||
|
||||
async function confirmBill() {
|
||||
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
|
||||
function askConfirm(ids: string[]) {
|
||||
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) {
|
||||
return;
|
||||
}
|
||||
void doConfirm(ids);
|
||||
}
|
||||
|
||||
async function doConfirm(ids: string[]) {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
|
||||
method: 'POST',
|
||||
});
|
||||
toastSuccess('申请已提交,请耐心等待总部打款审核');
|
||||
if (ids.length === 1) {
|
||||
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' });
|
||||
} else {
|
||||
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
}
|
||||
toastSuccess('已确认,等待总部打款');
|
||||
setConfirmed(false);
|
||||
setSelectedIds([]);
|
||||
await loadBills();
|
||||
} catch (e) {
|
||||
toastError(e instanceof Error ? e.message : '提交失败');
|
||||
@@ -80,8 +94,12 @@ export default function BillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-bills-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="partner-bills-page">
|
||||
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
|
||||
|
||||
<div className="partner-bill-stepper">
|
||||
@@ -89,7 +107,7 @@ export default function BillsPage() {
|
||||
<div className="partner-stepper-line" aria-hidden>
|
||||
<div
|
||||
className="partner-stepper-line-fill"
|
||||
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
|
||||
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
@@ -99,7 +117,7 @@ export default function BillsPage() {
|
||||
<span className="partner-step-label partner-step-label--active">数据核算</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
|
||||
{showApplyForm && !confirmed ? '2' : (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
)}
|
||||
@@ -107,20 +125,20 @@ export default function BillsPage() {
|
||||
<span className="partner-step-label partner-step-label--active">账单确认</span>
|
||||
</div>
|
||||
<div className="partner-step">
|
||||
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}>
|
||||
{isPaid ? (
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
|
||||
) : '3'}
|
||||
</div>
|
||||
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
|
||||
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
|
||||
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <div className="empty">加载中…</div>}
|
||||
{!loading && !bill && <div className="empty">暂无账单</div>}
|
||||
{!loading && !bill && <div className="empty">暂无待确认账单</div>}
|
||||
|
||||
{bill && (
|
||||
<section className="partner-bill-card">
|
||||
@@ -131,7 +149,7 @@ export default function BillsPage() {
|
||||
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
|
||||
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
|
||||
</div>
|
||||
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
|
||||
{billStatusLabel(status)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,17 +158,17 @@ export default function BillsPage() {
|
||||
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
|
||||
<div>
|
||||
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}>打款申请已驳回</p>
|
||||
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}>账单已驳回</p>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isReviewing && (
|
||||
{isUnpaid && (
|
||||
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||
您已提交打款申请,总部审核中,请耐心等待。
|
||||
您已确认账单,总部打款中,请耐心等待。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -177,29 +195,60 @@ export default function BillsPage() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{bills.length > 1 && (
|
||||
{bills.length > 0 && (
|
||||
<div style={{ padding: '0 20px' }}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>全部账单</h3>
|
||||
{bills.map((b) => (
|
||||
<button
|
||||
key={String(b.id)}
|
||||
type="button"
|
||||
className="partner-store-card"
|
||||
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/center/bills?id=${b.id}`)}
|
||||
>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{b.billNo}</p>
|
||||
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
|
||||
{b.status === 'REJECTED' && b.rejectReason ? (
|
||||
<p className="label-md text-primary" style={{ marginTop: 4 }}>驳回:{b.rejectReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<h3 className="headline-md" style={{ margin: 0 }}>全部账单</h3>
|
||||
{actionable.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-fill-max"
|
||||
style={{ fontSize: 13 }}
|
||||
disabled={!selectedIds.length || submitting}
|
||||
onClick={() => askConfirm(selectedIds)}
|
||||
>
|
||||
批量确认 ({selectedIds.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{bills.map((b) => {
|
||||
const id = String(b.id);
|
||||
const selectable = canConfirm(b.status);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="partner-store-card"
|
||||
style={{ margin: '0 0 12px', display: 'flex', gap: 10, alignItems: 'flex-start' }}
|
||||
>
|
||||
{selectable ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(id)}
|
||||
onChange={() => toggleSelect(id)}
|
||||
style={{ marginTop: 4 }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ width: 16 }} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
style={{ flex: 1, textAlign: 'left', background: 'none', border: 'none', padding: 0, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/center/bills?id=${id}`)}
|
||||
>
|
||||
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
|
||||
<div>
|
||||
<p className="body-md">{b.billNo}</p>
|
||||
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
|
||||
{b.status === 'REJECTED' && b.rejectReason ? (
|
||||
<p className="label-md text-primary" style={{ marginTop: 4 }}>驳回:{b.rejectReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -208,9 +257,7 @@ export default function BillsPage() {
|
||||
<div className="partner-info-banner" style={{ marginTop: 16 }}>
|
||||
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
|
||||
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
|
||||
{isRejected
|
||||
? '请根据驳回理由核对后重新确认并申请打款。'
|
||||
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
|
||||
确认后账单变为「未打款」,由总部完成打款。如有异议请先联系城市运营经理。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -224,14 +271,14 @@ export default function BillsPage() {
|
||||
className="partner-btn-primary"
|
||||
disabled={!confirmed || submitting}
|
||||
style={{ opacity: confirmed ? 1 : 0.5 }}
|
||||
onClick={() => void confirmBill()}
|
||||
onClick={() => askConfirm([String(bill!.id)])}
|
||||
>
|
||||
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
|
||||
{submitting ? '正在提交...' : '确认账单'}
|
||||
{!submitting && <span className="material-symbols-outlined">payments</span>}
|
||||
</button>
|
||||
</footer>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { contactSupport } from '../lib/contact';
|
||||
@@ -36,18 +37,27 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
document.title = isPrimary ? '合伙人中心' : '个人中心';
|
||||
}, [isPrimary]);
|
||||
|
||||
const loadCenter = useCallback(() => {
|
||||
const tasks: Promise<unknown>[] = [Promise.resolve(refresh())];
|
||||
if (isPrimary) {
|
||||
tasks.push(
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([])),
|
||||
listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0)),
|
||||
);
|
||||
}
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [isPrimary, refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPrimary) return;
|
||||
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
|
||||
.then(setBills)
|
||||
.catch(() => setBills([]));
|
||||
void listPartnerStaff()
|
||||
.then((list) => setStaffCount(list.length))
|
||||
.catch(() => setStaffCount(0));
|
||||
}, [isPrimary]);
|
||||
void loadCenter();
|
||||
}, [loadCenter]);
|
||||
|
||||
const finance = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
|
||||
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||
const settled = bills.filter((b) => b.status === 'PAID');
|
||||
const paidCount = settled.length;
|
||||
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
|
||||
@@ -55,7 +65,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
pendingBillCount: pending.length,
|
||||
rejectedCount,
|
||||
pendingTotal: bills
|
||||
.filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
|
||||
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED')
|
||||
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
|
||||
@@ -109,7 +119,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
|
||||
|
||||
return (
|
||||
<div className="page partner-center-page partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top">
|
||||
<section className="partner-profile-card">
|
||||
<button
|
||||
type="button"
|
||||
@@ -286,6 +296,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
<div className="partner-center-footer">
|
||||
<p className="label-md text-muted">传承千年 · 杜康好客</p>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import {
|
||||
@@ -155,45 +156,62 @@ export default function HomePage() {
|
||||
document.title = '工作台';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
|
||||
if (!account) return;
|
||||
const loadHome = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!account) return Promise.resolve();
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
|
||||
if (canOrders) {
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([]));
|
||||
tasks.push(
|
||||
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
|
||||
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
|
||||
.catch(() => setOrders([])),
|
||||
);
|
||||
} else {
|
||||
setOrders([]);
|
||||
}
|
||||
|
||||
if (canDashboard) {
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null));
|
||||
tasks.push(
|
||||
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
|
||||
.then(setDash)
|
||||
.catch(() => setDash(null)),
|
||||
);
|
||||
} else {
|
||||
setDash(null);
|
||||
}
|
||||
|
||||
if (canStores) {
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([]));
|
||||
tasks.push(
|
||||
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
|
||||
.then((data) => setStores(Array.isArray(data) ? data : []))
|
||||
.catch(() => setStores([])),
|
||||
);
|
||||
} else {
|
||||
setStores([]);
|
||||
}
|
||||
|
||||
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
});
|
||||
tasks.push(
|
||||
fetchPartnerLeaderboard('month')
|
||||
.then((data) => {
|
||||
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
|
||||
})
|
||||
.catch(() => {
|
||||
setLeaderboardEntries([]);
|
||||
}),
|
||||
);
|
||||
|
||||
return Promise.all(tasks).then(() => undefined);
|
||||
}, [navigate, account, canOrders, canDashboard, canStores]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHome();
|
||||
}, [loadHome]);
|
||||
|
||||
const storeCount = Number(dash?.storeCount || stores.length || 0);
|
||||
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
|
||||
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
|
||||
@@ -219,7 +237,7 @@ export default function HomePage() {
|
||||
const profit = revenue * 0.25;
|
||||
|
||||
return (
|
||||
<div className="page partner-home partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top">
|
||||
<main className="partner-home-body">
|
||||
{isPrimary && (
|
||||
<>
|
||||
@@ -346,6 +364,6 @@ export default function HomePage() {
|
||||
|
||||
<LeaderboardPreview entries={leaderboardEntries} />
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { isPrimaryAccount } from '../lib/partnerAccess';
|
||||
@@ -33,10 +34,10 @@ export default function LeaderboardPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadLeaderboard = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerLeaderboard(period)
|
||||
return fetchPartnerLeaderboard(period)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -45,8 +46,12 @@ export default function LeaderboardPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [period]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadLeaderboard();
|
||||
}, [loadLeaderboard]);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab">
|
||||
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
|
||||
|
||||
<div className="partner-leaderboard-tabs">
|
||||
@@ -124,6 +129,6 @@ export default function LeaderboardPage() {
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getLastPhone,
|
||||
getPartnerProfile,
|
||||
hasPartnerWxSession,
|
||||
request,
|
||||
saveAuth,
|
||||
saveRememberedSession,
|
||||
type PartnerSessionPayload,
|
||||
} from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
canPartnerUseWechatLogin,
|
||||
fetchClientConfig,
|
||||
loginPartnerWithWechat,
|
||||
PARTNER_WECHAT_LOGIN_HINT,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||
@@ -31,7 +20,7 @@ function AgreementCheckbox({
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
inputRef?: RefObject<HTMLLabelElement | null>;
|
||||
inputRef?: RefObject<HTMLLabelElement>;
|
||||
}) {
|
||||
return (
|
||||
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||
@@ -50,11 +39,6 @@ function AgreementCheckbox({
|
||||
);
|
||||
}
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
||||
try {
|
||||
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
|
||||
@@ -76,19 +60,9 @@ function formatPartnerError(e: unknown): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession, refresh, account } = usePartnerSession();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getPartnerProfile();
|
||||
const remembered = loadRememberedPhone();
|
||||
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
||||
@@ -96,22 +70,10 @@ export default function LoginPage() {
|
||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
const quickName = savedProfile?.name ?? '城市合伙人';
|
||||
const quickCompany = savedProfile?.companyName ?? '';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
@@ -175,14 +137,9 @@ export default function LoginPage() {
|
||||
body: JSON.stringify({ phone, code }),
|
||||
silent: true,
|
||||
});
|
||||
saveAuth(data);
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
persistRememberAccount(phone);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindPartnerWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
await finishLoginNavigate();
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
@@ -191,116 +148,6 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||
return;
|
||||
}
|
||||
const canUseWechat = await canPartnerUseWechatLogin({
|
||||
profile: savedProfile,
|
||||
phone,
|
||||
});
|
||||
if (!canUseWechat) {
|
||||
setMsg(PARTNER_WECHAT_LOGIN_HINT);
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const session = await loginPartnerWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
await finishLoginNavigate();
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick =
|
||||
wxAuthorize &&
|
||||
isWechatEnv() &&
|
||||
hasPartnerWxSession() &&
|
||||
!!savedProfile &&
|
||||
savedProfile.hasWechat === true;
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page partner-auth-page--quick">
|
||||
<header className="partner-auth-brand">
|
||||
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
|
||||
</div>
|
||||
<h1 className="partner-auth-title" style={{ fontSize: 20 }}>杜康好客</h1>
|
||||
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}>城市合伙人端</p>
|
||||
</header>
|
||||
|
||||
<section className="partner-glass-card">
|
||||
<div className="partner-quick-badge">已识别账号</div>
|
||||
<div className="partner-quick-avatar">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h2 className="headline-md">
|
||||
{quickName}
|
||||
{quickCompany ? (
|
||||
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
|
||||
) : null}
|
||||
</h2>
|
||||
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
|
||||
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
||||
<AgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
inputRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-primary"
|
||||
style={{ marginTop: 16 }}
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
|
||||
{wxAuthorize && !isWechatEnv()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
</p>
|
||||
)}
|
||||
{!canWechatQuick && (
|
||||
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
|
||||
验证码登录
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
||||
</nav>
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page">
|
||||
<div className="partner-auth-brand">
|
||||
@@ -367,25 +214,12 @@ export default function LoginPage() {
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-wechat" onClick={() => void 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>
|
||||
</>
|
||||
)}
|
||||
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
|
||||
手机号验证成功后,7 天内无需再次输入验证码
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{hasPartnerWxSession() && savedProfile?.hasWechat && (
|
||||
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
||||
)}
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { hasWarehouseAccess } from '../lib/partnerAccess';
|
||||
@@ -59,14 +60,17 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
document.title = '订单管理';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
const loadOrders = useCallback(() => {
|
||||
if (!isLoggedIn()) {
|
||||
navigate('/login');
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (!warehouseOk) {
|
||||
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
|
||||
setLoaded(true);
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
return request<OrdersResponse>('PARTNER_H5', '/partner/orders')
|
||||
.then((res) => {
|
||||
setData({
|
||||
list: Array.isArray(res.list) ? res.list : [],
|
||||
@@ -78,6 +82,10 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
.finally(() => setLoaded(true));
|
||||
}, [navigate, warehouseOk]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
const filtered = useMemo(() => data.list.filter((o) => {
|
||||
if (statusFilter === 'ALL') return true;
|
||||
const s = String(o.status).toUpperCase();
|
||||
@@ -142,7 +150,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
|
||||
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
|
||||
|
||||
<div className="partner-segment">
|
||||
@@ -265,6 +273,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import type { PartnerBillDto } from '@dukang/shared-types';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
|
||||
@@ -12,9 +13,9 @@ function fmtMoney(n: number) {
|
||||
|
||||
function billStatusLabel(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return '待结算';
|
||||
case 'CONFIRMED': return '审核中';
|
||||
case 'PAID': return '已结算';
|
||||
case 'AWAITING_CONFIRM': return '待确认';
|
||||
case 'UNPAID': return '未打款';
|
||||
case 'PAID': return '已打款';
|
||||
case 'REJECTED': return '已驳回';
|
||||
default: return status;
|
||||
}
|
||||
@@ -22,8 +23,8 @@ function billStatusLabel(status: string) {
|
||||
|
||||
function billStatusClass(status: string) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'partner-settlement-status--pending';
|
||||
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
|
||||
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending';
|
||||
case 'UNPAID': return 'partner-settlement-status--reviewing';
|
||||
case 'PAID': return 'partner-settlement-status--settled';
|
||||
case 'REJECTED': return 'partner-settlement-status--rejected';
|
||||
default: return '';
|
||||
@@ -47,13 +48,17 @@ export default function SettlementPage() {
|
||||
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
|
||||
const loadBills = useCallback(() => {
|
||||
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoggedIn()) { navigate('/login'); return; }
|
||||
request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
|
||||
}, [navigate]);
|
||||
void loadBills();
|
||||
}, [navigate, loadBills]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
const pending = bills.filter((b) => b.status === 'DRAFT');
|
||||
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM');
|
||||
const settled = bills.filter((b) => b.status === 'PAID');
|
||||
const currentMonth = bills.filter((b) => {
|
||||
const d = new Date(b.periodStart);
|
||||
@@ -72,9 +77,9 @@ export default function SettlementPage() {
|
||||
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
|
||||
if (!matchMonth) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
|
||||
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM';
|
||||
if (statusFilter === 'settled') return b.status === 'PAID';
|
||||
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
|
||||
if (statusFilter === 'reviewing') return b.status === 'UNPAID';
|
||||
if (statusFilter === 'rejected') return b.status === 'REJECTED';
|
||||
return true;
|
||||
});
|
||||
@@ -95,14 +100,14 @@ export default function SettlementPage() {
|
||||
|
||||
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'pending', label: '待结算' },
|
||||
{ key: 'reviewing', label: '审核中' },
|
||||
{ key: 'pending', label: '待确认' },
|
||||
{ key: 'reviewing', label: '未打款' },
|
||||
{ key: 'rejected', label: '已驳回' },
|
||||
{ key: 'settled', label: '已结算' },
|
||||
{ key: 'settled', label: '已打款' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-settlement-page">
|
||||
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page">
|
||||
<PageHeader title="财务对账中心" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-settlement-body">
|
||||
@@ -197,7 +202,7 @@ export default function SettlementPage() {
|
||||
驳回:{bill.rejectReason}
|
||||
</p>
|
||||
) : null}
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
|
||||
¥{fmtMoney(Number(bill.totalAmount || 0))}
|
||||
</p>
|
||||
</div>
|
||||
@@ -211,6 +216,6 @@ export default function SettlementPage() {
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import {
|
||||
AccountStatus,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
@@ -86,7 +87,7 @@ export default function StaffListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-staff-page">
|
||||
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page">
|
||||
<PageHeader title="子账号管理" onBack={() => navigate('/center')} />
|
||||
|
||||
<div style={{ padding: '0 20px 16px' }}>
|
||||
@@ -151,6 +152,6 @@ export default function StaffListPage() {
|
||||
添加子账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
import ChinaRegionPicker from '../components/ChinaRegionPicker';
|
||||
|
||||
import OssUploadField from '../components/OssUploadField';
|
||||
@@ -17,8 +15,6 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
||||
|
||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
|
||||
import {
|
||||
@@ -47,14 +43,15 @@ import {
|
||||
|
||||
const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
|
||||
|
||||
|
||||
type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
type FieldErrors = {
|
||||
|
||||
phone?: string;
|
||||
|
||||
storeSmsCode?: string;
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -79,16 +76,10 @@ export default function StoreCreatePage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { account, refresh } = usePartnerSession();
|
||||
const { account } = usePartnerSession();
|
||||
|
||||
const accountId = account?.id;
|
||||
|
||||
const wechatReady = !!account?.hasWechat;
|
||||
|
||||
const handleWechatReadyChange = useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const saved = loadStoreDraft(accountId);
|
||||
@@ -101,8 +92,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [checkingPhone, setCheckingPhone] = useState(false);
|
||||
|
||||
const [cities, setCities] = useState<OpenCityOption[]>([]);
|
||||
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
@@ -111,6 +100,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [smsHint, setSmsHint] = useState('');
|
||||
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
|
||||
const draftSaveDisabledRef = useRef(false);
|
||||
|
||||
function reportFormError(message: string) {
|
||||
@@ -142,17 +133,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 2 || !isWechatEnv()) return;
|
||||
void refresh();
|
||||
weixinSdk.reset();
|
||||
void weixinSdk.init().catch(() => {
|
||||
/* OssUploadField 点击时会再次初始化 */
|
||||
});
|
||||
}, [step, refresh]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
void fetchPartnerCities()
|
||||
@@ -172,6 +152,26 @@ export default function StoreCreatePage() {
|
||||
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void request<StoreCategoryNode[]>('PARTNER_H5', '/partner/store-categories')
|
||||
.then((list) => {
|
||||
const tree = Array.isArray(list) ? list : [];
|
||||
setCategoryTree(tree);
|
||||
if (form.categoryId && !form.categoryParentId) {
|
||||
const parent = tree.find((root) =>
|
||||
(root.children ?? []).some((child) => child.id === form.categoryId),
|
||||
);
|
||||
if (parent) patchForm({ categoryParentId: parent.id });
|
||||
}
|
||||
})
|
||||
.catch(() => setCategoryTree([]));
|
||||
}, []);
|
||||
|
||||
const categoryChildren = useMemo(() => {
|
||||
const parent = categoryTree.find((item) => item.id === form.categoryParentId);
|
||||
return Array.isArray(parent?.children) ? parent!.children! : [];
|
||||
}, [categoryTree, form.categoryParentId]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
@@ -373,54 +373,10 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep1(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
} else {
|
||||
reportFormError(msg);
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPhone(true);
|
||||
|
||||
setSubmitError('');
|
||||
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
|
||||
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
|
||||
|
||||
if (!phoneCheck.available) {
|
||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
||||
setFieldErrors({ phone: phoneMsg });
|
||||
return;
|
||||
}
|
||||
if (phoneCheck.needConfirm) {
|
||||
const ok = window.confirm(
|
||||
phoneCheck.message ??
|
||||
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
|
||||
} finally {
|
||||
|
||||
setCheckingPhone(false);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
@@ -445,6 +401,14 @@ export default function StoreCreatePage() {
|
||||
const msg = validateStoreStep3(form);
|
||||
|
||||
if (msg) {
|
||||
if (isPhoneValidationMessage(msg)) {
|
||||
if (msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: msg });
|
||||
}
|
||||
return;
|
||||
}
|
||||
reportFormError(msg);
|
||||
return;
|
||||
}
|
||||
@@ -452,15 +416,6 @@ export default function StoreCreatePage() {
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
if (isPhoneValidationMessage(step1Msg)) {
|
||||
if (step1Msg.includes('验证码')) {
|
||||
setFieldErrors({ storeSmsCode: step1Msg });
|
||||
} else {
|
||||
setFieldErrors({ phone: step1Msg });
|
||||
}
|
||||
goStep(1);
|
||||
return;
|
||||
}
|
||||
reportFormError(step1Msg);
|
||||
goStep(1);
|
||||
return;
|
||||
@@ -500,6 +455,8 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
setSubmitting(false);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
@@ -511,6 +468,7 @@ export default function StoreCreatePage() {
|
||||
);
|
||||
if (!ok) {
|
||||
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
confirmBindExisting = true;
|
||||
@@ -518,6 +476,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -549,6 +508,12 @@ export default function StoreCreatePage() {
|
||||
|
||||
address: form.address.trim(),
|
||||
|
||||
openTime: form.openTime.trim(),
|
||||
|
||||
closeTime: form.closeTime.trim(),
|
||||
|
||||
categoryId: form.categoryId.trim(),
|
||||
|
||||
intro: form.intro.trim() || undefined,
|
||||
|
||||
coverUrl: form.coverUrl.trim() || undefined,
|
||||
@@ -586,7 +551,7 @@ export default function StoreCreatePage() {
|
||||
}
|
||||
if (/验证码/.test(message)) {
|
||||
setFieldErrors({ storeSmsCode: message });
|
||||
goStep(1);
|
||||
goStep(3);
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
@@ -603,17 +568,13 @@ export default function StoreCreatePage() {
|
||||
|
||||
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
|
||||
|
||||
const nextDisabled = submitting || checkingPhone;
|
||||
const nextDisabled = submitting;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
|
||||
<div className="partner-page-sticky">
|
||||
|
||||
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
|
||||
|
||||
|
||||
<div className="partner-page-sticky partner-home--flush-top">
|
||||
|
||||
<nav className="partner-stepper">
|
||||
|
||||
@@ -713,100 +674,58 @@ export default function StoreCreatePage() {
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
||||
<label>店铺类型 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-field-input">
|
||||
<div className="partner-input-row" style={{ gap: 8 }}>
|
||||
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<select
|
||||
|
||||
<input
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="tel"
|
||||
value={form.categoryParentId}
|
||||
|
||||
placeholder="请输入11位手机号"
|
||||
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })}
|
||||
|
||||
value={form.phone}
|
||||
|
||||
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{fieldErrors.phone && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
|
||||
)}
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
验证码将发送至该手机号,需门店负责人确认后方可录入
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row">
|
||||
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-input"
|
||||
|
||||
type="text"
|
||||
|
||||
inputMode="numeric"
|
||||
|
||||
maxLength={6}
|
||||
|
||||
placeholder="请输入短信验证码"
|
||||
|
||||
value={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || checkingPhone}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
aria-label="一级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
<option value="">选择大类</option>
|
||||
|
||||
</button>
|
||||
{categoryTree.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
<select
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
value={form.categoryId}
|
||||
|
||||
onChange={(e) => patchForm({ categoryId: e.target.value })}
|
||||
|
||||
disabled={!form.categoryParentId}
|
||||
|
||||
aria-label="二级店铺类型"
|
||||
|
||||
>
|
||||
|
||||
<option value="">{form.categoryParentId ? '选择细类' : '请先选大类'}</option>
|
||||
|
||||
{categoryChildren.map((item) => (
|
||||
|
||||
<option key={item.id} value={item.id}>{item.name}</option>
|
||||
|
||||
))}
|
||||
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -817,6 +736,52 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>营业时间 <span className="text-primary">*</span></label>
|
||||
|
||||
<div className="partner-input-row" style={{ alignItems: 'center', gap: 8 }}>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.openTime}
|
||||
|
||||
onChange={(e) => patchForm({ openTime: e.target.value })}
|
||||
|
||||
aria-label="营业开始时间"
|
||||
|
||||
/>
|
||||
|
||||
<span className="label-md text-muted">至</span>
|
||||
|
||||
<input
|
||||
|
||||
className="partner-field-input partner-field-input--block"
|
||||
|
||||
type="time"
|
||||
|
||||
value={form.closeTime}
|
||||
|
||||
onChange={(e) => patchForm({ closeTime: e.target.value })}
|
||||
|
||||
aria-label="营业结束时间"
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
将展示给用户端与门店端,默认 10:00–22:00,可按实际调整。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>门店简介</label>
|
||||
@@ -895,13 +860,9 @@ export default function StoreCreatePage() {
|
||||
|
||||
value={form.coverUrl}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={handleWechatReadyChange}
|
||||
|
||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||
|
||||
label="点击或拖拽上传"
|
||||
label="从系统相册选择"
|
||||
|
||||
/>
|
||||
|
||||
@@ -931,10 +892,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
value={url}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={handleWechatReadyChange}
|
||||
|
||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||
|
||||
/>
|
||||
@@ -951,7 +908,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>上传签约协议首页与盖章页</p>
|
||||
|
||||
<OssUploadField
|
||||
|
||||
@@ -963,10 +920,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
value={form.contractUrl}
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={handleWechatReadyChange}
|
||||
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
|
||||
label="上传合同副本"
|
||||
@@ -1007,6 +960,54 @@ export default function StoreCreatePage() {
|
||||
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>户主姓名 *</label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>银行卡号 *</label>
|
||||
|
||||
<input
|
||||
className="partner-field-input partner-field-input--block"
|
||||
inputMode="numeric"
|
||||
autoComplete="cc-number"
|
||||
maxLength={19}
|
||||
placeholder="请输入16-19位银行卡号"
|
||||
value={form.bankAccountNo}
|
||||
onChange={(e) => patchForm({ bankAccountNo: e.target.value.replace(/\D/g, '').slice(0, 19) })}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>开户支行 *</label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
@@ -1039,54 +1040,76 @@ export default function StoreCreatePage() {
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
|
||||
该手机号将作为门店端登录账号,提交前会再次校验是否已被占用。
|
||||
该手机号将作为门店端登录账号,验证码发送至该号确认后方可提交。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
<div className="partner-field">
|
||||
|
||||
<label>户主姓名 *</label>
|
||||
<label>门店账号验证码 <span className="text-primary">*</span></label>
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
|
||||
<div className="partner-input-row">
|
||||
|
||||
</div>
|
||||
<div className="partner-input-wrap" style={{ flex: 1 }}>
|
||||
|
||||
<div className="partner-field">
|
||||
<span className="material-symbols-outlined partner-input-icon">shield</span>
|
||||
|
||||
<label>银行卡号 *</label>
|
||||
<input
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
|
||||
className="partner-input"
|
||||
|
||||
</div>
|
||||
type="text"
|
||||
|
||||
<div className="partner-field">
|
||||
inputMode="numeric"
|
||||
|
||||
<label>开户支行 *</label>
|
||||
maxLength={6}
|
||||
|
||||
<input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
|
||||
placeholder="请输入短信验证码"
|
||||
|
||||
value={form.storeSmsCode}
|
||||
|
||||
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<button
|
||||
|
||||
type="button"
|
||||
|
||||
className="partner-code-btn"
|
||||
|
||||
disabled={smsCooldown > 0 || submitting}
|
||||
|
||||
onClick={() => void sendStorePhoneCode()}
|
||||
|
||||
>
|
||||
|
||||
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{smsHint && (
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
|
||||
|
||||
)}
|
||||
|
||||
{fieldErrors.storeSmsCode && (
|
||||
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
|
||||
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<div className="partner-info-banner" style={{ background: 'rgba(254,214,91,0.15)', borderColor: 'rgba(254,214,91,0.3)' }}>
|
||||
|
||||
<span className="material-symbols-outlined text-secondary">info</span>
|
||||
|
||||
<p className="body-md" style={{ color: 'var(--color-on-secondary-container)' }}>
|
||||
|
||||
请确保银行卡信息准确,以免影响每月的餐费结算。
|
||||
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
</>
|
||||
|
||||
)}
|
||||
@@ -1095,7 +1118,11 @@ export default function StoreCreatePage() {
|
||||
|
||||
<footer className="partner-sticky-footer">
|
||||
|
||||
{step > 1 && (
|
||||
{step === 1 ? (
|
||||
|
||||
<button type="button" className="partner-btn-outline" onClick={() => navigate('/stores')} disabled={nextDisabled}>返回</button>
|
||||
|
||||
) : (
|
||||
|
||||
<button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}>上一步</button>
|
||||
|
||||
@@ -1105,7 +1132,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
|
||||
|
||||
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
|
||||
<span>下一步</span>
|
||||
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
@@ -47,7 +46,7 @@ export default function StoreDetailPage() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mediaSaving, setMediaSaving] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
const [wechatReady, setWechatReady] = useState(false);
|
||||
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false);
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
@@ -93,8 +92,8 @@ export default function StoreDetailPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
@@ -105,7 +104,27 @@ export default function StoreDetailPage() {
|
||||
});
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
toastSuccess('状态已更新');
|
||||
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!id || statusSaving) return;
|
||||
setCloseConfirmOpen(false);
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
setStatus('CLOSED');
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: 'CLOSED' } : prev));
|
||||
toastSuccess('门店已关闭');
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
@@ -179,8 +198,7 @@ export default function StoreDetailPage() {
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<div className="empty">{loadError}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -202,10 +220,8 @@ export default function StoreDetailPage() {
|
||||
const canOpen = canPartnerOpenStore(auditStatus);
|
||||
|
||||
return (
|
||||
<div className="partner-detail-page">
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
<div className="partner-detail-page partner-home--flush-top">
|
||||
<main style={{ padding: '12px 20px 16px' }}>
|
||||
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
@@ -284,10 +300,8 @@ export default function StoreDetailPage() {
|
||||
bizType="STORE_TITLE"
|
||||
mediaType="IMAGE"
|
||||
value={coverUrl}
|
||||
wechatReady={wechatReady}
|
||||
onWechatReadyChange={setWechatReady}
|
||||
onChange={setCoverUrl}
|
||||
label="点击更换门头照"
|
||||
label="从系统相册选择"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -336,8 +350,6 @@ export default function StoreDetailPage() {
|
||||
bizType="STORE_ENV"
|
||||
mediaType="IMAGE"
|
||||
value={url}
|
||||
wechatReady={wechatReady}
|
||||
onWechatReadyChange={setWechatReady}
|
||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||
/>
|
||||
))}
|
||||
@@ -390,6 +402,25 @@ export default function StoreDetailPage() {
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{closeConfirmOpen && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseConfirmOpen(false)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseConfirmOpen(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { isLoggedIn, request } from '../lib/api';
|
||||
import { toastSuccess } from '../lib/toast';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ export default function StoreListPage() {
|
||||
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [closeTarget, setCloseTarget] = useState<string | null>(null);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
|
||||
@@ -66,8 +69,8 @@ export default function StoreListPage() {
|
||||
return;
|
||||
}
|
||||
if (next === 'CLOSED') {
|
||||
const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
|
||||
if (!ok) return;
|
||||
setCloseTarget(storeId);
|
||||
return;
|
||||
}
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
@@ -77,6 +80,26 @@ export default function StoreListPage() {
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
await loadStores();
|
||||
if (next === 'OPEN') toastSuccess('开店成功');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCloseStore() {
|
||||
if (!closeTarget) return;
|
||||
const storeId = closeTarget;
|
||||
setCloseTarget(null);
|
||||
setUpdatingId(storeId);
|
||||
setError('');
|
||||
try {
|
||||
await request('PARTNER_H5', `/partner/stores/${storeId}/status`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ status: 'CLOSED' }),
|
||||
});
|
||||
await loadStores();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
@@ -85,7 +108,7 @@ export default function StoreListPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page partner-store-page partner-home--flush-top">
|
||||
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top">
|
||||
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
|
||||
|
||||
<div className="partner-sticky-filter">
|
||||
@@ -188,6 +211,24 @@ export default function StoreListPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{closeTarget && (
|
||||
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setCloseTarget(null)}>
|
||||
<div className="partner-ship-modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>确认关闭门店?</h3>
|
||||
<p className="body-md text-muted" style={{ lineHeight: 1.5 }}>
|
||||
关闭后不可恢复营业,确认关闭该门店?
|
||||
</p>
|
||||
<div className="partner-ship-actions">
|
||||
<button type="button" className="partner-btn-secondary" onClick={() => setCloseTarget(null)}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void confirmCloseStore()}>
|
||||
确认关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
|
||||
|
||||
function fmtMoney(n: number) {
|
||||
@@ -20,10 +21,10 @@ export default function WeeklyReportPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const loadWeeklyReport = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
fetchPartnerWeeklyReport(selectedStart)
|
||||
return fetchPartnerWeeklyReport(selectedStart)
|
||||
.then(setData)
|
||||
.catch((e) => {
|
||||
setData(null);
|
||||
@@ -32,6 +33,10 @@ export default function WeeklyReportPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [selectedStart]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWeeklyReport();
|
||||
}, [loadWeeklyReport]);
|
||||
|
||||
const maxDailyGmv = useMemo(() => {
|
||||
if (!data?.dailyGmv.length) return 1;
|
||||
return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
|
||||
@@ -42,7 +47,7 @@ export default function WeeklyReportPage() {
|
||||
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="page-no-tab">
|
||||
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab">
|
||||
<PageHeader title="数据周报" onBack={() => navigate('/')} />
|
||||
|
||||
<main className="partner-weekly-page">
|
||||
@@ -211,6 +216,6 @@ export default function WeeklyReportPage() {
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3329,10 +3329,16 @@ body {
|
||||
}
|
||||
|
||||
.partner-home--flush-top.partner-store-page,
|
||||
.partner-home--flush-top.partner-center-page {
|
||||
.partner-home--flush-top.partner-center-page,
|
||||
.partner-home--flush-top.partner-detail-page,
|
||||
.partner-home--flush-top.partner-page-sticky {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.partner-home--flush-top.partner-page-sticky .partner-stepper {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.partner-home--flush-top .partner-sticky-filter {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/partner/',
|
||||
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import LoginPage from './pages/LoginPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import SelectStorePage from './pages/SelectStorePage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
@@ -22,7 +21,7 @@ export default function App() {
|
||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||
@@ -26,10 +25,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getStoreProfile();
|
||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setUploading(true);
|
||||
@@ -49,7 +50,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
}
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '选图失败';
|
||||
if (!/cancel/i.test(text)) setMsg(text);
|
||||
if (!/cancel/i.test(text)) {
|
||||
setMsg(`${text},可改从系统相册选择`);
|
||||
setShowAlbumFallback(true);
|
||||
inputRef.current?.click();
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
@@ -120,11 +125,13 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
if (file) {
|
||||
setShowAlbumFallback(false);
|
||||
void handleFile(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{previewUrl && (
|
||||
@@ -134,6 +141,16 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
||||
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||
</button>
|
||||
{showAlbumFallback && isWechatEnv() && (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-btn-outline"
|
||||
disabled={uploading}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
从系统相册选择
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
|
||||
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
|
||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||
|
||||
/** 微信验证通过后的免登录时长 */
|
||||
/** 手机号或微信验证通过后的免登录时长 */
|
||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
|
||||
}
|
||||
|
||||
export function touchShopSession() {
|
||||
if (!hasShopWxSession()) return;
|
||||
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
@@ -119,11 +119,16 @@ export function saveAuth(data: ShopSessionPayload) {
|
||||
}
|
||||
}
|
||||
|
||||
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||
export function saveRememberedSession(data: ShopSessionPayload) {
|
||||
saveAuth(data);
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: ShopSessionPayload) {
|
||||
saveAuth(data);
|
||||
saveRememberedSession(data);
|
||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||
import {
|
||||
authorizeShopWechat,
|
||||
checkNeedsWechatAuth,
|
||||
fetchShopAccount,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||
|
||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function formatScanError(e: unknown): string {
|
||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||
}
|
||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
||||
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [scanMsg, setScanMsg] = useState('');
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
|
||||
const loadDashboard = useCallback(() => {
|
||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||
@@ -58,7 +27,6 @@ export default function HomePage() {
|
||||
|
||||
useEffect(() => {
|
||||
function onResume() {
|
||||
setScanning(false);
|
||||
void loadDashboard();
|
||||
}
|
||||
function onVisibility() {
|
||||
@@ -74,95 +42,13 @@ export default function HomePage() {
|
||||
};
|
||||
}, [loadDashboard]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
}
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
if (shouldScan) {
|
||||
window.setTimeout(() => void runScan(), 0);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
});
|
||||
}, [searchParams, applySession, setSearchParams]);
|
||||
|
||||
async function runScan() {
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
setScanMsg('');
|
||||
try {
|
||||
await weixinSdk.init();
|
||||
const raw = await weixinSdk.scanQrCode();
|
||||
if (!raw) {
|
||||
void loadDashboard();
|
||||
return;
|
||||
}
|
||||
const token = parseRedeemTokenFromScan(raw);
|
||||
if (!token) {
|
||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||
return;
|
||||
}
|
||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||
} catch (e) {
|
||||
setScanMsg(formatScanError(e));
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScan() {
|
||||
setScanMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const profile = await fetchShopAccount();
|
||||
if (await checkNeedsWechatAuth(profile)) {
|
||||
setAuthModalOpen(true);
|
||||
return;
|
||||
}
|
||||
await runScan();
|
||||
} catch (e) {
|
||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||
}
|
||||
}
|
||||
|
||||
async function startWechatAuth() {
|
||||
setAuthLoading(true);
|
||||
setAuthError('');
|
||||
try {
|
||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||
await authorizeShopWechat();
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const store = dash?.store as Record<string, unknown> | undefined;
|
||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||
const openTime = String(store?.openTime || '10:00');
|
||||
const closeTime = String(store?.closeTime || '22:00');
|
||||
|
||||
return (
|
||||
<div className="shop-home-page">
|
||||
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page">
|
||||
<header className="shop-home-header">
|
||||
<h1 className="app-page-title">门店管理中心</h1>
|
||||
</header>
|
||||
@@ -189,20 +75,13 @@ export default function HomePage() {
|
||||
</section>
|
||||
|
||||
<section className="shop-home-scan">
|
||||
<button
|
||||
type="button"
|
||||
<Link
|
||||
to="/redeem/phone"
|
||||
className="shop-home-scan-btn"
|
||||
disabled={scanning}
|
||||
onClick={() => void handleScan()}
|
||||
>
|
||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||
</button>
|
||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
||||
<span className="material-symbols-outlined">smartphone</span>
|
||||
手机号核销
|
||||
</Link>
|
||||
<p className="shop-home-scan-label">手机号核销</p>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-status">
|
||||
@@ -249,17 +128,6 @@ export default function HomePage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<WechatScanAuthModal
|
||||
open={authModalOpen}
|
||||
loading={authLoading}
|
||||
error={authError}
|
||||
onAuthorize={() => void startWechatAuth()}
|
||||
onCancel={() => {
|
||||
setAuthModalOpen(false);
|
||||
setAuthError('');
|
||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,14 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useRef, useState, type RefObject } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
||||
import { routeAfterShopLogin } from './SelectStorePage';
|
||||
import {
|
||||
bindShopWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
handleShopWechatCallback,
|
||||
handleShopWechatLoginResult,
|
||||
loginShopWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
function maskPhone(phone: string) {
|
||||
if (phone.length < 7) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
getLastPhone,
|
||||
request,
|
||||
saveRememberedSession,
|
||||
type ShopSessionPayload,
|
||||
} from '../lib/api';
|
||||
import { routeAfterShopLogin } from './SelectStorePage';
|
||||
|
||||
function ShopAgreementCheckbox({
|
||||
agreed,
|
||||
@@ -35,7 +17,7 @@ function ShopAgreementCheckbox({
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onChange: (next: boolean) => void;
|
||||
labelRef?: RefObject<HTMLLabelElement | null>;
|
||||
labelRef?: RefObject<HTMLLabelElement>;
|
||||
}) {
|
||||
return (
|
||||
<label className="shop-login-agreement" ref={labelRef}>
|
||||
@@ -61,44 +43,14 @@ function ShopAgreementCheckbox({
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const { applySession } = useStoreSession();
|
||||
const [params, setSearchParams] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getStoreProfile();
|
||||
const [phone, setPhone] = useState(getLastPhone());
|
||||
const [code, setCode] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handleShopWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
const session = handleShopWechatLoginResult(result);
|
||||
if (session) {
|
||||
applySession(session);
|
||||
stripOAuthParamsFromLocation();
|
||||
setSearchParams({}, { replace: true });
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
})
|
||||
.catch((e) => setMsg(formatWechatError(e)));
|
||||
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
||||
|
||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先阅读并同意用户协议');
|
||||
@@ -141,13 +93,8 @@ export default function LoginPage() {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveAuth(data);
|
||||
saveRememberedSession(data);
|
||||
applySession(data);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindShopWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
routeAfterShopLogin(data, navigate);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
@@ -156,103 +103,6 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function wechatLogin() {
|
||||
if (!ensureAgreed()) return;
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg('请在微信内打开以使用微信一键登录');
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const session = await loginShopWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
routeAfterShopLogin(session, navigate);
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
||||
|
||||
return (
|
||||
<div className="shop-quick-login-page">
|
||||
<header className="shop-quick-header">
|
||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
|
||||
<h1 className="shop-quick-welcome">欢迎回来</h1>
|
||||
<div className="shop-quick-welcome-line" />
|
||||
</header>
|
||||
|
||||
<section className="shop-quick-store-card">
|
||||
<div className="shop-quick-store-inner">
|
||||
<div className="shop-quick-store-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
||||
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
||||
</div>
|
||||
<span className="shop-quick-verified">
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
||||
认证门店
|
||||
</span>
|
||||
<div className="shop-quick-switch">
|
||||
<Link to="/login">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
|
||||
切换账号
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="shop-quick-actions">
|
||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
||||
<ShopAgreementCheckbox
|
||||
agreed={agreed}
|
||||
onChange={setAgreed}
|
||||
labelRef={agreementRef}
|
||||
/>
|
||||
{canWechatQuick ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
) : (
|
||||
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
||||
{wxAuthorize && !isWechatEnv()
|
||||
? '请在微信内打开以使用一键登录'
|
||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
||||
</p>
|
||||
)}
|
||||
{!canWechatQuick && (
|
||||
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
||||
验证码登录
|
||||
</Link>
|
||||
)}
|
||||
<div className="shop-quick-secure">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
||||
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="shop-quick-footer">
|
||||
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
|
||||
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 杜康酒业门店管理系统</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-login-page">
|
||||
<header className="shop-login-hero">
|
||||
@@ -322,26 +172,9 @@ export default function LoginPage() {
|
||||
<span>{loading ? '登录中...' : '登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<div className="shop-login-divider">
|
||||
<span className="shop-login-divider-line" />
|
||||
<span className="shop-login-divider-text">或者</span>
|
||||
<span className="shop-login-divider-line" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-login-wechat"
|
||||
disabled={wxLoading}
|
||||
onClick={() => void wechatLogin()}
|
||||
>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
|
||||
手机号验证成功后,7 天内无需再次输入验证码
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -350,11 +183,6 @@ export default function LoginPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
||||
security
|
||||
</span>
|
||||
{hasShopWxSession() && savedProfile && (
|
||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
||||
</p>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
@@ -24,21 +25,21 @@ export default function MinePage() {
|
||||
const [binding, setBinding] = useState(false);
|
||||
const [bindMsg, setBindMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
|
||||
const loadMine = useCallback(() => {
|
||||
return Promise.all([
|
||||
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null)),
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false)),
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null)),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchShopAccount()
|
||||
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
|
||||
.catch(() => setHasWechat(null));
|
||||
}, []);
|
||||
void loadMine();
|
||||
}, [loadMine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
|
||||
@@ -92,7 +93,7 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-mine-page">
|
||||
<PullToRefresh onRefresh={loadMine} className="shop-mine-page">
|
||||
<header className="shop-mine-header">
|
||||
<h1 className="app-page-title">我的</h1>
|
||||
</header>
|
||||
@@ -174,6 +175,6 @@ export default function MinePage() {
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,33 +1,22 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type BalanceResult = {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
||||
};
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Step = 'lookup' | 'amount' | 'confirm';
|
||||
|
||||
export default function PhoneRedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>('lookup');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [lookupCode, setLookupCode] = useState('');
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (lookupCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lookupCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendLookupSms() {
|
||||
async function sendConfirmSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim() }),
|
||||
});
|
||||
setLookupCooldown(60);
|
||||
setMsg('验证码已发送至用户手机');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBalance() {
|
||||
if (!lookupCode.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
||||
});
|
||||
setBalance(res);
|
||||
setStep('amount');
|
||||
setMsg('');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRedeem() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
if (balance && value > balance.totalBalance) {
|
||||
setMsg('核销金额不能超过可用权益');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
||||
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
||||
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||
});
|
||||
setPrepared(result);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(60);
|
||||
setStep('confirm');
|
||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
||||
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
||||
setPrepared(null);
|
||||
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!prepared) {
|
||||
setMsg('请先发送核销验证码');
|
||||
return;
|
||||
}
|
||||
if (!confirmCode.trim()) {
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: balance?.sessionId,
|
||||
sessionId: prepared.sessionId,
|
||||
code: confirmCode.trim(),
|
||||
}),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', {
|
||||
state: { result, storeName, user: balance?.user },
|
||||
state: { result, storeName, user: prepared.user },
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
||||
const amountValue = Number(amount);
|
||||
const canSendCode =
|
||||
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-body">
|
||||
{step === 'lookup' && (
|
||||
<>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户收到的验证码"
|
||||
value={lookupCode}
|
||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
||||
onClick={() => void sendLookupSms()}
|
||||
>
|
||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void queryBalance()}
|
||||
>
|
||||
查询权益
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setPhone(e.target.value.replace(/\D/g, ''));
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'amount' && balance && (
|
||||
<>
|
||||
<div className="shop-redeem-user">
|
||||
<div className="shop-redeem-user-left">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span>用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-redeem-amount-section">
|
||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
||||
<div className="shop-redeem-amount">
|
||||
<span className="shop-redeem-amount-symbol">¥</span>
|
||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入核销金额"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
||||
onClick={() => void prepareRedeem()}
|
||||
>
|
||||
发送确认验证码并核销
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('lookup');
|
||||
setBalance(null);
|
||||
setAmount('');
|
||||
setLookupCode('');
|
||||
}}
|
||||
>
|
||||
更换手机号
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">待核销金额</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="请输入待核销金额"
|
||||
value={amount}
|
||||
disabled={loading}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value);
|
||||
setPrepared(null);
|
||||
setConfirmCode('');
|
||||
setConfirmCooldown(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{step === 'confirm' && balance && (
|
||||
<>
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>用户</span>
|
||||
<span>{userLabel}</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>核销金额</span>
|
||||
<span>¥{formatAmount(Number(amount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销确认验证码</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="用户手机收到的确认码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">核销验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
placeholder="输入用户收到的验证码"
|
||||
value={confirmCode}
|
||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void confirmRedeem()}
|
||||
className="shop-phone-code-btn"
|
||||
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||
onClick={() => void sendConfirmSms()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
||||
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('amount');
|
||||
setConfirmCode('');
|
||||
}}
|
||||
>
|
||||
返回修改金额
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||
onClick={() => void confirmRedeem()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||
</button>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type RangeKey = 'today' | '7d' | '30d';
|
||||
@@ -28,15 +29,21 @@ export default function RecordsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [storeName, setStoreName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
});
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {});
|
||||
const loadRecords = useCallback(() => {
|
||||
return Promise.all([
|
||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
||||
setRecords(d.list || []);
|
||||
}),
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '')))
|
||||
.catch(() => {}),
|
||||
]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRecords();
|
||||
}, [loadRecords]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
@@ -55,7 +62,7 @@ export default function RecordsPage() {
|
||||
}, [filtered]);
|
||||
|
||||
return (
|
||||
<div className="shop-records-page">
|
||||
<PullToRefresh onRefresh={loadRecords} className="shop-records-page">
|
||||
<header className="shop-records-header">
|
||||
<h1 className="app-page-title">核销记录</h1>
|
||||
</header>
|
||||
@@ -176,6 +183,6 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import {
|
||||
needsStoreSelection,
|
||||
@@ -19,15 +20,19 @@ export default function SelectStorePage() {
|
||||
const currentStoreId = store?.storeId || '';
|
||||
const canGoBack = Boolean(currentStoreId);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
return request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
|
||||
.then((list) => setStores(list))
|
||||
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
|
||||
}, [authenticated, navigate]);
|
||||
void loadStores();
|
||||
}, [authenticated, navigate, loadStores]);
|
||||
|
||||
async function onSelect(storeId: string) {
|
||||
if (loadingId) return;
|
||||
@@ -63,7 +68,7 @@ export default function SelectStorePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shop-select-store-page">
|
||||
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page">
|
||||
<header className="shop-subpage-header">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
@@ -151,7 +156,7 @@ export default function SelectStorePage() {
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { getStoreProfile, request } from '../lib/api';
|
||||
|
||||
@@ -116,7 +117,7 @@ export default function StaffPage() {
|
||||
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
|
||||
|
||||
return (
|
||||
<div className="shop-staff-page">
|
||||
<PullToRefresh onRefresh={reload} className="shop-staff-page">
|
||||
<header className="shop-subpage-header">
|
||||
<button
|
||||
type="button"
|
||||
@@ -322,6 +323,6 @@ export default function StaffPage() {
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/shop/',
|
||||
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# 企业微信客服链接(覆盖 shared-types 默认值)
|
||||
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
|
||||
@@ -20,6 +20,10 @@ import RedeemCodePage from './pages/RedeemCodePage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import AfterSalePage from './pages/AfterSalePage';
|
||||
import AfterSaleListPage from './pages/AfterSaleListPage';
|
||||
import InvoiceApplyPage from './pages/InvoiceApplyPage';
|
||||
import InvoiceListPage from './pages/InvoiceListPage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
import { capturePromoFromUrl } from './lib/promo';
|
||||
import WechatShareBootstrap from './components/WechatShareBootstrap';
|
||||
@@ -51,6 +55,10 @@ export default function App() {
|
||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||
<Route path="/pay" element={<PayPage />} />
|
||||
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||
<Route path="/after-sale" element={<AfterSalePage />} />
|
||||
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
|
||||
<Route path="/invoices" element={<InvoiceListPage />} />
|
||||
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
|
||||
<Route path="/addresses" element={<AddressListPage />} />
|
||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import { track } from '../lib/analytics';
|
||||
import { openWecomCustomerService } from '../lib/customer-service';
|
||||
|
||||
type ContactCustomerSheetProps = {
|
||||
orderId?: string;
|
||||
@@ -8,8 +8,7 @@ type ContactCustomerSheetProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
|
||||
const navigate = useNavigate();
|
||||
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
function openPhone() {
|
||||
@@ -18,13 +17,11 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
|
||||
onClose();
|
||||
}
|
||||
|
||||
function openChat() {
|
||||
track('cs_contact', { type: 'chat', orderId });
|
||||
const qs = new URLSearchParams();
|
||||
if (orderId) qs.set('orderId', orderId);
|
||||
if (orderNo) qs.set('orderNo', orderNo);
|
||||
onClose();
|
||||
navigate(`/customer-service?${qs.toString()}`);
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf', orderId });
|
||||
if (openWecomCustomerService()) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,7 +46,7 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="contact-customer-option" onClick={openChat}>
|
||||
<button type="button" className="contact-customer-option" onClick={openOnline}>
|
||||
<div className="contact-customer-option-icon">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
|
||||
import { isWechatEnv } from './weixin';
|
||||
|
||||
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
|
||||
export function getCustomerServiceWecomUrl(): string {
|
||||
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
|
||||
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
|
||||
* @returns true 已跳转;false 非微信环境已提示
|
||||
*/
|
||||
export function openWecomCustomerService(): boolean {
|
||||
if (!isWechatEnv()) {
|
||||
window.alert('请在微信中打开以联系在线客服');
|
||||
return false;
|
||||
}
|
||||
window.location.href = getCustomerServiceWecomUrl();
|
||||
return true;
|
||||
}
|
||||
|
||||
export { CUSTOMER_SERVICE_PHONE };
|
||||
@@ -0,0 +1,37 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS */
|
||||
export async function uploadFileToOss(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', options.bizType);
|
||||
formData.append('mediaType', mediaType);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'X-Client-App': 'USER_H5',
|
||||
};
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '上传失败');
|
||||
return json.data as UploadFileResult;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type TicketRow = {
|
||||
id: string;
|
||||
ticketNo: string;
|
||||
ticketType: TicketTypeDto;
|
||||
status: string;
|
||||
orderNo?: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
PENDING: '待处理',
|
||||
OPEN: '处理中',
|
||||
RESOLVED: '已完成',
|
||||
REJECTED: '已驳回',
|
||||
CLOSED: '已关闭',
|
||||
};
|
||||
|
||||
export default function AfterSaleListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<TicketRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
|
||||
.then((res) => setItems(res.items ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body">
|
||||
{loading ? (
|
||||
<p className="after-sale-empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="after-sale-empty-box">
|
||||
<p className="after-sale-empty">暂无售后工单</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||
申请售后
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="after-sale-order-list">
|
||||
{items.map((t) => (
|
||||
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
|
||||
<div className="after-sale-ticket-head">
|
||||
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
|
||||
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
|
||||
</div>
|
||||
<p className="after-sale-order-no">{t.ticketNo}</p>
|
||||
<p className="after-sale-order-meta">订单 {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
|
||||
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
|
||||
新建售后
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
AFTER_SALE_TICKET_TYPES,
|
||||
TICKET_TYPE_LABELS,
|
||||
type AfterSaleTicketType,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { uploadFileToOss } from '../lib/upload';
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo: string;
|
||||
status: string;
|
||||
payAmount: number | string;
|
||||
productName?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
|
||||
|
||||
export default function AfterSalePage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const presetOrderId = params.get('orderId') || '';
|
||||
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
|
||||
|
||||
const [step, setStep] = useState(0);
|
||||
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
|
||||
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
|
||||
);
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [orderId, setOrderId] = useState(presetOrderId);
|
||||
const [remark, setRemark] = useState('');
|
||||
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [ticketNo, setTicketNo] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
|
||||
])
|
||||
.then(([paid, completed]) => {
|
||||
const map = new Map<string, OrderRow>();
|
||||
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
|
||||
setOrders([...map.values()]);
|
||||
})
|
||||
.catch(() => setOrders([]));
|
||||
}, []);
|
||||
|
||||
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
|
||||
|
||||
async function onPickFiles(files: FileList | null) {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploaded: string[] = [];
|
||||
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
|
||||
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
|
||||
uploaded.push(res.url);
|
||||
}
|
||||
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!ticketType || !orderId) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
ticketType,
|
||||
remark: remark.trim() || undefined,
|
||||
evidenceUrls,
|
||||
}),
|
||||
});
|
||||
setTicketNo(ticket.ticketNo);
|
||||
setStep(3);
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function nextFromType() {
|
||||
if (!ticketType) {
|
||||
window.alert('请选择售后类型');
|
||||
return;
|
||||
}
|
||||
setStep(1);
|
||||
}
|
||||
|
||||
function nextFromOrder() {
|
||||
if (!orderId) {
|
||||
window.alert('请选择订单');
|
||||
return;
|
||||
}
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
|
||||
|
||||
<div className="after-sale-steps">
|
||||
{STEPS.map((label, i) => (
|
||||
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<main className="after-sale-body">
|
||||
{step === 0 && (
|
||||
<div className="after-sale-type-list">
|
||||
{AFTER_SALE_TICKET_TYPES.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
|
||||
onClick={() => setTicketType(t)}
|
||||
>
|
||||
<span>{TICKET_TYPE_LABELS[t]}</span>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={nextFromType}>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className="after-sale-order-list">
|
||||
{orders.length === 0 ? (
|
||||
<p className="after-sale-empty">暂无可售后订单</p>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setOrderId(o.id)}
|
||||
>
|
||||
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
<div className="after-sale-actions">
|
||||
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
|
||||
上一步
|
||||
</button>
|
||||
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
|
||||
下一步
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="after-sale-form">
|
||||
<p className="after-sale-summary">
|
||||
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
|
||||
</p>
|
||||
<label className="after-sale-label">问题描述</label>
|
||||
<textarea
|
||||
className="after-sale-textarea"
|
||||
rows={4}
|
||||
placeholder="请描述问题(选填)"
|
||||
value={remark}
|
||||
onChange={(e) => setRemark(e.target.value)}
|
||||
/>
|
||||
<label className="after-sale-label">凭证图片(破损类建议上传)</label>
|
||||
<div className="after-sale-evidence">
|
||||
{evidenceUrls.map((url) => (
|
||||
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
|
||||
))}
|
||||
{evidenceUrls.length < 6 && (
|
||||
<label className="after-sale-evidence-add">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
disabled={uploading}
|
||||
onChange={(e) => void onPickFiles(e.target.files)}
|
||||
/>
|
||||
{uploading ? '上传中' : '+'}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="after-sale-actions">
|
||||
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
|
||||
上一步
|
||||
</button>
|
||||
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? '提交中…' : '提交工单'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="after-sale-success">
|
||||
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
|
||||
<p className="after-sale-success-title">售后已提交</p>
|
||||
<p className="after-sale-success-no">工单号 {ticketNo}</p>
|
||||
<p className="after-sale-success-hint">总部将尽快审核,请留意处理进度</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
|
||||
查看我的售后
|
||||
</button>
|
||||
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
|
||||
返回订单
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,186 +1,35 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: 'user' | 'agent' | 'system';
|
||||
text: string;
|
||||
time?: string;
|
||||
};
|
||||
|
||||
const QUICK_QUESTIONS = [
|
||||
{ key: 'logistics', label: '物流查询' },
|
||||
{ key: 'damage', label: '破损补发' },
|
||||
{ key: 'refund', label: '申请退款' },
|
||||
{ key: 'address', label: '修改地址' },
|
||||
] as const;
|
||||
|
||||
function nowLabel() {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function agentReply(userText: string, orderNo?: string) {
|
||||
if (/订单|DK\d+/i.test(userText) || orderNo) {
|
||||
return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。';
|
||||
}
|
||||
if (userText.includes('破损') || userText.includes('补发')) {
|
||||
return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。';
|
||||
}
|
||||
if (userText.includes('退款')) {
|
||||
return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。';
|
||||
}
|
||||
if (userText.includes('地址')) {
|
||||
return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。';
|
||||
}
|
||||
if (userText.includes('物流')) {
|
||||
return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。';
|
||||
}
|
||||
return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
|
||||
}
|
||||
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const orderId = params.get('orderId') || '';
|
||||
const orderNo = params.get('orderNo') || '';
|
||||
const [input, setInput] = useState('');
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [sending, setSending] = useState(false);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
useEffect(() => {
|
||||
const welcome: ChatMessage[] = [
|
||||
{ id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() },
|
||||
{
|
||||
id: 'agent-welcome',
|
||||
role: 'agent',
|
||||
text: orderNo
|
||||
? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?`
|
||||
: '您好,我是杜康好客客服。请问有什么可以帮您?',
|
||||
},
|
||||
];
|
||||
setMessages(welcome);
|
||||
}, [orderNo]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
function pushMessage(role: ChatMessage['role'], text: string) {
|
||||
setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]);
|
||||
function openOnline() {
|
||||
track('cs_contact', { type: 'wecom_kf' });
|
||||
openWecomCustomerService();
|
||||
}
|
||||
|
||||
async function sendText(text: string) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending) return;
|
||||
setSending(true);
|
||||
pushMessage('user', trimmed);
|
||||
setInput('');
|
||||
window.setTimeout(() => {
|
||||
pushMessage('agent', agentReply(trimmed, orderNo));
|
||||
setSending(false);
|
||||
}, 600);
|
||||
}
|
||||
|
||||
async function loadOrderContext() {
|
||||
if (!orderId) return null;
|
||||
try {
|
||||
return await request<Record<string, unknown>>('USER_H5', `/trade/orders/${orderId}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
loadOrderContext().then((order) => {
|
||||
if (!order) return;
|
||||
const no = String(order.orderNo || orderNo);
|
||||
if (no && !orderNo) {
|
||||
pushMessage('system', `已关联订单 ${no}`);
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [orderId]);
|
||||
|
||||
return (
|
||||
<div className="customer-service-page">
|
||||
<div className="customer-service-page customer-service-page--oa">
|
||||
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
|
||||
|
||||
{orderNo && (
|
||||
<div className="customer-service-order-card">
|
||||
<span className="material-symbols-outlined">receipt_long</span>
|
||||
<div>
|
||||
<p className="customer-service-order-label">当前咨询订单</p>
|
||||
<p className="customer-service-order-no">{orderNo}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="customer-service-oa-body">
|
||||
<p className="customer-service-wecom-title">杜康好客客服</p>
|
||||
<p className="customer-service-wecom-hint">点击下方按钮,在微信内进入在线客服会话</p>
|
||||
|
||||
<div className="customer-service-chat" ref={listRef}>
|
||||
{messages.map((m) => {
|
||||
if (m.role === 'system') {
|
||||
return (
|
||||
<div key={m.id} className="customer-service-time">
|
||||
<span>{m.text}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isUser = m.role === 'user';
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
className={`customer-service-bubble-row${isUser ? ' is-user' : ' is-agent'}`}
|
||||
>
|
||||
{!isUser && (
|
||||
<div className="customer-service-avatar" aria-hidden>
|
||||
<span className="material-symbols-outlined">support_agent</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`customer-service-bubble${isUser ? ' is-user' : ''}`}>{m.text}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="customer-service-quick">
|
||||
{QUICK_QUESTIONS.map((q) => (
|
||||
<button
|
||||
key={q.key}
|
||||
type="button"
|
||||
className="customer-service-quick-btn"
|
||||
disabled={sending}
|
||||
onClick={() => sendText(q.label)}
|
||||
>
|
||||
{q.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<footer className="customer-service-inputbar">
|
||||
<input
|
||||
type="text"
|
||||
className="customer-service-input"
|
||||
placeholder="请输入您的问题..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void sendText(input);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="customer-service-send"
|
||||
disabled={sending || !input.trim()}
|
||||
onClick={() => sendText(input)}
|
||||
>
|
||||
发送
|
||||
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}>
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系在线客服
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<a className="customer-service-phone-link" href={`tel:${tel}`}>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
或拨打客服电话 {CUSTOMER_SERVICE_PHONE}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceKind,
|
||||
type InvoiceTitleType,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
|
||||
|
||||
export default function InvoiceApplyPage() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const presetOrderId = params.get('orderId') || '';
|
||||
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [orderId, setOrderId] = useState(presetOrderId);
|
||||
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
|
||||
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
|
||||
const [titleName, setTitleName] = useState('');
|
||||
const [taxNo, setTaxNo] = useState('');
|
||||
const [addressPhone, setAddressPhone] = useState('');
|
||||
const [bankAccount, setBankAccount] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
|
||||
.then((res) => setOrders(res.list ?? []))
|
||||
.catch(() => setOrders([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
|
||||
}, [invoiceKind]);
|
||||
|
||||
async function submit() {
|
||||
if (!orderId) {
|
||||
window.alert('请选择订单');
|
||||
return;
|
||||
}
|
||||
if (!titleName.trim() || !email.trim() || !phone.trim()) {
|
||||
window.alert('请填写抬头名称、邮箱与手机号');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
titleType,
|
||||
invoiceKind,
|
||||
titleName: titleName.trim(),
|
||||
taxNo: taxNo.trim() || undefined,
|
||||
addressPhone: addressPhone.trim() || undefined,
|
||||
bankAccount: bankAccount.trim() || undefined,
|
||||
email: email.trim(),
|
||||
phone: phone.trim(),
|
||||
}),
|
||||
});
|
||||
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
|
||||
navigate('/invoices');
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body after-sale-form">
|
||||
<label className="after-sale-label">选择已完成订单</label>
|
||||
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
|
||||
{orders.length === 0 ? (
|
||||
<p className="after-sale-empty">暂无已完成订单</p>
|
||||
) : (
|
||||
orders.map((o) => (
|
||||
<button
|
||||
key={o.id}
|
||||
type="button"
|
||||
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
|
||||
onClick={() => setOrderId(o.id)}
|
||||
>
|
||||
<p className="after-sale-order-no">{o.orderNo}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
|
||||
</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">发票类型</label>
|
||||
<div className="invoice-chip-row">
|
||||
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
|
||||
onClick={() => setInvoiceKind(k)}
|
||||
>
|
||||
{INVOICE_KIND_LABELS[k]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">抬头类型</label>
|
||||
<div className="invoice-chip-row">
|
||||
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
|
||||
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
|
||||
onClick={() => setTitleType(t)}
|
||||
>
|
||||
{INVOICE_TITLE_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="after-sale-label">抬头名称</label>
|
||||
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
|
||||
|
||||
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||
<>
|
||||
<label className="after-sale-label">税号</label>
|
||||
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{invoiceKind === 'SPECIAL' && (
|
||||
<>
|
||||
<label className="after-sale-label">地址与电话</label>
|
||||
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
|
||||
<label className="after-sale-label">开户行与账号</label>
|
||||
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="after-sale-label">接收邮箱</label>
|
||||
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<label className="after-sale-label">手机号</label>
|
||||
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
|
||||
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
|
||||
{submitting ? '提交中…' : '提交申请'}
|
||||
</button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
INVOICE_KIND_LABELS,
|
||||
INVOICE_STATUS_LABELS,
|
||||
INVOICE_TITLE_TYPE_LABELS,
|
||||
type InvoiceDto,
|
||||
} from '@dukang/shared-types';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
export default function InvoiceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<InvoiceDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
|
||||
.then((res) => setItems(res.items ?? []))
|
||||
.catch(() => setItems([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="after-sale-page">
|
||||
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
|
||||
<main className="after-sale-body">
|
||||
{loading ? (
|
||||
<p className="after-sale-empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<div className="after-sale-empty-box">
|
||||
<p className="after-sale-empty">暂无发票申请</p>
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||
申请发票
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="after-sale-order-list">
|
||||
{items.map((inv) => (
|
||||
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
|
||||
<div className="after-sale-ticket-head">
|
||||
<span>
|
||||
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
|
||||
</span>
|
||||
<span className="after-sale-ticket-status">
|
||||
{INVOICE_STATUS_LABELS[inv.status]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="after-sale-order-no">{inv.titleName}</p>
|
||||
<p className="after-sale-order-meta">
|
||||
{inv.invoiceNo} · 订单 {inv.orderNo ?? inv.orderId}
|
||||
</p>
|
||||
{inv.status === 'ISSUED' && inv.fileUrl ? (
|
||||
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
|
||||
查看/下载发票
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
|
||||
申请发票
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,8 @@ const ORDER_SHORTCUTS = [
|
||||
|
||||
const SERVICES = [
|
||||
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
|
||||
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
|
||||
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
|
||||
{ icon: 'storefront', label: '可用门店', to: '/stores' },
|
||||
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
|
||||
{ icon: 'info', label: '关于我们', action: 'about' as const },
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function OrderDetailPage() {
|
||||
order &&
|
||||
!canPay &&
|
||||
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const canInvoice = order?.status === 'COMPLETED';
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
@@ -207,22 +208,6 @@ export default function OrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRefund() {
|
||||
if (!id || !canRefund) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '用户申请退款' }),
|
||||
});
|
||||
await loadOrder();
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
@@ -470,8 +455,21 @@ export default function OrderDetailPage() {
|
||||
联系客服
|
||||
</button>
|
||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
||||
申请退款
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-outline"
|
||||
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
|
||||
>
|
||||
申请售后
|
||||
</button>
|
||||
)}
|
||||
{canInvoice && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-outline"
|
||||
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
|
||||
>
|
||||
申请发票
|
||||
</button>
|
||||
)}
|
||||
{canPay && (
|
||||
|
||||
+247
-105
@@ -5786,7 +5786,6 @@
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
/* Customer service chat page */
|
||||
.customer-service-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -5794,142 +5793,285 @@
|
||||
background: #faf9f7;
|
||||
}
|
||||
|
||||
.customer-service-order-card {
|
||||
.customer-service-page--oa .customer-service-oa-body {
|
||||
flex: 1;
|
||||
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 12px 16px 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||
color: #a61d24;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.customer-service-order-label {
|
||||
.customer-service-wecom-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.customer-service-wecom-hint {
|
||||
margin: 0 0 28px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.customer-service-wecom-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
padding: 14px 20px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-service-wecom-btn .material-symbols-outlined {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.customer-service-phone-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 24px;
|
||||
font-size: 14px;
|
||||
color: #a61d24;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.after-sale-page {
|
||||
min-height: 100dvh;
|
||||
background: #faf9f7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.after-sale-steps {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.after-sale-step {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.after-sale-step.is-active,
|
||||
.after-sale-step.is-done {
|
||||
color: #a61d24;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.after-sale-body {
|
||||
flex: 1;
|
||||
padding: 16px 16px calc(24px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.after-sale-type-list,
|
||||
.after-sale-order-list,
|
||||
.after-sale-form,
|
||||
.after-sale-success {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.after-sale-type-item,
|
||||
.after-sale-order-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 14px 16px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(226, 190, 188, 0.35);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.after-sale-type-item.is-selected,
|
||||
.after-sale-order-item.is-selected {
|
||||
border-color: #a61d24;
|
||||
box-shadow: 0 0 0 1px #a61d24 inset;
|
||||
}
|
||||
|
||||
.after-sale-order-no {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.after-sale-order-meta {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.customer-service-order-no {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.customer-service-chat {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.customer-service-time {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.customer-service-time span {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
background: rgba(227, 226, 224, 0.3);
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.customer-service-bubble-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.customer-service-bubble-row.is-user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.customer-service-bubble-row.is-agent {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.customer-service-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(226, 190, 188, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a61d24;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.customer-service-bubble {
|
||||
padding: 12px;
|
||||
.after-sale-primary,
|
||||
.after-sale-secondary {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
background: #fff;
|
||||
color: #1a1c1b;
|
||||
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
|
||||
border: none;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-service-bubble.is-user {
|
||||
.after-sale-primary {
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
|
||||
}
|
||||
|
||||
.customer-service-quick {
|
||||
.after-sale-secondary {
|
||||
background: #e3e2e0;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.after-sale-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.after-sale-label {
|
||||
font-size: 13px;
|
||||
color: #5a413f;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.after-sale-textarea,
|
||||
.after-sale-input {
|
||||
width: 100%;
|
||||
border: 1px solid #e3e2e0;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.after-sale-evidence {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.customer-service-quick-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(166, 29, 36, 0.25);
|
||||
background: #fff;
|
||||
color: #a61d24;
|
||||
font-size: 12px;
|
||||
.after-sale-evidence-img,
|
||||
.after-sale-evidence-add {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.customer-service-inputbar {
|
||||
.after-sale-evidence-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #ccc;
|
||||
background: #fff;
|
||||
border-top: 1px solid #e3e2e0;
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.customer-service-input {
|
||||
flex: 1;
|
||||
border: 1px solid #e3e2e0;
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
.after-sale-summary {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
font-size: 13px;
|
||||
color: #a61d24;
|
||||
}
|
||||
|
||||
.customer-service-send {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
.after-sale-empty,
|
||||
.after-sale-empty-box {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.after-sale-success {
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding-top: 40px;
|
||||
}
|
||||
|
||||
.after-sale-success-icon {
|
||||
font-size: 56px;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.after-sale-success-title {
|
||||
margin: 8px 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-service-send:disabled {
|
||||
opacity: 0.5;
|
||||
.after-sale-success-no,
|
||||
.after-sale-success-hint {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.after-sale-ticket-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.after-sale-ticket-status {
|
||||
color: #a61d24;
|
||||
}
|
||||
|
||||
.after-sale-ticket-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.after-sale-file-link {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #a61d24;
|
||||
}
|
||||
|
||||
.invoice-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.invoice-chip {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(166, 29, 36, 0.25);
|
||||
background: #fff;
|
||||
font-size: 12px;
|
||||
color: #a61d24;
|
||||
}
|
||||
|
||||
.invoice-chip.is-selected {
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
border-color: #a61d24;
|
||||
}
|
||||
|
||||
.invoice-chip:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
|
||||
|
||||
@@ -52,8 +52,7 @@ 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 {
|
||||
.taro-scroll-view__scroll-y::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
pnpm --filter @dukang/mini-user dev
|
||||
# → http://localhost:5177
|
||||
|
||||
# 微信小程序
|
||||
# 微信小程序(本地联调,development)
|
||||
pnpm --filter @dukang/mini-user dev:weapp
|
||||
|
||||
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com)
|
||||
pnpm build:mini-user:weapp
|
||||
# 用微信开发者工具打开 apps/mini-user(miniprogramRoot = dist/)
|
||||
```
|
||||
|
||||
@@ -18,7 +21,7 @@ pnpm --filter @dukang/mini-user dev:weapp
|
||||
|
||||
| 环节 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000`;`NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||
|
||||
### 微信登录 `invalid code`
|
||||
@@ -45,10 +48,10 @@ pnpm --filter @dukang/mini-user dev:weapp
|
||||
**连远程 API**:
|
||||
|
||||
```bash
|
||||
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
|
||||
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
|
||||
```
|
||||
|
||||
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||
|
||||
## 页面结构(18 页)
|
||||
|
||||
|
||||
@@ -2,10 +2,16 @@ import { createRequire } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { defineConfig } from '@tarojs/cli';
|
||||
|
||||
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
|
||||
const isDevMode =
|
||||
process.env.NODE_ENV === 'development' ||
|
||||
process.argv.includes('--watch') ||
|
||||
process.argv.includes('development');
|
||||
|
||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||
const API_ORIGIN =
|
||||
process.env.VITE_API_TARGET ??
|
||||
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
|
||||
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||
|
||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||
|
||||
@@ -91,7 +97,7 @@ export default defineConfig(async () => ({
|
||||
},
|
||||
mini: {
|
||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||
debugReact: process.env.NODE_ENV !== 'production',
|
||||
debugReact: isDevMode,
|
||||
postcss: {
|
||||
pxtransform: { enable: true, config: {} },
|
||||
cssModules: { enable: false },
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
||||
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
||||
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
|
||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production",
|
||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production",
|
||||
"lint": "echo ok"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"postcss": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "miniprogram"
|
||||
"compileType": "miniprogram",
|
||||
"preloadBackgroundData": false
|
||||
}
|
||||
|
||||
@@ -39,19 +39,13 @@ body::-webkit-scrollbar,
|
||||
#app::-webkit-scrollbar,
|
||||
.taro_page::-webkit-scrollbar,
|
||||
.taro_router::-webkit-scrollbar,
|
||||
.taro-tabbar__panel::-webkit-scrollbar,
|
||||
*::-webkit-scrollbar {
|
||||
.taro-tabbar__panel::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
display: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
|
||||
.taro-tabbar__tabbar,
|
||||
.taro-tabbar__border {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
|
||||
export type StoreCategoryNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
children?: StoreCategoryNode[];
|
||||
};
|
||||
|
||||
export type CategorySelection = {
|
||||
parentId: string;
|
||||
parentName: string;
|
||||
childId: string;
|
||||
childName: string;
|
||||
};
|
||||
|
||||
export const EMPTY_CATEGORY: CategorySelection = {
|
||||
parentId: '',
|
||||
parentName: '',
|
||||
childId: '',
|
||||
childName: '',
|
||||
};
|
||||
|
||||
export function formatCategoryLabel(sel: CategorySelection): string {
|
||||
if (sel.childName) return sel.childName;
|
||||
if (sel.parentName) return sel.parentName;
|
||||
return '全部分类';
|
||||
}
|
||||
|
||||
type CategoryPickerProps = {
|
||||
open: boolean;
|
||||
tree: StoreCategoryNode[];
|
||||
value: CategorySelection;
|
||||
onClose: () => void;
|
||||
onConfirm: (next: CategorySelection) => void;
|
||||
};
|
||||
|
||||
type TabKey = 'parent' | 'child';
|
||||
|
||||
export default function CategoryPicker({
|
||||
open,
|
||||
tree,
|
||||
value,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: CategoryPickerProps) {
|
||||
const [draft, setDraft] = useState<CategorySelection>(value);
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('parent');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDraft(value);
|
||||
setActiveTab(value.parentId ? 'child' : 'parent');
|
||||
}, [open, value]);
|
||||
|
||||
const children = useMemo(() => {
|
||||
const parent = tree.find((n) => n.id === draft.parentId);
|
||||
return parent?.children ?? [];
|
||||
}, [tree, draft.parentId]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function selectParent(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft(EMPTY_CATEGORY);
|
||||
return;
|
||||
}
|
||||
setDraft({
|
||||
parentId: node.id,
|
||||
parentName: node.name,
|
||||
childId: '',
|
||||
childName: '',
|
||||
});
|
||||
setActiveTab('child');
|
||||
}
|
||||
|
||||
function selectChild(node: StoreCategoryNode | null) {
|
||||
if (!node) {
|
||||
setDraft((prev) => ({ ...prev, childId: '', childName: '' }));
|
||||
return;
|
||||
}
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
childId: node.id,
|
||||
childName: node.name,
|
||||
}));
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
onConfirm(draft);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="region-picker-overlay" onClick={onClose}>
|
||||
<View className="region-picker-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<View className="region-picker-toolbar">
|
||||
<View className="region-picker-tabs">
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'parent' ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab('parent')}
|
||||
>
|
||||
{draft.parentName || '大类'}
|
||||
</Text>
|
||||
<Text
|
||||
className={`region-picker-tab${activeTab === 'child' ? ' active' : ''}${!draft.parentId ? ' disabled' : ''}`}
|
||||
onClick={() => draft.parentId && setActiveTab('child')}
|
||||
>
|
||||
{draft.childName || '细类'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text className="region-picker-confirm ready" onClick={handleConfirm}>
|
||||
确定
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView className="region-picker-list" scrollY showScrollbar={false}>
|
||||
{activeTab === 'parent' ? (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.parentId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectParent(null)}
|
||||
>
|
||||
<Text>全部分类</Text>
|
||||
</View>
|
||||
{tree.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.parentId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectParent(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<View
|
||||
className={`region-picker-option${!draft.childId ? ' selected' : ''} region-picker-option--all`}
|
||||
onClick={() => selectChild(null)}
|
||||
>
|
||||
<Text>全部细类</Text>
|
||||
</View>
|
||||
{children.map((item) => (
|
||||
<View
|
||||
key={item.id}
|
||||
className={`region-picker-option${draft.childId === item.id ? ' selected' : ''}`}
|
||||
onClick={() => selectChild(item)}
|
||||
>
|
||||
<Text>{item.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Text } from '@tarojs/components';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ContactCsSessionContext = {
|
||||
orderId?: string;
|
||||
orderNo?: string;
|
||||
from?: string;
|
||||
};
|
||||
|
||||
type ContactCsButtonProps = {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
/** 客服会话来源上下文,便于客服后台识别 */
|
||||
session?: ContactCsSessionContext;
|
||||
};
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
/** 组装 session-from(微信限制约 1000 字符) */
|
||||
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
|
||||
if (!session) return 'dukang|from=mini-user';
|
||||
const parts = ['dukang'];
|
||||
if (session.from) parts.push(`from=${session.from}`);
|
||||
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
|
||||
if (session.orderId) parts.push(`orderId=${session.orderId}`);
|
||||
return parts.join('|');
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序客服入口(open-type=contact)。
|
||||
* 非 weapp 环境不渲染,由调用方走电话等兜底。
|
||||
*/
|
||||
export default function ContactCsButton({
|
||||
className = '',
|
||||
children = '联系在线客服',
|
||||
session,
|
||||
}: ContactCsButtonProps) {
|
||||
if (!isWeapp) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
openType="contact"
|
||||
sessionFrom={buildCsSessionFrom(session)}
|
||||
hoverClass="none"
|
||||
>
|
||||
{typeof children === 'string' ? <Text>{children}</Text> : children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Button, Text, View } from '@tarojs/components';
|
||||
|
||||
type PhoneQuickLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
|
||||
agreed: boolean;
|
||||
onRequireAgree: () => void;
|
||||
onGetPhoneNumber: (phoneCode: string) => void;
|
||||
onFail?: (message: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
|
||||
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
|
||||
*/
|
||||
export default function PhoneQuickLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
agreed,
|
||||
onRequireAgree,
|
||||
onGetPhoneNumber,
|
||||
onFail,
|
||||
}: PhoneQuickLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
|
||||
const label = loading ? '登录中...' : '手机号快捷登录';
|
||||
|
||||
if (!agreed) {
|
||||
return (
|
||||
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
openType={inactive ? undefined : 'getPhoneNumber'}
|
||||
hoverClass="none"
|
||||
onGetPhoneNumber={(e) => {
|
||||
if (inactive) return;
|
||||
const detail = e.detail as {
|
||||
errMsg?: string;
|
||||
code?: string;
|
||||
errno?: number;
|
||||
};
|
||||
if (!detail?.code) {
|
||||
const denied =
|
||||
detail?.errMsg?.includes('deny') ||
|
||||
detail?.errMsg?.includes('cancel') ||
|
||||
detail?.errno === 103;
|
||||
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
|
||||
return;
|
||||
}
|
||||
onGetPhoneNumber(detail.code);
|
||||
}}
|
||||
>
|
||||
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -7,25 +7,24 @@ type TabMainHeaderProps = {
|
||||
extra?: ReactNode;
|
||||
};
|
||||
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
|
||||
/**
|
||||
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
|
||||
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
|
||||
*/
|
||||
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
||||
const metrics = useNavBarMetrics();
|
||||
|
||||
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
|
||||
if (isH5 && !extra) {
|
||||
if (!extra) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
|
||||
{!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
|
||||
<View
|
||||
className="tab-main-header__content"
|
||||
style={tabNavContentStyle(metrics)}
|
||||
>
|
||||
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
|
||||
{process.env.TARO_ENV !== 'h5' ? (
|
||||
<Text className="tab-main-header__title">{title}</Text>
|
||||
) : null}
|
||||
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
|
||||
<View className="tab-main-header__extra">{extra}</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -3,22 +3,16 @@ import { View, Text } from '@tarojs/components';
|
||||
type WechatLoginButtonProps = {
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function WechatIcon() {
|
||||
return (
|
||||
<View className="wechat-login-icon" aria-hidden>
|
||||
<View className="wechat-login-icon__big" />
|
||||
<View className="wechat-login-icon__small" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */
|
||||
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
|
||||
export default function WechatLoginButton({
|
||||
loading = false,
|
||||
disabled = false,
|
||||
label = '授权登录',
|
||||
onClick,
|
||||
}: WechatLoginButtonProps) {
|
||||
const inactive = loading || disabled;
|
||||
@@ -28,10 +22,7 @@ export default function WechatLoginButton({
|
||||
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
||||
onClick={inactive ? undefined : onClick}
|
||||
>
|
||||
<WechatIcon />
|
||||
<Text className="login-wechat-btn__text">
|
||||
{loading ? '授权中...' : '微信一键授权'}
|
||||
</Text>
|
||||
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { ClientApp } from '@dukang/shared-types';
|
||||
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
|
||||
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||
|
||||
function resolveApiBase(): string {
|
||||
const origin =
|
||||
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
|
||||
return !!getToken();
|
||||
}
|
||||
|
||||
export function redirectToLogin() {
|
||||
goLogin();
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
clearAuth();
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isOnLoginPage(): boolean {
|
||||
try {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
const route = cur?.route || '';
|
||||
return route.includes('pages/login');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
||||
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
||||
const header: Record<string, string> = {
|
||||
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
||||
if (/账号已合并/.test(mergedMsg)) {
|
||||
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
||||
forceReloadAfterAccountMerge();
|
||||
} else if (!isOnLoginPage()) {
|
||||
redirectToLogin();
|
||||
}
|
||||
}
|
||||
throw new Error(body?.message || '登录已过期,请重新登录');
|
||||
|
||||
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
|
||||
'/pages/mine/index',
|
||||
]);
|
||||
|
||||
let loginNavigationPending = false;
|
||||
|
||||
function isLoginPageActive(): boolean {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const current = pages[pages.length - 1] as { route?: string } | undefined;
|
||||
return !!current?.route?.includes('pages/login/');
|
||||
}
|
||||
|
||||
function currentPagePath(): string {
|
||||
const pages = Taro.getCurrentPages();
|
||||
const cur = pages[pages.length - 1] as
|
||||
@@ -25,6 +33,7 @@ function currentPagePath(): string {
|
||||
|
||||
/** 跳转登录页;默认带回当前页作为 return */
|
||||
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||
if (loginNavigationPending || isLoginPageActive()) return;
|
||||
const returnTo = returnPath ?? currentPagePath();
|
||||
const parts: string[] = [];
|
||||
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
||||
@@ -34,9 +43,15 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||
}
|
||||
}
|
||||
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
||||
Taro.navigateTo({ url }).catch(() => {
|
||||
Taro.redirectTo({ url });
|
||||
});
|
||||
loginNavigationPending = true;
|
||||
void Taro.navigateTo({ url })
|
||||
.catch(() => Taro.redirectTo({ url }))
|
||||
.finally(() => {
|
||||
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
|
||||
setTimeout(() => {
|
||||
loginNavigationPending = false;
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
/** 登录成功后回到 return 页,或回退 / 首页 */
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { request } from './api';
|
||||
|
||||
import type { UserProfile } from './api';
|
||||
|
||||
export type MiniWechatProfile = {
|
||||
@@ -8,6 +6,17 @@ export type MiniWechatProfile = {
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export type MiniWechatProfileUpdate = MiniWechatProfile & {
|
||||
avatarResourceId?: string;
|
||||
};
|
||||
|
||||
export type UploadedAvatarResource = {
|
||||
resourceId: string;
|
||||
url: string;
|
||||
bucket: string;
|
||||
ossKey: string;
|
||||
};
|
||||
|
||||
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
|
||||
|
||||
export function cacheWxProfile(info: MiniWechatProfile) {
|
||||
@@ -32,77 +41,117 @@ export function getCachedWxProfile(): MiniWechatProfile | null {
|
||||
}
|
||||
|
||||
export function isDefaultMiniNickname(nickname?: string | null): boolean {
|
||||
if (!nickname || nickname === '访客') return true;
|
||||
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
|
||||
return /^用户\d{4}$/.test(nickname);
|
||||
}
|
||||
|
||||
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
|
||||
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
|
||||
if (!profile) return true;
|
||||
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
|
||||
}
|
||||
|
||||
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
||||
if (!profile.hasWechat) return profile;
|
||||
const cached = getCachedWxProfile();
|
||||
if (!cached) return profile;
|
||||
if (!cached && !profile.hasWechat) return profile;
|
||||
const nickname =
|
||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||
cached?.nickname ||
|
||||
profile.nickname ||
|
||||
'微信用户';
|
||||
return {
|
||||
...profile,
|
||||
nickname:
|
||||
cached.nickname ||
|
||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||
profile.nickname ||
|
||||
'微信用户',
|
||||
avatarUrl: profile.avatarUrl || cached.avatarUrl || null,
|
||||
nickname,
|
||||
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 用户点击触发:拉取微信昵称/头像 */
|
||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||
if (process.env.TARO_ENV !== 'weapp') {
|
||||
throw new Error('请在微信小程序中授权');
|
||||
}
|
||||
const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
|
||||
const info: MiniWechatProfile = {
|
||||
nickname: res.userInfo?.nickName?.trim(),
|
||||
avatarUrl: res.userInfo?.avatarUrl?.trim(),
|
||||
};
|
||||
if (!info.nickname && !info.avatarUrl) {
|
||||
throw new Error('未获取到微信头像或昵称');
|
||||
}
|
||||
cacheWxProfile(info);
|
||||
return info;
|
||||
}
|
||||
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
||||
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
||||
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
||||
const token = getToken();
|
||||
if (!token) throw new Error('请先登录');
|
||||
|
||||
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<MiniWechatProfile | null> {
|
||||
if (!info.nickname && !info.avatarUrl) return null;
|
||||
const res = await Taro.uploadFile({
|
||||
url: `${API_BASE}/common/resources/upload`,
|
||||
filePath: tempFilePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
bizType: 'AVATAR',
|
||||
mediaType: 'IMAGE',
|
||||
},
|
||||
header: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': CLIENT_APP,
|
||||
},
|
||||
});
|
||||
|
||||
let body: {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
|
||||
} = {};
|
||||
try {
|
||||
const updated = await request<{
|
||||
nickname?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
}>('/auth/wechat/mini-profile', {
|
||||
method: 'POST',
|
||||
data: info,
|
||||
});
|
||||
if (updated?.nickname || updated?.avatarUrl) {
|
||||
cacheWxProfile({
|
||||
nickname: updated.nickname ?? info.nickname,
|
||||
avatarUrl: updated.avatarUrl ?? info.avatarUrl,
|
||||
});
|
||||
}
|
||||
return info;
|
||||
body = JSON.parse(String(res.data || '{}')) as typeof body;
|
||||
} catch {
|
||||
return null;
|
||||
throw new Error('头像上传响应异常');
|
||||
}
|
||||
if (res.statusCode === 401 || body.code === 401) {
|
||||
throw new Error(body.message || '登录已过期,请重新登录');
|
||||
}
|
||||
if (
|
||||
res.statusCode >= 400 ||
|
||||
body.code !== 0 ||
|
||||
!body.data?.resourceId ||
|
||||
!body.data.url ||
|
||||
!body.data.bucket ||
|
||||
!body.data.ossKey
|
||||
) {
|
||||
throw new Error(body.message || '头像上传失败');
|
||||
}
|
||||
return {
|
||||
resourceId: body.data.resourceId,
|
||||
url: body.data.url,
|
||||
bucket: body.data.bucket,
|
||||
ossKey: body.data.ossKey,
|
||||
};
|
||||
}
|
||||
|
||||
/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */
|
||||
export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise<MiniWechatProfile | null> {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
|
||||
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
|
||||
const { request } = await import('./api');
|
||||
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
|
||||
method: 'POST',
|
||||
data: info,
|
||||
});
|
||||
cacheWxProfile({
|
||||
nickname: updated?.nickname ?? info.nickname,
|
||||
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
let info = prefetched ?? null;
|
||||
if (!info) {
|
||||
try {
|
||||
info = await fetchMiniWechatUserInfo();
|
||||
} catch {
|
||||
return getCachedWxProfile();
|
||||
}
|
||||
/**
|
||||
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
|
||||
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
|
||||
*/
|
||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||
const cached = getCachedWxProfile();
|
||||
if (cached?.nickname || cached?.avatarUrl) {
|
||||
return cached;
|
||||
}
|
||||
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
|
||||
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
|
||||
}
|
||||
|
||||
await uploadMiniWechatProfile(info);
|
||||
/** 绑定后上报微信资料(优先使用已拉取的信息) */
|
||||
export async function syncMiniWechatProfile(
|
||||
prefetched?: MiniWechatProfile | null,
|
||||
): Promise<MiniWechatProfile | null> {
|
||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
|
||||
if (!info?.nickname && !info?.avatarUrl) return null;
|
||||
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
|
||||
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
|
||||
return info;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,32 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { saveAuth } from './api';
|
||||
import { syncMiniWechatProfile } from './mini-wechat-profile';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
syncMiniWechatProfile,
|
||||
} from './mini-wechat-profile';
|
||||
|
||||
/**
|
||||
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
|
||||
*(开发者工具热更新时常见旧页 + 新 common 混用)
|
||||
*/
|
||||
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
|
||||
|
||||
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
|
||||
const _wxProfileCompat = {
|
||||
fetchMiniWechatUserInfo,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
};
|
||||
if (
|
||||
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
|
||||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
|
||||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
|
||||
) {
|
||||
throw new Error('mini-wechat-profile helpers missing');
|
||||
}
|
||||
import {
|
||||
authorizeWechatForPay,
|
||||
fetchClientConfig,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '地址管理',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import {
|
||||
@@ -44,6 +44,10 @@ export default function AddressesPage() {
|
||||
loadList();
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
Taro.redirectTo({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '好客权益',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
@@ -44,9 +44,9 @@ export default function BenefitPage() {
|
||||
syncTabBarSelected(2);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) return;
|
||||
Promise.all([
|
||||
const loadBenefit = useCallback(() => {
|
||||
if (!loggedIn) return Promise.resolve();
|
||||
return Promise.all([
|
||||
request<BenefitSummary>('/benefit/summary'),
|
||||
request<CouponItem[]>('/benefit/coupons'),
|
||||
])
|
||||
@@ -57,6 +57,14 @@ export default function BenefitPage() {
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
|
||||
}, [loggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadBenefit();
|
||||
}, [loadBenefit]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
|
||||
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
|
||||
const visible = tab === 'available' ? available : history;
|
||||
@@ -106,14 +114,6 @@ export default function BenefitPage() {
|
||||
<Text>康</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="benefit-hero-actions">
|
||||
<Text
|
||||
className="benefit-hero-link"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
|
||||
>
|
||||
查看权益明细 ›
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className="benefit-hero-cta"
|
||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||
|
||||
@@ -3,28 +3,42 @@ import Taro from '@tarojs/taro';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { toast } from '../../lib/api';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
function dialPhone() {
|
||||
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
|
||||
}
|
||||
|
||||
export default function CustomerServicePage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="cs-page">
|
||||
<SubPageHeader title="联系客服" />
|
||||
<View className="sub-page-body inset-page cs-body">
|
||||
<Text className="cs-title">客服热线</Text>
|
||||
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||
<Text className="cs-hint">工作时间:9:00 - 21:00</Text>
|
||||
<View
|
||||
className="cs-call-btn"
|
||||
onClick={() => {
|
||||
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
|
||||
toast('无法拨打电话'),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Text>拨打客服电话</Text>
|
||||
<Text className="cs-brand">杜康好客客服</Text>
|
||||
<Text className="cs-hint">
|
||||
{isWeapp
|
||||
? '点击下方按钮,进入小程序在线客服会话'
|
||||
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
|
||||
</Text>
|
||||
<Text className="cs-hours">工作时间:9:00 - 21:00</Text>
|
||||
|
||||
{isWeapp ? (
|
||||
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
|
||||
) : null}
|
||||
|
||||
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
|
||||
<Text>
|
||||
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{!isWeapp ? (
|
||||
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '杜康好客',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import CouponBadge from '../../components/CouponBadge';
|
||||
@@ -21,9 +21,9 @@ type Product = {
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
{ key: 'QINGXIANG', label: '清香型' },
|
||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||
{ key: 'NONGXIANG', label: '浓香型' },
|
||||
] as const;
|
||||
|
||||
export default function HomePage() {
|
||||
@@ -41,43 +41,71 @@ export default function HomePage() {
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadProducts = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
|
||||
.then((list) => setProducts(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
|
||||
function onAromaTabClick(key: string, open: boolean) {
|
||||
if (!open) {
|
||||
toast('暂未开放');
|
||||
return;
|
||||
useEffect(() => {
|
||||
void loadProducts();
|
||||
}, [loadProducts]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setDisplayCity(resolved.displayCity);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const list = await request<Product[]>(
|
||||
`/catalog/products?cityCode=${encodeURIComponent(nextCode)}`,
|
||||
);
|
||||
setProducts(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
const availableAromas = useMemo(
|
||||
() =>
|
||||
AROMA_TABS.filter((item) =>
|
||||
products.some((product) => product.aromaType === item.key),
|
||||
),
|
||||
[products],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || availableAromas.length === 0) return;
|
||||
if (!availableAromas.some((item) => item.key === tab)) {
|
||||
setTab(availableAromas[0].key);
|
||||
}
|
||||
setTab(key);
|
||||
}
|
||||
}, [availableAromas, loading, tab]);
|
||||
|
||||
function openProductDetail(id: string) {
|
||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||
}
|
||||
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="home-page no-tab-header">
|
||||
<TabMainHeader title="杜康好客" />
|
||||
|
||||
<View className="home-aroma-nav">
|
||||
<View className="home-aroma-tabs">
|
||||
{AROMA_TABS.map((t) => (
|
||||
{availableAromas.map((t) => (
|
||||
<Text
|
||||
key={t.key}
|
||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
|
||||
onClick={() => onAromaTabClick(t.key, t.open)}
|
||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</Text>
|
||||
@@ -88,12 +116,10 @@ export default function HomePage() {
|
||||
|
||||
<View className="home-product-list">
|
||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||
{!loading && !onSale ? <View className="home-empty">该香型暂未上线,敬请期待</View> : null}
|
||||
{!loading && onSale && filtered.length === 0 ? (
|
||||
<View className="home-empty">暂无商品</View>
|
||||
{!loading && products.length === 0 ? (
|
||||
<View className="home-empty">当前城市暂无在售商品</View>
|
||||
) : null}
|
||||
{!loading &&
|
||||
onSale &&
|
||||
filtered.map((p) => {
|
||||
const images = getProductImages(p);
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||
import {
|
||||
@@ -18,13 +19,14 @@ import {
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
getCachedWxProfile,
|
||||
syncMiniWechatProfile,
|
||||
type MiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||
|
||||
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, '').slice(0, 11);
|
||||
}
|
||||
@@ -33,6 +35,44 @@ function isValidPhone(phone: string) {
|
||||
return /^1[3-9]\d{9}$/.test(phone);
|
||||
}
|
||||
|
||||
function AgreementRow({
|
||||
agreed,
|
||||
onToggle,
|
||||
}: {
|
||||
agreed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View className="login-agreement" onClick={onToggle}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
请阅读并勾选同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户服务协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const returnTo = router.params.return || '';
|
||||
@@ -45,8 +85,10 @@ export default function LoginPage() {
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [wxLoading, setWxLoading] = useState(false);
|
||||
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
/** 须用户主动勾选,禁止默认同意 */
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
@@ -54,6 +96,7 @@ export default function LoginPage() {
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
@@ -66,7 +109,6 @@ export default function LoginPage() {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
}
|
||||
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
|
||||
if (!needPhone && !needWechat) {
|
||||
setCompleteMode(null);
|
||||
return;
|
||||
@@ -77,6 +119,7 @@ export default function LoginPage() {
|
||||
if (cancelled) return;
|
||||
if (needPhone && !me.phoneVerified) {
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
return;
|
||||
}
|
||||
if (needWechat && !me.hasWechat) {
|
||||
@@ -101,26 +144,39 @@ export default function LoginPage() {
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function cancelLogin() {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length > 1) {
|
||||
Taro.navigateBack().catch(() => {
|
||||
Taro.switchTab({ url: '/pages/home/index' });
|
||||
});
|
||||
return;
|
||||
}
|
||||
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
|
||||
Taro.reLaunch({ url: '/pages/home/index' });
|
||||
});
|
||||
}
|
||||
|
||||
function applySessionAndLeave(
|
||||
data: SessionPayload | WechatLoginResult,
|
||||
phone?: string,
|
||||
phoneValue?: string,
|
||||
wxInfo?: MiniWechatProfile | null,
|
||||
successToast = '登录成功',
|
||||
) {
|
||||
if (!data.accessToken) return;
|
||||
if (phone) saveUserPhone(phone);
|
||||
if (phoneValue) saveUserPhone(phoneValue);
|
||||
saveAuth({
|
||||
accessToken: data.accessToken,
|
||||
refreshToken: data.refreshToken,
|
||||
});
|
||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||
if (!phone) {
|
||||
if (!phoneValue) {
|
||||
void fetchUserProfile()
|
||||
.then((me) => resolveDefaultUserPhone(me))
|
||||
.catch(() => {});
|
||||
@@ -134,7 +190,6 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
||||
// 微信授权成功即登录;手机号改为下单页可选绑定
|
||||
if (result.accessToken) {
|
||||
applySessionAndLeave(result, undefined, wxInfo);
|
||||
return;
|
||||
@@ -142,11 +197,49 @@ export default function LoginPage() {
|
||||
if (result.needBindPhone && result.wxSessionKey) {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
||||
setShowSmsForm(true);
|
||||
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
|
||||
setSentHint('');
|
||||
return;
|
||||
}
|
||||
setMsg('微信登录未完成,请重试或使用手机号登录');
|
||||
setMsg('登录未完成,请重试或使用手机号登录');
|
||||
}
|
||||
|
||||
async function onPhoneQuickLogin(phoneCode: string) {
|
||||
if (!ensureAgreed()) return;
|
||||
setPhoneQuickLoading(true);
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
try {
|
||||
let loginCode: string | undefined;
|
||||
try {
|
||||
const loginRes = await Taro.login();
|
||||
loginCode = loginRes.code || undefined;
|
||||
} catch {
|
||||
/* openId 绑定失败不阻断手机号登录 */
|
||||
}
|
||||
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
|
||||
method: 'POST',
|
||||
data: {
|
||||
phoneCode,
|
||||
...(loginCode ? { loginCode } : {}),
|
||||
platform: 'mini',
|
||||
},
|
||||
});
|
||||
if (!data?.accessToken) {
|
||||
setMsg('登录成功但未返回令牌,请重试');
|
||||
return;
|
||||
}
|
||||
const profilePhone =
|
||||
typeof data.user === 'object' && data.user && 'phone' in data.user
|
||||
? String((data.user as { phone?: string }).phone || '')
|
||||
: '';
|
||||
applySessionAndLeave(data, profilePhone || undefined);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
|
||||
} finally {
|
||||
setPhoneQuickLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSendCode() {
|
||||
@@ -201,7 +294,6 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (completeMode === 'phone' && isLoggedIn()) {
|
||||
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
|
||||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||||
method: 'POST',
|
||||
data: { phone: normalized, code: code.trim() },
|
||||
@@ -235,15 +327,7 @@ export default function LoginPage() {
|
||||
setSentHint('');
|
||||
setWxLoading(true);
|
||||
try {
|
||||
let wxInfo: MiniWechatProfile | null = null;
|
||||
if (process.env.TARO_ENV === 'weapp') {
|
||||
try {
|
||||
wxInfo = await fetchMiniWechatUserInfo();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const wxInfo = getCachedWxProfile();
|
||||
|
||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||
const result = await bindWechatForUser(wxInfo);
|
||||
@@ -254,12 +338,13 @@ export default function LoginPage() {
|
||||
setBindMode(true);
|
||||
setWxSessionKey(result.wxSessionKey);
|
||||
setCompleteMode('phone');
|
||||
setShowSmsForm(true);
|
||||
setMsg('请绑定手机号完成认证');
|
||||
return;
|
||||
}
|
||||
if (result.ok) {
|
||||
await syncMiniWechatProfile(wxInfo);
|
||||
toast('微信授权成功', 'success');
|
||||
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||||
toast('授权成功', 'success');
|
||||
finishLoginNavigate(returnTo);
|
||||
return;
|
||||
}
|
||||
@@ -268,11 +353,11 @@ export default function LoginPage() {
|
||||
const result = await loginWithWechat();
|
||||
if (result) handleWechatLoginResult(result, wxInfo);
|
||||
} catch (e) {
|
||||
const raw = e instanceof Error ? e.message : '微信登录失败';
|
||||
const raw = e instanceof Error ? e.message : '授权登录失败';
|
||||
const hint = /invalid code/i.test(raw)
|
||||
? process.env.TARO_ENV === 'weapp'
|
||||
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
|
||||
: '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
|
||||
? '授权失败:请确认后端小程序 AppID 配置正确'
|
||||
: '授权失败:请确认公众号网页授权域名配置正确'
|
||||
: raw;
|
||||
setMsg(hint);
|
||||
} finally {
|
||||
@@ -282,21 +367,28 @@ export default function LoginPage() {
|
||||
|
||||
const displayMsg = msg || sentHint;
|
||||
const codeDisabled = cooldown > 0 || sending;
|
||||
const showWechatLogin =
|
||||
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
|
||||
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
||||
const showSmsForm = completeMode !== 'wechat';
|
||||
const showAuthLogin =
|
||||
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
|
||||
(IS_WEAPP || wxAuthorize);
|
||||
const showPhoneQuick =
|
||||
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
|
||||
const cardTitle =
|
||||
completeMode === 'phone'
|
||||
? '验证手机号'
|
||||
: bindMode
|
||||
? '绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '微信授权'
|
||||
: '手机验证码登录';
|
||||
? '授权登录'
|
||||
: '手机号快捷登录';
|
||||
|
||||
return (
|
||||
<PageShell variant="plain" className="login-page">
|
||||
<View className="login-nav">
|
||||
<View className="login-nav-back" onClick={cancelLogin}>
|
||||
<Text className="login-nav-back-icon">‹</Text>
|
||||
<Text>返回</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="login-header">
|
||||
<View className="login-logo-wrap">
|
||||
<View className="login-logo">
|
||||
@@ -309,7 +401,7 @@ export default function LoginPage() {
|
||||
{completeMode === 'phone'
|
||||
? '建议绑定手机号'
|
||||
: completeMode === 'wechat'
|
||||
? '完成微信授权'
|
||||
? '完成授权登录'
|
||||
: '欢迎来到杜康好客'}
|
||||
</Text>
|
||||
<Text className="login-welcome-sub">
|
||||
@@ -325,77 +417,100 @@ export default function LoginPage() {
|
||||
<View className="login-main">
|
||||
{completeMode === 'wechat' ? (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">微信一键授权</Text>
|
||||
<Text className="login-card-title">授权登录</Text>
|
||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||
使用微信支付前需授权微信账号
|
||||
使用支付功能前需完成授权登录
|
||||
</Text>
|
||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg}
|
||||
</Text>
|
||||
</View>
|
||||
{showWechatLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
{showAuthLogin ? (
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
) : null}
|
||||
</View>
|
||||
) : (
|
||||
<View className="login-card">
|
||||
<Text className="login-card-title">{cardTitle}</Text>
|
||||
|
||||
<View className="login-field">
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
{showPhoneQuick ? (
|
||||
<PhoneQuickLoginButton
|
||||
loading={phoneQuickLoading}
|
||||
agreed={agreed}
|
||||
onRequireAgree={() => ensureAgreed()}
|
||||
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||
onFail={(message) => setMsg(message)}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{showPhoneQuick ? (
|
||||
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||
<View className="login-divider-line" />
|
||||
<Text
|
||||
className="login-divider-text"
|
||||
onClick={() => setShowSmsForm((v) => !v)}
|
||||
>
|
||||
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||
</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{(showSmsForm || !showPhoneQuick) && (
|
||||
<>
|
||||
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||||
<Text className="login-field-prefix">+86</Text>
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onInput={(e) => {
|
||||
setPhone(normalizePhone(e.detail.value));
|
||||
setMsg('');
|
||||
setSentHint('');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="login-field">
|
||||
<Input
|
||||
className="login-field-input"
|
||||
type="number"
|
||||
maxlength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<Text
|
||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||
onClick={() => void onSendCode()}
|
||||
>
|
||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
>
|
||||
<Text className="login-sms-btn__text">
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '验证码登录'}
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{displayMsg ? (
|
||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||
@@ -403,48 +518,6 @@ export default function LoginPage() {
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||
{agreed ? <Text>✓</Text> : null}
|
||||
</View>
|
||||
<Text className="login-agreement-text">
|
||||
我已阅读并同意
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||
}}
|
||||
>
|
||||
《用户协议》
|
||||
</Text>
|
||||
和
|
||||
<Text
|
||||
className="login-agreement-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||
}}
|
||||
>
|
||||
《隐私政策》
|
||||
</Text>
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||
onClick={loading ? undefined : () => void login()}
|
||||
>
|
||||
<Text className="login-sms-btn__text">
|
||||
{loading
|
||||
? '处理中...'
|
||||
: completeMode === 'phone'
|
||||
? '完成验证'
|
||||
: bindMode
|
||||
? '绑定并登录'
|
||||
: '登录'}
|
||||
</Text>
|
||||
</View>
|
||||
{completeMode === 'phone' ? (
|
||||
<View
|
||||
className="login-skip-bind"
|
||||
@@ -459,16 +532,21 @@ export default function LoginPage() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showWechatLogin && completeMode !== 'wechat' ? (
|
||||
{showAuthLogin && completeMode !== 'wechat' ? (
|
||||
<>
|
||||
<View className="login-divider">
|
||||
<View className="login-divider-line" />
|
||||
<Text className="login-divider-text">或者</Text>
|
||||
<View className="login-divider-line" />
|
||||
</View>
|
||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
||||
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<View className="login-cancel-btn" onClick={cancelLogin}>
|
||||
<Text>暂不登录,继续浏览</Text>
|
||||
</View>
|
||||
<Text className="login-cancel-hint">无需登录也可浏览商品和门店</Text>
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import { View, Text, Image, Button, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
@@ -8,10 +8,13 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
|
||||
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
||||
import { goLogin } from '../../lib/auth-nav';
|
||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||
import {
|
||||
fetchMiniWechatUserInfo,
|
||||
isDefaultMiniNickname,
|
||||
mergeWxDisplayProfile,
|
||||
needsWxProfileFill,
|
||||
uploadAvatarTempFile,
|
||||
uploadMiniWechatProfile,
|
||||
} from '../../lib/mini-wechat-profile';
|
||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||
import { isWechatEnv } from '../../lib/weixin';
|
||||
@@ -29,6 +32,8 @@ const SERVICES = [
|
||||
{ icon: '关', label: '关于我们', action: 'about' as const },
|
||||
] as const;
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
@@ -40,16 +45,28 @@ export default function MinePage() {
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||
const [bindingWx, setBindingWx] = useState(false);
|
||||
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
|
||||
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
|
||||
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
|
||||
const [draftNickname, setDraftNickname] = useState('');
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [profileLoadError, setProfileLoadError] = useState('');
|
||||
|
||||
function resetGuestState() {
|
||||
setProfile(null);
|
||||
setBenefitBalance(0);
|
||||
setOrderCounts({});
|
||||
setProfileLoadError('');
|
||||
}
|
||||
|
||||
function applyProfile(me: UserProfile) {
|
||||
setProfile(mergeWxDisplayProfile(me));
|
||||
}
|
||||
|
||||
function loadProfile() {
|
||||
if (!isLoggedIn()) return;
|
||||
Promise.all([
|
||||
if (!isLoggedIn()) return Promise.resolve();
|
||||
setProfileLoadError('');
|
||||
return Promise.all([
|
||||
request<UserProfile>('/auth/me'),
|
||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||
...ORDER_SHORTCUTS.map((s) =>
|
||||
@@ -57,7 +74,7 @@ export default function MinePage() {
|
||||
),
|
||||
])
|
||||
.then(([me, coupons, ...totals]) => {
|
||||
setProfile(mergeWxDisplayProfile(me));
|
||||
applyProfile(me);
|
||||
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||
return sum;
|
||||
@@ -69,7 +86,16 @@ export default function MinePage() {
|
||||
});
|
||||
setOrderCounts(counts);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((error) => {
|
||||
if (!isLoggedIn()) {
|
||||
setAuthed(false);
|
||||
resetGuestState();
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : '个人资料加载失败';
|
||||
setProfileLoadError(message);
|
||||
toast('个人资料加载失败,请点击重试');
|
||||
});
|
||||
}
|
||||
|
||||
useDidShow(() => {
|
||||
@@ -83,99 +109,142 @@ export default function MinePage() {
|
||||
}
|
||||
});
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
const loggedInNow = isLoggedIn();
|
||||
setAuthed(loggedInNow);
|
||||
if (!loggedInNow) {
|
||||
resetGuestState();
|
||||
Taro.stopPullDownRefresh();
|
||||
return;
|
||||
}
|
||||
void loadProfile().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
request<ClientRuntimeConfig>('/common/client-config')
|
||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
||||
.catch(() => setWxAuthorize(true));
|
||||
}, []);
|
||||
|
||||
async function handleAvatarTap() {
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (bindingWx) return;
|
||||
|
||||
let current = profile;
|
||||
if (!current) {
|
||||
try {
|
||||
current = await fetchUserProfile();
|
||||
setProfile(current);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (current?.hasWechat) return;
|
||||
|
||||
async function ensureWechatBound(): Promise<boolean> {
|
||||
if (profile?.hasWechat) return true;
|
||||
if (!wxAuthorize) {
|
||||
toast('当前环境未开启微信授权');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
setBindingWx(true);
|
||||
try {
|
||||
if (process.env.TARO_ENV === 'h5') {
|
||||
if (!isWeapp) {
|
||||
if (!isWechatEnv()) {
|
||||
toast('请在微信内打开后授权');
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const result = await bindWechatForUser();
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) {
|
||||
return;
|
||||
}
|
||||
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', {
|
||||
bindMode: '1',
|
||||
wxSessionKey: result.wxSessionKey,
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.ok) {
|
||||
setProfile(
|
||||
mergeWxDisplayProfile({
|
||||
...(result.profile ?? {}),
|
||||
id: result.profile?.id ?? profile?.id ?? '',
|
||||
hasWechat: true,
|
||||
}),
|
||||
);
|
||||
loadProfile();
|
||||
toast('微信授权成功', 'success');
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
let wxInfo = null;
|
||||
try {
|
||||
wxInfo = await fetchMiniWechatUserInfo();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await bindWechatForUser(wxInfo);
|
||||
const result = await bindWechatForUser(null);
|
||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
if (result.ok) {
|
||||
const merged = mergeWxDisplayProfile({
|
||||
...(result.profile ?? {}),
|
||||
id: result.profile?.id ?? profile?.id ?? '',
|
||||
hasWechat: true,
|
||||
nickname: result.profile?.nickname || wxInfo.nickname || profile?.nickname,
|
||||
avatarUrl: result.profile?.avatarUrl || wxInfo.avatarUrl || profile?.avatarUrl,
|
||||
});
|
||||
setProfile(merged);
|
||||
loadProfile();
|
||||
toast('微信授权成功', 'success');
|
||||
if (result.ok && result.profile) {
|
||||
applyProfile({ ...result.profile, hasWechat: true });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '微信授权失败');
|
||||
toast(e instanceof Error ? e.message : '授权失败');
|
||||
return false;
|
||||
} finally {
|
||||
setBindingWx(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openProfileSheet(me?: UserProfile | null) {
|
||||
const base = mergeWxDisplayProfile(
|
||||
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||
);
|
||||
setDraftAvatarTemp('');
|
||||
setDraftAvatarUrl(base.avatarUrl || '');
|
||||
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
|
||||
setProfileSheetOpen(true);
|
||||
}
|
||||
|
||||
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
|
||||
async function handleAvatarTap() {
|
||||
if (bindingWx || savingProfile) return;
|
||||
if (!isLoggedIn()) {
|
||||
goLogin('/pages/mine/index');
|
||||
return;
|
||||
}
|
||||
if (isWeapp) {
|
||||
openProfileSheet();
|
||||
return;
|
||||
}
|
||||
if (!profile?.hasWechat) {
|
||||
const ok = await ensureWechatBound();
|
||||
if (ok) loadProfile();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
|
||||
const tempPath = e.detail?.avatarUrl?.trim();
|
||||
if (!tempPath) {
|
||||
toast('未获取到头像,请重试');
|
||||
return;
|
||||
}
|
||||
setDraftAvatarTemp(tempPath);
|
||||
setDraftAvatarUrl(tempPath);
|
||||
}
|
||||
|
||||
async function saveWxProfile() {
|
||||
const nickname = draftNickname.trim();
|
||||
if (!nickname) {
|
||||
toast('请填写昵称');
|
||||
return;
|
||||
}
|
||||
if (!draftAvatarTemp && !draftAvatarUrl) {
|
||||
toast('请选择头像');
|
||||
return;
|
||||
}
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
let avatarUrl = draftAvatarUrl;
|
||||
let avatarResourceId: string | undefined;
|
||||
if (draftAvatarTemp) {
|
||||
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
|
||||
avatarUrl = uploaded.url;
|
||||
avatarResourceId = uploaded.resourceId;
|
||||
}
|
||||
const updated = await uploadMiniWechatProfile({
|
||||
nickname,
|
||||
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
|
||||
});
|
||||
if (updated) applyProfile(updated);
|
||||
setProfileSheetOpen(false);
|
||||
toast('头像昵称已更新', 'success');
|
||||
loadProfile();
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('url' in item && item.url) {
|
||||
Taro.navigateTo({ url: item.url });
|
||||
@@ -190,17 +259,6 @@ export default function MinePage() {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDisplayProfile(profile: UserProfile | null, hasWechat: boolean) {
|
||||
if (!profile) {
|
||||
return { nickname: '用户', avatarUrl: null as string | null };
|
||||
}
|
||||
const merged = hasWechat ? mergeWxDisplayProfile(profile) : profile;
|
||||
return {
|
||||
nickname: merged.nickname || '用户',
|
||||
avatarUrl: merged.avatarUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderAvatarContent(displayAvatarUrl: string | null) {
|
||||
if (displayAvatarUrl) {
|
||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||
@@ -210,10 +268,7 @@ export default function MinePage() {
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
@@ -230,11 +285,10 @@ export default function MinePage() {
|
||||
</View>
|
||||
</View>
|
||||
<View className="mine-login-gate">
|
||||
<View className="mine-login-gate-hint">登录后管理订单与个人信息</View>
|
||||
<View
|
||||
className="mine-login-btn"
|
||||
onClick={() => goLogin('/pages/mine/index')}
|
||||
>
|
||||
<View className="mine-login-gate-hint">
|
||||
登录后管理订单与个人信息;无需登录也可浏览商品和门店
|
||||
</View>
|
||||
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
|
||||
<Text>去登录</Text>
|
||||
</View>
|
||||
</View>
|
||||
@@ -244,42 +298,75 @@ export default function MinePage() {
|
||||
}
|
||||
|
||||
const hasWechat = !!profile?.hasWechat;
|
||||
const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp';
|
||||
const display = resolveDisplayProfile(profile, hasWechat);
|
||||
const nickname = display.nickname;
|
||||
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
|
||||
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
|
||||
const display = mergeWxDisplayProfile(
|
||||
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||
);
|
||||
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
|
||||
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
|
||||
throw new Error('wx profile helpers missing');
|
||||
}
|
||||
const nickname = display.nickname || '用户';
|
||||
const needProfileFill = isWeapp && needsWxProfileFill(display);
|
||||
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
|
||||
const memberLabel = needProfileFill
|
||||
? '点击头像完善资料'
|
||||
: isWeapp || hasWechat
|
||||
? '好客会员'
|
||||
: canWxAuth
|
||||
? '点击头像授权'
|
||||
: '好客会员';
|
||||
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
|
||||
const previewAvatar = draftAvatarUrl || display.avatarUrl;
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||
<TabMainHeader title="我的" />
|
||||
<View className="mine-header">
|
||||
<View className="mine-header-texture" />
|
||||
<View className="mine-profile">
|
||||
<View
|
||||
className={`mine-avatar-wrap${!hasWechat && canWxBind ? ' mine-avatar-wrap--action' : ''}`}
|
||||
onClick={() => void handleAvatarTap()}
|
||||
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
|
||||
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||
>
|
||||
<View
|
||||
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : canWxBind ? ' mine-avatar--wx-pending' : ''}`}
|
||||
className={`mine-avatar${
|
||||
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
|
||||
}`}
|
||||
>
|
||||
{renderAvatarContent(display.avatarUrl)}
|
||||
</View>
|
||||
{!hasWechat && canWxBind ? (
|
||||
<View className="mine-avatar-status mine-avatar-status--pending">
|
||||
<Text>{bindingWx ? '授权中' : '去授权'}</Text>
|
||||
{avatarClickable ? (
|
||||
<View
|
||||
className={`mine-avatar-status${
|
||||
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
|
||||
}`}
|
||||
>
|
||||
<Text>
|
||||
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
<View>
|
||||
|
||||
<View
|
||||
className="mine-profile-meta"
|
||||
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||
>
|
||||
<Text className="mine-profile-name">{nickname}</Text>
|
||||
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
|
||||
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
|
||||
{memberLabel}
|
||||
</Text>
|
||||
{!hasWechat && canWxBind ? (
|
||||
<Text className="mine-wechat-hint">点击头像完成微信授权</Text>
|
||||
{profileLoadError ? (
|
||||
<Text
|
||||
className="mine-profile-retry"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
loadProfile();
|
||||
}}
|
||||
>
|
||||
资料加载失败,点击重试
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
@@ -376,6 +463,58 @@ export default function MinePage() {
|
||||
</View>
|
||||
|
||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||
|
||||
{profileSheetOpen ? (
|
||||
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||||
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||||
<Text className="mine-profile-sheet-hint">
|
||||
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||
</Text>
|
||||
<Button
|
||||
className="mine-profile-avatar-btn"
|
||||
openType="chooseAvatar"
|
||||
hoverClass="none"
|
||||
onChooseAvatar={onChooseAvatar}
|
||||
>
|
||||
<View className="mine-profile-avatar-preview">
|
||||
{previewAvatar ? (
|
||||
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||||
) : (
|
||||
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||
)}
|
||||
</View>
|
||||
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
||||
</Button>
|
||||
<View className="mine-profile-nickname-wrap">
|
||||
<Text className="mine-profile-nickname-label">昵称</Text>
|
||||
<Input
|
||||
className="mine-profile-nickname-input"
|
||||
type="nickname"
|
||||
maxlength={32}
|
||||
placeholder="点击填写昵称"
|
||||
value={draftNickname}
|
||||
onInput={(e) => setDraftNickname(e.detail.value)}
|
||||
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
|
||||
/>
|
||||
</View>
|
||||
<View className="mine-profile-sheet-actions">
|
||||
<View
|
||||
className="mine-profile-sheet-cancel"
|
||||
onClick={() => !savingProfile && setProfileSheetOpen(false)}
|
||||
>
|
||||
<Text>取消</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
|
||||
onClick={savingProfile ? undefined : () => void saveWxProfile()}
|
||||
>
|
||||
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import ShareNavButton from '../../components/ShareNavButton';
|
||||
import WechatShareReady from '../../components/WechatShareReady';
|
||||
import ContactCsButton from '../../components/ContactCsButton';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
import { maskPhone } from '../../lib/phone';
|
||||
@@ -14,6 +15,8 @@ import {
|
||||
toWeappShareMessage,
|
||||
} from '../../lib/wechat-share';
|
||||
|
||||
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||
|
||||
type OrderItem = {
|
||||
productName?: string;
|
||||
productSpec?: string;
|
||||
@@ -104,8 +107,20 @@ export default function OrderDetailPage() {
|
||||
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
|
||||
}
|
||||
|
||||
function goCustomerService() {
|
||||
Taro.navigateTo({ url: '/pages/customer-service/index' });
|
||||
}
|
||||
|
||||
const pageClass = [
|
||||
'order-detail-page',
|
||||
order ? 'order-detail-page--with-actions' : '',
|
||||
canPay ? 'order-detail-page--with-pay' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
|
||||
<PageShell variant="sub" className={pageClass}>
|
||||
<WechatShareReady payload={sharePayload} />
|
||||
<SubPageHeader
|
||||
title="订单详情"
|
||||
@@ -173,19 +188,39 @@ export default function OrderDetailPage() {
|
||||
)}
|
||||
</View>
|
||||
|
||||
{canPay && order && (
|
||||
<View className="pay-bar order-detail-pay-bar">
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">待支付</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={goPay}>
|
||||
去付款
|
||||
</View>
|
||||
{order ? (
|
||||
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
|
||||
{isWeapp ? (
|
||||
<ContactCsButton
|
||||
className="order-detail-cs-btn"
|
||||
session={{
|
||||
from: 'order-detail',
|
||||
orderId: order.id,
|
||||
orderNo: order.orderNo,
|
||||
}}
|
||||
>
|
||||
联系客服
|
||||
</ContactCsButton>
|
||||
) : (
|
||||
<View className="order-detail-cs-btn" onClick={goCustomerService}>
|
||||
<Text>联系客服</Text>
|
||||
</View>
|
||||
)}
|
||||
{canPay ? (
|
||||
<>
|
||||
<View className="order-confirm-total">
|
||||
<Text className="order-confirm-total-label">待支付</Text>
|
||||
<Text className="order-confirm-total-value">
|
||||
¥{Number(order.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-confirm-submit" onClick={goPay}>
|
||||
去付款
|
||||
</View>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
) : null}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '我的订单',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { View, Text, Image } from '@tarojs/components';
|
||||
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
import { request, toast } from '../../lib/api';
|
||||
import { buildPayUrl } from '../../lib/checkout-nav';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'all', label: '全部订单' },
|
||||
@@ -30,6 +31,13 @@ function orderStatusLabel(tab: string, status?: string): string {
|
||||
return STATUS_LABELS[status] || status;
|
||||
}
|
||||
|
||||
type OrderItem = {
|
||||
productName?: string;
|
||||
productImage?: string;
|
||||
unitPrice?: number;
|
||||
quantity?: number;
|
||||
};
|
||||
|
||||
type OrderRow = {
|
||||
id: string;
|
||||
orderNo?: string;
|
||||
@@ -37,7 +45,9 @@ type OrderRow = {
|
||||
payAmount?: number;
|
||||
productName?: string;
|
||||
qty?: number;
|
||||
createdAt?: string;
|
||||
quantity?: number;
|
||||
originOrderId?: string | null;
|
||||
items?: OrderItem[];
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
@@ -47,9 +57,9 @@ export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<OrderRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrders = useCallback(() => {
|
||||
setLoading(true);
|
||||
request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
|
||||
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
|
||||
)
|
||||
.then((data) => {
|
||||
@@ -63,6 +73,18 @@ export default function OrdersPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOrders();
|
||||
}, [loadOrders]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void loadOrders().finally(() => Taro.stopPullDownRefresh());
|
||||
});
|
||||
|
||||
function goPay(orderId: string) {
|
||||
Taro.navigateTo({ url: buildPayUrl({ orderId }) });
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell variant="sub" className="orders-page">
|
||||
<SubPageHeader title="我的订单" />
|
||||
@@ -81,35 +103,62 @@ export default function OrdersPage() {
|
||||
{loading ? <View className="u-empty">加载中…</View> : null}
|
||||
{!loading && orders.length === 0 ? <View className="u-empty">暂无订单</View> : null}
|
||||
{!loading &&
|
||||
orders.map((o) => (
|
||||
<View
|
||||
key={o.id}
|
||||
className="order-list-item"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||
>
|
||||
<View className="order-list-head">
|
||||
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||
<Text className="order-list-status">
|
||||
{orderStatusLabel(tab, o.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
<View className="order-list-thumb" />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text className="order-list-name">{o.productName || '杜康商品'}</Text>
|
||||
<Text className="order-list-meta">
|
||||
数量 {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
|
||||
orders.map((o) => {
|
||||
const item = o.items?.[0];
|
||||
const productName = item?.productName || o.productName || '杜康商品';
|
||||
const productImage = (item?.productImage || '').trim();
|
||||
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1;
|
||||
const unitPrice = Number(item?.unitPrice ?? 0);
|
||||
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={o.id}
|
||||
className="order-list-item"
|
||||
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
|
||||
>
|
||||
<View className="order-list-head">
|
||||
<Text className="order-list-no">{o.orderNo || o.id}</Text>
|
||||
<Text className="order-list-status">
|
||||
{orderStatusLabel(tab, o.status)}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="order-list-body">
|
||||
<View className="order-list-thumb">
|
||||
{productImage ? (
|
||||
<Image className="order-list-thumb-img" src={productImage} mode="aspectFill" />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className="order-list-name">{productName}</Text>
|
||||
<View className="order-list-meta-row">
|
||||
<Text className="order-list-meta">数量 {qty}</Text>
|
||||
<Text className="order-list-meta">单价 ¥{unitPrice.toFixed(2)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-list-footer">
|
||||
<View className="order-list-pay-amount">
|
||||
<Text className="order-list-meta">实付</Text>
|
||||
<Text className="order-product-price">
|
||||
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
{canPay ? (
|
||||
<View
|
||||
className="order-list-pay-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPay(o.id);
|
||||
}}
|
||||
>
|
||||
付款
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
<View className="order-list-footer">
|
||||
<Text className="order-list-meta">实付</Text>
|
||||
<Text className="order-product-price">
|
||||
¥{Number(o.payAmount ?? 0).toFixed(2)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -8,21 +8,25 @@ export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="legal-page">
|
||||
<SubPageHeader title={doc.title} />
|
||||
<View className="sub-page-body inset-page">
|
||||
<ScrollView scrollY className="legal-scroll">
|
||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
||||
<Text className="legal-intro">{doc.intro}</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading">{section.heading}</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View className="sub-page-body inset-page legal-body">
|
||||
<Text className="legal-updated" selectable>
|
||||
更新日期:{doc.updatedAt}
|
||||
</Text>
|
||||
<Text className="legal-intro" selectable>
|
||||
{doc.intro}
|
||||
</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading" selectable>
|
||||
{section.heading}
|
||||
</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -101,12 +101,14 @@ export default function RedeemPage() {
|
||||
className="redeem-input"
|
||||
type="digit"
|
||||
placeholder="输入核销金额"
|
||||
placeholderClass="redeem-input-placeholder"
|
||||
value={amount}
|
||||
onInput={(e) => setAmount(e.detail.value)}
|
||||
style={{ textAlign: 'center' }}
|
||||
/>
|
||||
</View>
|
||||
<View className="redeem-amount-foot">
|
||||
<Text className="redeem-tips" style={{ margin: 0 }}>
|
||||
<Text className="redeem-amount-hint">
|
||||
最高可核销 ¥{formatMoney(redeemableMax)}
|
||||
</Text>
|
||||
<Text className="redeem-fill-max" onClick={fillMaxAmount}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export default definePageConfig({
|
||||
navigationStyle: 'custom',
|
||||
navigationBarTitleText: '门店',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { View, Text, Image, Input } from '@tarojs/components';
|
||||
import Taro, { useDidShow } from '@tarojs/taro';
|
||||
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import TabMainHeader from '../../components/TabMainHeader';
|
||||
import RegionPicker from '../../components/RegionPicker';
|
||||
import CategoryPicker, {
|
||||
EMPTY_CATEGORY,
|
||||
formatCategoryLabel,
|
||||
type CategorySelection,
|
||||
type StoreCategoryNode,
|
||||
} from '../../components/CategoryPicker';
|
||||
import {
|
||||
DEFAULT_REGION,
|
||||
formatRegionLabel,
|
||||
@@ -26,20 +32,36 @@ type Store = {
|
||||
openTime?: string | null;
|
||||
closeTime?: string | null;
|
||||
status?: string;
|
||||
categoryId?: string | null;
|
||||
category?: { id?: string; name?: string; parentId?: string | null } | null;
|
||||
};
|
||||
|
||||
const CATEGORY_TABS = ['全部', '火锅', '地方菜', '高端餐饮', '烧烤烤肉', '西餐'] as const;
|
||||
const MOCK_DISTANCES = ['800m', '1.2km', '3.5km', '1.5km', '2.0km'];
|
||||
|
||||
export default function StoresPage() {
|
||||
const [stores, setStores] = useState<Store[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [categoryTab, setCategoryTab] = useState<string>('全部');
|
||||
const [keywordInput, setKeywordInput] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [region, setRegion] = useState<RegionSelection>(DEFAULT_REGION);
|
||||
const [regionOpen, setRegionOpen] = useState(false);
|
||||
const [category, setCategory] = useState<CategorySelection>(EMPTY_CATEGORY);
|
||||
const [categoryOpen, setCategoryOpen] = useState(false);
|
||||
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
|
||||
const [cityCode, setCityCode] = useState<string>(FALLBACK_CITY_CODE);
|
||||
const regionLabel = formatRegionLabel(region);
|
||||
const categoryLabel = formatCategoryLabel(category);
|
||||
|
||||
const childIdsByParent = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const root of categoryTree) {
|
||||
map.set(
|
||||
root.id,
|
||||
(root.children ?? []).map((c) => c.id),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [categoryTree]);
|
||||
|
||||
useDidShow(() => {
|
||||
syncTabBarSelected(1);
|
||||
@@ -50,21 +72,75 @@ export default function StoresPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
void request<StoreCategoryNode[]>('/store-categories')
|
||||
.then((tree) => setCategoryTree(Array.isArray(tree) ? tree : []))
|
||||
.catch(() => setCategoryTree([]));
|
||||
}, []);
|
||||
|
||||
const loadStores = useCallback(() => {
|
||||
setLoading(true);
|
||||
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
|
||||
request<Store[]>(path)
|
||||
return request<Store[]>(path)
|
||||
.then((list) => setStores(Array.isArray(list) ? list : []))
|
||||
.catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [cityCode]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStores();
|
||||
}, [loadStores]);
|
||||
|
||||
usePullDownRefresh(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const resolved = await resolveUserCity();
|
||||
setRegion(resolved.region);
|
||||
const nextCode = getCityCodeForCatalog(resolved);
|
||||
setCityCode(nextCode);
|
||||
setLoading(true);
|
||||
const path = nextCode ? `/stores?cityCode=${encodeURIComponent(nextCode)}` : '/stores';
|
||||
const list = await request<Store[]>(path);
|
||||
setStores(Array.isArray(list) ? list : []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
Taro.stopPullDownRefresh();
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
function matchesCategory(store: Store): boolean {
|
||||
if (!category.parentId) return true;
|
||||
const storeCatId = String(store.categoryId || store.category?.id || '');
|
||||
const storeParentId = String(store.category?.parentId || '');
|
||||
if (category.childId) {
|
||||
return storeCatId === category.childId;
|
||||
}
|
||||
if (storeParentId && storeParentId === category.parentId) return true;
|
||||
const siblings = childIdsByParent.get(category.parentId) ?? [];
|
||||
return siblings.includes(storeCatId);
|
||||
}
|
||||
|
||||
const filtered = stores.filter((s) => {
|
||||
if (!matchesRegionFilter(s, region)) return false;
|
||||
if (!matchesCategory(s)) return false;
|
||||
if (!keyword.trim()) return true;
|
||||
const q = keyword.trim();
|
||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
||||
});
|
||||
|
||||
function applySearch() {
|
||||
setKeyword(keywordInput.trim());
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setKeywordInput('');
|
||||
setKeyword('');
|
||||
setCategory(EMPTY_CATEGORY);
|
||||
setRegion(DEFAULT_REGION);
|
||||
}
|
||||
|
||||
function formatHours(store: Store) {
|
||||
if (store.openTime && store.closeTime) {
|
||||
return `营业时间: ${store.openTime}-${store.closeTime}`;
|
||||
@@ -73,34 +149,37 @@ export default function StoresPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="tab"
|
||||
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
||||
>
|
||||
<PageShell variant="tab" className="store-page no-tab-header">
|
||||
<TabMainHeader title="门店" />
|
||||
<View className="store-toolbar">
|
||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||
<View className="store-location-pin" />
|
||||
<Text className="store-location-text">{regionLabel} ▾</Text>
|
||||
</View>
|
||||
<Input
|
||||
className="store-search"
|
||||
placeholder="搜索门店"
|
||||
value={keyword}
|
||||
onInput={(e) => setKeyword(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="store-category-tabs">
|
||||
{CATEGORY_TABS.map((tab) => (
|
||||
<Text
|
||||
key={tab}
|
||||
className={`store-category-tab${categoryTab === tab ? ' store-category-tab--active' : ''}`}
|
||||
onClick={() => setCategoryTab(tab)}
|
||||
>
|
||||
{tab}
|
||||
<View className="store-filter">
|
||||
<View className="store-search-row">
|
||||
<Input
|
||||
className="store-search-input"
|
||||
placeholder="搜索门店名称/地址"
|
||||
value={keywordInput}
|
||||
confirmType="search"
|
||||
onInput={(e) => setKeywordInput(e.detail.value)}
|
||||
onConfirm={applySearch}
|
||||
/>
|
||||
<View className="store-search-btn" onClick={applySearch} aria-label="搜索">
|
||||
<View className="store-search-icon" />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-filter-row">
|
||||
<View className="store-filter-chip" onClick={() => setRegionOpen(true)}>
|
||||
<Text className="store-filter-chip-text">{regionLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<View className="store-filter-chip" onClick={() => setCategoryOpen(true)}>
|
||||
<Text className="store-filter-chip-text">{categoryLabel}</Text>
|
||||
<Text className="store-filter-chip-arrow">▾</Text>
|
||||
</View>
|
||||
<Text className="store-filter-reset" onClick={resetFilters}>
|
||||
重置
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="store-list">
|
||||
@@ -150,6 +229,13 @@ export default function StoresPage() {
|
||||
onClose={() => setRegionOpen(false)}
|
||||
onConfirm={(next) => setRegion(next)}
|
||||
/>
|
||||
<CategoryPicker
|
||||
open={categoryOpen}
|
||||
tree={categoryTree}
|
||||
value={category}
|
||||
onClose={() => setCategoryOpen(false)}
|
||||
onConfirm={(next) => setCategory(next)}
|
||||
/>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '用户协议',
|
||||
navigationBarTitleText: '用户服务协议',
|
||||
navigationStyle: 'custom',
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, Text, ScrollView } from '@tarojs/components';
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import { getLegalDocument } from '@dukang/shared-types';
|
||||
import PageShell from '../../components/PageShell';
|
||||
import SubPageHeader from '../../components/SubPageHeader';
|
||||
@@ -8,21 +8,25 @@ export default function UserAgreementPage() {
|
||||
return (
|
||||
<PageShell variant="sub" className="legal-page">
|
||||
<SubPageHeader title={doc.title} />
|
||||
<View className="sub-page-body inset-page">
|
||||
<ScrollView scrollY className="legal-scroll">
|
||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
||||
<Text className="legal-intro">{doc.intro}</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading">{section.heading}</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
<View className="sub-page-body inset-page legal-body">
|
||||
<Text className="legal-updated" selectable>
|
||||
更新日期:{doc.updatedAt}
|
||||
</Text>
|
||||
<Text className="legal-intro" selectable>
|
||||
{doc.intro}
|
||||
</Text>
|
||||
{doc.sections.map((section) => (
|
||||
<View key={section.heading} className="legal-section">
|
||||
<Text className="legal-heading" selectable>
|
||||
{section.heading}
|
||||
</Text>
|
||||
{section.paragraphs.map((p, i) => (
|
||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||
{p}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</PageShell>
|
||||
);
|
||||
|
||||
@@ -120,19 +120,7 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.benefit-hero-actions {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.benefit-hero-link {
|
||||
font-size: 13px;
|
||||
color: var(--color-heritage-red);
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.benefit-hero-cta {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-heritage-red);
|
||||
|
||||
@@ -81,10 +81,6 @@
|
||||
border-bottom-color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.home-aroma-tab--muted {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.home-product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
.legal-page .legal-scroll {
|
||||
height: 100%;
|
||||
.legal-page .legal-body {
|
||||
min-height: calc(100vh - var(--nav-bar-height, 88px));
|
||||
padding-top: 8px;
|
||||
padding-bottom: 40px;
|
||||
box-sizing: border-box;
|
||||
padding-bottom: 32px;
|
||||
background: var(--color-background, #f7f4ef);
|
||||
}
|
||||
|
||||
.legal-updated {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface-variant, #8d706e);
|
||||
color: #8d706e;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.legal-intro {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-on-surface, #1f1a17);
|
||||
line-height: 1.75;
|
||||
color: #1f1a17;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@@ -27,14 +30,15 @@
|
||||
display: block;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-on-surface, #1f1a17);
|
||||
color: #1f1a17;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.legal-paragraph {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--color-on-surface-variant, #5c504c);
|
||||
line-height: 1.75;
|
||||
color: #3d3530;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -234,7 +234,8 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.login-wechat-btn {
|
||||
.login-wechat-btn,
|
||||
.login-phone-quick-btn {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
border: none;
|
||||
@@ -250,52 +251,33 @@
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
||||
}
|
||||
|
||||
.login-wechat-btn:active {
|
||||
.login-wechat-btn:active,
|
||||
.login-phone-quick-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.login-wechat-btn--disabled {
|
||||
.login-wechat-btn--disabled,
|
||||
.login-phone-quick-btn--disabled {
|
||||
opacity: 0.7;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.login-wechat-btn__text {
|
||||
.login-wechat-btn__text,
|
||||
.login-phone-quick-btn__text {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.wechat-login-icon {
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.wechat-login-icon__big,
|
||||
.wechat-login-icon__small {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.wechat-login-icon__big {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 0;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.wechat-login-icon__small {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
right: 0;
|
||||
bottom: 3px;
|
||||
/* 重置小程序 Button 默认样式,避免绿边/微信绿 */
|
||||
.login-phone-quick-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
@@ -344,3 +326,51 @@
|
||||
.login-agreement-link {
|
||||
color: var(--color-heritage-red);
|
||||
}
|
||||
|
||||
.login-nav {
|
||||
min-height: 44px;
|
||||
padding: 8px var(--space-page) 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-nav-back {
|
||||
min-width: 72px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-on-surface);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-nav-back-icon {
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.login-cancel-btn {
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
margin-top: 20px;
|
||||
border: 1px solid var(--color-heritage-red);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.login-cancel-hint {
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mine-profile-meta {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mine-avatar-status {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
@@ -86,13 +91,6 @@
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.mine-wechat-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.mine-avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
@@ -151,6 +149,14 @@
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.mine-profile-retry {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #fff;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.mine-main {
|
||||
margin-top: -28px;
|
||||
position: relative;
|
||||
@@ -369,3 +375,126 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
background: rgba(20, 16, 14, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mine-profile-sheet {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
border-radius: 20px 20px 0 0;
|
||||
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.mine-profile-sheet-title {
|
||||
display: block;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1f1a17;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: #8d706e;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mine-profile-avatar-btn {
|
||||
margin: 20px auto 0;
|
||||
padding: 0;
|
||||
width: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mine-profile-avatar-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mine-profile-avatar-preview {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(166, 29, 36, 0.25);
|
||||
background: #f7f4ef;
|
||||
}
|
||||
|
||||
.mine-profile-avatar-tip {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-heritage-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mine-profile-nickname-wrap {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.mine-profile-nickname-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: #5c504c;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mine-profile-nickname-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border-radius: 12px;
|
||||
background: #f7f4ef;
|
||||
font-size: 15px;
|
||||
color: #1f1a17;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-actions {
|
||||
margin-top: 22px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-cancel,
|
||||
.mine-profile-sheet-save {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-cancel {
|
||||
background: #f0ebe4;
|
||||
color: #5c504c;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-save {
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.mine-profile-sheet-save.is-disabled {
|
||||
opacity: 0.65;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -20,11 +20,15 @@
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
|
||||
/* 无自定义 Tab 顶栏:贴顶(标题走小程序/H5 系统导航栏) */
|
||||
.page-shell.no-tab-header,
|
||||
.page-shell.h5-no-tab-header {
|
||||
--nav-bar-height: 0px;
|
||||
--nav-status-bar-height: 0px;
|
||||
--nav-content-height: 0px;
|
||||
}
|
||||
|
||||
.page-shell.no-tab-header .home-aroma-nav,
|
||||
.page-shell.h5-no-tab-header .home-aroma-nav {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.order-detail-page--with-pay .sub-page-body {
|
||||
.order-detail-page--with-pay .sub-page-body,
|
||||
.order-detail-page--with-actions .sub-page-body {
|
||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
@@ -16,6 +17,57 @@
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-detail-actionbar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px var(--space-page) calc(10px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--color-card, #fff);
|
||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-detail-actionbar--with-pay {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.order-detail-cs-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
min-width: 96px;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
margin: 0;
|
||||
border: 1px solid var(--color-outline, #c8c4be);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--color-on-surface);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.order-detail-cs-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.order-detail-actionbar .order-confirm-total {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-detail-actionbar .order-confirm-submit {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: var(--color-card);
|
||||
border-radius: var(--radius-lg);
|
||||
@@ -262,6 +314,13 @@
|
||||
background: var(--color-surface-container);
|
||||
margin-right: 12px;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.order-list-thumb-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.order-list-name {
|
||||
@@ -271,6 +330,12 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.order-list-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-list-meta {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
@@ -284,6 +349,28 @@
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-surface-container);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-list-pay-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-list-pay-btn {
|
||||
flex-shrink: 0;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pay-status {
|
||||
|
||||
@@ -47,16 +47,20 @@
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.redeem-input-placeholder {
|
||||
color: var(--color-subtle-gray);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 小程序原生 input:text-align 需落到组件自身与内部节点 */
|
||||
.redeem-input,
|
||||
.redeem-input input,
|
||||
.redeem-input .taro-input,
|
||||
.redeem-input .weui-input {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
height: 52px !important;
|
||||
min-height: 0 !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
@@ -65,18 +69,28 @@
|
||||
box-sizing: border-box !important;
|
||||
font-size: 24px !important;
|
||||
font-weight: 700 !important;
|
||||
line-height: normal !important;
|
||||
color: inherit;
|
||||
line-height: 52px !important;
|
||||
color: var(--color-heritage-red);
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.redeem-tips {
|
||||
margin: 0 var(--space-page);
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0 var(--space-page);
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.redeem-amount-hint {
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.redeem-amount-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -495,27 +509,70 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cs-brand {
|
||||
display: block;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--color-on-surface);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cs-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.cs-phone {
|
||||
.cs-phone,
|
||||
.cs-phone-display {
|
||||
display: block;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 32px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-heritage-red);
|
||||
margin-bottom: 8px;
|
||||
margin-top: 20px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.cs-hint {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--color-subtle-gray);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 8px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.cs-hours {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--color-subtle-gray);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.cs-online-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-width: 280px;
|
||||
padding: 14px 24px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cs-online-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.cs-call-btn {
|
||||
padding: 12px 32px;
|
||||
border-radius: 999px;
|
||||
@@ -524,3 +581,12 @@
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cs-phone-link {
|
||||
margin-top: 20px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--color-subtle-gray);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -3,56 +3,34 @@
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.store-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.store-filter {
|
||||
padding: 0 var(--space-page) 12px;
|
||||
}
|
||||
|
||||
.store-location {
|
||||
.store-search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
max-width: 42%;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-location-pin {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-heritage-red);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.store-location-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-search {
|
||||
.store-search-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
font-size: 13px;
|
||||
line-height: 40px;
|
||||
color: var(--color-on-surface);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.store-search input,
|
||||
.store-search .taro-input,
|
||||
.store-search .weui-input {
|
||||
.store-search-input input,
|
||||
.store-search-input .taro-input,
|
||||
.store-search-input .weui-input {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
min-height: 0 !important;
|
||||
@@ -66,32 +44,92 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.store-category-tabs {
|
||||
.store-search-btn {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
padding: 12px var(--space-page);
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid rgba(226, 190, 188, 0.1);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.store-category-tab {
|
||||
flex-shrink: 0;
|
||||
margin-right: 24px;
|
||||
.store-search-icon {
|
||||
position: relative;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-search-icon::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -5px;
|
||||
bottom: -4px;
|
||||
width: 7px;
|
||||
height: 2px;
|
||||
background: currentColor;
|
||||
border-radius: 1px;
|
||||
transform: rotate(45deg);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
.store-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.store-filter-chip {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-container-low);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.store-filter-chip-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-on-surface-variant);
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
.store-category-tab--active {
|
||||
.store-filter-chip-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 10px;
|
||||
color: var(--color-subtle-gray);
|
||||
}
|
||||
|
||||
.store-filter-reset {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--color-heritage-red);
|
||||
border-bottom-color: var(--color-heritage-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.store-list {
|
||||
padding: 16px var(--space-page);
|
||||
padding: 4px var(--space-page) 16px;
|
||||
}
|
||||
|
||||
.store-card {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user