1 Commits

Author SHA1 Message Date
jacy 81a6e3674b Merge pull request 'Dev' (#9) from dev into main
CI / verify (push) Has been cancelled
Reviewed-on: https://git.yqidian.com/jacy/dukang/pulls/9
2026-07-17 10:50:17 +08:00
169 changed files with 2858 additions and 7840 deletions
-1
View File
@@ -15,4 +15,3 @@ coverage/
server/dukang-api/prisma/migrations/ server/dukang-api/prisma/migrations/
debug_v3.xlsx debug_v3.xlsx
~$debug_v3.xlsx ~$debug_v3.xlsx
deploy/auto-release.env
-4
View File
@@ -20,7 +20,6 @@ import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage'; import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage'; import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import StoreCategoriesPage from './pages/StoreCategoriesPage';
import PromoCodesPage from './pages/PromoCodesPage'; import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout'; import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage'; import PromoCodeDetailPage from './pages/promo/PromoCodeDetailPage';
@@ -32,7 +31,6 @@ import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage'; import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage'; import WineryBillsPage from './pages/WineryBillsPage';
import TicketsPage from './pages/TicketsPage'; import TicketsPage from './pages/TicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage'; import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage'; import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage'; import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
@@ -70,7 +68,6 @@ export default function App() {
<Route path="/products" element={<ProductsPage />} /> <Route path="/products" element={<ProductsPage />} />
<Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} /> <Route path="/product-detail-templates" element={<ProductDetailTemplatesPage />} />
<Route path="/stores" element={<StoresPage />} /> <Route path="/stores" element={<StoresPage />} />
<Route path="/store-categories" element={<StoreCategoriesPage />} />
<Route path="/store-accounts" element={<StoreAccountsPage />} /> <Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} /> <Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} /> <Route path="/resources" element={<ResourcesPage />} />
@@ -92,7 +89,6 @@ export default function App() {
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} /> <Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} /> <Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} /> <Route path="/tickets" element={<TicketsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/logs/users" element={<UserLogsPage />} /> <Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} /> <Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} /> <Route path="/logs/partners" element={<PartnerLogsPage />} />
@@ -44,7 +44,6 @@ const MENU_ITEMS: MenuProps['items'] = [
label: '门店', label: '门店',
children: [ children: [
{ key: '/stores', label: '门店列表' }, { key: '/stores', label: '门店列表' },
{ key: '/store-categories', label: '门店分类' },
{ key: '/store-accounts', label: '门店账户' }, { key: '/store-accounts', label: '门店账户' },
{ key: '/store-media', label: '门店资源' }, { key: '/store-media', label: '门店资源' },
], ],
@@ -92,7 +91,6 @@ const MENU_ITEMS: MenuProps['items'] = [
], ],
}, },
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' }, { key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' }, { key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{ {
key: 'logs-group', key: 'logs-group',
+3 -5
View File
@@ -62,11 +62,9 @@ export const CITY_STATUS_LABELS: Record<string, string> = {
}; };
export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = { export const PARTNER_BILL_STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核', DRAFT: '草稿',
AWAITING_CONFIRM: '待合伙人确认', CONFIRMED: '确认',
UNPAID: '未打款', PAID: '已结算',
PAID: '已打款',
REJECTED: '已驳回',
}; };
export const MEDIA_TYPE_LABELS: Record<string, string> = { export const MEDIA_TYPE_LABELS: Record<string, string> = {
-11
View File
@@ -27,10 +27,6 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' }, { value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' }, { value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', 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_CREATE', label: '新增商品' },
{ value: 'PRODUCT_UPDATE', label: '编辑商品' }, { value: 'PRODUCT_UPDATE', label: '编辑商品' },
{ value: 'PRODUCT_DELETE', label: '删除商品' }, { value: 'PRODUCT_DELETE', label: '删除商品' },
@@ -40,16 +36,9 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'TICKET_REJECT', label: '工单驳回' }, { value: 'TICKET_REJECT', label: '工单驳回' },
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' }, { value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
{ value: 'STORE_PAYOUT_BATCH_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_GENERATE', label: '生成合伙人账单' },
{ value: 'PARTNER_BILL_SEND', label: '发送合伙人账单' },
{ value: 'PARTNER_BILL_BATCH_SEND', label: '批量发送合伙人账单' },
{ value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' }, { value: 'PARTNER_BILL_CONFIRM', label: '确认合伙人账单' },
{ value: 'PARTNER_BILL_MARK_PAID', 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; ] as const;
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries( export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = Object.fromEntries(
-247
View File
@@ -1,247 +0,0 @@
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>
);
}
+35 -103
View File
@@ -38,17 +38,15 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string }; type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = { const STATUS_LABELS: Record<string, string> = {
PENDING_REVIEW: '待审核', DRAFT: '待合伙人确认',
AWAITING_CONFIRM: '待合伙人确认', CONFIRMED: '待打款审核',
UNPAID: '未打款',
PAID: '已打款', PAID: '已打款',
REJECTED: '已驳回', REJECTED: '已驳回',
}; };
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
PENDING_REVIEW: 'gold', DRAFT: 'default',
AWAITING_CONFIRM: 'blue', CONFIRMED: 'orange',
UNPAID: 'red',
PAID: 'green', PAID: 'green',
REJECTED: 'red', REJECTED: 'red',
}; };
@@ -76,7 +74,6 @@ export default function PartnerBillsPage() {
const [rejectTarget, setRejectTarget] = useState<Row | null>(null); const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm(); const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
useEffect(() => { useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -84,10 +81,10 @@ export default function PartnerBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
async function generateBill(values: { partnerId?: string; month: Dayjs }) { async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
const year = values.month.year(); const year = values.month.year();
const month = values.month.month() + 1; const month = values.month.month() + 1;
if (!values.partnerId) { if (values.all || !values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>( const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all', '/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) }, { method: 'POST', body: JSON.stringify({ year, month }) },
@@ -98,71 +95,31 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }), body: JSON.stringify({ partnerId: values.partnerId, year, month }),
}); });
message.success('账单已生成(待审核)'); message.success('账单已生成');
} }
setGenOpen(false); setGenOpen(false);
reload(); reload();
} }
function sendBills(ids: string[]) { async function confirmBill(id: string) {
Modal.confirm({ await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
title: '发送给合伙人?', message.success('已代确认,进入待打款审核');
content: `将发送 ${ids.length} 笔账单到合伙人端,状态变为「待合伙人确认」。`, reload();
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();
},
});
} }
function markPaid(ids: string[], amountHint?: number) { async function markPaid(id: string) {
Modal.confirm({ await request(`/admin/partner-bills/${id}/mark-paid`, {
title: '确认打款?', method: 'POST',
content: `将标记 ${ids.length} 笔账单为已打款${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}`, body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
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) { function openReject(row: Row) {
Modal.confirm({ setRejectTarget(row);
title: '驳回该账单?', rejectForm.resetFields();
content: '驳回后合伙人需重新等待总部发送。请在下一步填写理由。', setRejectOpen(true);
okText: '继续填写理由',
cancelText: '取消',
onOk: () => {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
},
});
} }
async function submitReject(values: { reason: string }) { async function submitReject(values: { reason: string }) {
@@ -173,7 +130,7 @@ export default function PartnerBillsPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }), body: JSON.stringify({ reason: values.reason.trim() }),
}); });
message.success('已驳回'); message.success('已驳回打款申请');
setRejectOpen(false); setRejectOpen(false);
setRejectTarget(null); setRejectTarget(null);
reload(); reload();
@@ -201,10 +158,6 @@ export default function PartnerBillsPage() {
const summary = data?.summary; const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—'; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true }, { title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
@@ -241,7 +194,7 @@ export default function PartnerBillsPage() {
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
width: 130, width: 120,
render: (s, row) => ( render: (s, row) => (
<Space direction="vertical" size={0}> <Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag> <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
@@ -259,26 +212,21 @@ export default function PartnerBillsPage() {
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap> <Space size={0} wrap>
{(row.status === 'PENDING_REVIEW' || row.status === 'REJECTED') && ( {row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => sendBills([row.id])}> <Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button> </Button>
)} )}
{row.status === 'UNPAID' && ( {row.status === 'CONFIRMED' && (
<> <>
<Button type="link" size="small" onClick={() => markPaid([row.id], Number(row.totalAmount))}> <Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button> </Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}> <Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button> </Button>
</> </>
)} )}
{row.status === 'AWAITING_CONFIRM' && (
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
)}
</Space> </Space>
), ),
}, },
@@ -291,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
1 8:00 T+30
</Typography.Text> </Typography.Text>
</Space> </Space>
@@ -339,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态">
<Select <Select
allowClear allowClear
style={{ width: 150 }} style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
@@ -369,20 +317,6 @@ export default function PartnerBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </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> </Form>
<Table <Table
@@ -391,10 +325,6 @@ export default function PartnerBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
}}
scroll={{ x: 1280 }} scroll={{ x: 1280 }}
pagination={{ pagination={{
current: page, current: page,
@@ -408,7 +338,7 @@ export default function PartnerBillsPage() {
}} }}
/> />
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}> <Modal title="生成合伙人账单T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form <Form
form={genForm} form={genForm}
layout="vertical" layout="vertical"
@@ -431,7 +361,7 @@ export default function PartnerBillsPage() {
/> />
</Form.Item> </Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block>
</Button> </Button>
</Form> </Form>
</Modal> </Modal>
@@ -448,6 +378,8 @@ export default function PartnerBillsPage() {
> >
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}> <Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''} {rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
<br />
H5
</Typography.Paragraph> </Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}> <Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item <Form.Item
@@ -458,7 +390,7 @@ export default function PartnerBillsPage() {
{ max: 500, message: '不超过 500 字' }, { max: 500, message: '不超过 500 字' },
]} ]}
> >
<Input.TextArea rows={4} placeholder="请说明驳回原因" maxLength={500} showCount /> <Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
</Form.Item> </Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}> <Button type="primary" danger htmlType="submit" block loading={rejecting}>
+92 -134
View File
@@ -6,7 +6,6 @@ import {
Descriptions, Descriptions,
Drawer, Drawer,
Form, Form,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -24,26 +23,26 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
billNo: string;
billDate: string;
redeemCount: number;
redeemAmount: number; redeemAmount: number;
settlementRate: number;
payoutAmount: number; payoutAmount: number;
settlementRate: number;
status: string; status: string;
paidAt?: string | null; expectedPayAt: string;
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string }; store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
}; };
type StoreOption = { id: string; name: string; phone: string }; type StoreOption = { id: string; name: string; phone: string };
const STATUS_LABELS: Record<string, string> = { const PAYOUT_STATUS_LABELS: Record<string, string> = {
UNPAID: '打款', PENDING: '打款',
PAID: '已打款', PAID: '已打款',
}; };
const STATUS_COLORS: Record<string, string> = { const PAYOUT_STATUS_COLORS: Record<string, string> = {
UNPAID: 'red', PENDING: 'orange',
PAID: 'green', PAID: 'green',
}; };
@@ -52,7 +51,7 @@ export default function StoreBillsPage() {
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const [stores, setStores] = useState<StoreOption[]>([]); const [stores, setStores] = useState<StoreOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-bills', '/admin/store-payouts',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status); if (filters.status) qs.set('status', filters.status);
@@ -66,8 +65,6 @@ export default function StoreBillsPage() {
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [batchLoading, setBatchLoading] = useState(false);
useEffect(() => { useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`) void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -75,37 +72,13 @@ export default function StoreBillsPage() {
.catch(() => {}); .catch(() => {});
}, []); }, []);
function confirmPay(ids: string[], amountHint?: number) { async function confirmPayout(id: string) {
Modal.confirm({ await request(`/admin/store-payouts/${id}/confirm`, {
title: '确认打款?', method: 'POST',
content: `将确认 ${ids.length} 笔门店对账单${amountHint != null ? `,合计约 ¥${amountHint.toFixed(2)}` : ''}。此操作不可撤销。`, body: JSON.stringify({ remark: '财务确认打款' }),
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() { async function exportExcel() {
@@ -116,8 +89,10 @@ export default function StoreBillsPage() {
if (filters.storeId) qs.set('storeId', filters.storeId); if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo); if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/store-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(
downloadExcelCsv(result.csv, `门店对账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`); `/admin/store-payouts/export?${qs}`,
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
@@ -125,56 +100,62 @@ export default function StoreBillsPage() {
} }
const summary = data?.summary; 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> = [ const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 170, ellipsis: true },
{ {
title: '账单日', title: '核销日期',
dataIndex: 'billDate',
width: 110, width: 110,
render: (v) => String(v || '').slice(0, 10), render: (_, r) =>
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
}, },
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true }, { title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' }, { title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 }, { title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
{ title: '核销笔数', dataIndex: 'redeemCount', width: 90 },
{ {
title: '核销金额', title: '核销单号',
dataIndex: 'redeemAmount', dataIndex: ['redeemRecord', 'redeemNo'],
width: 110, width: 160,
render: (v) => `¥${Number(v).toFixed(2)}`, ellipsis: true,
render: (v) => v || '—',
}, },
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{ {
title: '结算比例', title: '核销比例',
dataIndex: 'settlementRate', dataIndex: 'settlementRate',
width: 90, width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`, render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
}, },
{ {
title: '应付金额', title: '应付门店',
dataIndex: 'payoutAmount', dataIndex: 'payoutAmount',
width: 110, width: 100,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${v}`,
}, },
{ {
title: '状态', title: '打款状态',
dataIndex: 'status', dataIndex: 'status',
width: 90, width: 100,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
}, },
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作',
width: 160, width: 140,
fixed: 'right', fixed: 'right',
render: (_, row) => ( render: (_, row) => (
<Space size={0}> <Space size={0}>
<Button type="link" size="small" onClick={() => void openDetail(row.id)}> <Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-payouts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button> </Button>
{row.status === 'UNPAID' && ( {row.status === 'PENDING' && (
<Button type="link" size="small" onClick={() => confirmPay([row.id], Number(row.payoutAmount))}> <Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
</Button> </Button>
)} )}
@@ -187,19 +168,19 @@ export default function StoreBillsPage() {
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 绿 T+1 = ×
</Typography.Text> </Typography.Text>
</Space> </Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <Space size="large" wrap>
<Statistic title="账单数" value={summary.count} /> <Statistic title="明细笔数" value={summary.count} />
<Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="核销额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.payoutAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -208,12 +189,16 @@ export default function StoreBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { storeId?: string; status?: string; range?: [Dayjs, Dayjs] }) => { onFinish={(v: {
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({ setFilters({
storeId: v.storeId || '', storeId: v.storeId || '',
status: v.status || '', status: v.status || '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '', dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
}); });
setPage(1); setPage(1);
}} }}
@@ -225,17 +210,21 @@ export default function StoreBillsPage() {
placeholder="全部门店" placeholder="全部门店"
style={{ width: 200 }} style={{ width: 200 }}
optionFilterProp="label" optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name || s.phone || s.id }))} options={stores.map((s) => ({
value: s.id,
label: `${s.name}${s.phone}`,
}))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="打款状态">
<Select <Select
allowClear allowClear
style={{ width: 120 }} style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))} placeholder="全部"
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/> />
</Form.Item> </Form.Item>
<Form.Item name="range" label="账单日"> <Form.Item name="dateRange" label="核销日期">
<DatePicker.RangePicker /> <DatePicker.RangePicker />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -259,16 +248,6 @@ export default function StoreBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
loading={batchLoading}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
@@ -277,12 +256,7 @@ export default function StoreBillsPage() {
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ scroll={{ x: 1300 }}
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1200 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -295,44 +269,28 @@ export default function StoreBillsPage() {
}} }}
/> />
<Drawer title="门店对账单明细" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={520}> <Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && ( {detail && (
<> <Descriptions column={1} bordered size="small">
<Descriptions column={1} size="small" bordered> <Descriptions.Item label="门店">
<Descriptions.Item label="账单号">{String(detail.billNo)}</Descriptions.Item> {String((detail.store as { name?: string })?.name ?? '—')}
<Descriptions.Item label="账单日">{String(detail.billDate || '').slice(0, 10)}</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="应付">¥{Number(detail.payoutAmount ?? 0).toFixed(2)}</Descriptions.Item> <Descriptions.Item label="核销单号">
<Descriptions.Item label="状态">{STATUS_LABELS[String(detail.status)] || String(detail.status)}</Descriptions.Item> {String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
<Descriptions.Item label="打款时间"> </Descriptions.Item>
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'} <Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
</Descriptions.Item> <Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
</Descriptions> <Descriptions.Item label="结算比例">
<Typography.Title level={5} style={{ marginTop: 16 }}> {detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item>
</Typography.Title> <Descriptions.Item label="状态">
<Table {PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
size="small" </Descriptions.Item>
rowKey="id" <Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
pagination={false} <Descriptions.Item label="实际打款">
dataSource={(detail.payouts as Array<Record<string, unknown>>) || []} {detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
columns={[ </Descriptions.Item>
{ </Descriptions>
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> </Drawer>
</div> </div>
@@ -1,233 +0,0 @@
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>
);
}
+7 -213
View File
@@ -19,15 +19,8 @@ import {
message, message,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { FilePdfOutlined, LinkOutlined } from '@ant-design/icons';
import { request, type Paginated } from '../lib/api'; import { request, type Paginated } from '../lib/api';
import { import { ADMIN_OPTIONS_PAGE_SIZE, STORE_AUDIT_STATUS_LABELS, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
ADMIN_OPTIONS_PAGE_SIZE,
RESOURCE_BIZ_TYPE_LABELS,
STORE_AUDIT_STATUS_LABELS,
STORE_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
import { import {
validateStoreCreateStep1, validateStoreCreateStep1,
validateStoreCreateStep3, validateStoreCreateStep3,
@@ -44,209 +37,6 @@ const CREATE_STEPS = [
{ title: '结算资质' }, { 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 = { type StoreRow = {
id: string; id: string;
name: string; name: string;
@@ -517,7 +307,7 @@ export default function StoresPage() {
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1200 }} <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); } }} /> pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="门店详情" width={760} open={drawerOpen} onClose={() => setDrawerOpen(false)} <Drawer title="门店详情" width={600} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && ( extra={detail && (
<Space wrap> <Space wrap>
{String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? ( {String(detail.auditStatus || '') === 'PENDING' || String(detail.auditStatus || '') === 'REJECTED' ? (
@@ -577,7 +367,6 @@ export default function StoresPage() {
)}> )}>
{detail && ( {detail && (
<> <>
<StoreAuditMediaSection detail={detail} />
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="审核状态"> <Descriptions.Item label="审核状态">
@@ -601,6 +390,11 @@ export default function StoresPage() {
</Button> </Button>
</Descriptions.Item> </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 ? ( {Array.isArray(detail.audits) && (detail.audits as Array<Record<string, unknown>>).length > 0 ? (
<Descriptions.Item label="审核记录"> <Descriptions.Item label="审核记录">
<Space direction="vertical" size={4} style={{ width: '100%' }}> <Space direction="vertical" size={4} style={{ width: '100%' }}>
+28 -102
View File
@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd'; import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { fmtTime } from '../lib/constants'; import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
@@ -9,12 +8,11 @@ import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; id: string;
ticketNo: string; ticketNo: string;
ticketType: TicketTypeDto; ticketType: string;
status: string; status: string;
refType: string; refType: string;
refId: string; refId: string;
remark?: string; remark?: string;
extraJson?: { evidenceUrls?: string[] };
createdAt: string; createdAt: string;
}; };
@@ -30,7 +28,7 @@ export default function TicketsPage() {
}, },
[filters], [filters],
); );
const [detail, setDetail] = useState<Row | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
async function approve(id: string) { async function approve(id: string) {
@@ -52,124 +50,52 @@ export default function TicketsPage() {
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 }, { 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: '状态', dataIndex: 'status', width: 90 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` }, { title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true }, { title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', title: '操作', width: 80,
width: 80,
render: (_, row) => ( render: (_, row) => (
<Button <Button type="link" size="small" onClick={async () => {
type="link" setDetail(await request(`/admin/tickets/${row.id}`));
size="small" setDrawerOpen(true);
onClick={async () => { }}></Button>
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
), ),
}, },
]; ];
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
return ( return (
<div> <div>
<Typography.Title level={4}></Typography.Title> <Typography.Title level={4}></Typography.Title>
<Form <Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="ticketType" label="类型"> <Form.Item name="ticketType" label="类型">
<Select <Select allowClear style={{ width: 120 }} options={[
allowClear { value: 'REFUND', label: '退款' },
style={{ width: 140 }} { value: 'RESHIPMENT', label: '补发' },
options={[ { value: 'ALERT', label: '异常' },
{ value: 'REFUND', label: '仅退款' }, ]} />
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态"> <Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
<Input allowClear placeholder="PENDING" /> <Button type="primary" htmlType="submit"></Button>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form> </Form>
<Table <Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
rowKey="id" pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
loading={loading} <Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
columns={columns} extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
dataSource={data?.items ?? []} <>
scroll={{ x: 900 }} <Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button>
pagination={{ <Button danger onClick={() => reject(String(detail.id))}></Button>
current: page, </>
pageSize, ) : null}>
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 && ( {detail && (
<Descriptions column={1} bordered size="small"> <Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item> <Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型"> <Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item> <Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联"> <Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
{String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</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> </Descriptions>
)} )}
</Drawer> </Drawer>
+43 -180
View File
@@ -3,10 +3,7 @@ import {
Button, Button,
Card, Card,
DatePicker, DatePicker,
Descriptions,
Drawer,
Form, Form,
Modal,
Select, Select,
Space, Space,
Statistic, Statistic,
@@ -24,34 +21,17 @@ import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; orderId: string;
billNo: string;
billDate: string;
orderCount: number;
orderAmount: number;
wineryRate: number;
wineryAmount: number;
status: string;
paidAt?: string | null;
};
type BillItem = {
id: string;
orderNo: string; orderNo: string;
deliveryType: string; deliveryType: string;
cityName?: string;
receiverCity?: string;
payAmount: number; payAmount: number;
wineryRate: number;
wineryAmount: number; wineryAmount: number;
paidAt: string; 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> = { const DELIVERY_LABELS: Record<string, string> = {
@@ -62,64 +42,29 @@ const DELIVERY_LABELS: Record<string, string> = {
export default function WineryBillsPage() { export default function WineryBillsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/winery-bills', '/admin/winery-bills',
() => { () => {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs; return qs;
}, },
[filters], [filters],
); );
const [exporting, setExporting] = useState(false); 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() { async function exportExcel() {
setExporting(true); setExporting(true);
try { try {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.year) qs.set('year', filters.year); if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month); if (filters.month) qs.set('month', filters.month);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom); if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`); const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all'; 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}`); message.success(`已导出 ${result.count}`);
} finally { } finally {
setExporting(false); setExporting(false);
@@ -128,22 +73,23 @@ export default function WineryBillsPage() {
const summary = data?.summary; const summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100); 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> = [ const columns: ColumnsType<Row> = [
{ title: '单号', dataIndex: 'billNo', width: 170, ellipsis: true }, { title: '单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
{ {
title: '账单日', title: '配送类型',
dataIndex: 'billDate', dataIndex: 'deliveryType',
width: 110, width: 90,
render: (v) => String(v || '').slice(0, 10), render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
}, },
{ title: '订单数', dataIndex: 'orderCount', width: 80 }, { 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: '酒单实付合计', title: '酒单实付',
dataIndex: 'orderAmount', dataIndex: 'payAmount',
width: 120, width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
@@ -159,27 +105,10 @@ export default function WineryBillsPage() {
render: (v) => `¥${Number(v).toFixed(2)}`, render: (v) => `¥${Number(v).toFixed(2)}`,
}, },
{ {
title: '状态', title: '支付时间',
dataIndex: 'status', dataIndex: 'paidAt',
width: 90, width: 170,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>, render: (v) => (v ? fmtTime(v) : '—'),
},
{
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>
),
}, },
]; ];
@@ -187,19 +116,19 @@ export default function WineryBillsPage() {
<div> <div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}> <Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}> <Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Typography.Text type="secondary"> <Typography.Text type="secondary">
8:00 × {ratePct}%绿 T+30 / = × {ratePct}%
</Typography.Text> </Typography.Text>
</Space> </Space>
{summary && ( {summary && (
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap> <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.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? 0} prefix="¥" precision={2} /> <Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space> </Space>
</Card> </Card>
)} )}
@@ -208,29 +137,27 @@ export default function WineryBillsPage() {
form={form} form={form}
layout="inline" layout="inline"
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
onFinish={(v: { status?: string; month?: Dayjs; range?: [Dayjs, Dayjs] }) => { onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
setFilters({ setFilters({
status: v.status || '',
year: v.month ? String(v.month.year()) : '', year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '', month: v.month ? String(v.month.month() + 1) : '',
dateFrom: v.range?.[0] ? v.range[0].format('YYYY-MM-DD') : '', deliveryType: v.deliveryType || '',
dateTo: v.range?.[1] ? v.range[1].format('YYYY-MM-DD') : '',
}); });
setPage(1); setPage(1);
}} }}
> >
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item name="month" label="账期月"> <Form.Item name="month" label="账期月">
<DatePicker picker="month" /> <DatePicker picker="month" />
</Form.Item> </Form.Item>
<Form.Item name="range" label="账单日"> <Form.Item name="deliveryType" label="配送类型">
<DatePicker.RangePicker /> <Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit"> <Button type="primary" htmlType="submit">
@@ -253,29 +180,15 @@ export default function WineryBillsPage() {
Excel Excel
</Button> </Button>
</Form.Item> </Form.Item>
<Form.Item>
<Button
type="primary"
disabled={!selectedKeys.length}
onClick={() => confirmPay(selectedKeys.map(String), selectedAmount)}
>
({selectedKeys.length})
</Button>
</Form.Item>
</Form> </Form>
<Table <Table
rowKey="id" rowKey="orderId"
className="admin-table-nowrap" className="admin-table-nowrap"
loading={loading} loading={loading}
columns={columns} columns={columns}
dataSource={data?.items ?? []} dataSource={data?.items ?? []}
rowSelection={{ scroll={{ x: 1200 }}
selectedRowKeys: selectedKeys,
onChange: setSelectedKeys,
getCheckboxProps: (r) => ({ disabled: r.status !== 'UNPAID' }),
}}
scroll={{ x: 1100 }}
pagination={{ pagination={{
current: page, current: page,
pageSize, pageSize,
@@ -287,56 +200,6 @@ 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> </div>
); );
} }
+5 -1
View File
@@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getPartnerProfile } from '../lib/api'; import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; import { partnerHomePath } from '../lib/partnerAccess';
@@ -18,6 +18,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { 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 }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
+157 -22
View File
@@ -1,7 +1,17 @@
import { useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload'; import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock'; 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 { toastError } from '../lib/toast';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
type OssUploadFieldProps = { type OssUploadFieldProps = {
value?: string; value?: string;
@@ -12,10 +22,27 @@ type OssUploadFieldProps = {
wide?: boolean; wide?: boolean;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
/** 父级已确认微信授权时可跳过检查 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
}; };
const DEFAULT_MAX_MB = 10; 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({ export default function OssUploadField({
value, value,
onChange, onChange,
@@ -25,10 +52,15 @@ export default function OssUploadField({
wide, wide,
compact, compact,
label, label,
wechatReady,
onWechatReadyChange,
}: OssUploadFieldProps) { }: OssUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [profile, setProfile] = useState<PartnerProfile | null>(null);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
function showUploadError(text: string) { function showUploadError(text: string) {
setError(text); setError(text);
@@ -37,6 +69,35 @@ export default function OssUploadField({
const resolvedAccept = const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); 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) { async function persistUpload(file: File) {
if (!file.size) { if (!file.size) {
@@ -64,38 +125,112 @@ export default function OssUploadField({
} }
} }
function pickFile() { async function startWechatAuth() {
if (uploading) return; setAuthorizing(true);
setError(''); 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(); inputRef.current?.click();
} }
const isImage = mediaType === 'IMAGE' && value; const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value; const isFile = mediaType === 'FILE' && value;
const busy = uploading; const busy = uploading || authorizing;
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传'); const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const triggerProps = { const triggerProps = {
type: 'button' as const, type: 'button' as const,
disabled: busy, disabled: busy || needsAuth,
onClick: pickFile, onClick: () => void pickFile(),
}; };
return ( return (
<div className="partner-oss-upload"> <div className="partner-oss-upload">
<input {needsAuth && (
ref={inputRef} <div className="partner-wechat-auth-hint" role="status">
type="file" <p className="body-md"></p>
accept={resolvedAccept} <button
className="partner-oss-upload-input" type="button"
disabled={busy} className="partner-btn-outline"
onChange={(e) => { style={{ marginTop: 8, width: '100%' }}
const file = e.target.files?.[0]; disabled={authorizing}
if (file) { onClick={() => void startWechatAuth()}
void uploadSelectedFile(file); >
} {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);
}}
/>
)}
{isImage ? ( {isImage ? (
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}> <button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
<img src={value} alt={label ?? '已上传'} /> <img src={value} alt={label ?? '已上传'} />
@@ -120,10 +255,10 @@ export default function OssUploadField({
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`} 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 }}> <span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
{busy ? 'hourglass_top' : 'photo_library'} {busy ? 'hourglass_top' : 'add_a_photo'}
</span> </span>
<span className="text-primary" style={{ fontWeight: 500 }}> <span className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : pickerLabel} {uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
</span> </span>
</button> </button>
)} )}
+4 -9
View File
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt'; const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound'; export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 手机号或微信验证通过后的免登录时长 */ /** 微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -77,7 +77,7 @@ export function isPartnerSessionExpired() {
} }
export function touchPartnerSession() { export function touchPartnerSession() {
if (!localStorage.getItem(REFRESH_TOKEN)) return; if (!hasPartnerWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
@@ -90,16 +90,11 @@ 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 */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) { export function saveWechatSession(data: PartnerSessionPayload) {
saveRememberedSession(data); saveAuth(data);
localStorage.setItem(PARTNER_WX_BOUND, '1'); localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
+6 -42
View File
@@ -8,10 +8,6 @@ export type StoreDraftForm = {
phone: string; phone: string;
storeSmsCode: string; storeSmsCode: string;
address: string; address: string;
openTime: string;
closeTime: string;
categoryParentId: string;
categoryId: string;
intro: string; intro: string;
coverUrl: string; coverUrl: string;
envPhotoUrls: string[]; envPhotoUrls: string[];
@@ -41,10 +37,6 @@ export const defaultStoreForm = (): StoreDraftForm => ({
phone: '', phone: '',
storeSmsCode: '', storeSmsCode: '',
address: '', address: '',
openTime: '10:00',
closeTime: '22:00',
categoryParentId: '',
categoryId: '',
intro: '', intro: '',
coverUrl: '', coverUrl: '',
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
@@ -76,10 +68,6 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
phone: String(raw.phone ?? base.phone), phone: String(raw.phone ?? base.phone),
storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode), storeSmsCode: String(raw.storeSmsCode ?? base.storeSmsCode),
address: String(raw.address ?? base.address), 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), intro: String(raw.intro ?? base.intro),
coverUrl: String(raw.coverUrl ?? base.coverUrl), coverUrl: String(raw.coverUrl ?? base.coverUrl),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3), envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
@@ -131,38 +119,21 @@ export function clearAllStoreDrafts(accountId?: string) {
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; 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( export function validateStoreStep1(
form: Pick< form: Pick<
StoreDraftForm, StoreDraftForm,
| 'regionCodes' 'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
| 'cityId'
| 'name'
| 'address'
| 'openTime'
| 'closeTime'
| 'categoryId'
| 'intro'
>, >,
): string | null { ): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) 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.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()) { if (form.intro.trim()) {
const len = form.intro.trim().length; const len = form.intro.trim().length;
if (len < 10 || len > 500) return '门店简介须为 10~500 字'; if (len < 10 || len > 500) return '门店简介须为 10~500 字';
@@ -187,18 +158,11 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
} }
export function validateStoreStep3( export function validateStoreStep3(
form: Pick< form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
>,
): string | null { ): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名'; if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号'; if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字'; if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行'; 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; return null;
} }
+52 -99
View File
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast'; import { toastError, toastSuccess } from '../lib/toast';
@@ -12,16 +11,16 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return '待确认'; case 'DRAFT': return '待确认';
case 'UNPAID': return '未打款'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已打款'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
} }
function canConfirm(status: string) { function canApplyPayment(status: string) {
return status === 'AWAITING_CONFIRM'; return status === 'DRAFT' || status === 'REJECTED';
} }
export default function BillsPage() { export default function BillsPage() {
@@ -31,7 +30,6 @@ export default function BillsPage() {
const [confirmed, setConfirmed] = useState(false); const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
async function loadBills() { async function loadBills() {
setLoading(true); setLoading(true);
@@ -52,7 +50,7 @@ export default function BillsPage() {
const billId = searchParams.get('id'); const billId = searchParams.get('id');
const actionable = useMemo( const actionable = useMemo(
() => bills.filter((b) => canConfirm(b.status)), () => bills.filter((b) => canApplyPayment(b.status)),
[bills], [bills],
); );
const bill = billId const bill = billId
@@ -60,32 +58,20 @@ export default function BillsPage() {
: actionable[0] ?? bills[0]; : actionable[0] ?? bills[0];
const status = String(bill?.status || ''); const status = String(bill?.status || '');
const showApplyForm = !!bill && canConfirm(status); const showApplyForm = !!bill && canApplyPayment(status);
const isUnpaid = status === 'UNPAID'; const isReviewing = status === 'CONFIRMED';
const isRejected = status === 'REJECTED'; const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID'; const isPaid = status === 'PAID';
function askConfirm(ids: string[]) { async function confirmBill() {
if (!window.confirm(`确认 ${ids.length} 笔账单无误并提交?确认后状态将变为「未打款」,等待总部打款。`)) { if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
return;
}
void doConfirm(ids);
}
async function doConfirm(ids: string[]) {
setSubmitting(true); setSubmitting(true);
try { try {
if (ids.length === 1) { await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
await request('PARTNER_H5', `/partner/settlement/bills/${ids[0]}/confirm`, { method: 'POST' }); method: 'POST',
} else { });
await request('PARTNER_H5', '/partner/settlement/bills/batch-confirm', { toastSuccess('申请已提交,请耐心等待总部打款审核');
method: 'POST',
body: JSON.stringify({ ids }),
});
}
toastSuccess('已确认,等待总部打款');
setConfirmed(false); setConfirmed(false);
setSelectedIds([]);
await loadBills(); await loadBills();
} catch (e) { } catch (e) {
toastError(e instanceof Error ? e.message : '提交失败'); toastError(e instanceof Error ? e.message : '提交失败');
@@ -94,12 +80,8 @@ export default function BillsPage() {
} }
} }
function toggleSelect(id: string) {
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
return ( return (
<PullToRefresh onRefresh={loadBills} className="partner-bills-page"> <div className="partner-bills-page">
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} /> <PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper"> <div className="partner-bill-stepper">
@@ -107,7 +89,7 @@ export default function BillsPage() {
<div className="partner-stepper-line" aria-hidden> <div className="partner-stepper-line" aria-hidden>
<div <div
className="partner-stepper-line-fill" className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isUnpaid || isRejected ? '75%' : '50%' }} style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
/> />
</div> </div>
<div className="partner-step"> <div className="partner-step">
@@ -117,7 +99,7 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isUnpaid || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}> <div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : ( {showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span> <span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)} )}
@@ -125,20 +107,20 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span> <span className="partner-step-label partner-step-label--active"></span>
</div> </div>
<div className="partner-step"> <div className="partner-step">
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isUnpaid || isRejected ? ' partner-step-circle--active' : ''}`}> <div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? ( {isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span> <span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'} ) : '3'}
</div> </div>
<span className={`partner-step-label${isUnpaid || isRejected || isPaid ? ' partner-step-label--active' : ''}`}> <span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '总部打款'} {isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{loading && <div className="empty"></div>} {loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>} {!loading && !bill && <div className="empty"></div>}
{bill && ( {bill && (
<section className="partner-bill-card"> <section className="partner-bill-card">
@@ -149,7 +131,7 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p> <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> <h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div> </div>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isUnpaid ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}> <span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)} {billStatusLabel(status)}
</span> </span>
</div> </div>
@@ -158,17 +140,17 @@ export default function BillsPage() {
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}> <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> <span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div> <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> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div> </div>
</div> </div>
)} )}
{isUnpaid && ( {isReviewing && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}> <div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span> <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 className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p> </p>
</div> </div>
)} )}
@@ -195,60 +177,29 @@ export default function BillsPage() {
</section> </section>
)} )}
{bills.length > 0 && ( {bills.length > 1 && (
<div style={{ padding: '0 20px' }}> <div style={{ padding: '0 20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}> <h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
<h3 className="headline-md" style={{ margin: 0 }}></h3> {bills.map((b) => (
{actionable.length > 1 && ( <button
<button key={String(b.id)}
type="button" type="button"
className="partner-fill-max" className="partner-store-card"
style={{ fontSize: 13 }} style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
disabled={!selectedIds.length || submitting} onClick={() => navigate(`/center/bills?id=${b.id}`)}
onClick={() => askConfirm(selectedIds)} >
> <div className="partner-store-card-header" style={{ marginBottom: 0 }}>
({selectedIds.length}) <div>
</button> <p className="body-md">{b.billNo}</p>
)} <p className="label-md text-muted">{billStatusLabel(b.status)}</p>
</div> {b.status === 'REJECTED' && b.rejectReason ? (
{bills.map((b) => { <p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
const id = String(b.id); ) : null}
const selectable = canConfirm(b.status); </div>
return ( <span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
<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> </div>
); </button>
})} ))}
</div> </div>
)} )}
@@ -257,7 +208,9 @@ export default function BillsPage() {
<div className="partner-info-banner" style={{ marginTop: 16 }}> <div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span> <span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}> <p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
{isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p> </p>
</div> </div>
@@ -271,14 +224,14 @@ export default function BillsPage() {
className="partner-btn-primary" className="partner-btn-primary"
disabled={!confirmed || submitting} disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }} style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => askConfirm([String(bill!.id)])} onClick={() => void confirmBill()}
> >
{submitting ? '正在提交...' : '确认账单'} {submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>} {!submitting && <span className="material-symbols-outlined">payments</span>}
</button> </button>
</footer> </footer>
</> </>
)} )}
</PullToRefresh> </div>
); );
} }
+13 -23
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact'; import { contactSupport } from '../lib/contact';
@@ -37,27 +36,18 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
document.title = isPrimary ? '合伙人中心' : '个人中心'; document.title = isPrimary ? '合伙人中心' : '个人中心';
}, [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(() => { useEffect(() => {
void loadCenter(); if (!isPrimary) return;
}, [loadCenter]); void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([]));
void listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0));
}, [isPrimary]);
const finance = useMemo(() => { const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length; const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length; const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
@@ -65,7 +55,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
pendingBillCount: pending.length, pendingBillCount: pending.length,
rejectedCount, rejectedCount,
pendingTotal: bills pendingTotal: bills
.filter((b) => b.status === 'AWAITING_CONFIRM' || b.status === 'UNPAID' || b.status === 'REJECTED') .filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0), .reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.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), balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
@@ -119,7 +109,7 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员'); const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
return ( return (
<PullToRefresh onRefresh={loadCenter} className="page partner-center-page partner-home--flush-top"> <div className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card"> <section className="partner-profile-card">
<button <button
type="button" type="button"
@@ -296,6 +286,6 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
<div className="partner-center-footer"> <div className="partner-center-footer">
<p className="label-md text-muted"> · </p> <p className="label-md text-muted"> · </p>
</div> </div>
</PullToRefresh> </div>
); );
} }
+24 -42
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { import {
@@ -156,62 +155,45 @@ export default function HomePage() {
document.title = '工作台'; document.title = '工作台';
}, []); }, []);
const loadHome = useCallback(() => { useEffect(() => {
if (!isLoggedIn()) { if (!isLoggedIn()) { navigate('/login'); return; }
navigate('/login'); // 等 session 带上账号后再按权限发请求,避免无权限接口弹错
return Promise.resolve(); if (!account) return;
}
if (!account) return Promise.resolve();
const tasks: Promise<unknown>[] = [];
if (canOrders) { if (canOrders) {
tasks.push( request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true }) .then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.then((data) => setOrders(Array.isArray(data.list) ? data.list : [])) .catch(() => setOrders([]));
.catch(() => setOrders([])),
);
} else { } else {
setOrders([]); setOrders([]);
} }
if (canDashboard) { if (canDashboard) {
tasks.push( request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true }) .then(setDash)
.then(setDash) .catch(() => setDash(null));
.catch(() => setDash(null)),
);
} else { } else {
setDash(null); setDash(null);
} }
if (canStores) { if (canStores) {
tasks.push( request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true }) .then((data) => setStores(Array.isArray(data) ? data : []))
.then((data) => setStores(Array.isArray(data) ? data : [])) .catch(() => setStores([]));
.catch(() => setStores([])),
);
} else { } else {
setStores([]); setStores([]);
} }
tasks.push( // 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
fetchPartnerLeaderboard('month') fetchPartnerLeaderboard('month')
.then((data) => { .then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT)); setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
}) })
.catch(() => { .catch(() => {
setLeaderboardEntries([]); setLeaderboardEntries([]);
}), });
);
return Promise.all(tasks).then(() => undefined);
}, [navigate, account, canOrders, canDashboard, canStores]); }, [navigate, account, canOrders, canDashboard, canStores]);
useEffect(() => {
void loadHome();
}, [loadHome]);
const storeCount = Number(dash?.storeCount || stores.length || 0); const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderStats = useMemo(() => summarizeOrders(orders), [orders]); const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
@@ -237,7 +219,7 @@ export default function HomePage() {
const profit = revenue * 0.25; const profit = revenue * 0.25;
return ( return (
<PullToRefresh onRefresh={loadHome} className="page partner-home partner-home--flush-top"> <div className="page partner-home partner-home--flush-top">
<main className="partner-home-body"> <main className="partner-home-body">
{isPrimary && ( {isPrimary && (
<> <>
@@ -364,6 +346,6 @@ export default function HomePage() {
<LeaderboardPreview entries={leaderboardEntries} /> <LeaderboardPreview entries={leaderboardEntries} />
</main> </main>
</PullToRefresh> </div>
); );
} }
+5 -10
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types'; import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerLeaderboard } from '../lib/leaderboard'; import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isPrimaryAccount } from '../lib/partnerAccess'; import { isPrimaryAccount } from '../lib/partnerAccess';
@@ -34,10 +33,10 @@ export default function LeaderboardPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadLeaderboard = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
return fetchPartnerLeaderboard(period) fetchPartnerLeaderboard(period)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -46,12 +45,8 @@ export default function LeaderboardPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [period]); }, [period]);
useEffect(() => {
void loadLeaderboard();
}, [loadLeaderboard]);
return ( return (
<PullToRefresh onRefresh={loadLeaderboard} className="page-no-tab"> <div className="page-no-tab">
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} /> <PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs"> <div className="partner-leaderboard-tabs">
@@ -129,6 +124,6 @@ export default function LeaderboardPage() {
<span className="material-symbols-outlined">add</span> <span className="material-symbols-outlined">add</span>
</Link> </Link>
)} )}
</PullToRefresh> </div>
); );
} }
+174 -8
View File
@@ -1,14 +1,25 @@
import { useRef, useState, type RefObject } from 'react'; import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { import {
getLastPhone, getLastPhone,
getPartnerProfile, getPartnerProfile,
hasPartnerWxSession,
request, request,
saveRememberedSession, saveAuth,
type PartnerSessionPayload, type PartnerSessionPayload,
} from '../lib/api'; } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess'; 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_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account'; const REMEMBER_FLAG_KEY = 'partner_remember_account';
@@ -20,7 +31,7 @@ function AgreementCheckbox({
}: { }: {
agreed: boolean; agreed: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement>; inputRef?: RefObject<HTMLLabelElement | null>;
}) { }) {
return ( return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}> <label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
@@ -39,6 +50,11 @@ 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 } { function loadRememberedPhone(): { phone: string; remember: boolean } {
try { try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1'; const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
@@ -60,9 +76,19 @@ function formatPartnerError(e: unknown): string {
return text; 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() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession(); const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile(); const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone(); const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || getLastPhone()); const [phone, setPhone] = useState(remembered.phone || getLastPhone());
@@ -70,10 +96,22 @@ export default function LoginPage() {
const [rememberAccount, setRememberAccount] = useState(remembered.remember); const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null); 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() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先勾选并同意用户协议'); setMsg('请先勾选并同意用户协议');
@@ -137,9 +175,14 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
silent: true, silent: true,
}); });
saveRememberedSession(data); saveAuth(data);
applySession(data); applySession(data);
persistRememberAccount(phone); persistRememberAccount(phone);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
await finishLoginNavigate(); await finishLoginNavigate();
} catch (e) { } catch (e) {
setMsg(formatPartnerError(e)); setMsg(formatPartnerError(e));
@@ -148,6 +191,116 @@ 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 ( return (
<div className="partner-auth-page"> <div className="partner-auth-page">
<div className="partner-auth-brand"> <div className="partner-auth-brand">
@@ -214,12 +367,25 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
7 {wxAuthorize && (
</p> <>
<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>
</>
)}
</div> </div>
</main> </main>
{hasPartnerWxSession() && savedProfile?.hasWechat && (
<Link to="/login?quick=1" className="partner-link"></Link>
)}
<footer className="partner-auth-footer"> <footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span> <span className="material-symbols-outlined">verified_user</span>
+7 -15
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess'; import { hasWarehouseAccess } from '../lib/partnerAccess';
@@ -60,17 +59,14 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
document.title = '订单管理'; document.title = '订单管理';
}, []); }, []);
const loadOrders = useCallback(() => { useEffect(() => {
if (!isLoggedIn()) { if (!isLoggedIn()) { navigate('/login'); return; }
navigate('/login');
return Promise.resolve();
}
if (!warehouseOk) { if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' }); setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true); setLoaded(true);
return Promise.resolve(); return;
} }
return request<OrdersResponse>('PARTNER_H5', '/partner/orders') request<OrdersResponse>('PARTNER_H5', '/partner/orders')
.then((res) => { .then((res) => {
setData({ setData({
list: Array.isArray(res.list) ? res.list : [], list: Array.isArray(res.list) ? res.list : [],
@@ -82,10 +78,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
.finally(() => setLoaded(true)); .finally(() => setLoaded(true));
}, [navigate, warehouseOk]); }, [navigate, warehouseOk]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
const filtered = useMemo(() => data.list.filter((o) => { const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true; if (statusFilter === 'ALL') return true;
const s = String(o.status).toUpperCase(); const s = String(o.status).toUpperCase();
@@ -150,7 +142,7 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
} }
return ( return (
<PullToRefresh onRefresh={loadOrders} className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}> <div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />} {!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment"> <div className="partner-segment">
@@ -273,6 +265,6 @@ export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
</div> </div>
</div> </div>
)} )}
</PullToRefresh> </div>
); );
} }
+17 -22
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import type { PartnerBillDto } from '@dukang/shared-types'; import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
@@ -13,9 +12,9 @@ function fmtMoney(n: number) {
function billStatusLabel(status: string) { function billStatusLabel(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return '待确认'; case 'DRAFT': return '待结算';
case 'UNPAID': return '未打款'; case 'CONFIRMED': return '审核中';
case 'PAID': return '已打款'; case 'PAID': return '已结算';
case 'REJECTED': return '已驳回'; case 'REJECTED': return '已驳回';
default: return status; default: return status;
} }
@@ -23,8 +22,8 @@ function billStatusLabel(status: string) {
function billStatusClass(status: string) { function billStatusClass(status: string) {
switch (status) { switch (status) {
case 'AWAITING_CONFIRM': return 'partner-settlement-status--pending'; case 'DRAFT': return 'partner-settlement-status--pending';
case 'UNPAID': return 'partner-settlement-status--reviewing'; case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled'; case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected'; case 'REJECTED': return 'partner-settlement-status--rejected';
default: return ''; default: return '';
@@ -48,17 +47,13 @@ export default function SettlementPage() {
const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 }); const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 });
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const loadBills = useCallback(() => {
return request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, []);
useEffect(() => { useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; } if (!isLoggedIn()) { navigate('/login'); return; }
void loadBills(); request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate, loadBills]); }, [navigate]);
const summary = useMemo(() => { const summary = useMemo(() => {
const pending = bills.filter((b) => b.status === 'AWAITING_CONFIRM'); const pending = bills.filter((b) => b.status === 'DRAFT');
const settled = bills.filter((b) => b.status === 'PAID'); const settled = bills.filter((b) => b.status === 'PAID');
const currentMonth = bills.filter((b) => { const currentMonth = bills.filter((b) => {
const d = new Date(b.periodStart); const d = new Date(b.periodStart);
@@ -77,9 +72,9 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false; if (!matchMonth) return false;
if (statusFilter === 'all') return true; if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'AWAITING_CONFIRM'; if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID'; if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'UNPAID'; if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED'; if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true; return true;
}); });
@@ -100,14 +95,14 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [ const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' }, { key: 'all', label: '全部' },
{ key: 'pending', label: '待确认' }, { key: 'pending', label: '待结算' },
{ key: 'reviewing', label: '未打款' }, { key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' }, { key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已打款' }, { key: 'settled', label: '已结算' },
]; ];
return ( return (
<PullToRefresh onRefresh={loadBills} className="page-no-tab partner-settlement-page"> <div className="page-no-tab partner-settlement-page">
<PageHeader title="财务对账中心" onBack={() => navigate('/')} /> <PageHeader title="财务对账中心" onBack={() => navigate('/')} />
<main className="partner-settlement-body"> <main className="partner-settlement-body">
@@ -202,7 +197,7 @@ export default function SettlementPage() {
{bill.rejectReason} {bill.rejectReason}
</p> </p>
) : null} ) : null}
<p className={`partner-settlement-item-amount${bill.status === 'AWAITING_CONFIRM' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}> <p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))} ¥{fmtMoney(Number(bill.totalAmount || 0))}
</p> </p>
</div> </div>
@@ -216,6 +211,6 @@ export default function SettlementPage() {
)} )}
</section> </section>
</main> </main>
</PullToRefresh> </div>
); );
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { import {
AccountStatus, AccountStatus,
PARTNER_STAFF_ROLE_LABELS, PARTNER_STAFF_ROLE_LABELS,
@@ -87,7 +86,7 @@ export default function StaffListPage() {
} }
return ( return (
<PullToRefresh onRefresh={loadStaff} className="page-no-tab partner-staff-page"> <div className="page-no-tab partner-staff-page">
<PageHeader title="子账号管理" onBack={() => navigate('/center')} /> <PageHeader title="子账号管理" onBack={() => navigate('/center')} />
<div style={{ padding: '0 20px 16px' }}> <div style={{ padding: '0 20px 16px' }}>
@@ -152,6 +151,6 @@ export default function StaffListPage() {
</Link> </Link>
</div> </div>
</PullToRefresh> </div>
); );
} }
+216 -243
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker'; import ChinaRegionPicker from '../components/ChinaRegionPicker';
import OssUploadField from '../components/OssUploadField'; import OssUploadField from '../components/OssUploadField';
@@ -15,6 +17,8 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { import {
@@ -43,15 +47,14 @@ import {
const STEPS = ['基本信息', '照片上传', '结算资质'] as const; const STEPS = ['基本信息', '照片上传', '结算资质'] as const;
type StoreCategoryNode = {
id: string;
name: string;
children?: StoreCategoryNode[];
};
type FieldErrors = { type FieldErrors = {
phone?: string; phone?: string;
storeSmsCode?: string; storeSmsCode?: string;
}; };
@@ -76,10 +79,16 @@ export default function StoreCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { account } = usePartnerSession(); const { account, refresh } = usePartnerSession();
const accountId = account?.id; const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
const handleWechatReadyChange = useCallback(() => {
void refresh();
}, [refresh]);
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const saved = loadStoreDraft(accountId); const saved = loadStoreDraft(accountId);
@@ -92,6 +101,8 @@ export default function StoreCreatePage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [checkingPhone, setCheckingPhone] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]); const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState(''); const [citiesError, setCitiesError] = useState('');
@@ -100,8 +111,6 @@ export default function StoreCreatePage() {
const [smsHint, setSmsHint] = useState(''); const [smsHint, setSmsHint] = useState('');
const [categoryTree, setCategoryTree] = useState<StoreCategoryNode[]>([]);
const draftSaveDisabledRef = useRef(false); const draftSaveDisabledRef = useRef(false);
function reportFormError(message: string) { function reportFormError(message: string) {
@@ -133,6 +142,17 @@ export default function StoreCreatePage() {
useEffect(() => {
if (step !== 2 || !isWechatEnv()) return;
void refresh();
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [step, refresh]);
useEffect(() => { useEffect(() => {
void fetchPartnerCities() void fetchPartnerCities()
@@ -152,26 +172,6 @@ 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(() => { useEffect(() => {
@@ -373,10 +373,54 @@ export default function StoreCreatePage() {
const msg = validateStoreStep1(form); const msg = validateStoreStep1(form);
if (msg) { if (msg) {
reportFormError(msg); if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
} else {
reportFormError(msg);
}
return; 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) { if (step === 2) {
@@ -401,14 +445,6 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form); const msg = validateStoreStep3(form);
if (msg) { if (msg) {
if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
return;
}
reportFormError(msg); reportFormError(msg);
return; return;
} }
@@ -416,6 +452,15 @@ export default function StoreCreatePage() {
const step1Msg = validateStoreStep1(form); const step1Msg = validateStoreStep1(form);
if (step1Msg) { if (step1Msg) {
if (isPhoneValidationMessage(step1Msg)) {
if (step1Msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: step1Msg });
} else {
setFieldErrors({ phone: step1Msg });
}
goStep(1);
return;
}
reportFormError(step1Msg); reportFormError(step1Msg);
goStep(1); goStep(1);
return; return;
@@ -455,8 +500,6 @@ export default function StoreCreatePage() {
}); });
setSubmitting(false);
return; return;
} }
@@ -468,7 +511,6 @@ export default function StoreCreatePage() {
); );
if (!ok) { if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' }); setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return; return;
} }
confirmBindExisting = true; confirmBindExisting = true;
@@ -476,7 +518,6 @@ export default function StoreCreatePage() {
} catch (e) { } catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败'); reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return; return;
} }
@@ -508,12 +549,6 @@ export default function StoreCreatePage() {
address: form.address.trim(), address: form.address.trim(),
openTime: form.openTime.trim(),
closeTime: form.closeTime.trim(),
categoryId: form.categoryId.trim(),
intro: form.intro.trim() || undefined, intro: form.intro.trim() || undefined,
coverUrl: form.coverUrl.trim() || undefined, coverUrl: form.coverUrl.trim() || undefined,
@@ -551,7 +586,7 @@ export default function StoreCreatePage() {
} }
if (/验证码/.test(message)) { if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message }); setFieldErrors({ storeSmsCode: message });
goStep(3); goStep(1);
return; return;
} }
setSubmitError(message); setSubmitError(message);
@@ -568,13 +603,17 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 50 : 100; const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
const nextDisabled = submitting; const nextDisabled = submitting || checkingPhone;
return ( return (
<div className="partner-page-sticky partner-home--flush-top"> <div className="partner-page-sticky">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
<nav className="partner-stepper"> <nav className="partner-stepper">
@@ -674,58 +713,100 @@ export default function StoreCreatePage() {
<div className="partner-field"> <div className="partner-field">
<label> <span className="text-primary">*</span></label> <label> <span className="text-primary">*</span></label>
<div className="partner-input-row" style={{ gap: 8 }}> <div className="partner-field-input">
<select <span className="material-symbols-outlined">call</span>
className="partner-field-input partner-field-input--block" <input
value={form.categoryParentId} type="tel"
onChange={(e) => patchForm({ categoryParentId: e.target.value, categoryId: '' })} placeholder="请输入11位手机号"
aria-label="一级店铺类型" value={form.phone}
> onChange={(e) => patchForm({ phone: e.target.value })}
<option value=""></option> />
{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> </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()}
>
{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> </div>
<div className="partner-field"> <div className="partner-field">
@@ -736,52 +817,6 @@ export default function StoreCreatePage() {
</div> </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:0022:00
</p>
</div>
<div className="partner-field"> <div className="partner-field">
<label></label> <label></label>
@@ -860,9 +895,13 @@ export default function StoreCreatePage() {
value={form.coverUrl} value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })} onChange={(coverUrl) => patchForm({ coverUrl })}
label="从系统相册选择" label="点击或拖拽上传"
/> />
@@ -892,6 +931,10 @@ export default function StoreCreatePage() {
value={url} value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)} onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/> />
@@ -908,7 +951,7 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3> <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 <OssUploadField
@@ -920,6 +963,10 @@ export default function StoreCreatePage() {
value={form.contractUrl} value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })} onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本" label="上传合同副本"
@@ -960,54 +1007,6 @@ export default function StoreCreatePage() {
</div> </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"> <section className="partner-form-card">
<div className="partner-field"> <div className="partner-field">
@@ -1040,76 +1039,54 @@ export default function StoreCreatePage() {
<p className="label-md text-muted" style={{ marginTop: 8 }}> <p className="label-md text-muted" style={{ marginTop: 8 }}>
</p> </p>
</div> </div>
</section>
<section className="partner-form-card">
<div className="partner-field"> <div className="partner-field">
<label> <span className="text-primary">*</span></label> <label> *</label>
<div className="partner-input-row"> <input className="partner-field-input partner-field-input--block" placeholder="请输入银行卡实名姓名" value={form.bankAccountName} onChange={(e) => patchForm({ bankAccountName: e.target.value })} />
<div className="partner-input-wrap" style={{ flex: 1 }}> </div>
<span className="material-symbols-outlined partner-input-icon">shield</span> <div className="partner-field">
<input <label> *</label>
className="partner-input" <input className="partner-field-input partner-field-input--block" placeholder="请输入16-19位银行卡号" value={form.bankAccountNo} onChange={(e) => patchForm({ bankAccountNo: e.target.value })} />
type="text" </div>
inputMode="numeric" <div className="partner-field">
maxLength={6} <label> *</label>
placeholder="请输入短信验证码" <input className="partner-field-input partner-field-input--block" placeholder="例如:中国工商银行洛阳分行" value={form.bankBranch} onChange={(e) => patchForm({ bankBranch: e.target.value })} />
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> </div>
</section> </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>
</> </>
)} )}
@@ -1118,11 +1095,7 @@ export default function StoreCreatePage() {
<footer className="partner-sticky-footer"> <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> <button type="button" className="partner-btn-outline" onClick={() => goStep(step - 1)} disabled={nextDisabled}></button>
@@ -1132,7 +1105,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}> <button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
<span></span> <span>{checkingPhone ? '校验中…' : '下一步'}</span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span> <span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
+16 -47
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import AppImage from '@dukang/shared-ui/AppImage'; import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api'; import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast'; import { toastSuccess } from '../lib/toast';
@@ -46,7 +47,7 @@ export default function StoreDetailPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false); const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState(''); const [actionError, setActionError] = useState('');
const [closeConfirmOpen, setCloseConfirmOpen] = useState(false); const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) { function applyStore(data: Record<string, unknown>) {
setStore(data); setStore(data);
@@ -92,8 +93,8 @@ export default function StoreDetailPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
setCloseConfirmOpen(true); const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
return; if (!ok) return;
} }
setStatusSaving(true); setStatusSaving(true);
setActionError(''); setActionError('');
@@ -104,27 +105,7 @@ export default function StoreDetailPage() {
}); });
setStatus(next); setStatus(next);
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev)); setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
toastSuccess(next === 'OPEN' ? '开店成功' : '状态已更新'); toastSuccess('状态已更新');
} 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) { } catch (e) {
setActionError(e instanceof Error ? e.message : '状态更新失败'); setActionError(e instanceof Error ? e.message : '状态更新失败');
} finally { } finally {
@@ -198,7 +179,8 @@ export default function StoreDetailPage() {
if (loadError) { if (loadError) {
return ( return (
<div className="partner-detail-page partner-home--flush-top"> <div className="partner-detail-page">
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<div className="empty">{loadError}</div> <div className="empty">{loadError}</div>
</div> </div>
); );
@@ -220,8 +202,10 @@ export default function StoreDetailPage() {
const canOpen = canPartnerOpenStore(auditStatus); const canOpen = canPartnerOpenStore(auditStatus);
return ( return (
<div className="partner-detail-page partner-home--flush-top"> <div className="partner-detail-page">
<main style={{ padding: '12px 20px 16px' }}> <PageHeader title="门店详情" onBack={() => navigate('/stores')} />
<main style={{ padding: '16px 20px' }}>
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>} {actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
<section className="partner-form-card" style={{ margin: '0 0 16px' }}> <section className="partner-form-card" style={{ margin: '0 0 16px' }}>
@@ -300,8 +284,10 @@ export default function StoreDetailPage() {
bizType="STORE_TITLE" bizType="STORE_TITLE"
mediaType="IMAGE" mediaType="IMAGE"
value={coverUrl} value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl} onChange={setCoverUrl}
label="从系统相册选择" label="点击更换门头照"
/> />
</div> </div>
)} )}
@@ -350,6 +336,8 @@ export default function StoreDetailPage() {
bizType="STORE_ENV" bizType="STORE_ENV"
mediaType="IMAGE" mediaType="IMAGE"
value={url} value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))} onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/> />
))} ))}
@@ -402,25 +390,6 @@ export default function StoreDetailPage() {
</button> </button>
)} )}
</footer> </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> </div>
); );
} }
+4 -45
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { isLoggedIn, request } from '../lib/api'; import { isLoggedIn, request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext'; import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess'; import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import { import {
@@ -37,7 +35,6 @@ export default function StoreListPage() {
const [filter, setFilter] = useState<StatusFilter>(initialFilter); const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null); const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [closeTarget, setCloseTarget] = useState<string | null>(null);
const loadStores = useCallback(() => { const loadStores = useCallback(() => {
return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores); return request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
@@ -69,8 +66,8 @@ export default function StoreListPage() {
return; return;
} }
if (next === 'CLOSED') { if (next === 'CLOSED') {
setCloseTarget(storeId); const ok = window.confirm('关闭后不可恢复营业,确认关闭该门店?');
return; if (!ok) return;
} }
setUpdatingId(storeId); setUpdatingId(storeId);
setError(''); setError('');
@@ -80,26 +77,6 @@ export default function StoreListPage() {
body: JSON.stringify({ status: next }), body: JSON.stringify({ status: next }),
}); });
await loadStores(); 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 { } catch {
/* request 已 toast */ /* request 已 toast */
} finally { } finally {
@@ -108,7 +85,7 @@ export default function StoreListPage() {
} }
return ( return (
<PullToRefresh onRefresh={loadStores} className="page partner-store-page partner-home--flush-top"> <div className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>} {error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter"> <div className="partner-sticky-filter">
@@ -211,24 +188,6 @@ export default function StoreListPage() {
</div> </div>
); );
})} })}
{closeTarget && ( </div>
<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>
); );
} }
+5 -10
View File
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { PartnerWeeklyReportResponse } from '@dukang/shared-types'; import type { PartnerWeeklyReportResponse } from '@dukang/shared-types';
import PageHeader from '@dukang/shared-ui/PageHeader'; import PageHeader from '@dukang/shared-ui/PageHeader';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { fetchPartnerWeeklyReport } from '../lib/weeklyReport'; import { fetchPartnerWeeklyReport } from '../lib/weeklyReport';
function fmtMoney(n: number) { function fmtMoney(n: number) {
@@ -21,10 +20,10 @@ export default function WeeklyReportPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadWeeklyReport = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
setError(''); setError('');
return fetchPartnerWeeklyReport(selectedStart) fetchPartnerWeeklyReport(selectedStart)
.then(setData) .then(setData)
.catch((e) => { .catch((e) => {
setData(null); setData(null);
@@ -33,10 +32,6 @@ export default function WeeklyReportPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [selectedStart]); }, [selectedStart]);
useEffect(() => {
void loadWeeklyReport();
}, [loadWeeklyReport]);
const maxDailyGmv = useMemo(() => { const maxDailyGmv = useMemo(() => {
if (!data?.dailyGmv.length) return 1; if (!data?.dailyGmv.length) return 1;
return Math.max(1, ...data.dailyGmv.map((item) => item.amount)); return Math.max(1, ...data.dailyGmv.map((item) => item.amount));
@@ -47,7 +42,7 @@ export default function WeeklyReportPage() {
const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0; const growthPositive = (summary?.gmvGrowthPercent ?? 0) >= 0;
return ( return (
<PullToRefresh onRefresh={loadWeeklyReport} className="page-no-tab"> <div className="page-no-tab">
<PageHeader title="数据周报" onBack={() => navigate('/')} /> <PageHeader title="数据周报" onBack={() => navigate('/')} />
<main className="partner-weekly-page"> <main className="partner-weekly-page">
@@ -216,6 +211,6 @@ export default function WeeklyReportPage() {
</> </>
)} )}
</main> </main>
</PullToRefresh> </div>
); );
} }
+1 -7
View File
@@ -3329,16 +3329,10 @@ body {
} }
.partner-home--flush-top.partner-store-page, .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; padding-top: 12px;
} }
.partner-home--flush-top.partner-page-sticky .partner-stepper {
padding-top: 4px;
}
.partner-home--flush-top .partner-sticky-filter { .partner-home--flush-top .partner-sticky-filter {
padding-top: 12px; padding-top: 12px;
} }
+1 -2
View File
@@ -3,8 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。 base: '/partner/',
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
+2 -1
View File
@@ -5,6 +5,7 @@ import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage'; import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage'; import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage'; import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage'; import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import RecordsPage from './pages/RecordsPage'; import RecordsPage from './pages/RecordsPage';
@@ -21,7 +22,7 @@ export default function App() {
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} /> <Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/select-store" element={<SelectStorePage />} /> <Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} /> <Route path="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} /> <Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} /> <Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} /> <Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}> <Route element={<TabLayout />}>
+5
View File
@@ -1,4 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']); const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
@@ -25,6 +26,10 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
} }
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) { 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 }} />; return <Navigate to="/login" replace state={{ from: location }} />;
} }
@@ -19,7 +19,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
const [photoResourceId, setPhotoResourceId] = useState(''); const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null); const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
async function handleFile(file: File) { async function handleFile(file: File) {
setUploading(true); setUploading(true);
@@ -50,11 +49,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
} }
} catch (e) { } catch (e) {
const text = e instanceof Error ? e.message : '选图失败'; const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) { if (!/cancel/i.test(text)) setMsg(text);
setMsg(`${text},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
} finally { } finally {
setUploading(false); setUploading(false);
} }
@@ -125,13 +120,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
ref={inputRef} ref={inputRef}
type="file" type="file"
accept="image/*" accept="image/*"
capture="environment"
hidden hidden
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) void handleFile(file);
setShowAlbumFallback(false);
void handleFile(file);
}
}} }}
/> />
{previewUrl && ( {previewUrl && (
@@ -141,16 +134,6 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}> <button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'} {uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
</button> </button>
{showAlbumFallback && isWechatEnv() && (
<button
type="button"
className="shop-btn-outline"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
</button>
)}
<button <button
type="button" type="button"
className="shop-redeem-confirm-btn" className="shop-redeem-confirm-btn"
+4 -9
View File
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt'; const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
export const SHOP_WX_BOUND = 'shopWxBound'; export const SHOP_WX_BOUND = 'shopWxBound';
/** 手机号或微信验证通过后的免登录时长 */ /** 微信验证通过后的免登录时长 */
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [ const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
} }
export function touchShopSession() { export function touchShopSession() {
if (!localStorage.getItem(REFRESH_TOKEN)) return; if (!hasShopWxSession()) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS)); localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
@@ -119,16 +119,11 @@ 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 */ /** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: ShopSessionPayload) { export function saveWechatSession(data: ShopSessionPayload) {
saveRememberedSession(data); saveAuth(data);
localStorage.setItem(SHOP_WX_BOUND, '1'); localStorage.setItem(SHOP_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
} }
export function clearAuth(options?: { keepProfile?: boolean }) { export function clearAuth(options?: { keepProfile?: boolean }) {
+139 -7
View File
@@ -1,16 +1,47 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api'; 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) { function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); 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() { export default function HomePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null); const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true); 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(() => { const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard') return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
@@ -27,6 +58,7 @@ export default function HomePage() {
useEffect(() => { useEffect(() => {
function onResume() { function onResume() {
setScanning(false);
void loadDashboard(); void loadDashboard();
} }
function onVisibility() { function onVisibility() {
@@ -42,13 +74,95 @@ export default function HomePage() {
}; };
}, [loadDashboard]); }, [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 store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || []; const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00'); const openTime = String(store?.openTime || '10:00');
const closeTime = String(store?.closeTime || '22:00'); const closeTime = String(store?.closeTime || '22:00');
return ( return (
<PullToRefresh onRefresh={loadDashboard} className="shop-home-page"> <div className="shop-home-page">
<header className="shop-home-header"> <header className="shop-home-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -75,13 +189,20 @@ export default function HomePage() {
</section> </section>
<section className="shop-home-scan"> <section className="shop-home-scan">
<Link <button
to="/redeem/phone" type="button"
className="shop-home-scan-btn" 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> <span className="material-symbols-outlined">smartphone</span>
</Link> </Link>
<p className="shop-home-scan-label"></p>
</section> </section>
<section className="shop-home-status"> <section className="shop-home-status">
@@ -128,6 +249,17 @@ export default function HomePage() {
</section> </section>
</div> </div>
</PullToRefresh> <WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
); );
} }
+185 -13
View File
@@ -1,14 +1,32 @@
import { useRef, useState, type RefObject } from 'react'; import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage'; 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 { useStoreSession } from '../contexts/StoreSessionContext';
import { import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
getLastPhone,
request,
saveRememberedSession,
type ShopSessionPayload,
} from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage'; 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;
}
function ShopAgreementCheckbox({ function ShopAgreementCheckbox({
agreed, agreed,
@@ -17,7 +35,7 @@ function ShopAgreementCheckbox({
}: { }: {
agreed: boolean; agreed: boolean;
onChange: (next: boolean) => void; onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement>; labelRef?: RefObject<HTMLLabelElement | null>;
}) { }) {
return ( return (
<label className="shop-login-agreement" ref={labelRef}> <label className="shop-login-agreement" ref={labelRef}>
@@ -43,14 +61,44 @@ function ShopAgreementCheckbox({
export default function LoginPage() { export default function LoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { applySession } = useStoreSession(); const { applySession } = useStoreSession();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone()); const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0); const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null); 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() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先阅读并同意用户协议'); setMsg('请先阅读并同意用户协议');
@@ -93,8 +141,13 @@ export default function LoginPage() {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone, code }), body: JSON.stringify({ phone, code }),
}); });
saveRememberedSession(data); saveAuth(data);
applySession(data); applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate); routeAfterShopLogin(data, navigate);
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败'); setMsg(e instanceof Error ? e.message : '登录失败');
@@ -103,6 +156,103 @@ 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 ( return (
<div className="shop-login-page"> <div className="shop-login-page">
<header className="shop-login-hero"> <header className="shop-login-hero">
@@ -172,9 +322,26 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span> <span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>} {!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button> </button>
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
7 {wxAuthorize && (
</p> <>
<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>
</>
)}
</div> </div>
</main> </main>
@@ -183,6 +350,11 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}> <span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security security
</span> </span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link>
</p>
)}
</footer> </footer>
</div> </div>
); );
+15 -16
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types'; import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
@@ -25,21 +24,21 @@ export default function MinePage() {
const [binding, setBinding] = useState(false); const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState(''); const [bindMsg, setBindMsg] = useState('');
const loadMine = useCallback(() => { useEffect(() => {
return Promise.all([ request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
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(() => { useEffect(() => {
void loadMine(); fetchClientConfig()
}, [loadMine]); .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null));
}, []);
useEffect(() => { useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return; if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
@@ -93,7 +92,7 @@ export default function MinePage() {
} }
return ( return (
<PullToRefresh onRefresh={loadMine} className="shop-mine-page"> <div className="shop-mine-page">
<header className="shop-mine-header"> <header className="shop-mine-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -175,6 +174,6 @@ export default function MinePage() {
退 退
</button> </button>
</div> </div>
</PullToRefresh> </div>
); );
} }
+211 -86
View File
@@ -1,22 +1,33 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api'; import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) { function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() { export default function PhoneRedeemPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState(''); const [phone, setPhone] = useState('');
const [amount, setAmount] = useState(''); const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = useState(''); const [confirmCode, setConfirmCode] = useState('');
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null); const [amount, setAmount] = useState('');
const [balance, setBalance] = useState<BalanceResult | null>(null);
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false); const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0); const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => { useEffect(() => {
@@ -28,17 +39,62 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店')); .catch(() => setStoreName('当前门店'));
}, []); }, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => { useEffect(() => {
if (confirmCooldown <= 0) return; if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000); const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [confirmCooldown]); }, [confirmCooldown]);
async function sendConfirmSms() { async function sendLookupSms() {
if (!/^1\d{10}$/.test(phone.trim())) { if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号'); setMsg('请输入正确的手机号');
return; 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) { if (storeClosed) {
setMsg('门店未营业,无法核销'); setMsg('门店未营业,无法核销');
return; return;
@@ -48,30 +104,28 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额'); setMsg('请输入有效核销金额');
return; return;
} }
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true); setLoading(true);
setMsg(''); setMsg('');
try { try {
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', { await request('SHOP_H5', '/shop/redeem/phone/prepare', {
method: 'POST', method: 'POST',
body: JSON.stringify({ phone: phone.trim(), amount: value }), body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
}); });
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60); setConfirmCooldown(60);
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`); setStep('confirm');
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
} catch (e) { } catch (e) {
setPrepared(null); setMsg(e instanceof Error ? e.message : '发起核销失败');
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
async function confirmRedeem() { async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) { if (!confirmCode.trim()) {
setMsg('请输入确认验证码'); setMsg('请输入确认验证码');
return; return;
@@ -82,13 +136,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', { const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
sessionId: prepared.sessionId, sessionId: balance?.sessionId,
code: confirmCode.trim(), code: confirmCode.trim(),
}), }),
}); });
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result)); sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', { navigate('/redeem/success', {
state: { result, storeName, user: prepared.user }, state: { result, storeName, user: balance?.user },
}); });
} catch (e) { } catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败'); setMsg(e instanceof Error ? e.message : '核销失败');
@@ -97,9 +151,7 @@ export default function PhoneRedeemPage() {
} }
} }
const amountValue = Number(amount); const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
const canSendCode =
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
return ( return (
<div className="shop-redeem-page"> <div className="shop-redeem-page">
@@ -127,77 +179,150 @@ export default function PhoneRedeemPage() {
</div> </div>
<div className="shop-redeem-body"> <div className="shop-redeem-body">
<div className="shop-phone-field"> {step === 'lookup' && (
<label className="shop-phone-label"></label> <>
<input <div className="shop-phone-field">
className="shop-phone-input" <label className="shop-phone-label"></label>
type="tel" <input
maxLength={11} className="shop-phone-input"
placeholder="请输入用户手机号" type="tel"
value={phone} maxLength={11}
disabled={loading} placeholder="请输入用户手机号"
onChange={(e) => { value={phone}
setPhone(e.target.value.replace(/\D/g, '')); onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
setPrepared(null); />
setConfirmCode(''); </div>
setConfirmCooldown(0); <div className="shop-phone-field">
}} <label className="shop-phone-label"></label>
/> <div className="shop-phone-code-row">
</div> <input
className="shop-phone-input"
<div className="shop-phone-field"> type="text"
<label className="shop-phone-label"></label> maxLength={6}
<input placeholder="用户收到的验证码"
className="shop-phone-input" value={lookupCode}
type="number" onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
min={0.01} />
step={0.01} <button
placeholder="请输入待核销金额" type="button"
value={amount} className="shop-phone-code-btn"
disabled={loading} disabled={loading || lookupCooldown > 0 || !phone.trim()}
onChange={(e) => { onClick={() => void sendLookupSms()}
setAmount(e.target.value); >
setPrepared(null); {lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
setConfirmCode(''); </button>
setConfirmCooldown(0); </div>
}} </div>
/>
</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 <button
type="button" type="button"
className="shop-phone-code-btn" className="shop-redeem-confirm-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode} disabled={loading || storeClosed}
onClick={() => void sendConfirmSms()} onClick={() => void queryBalance()}
> >
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button> </button>
</div> </>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}> )}
</p>
</div>
<button {step === 'amount' && balance && (
type="button" <>
className="shop-redeem-confirm-btn" <div className="shop-redeem-user">
disabled={loading || storeClosed || !prepared || !confirmCode.trim()} <div className="shop-redeem-user-left">
onClick={() => void confirmRedeem()} <span className="material-symbols-outlined">person</span>
> <span></span>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`} </div>
</button> <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>
</>
)}
{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>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('amount');
setConfirmCode('');
}}
>
</button>
</>
)}
{msg && <p className="shop-redeem-error">{msg}</p>} {msg && <p className="shop-redeem-error">{msg}</p>}
</div> </div>
+10 -17
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { request } from '../lib/api'; import { request } from '../lib/api';
type RangeKey = 'today' | '7d' | '30d'; type RangeKey = 'today' | '7d' | '30d';
@@ -29,20 +28,14 @@ export default function RecordsPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all'); const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState(''); const [storeName, setStoreName] = useState('');
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(() => { useEffect(() => {
void loadRecords(); request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
}, [loadRecords]); setRecords(d.list || []);
});
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => setStoreName(String(s.name || '')))
.catch(() => {});
}, []);
const filtered = useMemo(() => { const filtered = useMemo(() => {
return records.filter((r) => { return records.filter((r) => {
@@ -62,7 +55,7 @@ export default function RecordsPage() {
}, [filtered]); }, [filtered]);
return ( return (
<PullToRefresh onRefresh={loadRecords} className="shop-records-page"> <div className="shop-records-page">
<header className="shop-records-header"> <header className="shop-records-header">
<h1 className="app-page-title"></h1> <h1 className="app-page-title"></h1>
</header> </header>
@@ -183,6 +176,6 @@ export default function RecordsPage() {
</div> </div>
)} )}
</div> </div>
</PullToRefresh> </div>
); );
} }
+7 -12
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { import {
needsStoreSelection, needsStoreSelection,
@@ -20,19 +19,15 @@ export default function SelectStorePage() {
const currentStoreId = store?.storeId || ''; const currentStoreId = store?.storeId || '';
const canGoBack = Boolean(currentStoreId); 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(() => { useEffect(() => {
if (!authenticated) { if (!authenticated) {
navigate('/login', { replace: true }); navigate('/login', { replace: true });
return; return;
} }
void loadStores(); void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
}, [authenticated, navigate, loadStores]); .then((list) => setStores(list))
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, [authenticated, navigate]);
async function onSelect(storeId: string) { async function onSelect(storeId: string) {
if (loadingId) return; if (loadingId) return;
@@ -68,7 +63,7 @@ export default function SelectStorePage() {
} }
return ( return (
<PullToRefresh onRefresh={loadStores} className="shop-select-store-page"> <div className="shop-select-store-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
{canGoBack ? ( {canGoBack ? (
<button <button
@@ -156,7 +151,7 @@ export default function SelectStorePage() {
) : null} ) : null}
</section> </section>
</div> </div>
</PullToRefresh> </div>
); );
} }
+2 -3
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types'; import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { useStoreSession } from '../contexts/StoreSessionContext'; import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api'; import { getStoreProfile, request } from '../lib/api';
@@ -117,7 +116,7 @@ export default function StaffPage() {
form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length; form.storeIds.length === 0 ? ownedStores.length : form.storeIds.length;
return ( return (
<PullToRefresh onRefresh={reload} className="shop-staff-page"> <div className="shop-staff-page">
<header className="shop-subpage-header"> <header className="shop-subpage-header">
<button <button
type="button" type="button"
@@ -323,6 +322,6 @@ export default function StaffPage() {
</section> </section>
)} )}
</div> </div>
</PullToRefresh> </div>
); );
} }
+1 -2
View File
@@ -3,8 +3,7 @@ import react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
export default defineConfig({ export default defineConfig({
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。 base: '/shop/',
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()], plugins: [react()],
resolve: { resolve: {
alias: { alias: {
-2
View File
@@ -1,2 +0,0 @@
# 企业微信客服链接(覆盖 shared-types 默认值)
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
-8
View File
@@ -20,10 +20,6 @@ import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage'; import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage'; import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage'; 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 { UserSessionProvider } from './contexts/UserSessionContext';
import { capturePromoFromUrl } from './lib/promo'; import { capturePromoFromUrl } from './lib/promo';
import WechatShareBootstrap from './components/WechatShareBootstrap'; import WechatShareBootstrap from './components/WechatShareBootstrap';
@@ -55,10 +51,6 @@ export default function App() {
<Route path="/order/confirm" element={<OrderConfirmPage />} /> <Route path="/order/confirm" element={<OrderConfirmPage />} />
<Route path="/pay" element={<PayPage />} /> <Route path="/pay" element={<PayPage />} />
<Route path="/customer-service" element={<CustomerServicePage />} /> <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" element={<AddressListPage />} />
<Route path="/addresses/new" element={<AddressEditPage />} /> <Route path="/addresses/new" element={<AddressEditPage />} />
<Route path="/addresses/:id/edit" 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 { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import { track } from '../lib/analytics'; import { track } from '../lib/analytics';
import { openWecomCustomerService } from '../lib/customer-service';
type ContactCustomerSheetProps = { type ContactCustomerSheetProps = {
orderId?: string; orderId?: string;
@@ -8,7 +8,8 @@ type ContactCustomerSheetProps = {
onClose: () => void; onClose: () => void;
}; };
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) { export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function openPhone() { function openPhone() {
@@ -17,11 +18,13 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
onClose(); onClose();
} }
function openOnline() { function openChat() {
track('cs_contact', { type: 'wecom_kf', orderId }); track('cs_contact', { type: 'chat', orderId });
if (openWecomCustomerService()) { const qs = new URLSearchParams();
onClose(); if (orderId) qs.set('orderId', orderId);
} if (orderNo) qs.set('orderNo', orderNo);
onClose();
navigate(`/customer-service?${qs.toString()}`);
} }
return ( return (
@@ -46,7 +49,7 @@ export default function ContactCustomerSheet({ orderId, onClose }: ContactCustom
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span> <span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button> </button>
<button type="button" className="contact-customer-option" onClick={openOnline}> <button type="button" className="contact-customer-option" onClick={openChat}>
<div className="contact-customer-option-icon"> <div className="contact-customer-option-icon">
<span className="material-symbols-outlined">chat</span> <span className="material-symbols-outlined">chat</span>
</div> </div>
-23
View File
@@ -1,23 +0,0 @@
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 };
-37
View File
@@ -1,37 +0,0 @@
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;
}
@@ -1,71 +0,0 @@
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>
);
}
-232
View File
@@ -1,232 +0,0 @@
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>
);
}
+171 -20
View File
@@ -1,35 +1,186 @@
import { useNavigate } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader'; import SubPageHeader from '../components/SubPageHeader';
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service'; import { request } from '../lib/api';
import { track } from '../lib/analytics';
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 '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
}
export default function CustomerServicePage() { export default function CustomerServicePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); 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);
function openOnline() { useEffect(() => {
track('cs_contact', { type: 'wecom_kf' }); const welcome: ChatMessage[] = [
openWecomCustomerService(); { 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 }]);
} }
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 ( return (
<div className="customer-service-page customer-service-page--oa"> <div className="customer-service-page">
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} /> <SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
<div className="customer-service-oa-body"> {orderNo && (
<p className="customer-service-wecom-title"></p> <div className="customer-service-order-card">
<p className="customer-service-wecom-hint">线</p> <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>
)}
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}> <div className="customer-service-chat" ref={listRef}>
<span className="material-symbols-outlined">headset_mic</span> {messages.map((m) => {
线 if (m.role === 'system') {
</button> return (
<div key={m.id} className="customer-service-time">
<a className="customer-service-phone-link" href={`tel:${tel}`}> <span>{m.text}</span>
<span className="material-symbols-outlined">call</span> </div>
{CUSTOMER_SERVICE_PHONE} );
</a> }
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>
<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>
</footer>
</div> </div>
); );
} }
-158
View File
@@ -1,158 +0,0 @@
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>
);
}
@@ -1,68 +0,0 @@
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>
);
}
-2
View File
@@ -17,8 +17,6 @@ const ORDER_SHORTCUTS = [
const SERVICES = [ const SERVICES = [
{ icon: 'location_on', label: '地址管理', to: '/addresses' }, { 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: 'storefront', label: '可用门店', to: '/stores' },
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const }, { icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
{ icon: 'info', label: '关于我们', action: 'about' as const }, { icon: 'info', label: '关于我们', action: 'about' as const },
+18 -16
View File
@@ -178,7 +178,6 @@ export default function OrderDetailPage() {
order && order &&
!canPay && !canPay &&
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status); ['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 productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0); const freightTotal = Number(order?.freightAmount ?? 0);
@@ -208,6 +207,22 @@ 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>; if (!order) return <div className="empty">...</div>;
return ( return (
@@ -455,21 +470,8 @@ export default function OrderDetailPage() {
</button> </button>
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && ( {canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
<button <button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
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> </button>
)} )}
{canPay && ( {canPay && (
+86 -228
View File
@@ -5786,6 +5786,7 @@
color: #1a1c1b; color: #1a1c1b;
} }
/* Customer service chat page */
.customer-service-page { .customer-service-page {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
@@ -5793,285 +5794,142 @@
background: #faf9f7; background: #faf9f7;
} }
.customer-service-page--oa .customer-service-oa-body { .customer-service-order-card {
flex: 1;
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 12px;
text-align: center; margin: 12px 16px 0;
}
.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; padding: 12px 16px;
background: #fff; background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06); border-radius: 12px;
} box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
.after-sale-step {
font-size: 12px;
color: #999;
}
.after-sale-step.is-active,
.after-sale-step.is-done {
color: #a61d24; color: #a61d24;
}
.customer-service-order-label {
margin: 0;
font-size: 12px;
color: #5a413f;
}
.customer-service-order-no {
margin: 4px 0 0;
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1a1c1b;
} }
.after-sale-body { .customer-service-chat {
flex: 1; flex: 1;
padding: 16px 16px calc(24px + env(safe-area-inset-bottom)); overflow-y: auto;
} padding: 16px;
.after-sale-type-list,
.after-sale-order-list,
.after-sale-form,
.after-sale-success {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 0;
} }
.after-sale-type-item, .customer-service-time {
.after-sale-order-item {
display: flex; display: flex;
align-items: center; justify-content: 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, .customer-service-time span {
.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; font-size: 12px;
color: #5a413f; color: #999;
background: rgba(227, 226, 224, 0.3);
padding: 4px 12px;
border-radius: 999px;
} }
.after-sale-primary, .customer-service-bubble-row {
.after-sale-secondary {
width: 100%;
padding: 14px;
border-radius: 12px;
border: none;
font-size: 15px;
font-weight: 600;
}
.after-sale-primary {
background: #a61d24;
color: #fff;
}
.after-sale-secondary {
background: #e3e2e0;
color: #1a1c1b;
}
.after-sale-actions {
display: flex; 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; gap: 8px;
max-width: 85%;
} }
.after-sale-evidence-img, .customer-service-bubble-row.is-user {
.after-sale-evidence-add { align-self: flex-end;
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
} }
.after-sale-evidence-add { .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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: 1px dashed #ccc; color: #a61d24;
background: #fff; flex-shrink: 0;
font-size: 24px;
color: #999;
} }
.after-sale-summary { .customer-service-bubble {
margin: 0;
padding: 12px; padding: 12px;
border-radius: 10px; border-radius: 12px;
background: rgba(166, 29, 36, 0.06); font-size: 14px;
font-size: 13px; line-height: 1.5;
color: #a61d24; background: #fff;
color: #1a1c1b;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
} }
.after-sale-empty, .customer-service-bubble.is-user {
.after-sale-empty-box { background: #a61d24;
text-align: center; color: #fff;
color: #999; box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
padding: 24px 0;
} }
.after-sale-success { .customer-service-quick {
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;
}
.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; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 8px 16px 12px;
} }
.invoice-chip { .customer-service-quick-btn {
padding: 8px 12px; padding: 6px 12px;
border-radius: 999px; border-radius: 999px;
border: 1px solid rgba(166, 29, 36, 0.25); border: 1px solid rgba(166, 29, 36, 0.25);
background: #fff; background: #fff;
font-size: 12px;
color: #a61d24; color: #a61d24;
font-size: 12px;
} }
.invoice-chip.is-selected { .customer-service-inputbar {
display: flex;
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: #fff;
border-top: 1px solid #e3e2e0;
}
.customer-service-input {
flex: 1;
border: 1px solid #e3e2e0;
border-radius: 999px;
padding: 10px 16px;
font-size: 14px;
outline: none;
}
.customer-service-send {
border: none;
border-radius: 999px;
padding: 10px 18px;
background: #a61d24; background: #a61d24;
color: #fff; color: #fff;
border-color: #a61d24; font-size: 14px;
font-weight: 600;
} }
.invoice-chip:disabled { .customer-service-send:disabled {
opacity: 0.4; opacity: 0.5;
} }
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */ /* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
+2 -1
View File
@@ -52,7 +52,8 @@ scroll-view::-webkit-scrollbar,
.taro-scroll::-webkit-scrollbar, .taro-scroll::-webkit-scrollbar,
.taro-scroll-view::-webkit-scrollbar, .taro-scroll-view::-webkit-scrollbar,
.taro-scroll-view__scroll-x::-webkit-scrollbar, .taro-scroll-view__scroll-x::-webkit-scrollbar,
.taro-scroll-view__scroll-y::-webkit-scrollbar { .taro-scroll-view__scroll-y::-webkit-scrollbar,
*::-webkit-scrollbar {
display: none; display: none;
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
+4 -7
View File
@@ -9,11 +9,8 @@
pnpm --filter @dukang/mini-user dev pnpm --filter @dukang/mini-user dev
# → http://localhost:5177 # → http://localhost:5177
# 微信小程序(本地联调,development # 微信小程序
pnpm --filter @dukang/mini-user dev:weapp pnpm --filter @dukang/mini-user dev:weapp
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com
pnpm build:mini-user:weapp
# 用微信开发者工具打开 apps/mini-userminiprogramRoot = dist/ # 用微信开发者工具打开 apps/mini-userminiprogramRoot = dist/
``` ```
@@ -21,7 +18,7 @@ pnpm build:mini-user:weapp
| 环节 | 文件 | 说明 | | 环节 | 文件 | 说明 |
|------|------|------| |------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 | | **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000``NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` | | **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code` ### 微信登录 `invalid code`
@@ -48,10 +45,10 @@ pnpm --filter @dukang/mini-user dev:weapp
**连远程 API** **连远程 API**
```bash ```bash
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev $env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
``` ```
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。 并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
## 页面结构(18 页) ## 页面结构(18 页)
+2 -8
View File
@@ -2,16 +2,10 @@ import { createRequire } from 'node:module';
import path from 'node:path'; import path from 'node:path';
import { defineConfig } from '@tarojs/cli'; 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);本地默认本机,生产构建默认远程 */ /** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN = const API_ORIGIN =
process.env.VITE_API_TARGET ?? process.env.VITE_API_TARGET ??
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com'); (process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json')); const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
@@ -97,7 +91,7 @@ export default defineConfig(async () => ({
}, },
mini: { mini: {
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */ /** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
debugReact: isDevMode, debugReact: process.env.NODE_ENV !== 'production',
postcss: { postcss: {
pxtransform: { enable: true, config: {} }, pxtransform: { enable: true, config: {} },
cssModules: { enable: false }, cssModules: { enable: false },
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "node ../../scripts/dev-mini-user-h5.mjs", "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: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", "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 --mode production", "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 --mode production", "build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
"lint": "echo ok" "lint": "echo ok"
}, },
"dependencies": { "dependencies": {
+1 -2
View File
@@ -10,6 +10,5 @@
"postcss": false, "postcss": false,
"minified": false "minified": false
}, },
"compileType": "miniprogram", "compileType": "miniprogram"
"preloadBackgroundData": false
} }
+7 -1
View File
@@ -39,13 +39,19 @@ body::-webkit-scrollbar,
#app::-webkit-scrollbar, #app::-webkit-scrollbar,
.taro_page::-webkit-scrollbar, .taro_page::-webkit-scrollbar,
.taro_router::-webkit-scrollbar, .taro_router::-webkit-scrollbar,
.taro-tabbar__panel::-webkit-scrollbar { .taro-tabbar__panel::-webkit-scrollbar,
*::-webkit-scrollbar {
width: 0 !important; width: 0 !important;
height: 0 !important; height: 0 !important;
display: none !important; display: none !important;
background: transparent; background: transparent;
} }
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */ /* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
.taro-tabbar__tabbar, .taro-tabbar__tabbar,
.taro-tabbar__border { .taro-tabbar__border {
@@ -1,50 +0,0 @@
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>
);
}
@@ -1,63 +0,0 @@
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>
);
}
+11 -10
View File
@@ -7,24 +7,25 @@ type TabMainHeaderProps = {
extra?: ReactNode; extra?: ReactNode;
}; };
/** const isH5 = process.env.TARO_ENV === 'h5';
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。 /** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
*/
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) { export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics(); const metrics = useNavBarMetrics();
if (!extra) { // H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (isH5 && !extra) {
return null; return null;
} }
return ( return (
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}> <View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{process.env.TARO_ENV !== 'h5' ? ( {!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
<Text className="tab-main-header__title">{title}</Text> <View
) : null} className="tab-main-header__content"
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}> style={tabNavContentStyle(metrics)}
<View className="tab-main-header__extra">{extra}</View> >
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
</View> </View>
</View> </View>
); );
@@ -3,16 +3,22 @@ import { View, Text } from '@tarojs/components';
type WechatLoginButtonProps = { type WechatLoginButtonProps = {
loading?: boolean; loading?: boolean;
disabled?: boolean; disabled?: boolean;
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
label?: string;
onClick: () => void; 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({ export default function WechatLoginButton({
loading = false, loading = false,
disabled = false, disabled = false,
label = '授权登录',
onClick, onClick,
}: WechatLoginButtonProps) { }: WechatLoginButtonProps) {
const inactive = loading || disabled; const inactive = loading || disabled;
@@ -22,7 +28,10 @@ export default function WechatLoginButton({
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`} className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
onClick={inactive ? undefined : onClick} onClick={inactive ? undefined : onClick}
> >
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text> <WechatIcon />
<Text className="login-wechat-btn__text">
{loading ? '授权中...' : '微信一键授权'}
</Text>
</View> </View>
); );
} }
+18 -1
View File
@@ -1,6 +1,6 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types'; import { ClientApp } from '@dukang/shared-types';
import { forceReloadAfterAccountMerge } from './auth-nav'; import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
function resolveApiBase(): string { function resolveApiBase(): string {
const origin = const origin =
@@ -54,6 +54,10 @@ export function isLoggedIn(): boolean {
return !!getToken(); return !!getToken();
} }
export function redirectToLogin() {
goLogin();
}
export function logout() { export function logout() {
clearAuth(); clearAuth();
Taro.reLaunch({ url: '/pages/home/index' }); Taro.reLaunch({ url: '/pages/home/index' });
@@ -72,6 +76,17 @@ function parseBody(data: unknown): { code?: number; message?: string } {
return {}; 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 } */ /** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> { export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = { const header: Record<string, string> = {
@@ -100,6 +115,8 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
if (/账号已合并/.test(mergedMsg)) { if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」 // 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge(); forceReloadAfterAccountMerge();
} else if (!isOnLoginPage()) {
redirectToLogin();
} }
} }
throw new Error(body?.message || '登录已过期,请重新登录'); throw new Error(body?.message || '登录已过期,请重新登录');
+3 -18
View File
@@ -7,14 +7,6 @@ const TAB_PAGES = new Set([
'/pages/mine/index', '/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 { function currentPagePath(): string {
const pages = Taro.getCurrentPages(); const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as const cur = pages[pages.length - 1] as
@@ -33,7 +25,6 @@ function currentPagePath(): string {
/** 跳转登录页;默认带回当前页作为 return */ /** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) { export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath(); const returnTo = returnPath ?? currentPagePath();
const parts: string[] = []; const parts: string[] = [];
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`); if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
@@ -43,15 +34,9 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
} }
} }
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index'; const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
loginNavigationPending = true; Taro.navigateTo({ url }).catch(() => {
void Taro.navigateTo({ url }) Taro.redirectTo({ url });
.catch(() => Taro.redirectTo({ url })) });
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
} }
/** 登录成功后回到 return 页,或回退 / 首页 */ /** 登录成功后回到 return 页,或回退 / 首页 */
+62 -111
View File
@@ -1,4 +1,6 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { request } from './api';
import type { UserProfile } from './api'; import type { UserProfile } from './api';
export type MiniWechatProfile = { export type MiniWechatProfile = {
@@ -6,17 +8,6 @@ export type MiniWechatProfile = {
avatarUrl?: string; 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'; const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
export function cacheWxProfile(info: MiniWechatProfile) { export function cacheWxProfile(info: MiniWechatProfile) {
@@ -41,117 +32,77 @@ export function getCachedWxProfile(): MiniWechatProfile | null {
} }
export function isDefaultMiniNickname(nickname?: string | null): boolean { export function isDefaultMiniNickname(nickname?: string | null): boolean {
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true; if (!nickname || nickname === '访客') return true;
return /^用户\d{4}$/.test(nickname); 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 { export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
if (!profile.hasWechat) return profile;
const cached = getCachedWxProfile(); const cached = getCachedWxProfile();
if (!cached && !profile.hasWechat) return profile; if (!cached) return profile;
const nickname =
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
cached?.nickname ||
profile.nickname ||
'微信用户';
return { return {
...profile, ...profile,
nickname, nickname:
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null, cached.nickname ||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
profile.nickname ||
'微信用户',
avatarUrl: profile.avatarUrl || cached.avatarUrl || null,
}; };
} }
/** 上传 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('请先登录');
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 {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
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 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;
}
/**
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
*/
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> { export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
const cached = getCachedWxProfile(); if (process.env.TARO_ENV !== 'weapp') {
if (cached?.nickname || cached?.avatarUrl) { throw new Error('请在微信小程序中授权');
return cached;
} }
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
throw new Error('请在「我的」页点击头像完善微信头像和昵称'); const info: MiniWechatProfile = {
} nickname: res.userInfo?.nickName?.trim(),
avatarUrl: res.userInfo?.avatarUrl?.trim(),
/** 绑定后上报微信资料(优先使用已拉取的信息) */ };
export async function syncMiniWechatProfile( if (!info.nickname && !info.avatarUrl) {
prefetched?: MiniWechatProfile | null, throw new Error('未获取到微信头像或昵称');
): Promise<MiniWechatProfile | null> { }
if (process.env.TARO_ENV !== 'weapp') return null; cacheWxProfile(info);
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile(); return info;
if (!info?.nickname && !info?.avatarUrl) return null; }
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname }); export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<MiniWechatProfile | null> {
if (!info.nickname && !info.avatarUrl) return null;
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;
} catch {
return null;
}
}
/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */
export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise<MiniWechatProfile | null> {
if (process.env.TARO_ENV !== 'weapp') return null;
let info = prefetched ?? null;
if (!info) {
try {
info = await fetchMiniWechatUserInfo();
} catch {
return getCachedWxProfile();
}
}
await uploadMiniWechatProfile(info);
return info; return info;
} }
+1 -26
View File
@@ -3,32 +3,7 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { saveAuth } from './api'; import { saveAuth } from './api';
import { import { syncMiniWechatProfile } from './mini-wechat-profile';
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 { import {
authorizeWechatForPay, authorizeWechatForPay,
fetchClientConfig, fetchClientConfig,
@@ -1,6 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '地址管理', navigationBarTitleText: '地址管理',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+1 -5
View File
@@ -1,6 +1,6 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh, useRouter } from '@tarojs/taro'; import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { import {
@@ -44,10 +44,6 @@ export default function AddressesPage() {
loadList(); loadList();
}); });
usePullDownRefresh(() => {
void Promise.resolve(loadList()).finally(() => Taro.stopPullDownRefresh());
});
function selectAddress(addr: Address) { function selectAddress(addr: Address) {
if (!selectMode) return; if (!selectMode) return;
Taro.redirectTo({ Taro.redirectTo({
@@ -1,6 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '好客权益', navigationBarTitleText: '好客权益',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+13 -13
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar'; import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
@@ -44,9 +44,9 @@ export default function BenefitPage() {
syncTabBarSelected(2); syncTabBarSelected(2);
}); });
const loadBenefit = useCallback(() => { useEffect(() => {
if (!loggedIn) return Promise.resolve(); if (!loggedIn) return;
return Promise.all([ Promise.all([
request<BenefitSummary>('/benefit/summary'), request<BenefitSummary>('/benefit/summary'),
request<CouponItem[]>('/benefit/coupons'), request<CouponItem[]>('/benefit/coupons'),
]) ])
@@ -57,14 +57,6 @@ export default function BenefitPage() {
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')); .catch((e) => toast(e instanceof Error ? e.message : '加载失败'));
}, [loggedIn]); }, [loggedIn]);
useEffect(() => {
void loadBenefit();
}, [loadBenefit]);
usePullDownRefresh(() => {
void loadBenefit().finally(() => Taro.stopPullDownRefresh());
});
const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0); const available = coupons.filter((c) => c.status === 'ACTIVE' && c.balance > 0);
const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID'); const history = coupons.filter((c) => c.status === 'USED_UP' || c.status === 'VOID');
const visible = tab === 'available' ? available : history; const visible = tab === 'available' ? available : history;
@@ -114,6 +106,14 @@ export default function BenefitPage() {
<Text></Text> <Text></Text>
</View> </View>
</View> </View>
<View className="benefit-hero-actions">
<Text
className="benefit-hero-link"
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
>
</Text>
</View>
<View <View
className="benefit-hero-cta" className="benefit-hero-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })} onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
@@ -3,42 +3,28 @@ import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api'; import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() { export default function CustomerServicePage() {
return ( return (
<PageShell variant="sub" className="cs-page"> <PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" /> <SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body"> <View className="sub-page-body inset-page cs-body">
<Text className="cs-brand"></Text> <Text className="cs-title">线</Text>
<Text className="cs-hint"> <Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
{isWeapp <Text className="cs-hint">9:00 - 21:00</Text>
? '点击下方按钮,进入小程序在线客服会话' <View
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'} className="cs-call-btn"
</Text> onClick={() => {
<Text className="cs-hours">9:00 - 21:00</Text> Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
toast('无法拨打电话'),
{isWeapp ? ( );
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} /> }}
) : null} >
<Text></Text>
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View> </View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View> </View>
</PageShell> </PageShell>
); );
@@ -1,5 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '杜康好客', navigationBarTitleText: '杜康好客',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+25 -51
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge'; import CouponBadge from '../../components/CouponBadge';
@@ -21,9 +21,9 @@ type Product = {
}; };
const AROMA_TABS = [ const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' }, { key: 'QINGXIANG', label: '清香型', open: true },
{ key: 'JIANGXIANG', label: '酱香型' }, { key: 'JIANGXIANG', label: '酱香型', open: false },
{ key: 'NONGXIANG', label: '浓香型' }, { key: 'NONGXIANG', label: '浓香型', open: false },
] as const; ] as const;
export default function HomePage() { export default function HomePage() {
@@ -41,71 +41,43 @@ export default function HomePage() {
}); });
}); });
const loadProducts = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
return request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`) request<Product[]>(`/catalog/products?cityCode=${encodeURIComponent(cityCode)}`)
.then((list) => setProducts(Array.isArray(list) ? list : [])) .then((list) => setProducts(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cityCode]); }, [cityCode]);
useEffect(() => { function onAromaTabClick(key: string, open: boolean) {
void loadProducts(); if (!open) {
}, [loadProducts]); toast('暂未开放');
return;
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);
} }
}, [availableAromas, loading, tab]); setTab(key);
}
function openProductDetail(id: string) { function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` }); Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
} }
const filtered = products.filter((p) => p.aromaType === tab); const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
return ( return (
<PageShell variant="tab" className="home-page no-tab-header"> <PageShell
variant="tab"
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="杜康好客" /> <TabMainHeader title="杜康好客" />
<View className="home-aroma-nav"> <View className="home-aroma-nav">
<View className="home-aroma-tabs"> <View className="home-aroma-tabs">
{availableAromas.map((t) => ( {AROMA_TABS.map((t) => (
<Text <Text
key={t.key} key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`} className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => setTab(t.key)} onClick={() => onAromaTabClick(t.key, t.open)}
> >
{t.label} {t.label}
</Text> </Text>
@@ -116,10 +88,12 @@ export default function HomePage() {
<View className="home-product-list"> <View className="home-product-list">
{loading ? <View className="home-empty"></View> : null} {loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? ( {!loading && !onSale ? <View className="home-empty">线</View> : null}
<View className="home-empty"></View> {!loading && onSale && filtered.length === 0 ? (
<View className="home-empty"></View>
) : null} ) : null}
{!loading && {!loading &&
onSale &&
filtered.map((p) => { filtered.map((p) => {
const images = getProductImages(p); const images = getProductImages(p);
return ( return (
+135 -213
View File
@@ -9,7 +9,6 @@ import {
} from '@dukang/shared-types'; } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton'; import WechatLoginButton from '../../components/WechatLoginButton';
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types'; import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav'; import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import { import {
@@ -19,14 +18,13 @@ import {
import { fetchUserProfile } from '../../lib/pay-wechat'; import { fetchUserProfile } from '../../lib/pay-wechat';
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone'; import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import { import {
fetchMiniWechatUserInfo,
getCachedWxProfile, getCachedWxProfile,
syncMiniWechatProfile, syncMiniWechatProfile,
type MiniWechatProfile, type MiniWechatProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api'; import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
function normalizePhone(value: string) { function normalizePhone(value: string) {
return value.replace(/\D/g, '').slice(0, 11); return value.replace(/\D/g, '').slice(0, 11);
} }
@@ -35,44 +33,6 @@ function isValidPhone(phone: string) {
return /^1[3-9]\d{9}$/.test(phone); 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() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const returnTo = router.params.return || ''; const returnTo = router.params.return || '';
@@ -85,10 +45,8 @@ export default function LoginPage() {
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false); const [wxLoading, setWxLoading] = useState(false);
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0); const [cooldown, setCooldown] = useState(0);
/** 须用户主动勾选,禁止默认同意 */
const [agreed, setAgreed] = useState(false); const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState(''); const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState(''); const [sentHint, setSentHint] = useState('');
@@ -96,7 +54,6 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey); const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true); const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null); const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
useEffect(() => { useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config') request<ClientRuntimeConfig>('/common/client-config')
@@ -109,6 +66,7 @@ export default function LoginPage() {
setCompleteMode(null); setCompleteMode(null);
return; return;
} }
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
if (!needPhone && !needWechat) { if (!needPhone && !needWechat) {
setCompleteMode(null); setCompleteMode(null);
return; return;
@@ -119,7 +77,6 @@ export default function LoginPage() {
if (cancelled) return; if (cancelled) return;
if (needPhone && !me.phoneVerified) { if (needPhone && !me.phoneVerified) {
setCompleteMode('phone'); setCompleteMode('phone');
setShowSmsForm(true);
return; return;
} }
if (needWechat && !me.hasWechat) { if (needWechat && !me.hasWechat) {
@@ -144,39 +101,26 @@ export default function LoginPage() {
function ensureAgreed() { function ensureAgreed() {
if (!agreed) { if (!agreed) {
setMsg('请先阅读并勾选同意用户服务协议》和《隐私政策》'); setMsg('请先勾选同意用户协议');
return false; return false;
} }
return true; 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( function applySessionAndLeave(
data: SessionPayload | WechatLoginResult, data: SessionPayload | WechatLoginResult,
phoneValue?: string, phone?: string,
wxInfo?: MiniWechatProfile | null, wxInfo?: MiniWechatProfile | null,
successToast = '登录成功', successToast = '登录成功',
) { ) {
if (!data.accessToken) return; if (!data.accessToken) return;
if (phoneValue) saveUserPhone(phoneValue); if (phone) saveUserPhone(phone);
saveAuth({ saveAuth({
accessToken: data.accessToken, accessToken: data.accessToken,
refreshToken: data.refreshToken, refreshToken: data.refreshToken,
}); });
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile()); void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
if (!phoneValue) { if (!phone) {
void fetchUserProfile() void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me)) .then((me) => resolveDefaultUserPhone(me))
.catch(() => {}); .catch(() => {});
@@ -190,6 +134,7 @@ export default function LoginPage() {
} }
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) { function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
// 微信授权成功即登录;手机号改为下单页可选绑定
if (result.accessToken) { if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo); applySessionAndLeave(result, undefined, wxInfo);
return; return;
@@ -197,49 +142,11 @@ export default function LoginPage() {
if (result.needBindPhone && result.wxSessionKey) { if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true); setBindMode(true);
setWxSessionKey(result.wxSessionKey); setWxSessionKey(result.wxSessionKey);
setShowSmsForm(true); setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
setSentHint(''); setSentHint('');
return; 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() { async function onSendCode() {
@@ -294,6 +201,7 @@ export default function LoginPage() {
return; return;
} }
if (completeMode === 'phone' && isLoggedIn()) { if (completeMode === 'phone' && isLoggedIn()) {
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
const data = await request<SessionPayload>('/auth/phone/bind', { const data = await request<SessionPayload>('/auth/phone/bind', {
method: 'POST', method: 'POST',
data: { phone: normalized, code: code.trim() }, data: { phone: normalized, code: code.trim() },
@@ -327,7 +235,15 @@ export default function LoginPage() {
setSentHint(''); setSentHint('');
setWxLoading(true); setWxLoading(true);
try { try {
const wxInfo = getCachedWxProfile(); let wxInfo: MiniWechatProfile | null = null;
if (process.env.TARO_ENV === 'weapp') {
try {
wxInfo = await fetchMiniWechatUserInfo();
} catch (e) {
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
return;
}
}
if (completeMode === 'wechat' && isLoggedIn()) { if (completeMode === 'wechat' && isLoggedIn()) {
const result = await bindWechatForUser(wxInfo); const result = await bindWechatForUser(wxInfo);
@@ -338,13 +254,12 @@ export default function LoginPage() {
setBindMode(true); setBindMode(true);
setWxSessionKey(result.wxSessionKey); setWxSessionKey(result.wxSessionKey);
setCompleteMode('phone'); setCompleteMode('phone');
setShowSmsForm(true);
setMsg('请绑定手机号完成认证'); setMsg('请绑定手机号完成认证');
return; return;
} }
if (result.ok) { if (result.ok) {
if (wxInfo) await syncMiniWechatProfile(wxInfo); await syncMiniWechatProfile(wxInfo);
toast('授权成功', 'success'); toast('微信授权成功', 'success');
finishLoginNavigate(returnTo); finishLoginNavigate(returnTo);
return; return;
} }
@@ -353,11 +268,11 @@ export default function LoginPage() {
const result = await loginWithWechat(); const result = await loginWithWechat();
if (result) handleWechatLoginResult(result, wxInfo); if (result) handleWechatLoginResult(result, wxInfo);
} catch (e) { } catch (e) {
const raw = e instanceof Error ? e.message : '授权登录失败'; const raw = e instanceof Error ? e.message : '微信登录失败';
const hint = /invalid code/i.test(raw) const hint = /invalid code/i.test(raw)
? process.env.TARO_ENV === 'weapp' ? process.env.TARO_ENV === 'weapp'
? '授权失败:请确认后端小程序 AppID 配置正确' ? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
: '授权失败:请确认公众号网页授权域名配置正确' : '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
: raw; : raw;
setMsg(hint); setMsg(hint);
} finally { } finally {
@@ -367,28 +282,21 @@ export default function LoginPage() {
const displayMsg = msg || sentHint; const displayMsg = msg || sentHint;
const codeDisabled = cooldown > 0 || sending; const codeDisabled = cooldown > 0 || sending;
const showAuthLogin = const showWechatLogin =
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) && (completeMode === 'wechat' || (!bindMode && !completeMode)) &&
(IS_WEAPP || wxAuthorize); (process.env.TARO_ENV === 'weapp' || wxAuthorize);
const showPhoneQuick = const showSmsForm = completeMode !== 'wechat';
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
const cardTitle = const cardTitle =
completeMode === 'phone' completeMode === 'phone'
? '验证手机号' ? '验证手机号'
: bindMode : bindMode
? '绑定手机号' ? '绑定手机号'
: completeMode === 'wechat' : completeMode === 'wechat'
? '授权登录' ? '微信授权'
: '手机号快捷登录'; : '手机验证码登录';
return ( return (
<PageShell variant="plain" className="login-page"> <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-header">
<View className="login-logo-wrap"> <View className="login-logo-wrap">
<View className="login-logo"> <View className="login-logo">
@@ -401,7 +309,7 @@ export default function LoginPage() {
{completeMode === 'phone' {completeMode === 'phone'
? '建议绑定手机号' ? '建议绑定手机号'
: completeMode === 'wechat' : completeMode === 'wechat'
? '完成授权登录' ? '完成微信授权'
: '欢迎来到杜康好客'} : '欢迎来到杜康好客'}
</Text> </Text>
<Text className="login-welcome-sub"> <Text className="login-welcome-sub">
@@ -417,100 +325,77 @@ export default function LoginPage() {
<View className="login-main"> <View className="login-main">
{completeMode === 'wechat' ? ( {completeMode === 'wechat' ? (
<View className="login-card"> <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 className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使 使
</Text> </Text>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} /> <View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
{displayMsg ? ( <View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}> {agreed ? <Text></Text> : null}
{displayMsg} </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> </Text>
) : null} </View>
{showAuthLogin ? ( {showWechatLogin ? (
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} /> <WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
) : null} ) : null}
</View> </View>
) : ( ) : (
<View className="login-card"> <View className="login-card">
<Text className="login-card-title">{cardTitle}</Text> <Text className="login-card-title">{cardTitle}</Text>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} /> <View className="login-field">
<Text className="login-field-prefix">+86</Text>
{showPhoneQuick ? ( <Input
<PhoneQuickLoginButton className="login-field-input"
loading={phoneQuickLoading} type="number"
agreed={agreed} maxlength={11}
onRequireAgree={() => ensureAgreed()} placeholder="请输入手机号"
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)} value={phone}
onFail={(message) => setMsg(message)} onInput={(e) => {
setPhone(normalizePhone(e.detail.value));
setMsg('');
setSentHint('');
}}
/> />
) : null} </View>
{showPhoneQuick ? ( <View className="login-field">
<View className="login-divider" style={{ marginTop: 20 }}> <Input
<View className="login-divider-line" /> className="login-field-input"
<Text type="number"
className="login-divider-text" maxlength={6}
onClick={() => setShowSmsForm((v) => !v)} placeholder="请输入验证码"
> value={code}
{showSmsForm ? '收起验证码登录' : '使用验证码登录'} onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
</Text> />
<View className="login-divider-line" /> <Text
</View> className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
) : null} onClick={() => void onSendCode()}
>
{(showSmsForm || !showPhoneQuick) && ( {sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
<> </Text>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}> </View>
<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 ? ( {displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}> <Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
@@ -518,6 +403,48 @@ export default function LoginPage() {
</Text> </Text>
) : null} ) : 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' ? ( {completeMode === 'phone' ? (
<View <View
className="login-skip-bind" className="login-skip-bind"
@@ -532,21 +459,16 @@ export default function LoginPage() {
</View> </View>
)} )}
{showAuthLogin && completeMode !== 'wechat' ? ( {showWechatLogin && completeMode !== 'wechat' ? (
<> <>
<View className="login-divider"> <View className="login-divider">
<View className="login-divider-line" /> <View className="login-divider-line" />
<Text className="login-divider-text"></Text> <Text className="login-divider-text"></Text>
<View className="login-divider-line" /> <View className="login-divider-line" />
</View> </View>
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} /> <WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
</> </>
) : null} ) : null}
<View className="login-cancel-btn" onClick={cancelLogin}>
<Text></Text>
</View>
<Text className="login-cancel-hint"></Text>
</View> </View>
</PageShell> </PageShell>
); );
@@ -1,5 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '我的', navigationBarTitleText: '我的',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+104 -243
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, Image, Button, Input } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types'; import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
@@ -8,13 +8,10 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types'; import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
import { goLogin } from '../../lib/auth-nav'; import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth'; import { bindWechatForUser } from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat';
import { import {
fetchMiniWechatUserInfo, fetchMiniWechatUserInfo,
isDefaultMiniNickname,
mergeWxDisplayProfile, mergeWxDisplayProfile,
needsWxProfileFill,
uploadAvatarTempFile,
uploadMiniWechatProfile,
} from '../../lib/mini-wechat-profile'; } from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api'; import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin'; import { isWechatEnv } from '../../lib/weixin';
@@ -32,8 +29,6 @@ const SERVICES = [
{ icon: '关', label: '关于我们', action: 'about' as const }, { icon: '关', label: '关于我们', action: 'about' as const },
] as const; ] as const;
const isWeapp = process.env.TARO_ENV === 'weapp';
function formatMoney(amount: number) { function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
@@ -45,28 +40,16 @@ export default function MinePage() {
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({}); const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [wxAuthorize, setWxAuthorize] = useState(true); const [wxAuthorize, setWxAuthorize] = useState(true);
const [bindingWx, setBindingWx] = useState(false); 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() { function resetGuestState() {
setProfile(null); setProfile(null);
setBenefitBalance(0); setBenefitBalance(0);
setOrderCounts({}); setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
setProfile(mergeWxDisplayProfile(me));
} }
function loadProfile() { function loadProfile() {
if (!isLoggedIn()) return Promise.resolve(); if (!isLoggedIn()) return;
setProfileLoadError(''); Promise.all([
return Promise.all([
request<UserProfile>('/auth/me'), request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []), request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
...ORDER_SHORTCUTS.map((s) => ...ORDER_SHORTCUTS.map((s) =>
@@ -74,7 +57,7 @@ export default function MinePage() {
), ),
]) ])
.then(([me, coupons, ...totals]) => { .then(([me, coupons, ...totals]) => {
applyProfile(me); setProfile(mergeWxDisplayProfile(me));
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => { const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0); if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
return sum; return sum;
@@ -86,16 +69,7 @@ export default function MinePage() {
}); });
setOrderCounts(counts); setOrderCounts(counts);
}) })
.catch((error) => { .catch(() => {});
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
} }
useDidShow(() => { useDidShow(() => {
@@ -109,142 +83,99 @@ export default function MinePage() {
} }
}); });
usePullDownRefresh(() => {
const loggedInNow = isLoggedIn();
setAuthed(loggedInNow);
if (!loggedInNow) {
resetGuestState();
Taro.stopPullDownRefresh();
return;
}
void loadProfile().finally(() => Taro.stopPullDownRefresh());
});
useEffect(() => { useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config') request<ClientRuntimeConfig>('/common/client-config')
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config))) .then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(true)); .catch(() => setWxAuthorize(true));
}, []); }, []);
async function ensureWechatBound(): Promise<boolean> { async function handleAvatarTap() {
if (profile?.hasWechat) return true; 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;
if (!wxAuthorize) { if (!wxAuthorize) {
toast('当前环境未开启微信授权'); toast('当前环境未开启微信授权');
return false; return;
} }
setBindingWx(true); setBindingWx(true);
try { try {
if (!isWeapp) { if (process.env.TARO_ENV === 'h5') {
if (!isWechatEnv()) { if (!isWechatEnv()) {
toast('请在微信内打开后授权'); toast('请在微信内打开后授权');
return false; return;
} }
const result = await bindWechatForUser(); const result = await bindWechatForUser();
if (!result.ok && 'redirecting' in result && result.redirecting) return false; if (!result.ok && 'redirecting' in result && result.redirecting) {
return;
}
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) { if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { goLogin('/pages/mine/index', {
bindMode: '1', bindMode: '1',
wxSessionKey: result.wxSessionKey, wxSessionKey: result.wxSessionKey,
}); });
return false; return;
} }
if (result.ok && result.profile) { if (result.ok) {
applyProfile({ ...result.profile, hasWechat: true }); setProfile(
return true; mergeWxDisplayProfile({
...(result.profile ?? {}),
id: result.profile?.id ?? profile?.id ?? '',
hasWechat: true,
}),
);
loadProfile();
toast('微信授权成功', 'success');
} }
return false; return;
} }
const result = await bindWechatForUser(null); let wxInfo = null;
try {
wxInfo = await fetchMiniWechatUserInfo();
} catch (e) {
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
return;
}
const result = await bindWechatForUser(wxInfo);
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) { if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey }); goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return false; return;
} }
if (result.ok && result.profile) { if (result.ok) {
applyProfile({ ...result.profile, hasWechat: true }); const merged = mergeWxDisplayProfile({
return true; ...(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');
} }
return false;
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '授权失败'); toast(e instanceof Error ? e.message : '微信授权失败');
return false;
} finally { } finally {
setBindingWx(false); 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]) { function handleService(item: (typeof SERVICES)[number]) {
if ('url' in item && item.url) { if ('url' in item && item.url) {
Taro.navigateTo({ url: item.url }); Taro.navigateTo({ url: item.url });
@@ -259,6 +190,17 @@ 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) { function renderAvatarContent(displayAvatarUrl: string | null) {
if (displayAvatarUrl) { if (displayAvatarUrl) {
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />; return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
@@ -268,7 +210,10 @@ export default function MinePage() {
if (!authed) { if (!authed) {
return ( return (
<PageShell variant="tab" className="mine-page no-tab-header"> <PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
@@ -285,10 +230,11 @@ export default function MinePage() {
</View> </View>
</View> </View>
<View className="mine-login-gate"> <View className="mine-login-gate">
<View className="mine-login-gate-hint"> <View className="mine-login-gate-hint"></View>
<View
</View> className="mine-login-btn"
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}> onClick={() => goLogin('/pages/mine/index')}
>
<Text></Text> <Text></Text>
</View> </View>
</View> </View>
@@ -298,75 +244,42 @@ export default function MinePage() {
} }
const hasWechat = !!profile?.hasWechat; const hasWechat = !!profile?.hasWechat;
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv()); const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp';
const display = mergeWxDisplayProfile( const display = resolveDisplayProfile(profile, hasWechat);
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false }, const nickname = display.nickname;
); const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
// 强制保留 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 ( return (
<PageShell variant="tab" className="mine-page no-tab-header"> <PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="我的" /> <TabMainHeader title="我的" />
<View className="mine-header"> <View className="mine-header">
<View className="mine-header-texture" /> <View className="mine-header-texture" />
<View className="mine-profile"> <View className="mine-profile">
<View <View
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`} className={`mine-avatar-wrap${!hasWechat && canWxBind ? ' mine-avatar-wrap--action' : ''}`}
onClick={avatarClickable ? () => void handleAvatarTap() : undefined} onClick={() => void handleAvatarTap()}
> >
<View <View
className={`mine-avatar${ className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : canWxBind ? ' mine-avatar--wx-pending' : ''}`}
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
> >
{renderAvatarContent(display.avatarUrl)} {renderAvatarContent(display.avatarUrl)}
</View> </View>
{avatarClickable ? ( {!hasWechat && canWxBind ? (
<View <View className="mine-avatar-status mine-avatar-status--pending">
className={`mine-avatar-status${ <Text>{bindingWx ? '授权中' : '去授权'}</Text>
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View> </View>
) : null} ) : null}
</View> </View>
<View>
<View
className="mine-profile-meta"
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<Text className="mine-profile-name">{nickname}</Text> <Text className="mine-profile-name">{nickname}</Text>
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}> <Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel} {memberLabel}
</Text> </Text>
{profileLoadError ? ( {!hasWechat && canWxBind ? (
<Text <Text className="mine-wechat-hint"></Text>
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null} ) : null}
</View> </View>
</View> </View>
@@ -463,58 +376,6 @@ export default function MinePage() {
</View> </View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null} {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> </PageShell>
); );
} }
+13 -48
View File
@@ -5,7 +5,6 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton'; import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
@@ -15,8 +14,6 @@ import {
toWeappShareMessage, toWeappShareMessage,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = { type OrderItem = {
productName?: string; productName?: string;
productSpec?: string; productSpec?: string;
@@ -107,20 +104,8 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) }); 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 ( return (
<PageShell variant="sub" className={pageClass}> <PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
<WechatShareReady payload={sharePayload} /> <WechatShareReady payload={sharePayload} />
<SubPageHeader <SubPageHeader
title="订单详情" title="订单详情"
@@ -188,39 +173,19 @@ export default function OrderDetailPage() {
)} )}
</View> </View>
{order ? ( {canPay && order && (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}> <View className="pay-bar order-detail-pay-bar">
{isWeapp ? ( <View className="order-confirm-total">
<ContactCsButton <Text className="order-confirm-total-label"></Text>
className="order-detail-cs-btn" <Text className="order-confirm-total-value">
session={{ ¥{Number(order.payAmount ?? 0).toFixed(2)}
from: 'order-detail', </Text>
orderId: order.id, </View>
orderNo: order.orderNo, <View className="order-confirm-submit" onClick={goPay}>
}}
> </View>
</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> </View>
) : null} )}
</PageShell> </PageShell>
); );
} }
@@ -1,6 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom', navigationStyle: 'custom',
navigationBarTitleText: '我的订单', navigationBarTitleText: '我的订单',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+32 -81
View File
@@ -1,10 +1,9 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components'; import { View, Text } from '@tarojs/components';
import Taro, { usePullDownRefresh, useRouter } from '@tarojs/taro'; import Taro, { useRouter } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
const TABS = [ const TABS = [
{ key: 'all', label: '全部订单' }, { key: 'all', label: '全部订单' },
@@ -31,13 +30,6 @@ function orderStatusLabel(tab: string, status?: string): string {
return STATUS_LABELS[status] || status; return STATUS_LABELS[status] || status;
} }
type OrderItem = {
productName?: string;
productImage?: string;
unitPrice?: number;
quantity?: number;
};
type OrderRow = { type OrderRow = {
id: string; id: string;
orderNo?: string; orderNo?: string;
@@ -45,9 +37,7 @@ type OrderRow = {
payAmount?: number; payAmount?: number;
productName?: string; productName?: string;
qty?: number; qty?: number;
quantity?: number; createdAt?: string;
originOrderId?: string | null;
items?: OrderItem[];
}; };
export default function OrdersPage() { export default function OrdersPage() {
@@ -57,9 +47,9 @@ export default function OrdersPage() {
const [orders, setOrders] = useState<OrderRow[]>([]); const [orders, setOrders] = useState<OrderRow[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const loadOrders = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
return request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>( request<{ list?: OrderRow[]; items?: OrderRow[]; total?: number } | OrderRow[]>(
`/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`, `/trade/orders?tab=${encodeURIComponent(tab)}&pageSize=20`,
) )
.then((data) => { .then((data) => {
@@ -73,18 +63,6 @@ export default function OrdersPage() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [tab]); }, [tab]);
useEffect(() => {
void loadOrders();
}, [loadOrders]);
usePullDownRefresh(() => {
void loadOrders().finally(() => Taro.stopPullDownRefresh());
});
function goPay(orderId: string) {
Taro.navigateTo({ url: buildPayUrl({ orderId }) });
}
return ( return (
<PageShell variant="sub" className="orders-page"> <PageShell variant="sub" className="orders-page">
<SubPageHeader title="我的订单" /> <SubPageHeader title="我的订单" />
@@ -103,62 +81,35 @@ export default function OrdersPage() {
{loading ? <View className="u-empty"></View> : null} {loading ? <View className="u-empty"></View> : null}
{!loading && orders.length === 0 ? <View className="u-empty"></View> : null} {!loading && orders.length === 0 ? <View className="u-empty"></View> : null}
{!loading && {!loading &&
orders.map((o) => { orders.map((o) => (
const item = o.items?.[0]; <View
const productName = item?.productName || o.productName || '杜康商品'; key={o.id}
const productImage = (item?.productImage || '').trim(); className="order-list-item"
const qty = item?.quantity ?? o.quantity ?? o.qty ?? 1; onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })}
const unitPrice = Number(item?.unitPrice ?? 0); >
const canPay = o.status === 'PENDING_PAY' && !o.originOrderId; <View className="order-list-head">
<Text className="order-list-no">{o.orderNo || o.id}</Text>
return ( <Text className="order-list-status">
<View {orderStatusLabel(tab, o.status)}
key={o.id} </Text>
className="order-list-item" </View>
onClick={() => Taro.navigateTo({ url: `/pages/order-detail/index?id=${o.id}` })} <View className="order-list-body">
> <View className="order-list-thumb" />
<View className="order-list-head"> <View style={{ flex: 1 }}>
<Text className="order-list-no">{o.orderNo || o.id}</Text> <Text className="order-list-name">{o.productName || '杜康商品'}</Text>
<Text className="order-list-status"> <Text className="order-list-meta">
{orderStatusLabel(tab, o.status)} {o.qty ?? 1} · {o.createdAt ? String(o.createdAt).slice(0, 10) : ''}
</Text> </Text>
</View> </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>
); <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> </PageShell>
); );
} }
@@ -1,4 +1,4 @@
import { View, Text } from '@tarojs/components'; import { View, Text, ScrollView } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types'; import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
@@ -8,25 +8,21 @@ export default function PrivacyPolicyPage() {
return ( return (
<PageShell variant="sub" className="legal-page"> <PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} /> <SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page legal-body"> <View className="sub-page-body inset-page">
<Text className="legal-updated" selectable> <ScrollView scrollY className="legal-scroll">
{doc.updatedAt} <Text className="legal-updated">{doc.updatedAt}</Text>
</Text> <Text className="legal-intro">{doc.intro}</Text>
<Text className="legal-intro" selectable> {doc.sections.map((section) => (
{doc.intro} <View key={section.heading} className="legal-section">
</Text> <Text className="legal-heading">{section.heading}</Text>
{doc.sections.map((section) => ( {section.paragraphs.map((p, i) => (
<View key={section.heading} className="legal-section"> <Text key={`${section.heading}-${i}`} className="legal-paragraph">
<Text className="legal-heading" selectable> {p}
{section.heading} </Text>
</Text> ))}
{section.paragraphs.map((p, i) => ( </View>
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable> ))}
{p} </ScrollView>
</Text>
))}
</View>
))}
</View> </View>
</PageShell> </PageShell>
); );
+1 -3
View File
@@ -101,14 +101,12 @@ export default function RedeemPage() {
className="redeem-input" className="redeem-input"
type="digit" type="digit"
placeholder="输入核销金额" placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder"
value={amount} value={amount}
onInput={(e) => setAmount(e.detail.value)} onInput={(e) => setAmount(e.detail.value)}
style={{ textAlign: 'center' }}
/> />
</View> </View>
<View className="redeem-amount-foot"> <View className="redeem-amount-foot">
<Text className="redeem-amount-hint"> <Text className="redeem-tips" style={{ margin: 0 }}>
¥{formatMoney(redeemableMax)} ¥{formatMoney(redeemableMax)}
</Text> </Text>
<Text className="redeem-fill-max" onClick={fillMaxAmount}> <Text className="redeem-fill-max" onClick={fillMaxAmount}>
@@ -1,5 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '门店', navigationBarTitleText: '门店',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark',
}); });
+8 -29
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { View, Text, Image, Input } from '@tarojs/components'; import { View, Text, Image, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader'; import TabMainHeader from '../../components/TabMainHeader';
import RegionPicker from '../../components/RegionPicker'; import RegionPicker from '../../components/RegionPicker';
@@ -49,39 +49,15 @@ export default function StoresPage() {
}); });
}); });
const loadStores = useCallback(() => { useEffect(() => {
setLoading(true); setLoading(true);
const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores'; const path = cityCode ? `/stores?cityCode=${encodeURIComponent(cityCode)}` : '/stores';
return request<Store[]>(path) request<Store[]>(path)
.then((list) => setStores(Array.isArray(list) ? list : [])) .then((list) => setStores(Array.isArray(list) ? list : []))
.catch((e) => toast(e instanceof Error ? e.message : '加载失败')) .catch((e) => toast(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [cityCode]); }, [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();
}
})();
});
const filtered = stores.filter((s) => { const filtered = stores.filter((s) => {
if (!matchesRegionFilter(s, region)) return false; if (!matchesRegionFilter(s, region)) return false;
if (!keyword.trim()) return true; if (!keyword.trim()) return true;
@@ -97,7 +73,10 @@ export default function StoresPage() {
} }
return ( return (
<PageShell variant="tab" className="store-page no-tab-header"> <PageShell
variant="tab"
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<TabMainHeader title="门店" /> <TabMainHeader title="门店" />
<View className="store-toolbar"> <View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}> <View className="store-location" onClick={() => setRegionOpen(true)}>
@@ -1,4 +1,4 @@
export default definePageConfig({ export default definePageConfig({
navigationBarTitleText: '用户服务协议', navigationBarTitleText: '用户协议',
navigationStyle: 'custom', navigationStyle: 'custom',
}); });
@@ -1,4 +1,4 @@
import { View, Text } from '@tarojs/components'; import { View, Text, ScrollView } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types'; import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
@@ -8,25 +8,21 @@ export default function UserAgreementPage() {
return ( return (
<PageShell variant="sub" className="legal-page"> <PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} /> <SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page legal-body"> <View className="sub-page-body inset-page">
<Text className="legal-updated" selectable> <ScrollView scrollY className="legal-scroll">
{doc.updatedAt} <Text className="legal-updated">{doc.updatedAt}</Text>
</Text> <Text className="legal-intro">{doc.intro}</Text>
<Text className="legal-intro" selectable> {doc.sections.map((section) => (
{doc.intro} <View key={section.heading} className="legal-section">
</Text> <Text className="legal-heading">{section.heading}</Text>
{doc.sections.map((section) => ( {section.paragraphs.map((p, i) => (
<View key={section.heading} className="legal-section"> <Text key={`${section.heading}-${i}`} className="legal-paragraph">
<Text className="legal-heading" selectable> {p}
{section.heading} </Text>
</Text> ))}
{section.paragraphs.map((p, i) => ( </View>
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable> ))}
{p} </ScrollView>
</Text>
))}
</View>
))}
</View> </View>
</PageShell> </PageShell>
); );
+12
View File
@@ -120,7 +120,19 @@
font-size: 18px; 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 { .benefit-hero-cta {
flex: 1;
height: 44px; height: 44px;
border-radius: var(--radius-full); border-radius: var(--radius-full);
background: var(--color-heritage-red); background: var(--color-heritage-red);
+4
View File
@@ -81,6 +81,10 @@
border-bottom-color: var(--color-heritage-red); border-bottom-color: var(--color-heritage-red);
} }
.home-aroma-tab--muted {
opacity: 0.65;
}
.home-product-list { .home-product-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+9 -13
View File
@@ -1,24 +1,21 @@
.legal-page .legal-body { .legal-page .legal-scroll {
min-height: calc(100vh - var(--nav-bar-height, 88px)); height: 100%;
padding-top: 8px;
padding-bottom: 40px;
box-sizing: border-box; box-sizing: border-box;
background: var(--color-background, #f7f4ef); padding-bottom: 32px;
} }
.legal-updated { .legal-updated {
display: block; display: block;
font-size: 12px; font-size: 12px;
color: #8d706e; color: var(--color-on-surface-variant, #8d706e);
margin-bottom: 12px; margin-bottom: 12px;
line-height: 1.5;
} }
.legal-intro { .legal-intro {
display: block; display: block;
font-size: 14px; font-size: 14px;
line-height: 1.75; line-height: 1.7;
color: #1f1a17; color: var(--color-on-surface, #1f1a17);
margin-bottom: 20px; margin-bottom: 20px;
} }
@@ -30,15 +27,14 @@
display: block; display: block;
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
color: #1f1a17; color: var(--color-on-surface, #1f1a17);
margin-bottom: 8px; margin-bottom: 8px;
line-height: 1.5;
} }
.legal-paragraph { .legal-paragraph {
display: block; display: block;
font-size: 13px; font-size: 13px;
line-height: 1.75; line-height: 1.7;
color: #3d3530; color: var(--color-on-surface-variant, #5c504c);
margin-bottom: 8px; margin-bottom: 8px;
} }
+30 -60
View File
@@ -234,8 +234,7 @@
flex-shrink: 0; flex-shrink: 0;
} }
.login-wechat-btn, .login-wechat-btn {
.login-phone-quick-btn {
width: 100%; width: 100%;
height: 56px; height: 56px;
border: none; border: none;
@@ -251,33 +250,52 @@
font-weight: 600; font-weight: 600;
line-height: 1; line-height: 1;
padding: 0; padding: 0;
margin: 0;
box-sizing: border-box; box-sizing: border-box;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1); 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); transform: scale(0.98);
} }
.login-wechat-btn--disabled, .login-wechat-btn--disabled {
.login-phone-quick-btn--disabled {
opacity: 0.7; opacity: 0.7;
pointer-events: none; pointer-events: none;
} }
.login-wechat-btn__text, .login-wechat-btn__text {
.login-phone-quick-btn__text {
color: #fff; color: #fff;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
line-height: 26px; line-height: 26px;
} }
/* 重置小程序 Button 默认样式,避免绿边/微信绿 */ .wechat-login-icon {
.login-phone-quick-btn::after { position: relative;
border: none; 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;
} }
.login-footer { .login-footer {
@@ -326,51 +344,3 @@
.login-agreement-link { .login-agreement-link {
color: var(--color-heritage-red); 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;
}
+7 -136
View File
@@ -46,11 +46,6 @@
cursor: pointer; cursor: pointer;
} }
.mine-profile-meta {
min-width: 0;
flex: 1;
}
.mine-avatar-status { .mine-avatar-status {
position: absolute; position: absolute;
left: 50%; left: 50%;
@@ -91,6 +86,13 @@
background: rgba(255, 255, 255, 0.25); 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 { .mine-avatar {
width: 64px; width: 64px;
height: 64px; height: 64px;
@@ -149,14 +151,6 @@
color: rgba(255, 255, 255, 0.9); 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 { .mine-main {
margin-top: -28px; margin-top: -28px;
position: relative; position: relative;
@@ -375,126 +369,3 @@
font-size: 15px; font-size: 15px;
font-weight: 600; 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;
}
+1 -5
View File
@@ -20,15 +20,11 @@
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
} }
/* 无自定义 Tab 顶栏:贴顶(标题走小程序/H5 系统导航栏) */ /* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
.page-shell.no-tab-header,
.page-shell.h5-no-tab-header { .page-shell.h5-no-tab-header {
--nav-bar-height: 0px; --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 { .page-shell.h5-no-tab-header .home-aroma-nav {
top: 0; top: 0;
} }
+1 -88
View File
@@ -7,8 +7,7 @@
min-height: 100vh; 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)); padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
} }
@@ -17,57 +16,6 @@
gap: 12px; 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 { .order-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
@@ -314,13 +262,6 @@
background: var(--color-surface-container); background: var(--color-surface-container);
margin-right: 12px; margin-right: 12px;
flex-shrink: 0; flex-shrink: 0;
overflow: hidden;
}
.order-list-thumb-img {
width: 100%;
height: 100%;
display: block;
} }
.order-list-name { .order-list-name {
@@ -330,12 +271,6 @@
margin-bottom: 4px; margin-bottom: 4px;
} }
.order-list-meta-row {
display: flex;
align-items: center;
gap: 12px;
}
.order-list-meta { .order-list-meta {
display: block; display: block;
font-size: 12px; font-size: 12px;
@@ -349,28 +284,6 @@
margin-top: 12px; margin-top: 12px;
padding-top: 12px; padding-top: 12px;
border-top: 1px solid var(--color-surface-container); 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 { .pay-status {
+10 -76
View File
@@ -47,20 +47,16 @@
color: var(--color-heritage-red); color: var(--color-heritage-red);
text-align: center; text-align: center;
box-sizing: border-box; box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
} }
.redeem-input-placeholder {
color: var(--color-subtle-gray);
text-align: center;
}
/* 小程序原生 inputtext-align 需落到组件自身与内部节点 */
.redeem-input,
.redeem-input input, .redeem-input input,
.redeem-input .taro-input, .redeem-input .taro-input,
.redeem-input .weui-input { .redeem-input .weui-input {
width: 100% !important; width: 100% !important;
height: 52px !important; height: 100% !important;
min-height: 0 !important; min-height: 0 !important;
padding: 0 !important; padding: 0 !important;
margin: 0 !important; margin: 0 !important;
@@ -69,28 +65,18 @@
box-sizing: border-box !important; box-sizing: border-box !important;
font-size: 24px !important; font-size: 24px !important;
font-weight: 700 !important; font-weight: 700 !important;
line-height: 52px !important; line-height: normal !important;
color: var(--color-heritage-red); color: inherit;
text-align: center !important; text-align: center !important;
} }
.redeem-tips { .redeem-tips {
display: block; margin: 0 var(--space-page);
box-sizing: border-box;
width: 100%;
margin: 0;
padding: 0 var(--space-page);
font-size: 12px; font-size: 12px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
line-height: 1.6; line-height: 1.6;
} }
.redeem-amount-hint {
font-size: 12px;
color: var(--color-subtle-gray);
line-height: 1.4;
}
.redeem-amount-foot { .redeem-amount-foot {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -509,70 +495,27 @@
text-align: center; 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 { .cs-title {
font-size: 14px; font-size: 14px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
margin-bottom: 12px; margin-bottom: 12px;
} }
.cs-phone, .cs-phone {
.cs-phone-display {
display: block;
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 28px; font-size: 32px;
font-weight: 700; font-weight: 700;
color: var(--color-heritage-red); color: var(--color-heritage-red);
margin-top: 20px; margin-bottom: 8px;
letter-spacing: 1px; letter-spacing: 1px;
} }
.cs-hint { .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; font-size: 12px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
margin-bottom: 32px; 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 { .cs-call-btn {
padding: 12px 32px; padding: 12px 32px;
border-radius: 999px; border-radius: 999px;
@@ -581,12 +524,3 @@
font-size: 15px; font-size: 15px;
font-weight: 600; 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;
}
-77
View File
@@ -1,77 +0,0 @@
#!/usr/bin/env node
const fs = require('fs');
const crypto = require('crypto');
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
function stripQuotes(s) {
const t = s.trim();
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1);
}
return t;
}
function parseEnvValue(text, key) {
const re = new RegExp(`^${key}=(.*)$`, 'm');
const m = text.match(re);
if (!m) return null;
return stripQuotes(m[1]);
}
function tryKey(name, key) {
const literalN = (key.match(/\\n/g) || []).length;
const realN = (key.match(/\n/g) || []).length;
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
try {
crypto.createPrivateKey(key);
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
return true;
} catch (e) {
console.log(
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
);
return false;
}
}
const text = fs.readFileSync(path, 'utf8');
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
if (!raw) {
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
process.exit(1);
}
console.log('file=', path);
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
const variants = {
as_is: raw,
unescape_n: raw.replace(/\\n/g, '\n'),
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
// dotenv style sometimes leaves surrounding quotes in process.env
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
};
let ok = false;
for (const [name, key] of Object.entries(variants)) {
if (tryKey(name, key)) ok = true;
}
// also simulate dotenv load
try {
const dotenv = require('dotenv');
const parsed = dotenv.parse(text);
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
tryKey('dotenv_as_is', fromDotenv);
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
} catch (e) {
console.log('dotenv skip', e.message);
}
process.exit(ok ? 0 : 2);
+15
View File
@@ -0,0 +1,15 @@
# 复制为 auto-release.env(勿提交 Git
# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
APP_ROOT=/opt/dukang-haoke
GIT_REMOTE=origin
GIT_BRANCH=dev
DEPLOY_GIT_REF=refs/heads/dev
# Webhook 密钥(CodeUp 配置「Secret Token」时使用同一值)
DEPLOY_WEBHOOK_SECRET=f85777004ea4435bf8b4d5813de8255d286a85d34ce2b30a
DEPLOY_WEBHOOK_HOST=127.0.0.1
DEPLOY_WEBHOOK_PORT=8095
DEPLOY_LOG_FILE=/var/log/dukang/deploy.log
DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock
+2 -2
View File
@@ -1,7 +1,7 @@
# 复制为 auto-release.env(勿提交 Git # 复制为 auto-release.env(勿提交 Git
# 服务器路径: /opt/dukang/deploy/auto-release.env # 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
APP_ROOT=/opt/dukang APP_ROOT=/opt/dukang-haoke
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
GIT_REMOTE=origin GIT_REMOTE=origin
GIT_BRANCH=dev GIT_BRANCH=dev
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$SCRIPT_DIR/auto-release.env" ENV_FILE="$SCRIPT_DIR/auto-release.env"
APP_ROOT="${APP_ROOT:-/opt/dukang}" APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
GIT_REMOTE="${GIT_REMOTE:-origin}" GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_BRANCH="${GIT_BRANCH:-dev}" GIT_BRANCH="${GIT_BRANCH:-dev}"
LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}" LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}"

Some files were not shown because too many files have changed in this diff Show More