20 Commits

Author SHA1 Message Date
ljy 56dfffd126 fix:增加小程序客服 2026-07-21 22:24:53 +08:00
jacy bdf80e577b fix(mini-user): address review blockers
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:03:02 +08:00
jacy eb96b36d0b feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:41:06 +08:00
jacy 36bec94639 fix(h5): temporarily disable WeChat camera flows
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:18:51 +08:00
jacy c11c7647b9 fix(h5): serve assets from dedicated domain roots
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:51:23 +08:00
jacy 83ed90ef67 feat(h5-auth): remember SMS sessions and simplify media access
CI / verify (pull_request) Has been cancelled
Hide WeChat login for shop and partner users, keep verified sessions for seven days, and allow JSSDK camera or album access without an OpenID binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:41:17 +08:00
jacy 73dbf6effb chore(deploy): stop tracking auto-release.env; feat(mini-user): avatar profile sheet
CI / verify (pull_request) Has been cancelled
Remove secrets file from the index (keep local). Mine page can choose avatar and nickname via WeChat components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:31:07 +08:00
jacy b8adfe98a3 ignore 2026-07-21 10:19:09 +08:00
jacy 6c6fb10490 fix(deploy): correct APP_ROOT to /opt/dukang
CI / verify (pull_request) Has been cancelled
Server path is /opt/dukang; stale /opt/dukang-haoke broke PM2 cwd for mini-user H5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:10:35 +08:00
jacy 935ab0d1d4 微信小程序审核不通过修改
CI / verify (pull_request) Has been cancelled
2026-07-21 09:32:06 +08:00
jacy 350a086a73 微信小程序端登录和头像调整 2026-07-21 08:59:44 +08:00
jacy c5f526d51d 我的页面中的头像 2026-07-21 08:10:52 +08:00
jacy e1f5130c18 页面顶部空白调整 2026-07-21 08:07:07 +08:00
jacy bc0b0cafd6 微信小程序支付遇到问题,因为没有 WX_MCH_PRIVATE_KEY 2026-07-21 07:59:57 +08:00
jacy ade68972a9 微信小程序端修改config服务器地址配置 2026-07-21 07:35:39 +08:00
jacy 47b39e3ba0 迁移的一些脚本文件 2026-07-21 00:01:50 +08:00
jacy cf73bbae23 迁移的一些脚本文件 2026-07-21 00:01:45 +08:00
jacy d1b207422c fix(api): add TICKET_COLLAB to EventType enum
CI / verify (pull_request) Has been cancelled
Unblock nest build for after-sale ticket collaboration events.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 21:41:44 +08:00
jacy 4a29d8297c Merge commit '792b543ba88acda4186bd823461ef1bb76d12f5b' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-20 21:37:55 +08:00
ljy 792b543ba8 fix;提交、 2026-07-19 22:16:55 +08:00
119 changed files with 4417 additions and 1958 deletions
+1
View File
@@ -15,3 +15,4 @@ coverage/
server/dukang-api/prisma/migrations/
debug_v3.xlsx
~$debug_v3.xlsx
deploy/auto-release.env
+2
View File
@@ -31,6 +31,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import TicketsPage from './pages/TicketsPage';
import InvoicesPage from './pages/InvoicesPage';
import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage';
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
@@ -89,6 +90,7 @@ export default function App() {
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/invoices" element={<InvoicesPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
<Route path="/logs/partners" element={<PartnerLogsPage />} />
@@ -91,6 +91,7 @@ const MENU_ITEMS: MenuProps['items'] = [
],
},
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
{ key: '/invoices', icon: <FileTextOutlined />, label: '发票管理' },
{ key: '/resources', icon: <CloudUploadOutlined />, label: 'OSS 资源库' },
{
key: 'logs-group',
+247
View File
@@ -0,0 +1,247 @@
import { useState } from 'react';
import {
Button,
Descriptions,
Drawer,
Form,
Input,
Select,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceStatus,
type InvoiceTitleType,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import { uploadFileToOss } from '../lib/upload';
type Row = {
id: string;
invoiceNo: string;
orderNo?: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
status: InvoiceStatus;
overdue?: boolean;
createdAt: string;
fileUrl?: string;
email?: string;
phone?: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
payAmount?: string;
userPhone?: string;
remark?: string;
};
export default function InvoicesPage() {
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/invoices',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [uploading, setUploading] = useState(false);
async function openDetail(id: string) {
setDetail(await request(`/admin/invoices/${id}`));
setDrawerOpen(true);
}
async function issueWithFile(file: File) {
if (!detail) return false;
setUploading(true);
try {
const uploaded = await uploadFileToOss(file, { bizType: 'invoice' });
await request(`/admin/invoices/${detail.id}/issue`, {
method: 'POST',
body: JSON.stringify({ fileUrl: uploaded.url }),
});
message.success('已开票回传');
reload();
setDrawerOpen(false);
} catch (e) {
message.error(e instanceof Error ? e.message : '开票失败');
} finally {
setUploading(false);
}
return false;
}
async function reject() {
if (!detail) return;
await request(`/admin/invoices/${detail.id}/reject`, {
method: 'POST',
body: JSON.stringify({ remark: '驳回' }),
});
message.success('已驳回');
reload();
setDrawerOpen(false);
}
const columns: ColumnsType<Row> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
{
title: '抬头',
width: 100,
render: (_, r) => INVOICE_TITLE_TYPE_LABELS[r.titleType] ?? r.titleType,
},
{
title: '票种',
width: 120,
render: (_, r) => INVOICE_KIND_LABELS[r.invoiceKind] ?? r.invoiceKind,
},
{ title: '名称', dataIndex: 'titleName', ellipsis: true },
{
title: '状态',
width: 110,
render: (_, r) => (
<>
<Tag color={r.status === 'ISSUED' ? 'green' : r.status === 'REJECTED' ? 'red' : 'orange'}>
{INVOICE_STATUS_LABELS[r.status] ?? r.status}
</Tag>
{r.overdue ? <Tag color="red"></Tag> : null}
</>
),
},
{ title: '申请时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
),
},
];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'PENDING', label: '待开票' },
{ value: 'ISSUED', label: '已开票' },
{ value: 'REJECTED', label: '已驳回' },
]}
/>
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1000 }}
rowClassName={(r) => (r.overdue ? 'ant-table-row-overdue' : '')}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="发票详情"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<>
<Upload
accept="image/*,.pdf"
showUploadList={false}
beforeUpload={(file) => {
void issueWithFile(file);
return false;
}}
>
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="申请单号">{detail.invoiceNo}</Descriptions.Item>
<Descriptions.Item label="订单号">{detail.orderNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="金额">{detail.payAmount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="用户手机">{detail.userPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="抬头类型">
{INVOICE_TITLE_TYPE_LABELS[detail.titleType]}
</Descriptions.Item>
<Descriptions.Item label="发票类型">
{INVOICE_KIND_LABELS[detail.invoiceKind]}
</Descriptions.Item>
<Descriptions.Item label="抬头名称">{detail.titleName}</Descriptions.Item>
<Descriptions.Item label="税号">{detail.taxNo ?? '—'}</Descriptions.Item>
<Descriptions.Item label="地址电话">{detail.addressPhone ?? '—'}</Descriptions.Item>
<Descriptions.Item label="开户行账号">{detail.bankAccount ?? '—'}</Descriptions.Item>
<Descriptions.Item label="邮箱">{detail.email}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="状态">
{INVOICE_STATUS_LABELS[detail.status]}
{detail.overdue ? '(超 2 工作日)' : ''}
</Descriptions.Item>
<Descriptions.Item label="发票文件">
{detail.fileUrl ? (
<a href={detail.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : (
'—'
)}
</Descriptions.Item>
<Descriptions.Item label="备注">{detail.remark ?? '—'}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</div>
);
}
+102 -28
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Table, Typography, message } from 'antd';
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -8,11 +9,12 @@ import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
ticketNo: string;
ticketType: string;
ticketType: TicketTypeDto;
status: string;
refType: string;
refId: string;
remark?: string;
extraJson?: { evidenceUrls?: string[] };
createdAt: string;
};
@@ -28,7 +30,7 @@ export default function TicketsPage() {
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
async function approve(id: string) {
@@ -50,52 +52,124 @@ export default function TicketsPage() {
const columns: ColumnsType<Row> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{ title: '类型', dataIndex: 'ticketType', width: 100 },
{
title: '类型',
dataIndex: 'ticketType',
width: 110,
render: (t: TicketTypeDto) => TICKET_TYPE_LABELS[t] ?? t,
},
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}></Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/tickets/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
const evidenceUrls = detail?.extraJson?.evidenceUrls ?? [];
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="ticketType" label="类型">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'REFUND', label: '退款' },
{ value: 'RESHIPMENT', label: '补发' },
{ value: 'ALERT', label: '异常' },
]} />
<Select
allowClear
style={{ width: 140 }}
options={[
{ value: 'REFUND', label: '仅退款' },
{ value: 'RESHIPMENT', label: '破损补发' },
{ value: 'DAMAGE_RETURN', label: '破损退货' },
{ value: 'RETURN_REFUND', label: '退货退款' },
{ value: 'ALERT', label: '异常' },
]}
/>
</Form.Item>
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
<Form.Item name="status" label="状态">
<Input allowClear placeholder="PENDING" />
</Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}></Button>
<Button danger onClick={() => reject(String(detail.id))}></Button>
</>
) : null}>
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 900 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="工单详情"
width={480}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</>
) : null
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
<Descriptions.Item label="类型">
{TICKET_TYPE_LABELS[detail.ticketType] ?? detail.ticketType}
</Descriptions.Item>
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
<Descriptions.Item label="关联">
{String(detail.refType)} #{String(detail.refId)}
</Descriptions.Item>
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
<Descriptions.Item label="凭证">
{evidenceUrls.length ? (
<Image.PreviewGroup>
{evidenceUrls.map((url) => (
<Image key={url} src={url} width={72} style={{ marginRight: 8 }} />
))}
</Image.PreviewGroup>
) : (
'—'
)}
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
+1 -5
View File
@@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router-dom';
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
import { getPartnerProfile } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess';
@@ -18,10 +18,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
}
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getPartnerProfile();
if (profile && hasPartnerWxSession() && profile.hasWechat) {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />;
}
+22 -157
View File
@@ -1,17 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useRef, useState } from 'react';
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
import { enqueueUpload } from '../lib/upload-lock';
import {
authorizePartnerWechat,
fetchClientConfig,
fetchPartnerProfile,
needsWechatAuth,
type PartnerProfile,
} from '../lib/wechat-auth';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast';
import type { ClientRuntimeConfig } from '@dukang/shared-types';
type OssUploadFieldProps = {
value?: string;
@@ -22,27 +12,10 @@ type OssUploadFieldProps = {
wide?: boolean;
compact?: boolean;
label?: string;
/** 父级已确认微信授权时可跳过检查 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
};
const DEFAULT_MAX_MB = 10;
function formatWechatUploadError(e: unknown): string {
const msg = e instanceof Error ? e.message : '无法打开相册';
const formatted = formatChooseImageFailMessage(msg);
if (formatted) return formatted;
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
}
return msg;
}
function acceptsImages(accept: string) {
return accept.includes('image');
}
export default function OssUploadField({
value,
onChange,
@@ -52,15 +25,10 @@ export default function OssUploadField({
wide,
compact,
label,
wechatReady,
onWechatReadyChange,
}: OssUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [authorizing, setAuthorizing] = useState(false);
const [error, setError] = useState('');
const [profile, setProfile] = useState<PartnerProfile | null>(null);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
function showUploadError(text: string) {
setError(text);
@@ -69,35 +37,6 @@ export default function OssUploadField({
const resolvedAccept =
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
const inWechat = isWechatEnv();
const useWechatPicker =
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
onWechatReadyChangeRef.current = onWechatReadyChange;
useEffect(() => {
if (!useWechatPicker) return;
void fetchClientConfig()
.then(setClientConfig)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
if (wechatReady === true) return;
void fetchPartnerProfile()
.then(setProfile)
.catch(() => {
/* 未登录等场景由上传接口报错 */
});
}, [useWechatPicker, wechatReady]);
useEffect(() => {
if (!useWechatPicker || needsAuth) return;
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */
});
}, [useWechatPicker, needsAuth]);
async function persistUpload(file: File) {
if (!file.size) {
@@ -125,112 +64,38 @@ export default function OssUploadField({
}
}
async function startWechatAuth() {
setAuthorizing(true);
function pickFile() {
if (uploading) return;
setError('');
try {
const result = await authorizePartnerWechat();
if (result) {
const me = await fetchPartnerProfile();
setProfile(me);
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
}
} catch (e) {
const text = e instanceof Error ? e.message : '微信授权失败';
showUploadError(text);
} finally {
setAuthorizing(false);
}
}
async function pickWechatImage() {
setUploading(true);
setError('');
try {
weixinSdk.reset();
await weixinSdk.init();
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
await enqueueUpload(async () => {
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (!files?.[0]) return;
await persistUpload(files[0]);
});
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
} finally {
setUploading(false);
}
}
async function pickFile() {
if (uploading || authorizing) return;
setError('');
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
showUploadError(text);
return;
}
if (useWechatPicker) {
try {
await pickWechatImage();
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e));
}
return;
}
inputRef.current?.click();
}
const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value;
const busy = uploading || authorizing;
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const busy = uploading;
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
const triggerProps = {
type: 'button' as const,
disabled: busy || needsAuth,
onClick: () => void pickFile(),
disabled: busy,
onClick: pickFile,
};
return (
<div className="partner-oss-upload">
{needsAuth && (
<div className="partner-wechat-auth-hint" role="status">
<p className="body-md"></p>
<button
type="button"
className="partner-btn-outline"
style={{ marginTop: 8, width: '100%' }}
disabled={authorizing}
onClick={() => void startWechatAuth()}
>
{authorizing ? '跳转授权中…' : '微信授权绑定'}
</button>
</div>
)}
{!useWechatPicker && (
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadSelectedFile(file);
}}
/>
)}
<input
ref={inputRef}
type="file"
accept={resolvedAccept}
className="partner-oss-upload-input"
disabled={busy}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
void uploadSelectedFile(file);
}
}}
/>
{isImage ? (
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
<img src={value} alt={label ?? '已上传'} />
@@ -255,10 +120,10 @@ export default function OssUploadField({
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
>
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
{busy ? 'hourglass_top' : 'add_a_photo'}
{busy ? 'hourglass_top' : 'photo_library'}
</span>
<span className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
{uploading ? '上传中…' : pickerLabel}
</span>
</button>
)}
+9 -4
View File
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
export const PARTNER_WX_BOUND = 'partnerWxBound';
/** 微信验证通过后的免登录时长 */
/** 手机号或微信验证通过后的免登录时长 */
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -77,7 +77,7 @@ export function isPartnerSessionExpired() {
}
export function touchPartnerSession() {
if (!hasPartnerWxSession()) return;
if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
@@ -90,11 +90,16 @@ export function saveAuth(data: PartnerSessionPayload) {
}
}
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: PartnerSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: PartnerSessionPayload) {
saveAuth(data);
saveRememberedSession(data);
localStorage.setItem(PARTNER_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
}
export function clearAuth(options?: { keepProfile?: boolean }) {
+8 -174
View File
@@ -1,25 +1,14 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
import { useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import {
getLastPhone,
getPartnerProfile,
hasPartnerWxSession,
request,
saveAuth,
saveRememberedSession,
type PartnerSessionPayload,
} from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { partnerHomePath } from '../lib/partnerAccess';
import {
bindPartnerWechatAfterSmsLogin,
canPartnerUseWechatLogin,
fetchClientConfig,
loginPartnerWithWechat,
PARTNER_WECHAT_LOGIN_HINT,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
const REMEMBER_FLAG_KEY = 'partner_remember_account';
@@ -31,7 +20,7 @@ function AgreementCheckbox({
}: {
agreed: boolean;
onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement | null>;
inputRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
@@ -50,11 +39,6 @@ function AgreementCheckbox({
);
}
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function loadRememberedPhone(): { phone: string; remember: boolean } {
try {
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
@@ -76,19 +60,9 @@ function formatPartnerError(e: unknown): string {
return text;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
export default function LoginPage() {
const navigate = useNavigate();
const { applySession, refresh, account } = usePartnerSession();
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getPartnerProfile();
const remembered = loadRememberedPhone();
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
@@ -96,22 +70,10 @@ export default function LoginPage() {
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
@@ -175,14 +137,9 @@ export default function LoginPage() {
body: JSON.stringify({ phone, code }),
silent: true,
});
saveAuth(data);
saveRememberedSession(data);
applySession(data);
persistRememberAccount(phone);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindPartnerWechatAfterSmsLogin();
return;
}
await finishLoginNavigate();
} catch (e) {
setMsg(formatPartnerError(e));
@@ -191,116 +148,6 @@ export default function LoginPage() {
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg(WECHAT_INAPP_REQUIRED_MSG);
return;
}
const canUseWechat = await canPartnerUseWechatLogin({
profile: savedProfile,
phone,
});
if (!canUseWechat) {
setMsg(PARTNER_WECHAT_LOGIN_HINT);
return;
}
setWxLoading(true);
try {
const session = await loginPartnerWithWechat();
if (session) {
applySession(session);
await finishLoginNavigate();
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick =
wxAuthorize &&
isWechatEnv() &&
hasPartnerWxSession() &&
!!savedProfile &&
savedProfile.hasWechat === true;
return (
<div className="partner-auth-page partner-auth-page--quick">
<header className="partner-auth-brand">
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
</div>
<h1 className="partner-auth-title" style={{ fontSize: 20 }}></h1>
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}></p>
</header>
<section className="partner-glass-card">
<div className="partner-quick-badge"></div>
<div className="partner-quick-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ textAlign: 'center' }}>
<h2 className="headline-md">
{quickName}
{quickCompany ? (
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
) : null}
</h2>
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
</p>
</div>
</section>
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
style={{ marginTop: 16 }}
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
</Link>
)}
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}></Link>
</nav>
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
</span>
</div>
</footer>
</div>
);
}
return (
<div className="partner-auth-page">
<div className="partner-auth-brand">
@@ -367,25 +214,12 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{wxAuthorize && (
<>
<div className="partner-auth-divider"><span></span></div>
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
</svg>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
7
</p>
</div>
</main>
{hasPartnerWxSession() && savedProfile?.hasWechat && (
<Link to="/login?quick=1" className="partner-link"></Link>
)}
<footer className="partner-auth-footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className="material-symbols-outlined">verified_user</span>
+4 -35
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -17,8 +17,6 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import {
@@ -79,16 +77,10 @@ export default function StoreCreatePage() {
const navigate = useNavigate();
const { account, refresh } = usePartnerSession();
const { account } = usePartnerSession();
const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
const handleWechatReadyChange = useCallback(() => {
void refresh();
}, [refresh]);
const [params, setParams] = useSearchParams();
const saved = loadStoreDraft(accountId);
@@ -142,17 +134,6 @@ export default function StoreCreatePage() {
useEffect(() => {
if (step !== 2 || !isWechatEnv()) return;
void refresh();
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* OssUploadField 点击时会再次初始化 */
});
}, [step, refresh]);
useEffect(() => {
void fetchPartnerCities()
@@ -895,13 +876,9 @@ export default function StoreCreatePage() {
value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })}
label="点击或拖拽上传"
label="从系统相册选择"
/>
@@ -931,10 +908,6 @@ export default function StoreCreatePage() {
value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/>
@@ -951,7 +924,7 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}></p>
<OssUploadField
@@ -963,10 +936,6 @@ export default function StoreCreatePage() {
value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本"
@@ -47,7 +47,6 @@ export default function StoreDetailPage() {
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
@@ -284,10 +283,8 @@ export default function StoreDetailPage() {
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl}
label="点击更换门头照"
label="从系统相册选择"
/>
</div>
)}
@@ -336,8 +333,6 @@ export default function StoreDetailPage() {
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
+2 -1
View File
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/partner/',
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()],
resolve: {
alias: {
+1 -2
View File
@@ -5,7 +5,6 @@ import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
import RecordsPage from './pages/RecordsPage';
@@ -22,7 +21,7 @@ export default function App() {
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
<Route path="/select-store" element={<SelectStorePage />} />
<Route path="/staff" element={<StaffPage />} />
<Route path="/redeem" element={<RedeemConfirmPage />} />
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}>
-5
View File
@@ -1,5 +1,4 @@
import { Navigate, useLocation } from 'react-router-dom';
import { getStoreProfile, hasShopWxSession } from '../lib/api';
import { useStoreSession } from '../contexts/StoreSessionContext';
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
@@ -26,10 +25,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
}
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
const profile = getStoreProfile();
if (profile && hasShopWxSession() && location.pathname !== '/login') {
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
}
return <Navigate to="/login" replace state={{ from: location }} />;
}
@@ -19,6 +19,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
const [photoResourceId, setPhotoResourceId] = useState('');
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
const [msg, setMsg] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
async function handleFile(file: File) {
setUploading(true);
@@ -49,7 +50,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
}
} catch (e) {
const text = e instanceof Error ? e.message : '选图失败';
if (!/cancel/i.test(text)) setMsg(text);
if (!/cancel/i.test(text)) {
setMsg(`${text},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
} finally {
setUploading(false);
}
@@ -120,11 +125,13 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
if (file) {
setShowAlbumFallback(false);
void handleFile(file);
}
}}
/>
{previewUrl && (
@@ -134,6 +141,16 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
</button>
{showAlbumFallback && isWechatEnv() && (
<button
type="button"
className="shop-btn-outline"
disabled={uploading}
onClick={() => inputRef.current?.click()}
>
</button>
)}
<button
type="button"
className="shop-redeem-confirm-btn"
+9 -4
View File
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
export const SHOP_WX_BOUND = 'shopWxBound';
/** 微信验证通过后的免登录时长 */
/** 手机号或微信验证通过后的免登录时长 */
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const AUTH_RECOVERY_EXEMPT_PATHS = [
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
}
export function touchShopSession() {
if (!hasShopWxSession()) return;
if (!localStorage.getItem(REFRESH_TOKEN)) return;
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
@@ -119,11 +119,16 @@ export function saveAuth(data: ShopSessionPayload) {
}
}
/** 手机号验证成功后写入 7 天免验证码会话 */
export function saveRememberedSession(data: ShopSessionPayload) {
saveAuth(data);
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
/** 微信登录/绑定成功后写入 7 天免登录 session */
export function saveWechatSession(data: ShopSessionPayload) {
saveAuth(data);
saveRememberedSession(data);
localStorage.setItem(SHOP_WX_BOUND, '1');
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
}
export function clearAuth(options?: { keepProfile?: boolean }) {
+4 -137
View File
@@ -1,47 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { Link, useNavigate } from 'react-router-dom';
import { request } from '../lib/api';
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
import {
authorizeShopWechat,
checkNeedsWechatAuth,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import WechatScanAuthModal from '../components/WechatScanAuthModal';
const PENDING_SCAN_KEY = 'shop_pending_scan';
function formatMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
function formatScanError(e: unknown): string {
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
if (/invalid signature/i.test(msg)) {
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
}
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
}
return msg;
}
export default function HomePage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [open, setOpen] = useState(true);
const [scanMsg, setScanMsg] = useState('');
const [scanning, setScanning] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [authLoading, setAuthLoading] = useState(false);
const [authError, setAuthError] = useState('');
const loadDashboard = useCallback(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
@@ -58,7 +26,6 @@ export default function HomePage() {
useEffect(() => {
function onResume() {
setScanning(false);
void loadDashboard();
}
function onVisibility() {
@@ -74,88 +41,6 @@ export default function HomePage() {
};
}, [loadDashboard]);
useEffect(() => {
if (!isWechatEnv() || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
}
setAuthModalOpen(false);
setAuthError('');
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
sessionStorage.removeItem(PENDING_SCAN_KEY);
if (shouldScan) {
window.setTimeout(() => void runScan(), 0);
}
})
.catch((e) => {
setAuthError(e instanceof Error ? e.message : '微信授权失败');
});
}, [searchParams, applySession, setSearchParams]);
async function runScan() {
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
setScanning(true);
setScanMsg('');
try {
await weixinSdk.init();
const raw = await weixinSdk.scanQrCode();
if (!raw) {
void loadDashboard();
return;
}
const token = parseRedeemTokenFromScan(raw);
if (!token) {
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
return;
}
navigate(`/redeem?token=${encodeURIComponent(token)}`);
} catch (e) {
setScanMsg(formatScanError(e));
} finally {
setScanning(false);
}
}
async function handleScan() {
setScanMsg('');
if (!isWechatEnv()) {
setScanMsg('请在微信内打开门店端进行扫码核销');
return;
}
try {
const profile = await fetchShopAccount();
if (await checkNeedsWechatAuth(profile)) {
setAuthModalOpen(true);
return;
}
await runScan();
} catch (e) {
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
}
}
async function startWechatAuth() {
setAuthLoading(true);
setAuthError('');
try {
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
await authorizeShopWechat();
} catch (e) {
sessionStorage.removeItem(PENDING_SCAN_KEY);
setAuthError(e instanceof Error ? e.message : '微信授权失败');
setAuthLoading(false);
}
}
const store = dash?.store as Record<string, unknown> | undefined;
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00');
@@ -189,20 +74,13 @@ export default function HomePage() {
</section>
<section className="shop-home-scan">
<button
type="button"
<Link
to="/redeem/phone"
className="shop-home-scan-btn"
disabled={scanning}
onClick={() => void handleScan()}
>
<span className="material-symbols-outlined">qr_code_scanner</span>
</button>
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
<Link to="/redeem/phone" className="shop-home-phone-link">
<span className="material-symbols-outlined">smartphone</span>
</Link>
<p className="shop-home-scan-label"></p>
</section>
<section className="shop-home-status">
@@ -249,17 +127,6 @@ export default function HomePage() {
</section>
</div>
<WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
);
}
+13 -185
View File
@@ -1,32 +1,14 @@
import { useEffect, useRef, useState, type RefObject } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useRef, useState, type RefObject } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
import {
bindShopWechatAfterSmsLogin,
fetchClientConfig,
handleShopWechatCallback,
handleShopWechatLoginResult,
loginShopWithWechat,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
}
getLastPhone,
request,
saveRememberedSession,
type ShopSessionPayload,
} from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
function ShopAgreementCheckbox({
agreed,
@@ -35,7 +17,7 @@ function ShopAgreementCheckbox({
}: {
agreed: boolean;
onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement | null>;
labelRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="shop-login-agreement" ref={labelRef}>
@@ -61,44 +43,14 @@ function ShopAgreementCheckbox({
export default function LoginPage() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [params, setSearchParams] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState('');
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [msg, setMsg] = useState('');
const [codeCooldown, setCodeCooldown] = useState(0);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
routeAfterShopLogin(session, navigate);
}
})
.catch((e) => setMsg(formatWechatError(e)));
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
const quickPhone = savedProfile?.phone || phone;
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并同意用户协议');
@@ -141,13 +93,8 @@ export default function LoginPage() {
method: 'POST',
body: JSON.stringify({ phone, code }),
});
saveAuth(data);
saveRememberedSession(data);
applySession(data);
if (isWechatEnv() && wxAuthorize) {
setMsg('登录成功,正在关联微信…');
await bindShopWechatAfterSmsLogin();
return;
}
routeAfterShopLogin(data, navigate);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
@@ -156,103 +103,6 @@ export default function LoginPage() {
}
}
async function wechatLogin() {
if (!ensureAgreed()) return;
setMsg('');
if (!isWechatEnv()) {
setMsg('请在微信内打开以使用微信一键登录');
return;
}
setWxLoading(true);
try {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
routeAfterShopLogin(session, navigate);
}
} catch (e) {
setMsg(formatWechatError(e));
} finally {
setWxLoading(false);
}
}
if (quick) {
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
return (
<div className="shop-quick-login-page">
<header className="shop-quick-header">
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
<h1 className="shop-quick-welcome"></h1>
<div className="shop-quick-welcome-line" />
</header>
<section className="shop-quick-store-card">
<div className="shop-quick-store-inner">
<div className="shop-quick-store-icon">
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
</div>
<div>
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
</div>
<span className="shop-quick-verified">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
</span>
<div className="shop-quick-switch">
<Link to="/login">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
</Link>
</div>
</div>
</section>
<div className="shop-quick-actions">
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
{canWechatQuick ? (
<button
type="button"
className="shop-quick-login-btn shop-quick-login-btn--wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
{wxAuthorize && !isWechatEnv()
? '请在微信内打开以使用一键登录'
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
</p>
)}
{!canWechatQuick && (
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
</Link>
)}
<div className="shop-quick-secure">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
</div>
</div>
<footer className="shop-quick-footer">
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 </p>
</footer>
</div>
);
}
return (
<div className="shop-login-page">
<header className="shop-login-hero">
@@ -322,26 +172,9 @@ export default function LoginPage() {
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{wxAuthorize && (
<>
<div className="shop-login-divider">
<span className="shop-login-divider-line" />
<span className="shop-login-divider-text"></span>
<span className="shop-login-divider-line" />
</div>
<button
type="button"
className="shop-login-wechat"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
</>
)}
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
7
</p>
</div>
</main>
@@ -350,11 +183,6 @@ export default function LoginPage() {
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
security
</span>
{hasShopWxSession() && savedProfile && (
<p style={{ marginTop: 16, textAlign: 'center' }}>
<Link to="/login?quick=1" className="text-primary body-md"></Link>
</p>
)}
</footer>
</div>
);
+85 -210
View File
@@ -1,33 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() {
const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState('');
const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = useState('');
const [amount, setAmount] = useState('');
const [balance, setBalance] = useState<BalanceResult | null>(null);
const [confirmCode, setConfirmCode] = useState('');
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
const [storeName, setStoreName] = useState('');
const [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => {
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => {
if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [confirmCooldown]);
async function sendLookupSms() {
async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号');
return;
}
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
});
setLookupCooldown(60);
setMsg('验证码已发送至用户手机');
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
} finally {
setLoading(false);
}
}
async function queryBalance() {
if (!lookupCode.trim()) {
setMsg('请输入验证码');
return;
}
setLoading(true);
setMsg('');
try {
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
});
setBalance(res);
setStep('amount');
setMsg('');
} catch (e) {
setMsg(e instanceof Error ? e.message : '查询失败');
} finally {
setLoading(false);
}
}
async function prepareRedeem() {
if (storeClosed) {
setMsg('门店未营业,无法核销');
return;
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额');
return;
}
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
method: 'POST',
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
body: JSON.stringify({ phone: phone.trim(), amount: value }),
});
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60);
setStep('confirm');
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发起核销失败');
setPrepared(null);
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally {
setLoading(false);
}
}
async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) {
setMsg('请输入确认验证码');
return;
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST',
body: JSON.stringify({
sessionId: balance?.sessionId,
sessionId: prepared.sessionId,
code: confirmCode.trim(),
}),
});
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', {
state: { result, storeName, user: balance?.user },
state: { result, storeName, user: prepared.user },
});
} catch (e) {
setMsg(e instanceof Error ? e.message : '核销失败');
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
}
}
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
const amountValue = Number(amount);
const canSendCode =
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
return (
<div className="shop-redeem-page">
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
</div>
<div className="shop-redeem-body">
{step === 'lookup' && (
<>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="tel"
maxLength={11}
placeholder="请输入用户手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
className="shop-phone-input"
type="text"
maxLength={6}
placeholder="用户收到的验证码"
value={lookupCode}
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="button"
className="shop-phone-code-btn"
disabled={loading || lookupCooldown > 0 || !phone.trim()}
onClick={() => void sendLookupSms()}
>
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
</button>
</div>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void queryBalance()}
>
</button>
</>
)}
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="tel"
maxLength={11}
placeholder="请输入用户手机号"
value={phone}
disabled={loading}
onChange={(e) => {
setPhone(e.target.value.replace(/\D/g, ''));
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
{step === 'amount' && balance && (
<>
<div className="shop-redeem-user">
<div className="shop-redeem-user-left">
<span className="material-symbols-outlined">person</span>
<span></span>
</div>
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
{userLabel}
</span>
</div>
<div className="shop-redeem-amount-section">
<p className="shop-redeem-amount-label"></p>
<div className="shop-redeem-amount">
<span className="shop-redeem-amount-symbol">¥</span>
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
</div>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="number"
min={0.01}
step={0.01}
placeholder="请输入核销金额"
value={amount}
onChange={(e) => setAmount(e.target.value)}
/>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || balance.totalBalance <= 0}
onClick={() => void prepareRedeem()}
>
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('lookup');
setBalance(null);
setAmount('');
setLookupCode('');
}}
>
</button>
</>
)}
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="number"
min={0.01}
step={0.01}
placeholder="请输入待核销金额"
value={amount}
disabled={loading}
onChange={(e) => {
setAmount(e.target.value);
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
{step === 'confirm' && balance && (
<>
<div className="shop-redeem-details">
<div className="shop-redeem-detail-row">
<span></span>
<span>{userLabel}</span>
</div>
<div className="shop-redeem-detail-row">
<span></span>
<span>¥{formatAmount(Number(amount))}</span>
</div>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="text"
maxLength={6}
placeholder="用户手机收到的确认码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
</p>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
className="shop-phone-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="输入用户收到的验证码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void confirmRedeem()}
className="shop-phone-code-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
onClick={() => void sendConfirmSms()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('amount');
setConfirmCode('');
}}
>
</button>
</>
)}
</div>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
</p>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>}
</div>
+2 -1
View File
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/shop/',
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()],
resolve: {
alias: {
+2
View File
@@ -0,0 +1,2 @@
# 企业微信客服链接(覆盖 shared-types 默认值)
# VITE_CS_WECOM_URL=https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd
+8
View File
@@ -20,6 +20,10 @@ import RedeemCodePage from './pages/RedeemCodePage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
import PayPage from './pages/PayPage';
import CustomerServicePage from './pages/CustomerServicePage';
import AfterSalePage from './pages/AfterSalePage';
import AfterSaleListPage from './pages/AfterSaleListPage';
import InvoiceApplyPage from './pages/InvoiceApplyPage';
import InvoiceListPage from './pages/InvoiceListPage';
import { UserSessionProvider } from './contexts/UserSessionContext';
import { capturePromoFromUrl } from './lib/promo';
import WechatShareBootstrap from './components/WechatShareBootstrap';
@@ -51,6 +55,10 @@ export default function App() {
<Route path="/order/confirm" element={<OrderConfirmPage />} />
<Route path="/pay" element={<PayPage />} />
<Route path="/customer-service" element={<CustomerServicePage />} />
<Route path="/after-sale" element={<AfterSalePage />} />
<Route path="/after-sale/list" element={<AfterSaleListPage />} />
<Route path="/invoices" element={<InvoiceListPage />} />
<Route path="/invoices/apply" element={<InvoiceApplyPage />} />
<Route path="/addresses" element={<AddressListPage />} />
<Route path="/addresses/new" element={<AddressEditPage />} />
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import { track } from '../lib/analytics';
import { openWecomCustomerService } from '../lib/customer-service';
type ContactCustomerSheetProps = {
orderId?: string;
@@ -8,8 +8,7 @@ type ContactCustomerSheetProps = {
onClose: () => void;
};
export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
const navigate = useNavigate();
export default function ContactCustomerSheet({ orderId, onClose }: ContactCustomerSheetProps) {
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function openPhone() {
@@ -18,13 +17,11 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
onClose();
}
function openChat() {
track('cs_contact', { type: 'chat', orderId });
const qs = new URLSearchParams();
if (orderId) qs.set('orderId', orderId);
if (orderNo) qs.set('orderNo', orderNo);
onClose();
navigate(`/customer-service?${qs.toString()}`);
function openOnline() {
track('cs_contact', { type: 'wecom_kf', orderId });
if (openWecomCustomerService()) {
onClose();
}
}
return (
@@ -49,7 +46,7 @@ export default function ContactCustomerSheet({ orderId, orderNo, onClose }: Cont
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
</button>
<button type="button" className="contact-customer-option" onClick={openChat}>
<button type="button" className="contact-customer-option" onClick={openOnline}>
<div className="contact-customer-option-icon">
<span className="material-symbols-outlined">chat</span>
</div>
+23
View File
@@ -0,0 +1,23 @@
import { CUSTOMER_SERVICE_PHONE, CUSTOMER_SERVICE_WECOM_URL } from '@dukang/shared-types';
import { isWechatEnv } from './weixin';
/** 企微客服链接:优先 Vite env,否则 shared-types 默认 */
export function getCustomerServiceWecomUrl(): string {
const fromEnv = import.meta.env.VITE_CS_WECOM_URL?.trim();
return fromEnv || CUSTOMER_SERVICE_WECOM_URL;
}
/**
* 打开企业微信客服会话(须在微信内;需用户点击手势)。
* @returns true 已跳转;false 非微信环境已提示
*/
export function openWecomCustomerService(): boolean {
if (!isWechatEnv()) {
window.alert('请在微信中打开以联系在线客服');
return false;
}
window.location.href = getCustomerServiceWecomUrl();
return true;
}
export { CUSTOMER_SERVICE_PHONE };
+37
View File
@@ -0,0 +1,37 @@
import { apiBase } from './api';
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
/** 经 API 服务端转存 OSS */
export async function uploadFileToOss(
file: File,
options: { bizType: string; mediaType?: OssMediaType },
): Promise<UploadFileResult> {
const mediaType = options.mediaType ?? (file.type.startsWith('video/') ? 'VIDEO' : 'IMAGE');
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', options.bizType);
formData.append('mediaType', mediaType);
const headers: Record<string, string> = {
'X-Client-App': 'USER_H5',
};
const token = localStorage.getItem('accessToken');
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '上传失败');
return json.data as UploadFileResult;
}
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type TicketRow = {
id: string;
ticketNo: string;
ticketType: TicketTypeDto;
status: string;
orderNo?: string;
remark?: string;
createdAt: string;
};
const STATUS_LABEL: Record<string, string> = {
PENDING: '待处理',
OPEN: '处理中',
RESOLVED: '已完成',
REJECTED: '已驳回',
CLOSED: '已关闭',
};
export default function AfterSaleListPage() {
const navigate = useNavigate();
const [items, setItems] = useState<TicketRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<{ items: TicketRow[] }>('USER_H5', '/trade/after-sale-tickets?pageSize=50')
.then((res) => setItems(res.items ?? []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, []);
return (
<div className="after-sale-page">
<SubPageHeader title="我的售后" onBack={() => navigate(-1)} />
<main className="after-sale-body">
{loading ? (
<p className="after-sale-empty"></p>
) : items.length === 0 ? (
<div className="after-sale-empty-box">
<p className="after-sale-empty"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
</button>
</div>
) : (
<div className="after-sale-order-list">
{items.map((t) => (
<div key={t.id} className="after-sale-order-item after-sale-ticket-card">
<div className="after-sale-ticket-head">
<span>{TICKET_TYPE_LABELS[t.ticketType] ?? t.ticketType}</span>
<span className="after-sale-ticket-status">{STATUS_LABEL[t.status] ?? t.status}</span>
</div>
<p className="after-sale-order-no">{t.ticketNo}</p>
<p className="after-sale-order-meta"> {t.orderNo ?? '—'} · {new Date(t.createdAt).toLocaleString()}</p>
{t.remark ? <p className="after-sale-order-meta">{t.remark}</p> : null}
</div>
))}
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale')}>
</button>
</div>
)}
</main>
</div>
);
}
+232
View File
@@ -0,0 +1,232 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
AFTER_SALE_TICKET_TYPES,
TICKET_TYPE_LABELS,
type AfterSaleTicketType,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
import { uploadFileToOss } from '../lib/upload';
type OrderRow = {
id: string;
orderNo: string;
status: string;
payAmount: number | string;
productName?: string;
createdAt?: string;
};
const STEPS = ['类型', '订单', '凭证', '完成'] as const;
export default function AfterSalePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
const presetType = (params.get('type') as AfterSaleTicketType | null) || null;
const [step, setStep] = useState(0);
const [ticketType, setTicketType] = useState<AfterSaleTicketType | null>(
presetType && AFTER_SALE_TICKET_TYPES.includes(presetType) ? presetType : null,
);
const [orders, setOrders] = useState<OrderRow[]>([]);
const [orderId, setOrderId] = useState(presetOrderId);
const [remark, setRemark] = useState('');
const [evidenceUrls, setEvidenceUrls] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [ticketNo, setTicketNo] = useState('');
useEffect(() => {
Promise.all([
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=paid&pageSize=50'),
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50'),
])
.then(([paid, completed]) => {
const map = new Map<string, OrderRow>();
[...(paid.list ?? []), ...(completed.list ?? [])].forEach((o) => map.set(o.id, o));
setOrders([...map.values()]);
})
.catch(() => setOrders([]));
}, []);
const selectedOrder = useMemo(() => orders.find((o) => o.id === orderId), [orders, orderId]);
async function onPickFiles(files: FileList | null) {
if (!files?.length) return;
setUploading(true);
try {
const uploaded: string[] = [];
for (const file of Array.from(files).slice(0, 6 - evidenceUrls.length)) {
const res = await uploadFileToOss(file, { bizType: 'after-sale' });
uploaded.push(res.url);
}
setEvidenceUrls((prev) => [...prev, ...uploaded].slice(0, 6));
} catch (e) {
window.alert(e instanceof Error ? e.message : '上传失败');
} finally {
setUploading(false);
}
}
async function submit() {
if (!ticketType || !orderId) return;
setSubmitting(true);
try {
const ticket = await request<{ ticketNo: string }>('USER_H5', `/trade/orders/${orderId}/after-sale-tickets`, {
method: 'POST',
body: JSON.stringify({
ticketType,
remark: remark.trim() || undefined,
evidenceUrls,
}),
});
setTicketNo(ticket.ticketNo);
setStep(3);
} catch (e) {
window.alert(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
}
}
function nextFromType() {
if (!ticketType) {
window.alert('请选择售后类型');
return;
}
setStep(1);
}
function nextFromOrder() {
if (!orderId) {
window.alert('请选择订单');
return;
}
setStep(2);
}
return (
<div className="after-sale-page">
<SubPageHeader title="申请售后" onBack={() => navigate(-1)} />
<div className="after-sale-steps">
{STEPS.map((label, i) => (
<span key={label} className={`after-sale-step${i === step ? ' is-active' : i < step ? ' is-done' : ''}`}>
{label}
</span>
))}
</div>
<main className="after-sale-body">
{step === 0 && (
<div className="after-sale-type-list">
{AFTER_SALE_TICKET_TYPES.map((t) => (
<button
key={t}
type="button"
className={`after-sale-type-item${ticketType === t ? ' is-selected' : ''}`}
onClick={() => setTicketType(t)}
>
<span>{TICKET_TYPE_LABELS[t]}</span>
<span className="material-symbols-outlined">chevron_right</span>
</button>
))}
<button type="button" className="after-sale-primary" onClick={nextFromType}>
</button>
</div>
)}
{step === 1 && (
<div className="after-sale-order-list">
{orders.length === 0 ? (
<p className="after-sale-empty"></p>
) : (
orders.map((o) => (
<button
key={o.id}
type="button"
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
onClick={() => setOrderId(o.id)}
>
<p className="after-sale-order-no">{o.orderNo}</p>
<p className="after-sale-order-meta">
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
</p>
</button>
))
)}
<div className="after-sale-actions">
<button type="button" className="after-sale-secondary" onClick={() => setStep(0)}>
</button>
<button type="button" className="after-sale-primary" onClick={nextFromOrder}>
</button>
</div>
</div>
)}
{step === 2 && (
<div className="after-sale-form">
<p className="after-sale-summary">
{ticketType ? TICKET_TYPE_LABELS[ticketType] : ''} · {selectedOrder?.orderNo ?? orderId}
</p>
<label className="after-sale-label"></label>
<textarea
className="after-sale-textarea"
rows={4}
placeholder="请描述问题(选填)"
value={remark}
onChange={(e) => setRemark(e.target.value)}
/>
<label className="after-sale-label"></label>
<div className="after-sale-evidence">
{evidenceUrls.map((url) => (
<img key={url} src={url} alt="" className="after-sale-evidence-img" />
))}
{evidenceUrls.length < 6 && (
<label className="after-sale-evidence-add">
<input
type="file"
accept="image/*"
multiple
hidden
disabled={uploading}
onChange={(e) => void onPickFiles(e.target.files)}
/>
{uploading ? '上传中' : '+'}
</label>
)}
</div>
<div className="after-sale-actions">
<button type="button" className="after-sale-secondary" onClick={() => setStep(1)}>
</button>
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
{submitting ? '提交中…' : '提交工单'}
</button>
</div>
</div>
)}
{step === 3 && (
<div className="after-sale-success">
<span className="material-symbols-outlined after-sale-success-icon">check_circle</span>
<p className="after-sale-success-title"></p>
<p className="after-sale-success-no"> {ticketNo}</p>
<p className="after-sale-success-hint"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/after-sale/list')}>
</button>
<button type="button" className="after-sale-secondary" onClick={() => navigate('/orders')}>
</button>
</div>
)}
</main>
</div>
);
}
+20 -171
View File
@@ -1,186 +1,35 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type ChatMessage = {
id: string;
role: 'user' | 'agent' | 'system';
text: string;
time?: string;
};
const QUICK_QUESTIONS = [
{ key: 'logistics', label: '物流查询' },
{ key: 'damage', label: '破损补发' },
{ key: 'refund', label: '申请退款' },
{ key: 'address', label: '修改地址' },
] as const;
function nowLabel() {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function agentReply(userText: string, orderNo?: string) {
if (/订单|DK\d+/i.test(userText) || orderNo) {
return '已收到您的订单信息,客服将在工作时间 9:00-18:00 内为您处理,请保持电话畅通。';
}
if (userText.includes('破损') || userText.includes('补发')) {
return '非常抱歉给您带来不便。请提供订单号并描述破损情况,我们将尽快安排补发。';
}
if (userText.includes('退款')) {
return '请提供订单号与退款原因,客服将为您核实订单状态并协助办理。';
}
if (userText.includes('地址')) {
return '待发货/出库中的订单可在订单详情修改收货地址;已发货订单请联系客服协助处理。';
}
if (userText.includes('物流')) {
return '您可在订单详情查看配送进度;如有异常请提供订单号,我们为您查询。';
}
return '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
}
import { CUSTOMER_SERVICE_PHONE, openWecomCustomerService } from '../lib/customer-service';
import { track } from '../lib/analytics';
export default function CustomerServicePage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const orderId = params.get('orderId') || '';
const orderNo = params.get('orderNo') || '';
const [input, setInput] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sending, setSending] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
useEffect(() => {
const welcome: ChatMessage[] = [
{ id: 'sys-1', role: 'system', text: nowLabel(), time: nowLabel() },
{
id: 'agent-welcome',
role: 'agent',
text: orderNo
? `您好,我是杜康好客客服。已为您关联订单 ${orderNo},请问有什么可以帮您?`
: '您好,我是杜康好客客服。请问有什么可以帮您?',
},
];
setMessages(welcome);
}, [orderNo]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: 'smooth' });
}, [messages]);
function pushMessage(role: ChatMessage['role'], text: string) {
setMessages((prev) => [...prev, { id: `${Date.now()}-${prev.length}`, role, text }]);
function openOnline() {
track('cs_contact', { type: 'wecom_kf' });
openWecomCustomerService();
}
async function sendText(text: string) {
const trimmed = text.trim();
if (!trimmed || sending) return;
setSending(true);
pushMessage('user', trimmed);
setInput('');
window.setTimeout(() => {
pushMessage('agent', agentReply(trimmed, orderNo));
setSending(false);
}, 600);
}
async function loadOrderContext() {
if (!orderId) return null;
try {
return await request<Record<string, unknown>>('USER_H5', `/trade/orders/${orderId}`);
} catch {
return null;
}
}
useEffect(() => {
if (!orderId) return;
loadOrderContext().then((order) => {
if (!order) return;
const no = String(order.orderNo || orderNo);
if (no && !orderNo) {
pushMessage('system', `已关联订单 ${no}`);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [orderId]);
return (
<div className="customer-service-page">
<div className="customer-service-page customer-service-page--oa">
<SubPageHeader title="在线客服" onBack={() => navigate(-1)} />
{orderNo && (
<div className="customer-service-order-card">
<span className="material-symbols-outlined">receipt_long</span>
<div>
<p className="customer-service-order-label"></p>
<p className="customer-service-order-no">{orderNo}</p>
</div>
</div>
)}
<div className="customer-service-oa-body">
<p className="customer-service-wecom-title"></p>
<p className="customer-service-wecom-hint">线</p>
<div className="customer-service-chat" ref={listRef}>
{messages.map((m) => {
if (m.role === 'system') {
return (
<div key={m.id} className="customer-service-time">
<span>{m.text}</span>
</div>
);
}
const isUser = m.role === 'user';
return (
<div
key={m.id}
className={`customer-service-bubble-row${isUser ? ' is-user' : ' is-agent'}`}
>
{!isUser && (
<div className="customer-service-avatar" aria-hidden>
<span className="material-symbols-outlined">support_agent</span>
</div>
)}
<div className={`customer-service-bubble${isUser ? ' is-user' : ''}`}>{m.text}</div>
</div>
);
})}
</div>
<div className="customer-service-quick">
{QUICK_QUESTIONS.map((q) => (
<button
key={q.key}
type="button"
className="customer-service-quick-btn"
disabled={sending}
onClick={() => sendText(q.label)}
>
{q.label}
</button>
))}
</div>
<footer className="customer-service-inputbar">
<input
type="text"
className="customer-service-input"
placeholder="请输入您的问题..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void sendText(input);
}}
/>
<button
type="button"
className="customer-service-send"
disabled={sending || !input.trim()}
onClick={() => sendText(input)}
>
<button type="button" className="customer-service-wecom-btn" onClick={openOnline}>
<span className="material-symbols-outlined">headset_mic</span>
线
</button>
</footer>
<a className="customer-service-phone-link" href={`tel:${tel}`}>
<span className="material-symbols-outlined">call</span>
{CUSTOMER_SERVICE_PHONE}
</a>
</div>
</div>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
INVOICE_KIND_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceKind,
type InvoiceTitleType,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
type OrderRow = { id: string; orderNo: string; payAmount: number | string; productName?: string };
export default function InvoiceApplyPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const presetOrderId = params.get('orderId') || '';
const [orders, setOrders] = useState<OrderRow[]>([]);
const [orderId, setOrderId] = useState(presetOrderId);
const [titleType, setTitleType] = useState<InvoiceTitleType>('PERSONAL');
const [invoiceKind, setInvoiceKind] = useState<InvoiceKind>('NORMAL');
const [titleName, setTitleName] = useState('');
const [taxNo, setTaxNo] = useState('');
const [addressPhone, setAddressPhone] = useState('');
const [bankAccount, setBankAccount] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
request<{ list?: OrderRow[] }>('USER_H5', '/trade/orders?tab=completed&pageSize=50')
.then((res) => setOrders(res.list ?? []))
.catch(() => setOrders([]));
}, []);
useEffect(() => {
if (invoiceKind === 'SPECIAL') setTitleType('ENTERPRISE');
}, [invoiceKind]);
async function submit() {
if (!orderId) {
window.alert('请选择订单');
return;
}
if (!titleName.trim() || !email.trim() || !phone.trim()) {
window.alert('请填写抬头名称、邮箱与手机号');
return;
}
setSubmitting(true);
try {
await request('USER_H5', `/trade/orders/${orderId}/invoices`, {
method: 'POST',
body: JSON.stringify({
titleType,
invoiceKind,
titleName: titleName.trim(),
taxNo: taxNo.trim() || undefined,
addressPhone: addressPhone.trim() || undefined,
bankAccount: bankAccount.trim() || undefined,
email: email.trim(),
phone: phone.trim(),
}),
});
window.alert('发票申请已提交,总部将在 2 个工作日内开具');
navigate('/invoices');
} catch (e) {
window.alert(e instanceof Error ? e.message : '申请失败');
} finally {
setSubmitting(false);
}
}
return (
<div className="after-sale-page">
<SubPageHeader title="申请发票" onBack={() => navigate(-1)} />
<main className="after-sale-body after-sale-form">
<label className="after-sale-label"></label>
<div className="after-sale-order-list" style={{ marginBottom: 16 }}>
{orders.length === 0 ? (
<p className="after-sale-empty"></p>
) : (
orders.map((o) => (
<button
key={o.id}
type="button"
className={`after-sale-order-item${orderId === o.id ? ' is-selected' : ''}`}
onClick={() => setOrderId(o.id)}
>
<p className="after-sale-order-no">{o.orderNo}</p>
<p className="after-sale-order-meta">
{o.productName || '商品'} · ¥{Number(o.payAmount).toFixed(2)}
</p>
</button>
))
)}
</div>
<label className="after-sale-label"></label>
<div className="invoice-chip-row">
{(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => (
<button
key={k}
type="button"
className={`invoice-chip${invoiceKind === k ? ' is-selected' : ''}`}
onClick={() => setInvoiceKind(k)}
>
{INVOICE_KIND_LABELS[k]}
</button>
))}
</div>
<label className="after-sale-label"></label>
<div className="invoice-chip-row">
{(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => (
<button
key={t}
type="button"
className={`invoice-chip${titleType === t ? ' is-selected' : ''}`}
disabled={invoiceKind === 'SPECIAL' && t === 'PERSONAL'}
onClick={() => setTitleType(t)}
>
{INVOICE_TITLE_TYPE_LABELS[t]}
</button>
))}
</div>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={titleName} onChange={(e) => setTitleName(e.target.value)} placeholder="个人姓名或公司全称" />
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
<>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={taxNo} onChange={(e) => setTaxNo(e.target.value)} placeholder="纳税人识别号" />
</>
)}
{invoiceKind === 'SPECIAL' && (
<>
<label className="after-sale-label"></label>
<input className="after-sale-input" value={addressPhone} onChange={(e) => setAddressPhone(e.target.value)} />
<label className="after-sale-label"></label>
<input className="after-sale-input" value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
</>
)}
<label className="after-sale-label"></label>
<input className="after-sale-input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label className="after-sale-label"></label>
<input className="after-sale-input" value={phone} onChange={(e) => setPhone(e.target.value)} />
<button type="button" className="after-sale-primary" disabled={submitting} onClick={() => void submit()}>
{submitting ? '提交中…' : '提交申请'}
</button>
</main>
</div>
);
}
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
INVOICE_KIND_LABELS,
INVOICE_STATUS_LABELS,
INVOICE_TITLE_TYPE_LABELS,
type InvoiceDto,
} from '@dukang/shared-types';
import SubPageHeader from '../components/SubPageHeader';
import { request } from '../lib/api';
export default function InvoiceListPage() {
const navigate = useNavigate();
const [items, setItems] = useState<InvoiceDto[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
request<{ items: InvoiceDto[] }>('USER_H5', '/trade/invoices?pageSize=50')
.then((res) => setItems(res.items ?? []))
.catch(() => setItems([]))
.finally(() => setLoading(false));
}, []);
return (
<div className="after-sale-page">
<SubPageHeader title="我的发票" onBack={() => navigate(-1)} />
<main className="after-sale-body">
{loading ? (
<p className="after-sale-empty"></p>
) : items.length === 0 ? (
<div className="after-sale-empty-box">
<p className="after-sale-empty"></p>
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
</button>
</div>
) : (
<div className="after-sale-order-list">
{items.map((inv) => (
<div key={inv.id} className="after-sale-order-item after-sale-ticket-card">
<div className="after-sale-ticket-head">
<span>
{INVOICE_TITLE_TYPE_LABELS[inv.titleType]} · {INVOICE_KIND_LABELS[inv.invoiceKind]}
</span>
<span className="after-sale-ticket-status">
{INVOICE_STATUS_LABELS[inv.status]}
</span>
</div>
<p className="after-sale-order-no">{inv.titleName}</p>
<p className="after-sale-order-meta">
{inv.invoiceNo} · {inv.orderNo ?? inv.orderId}
</p>
{inv.status === 'ISSUED' && inv.fileUrl ? (
<a className="after-sale-file-link" href={inv.fileUrl} target="_blank" rel="noreferrer">
/
</a>
) : null}
</div>
))}
<button type="button" className="after-sale-primary" onClick={() => navigate('/invoices/apply')}>
</button>
</div>
)}
</main>
</div>
);
}
+2
View File
@@ -17,6 +17,8 @@ const ORDER_SHORTCUTS = [
const SERVICES = [
{ icon: 'location_on', label: '地址管理', to: '/addresses' },
{ icon: 'assignment_return', label: '售后工单', to: '/after-sale/list' },
{ icon: 'receipt_long', label: '我的发票', to: '/invoices' },
{ icon: 'storefront', label: '可用门店', to: '/stores' },
{ icon: 'headset_mic', label: '联系客服', badge: '在线中', action: 'cs' as const },
{ icon: 'info', label: '关于我们', action: 'about' as const },
+16 -18
View File
@@ -178,6 +178,7 @@ export default function OrderDetailPage() {
order &&
!canPay &&
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
const canInvoice = order?.status === 'COMPLETED';
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
const freightTotal = Number(order?.freightAmount ?? 0);
@@ -207,22 +208,6 @@ export default function OrderDetailPage() {
}
}
async function requestRefund() {
if (!id || !canRefund) return;
setConfirming(true);
try {
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
method: 'POST',
body: JSON.stringify({ remark: '用户申请退款' }),
});
await loadOrder();
} catch (e) {
window.alert(e instanceof Error ? e.message : '申请退款失败');
} finally {
setConfirming(false);
}
}
if (!order) return <div className="empty">...</div>;
return (
@@ -470,8 +455,21 @@ export default function OrderDetailPage() {
</button>
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
退
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/after-sale?orderId=${order.id}&type=REFUND`)}
>
</button>
)}
{canInvoice && (
<button
type="button"
className="order-detail-action-outline"
onClick={() => navigate(`/invoices/apply?orderId=${order.id}`)}
>
</button>
)}
{canPay && (
+247 -105
View File
@@ -5786,7 +5786,6 @@
color: #1a1c1b;
}
/* Customer service chat page */
.customer-service-page {
min-height: 100vh;
display: flex;
@@ -5794,142 +5793,285 @@
background: #faf9f7;
}
.customer-service-order-card {
.customer-service-page--oa .customer-service-oa-body {
flex: 1;
padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin: 12px 16px 0;
padding: 12px 16px;
background: #fff;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
color: #a61d24;
justify-content: center;
text-align: center;
}
.customer-service-order-label {
.customer-service-wecom-title {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: #1a1c1b;
}
.customer-service-wecom-hint {
margin: 0 0 28px;
font-size: 13px;
line-height: 1.5;
color: #5a413f;
}
.customer-service-wecom-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
max-width: 320px;
padding: 14px 20px;
border: none;
border-radius: 12px;
background: #a61d24;
color: #fff;
font-size: 16px;
font-weight: 600;
}
.customer-service-wecom-btn .material-symbols-outlined {
font-size: 22px;
}
.customer-service-phone-link {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 24px;
font-size: 14px;
color: #a61d24;
text-decoration: none;
}
.after-sale-page {
min-height: 100dvh;
background: #faf9f7;
display: flex;
flex-direction: column;
}
.after-sale-steps {
display: flex;
justify-content: space-around;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.after-sale-step {
font-size: 12px;
color: #999;
}
.after-sale-step.is-active,
.after-sale-step.is-done {
color: #a61d24;
font-weight: 600;
}
.after-sale-body {
flex: 1;
padding: 16px 16px calc(24px + env(safe-area-inset-bottom));
}
.after-sale-type-list,
.after-sale-order-list,
.after-sale-form,
.after-sale-success {
display: flex;
flex-direction: column;
gap: 12px;
}
.after-sale-type-item,
.after-sale-order-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
text-align: left;
padding: 14px 16px;
border-radius: 12px;
border: 1px solid rgba(226, 190, 188, 0.35);
background: #fff;
}
.after-sale-type-item.is-selected,
.after-sale-order-item.is-selected {
border-color: #a61d24;
box-shadow: 0 0 0 1px #a61d24 inset;
}
.after-sale-order-no {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #1a1c1b;
}
.after-sale-order-meta {
margin: 4px 0 0;
font-size: 12px;
color: #5a413f;
}
.customer-service-order-no {
margin: 4px 0 0;
font-size: 14px;
font-weight: 600;
color: #1a1c1b;
}
.customer-service-chat {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.customer-service-time {
display: flex;
justify-content: center;
}
.customer-service-time span {
font-size: 12px;
color: #999;
background: rgba(227, 226, 224, 0.3);
padding: 4px 12px;
border-radius: 999px;
}
.customer-service-bubble-row {
display: flex;
gap: 8px;
max-width: 85%;
}
.customer-service-bubble-row.is-user {
align-self: flex-end;
}
.customer-service-bubble-row.is-agent {
align-self: flex-start;
}
.customer-service-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff;
border: 1px solid rgba(226, 190, 188, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #a61d24;
flex-shrink: 0;
}
.customer-service-bubble {
padding: 12px;
.after-sale-primary,
.after-sale-secondary {
width: 100%;
padding: 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.5;
background: #fff;
color: #1a1c1b;
box-shadow: 0 4px 20px rgba(166, 29, 36, 0.05);
border: none;
font-size: 15px;
font-weight: 600;
}
.customer-service-bubble.is-user {
.after-sale-primary {
background: #a61d24;
color: #fff;
box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
}
.customer-service-quick {
.after-sale-secondary {
background: #e3e2e0;
color: #1a1c1b;
}
.after-sale-actions {
display: flex;
gap: 12px;
margin-top: 8px;
}
.after-sale-label {
font-size: 13px;
color: #5a413f;
margin-top: 4px;
}
.after-sale-textarea,
.after-sale-input {
width: 100%;
border: 1px solid #e3e2e0;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
background: #fff;
box-sizing: border-box;
}
.after-sale-evidence {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 16px 12px;
}
.customer-service-quick-btn {
padding: 6px 12px;
border-radius: 999px;
border: 1px solid rgba(166, 29, 36, 0.25);
background: #fff;
color: #a61d24;
font-size: 12px;
.after-sale-evidence-img,
.after-sale-evidence-add {
width: 72px;
height: 72px;
border-radius: 8px;
object-fit: cover;
}
.customer-service-inputbar {
.after-sale-evidence-add {
display: flex;
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
align-items: center;
justify-content: center;
border: 1px dashed #ccc;
background: #fff;
border-top: 1px solid #e3e2e0;
font-size: 24px;
color: #999;
}
.customer-service-input {
flex: 1;
border: 1px solid #e3e2e0;
border-radius: 999px;
padding: 10px 16px;
font-size: 14px;
outline: none;
.after-sale-summary {
margin: 0;
padding: 12px;
border-radius: 10px;
background: rgba(166, 29, 36, 0.06);
font-size: 13px;
color: #a61d24;
}
.customer-service-send {
border: none;
border-radius: 999px;
padding: 10px 18px;
background: #a61d24;
color: #fff;
font-size: 14px;
.after-sale-empty,
.after-sale-empty-box {
text-align: center;
color: #999;
padding: 24px 0;
}
.after-sale-success {
align-items: center;
text-align: center;
padding-top: 40px;
}
.after-sale-success-icon {
font-size: 56px;
color: #2e7d32;
}
.after-sale-success-title {
margin: 8px 0 4px;
font-size: 18px;
font-weight: 600;
}
.customer-service-send:disabled {
opacity: 0.5;
.after-sale-success-no,
.after-sale-success-hint {
margin: 0;
font-size: 13px;
color: #5a413f;
}
.after-sale-ticket-head {
display: flex;
justify-content: space-between;
width: 100%;
font-size: 13px;
font-weight: 600;
}
.after-sale-ticket-status {
color: #a61d24;
}
.after-sale-ticket-card {
flex-direction: column;
align-items: flex-start;
}
.after-sale-file-link {
margin-top: 8px;
font-size: 13px;
color: #a61d24;
}
.invoice-chip-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.invoice-chip {
padding: 8px 12px;
border-radius: 999px;
border: 1px solid rgba(166, 29, 36, 0.25);
background: #fff;
font-size: 12px;
color: #a61d24;
}
.invoice-chip.is-selected {
background: #a61d24;
color: #fff;
border-color: #a61d24;
}
.invoice-chip:disabled {
opacity: 0.4;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
+1 -2
View File
@@ -52,8 +52,7 @@ scroll-view::-webkit-scrollbar,
.taro-scroll::-webkit-scrollbar,
.taro-scroll-view::-webkit-scrollbar,
.taro-scroll-view__scroll-x::-webkit-scrollbar,
.taro-scroll-view__scroll-y::-webkit-scrollbar,
*::-webkit-scrollbar {
.taro-scroll-view__scroll-y::-webkit-scrollbar {
display: none;
width: 0 !important;
height: 0 !important;
+7 -4
View File
@@ -9,8 +9,11 @@
pnpm --filter @dukang/mini-user dev
# → http://localhost:5177
# 微信小程序
# 微信小程序(本地联调,development
pnpm --filter @dukang/mini-user dev:weapp
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com
pnpm build:mini-user:weapp
# 用微信开发者工具打开 apps/mini-userminiprogramRoot = dist/
```
@@ -18,7 +21,7 @@ pnpm --filter @dukang/mini-user dev:weapp
| 环节 | 文件 | 说明 |
|------|------|------|
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000``NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
| **编译注入** | `config/index.ts``defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000``build:weapp``--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
| **运行时拼装** | `src/lib/api.ts``resolveApiBase()` | `{origin}/api/v1` |
### 微信登录 `invalid code`
@@ -45,10 +48,10 @@ pnpm --filter @dukang/mini-user dev:weapp
**连远程 API**
```bash
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
```
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
## 页面结构(18 页)
+8 -2
View File
@@ -2,10 +2,16 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig } from '@tarojs/cli';
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
const isDevMode =
process.env.NODE_ENV === 'development' ||
process.argv.includes('--watch') ||
process.argv.includes('development');
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
const API_ORIGIN =
process.env.VITE_API_TARGET ??
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
@@ -91,7 +97,7 @@ export default defineConfig(async () => ({
},
mini: {
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
debugReact: process.env.NODE_ENV !== 'production',
debugReact: isDevMode,
postcss: {
pxtransform: { enable: true, config: {} },
cssModules: { enable: false },
+2 -2
View File
@@ -7,8 +7,8 @@
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production",
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production",
"lint": "echo ok"
},
"dependencies": {
+2 -1
View File
@@ -10,5 +10,6 @@
"postcss": false,
"minified": false
},
"compileType": "miniprogram"
"compileType": "miniprogram",
"preloadBackgroundData": false
}
+1 -7
View File
@@ -39,19 +39,13 @@ body::-webkit-scrollbar,
#app::-webkit-scrollbar,
.taro_page::-webkit-scrollbar,
.taro_router::-webkit-scrollbar,
.taro-tabbar__panel::-webkit-scrollbar,
*::-webkit-scrollbar {
.taro-tabbar__panel::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
display: none !important;
background: transparent;
}
* {
scrollbar-width: none;
-ms-overflow-style: none;
}
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
.taro-tabbar__tabbar,
.taro-tabbar__border {
@@ -0,0 +1,50 @@
import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react';
export type ContactCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type ContactCsButtonProps = {
className?: string;
children?: ReactNode;
/** 客服会话来源上下文,便于客服后台识别 */
session?: ContactCsSessionContext;
};
const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user';
const parts = ['dukang'];
if (session.from) parts.push(`from=${session.from}`);
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
if (session.orderId) parts.push(`orderId=${session.orderId}`);
return parts.join('|');
}
/**
* 微信小程序客服入口(open-type=contact)。
* 非 weapp 环境不渲染,由调用方走电话等兜底。
*/
export default function ContactCsButton({
className = '',
children = '联系在线客服',
session,
}: ContactCsButtonProps) {
if (!isWeapp) return null;
return (
<Button
className={className}
openType="contact"
sessionFrom={buildCsSessionFrom(session)}
hoverClass="none"
>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
@@ -0,0 +1,63 @@
import { Button, Text, View } from '@tarojs/components';
type PhoneQuickLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
agreed: boolean;
onRequireAgree: () => void;
onGetPhoneNumber: (phoneCode: string) => void;
onFail?: (message: string) => void;
};
/**
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
*/
export default function PhoneQuickLoginButton({
loading = false,
disabled = false,
agreed,
onRequireAgree,
onGetPhoneNumber,
onFail,
}: PhoneQuickLoginButtonProps) {
const inactive = loading || disabled;
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
const label = loading ? '登录中...' : '手机号快捷登录';
if (!agreed) {
return (
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
<Text className="login-phone-quick-btn__text">{label}</Text>
</View>
);
}
return (
<Button
className={className}
openType={inactive ? undefined : 'getPhoneNumber'}
hoverClass="none"
onGetPhoneNumber={(e) => {
if (inactive) return;
const detail = e.detail as {
errMsg?: string;
code?: string;
errno?: number;
};
if (!detail?.code) {
const denied =
detail?.errMsg?.includes('deny') ||
detail?.errMsg?.includes('cancel') ||
detail?.errno === 103;
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
return;
}
onGetPhoneNumber(detail.code);
}}
>
<Text className="login-phone-quick-btn__text">{label}</Text>
</Button>
);
}
+10 -11
View File
@@ -7,25 +7,24 @@ type TabMainHeaderProps = {
extra?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
/**
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
*/
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (isH5 && !extra) {
if (!extra) {
return null;
}
return (
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
<View
className="tab-main-header__content"
style={tabNavContentStyle(metrics)}
>
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
{process.env.TARO_ENV !== 'h5' ? (
<Text className="tab-main-header__title">{title}</Text>
) : null}
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
<View className="tab-main-header__extra">{extra}</View>
</View>
</View>
);
@@ -3,22 +3,16 @@ import { View, Text } from '@tarojs/components';
type WechatLoginButtonProps = {
loading?: boolean;
disabled?: boolean;
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
label?: string;
onClick: () => void;
};
function WechatIcon() {
return (
<View className="wechat-login-icon" aria-hidden>
<View className="wechat-login-icon__big" />
<View className="wechat-login-icon__small" />
</View>
);
}
/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn */
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
export default function WechatLoginButton({
loading = false,
disabled = false,
label = '授权登录',
onClick,
}: WechatLoginButtonProps) {
const inactive = loading || disabled;
@@ -28,10 +22,7 @@ export default function WechatLoginButton({
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
onClick={inactive ? undefined : onClick}
>
<WechatIcon />
<Text className="login-wechat-btn__text">
{loading ? '授权中...' : '微信一键授权'}
</Text>
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
</View>
);
}
+1 -18
View File
@@ -1,6 +1,6 @@
import Taro from '@tarojs/taro';
import { ClientApp } from '@dukang/shared-types';
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
import { forceReloadAfterAccountMerge } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
return !!getToken();
}
export function redirectToLogin() {
goLogin();
}
export function logout() {
clearAuth();
Taro.reLaunch({ url: '/pages/home/index' });
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
return {};
}
function isOnLoginPage(): boolean {
try {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as { route?: string } | undefined;
const route = cur?.route || '';
return route.includes('pages/login');
} catch {
return false;
}
}
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
} else if (!isOnLoginPage()) {
redirectToLogin();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
+18 -3
View File
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
'/pages/mine/index',
]);
let loginNavigationPending = false;
function isLoginPageActive(): boolean {
const pages = Taro.getCurrentPages();
const current = pages[pages.length - 1] as { route?: string } | undefined;
return !!current?.route?.includes('pages/login/');
}
function currentPagePath(): string {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
@@ -25,6 +33,7 @@ function currentPagePath(): string {
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath();
const parts: string[] = [];
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
@@ -34,9 +43,15 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
}
}
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
Taro.navigateTo({ url }).catch(() => {
Taro.redirectTo({ url });
});
loginNavigationPending = true;
void Taro.navigateTo({ url })
.catch(() => Taro.redirectTo({ url }))
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
+104 -55
View File
@@ -1,6 +1,4 @@
import Taro from '@tarojs/taro';
import { request } from './api';
import type { UserProfile } from './api';
export type MiniWechatProfile = {
@@ -8,6 +6,17 @@ export type MiniWechatProfile = {
avatarUrl?: string;
};
export type MiniWechatProfileUpdate = MiniWechatProfile & {
avatarResourceId?: string;
};
export type UploadedAvatarResource = {
resourceId: string;
url: string;
bucket: string;
ossKey: string;
};
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
export function cacheWxProfile(info: MiniWechatProfile) {
@@ -32,77 +41,117 @@ export function getCachedWxProfile(): MiniWechatProfile | null {
}
export function isDefaultMiniNickname(nickname?: string | null): boolean {
if (!nickname || nickname === '访客') return true;
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
return /^用户\d{4}$/.test(nickname);
}
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
if (!profile) return true;
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
}
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
if (!profile.hasWechat) return profile;
const cached = getCachedWxProfile();
if (!cached) return profile;
if (!cached && !profile.hasWechat) return profile;
const nickname =
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
cached?.nickname ||
profile.nickname ||
'微信用户';
return {
...profile,
nickname:
cached.nickname ||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
profile.nickname ||
'微信用户',
avatarUrl: profile.avatarUrl || cached.avatarUrl || null,
nickname,
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
};
}
/** 用户点击触发:拉取微信昵称/头像 */
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
if (process.env.TARO_ENV !== 'weapp') {
throw new Error('请在微信小程序中授权');
}
const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
const info: MiniWechatProfile = {
nickname: res.userInfo?.nickName?.trim(),
avatarUrl: res.userInfo?.avatarUrl?.trim(),
};
if (!info.nickname && !info.avatarUrl) {
throw new Error('未获取到微信头像或昵称');
}
cacheWxProfile(info);
return info;
}
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken();
if (!token) throw new Error('请先登录');
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<MiniWechatProfile | null> {
if (!info.nickname && !info.avatarUrl) return null;
const res = await Taro.uploadFile({
url: `${API_BASE}/common/resources/upload`,
filePath: tempFilePath,
name: 'file',
formData: {
bizType: 'AVATAR',
mediaType: 'IMAGE',
},
header: {
Authorization: `Bearer ${token}`,
'X-Client-App': CLIENT_APP,
},
});
let body: {
code?: number;
message?: string;
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
} = {};
try {
const updated = await request<{
nickname?: string | null;
avatarUrl?: string | null;
}>('/auth/wechat/mini-profile', {
method: 'POST',
data: info,
});
if (updated?.nickname || updated?.avatarUrl) {
cacheWxProfile({
nickname: updated.nickname ?? info.nickname,
avatarUrl: updated.avatarUrl ?? info.avatarUrl,
});
}
return info;
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
return null;
throw new Error('头像上传响应异常');
}
if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录');
}
if (
res.statusCode >= 400 ||
body.code !== 0 ||
!body.data?.resourceId ||
!body.data.url ||
!body.data.bucket ||
!body.data.ossKey
) {
throw new Error(body.message || '头像上传失败');
}
return {
resourceId: body.data.resourceId,
url: body.data.url,
bucket: body.data.bucket,
ossKey: body.data.ossKey,
};
}
/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */
export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise<MiniWechatProfile | null> {
if (process.env.TARO_ENV !== 'weapp') return null;
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST',
data: info,
});
cacheWxProfile({
nickname: updated?.nickname ?? info.nickname,
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
});
return updated;
}
let info = prefetched ?? null;
if (!info) {
try {
info = await fetchMiniWechatUserInfo();
} catch {
return getCachedWxProfile();
}
/**
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
*/
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
const cached = getCachedWxProfile();
if (cached?.nickname || cached?.avatarUrl) {
return cached;
}
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
}
await uploadMiniWechatProfile(info);
/** 绑定后上报微信资料(优先使用已拉取的信息) */
export async function syncMiniWechatProfile(
prefetched?: MiniWechatProfile | null,
): Promise<MiniWechatProfile | null> {
if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null;
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info;
}
+26 -1
View File
@@ -3,7 +3,32 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import Taro from '@tarojs/taro';
import { saveAuth } from './api';
import { syncMiniWechatProfile } from './mini-wechat-profile';
import {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
syncMiniWechatProfile,
} from './mini-wechat-profile';
/**
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
*(开发者工具热更新时常见旧页 + 新 common 混用)
*/
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
const _wxProfileCompat = {
fetchMiniWechatUserInfo,
mergeWxDisplayProfile,
needsWxProfileFill,
};
if (
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
) {
throw new Error('mini-wechat-profile helpers missing');
}
import {
authorizeWechatForPay,
fetchClientConfig,
@@ -106,14 +106,6 @@ export default function BenefitPage() {
<Text></Text>
</View>
</View>
<View className="benefit-hero-actions">
<Text
className="benefit-hero-link"
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
>
</Text>
</View>
<View
className="benefit-hero-cta"
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
@@ -3,28 +3,42 @@ import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() {
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body">
<Text className="cs-title">线</Text>
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
<Text className="cs-hint">9:00 - 21:00</Text>
<View
className="cs-call-btn"
onClick={() => {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
toast('无法拨打电话'),
);
}}
>
<Text></Text>
<Text className="cs-brand"></Text>
<Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View>
</PageShell>
);
@@ -1,4 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '杜康好客',
});
+23 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -21,9 +21,9 @@ type Product = {
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型', open: true },
{ key: 'JIANGXIANG', label: '酱香型', open: false },
{ key: 'NONGXIANG', label: '浓香型', open: false },
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
export default function HomePage() {
@@ -49,35 +49,38 @@ export default function HomePage() {
.finally(() => setLoading(false));
}, [cityCode]);
function onAromaTabClick(key: string, open: boolean) {
if (!open) {
toast('暂未开放');
return;
const availableAromas = useMemo(
() =>
AROMA_TABS.filter((item) =>
products.some((product) => product.aromaType === item.key),
),
[products],
);
useEffect(() => {
if (loading || availableAromas.length === 0) return;
if (!availableAromas.some((item) => item.key === tab)) {
setTab(availableAromas[0].key);
}
setTab(key);
}
}, [availableAromas, loading, tab]);
function openProductDetail(id: string) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
return (
<PageShell
variant="tab"
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<PageShell variant="tab" className="home-page no-tab-header">
<TabMainHeader title="杜康好客" />
<View className="home-aroma-nav">
<View className="home-aroma-tabs">
{AROMA_TABS.map((t) => (
{availableAromas.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => onAromaTabClick(t.key, t.open)}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</Text>
@@ -88,12 +91,10 @@ export default function HomePage() {
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && !onSale ? <View className="home-empty">线</View> : null}
{!loading && onSale && filtered.length === 0 ? (
<View className="home-empty"></View>
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
onSale &&
filtered.map((p) => {
const images = getProductImages(p);
return (
+213 -135
View File
@@ -9,6 +9,7 @@ import {
} from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import WechatLoginButton from '../../components/WechatLoginButton';
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
import {
@@ -18,13 +19,14 @@ import {
import { fetchUserProfile } from '../../lib/pay-wechat';
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
import {
fetchMiniWechatUserInfo,
getCachedWxProfile,
syncMiniWechatProfile,
type MiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
function normalizePhone(value: string) {
return value.replace(/\D/g, '').slice(0, 11);
}
@@ -33,6 +35,44 @@ function isValidPhone(phone: string) {
return /^1[3-9]\d{9}$/.test(phone);
}
function AgreementRow({
agreed,
onToggle,
}: {
agreed: boolean;
onToggle: () => void;
}) {
return (
<View className="login-agreement" onClick={onToggle}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
);
}
export default function LoginPage() {
const router = useRouter();
const returnTo = router.params.return || '';
@@ -45,8 +85,10 @@ export default function LoginPage() {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [wxLoading, setWxLoading] = useState(false);
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
const [sending, setSending] = useState(false);
const [cooldown, setCooldown] = useState(0);
/** 须用户主动勾选,禁止默认同意 */
const [agreed, setAgreed] = useState(false);
const [msg, setMsg] = useState('');
const [sentHint, setSentHint] = useState('');
@@ -54,6 +96,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
const [wxAuthorize, setWxAuthorize] = useState(true);
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
useEffect(() => {
request<ClientRuntimeConfig>('/common/client-config')
@@ -66,7 +109,6 @@ export default function LoginPage() {
setCompleteMode(null);
return;
}
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
if (!needPhone && !needWechat) {
setCompleteMode(null);
return;
@@ -77,6 +119,7 @@ export default function LoginPage() {
if (cancelled) return;
if (needPhone && !me.phoneVerified) {
setCompleteMode('phone');
setShowSmsForm(true);
return;
}
if (needWechat && !me.hasWechat) {
@@ -101,26 +144,39 @@ export default function LoginPage() {
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选同意用户协议');
setMsg('请先阅读并勾选同意用户服务协议》和《隐私政策》');
return false;
}
return true;
}
function cancelLogin() {
const pages = Taro.getCurrentPages();
if (pages.length > 1) {
Taro.navigateBack().catch(() => {
Taro.switchTab({ url: '/pages/home/index' });
});
return;
}
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
Taro.reLaunch({ url: '/pages/home/index' });
});
}
function applySessionAndLeave(
data: SessionPayload | WechatLoginResult,
phone?: string,
phoneValue?: string,
wxInfo?: MiniWechatProfile | null,
successToast = '登录成功',
) {
if (!data.accessToken) return;
if (phone) saveUserPhone(phone);
if (phoneValue) saveUserPhone(phoneValue);
saveAuth({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
});
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
if (!phone) {
if (!phoneValue) {
void fetchUserProfile()
.then((me) => resolveDefaultUserPhone(me))
.catch(() => {});
@@ -134,7 +190,6 @@ export default function LoginPage() {
}
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
// 微信授权成功即登录;手机号改为下单页可选绑定
if (result.accessToken) {
applySessionAndLeave(result, undefined, wxInfo);
return;
@@ -142,11 +197,49 @@ export default function LoginPage() {
if (result.needBindPhone && result.wxSessionKey) {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
setShowSmsForm(true);
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
setSentHint('');
return;
}
setMsg('微信登录未完成,请重试或使用手机号登录');
setMsg('登录未完成,请重试或使用手机号登录');
}
async function onPhoneQuickLogin(phoneCode: string) {
if (!ensureAgreed()) return;
setPhoneQuickLoading(true);
setMsg('');
setSentHint('');
try {
let loginCode: string | undefined;
try {
const loginRes = await Taro.login();
loginCode = loginRes.code || undefined;
} catch {
/* openId 绑定失败不阻断手机号登录 */
}
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
method: 'POST',
data: {
phoneCode,
...(loginCode ? { loginCode } : {}),
platform: 'mini',
},
});
if (!data?.accessToken) {
setMsg('登录成功但未返回令牌,请重试');
return;
}
const profilePhone =
typeof data.user === 'object' && data.user && 'phone' in data.user
? String((data.user as { phone?: string }).phone || '')
: '';
applySessionAndLeave(data, profilePhone || undefined);
} catch (e) {
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
} finally {
setPhoneQuickLoading(false);
}
}
async function onSendCode() {
@@ -201,7 +294,6 @@ export default function LoginPage() {
return;
}
if (completeMode === 'phone' && isLoggedIn()) {
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
const data = await request<SessionPayload>('/auth/phone/bind', {
method: 'POST',
data: { phone: normalized, code: code.trim() },
@@ -235,15 +327,7 @@ export default function LoginPage() {
setSentHint('');
setWxLoading(true);
try {
let wxInfo: MiniWechatProfile | null = null;
if (process.env.TARO_ENV === 'weapp') {
try {
wxInfo = await fetchMiniWechatUserInfo();
} catch (e) {
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
return;
}
}
const wxInfo = getCachedWxProfile();
if (completeMode === 'wechat' && isLoggedIn()) {
const result = await bindWechatForUser(wxInfo);
@@ -254,12 +338,13 @@ export default function LoginPage() {
setBindMode(true);
setWxSessionKey(result.wxSessionKey);
setCompleteMode('phone');
setShowSmsForm(true);
setMsg('请绑定手机号完成认证');
return;
}
if (result.ok) {
await syncMiniWechatProfile(wxInfo);
toast('微信授权成功', 'success');
if (wxInfo) await syncMiniWechatProfile(wxInfo);
toast('授权成功', 'success');
finishLoginNavigate(returnTo);
return;
}
@@ -268,11 +353,11 @@ export default function LoginPage() {
const result = await loginWithWechat();
if (result) handleWechatLoginResult(result, wxInfo);
} catch (e) {
const raw = e instanceof Error ? e.message : '微信登录失败';
const raw = e instanceof Error ? e.message : '授权登录失败';
const hint = /invalid code/i.test(raw)
? process.env.TARO_ENV === 'weapp'
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
: '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
? '授权失败:请确认后端小程序 AppID 配置正确'
: '授权失败:请确认公众号网页授权域名配置正确'
: raw;
setMsg(hint);
} finally {
@@ -282,21 +367,28 @@ export default function LoginPage() {
const displayMsg = msg || sentHint;
const codeDisabled = cooldown > 0 || sending;
const showWechatLogin =
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
const showSmsForm = completeMode !== 'wechat';
const showAuthLogin =
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
(IS_WEAPP || wxAuthorize);
const showPhoneQuick =
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
const cardTitle =
completeMode === 'phone'
? '验证手机号'
: bindMode
? '绑定手机号'
: completeMode === 'wechat'
? '微信授权'
: '手机验证码登录';
? '授权登录'
: '手机号快捷登录';
return (
<PageShell variant="plain" className="login-page">
<View className="login-nav">
<View className="login-nav-back" onClick={cancelLogin}>
<Text className="login-nav-back-icon"></Text>
<Text></Text>
</View>
</View>
<View className="login-header">
<View className="login-logo-wrap">
<View className="login-logo">
@@ -309,7 +401,7 @@ export default function LoginPage() {
{completeMode === 'phone'
? '建议绑定手机号'
: completeMode === 'wechat'
? '完成微信授权'
? '完成授权登录'
: '欢迎来到杜康好客'}
</Text>
<Text className="login-welcome-sub">
@@ -325,77 +417,100 @@ export default function LoginPage() {
<View className="login-main">
{completeMode === 'wechat' ? (
<View className="login-card">
<Text className="login-card-title"></Text>
<Text className="login-card-title"></Text>
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
使
使
</Text>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
</View>
{showWechatLogin ? (
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
) : null}
{showAuthLogin ? (
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
) : null}
</View>
) : (
<View className="login-card">
<Text className="login-card-title">{cardTitle}</Text>
<View className="login-field">
<Text className="login-field-prefix">+86</Text>
<Input
className="login-field-input"
type="number"
maxlength={11}
placeholder="请输入手机号"
value={phone}
onInput={(e) => {
setPhone(normalizePhone(e.detail.value));
setMsg('');
setSentHint('');
}}
/>
</View>
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
<View className="login-field">
<Input
className="login-field-input"
type="number"
maxlength={6}
placeholder="请输入验证码"
value={code}
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
{showPhoneQuick ? (
<PhoneQuickLoginButton
loading={phoneQuickLoading}
agreed={agreed}
onRequireAgree={() => ensureAgreed()}
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
onFail={(message) => setMsg(message)}
/>
<Text
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
onClick={() => void onSendCode()}
>
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
</Text>
</View>
) : null}
{showPhoneQuick ? (
<View className="login-divider" style={{ marginTop: 20 }}>
<View className="login-divider-line" />
<Text
className="login-divider-text"
onClick={() => setShowSmsForm((v) => !v)}
>
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
</Text>
<View className="login-divider-line" />
</View>
) : null}
{(showSmsForm || !showPhoneQuick) && (
<>
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
<Text className="login-field-prefix">+86</Text>
<Input
className="login-field-input"
type="number"
maxlength={11}
placeholder="请输入手机号"
value={phone}
onInput={(e) => {
setPhone(normalizePhone(e.detail.value));
setMsg('');
setSentHint('');
}}
/>
</View>
<View className="login-field">
<Input
className="login-field-input"
type="number"
maxlength={6}
placeholder="请输入验证码"
value={code}
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
/>
<Text
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
onClick={() => void onSendCode()}
>
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
</Text>
</View>
<View
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
onClick={loading ? undefined : () => void login()}
>
<Text className="login-sms-btn__text">
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '验证码登录'}
</Text>
</View>
</>
)}
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
@@ -403,48 +518,6 @@ export default function LoginPage() {
</Text>
) : null}
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
{agreed ? <Text></Text> : null}
</View>
<Text className="login-agreement-text">
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/user-agreement/index' });
}}
>
</Text>
<Text
className="login-agreement-link"
onClick={(e) => {
e.stopPropagation();
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
}}
>
</Text>
</Text>
</View>
<View
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
onClick={loading ? undefined : () => void login()}
>
<Text className="login-sms-btn__text">
{loading
? '处理中...'
: completeMode === 'phone'
? '完成验证'
: bindMode
? '绑定并登录'
: '登录'}
</Text>
</View>
{completeMode === 'phone' ? (
<View
className="login-skip-bind"
@@ -459,16 +532,21 @@ export default function LoginPage() {
</View>
)}
{showWechatLogin && completeMode !== 'wechat' ? (
{showAuthLogin && completeMode !== 'wechat' ? (
<>
<View className="login-divider">
<View className="login-divider-line" />
<Text className="login-divider-text"></Text>
<View className="login-divider-line" />
</View>
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
</>
) : null}
<View className="login-cancel-btn" onClick={cancelLogin}>
<Text></Text>
</View>
<Text className="login-cancel-hint"></Text>
</View>
</PageShell>
);
@@ -1,4 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '我的',
});
+229 -101
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { View, Text, Image } from '@tarojs/components';
import { View, Text, Image, Button, Input } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
@@ -8,10 +8,13 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth';
import { fetchUserProfile } from '../../lib/pay-wechat';
import {
fetchMiniWechatUserInfo,
isDefaultMiniNickname,
mergeWxDisplayProfile,
needsWxProfileFill,
uploadAvatarTempFile,
uploadMiniWechatProfile,
} from '../../lib/mini-wechat-profile';
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
import { isWechatEnv } from '../../lib/weixin';
@@ -29,6 +32,8 @@ const SERVICES = [
{ icon: '关', label: '关于我们', action: 'about' as const },
] as const;
const isWeapp = process.env.TARO_ENV === 'weapp';
function formatMoney(amount: number) {
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
@@ -40,15 +45,27 @@ export default function MinePage() {
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
const [wxAuthorize, setWxAuthorize] = useState(true);
const [bindingWx, setBindingWx] = useState(false);
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
function resetGuestState() {
setProfile(null);
setBenefitBalance(0);
setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
setProfile(mergeWxDisplayProfile(me));
}
function loadProfile() {
if (!isLoggedIn()) return;
setProfileLoadError('');
Promise.all([
request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
@@ -57,7 +74,7 @@ export default function MinePage() {
),
])
.then(([me, coupons, ...totals]) => {
setProfile(mergeWxDisplayProfile(me));
applyProfile(me);
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
return sum;
@@ -69,7 +86,16 @@ export default function MinePage() {
});
setOrderCounts(counts);
})
.catch(() => {});
.catch((error) => {
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
}
useDidShow(() => {
@@ -89,93 +115,125 @@ export default function MinePage() {
.catch(() => setWxAuthorize(true));
}, []);
async function handleAvatarTap() {
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (bindingWx) return;
let current = profile;
if (!current) {
try {
current = await fetchUserProfile();
setProfile(current);
} catch {
/* ignore */
}
}
if (current?.hasWechat) return;
async function ensureWechatBound(): Promise<boolean> {
if (profile?.hasWechat) return true;
if (!wxAuthorize) {
toast('当前环境未开启微信授权');
return;
return false;
}
setBindingWx(true);
try {
if (process.env.TARO_ENV === 'h5') {
if (!isWeapp) {
if (!isWechatEnv()) {
toast('请在微信内打开后授权');
return;
return false;
}
const result = await bindWechatForUser();
if (!result.ok && 'redirecting' in result && result.redirecting) {
return;
}
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', {
bindMode: '1',
wxSessionKey: result.wxSessionKey,
});
return;
return false;
}
if (result.ok) {
setProfile(
mergeWxDisplayProfile({
...(result.profile ?? {}),
id: result.profile?.id ?? profile?.id ?? '',
hasWechat: true,
}),
);
loadProfile();
toast('微信授权成功', 'success');
if (result.ok && result.profile) {
applyProfile({ ...result.profile, hasWechat: true });
return true;
}
return;
return false;
}
let wxInfo = null;
try {
wxInfo = await fetchMiniWechatUserInfo();
} catch (e) {
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
return;
}
const result = await bindWechatForUser(wxInfo);
const result = await bindWechatForUser(null);
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
return;
return false;
}
if (result.ok) {
const merged = mergeWxDisplayProfile({
...(result.profile ?? {}),
id: result.profile?.id ?? profile?.id ?? '',
hasWechat: true,
nickname: result.profile?.nickname || wxInfo.nickname || profile?.nickname,
avatarUrl: result.profile?.avatarUrl || wxInfo.avatarUrl || profile?.avatarUrl,
});
setProfile(merged);
loadProfile();
toast('微信授权成功', 'success');
if (result.ok && result.profile) {
applyProfile({ ...result.profile, hasWechat: true });
return true;
}
return false;
} catch (e) {
toast(e instanceof Error ? e.message : '微信授权失败');
toast(e instanceof Error ? e.message : '授权失败');
return false;
} finally {
setBindingWx(false);
}
}
function openProfileSheet(me?: UserProfile | null) {
const base = mergeWxDisplayProfile(
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
setDraftAvatarTemp('');
setDraftAvatarUrl(base.avatarUrl || '');
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
setProfileSheetOpen(true);
}
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) return;
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (isWeapp) {
openProfileSheet();
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
return;
}
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
const tempPath = e.detail?.avatarUrl?.trim();
if (!tempPath) {
toast('未获取到头像,请重试');
return;
}
setDraftAvatarTemp(tempPath);
setDraftAvatarUrl(tempPath);
}
async function saveWxProfile() {
const nickname = draftNickname.trim();
if (!nickname) {
toast('请填写昵称');
return;
}
if (!draftAvatarTemp && !draftAvatarUrl) {
toast('请选择头像');
return;
}
setSavingProfile(true);
try {
let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) {
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
}
const updated = await uploadMiniWechatProfile({
nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false);
toast('头像昵称已更新', 'success');
loadProfile();
} catch (err) {
toast(err instanceof Error ? err.message : '保存失败');
} finally {
setSavingProfile(false);
}
}
function handleService(item: (typeof SERVICES)[number]) {
if ('url' in item && item.url) {
Taro.navigateTo({ url: item.url });
@@ -190,17 +248,6 @@ export default function MinePage() {
}
}
function resolveDisplayProfile(profile: UserProfile | null, hasWechat: boolean) {
if (!profile) {
return { nickname: '用户', avatarUrl: null as string | null };
}
const merged = hasWechat ? mergeWxDisplayProfile(profile) : profile;
return {
nickname: merged.nickname || '用户',
avatarUrl: merged.avatarUrl || null,
};
}
function renderAvatarContent(displayAvatarUrl: string | null) {
if (displayAvatarUrl) {
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
@@ -210,10 +257,7 @@ export default function MinePage() {
if (!authed) {
return (
<PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<PageShell variant="tab" className="mine-page no-tab-header">
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -230,11 +274,10 @@ export default function MinePage() {
</View>
</View>
<View className="mine-login-gate">
<View className="mine-login-gate-hint"></View>
<View
className="mine-login-btn"
onClick={() => goLogin('/pages/mine/index')}
>
<View className="mine-login-gate-hint">
</View>
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
<Text></Text>
</View>
</View>
@@ -244,42 +287,75 @@ export default function MinePage() {
}
const hasWechat = !!profile?.hasWechat;
const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp';
const display = resolveDisplayProfile(profile, hasWechat);
const nickname = display.nickname;
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
const display = mergeWxDisplayProfile(
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
);
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
throw new Error('wx profile helpers missing');
}
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const memberLabel = needProfileFill
? '点击头像完善资料'
: isWeapp || hasWechat
? '好客会员'
: canWxAuth
? '点击头像授权'
: '好客会员';
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
const previewAvatar = draftAvatarUrl || display.avatarUrl;
return (
<PageShell
variant="tab"
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<PageShell variant="tab" className="mine-page no-tab-header">
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
<View className="mine-profile">
<View
className={`mine-avatar-wrap${!hasWechat && canWxBind ? ' mine-avatar-wrap--action' : ''}`}
onClick={() => void handleAvatarTap()}
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<View
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : canWxBind ? ' mine-avatar--wx-pending' : ''}`}
className={`mine-avatar${
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
>
{renderAvatarContent(display.avatarUrl)}
</View>
{!hasWechat && canWxBind ? (
<View className="mine-avatar-status mine-avatar-status--pending">
<Text>{bindingWx ? '授权中' : '去授权'}</Text>
{avatarClickable ? (
<View
className={`mine-avatar-status${
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View>
) : null}
</View>
<View>
<View
className="mine-profile-meta"
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<Text className="mine-profile-name">{nickname}</Text>
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel}
</Text>
{!hasWechat && canWxBind ? (
<Text className="mine-wechat-hint"></Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null}
</View>
</View>
@@ -376,6 +452,58 @@ export default function MinePage() {
</View>
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
{profileSheetOpen ? (
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
<Text className="mine-profile-sheet-title"></Text>
<Text className="mine-profile-sheet-hint">
</Text>
<Button
className="mine-profile-avatar-btn"
openType="chooseAvatar"
hoverClass="none"
onChooseAvatar={onChooseAvatar}
>
<View className="mine-profile-avatar-preview">
{previewAvatar ? (
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
) : (
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
)}
</View>
<Text className="mine-profile-avatar-tip"></Text>
</Button>
<View className="mine-profile-nickname-wrap">
<Text className="mine-profile-nickname-label"></Text>
<Input
className="mine-profile-nickname-input"
type="nickname"
maxlength={32}
placeholder="点击填写昵称"
value={draftNickname}
onInput={(e) => setDraftNickname(e.detail.value)}
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
/>
</View>
<View className="mine-profile-sheet-actions">
<View
className="mine-profile-sheet-cancel"
onClick={() => !savingProfile && setProfileSheetOpen(false)}
>
<Text></Text>
</View>
<View
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
onClick={savingProfile ? undefined : () => void saveWxProfile()}
>
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
</View>
</View>
</View>
</View>
) : null}
</PageShell>
);
}
+48 -13
View File
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
@@ -14,6 +15,8 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = {
productName?: string;
productSpec?: string;
@@ -104,8 +107,20 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
}
function goCustomerService() {
Taro.navigateTo({ url: '/pages/customer-service/index' });
}
const pageClass = [
'order-detail-page',
order ? 'order-detail-page--with-actions' : '',
canPay ? 'order-detail-page--with-pay' : '',
]
.filter(Boolean)
.join(' ');
return (
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
<PageShell variant="sub" className={pageClass}>
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
@@ -173,19 +188,39 @@ export default function OrderDetailPage() {
)}
</View>
{canPay && order && (
<View className="pay-bar order-detail-pay-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
{order ? (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
{isWeapp ? (
<ContactCsButton
className="order-detail-cs-btn"
session={{
from: 'order-detail',
orderId: order.id,
orderNo: order.orderNo,
}}
>
</ContactCsButton>
) : (
<View className="order-detail-cs-btn" onClick={goCustomerService}>
<Text></Text>
</View>
)}
{canPay ? (
<>
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
</>
) : null}
</View>
)}
) : null}
</PageShell>
);
}
@@ -1,4 +1,4 @@
import { View, Text, ScrollView } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
@@ -8,21 +8,25 @@ export default function PrivacyPolicyPage() {
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page">
<ScrollView scrollY className="legal-scroll">
<Text className="legal-updated">{doc.updatedAt}</Text>
<Text className="legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
<View className="sub-page-body inset-page legal-body">
<Text className="legal-updated" selectable>
{doc.updatedAt}
</Text>
<Text className="legal-intro" selectable>
{doc.intro}
</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading" selectable>
{section.heading}
</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
{p}
</Text>
))}
</View>
))}
</View>
</PageShell>
);
@@ -101,8 +101,10 @@ export default function RedeemPage() {
className="redeem-input"
type="digit"
placeholder="输入核销金额"
placeholderClass="redeem-input-placeholder"
value={amount}
onInput={(e) => setAmount(e.detail.value)}
style={{ textAlign: 'center' }}
/>
</View>
<View className="redeem-amount-foot">
@@ -1,4 +1,3 @@
export default definePageConfig({
navigationStyle: 'custom',
navigationBarTitleText: '门店',
});
+1 -4
View File
@@ -73,10 +73,7 @@ export default function StoresPage() {
}
return (
<PageShell
variant="tab"
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
>
<PageShell variant="tab" className="store-page no-tab-header">
<TabMainHeader title="门店" />
<View className="store-toolbar">
<View className="store-location" onClick={() => setRegionOpen(true)}>
@@ -1,4 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '用户协议',
navigationBarTitleText: '用户服务协议',
navigationStyle: 'custom',
});
@@ -1,4 +1,4 @@
import { View, Text, ScrollView } from '@tarojs/components';
import { View, Text } from '@tarojs/components';
import { getLegalDocument } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
@@ -8,21 +8,25 @@ export default function UserAgreementPage() {
return (
<PageShell variant="sub" className="legal-page">
<SubPageHeader title={doc.title} />
<View className="sub-page-body inset-page">
<ScrollView scrollY className="legal-scroll">
<Text className="legal-updated">{doc.updatedAt}</Text>
<Text className="legal-intro">{doc.intro}</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading">{section.heading}</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
{p}
</Text>
))}
</View>
))}
</ScrollView>
<View className="sub-page-body inset-page legal-body">
<Text className="legal-updated" selectable>
{doc.updatedAt}
</Text>
<Text className="legal-intro" selectable>
{doc.intro}
</Text>
{doc.sections.map((section) => (
<View key={section.heading} className="legal-section">
<Text className="legal-heading" selectable>
{section.heading}
</Text>
{section.paragraphs.map((p, i) => (
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
{p}
</Text>
))}
</View>
))}
</View>
</PageShell>
);
-12
View File
@@ -120,19 +120,7 @@
font-size: 18px;
}
.benefit-hero-actions {
display: flex;
margin-top: 8px;
}
.benefit-hero-link {
font-size: 13px;
color: var(--color-heritage-red);
margin-right: 16px;
}
.benefit-hero-cta {
flex: 1;
height: 44px;
border-radius: var(--radius-full);
background: var(--color-heritage-red);
-4
View File
@@ -81,10 +81,6 @@
border-bottom-color: var(--color-heritage-red);
}
.home-aroma-tab--muted {
opacity: 0.65;
}
.home-product-list {
display: flex;
flex-direction: column;
+13 -9
View File
@@ -1,21 +1,24 @@
.legal-page .legal-scroll {
height: 100%;
.legal-page .legal-body {
min-height: calc(100vh - var(--nav-bar-height, 88px));
padding-top: 8px;
padding-bottom: 40px;
box-sizing: border-box;
padding-bottom: 32px;
background: var(--color-background, #f7f4ef);
}
.legal-updated {
display: block;
font-size: 12px;
color: var(--color-on-surface-variant, #8d706e);
color: #8d706e;
margin-bottom: 12px;
line-height: 1.5;
}
.legal-intro {
display: block;
font-size: 14px;
line-height: 1.7;
color: var(--color-on-surface, #1f1a17);
line-height: 1.75;
color: #1f1a17;
margin-bottom: 20px;
}
@@ -27,14 +30,15 @@
display: block;
font-size: 15px;
font-weight: 600;
color: var(--color-on-surface, #1f1a17);
color: #1f1a17;
margin-bottom: 8px;
line-height: 1.5;
}
.legal-paragraph {
display: block;
font-size: 13px;
line-height: 1.7;
color: var(--color-on-surface-variant, #5c504c);
line-height: 1.75;
color: #3d3530;
margin-bottom: 8px;
}
+60 -30
View File
@@ -234,7 +234,8 @@
flex-shrink: 0;
}
.login-wechat-btn {
.login-wechat-btn,
.login-phone-quick-btn {
width: 100%;
height: 56px;
border: none;
@@ -250,52 +251,33 @@
font-weight: 600;
line-height: 1;
padding: 0;
margin: 0;
box-sizing: border-box;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
}
.login-wechat-btn:active {
.login-wechat-btn:active,
.login-phone-quick-btn:active {
transform: scale(0.98);
}
.login-wechat-btn--disabled {
.login-wechat-btn--disabled,
.login-phone-quick-btn--disabled {
opacity: 0.7;
pointer-events: none;
}
.login-wechat-btn__text {
.login-wechat-btn__text,
.login-phone-quick-btn__text {
color: #fff;
font-size: 18px;
font-weight: 600;
line-height: 26px;
}
.wechat-login-icon {
position: relative;
width: 24px;
height: 24px;
flex-shrink: 0;
}
.wechat-login-icon__big,
.wechat-login-icon__small {
position: absolute;
border-radius: 50%;
background: #fff;
}
.wechat-login-icon__big {
width: 16px;
height: 16px;
left: 0;
top: 5px;
}
.wechat-login-icon__small {
width: 11px;
height: 11px;
right: 0;
bottom: 3px;
/* 重置小程序 Button 默认样式,避免绿边/微信绿 */
.login-phone-quick-btn::after {
border: none;
}
.login-footer {
@@ -344,3 +326,51 @@
.login-agreement-link {
color: var(--color-heritage-red);
}
.login-nav {
min-height: 44px;
padding: 8px var(--space-page) 0;
display: flex;
align-items: center;
box-sizing: border-box;
}
.login-nav-back {
min-width: 72px;
min-height: 40px;
display: flex;
align-items: center;
gap: 4px;
color: var(--color-on-surface);
font-size: 16px;
font-weight: 600;
}
.login-nav-back-icon {
font-size: 30px;
line-height: 1;
}
.login-cancel-btn {
width: 100%;
min-height: 48px;
margin-top: 20px;
border: 1px solid var(--color-heritage-red);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
color: var(--color-heritage-red);
font-size: 16px;
font-weight: 700;
background: #fff;
}
.login-cancel-hint {
display: block;
margin-top: 10px;
text-align: center;
color: var(--color-on-surface-variant);
font-size: 13px;
}
+136 -7
View File
@@ -46,6 +46,11 @@
cursor: pointer;
}
.mine-profile-meta {
min-width: 0;
flex: 1;
}
.mine-avatar-status {
position: absolute;
left: 50%;
@@ -86,13 +91,6 @@
background: rgba(255, 255, 255, 0.25);
}
.mine-wechat-hint {
display: block;
margin-top: 4px;
font-size: 10px;
color: rgba(255, 255, 255, 0.75);
}
.mine-avatar {
width: 64px;
height: 64px;
@@ -151,6 +149,14 @@
color: rgba(255, 255, 255, 0.9);
}
.mine-profile-retry {
display: block;
margin-top: 6px;
font-size: 11px;
color: #fff;
text-decoration: underline;
}
.mine-main {
margin-top: -28px;
position: relative;
@@ -369,3 +375,126 @@
font-size: 15px;
font-weight: 600;
}
.mine-profile-sheet-mask {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(20, 16, 14, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
}
.mine-profile-sheet {
width: 100%;
max-width: 480px;
box-sizing: border-box;
background: #fff;
border-radius: 20px 20px 0 0;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
}
.mine-profile-sheet-title {
display: block;
font-size: 17px;
font-weight: 700;
color: #1f1a17;
text-align: center;
}
.mine-profile-sheet-hint {
display: block;
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: #8d706e;
text-align: center;
}
.mine-profile-avatar-btn {
margin: 20px auto 0;
padding: 0;
width: auto;
background: transparent;
border: none;
line-height: 1.2;
display: flex;
flex-direction: column;
align-items: center;
}
.mine-profile-avatar-btn::after {
border: none;
}
.mine-profile-avatar-preview {
width: 88px;
height: 88px;
border-radius: 50%;
overflow: hidden;
border: 2px solid rgba(166, 29, 36, 0.25);
background: #f7f4ef;
}
.mine-profile-avatar-tip {
margin-top: 8px;
font-size: 12px;
color: var(--color-heritage-red);
font-weight: 600;
}
.mine-profile-nickname-wrap {
margin-top: 18px;
}
.mine-profile-nickname-label {
display: block;
font-size: 13px;
color: #5c504c;
margin-bottom: 8px;
}
.mine-profile-nickname-input {
width: 100%;
box-sizing: border-box;
height: 44px;
padding: 0 14px;
border-radius: 12px;
background: #f7f4ef;
font-size: 15px;
color: #1f1a17;
}
.mine-profile-sheet-actions {
margin-top: 22px;
display: flex;
gap: 12px;
}
.mine-profile-sheet-cancel,
.mine-profile-sheet-save {
flex: 1;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
}
.mine-profile-sheet-cancel {
background: #f0ebe4;
color: #5c504c;
}
.mine-profile-sheet-save {
background: var(--color-heritage-red);
color: #fff;
}
.mine-profile-sheet-save.is-disabled {
opacity: 0.65;
pointer-events: none;
}
+5 -1
View File
@@ -20,11 +20,15 @@
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
/* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
/* 无自定义 Tab 顶栏:贴顶(标题走小程序/H5 系统导航栏) */
.page-shell.no-tab-header,
.page-shell.h5-no-tab-header {
--nav-bar-height: 0px;
--nav-status-bar-height: 0px;
--nav-content-height: 0px;
}
.page-shell.no-tab-header .home-aroma-nav,
.page-shell.h5-no-tab-header .home-aroma-nav {
top: 0;
}
+53 -1
View File
@@ -7,7 +7,8 @@
min-height: 100vh;
}
.order-detail-page--with-pay .sub-page-body {
.order-detail-page--with-pay .sub-page-body,
.order-detail-page--with-actions .sub-page-body {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
@@ -16,6 +17,57 @@
gap: 12px;
}
.order-detail-actionbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 10px;
padding: 10px var(--space-page) calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--color-card, #fff);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
}
.order-detail-actionbar--with-pay {
justify-content: space-between;
}
.order-detail-cs-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 96px;
height: 40px;
padding: 0 14px;
margin: 0;
border: 1px solid var(--color-outline, #c8c4be);
border-radius: 999px;
background: transparent;
color: var(--color-on-surface);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
box-sizing: border-box;
}
.order-detail-cs-btn::after {
border: none;
}
.order-detail-actionbar .order-confirm-total {
flex: 1;
min-width: 0;
}
.order-detail-actionbar .order-confirm-submit {
flex-shrink: 0;
}
.order-card {
background: var(--color-card);
border-radius: var(--radius-lg);
+65 -9
View File
@@ -47,16 +47,20 @@
color: var(--color-heritage-red);
text-align: center;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.redeem-input-placeholder {
color: var(--color-subtle-gray);
text-align: center;
}
/* 小程序原生 inputtext-align 需落到组件自身与内部节点 */
.redeem-input,
.redeem-input input,
.redeem-input .taro-input,
.redeem-input .weui-input {
width: 100% !important;
height: 100% !important;
height: 52px !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
@@ -65,8 +69,8 @@
box-sizing: border-box !important;
font-size: 24px !important;
font-weight: 700 !important;
line-height: normal !important;
color: inherit;
line-height: 52px !important;
color: var(--color-heritage-red);
text-align: center !important;
}
@@ -495,27 +499,70 @@
text-align: center;
}
.cs-brand {
display: block;
font-family: var(--font-headline);
font-size: 22px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 10px;
}
.cs-title {
font-size: 14px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
}
.cs-phone {
.cs-phone,
.cs-phone-display {
display: block;
font-family: var(--font-headline);
font-size: 32px;
font-size: 28px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 8px;
margin-top: 20px;
letter-spacing: 1px;
}
.cs-hint {
display: block;
font-size: 14px;
color: var(--color-subtle-gray);
line-height: 1.5;
margin-bottom: 8px;
max-width: 280px;
}
.cs-hours {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 32px;
}
.cs-online-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 280px;
padding: 14px 24px;
margin: 0;
border: none;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
box-sizing: border-box;
}
.cs-online-btn::after {
border: none;
}
.cs-call-btn {
padding: 12px 32px;
border-radius: 999px;
@@ -524,3 +571,12 @@
font-size: 15px;
font-weight: 600;
}
.cs-phone-link {
margin-top: 20px;
padding: 8px 12px;
font-size: 13px;
color: var(--color-subtle-gray);
text-decoration: underline;
text-underline-offset: 3px;
}
+77
View File
@@ -0,0 +1,77 @@
#!/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
@@ -1,15 +0,0 @@
# 复制为 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
# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
# 服务器路径: /opt/dukang/deploy/auto-release.env
APP_ROOT=/opt/dukang-haoke
APP_ROOT=/opt/dukang
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
GIT_REMOTE=origin
GIT_BRANCH=dev
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="$SCRIPT_DIR/auto-release.env"
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
APP_ROOT="${APP_ROOT:-/opt/dukang}"
GIT_REMOTE="${GIT_REMOTE:-origin}"
GIT_BRANCH="${GIT_BRANCH:-dev}"
LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}"
+1 -1
View File
@@ -4,7 +4,7 @@ DEPLOY_USER=root
DEPLOY_PORT=22
# DEPLOY_SSH_KEY=~/.ssh/id_rsa
APP_ROOT=/opt/dukang-haoke
APP_ROOT=/opt/dukang
# 生产仓库(SSH
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
GIT_REMOTE=origin
+1 -1
View File
@@ -9,7 +9,7 @@ DEPLOY_HOST=""
DEPLOY_USER="root"
DEPLOY_PORT="22"
DEPLOY_SSH_KEY=""
APP_ROOT="/opt/dukang-haoke"
APP_ROOT="/opt/dukang"
GIT_REMOTE="origin"
GIT_BRANCH="dev"
+1 -1
View File
@@ -1,5 +1,5 @@
/** PM2 ecosystem — 杜康好客生产部署 */
const APP_ROOT = '/opt/dukang-haoke';
const APP_ROOT = '/opt/dukang';
module.exports = {
apps: [
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# 启用 api.dukanghaoke.com:扩证书 + 装 nginx 配置
set -euo pipefail
DOMAIN_API=api.dukanghaoke.com
CERT_NAME=user.dukanghaoke.com
EMAIL="${CERTBOT_EMAIL:-admin@dukanghaoke.com}"
APP_ROOT="${APP_ROOT:-/opt/dukang}"
NGINX_SRC="$APP_ROOT/deploy/nginx-dukanghaoke.conf"
NGINX_DST="/etc/nginx/sites-available/dukang"
mkdir -p /var/www/certbot/.well-known/acme-challenge
mkdir -p /var/log/nginx/dukang
echo "==> 1. 临时 HTTP 放行 api 域名(便于 http-01"
# 先写入含 api 的 80 server,若证书尚未含 api,443 块可暂用现有证书
if [[ -f "$NGINX_SRC" ]]; then
install -m 644 "$NGINX_SRC" "$NGINX_DST"
ln -sfn "$NGINX_DST" /etc/nginx/sites-enabled/dukang
fi
nginx -t
systemctl reload nginx
echo "==> 2. 扩展证书加入 $DOMAIN_API"
certbot certonly --nginx \
--cert-name "$CERT_NAME" \
--expand \
-d user.dukanghaoke.com \
-d shop.dukanghaoke.com \
-d partner.dukanghaoke.com \
-d admin.dukanghaoke.com \
-d api.dukanghaoke.com \
--email "$EMAIL" \
--agree-tos \
--non-interactive \
--keep-until-expiring
echo "==> 3. 重载 nginx"
nginx -t
systemctl reload nginx
echo "==> 4. 健康检查"
sleep 1
curl -sf -o /dev/null -w "api-https:%{http_code} content-type:%{content_type}\n" \
"https://${DOMAIN_API}/api/v1/health"
curl -sf "https://${DOMAIN_API}/api/v1/health"; echo
echo "==> 完成"
+1 -1
View File
@@ -1,4 +1,4 @@
# 杜康好客 — CodeUp Webhook 反代(挂到 dkapi.runxian.top 443/80 server 块内)
# 杜康好客 — CodeUp Webhook 反代(挂到 api.dukanghaoke.com 443/80 server 块内)
# setup-webhook.sh 会自动 include 此文件
location = /hooks/deploy {
+185
View File
@@ -0,0 +1,185 @@
# 杜康好客 — dukanghaoke.com
# HTTP → HTTPS
server {
listen 80;
server_name user.dukanghaoke.com shop.dukanghaoke.com partner.dukanghaoke.com admin.dukanghaoke.com api.dukanghaoke.com;
location ^~ /.well-known/acme-challenge/ {
root /var/www/certbot;
default_type "text/plain";
}
location / {
return 301 https://$host$request_uri;
}
}
# api.dukanghaoke.com → 8090
server {
listen 443 ssl;
http2 on;
server_name api.dukanghaoke.com;
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
include /opt/dukang/deploy/nginx-deploy-webhook.conf;
location / {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
# user.dukanghaoke.com → 8091
server {
listen 443 ssl;
http2 on;
server_name user.dukanghaoke.com;
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8091;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# shop.dukanghaoke.com → 8092
server {
listen 443 ssl;
http2 on;
server_name shop.dukanghaoke.com;
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8092;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# partner.dukanghaoke.com → 8093
server {
listen 443 ssl;
http2 on;
server_name partner.dukanghaoke.com;
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8093;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
# admin.dukanghaoke.com → 8094
server {
listen 443 ssl;
http2 on;
server_name admin.dukanghaoke.com;
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 50m;
location /api/ {
proxy_pass http://127.0.0.1:8090;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
location / {
proxy_pass http://127.0.0.1:8094;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
# 在服务器上执行:拉取后的常规发版(不覆盖 .env.production、默认不 seed
set -euo pipefail
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
APP_ROOT="${APP_ROOT:-/opt/dukang}"
DEPLOY_DIR="$APP_ROOT/deploy"
SKIP_BUILD=false
@@ -47,7 +47,7 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
# C 端 H5 编译期注入的 API origin(勿落到 localhost
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
export VITE_API_TARGET="${VITE_API_TARGET:-https://api.dukanghaoke.com}"
pnpm approve-builds --all 2>/dev/null || true
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
+19 -7
View File
@@ -30,13 +30,25 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
MARKER="include $HOOK_CONF;"
for conf in /etc/nginx/conf.d/dukang-runxian.conf /etc/nginx/conf.d/dukang-runxian-ssl.conf; do
if [[ -f "$conf" ]] && grep -q 'server_name dkapi.runxian.top' "$conf"; then
# 清理旧错误 include
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
for conf in \
/etc/nginx/sites-available/dukang \
/etc/nginx/conf.d/dukang-runxian.conf \
/etc/nginx/conf.d/dukang-runxian-ssl.conf
do
if [[ ! -f "$conf" ]]; then
continue
fi
# 清理旧错误 include
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
echo " 已 patch $conf (api.dukanghaoke.com)"
fi
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
if ! grep -qF "$MARKER" "$conf"; then
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
echo " 已 patch $conf"
echo " 已 patch $conf (dkapi.runxian.top)"
fi
fi
done
@@ -62,14 +74,14 @@ SECRET="$(grep DEPLOY_WEBHOOK_SECRET "$ENV_FILE" | cut -d= -f2- | tr -d '\"')"
echo ""
echo "=========================================="
echo " Webhook 已就绪"
echo " URL: https://dkapi.runxian.top/hooks/deploy"
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
echo " Secret: $SECRET"
echo " Branch: dev (refs/heads/dev)"
echo " Log: $LOG_DIR/deploy.log"
echo "=========================================="
echo ""
echo "CodeUp 配置:仓库 → 设置 → Webhooks → 添加"
echo " URL: https://dkapi.runxian.top/hooks/deploy"
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
echo " Secret Token: (与上方 Secret 相同)"
echo " 触发事件: Push events"
echo " 分支过滤: dev"
+1 -1
View File
@@ -11,7 +11,7 @@ DEPLOY_HOST=""
DEPLOY_USER="root"
DEPLOY_PORT="22"
DEPLOY_SSH_KEY=""
APP_ROOT="/opt/dukang-haoke"
APP_ROOT="/opt/dukang"
TARGET="${1:-production}"
case "$TARGET" in
+1
View File
@@ -17,6 +17,7 @@
"dev:hq": "pnpm --filter @dukang/mini-hq dev",
"dev:mini-user": "pnpm --filter @dukang/mini-user dev",
"dev:mini-user:weapp": "pnpm --filter @dukang/mini-user dev:weapp",
"build:mini-user:weapp": "pnpm --filter @dukang/mini-user build:weapp",
"preview:hq": "pnpm --filter @dukang/mini-hq build && pnpm --filter @dukang/mini-hq preview",
"build": "pnpm -r build",
"lint": "eslint .",
+7
View File
@@ -45,6 +45,13 @@ export const BRAND_LOGO_MARK_URL = `${BRAND_LOGO_OSS_BASE}logo2.png`;
/** 总部客服电话(C 端联系客服) */
export const CUSTOMER_SERVICE_PHONE = '400-888-1234';
/**
* 企业微信「微信客服」链接(C 端「在线客服」;微信内网页点击后进入原生客服会话)
* 可在 h5-user 用 VITE_CS_WECOM_URL 覆盖
*/
export const CUSTOMER_SERVICE_WECOM_URL =
'https://work.weixin.qq.com/kfid/kfc8b88659a1dffa8cd';
function readEnv(env?: Record<string, string | undefined>) {
return (
env ??
+4 -2
View File
@@ -9,6 +9,7 @@ export const HQ_PERMISSION_CATALOG = [
{ key: 'benefit', label: '好客权益' },
{ key: 'deliveries', label: '配送单' },
{ key: 'tickets', label: '工单中心' },
{ key: 'invoices', label: '发票管理' },
{ key: 'resources', label: 'OSS 资源库' },
{ key: 'logs', label: '日志' },
{ key: 'hq_permissions', label: '权限分配' },
@@ -38,9 +39,10 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'benefit',
'deliveries',
'tickets',
'invoices',
'resources',
'logs',
],
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'logs'],
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'logs'],
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'invoices', 'logs'],
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
};
+2
View File
@@ -9,7 +9,9 @@ export * from './redeem';
export * from './settlement';
export * from './ops';
export * from './ticket';
export * from './invoice';
export * from './user-log';
export * from './store-log';
export * from './partner-log';
export * from './promo';
+54
View File
@@ -0,0 +1,54 @@
export type InvoiceTitleType = 'PERSONAL' | 'ENTERPRISE';
export type InvoiceKind = 'NORMAL' | 'SPECIAL';
export type InvoiceStatus = 'PENDING' | 'ISSUED' | 'REJECTED';
export const INVOICE_TITLE_TYPE_LABELS: Record<InvoiceTitleType, string> = {
PERSONAL: '个人',
ENTERPRISE: '企业',
};
export const INVOICE_KIND_LABELS: Record<InvoiceKind, string> = {
NORMAL: '增值税普通发票',
SPECIAL: '增值税专用发票',
};
export const INVOICE_STATUS_LABELS: Record<InvoiceStatus, string> = {
PENDING: '待开票',
ISSUED: '已开票',
REJECTED: '已驳回',
};
export interface CreateInvoiceRequest {
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
taxNo?: string;
addressPhone?: string;
bankAccount?: string;
email: string;
phone: string;
remark?: string;
}
export interface InvoiceDto {
id: string;
invoiceNo: string;
orderId: string;
userId: string;
titleType: InvoiceTitleType;
invoiceKind: InvoiceKind;
titleName: string;
taxNo?: string | null;
addressPhone?: string | null;
bankAccount?: string | null;
email: string;
phone: string;
status: InvoiceStatus;
fileUrl?: string | null;
remark?: string | null;
issuedAt?: string | null;
createdAt: string;
orderNo?: string;
/** 待开票超过 2 个工作日(总部列表标红用) */
overdue?: boolean;
}
+11 -10
View File
@@ -21,10 +21,10 @@ export type LegalDocument = {
*/
export const USER_AGREEMENT: LegalDocument = {
id: 'user-agreement',
title: '用户协议',
updatedAt: '2026-07-15',
title: '用户服务协议',
updatedAt: '2026-07-21',
intro:
'欢迎使用「杜康好客」平台(含微信小程序、微信内置浏览器 H5 及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您勾选同意继续使用,即视为已阅读并接受本协议全部内容。',
'欢迎使用「杜康好客」平台(含小程序、移动网页及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您须主动勾选同意继续使用,不得默认强制同意;勾选即视为已阅读并接受本协议全部内容。',
sections: [
{
heading: '一、服务说明',
@@ -36,7 +36,7 @@ export const USER_AGREEMENT: LegalDocument = {
{
heading: '二、账号注册与安全',
paragraphs: [
'您可通过手机号验证码、微信授权等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
'您可通过手机号快捷登录、手机号验证码等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
],
@@ -88,20 +88,21 @@ export const USER_AGREEMENT: LegalDocument = {
export const PRIVACY_POLICY: LegalDocument = {
id: 'privacy-policy',
title: '隐私政策',
updatedAt: '2026-07-15',
updatedAt: '2026-07-21',
intro:
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您勾选同意,即表示您已充分理解并同意我们按本政策处理相关个人信息。',
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您须主动勾选同意后,我们才会按本政策处理相关个人信息;我们不会默认勾选或强制同意。',
sections: [
{
heading: '一、我们如何收集与使用个人信息',
paragraphs: [
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能收集并使用下列信息:',
'1)账号信息:手机号码、验证码、微信 OpenID/UnionID、昵称与头像(若您授权微信);用于注册登录、账号绑定与安全保障。',
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能在取得您授权同意后收集并使用下列信息:',
'1)账号信息:手机号码、验证码、开放平台账号标识(OpenID/UnionID,若您授权)、昵称与头像(若您主动授权);用于注册登录、账号绑定与安全保障。',
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
'我们不会以默认勾选等方式强制您同意本政策;未征得同意前,我们不会超范围收集与实现业务功能无关的个人信息。',
'收集目的与方式:仅在实现上述业务功能所必需的范围内,通过您主动填写、授权组件或系统必要日志收集;未征得同意前,我们不会超范围收集与业务无关的个人信息。',
'我们不会以默认勾选等方式强制您同意本政策。',
],
},
{
@@ -109,7 +110,7 @@ export const PRIVACY_POLICY: LegalDocument = {
paragraphs: [
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
'1)获得您的明确同意;',
'2)为实现支付、短信、配送、地图/定位、微信登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
'2)为实现支付、短信、配送、地图/定位、账号登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
'3)根据法律法规、行政或司法机关要求;',
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
],
+16
View File
@@ -45,6 +45,22 @@ export interface RedeemPhonePrepareDto {
expireInSeconds: number;
}
export interface RedeemPhoneDirectPrepareRequest {
phone: string;
amount: number;
}
export interface RedeemPhoneDirectPrepareResult extends RedeemPhonePrepareDto {
totalBalance: number;
maskedPhone: string;
user: {
id: string;
userNo?: string | null;
nickname?: string | null;
phone?: string | null;
};
}
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
+32 -1
View File
@@ -1,4 +1,28 @@
export type TicketTypeDto = 'REFUND' | 'RESHIPMENT' | 'ALERT';
/** 售后工单类型(PRD 四类型 + 系统 ALERT */
export type TicketTypeDto =
| 'REFUND'
| 'RESHIPMENT'
| 'ALERT'
| 'DAMAGE_RETURN'
| 'RETURN_REFUND';
/** 用户可发起的售后类型 */
export const AFTER_SALE_TICKET_TYPES = [
'REFUND',
'RESHIPMENT',
'DAMAGE_RETURN',
'RETURN_REFUND',
] as const;
export type AfterSaleTicketType = (typeof AFTER_SALE_TICKET_TYPES)[number];
export const TICKET_TYPE_LABELS: Record<TicketTypeDto, string> = {
REFUND: '仅退款',
RESHIPMENT: '破损补发',
ALERT: '异常',
DAMAGE_RETURN: '破损退货',
RETURN_REFUND: '退货退款',
};
export interface TicketDto {
id: string;
@@ -8,9 +32,16 @@ export interface TicketDto {
refType: string;
refId: string;
remark?: string | null;
extraJson?: Record<string, unknown> | null;
createdAt: string;
}
export interface TicketActionRequest {
remark?: string;
}
export interface CreateAfterSaleTicketRequest {
ticketType: AfterSaleTicketType;
remark?: string;
evidenceUrls?: string[];
}
+1 -1
View File
@@ -50,7 +50,7 @@ WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
# 腾讯位置服务(逆地理编码,微信定位展示城市)
TENCENT_LBS_KEY=
+3 -2
View File
@@ -4,7 +4,8 @@
NODE_ENV=production
DATABASE_URL="mysql://dukang:CHANGE_ME@localhost:3306/dukang_haoke"
# 生产:阿里云 RDS(密码中的 @ ! 需 URL 编码为 %40 %21
DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_prod?charset=utf8mb4"
REDIS_URL="redis://localhost:6379"
JWT_SECRET="CHANGE_ME-strong-random-secret"
JWT_EXPIRES_IN="7d"
@@ -36,7 +37,7 @@ WX_MCH_SERIAL_NO=
WX_MCH_PRIVATE_KEY=
WX_API_V3_KEY=
WX_PLATFORM_CERT=
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
+52
View File
@@ -69,6 +69,7 @@ enum EventType {
BENEFIT_LEDGER
HQ_OPERATION
PROMO_TOUCH
TICKET_COLLAB
}
enum ActorType {
@@ -83,6 +84,24 @@ enum TicketType {
REFUND
RESHIPMENT
ALERT
DAMAGE_RETURN
RETURN_REFUND
}
enum InvoiceTitleType {
PERSONAL
ENTERPRISE
}
enum InvoiceKind {
NORMAL
SPECIAL
}
enum InvoiceStatus {
PENDING
ISSUED
REJECTED
}
enum AromaType {
@@ -705,6 +724,7 @@ model User {
promoTouch UserPromoAttribution?
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
orders Order[]
invoices UserInvoice[]
benefitCoupons BenefitCoupon[]
redeemRecords RedeemRecord[]
redeemPendingRecords RedeemPendingRecord[]
@@ -919,6 +939,7 @@ model Order {
imageResource CommonResource? @relation("OrderProductImage", fields: [imageResourceId], references: [id], onDelete: SetNull)
delivery OrderDelivery?
benefitCoupon BenefitCoupon?
invoices UserInvoice[]
@@index([userId, status])
@@index([cityId, createdAt])
@@ -931,6 +952,37 @@ model Order {
@@map("user_order")
}
model UserInvoice {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
invoiceNo String @unique @map("invoice_no") @db.VarChar(32)
orderId BigInt @map("order_id") @db.UnsignedBigInt
userId BigInt @map("user_id") @db.UnsignedBigInt
titleType InvoiceTitleType @map("title_type")
invoiceKind InvoiceKind @map("invoice_kind")
titleName String @map("title_name") @db.VarChar(128)
taxNo String? @map("tax_no") @db.VarChar(32)
addressPhone String? @map("address_phone") @db.VarChar(256)
bankAccount String? @map("bank_account") @db.VarChar(256)
email String @db.VarChar(128)
phone String @db.VarChar(20)
status InvoiceStatus @default(PENDING)
fileUrl String? @map("file_url") @db.VarChar(512)
resourceId BigInt? @map("resource_id") @db.UnsignedBigInt
issuedAt DateTime? @map("issued_at") @db.DateTime(3)
operatorId BigInt? @map("operator_id") @db.UnsignedBigInt
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
@@index([userId, status])
@@index([orderId])
@@index([status, createdAt])
@@map("user_invoice")
}
model OrderDelivery {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
orderId BigInt @unique @map("order_id") @db.UnsignedBigInt
@@ -62,6 +62,7 @@ enum EventType {
BENEFIT_LEDGER
HQ_OPERATION
PROMO_TOUCH
TICKET_COLLAB
}
enum ActorType {
@@ -1,7 +1,10 @@
import { resolve } from 'path';
import { PrismaClient } from '@prisma/client';
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
function apiRoot() {
return resolve(__dirname, '..', '..');
}
@@ -11,6 +14,11 @@ export function resolveEnvFilePath() {
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
}
function normalizeConfigValue(key: string, value: string): string {
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
return value;
}
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
export async function preloadSystemConfigEnv(): Promise<number> {
const prisma = new PrismaClient();
@@ -18,7 +26,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
const rows = await prisma.systemConfig.findMany();
for (const row of rows) {
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
process.env[row.configKey] = row.value;
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
}
}
return rows.length;
@@ -41,7 +49,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
export function applyEnvOverlay(values: Record<string, string>) {
for (const [key, value] of Object.entries(values)) {
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
process.env[key] = value;
process.env[key] = normalizeConfigValue(key, value);
}
}
}
@@ -205,17 +205,31 @@ export class SystemConfigService implements OnModuleInit {
}
}
private normalizeByMeta(meta: { type: string }, raw: string): string {
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
if (meta.type === 'boolean') {
return raw === 'true' || raw === '1' ? 'true' : 'false';
}
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
let value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).trim();
}
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
}
return raw;
}
}
function formatEnvLine(key: string, value: string): string {
if (/[\s#"'\\]/.test(value)) {
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return `${key}=${value}`;
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
if (!needsQuote) return `${key}=${value}`;
const escaped = value
.replace(/\\/g, '\\\\')
.replace(/\r\n/g, '\\n')
.replace(/\n/g, '\\n')
.replace(/"/g, '\\"');
return `${key}="${escaped}"`;
}
@@ -67,3 +67,22 @@ export function safeEqual(a: string, b: string): boolean {
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* .env / system_config PEM
* - DB/
* - \\n
* OpenSSL 1E08010C DECODER unsupported
*/
export function normalizePemEnv(raw: string | undefined | null): string {
if (!raw) return '';
let value = String(raw).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1).trim();
}
value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
return value.trim();
}
@@ -7,6 +7,7 @@ import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './w
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
import {
decryptPayResource,
normalizePemEnv,
verifyPaySignature,
type WechatPayNotifyEnvelope,
} from './wechat-pay.util';
@@ -15,6 +16,7 @@ type TokenCache = { accessToken: string; expiresAt: number };
type TicketCache = { ticket: string; expiresAt: number };
const ACCESS_TOKEN_KEY = 'wechat:access_token';
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
@Injectable()
@@ -27,10 +29,10 @@ export class WechatApiProvider implements IWechatProvider {
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
private readonly mchId = process.env.WX_MCH_ID ?? '';
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
constructor(
private readonly redis: RedisService,
@@ -254,7 +256,7 @@ export class WechatApiProvider implements IWechatProvider {
if (platform === 'h5') {
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
}
const accessToken = await this.getAccessToken();
const accessToken = await this.getMiniAccessToken();
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
const data = await this.fetchJson<{
errcode?: number;
@@ -432,6 +434,37 @@ export class WechatApiProvider implements IWechatProvider {
return data.access_token;
}
/** 小程序 access_tokengetPhoneNumber 等 wxa 接口必须用小程序 AppID) */
private async getMiniAccessToken(): Promise<string> {
const appId = this.miniAppId;
const appSecret = this.miniAppSecret;
if (!appId || !appSecret) {
throw new InternalServerErrorException(
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
);
}
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
url.searchParams.set('grant_type', 'client_credential');
url.searchParams.set('appid', appId);
url.searchParams.set('secret', appSecret);
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
url.toString(),
);
if (!data.access_token) {
throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败');
}
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
await this.redis.setJson(
MINI_ACCESS_TOKEN_KEY,
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
ttl,
);
return data.access_token;
}
private async getJsapiTicket(): Promise<string> {
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
@@ -16,7 +16,7 @@ import { ClientConfigController } from './client-config.controller';
import { WechatLocationService } from './wechat-location.service';
@Module({
imports: [IamModule, IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
controllers: [
ResourceController,
EventController,
@@ -143,7 +143,7 @@ export class CreateEventDto {
export class CreateTicketDto {
@IsString()
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT'])
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
ticketType: string;
@IsString()
@@ -165,6 +165,9 @@ export class CreateTicketDto {
@IsOptional()
@IsString()
param1Desc?: string;
@IsOptional()
extraJson?: Record<string, unknown>;
}
export class UpdateTicketStatusDto {

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