短信验证调试成功
This commit is contained in:
@@ -21,6 +21,7 @@ import ResourcesPage from './pages/ResourcesPage';
|
||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import UserLogsPage from './pages/UserLogsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
@@ -56,7 +57,8 @@ export default function App() {
|
||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||
<Route path="/logs/third-party" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SafetyOutlined,
|
||||
LogoutOutlined,
|
||||
CloudUploadOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||
|
||||
@@ -57,7 +58,15 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
},
|
||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
|
||||
{
|
||||
key: 'logs-group',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '日志',
|
||||
children: [
|
||||
{ key: '/logs/users', label: '用户日志' },
|
||||
{ key: '/logs/third-party', label: '第三方日志' },
|
||||
],
|
||||
},
|
||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
@@ -86,7 +95,7 @@ export default function AdminLayout() {
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group']}
|
||||
defaultOpenKeys={['stores-group', 'partners-group', 'benefit-group', 'logs-group']}
|
||||
items={MENU_ITEMS}
|
||||
onClick={({ key }) => {
|
||||
if (key.startsWith('/')) navigate(key);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export type UserLogCategory =
|
||||
| 'login'
|
||||
| 'browse_product'
|
||||
| 'order'
|
||||
| 'pay'
|
||||
| 'wechat_auth'
|
||||
| 'browse_store'
|
||||
| 'redeem'
|
||||
| 'profile';
|
||||
|
||||
const USER_LOG_EVENT_CATEGORIES: Record<UserLogCategory, readonly string[]> = {
|
||||
login: ['login_success', 'sms_login'],
|
||||
browse_product: ['home_view', 'product_click', 'product_detail_view'],
|
||||
order: ['order_confirm_view', 'order_submit'],
|
||||
pay: ['pay_success', 'pay_fail'],
|
||||
wechat_auth: ['wechat_login', 'wechat_phone', 'wechat_location', 'wechat_album'],
|
||||
browse_store: ['store_list_view', 'store_detail_view'],
|
||||
redeem: ['benefit_redeem_start', 'benefit_redeem_success'],
|
||||
profile: ['profile_update'],
|
||||
};
|
||||
|
||||
export const USER_LOG_CATEGORY_OPTIONS: Array<{ value: UserLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'browse_product', label: '浏览商品' },
|
||||
{ value: 'order', label: '下单' },
|
||||
{ value: 'pay', label: '支付' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'browse_store', label: '浏览门店' },
|
||||
{ value: 'redeem', label: '核销' },
|
||||
{ value: 'profile', label: '信息修改' },
|
||||
];
|
||||
|
||||
export function resolveUserLogCategory(eventName: string): UserLogCategory | null {
|
||||
for (const [category, events] of Object.entries(USER_LOG_EVENT_CATEGORIES) as Array<
|
||||
[UserLogCategory, readonly string[]]
|
||||
>) {
|
||||
if (events.includes(eventName)) return category;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const USER_LOG_CATEGORY_LABELS = Object.fromEntries(
|
||||
USER_LOG_CATEGORY_OPTIONS.filter((o) => o.value).map((o) => [o.value, o.label]),
|
||||
) as Record<string, string>;
|
||||
@@ -1,13 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button, Descriptions, Divider, Drawer, Form, Input, InputNumber, Modal, Select, Space,
|
||||
Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { request } from '../lib/api';
|
||||
import { AROMA_TYPE_LABELS, PRODUCT_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import OssUpload from '../components/OssUpload';
|
||||
|
||||
type ProductDetailContentDto = {
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon: string; title: string; desc: string }>;
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
skuCode: string;
|
||||
@@ -21,9 +29,196 @@ type Row = {
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ProductFormValues = {
|
||||
skuCode?: string;
|
||||
barcode69?: string;
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
aromaType?: string;
|
||||
spec: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
status?: string;
|
||||
sortOrder?: number;
|
||||
coverUrl?: string;
|
||||
carouselUrls?: string[];
|
||||
detailImageUrls?: string[];
|
||||
storyTitle?: string;
|
||||
storyText?: string;
|
||||
features?: Array<{ icon?: string; title?: string; desc?: string }>;
|
||||
};
|
||||
|
||||
function mapDetailToForm(d: Record<string, unknown>) {
|
||||
const detail = (d.detailContent ?? {}) as ProductDetailContentDto;
|
||||
return {
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
carouselUrls: ((d as Row).carouselUrls?.length ? (d as Row).carouselUrls : ['']) as string[],
|
||||
detailImageUrls: ((d as Row).detailImageUrls?.length ? (d as Row).detailImageUrls : ['']) as string[],
|
||||
storyTitle: detail.storyTitle ?? '',
|
||||
storyText: detail.storyText ?? '',
|
||||
features: detail.features?.length
|
||||
? detail.features
|
||||
: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
};
|
||||
}
|
||||
|
||||
function buildProductPayload(v: ProductFormValues) {
|
||||
const carouselUrls = (v.carouselUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
const detailImageUrls = (v.detailImageUrls ?? []).map((u) => u?.trim()).filter(Boolean);
|
||||
const features = (v.features ?? [])
|
||||
.filter((f) => f?.title?.trim() || f?.desc?.trim())
|
||||
.map((f) => ({
|
||||
icon: f.icon?.trim() || 'star',
|
||||
title: f.title?.trim() || '',
|
||||
desc: f.desc?.trim() || '',
|
||||
}));
|
||||
|
||||
const detailContent: ProductDetailContentDto = {
|
||||
storyTitle: v.storyTitle?.trim() || undefined,
|
||||
storyText: v.storyText?.trim() || undefined,
|
||||
features: features.length ? features : undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
skuCode: v.skuCode,
|
||||
barcode69: v.barcode69,
|
||||
name: v.name,
|
||||
subtitle: v.subtitle,
|
||||
aromaType: v.aromaType,
|
||||
spec: v.spec,
|
||||
price: v.price,
|
||||
benefitAmount: v.benefitAmount,
|
||||
status: v.status,
|
||||
sortOrder: v.sortOrder,
|
||||
coverUrl: v.coverUrl,
|
||||
carouselUrls,
|
||||
detailImageUrls,
|
||||
detailContent,
|
||||
};
|
||||
}
|
||||
|
||||
function ImageUrlList({ name, label, bizType }: { name: string; label: string; bizType: string }) {
|
||||
return (
|
||||
<Form.List name={name}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item {...field} style={{ flex: 1, marginBottom: 0 }}>
|
||||
<OssUpload bizType={bizType} mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 8 }} />
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add('')} block icon={<PlusOutlined />}>
|
||||
添加{label}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductDetailFields() {
|
||||
return (
|
||||
<>
|
||||
<Typography.Text type="secondary">详情页轮播(CAROUSEL)</Typography.Text>
|
||||
<ImageUrlList name="carouselUrls" label="轮播图" bizType="CAROUSEL" />
|
||||
<Divider />
|
||||
<Typography.Text type="secondary">详情长图(DETAIL)</Typography.Text>
|
||||
<ImageUrlList name="detailImageUrls" label="详情图" bizType="DETAIL" />
|
||||
<Divider />
|
||||
<Form.Item name="storyTitle" label="故事标题">
|
||||
<Input placeholder="如:千年杜康 · 唯有此处" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storyText" label="故事正文">
|
||||
<Input.TextArea rows={4} placeholder="商品故事描述" />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">卖点特色</Typography.Text>
|
||||
<Form.List name="features">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} direction="vertical" style={{ display: 'flex', marginBottom: 12, width: '100%' }}>
|
||||
<Space align="start">
|
||||
<Form.Item {...field} name={[field.name, 'icon']} label="图标" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="material icon 名" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'title']} label="标题" style={{ marginBottom: 0, flex: 1 }}>
|
||||
<Input placeholder="标题" />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ marginTop: 30 }} />
|
||||
)}
|
||||
</Space>
|
||||
<Form.Item {...field} name={[field.name, 'desc']} label="描述" style={{ marginBottom: 0 }}>
|
||||
<Input placeholder="简短描述" />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add({ icon: 'star', title: '', desc: '' })} block icon={<PlusOutlined />}>
|
||||
添加卖点
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseInfoFields({ mode }: { mode: 'create' | 'edit' }) {
|
||||
return (
|
||||
<>
|
||||
{mode === 'create' && (
|
||||
<>
|
||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}>
|
||||
<Input placeholder="500ml | 52度" />
|
||||
</Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
@@ -60,10 +255,7 @@ export default function ProductsPage() {
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/products/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
coverUrl: (d as { mainImageUrl?: string }).mainImageUrl,
|
||||
});
|
||||
editForm.setFieldsValue(mapDetailToForm(d));
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
),
|
||||
@@ -88,11 +280,12 @@ export default function ProductsPage() {
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
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)}
|
||||
<Drawer title="编辑商品" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const payload = buildProductPayload(v);
|
||||
await request(`/admin/products/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
@@ -106,48 +299,32 @@ export default function ProductsPage() {
|
||||
<Descriptions.Item label="香型">{AROMA_TYPE_LABELS[String(detail.aromaType)] || String(detail.aromaType)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="edit" /> },
|
||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||
]} />
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建商品" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(v) });
|
||||
const payload = buildProductPayload(v);
|
||||
await request('/admin/products', { method: 'POST', body: JSON.stringify(payload) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}} width={520}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0 }}>
|
||||
<Form.Item name="skuCode" label="SKU" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="barcode69" label="69码" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="商品名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input /></Form.Item>
|
||||
<Form.Item name="aromaType" label="香型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(AROMA_TYPE_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格" rules={[{ required: true }]}><Input placeholder="500ml | 52度" /></Form.Item>
|
||||
<Form.Item name="price" label="售价" rules={[{ required: true }]}><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="benefitAmount" label="权益额"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(PRODUCT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面">
|
||||
<OssUpload bizType="COVER" mediaType="IMAGE" />
|
||||
</Form.Item>
|
||||
}} width={720}>
|
||||
<Form form={createForm} layout="vertical" initialValues={{
|
||||
aromaType: 'QINGXIANG', status: 'DRAFT', sortOrder: 0,
|
||||
carouselUrls: [''], detailImageUrls: [''],
|
||||
features: [{ icon: 'water_drop', title: '', desc: '' }],
|
||||
}}>
|
||||
<Tabs items={[
|
||||
{ key: 'base', label: '基础信息', children: <BaseInfoFields mode="create" /> },
|
||||
{ key: 'detail', label: '详情页', children: <ProductDetailFields /> },
|
||||
]} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Segmented, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { USER_LOG_CATEGORY_OPTIONS, resolveUserLogCategory, USER_LOG_CATEGORY_LABELS, type UserLogCategory } from '../lib/user-log';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
userId: string | null;
|
||||
userNo: string | null;
|
||||
phone: string | null;
|
||||
nickname: string | null;
|
||||
category: UserLogCategory | null;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
refId: string | null;
|
||||
extraJson: Record<string, unknown> | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS = USER_LOG_CATEGORY_LABELS;
|
||||
|
||||
function summarizeExtra(json: Record<string, unknown> | null) {
|
||||
if (!json) return '—';
|
||||
const text = JSON.stringify(json);
|
||||
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
||||
}
|
||||
|
||||
export default function UserLogsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [form] = Form.useForm();
|
||||
const [category, setCategory] = useState(searchParams.get('category') ?? '');
|
||||
const [filters, setFilters] = useState<Record<string, string>>(() => ({
|
||||
userId: searchParams.get('userId') ?? '',
|
||||
phone: searchParams.get('phone') ?? '',
|
||||
userNo: searchParams.get('userNo') ?? '',
|
||||
eventName: searchParams.get('eventName') ?? '',
|
||||
}));
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/logs/users',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.userId) qs.set('userId', filters.userId);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.userNo) qs.set('userNo', filters.userNo);
|
||||
if (filters.eventName) qs.set('eventName', filters.eventName);
|
||||
if (category) qs.set('category', category);
|
||||
return qs;
|
||||
},
|
||||
[filters, category],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue(filters);
|
||||
}, [form, filters]);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '用户', width: 160,
|
||||
render: (_, r) => (
|
||||
<div>
|
||||
<div>{r.nickname || r.userNo || '—'}</div>
|
||||
<div style={{ color: '#999', fontSize: 12 }}>{r.phone || r.userId || '—'}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分类', dataIndex: 'category', width: 100,
|
||||
render: (v: UserLogCategory | null, r) => (
|
||||
<Tag>{CATEGORY_LABELS[v ?? ''] || resolveUserLogCategory(r.eventName) || '其他'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '事件', dataIndex: 'eventName', width: 160 },
|
||||
{
|
||||
title: '关联', width: 120,
|
||||
render: (_, r) => (r.refType && r.refId ? `${r.refType}#${r.refId}` : '—'),
|
||||
},
|
||||
{
|
||||
title: '摘要', ellipsis: true,
|
||||
render: (_, r) => summarizeExtra(r.extraJson),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/logs/users/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>用户日志</Typography.Title>
|
||||
<Segmented
|
||||
style={{ marginBottom: 16 }}
|
||||
options={USER_LOG_CATEGORY_OPTIONS.map((o) => ({ value: o.value, label: o.label }))}
|
||||
value={category}
|
||||
onChange={(v) => {
|
||||
setCategory(String(v));
|
||||
setPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (v) next.set('category', String(v));
|
||||
else next.delete('category');
|
||||
setSearchParams(next);
|
||||
}}
|
||||
/>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
for (const key of ['userId', 'phone', 'userNo', 'eventName'] as const) {
|
||||
if (v[key]) next.set(key, v[key]);
|
||||
else next.delete(key);
|
||||
}
|
||||
setSearchParams(next);
|
||||
}}>
|
||||
<Form.Item name="userId" label="用户ID"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号"><Input allowClear style={{ width: 130 }} /></Form.Item>
|
||||
<Form.Item name="userNo" label="用户编号"><Input allowClear style={{ width: 120 }} /></Form.Item>
|
||||
<Form.Item name="eventName" label="事件名"><Input allowClear style={{ width: 160 }} placeholder="pay_success" /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">筛选</Button></Form.Item>
|
||||
<Form.Item><Button onClick={() => { form.resetFields(); setFilters({}); setCategory(''); setSearchParams({}); setPage(1); }}>重置</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
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)}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="用户">{String(detail.nickname || detail.userNo || detail.userId || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="手机">{String(detail.phone || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="分类">{CATEGORY_LABELS[String(detail.category ?? '')] || String(detail.category || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="事件">{String(detail.eventName)}</Descriptions.Item>
|
||||
<Descriptions.Item label="客户端">{String(detail.clientApp || '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{detail.refType ? `${String(detail.refType)}#${String(detail.refId)}` : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
<Descriptions.Item label="参数">
|
||||
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
||||
{JSON.stringify(detail.extraJson ?? {}, null, 2)}
|
||||
</pre>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
@@ -24,6 +25,7 @@ type UserDetail = AdminUserRow & {
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [data, setData] = useState<Paginated<AdminUserRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -99,11 +101,16 @@ export default function UsersPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Space size="small">
|
||||
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
<Button type="link" size="small" onClick={() => navigate(`/logs/users?userId=${row.id}`)}>
|
||||
查看日志
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -198,6 +205,13 @@ export default function UsersPage() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginTop: 16 }}
|
||||
onClick={() => navigate(`/logs/users?userId=${detail.id}`)}
|
||||
>
|
||||
查看用户日志
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -17,6 +17,7 @@ import RedeemPage from './pages/RedeemPage';
|
||||
import RedeemCodePage from './pages/RedeemCodePage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import PayPage from './pages/PayPage';
|
||||
import CustomerServicePage from './pages/CustomerServicePage';
|
||||
import { UserSessionProvider } from './contexts/UserSessionContext';
|
||||
|
||||
export default function App() {
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
<Route path="/order/confirm" element={<OrderConfirmPage />} />
|
||||
<Route path="/pay" element={<PayPage />} />
|
||||
<Route path="/customer-service" element={<CustomerServicePage />} />
|
||||
<Route path="/addresses" element={<AddressListPage />} />
|
||||
<Route path="/addresses/new" element={<AddressEditPage />} />
|
||||
<Route path="/addresses/:id/edit" element={<AddressEditPage />} />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
|
||||
import { track } from '../lib/analytics';
|
||||
|
||||
type ContactCustomerSheetProps = {
|
||||
orderId?: string;
|
||||
orderNo?: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export default function ContactCustomerSheet({ orderId, orderNo, onClose }: ContactCustomerSheetProps) {
|
||||
const navigate = useNavigate();
|
||||
const tel = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
|
||||
|
||||
function openPhone() {
|
||||
track('cs_contact', { type: 'phone', orderId });
|
||||
window.location.href = `tel:${tel}`;
|
||||
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()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contact-customer-overlay" onClick={onClose}>
|
||||
<div className="contact-customer-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="contact-customer-head">
|
||||
<h3>联系客服</h3>
|
||||
<button type="button" className="contact-customer-close" aria-label="关闭" onClick={onClose}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="contact-customer-options">
|
||||
<button type="button" className="contact-customer-option" onClick={openPhone}>
|
||||
<div className="contact-customer-option-icon">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">拨打总部客服电话</p>
|
||||
<p className="contact-customer-option-sub">{CUSTOMER_SERVICE_PHONE}</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
|
||||
<button type="button" className="contact-customer-option" onClick={openChat}>
|
||||
<div className="contact-customer-option-icon">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
<div className="contact-customer-option-body">
|
||||
<p className="contact-customer-option-title">在线客服</p>
|
||||
<p className="contact-customer-option-sub">专业客服实时解答</p>
|
||||
</div>
|
||||
<span className="material-symbols-outlined contact-customer-chevron">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className="contact-customer-cancel" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { bindPhone, request, saveSession } from '../lib/api';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
import { bindPhone, type SessionPayload } from '../lib/api';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { useSmsCode } from '../lib/use-sms-code';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
|
||||
type PhoneVerifySheetProps = {
|
||||
open: boolean;
|
||||
@@ -16,18 +19,18 @@ export default function PhoneVerifySheet({
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: PhoneVerifySheetProps) {
|
||||
const { applySession } = useUserSession();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const { sendCode, sending, codeCooldown, sentHint, error, setError, clearMessages } = useSmsCode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhone('');
|
||||
setCode('');
|
||||
setMsg('');
|
||||
setCodeCooldown(0);
|
||||
setError('');
|
||||
clearMessages();
|
||||
return;
|
||||
}
|
||||
if (defaultPhone) {
|
||||
@@ -36,51 +39,32 @@ export default function PhoneVerifySheet({
|
||||
setPhone(normalized);
|
||||
}
|
||||
}
|
||||
}, [open, defaultPhone]);
|
||||
}, [open, defaultPhone, clearMessages, setError]);
|
||||
|
||||
async function sendCode() {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return;
|
||||
}
|
||||
setMsg('');
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: 'BIND_PHONE' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
async function onSendCode() {
|
||||
clearMessages();
|
||||
await sendCode(phone, SmsScene.BIND_PHONE);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
setMsg('请输入验证码');
|
||||
setError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setError('');
|
||||
try {
|
||||
const session = await bindPhone(phone, code);
|
||||
saveSession(session);
|
||||
applySession(session as SessionPayload);
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '验证失败');
|
||||
setError(e instanceof Error ? e.message : '验证失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -105,7 +89,8 @@ export default function PhoneVerifySheet({
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
setPhone(normalizePhoneInput(e.target.value));
|
||||
setMsg('');
|
||||
setError('');
|
||||
clearMessages();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -115,19 +100,26 @@ export default function PhoneVerifySheet({
|
||||
inputMode="numeric"
|
||||
className="login-field-input"
|
||||
placeholder="请输入验证码"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0}
|
||||
onClick={sendCode}
|
||||
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0 || sending}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
||||
{sending
|
||||
? '发送中...'
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后重新获取`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className="login-msg">{msg}</p>}
|
||||
{(error || sentHint) && (
|
||||
<p className={`login-msg${sentHint && !error ? ' login-msg--hint' : ''}`}>{error || sentHint}</p>
|
||||
)}
|
||||
<button type="button" className="login-sms-btn" disabled={loading} onClick={submit}>
|
||||
{loading ? '验证中...' : '确认验证'}
|
||||
</button>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ensureSession,
|
||||
getDeviceKey,
|
||||
request,
|
||||
saveSession,
|
||||
type SessionPayload,
|
||||
type UserProfile,
|
||||
} from '../lib/api';
|
||||
@@ -21,6 +22,7 @@ type UserSessionContextValue = {
|
||||
ready: boolean;
|
||||
profile: UserProfile | null;
|
||||
phoneVerified: boolean;
|
||||
applySession: (session: SessionPayload) => void;
|
||||
refreshProfile: () => Promise<void>;
|
||||
resetSession: () => Promise<void>;
|
||||
};
|
||||
@@ -33,6 +35,7 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [phoneVerified, setPhoneVerified] = useState(false);
|
||||
|
||||
const applySession = useCallback((session: SessionPayload) => {
|
||||
saveSession(session);
|
||||
if (session.user) setProfile(session.user);
|
||||
setPhoneVerified(!!session.phoneVerified || !!session.user?.phoneVerified);
|
||||
}, []);
|
||||
@@ -83,10 +86,11 @@ export function UserSessionProvider({ children }: { children: ReactNode }) {
|
||||
ready,
|
||||
profile,
|
||||
phoneVerified,
|
||||
applySession,
|
||||
refreshProfile,
|
||||
resetSession,
|
||||
}),
|
||||
[ready, profile, phoneVerified, refreshProfile, resetSession],
|
||||
[ready, profile, phoneVerified, applySession, refreshProfile, resetSession],
|
||||
);
|
||||
|
||||
if (!ready) {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { apiBase } from './api';
|
||||
|
||||
export function track(eventName: string, params?: Record<string, unknown>) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
if (!token) return;
|
||||
|
||||
void fetch(`${apiBase}/analytics/events`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Client-App': 'USER_H5',
|
||||
},
|
||||
body: JSON.stringify({ events: [{ eventName, params }] }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { SmsScene } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
import { fetchClientConfig } from './pay-wechat';
|
||||
import { validateMobilePhone } from './phone';
|
||||
|
||||
export function useSmsCode() {
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentHint, setSentHint] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [mockSms, setMockSms] = useState(true);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClientConfig()
|
||||
.then((cfg) => setMockSms(cfg.mockSms))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startCooldown = useCallback(() => {
|
||||
setCodeCooldown(60);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
}, []);
|
||||
|
||||
const sendCode = useCallback(
|
||||
async (phone: string, scene: SmsScene) => {
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setError(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return false;
|
||||
}
|
||||
setSending(true);
|
||||
setError('');
|
||||
setSentHint('');
|
||||
try {
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene }),
|
||||
});
|
||||
setSentHint(mockSms ? '验证码已发送(开发模式)' : '验证码已发送,请注意查收');
|
||||
startCooldown();
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '发送失败');
|
||||
return false;
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[mockSms, startCooldown],
|
||||
);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setError('');
|
||||
setSentHint('');
|
||||
}, []);
|
||||
|
||||
return {
|
||||
sendCode,
|
||||
sending,
|
||||
codeCooldown,
|
||||
sentHint,
|
||||
error,
|
||||
setError,
|
||||
clearMessages,
|
||||
mockSms,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import SubPageHeader from '../components/SubPageHeader';
|
||||
import { request } from '../lib/api';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { buildAddressEditUrl, buildAddressListUrl, buildOrderConfirmUrl, hasCheckoutContext, readCheckoutContext } from '../lib/navigation';
|
||||
|
||||
type Address = {
|
||||
@@ -24,6 +25,7 @@ function formatAddress(a: Address) {
|
||||
}
|
||||
|
||||
export default function AddressListPage() {
|
||||
const { profile } = useUserSession();
|
||||
const [list, setList] = useState<Address[]>([]);
|
||||
const [pendingAddress, setPendingAddress] = useState<Address | null>(null);
|
||||
const [savingOrderAddress, setSavingOrderAddress] = useState(false);
|
||||
@@ -42,7 +44,7 @@ export default function AddressListPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadList();
|
||||
}, [loadList]);
|
||||
}, [loadList, profile?.id]);
|
||||
|
||||
function selectAddress(addr: Address) {
|
||||
if (!selectMode) return;
|
||||
@@ -137,7 +139,7 @@ export default function AddressListPage() {
|
||||
|
||||
<div className="address-list-cards">
|
||||
{list.map((a) => {
|
||||
const isDefault = a.isDefault === 1;
|
||||
const isDefault = Number(a.isDefault) === 1;
|
||||
const isSelected = selectMode && currentAddressId === String(a.id);
|
||||
return (
|
||||
<article
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } 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 '您好,杜康客服已收到您的消息,请稍候,我们将尽快回复。';
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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 }]);
|
||||
}
|
||||
|
||||
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">
|
||||
<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-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>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ProductCarousel from '../components/ProductCarousel';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { getProductImages } from '../lib/product-images';
|
||||
import { track } from '../lib/analytics';
|
||||
import CouponBadge from '@dukang/shared-ui/CouponBadge';
|
||||
|
||||
type Product = {
|
||||
@@ -42,6 +43,10 @@ export default function HomePage() {
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
track('home_view', { pagePath: '/' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
setCities(list);
|
||||
|
||||
@@ -2,22 +2,27 @@ import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||
import { request, saveSession, type UserProfile } from '../lib/api';
|
||||
import { SmsScene } from '@dukang/shared-types';
|
||||
import { request, type SessionPayload } from '../lib/api';
|
||||
import { normalizePhoneInput, validateMobilePhone } from '../lib/phone';
|
||||
import { useSmsCode } from '../lib/use-sms-code';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const returnTo = searchParams.get('return') || '/';
|
||||
const [phone, setPhone] = useState('13800000001');
|
||||
const [code, setCode] = useState('123456');
|
||||
const { applySession } = useUserSession();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreed, setAgreed] = useState(true);
|
||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
|
||||
const [bindMode, setBindMode] = useState(false);
|
||||
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
|
||||
useSmsCode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv()) return;
|
||||
@@ -38,12 +43,12 @@ export default function LoginPage() {
|
||||
return;
|
||||
}
|
||||
if (result.accessToken) {
|
||||
saveSession({
|
||||
applySession({
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken ?? '',
|
||||
deviceKey: result.deviceKey,
|
||||
phoneVerified: !!result.phoneVerified,
|
||||
user: result.user as never,
|
||||
user: result.user as SessionPayload['user'],
|
||||
});
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
}
|
||||
@@ -57,29 +62,11 @@ export default function LoginPage() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendCode() {
|
||||
async function onSendCode() {
|
||||
if (!ensureAgreed()) return;
|
||||
const phoneCheck = validateMobilePhone(phone);
|
||||
if (!phoneCheck.ok) {
|
||||
setMsg(phoneCheck.message ?? '请输入正确的手机号码');
|
||||
return;
|
||||
}
|
||||
clearMessages();
|
||||
setMsg('');
|
||||
await request('USER_H5', '/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, scene: bindMode ? 'BIND_PHONE' : 'USER_LOGIN' }),
|
||||
});
|
||||
setMsg('验证码已发送(Mock: 123456)');
|
||||
setCodeCooldown(60);
|
||||
const timer = setInterval(() => {
|
||||
setCodeCooldown((c) => {
|
||||
if (c <= 1) {
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return c - 1;
|
||||
});
|
||||
}, 1000);
|
||||
await sendCode(phone, bindMode ? SmsScene.BIND_PHONE : SmsScene.USER_LOGIN);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
@@ -95,6 +82,7 @@ export default function LoginPage() {
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
setSmsError('');
|
||||
try {
|
||||
if (bindMode && wxSessionKey) {
|
||||
const data = await request<WechatLoginResult>('USER_H5', '/auth/wechat/bind-phone', {
|
||||
@@ -104,15 +92,11 @@ export default function LoginPage() {
|
||||
handleWechatLoginResult(data);
|
||||
return;
|
||||
}
|
||||
const data = await request<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
deviceKey?: string;
|
||||
}>('USER_H5', '/auth/login/sms', {
|
||||
const data = await request<SessionPayload>('USER_H5', '/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone, code }),
|
||||
});
|
||||
saveSession(data);
|
||||
applySession(data);
|
||||
navigate(returnTo.startsWith('/') ? returnTo : '/');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||
@@ -136,6 +120,8 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const displayMsg = msg || smsError;
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<header className="login-header">
|
||||
@@ -164,6 +150,7 @@ export default function LoginPage() {
|
||||
onChange={(e) => {
|
||||
setPhone(normalizePhoneInput(e.target.value));
|
||||
setMsg('');
|
||||
clearMessages();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -173,19 +160,28 @@ export default function LoginPage() {
|
||||
inputMode="numeric"
|
||||
className="login-field-input"
|
||||
placeholder="请输入验证码"
|
||||
maxLength={6}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-get-code${codeCooldown > 0 ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0}
|
||||
onClick={sendCode}
|
||||
className={`login-get-code${codeCooldown > 0 || sending ? ' disabled' : ''}`}
|
||||
disabled={codeCooldown > 0 || sending}
|
||||
onClick={onSendCode}
|
||||
>
|
||||
{codeCooldown > 0 ? `${codeCooldown}s 后重新获取` : '获取验证码'}
|
||||
{sending
|
||||
? '发送中...'
|
||||
: codeCooldown > 0
|
||||
? `${codeCooldown}s 后重新获取`
|
||||
: '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{msg && <p className="login-msg">{msg}</p>}
|
||||
{(displayMsg || sentHint) && (
|
||||
<p className={`login-msg${sentHint && !displayMsg ? ' login-msg--hint' : ''}`}>
|
||||
{displayMsg || sentHint}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="login-sms-btn"
|
||||
|
||||
@@ -4,6 +4,7 @@ import TabMainHeader from '../components/TabMainHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||
|
||||
const DEFAULT_AVATAR =
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuAz_9Pnpk_Md4sEU6PXkeybus8oLZO9e-3pOpLuSwBX0jm_Z0JCfX1w2oZxz1VZayTh0PKUPjwjSuxJVX410fjtWFGR_f55f-nWppXWUweHRnEC7WyIWEqx4AyVHt-k02OhyaSGQfvY5cHG5IuRe9EqdcHy47gBQ82_cxGgX-DrKV4oYcwLoNRynAV0_xv2p1GOhisnQVulHwZcQClUJcP8q4nTY0Y3DR1w4ioa0DYTHePE43mLDJptjZcQqS7V8LihJdn4ze6fvQA';
|
||||
@@ -35,6 +36,7 @@ export default function MinePage() {
|
||||
const [benefitBalance, setBenefitBalance] = useState(0);
|
||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||
const [toast, setToast] = useState('');
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
@@ -67,7 +69,7 @@ export default function MinePage() {
|
||||
|
||||
function handleService(item: (typeof SERVICES)[number]) {
|
||||
if ('to' in item && item.to) return;
|
||||
if (item.action === 'cs') showToast('preV1:在线客服即将开放');
|
||||
if (item.action === 'cs') setShowCs(true);
|
||||
if (item.action === 'about') showToast('杜康好客 · 传承千年酒文化');
|
||||
}
|
||||
|
||||
@@ -208,6 +210,8 @@ export default function MinePage() {
|
||||
</main>
|
||||
|
||||
{toast && <div className="mine-toast">{toast}</div>}
|
||||
|
||||
{showCs && <ContactCustomerSheet onClose={() => setShowCs(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
import { track } from '../lib/analytics';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||
|
||||
type Address = {
|
||||
id: string;
|
||||
@@ -76,6 +78,12 @@ export default function OrderConfirmPage() {
|
||||
});
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (productId) {
|
||||
track('order_confirm_view', { refType: 'PRODUCT', refId: productId, productId, quantity });
|
||||
}
|
||||
}, [productId, quantity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!productId || !addressId) return;
|
||||
request<OrderPreview>('USER_H5', '/trade/orders/preview', {
|
||||
@@ -116,7 +124,12 @@ export default function OrderConfirmPage() {
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||
|
||||
async function doSubmit() {
|
||||
const clientLocation = await tryGetClientGpsLocation();
|
||||
let clientLocation = null;
|
||||
try {
|
||||
clientLocation = await tryGetClientGpsLocation();
|
||||
} catch {
|
||||
/* GPS 获取失败不阻塞下单 */
|
||||
}
|
||||
const order = await request<{ id: string }>('USER_H5', '/trade/orders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
|
||||
@@ -4,6 +4,7 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildOrderAddressSelectUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import ContactCustomerSheet from '../components/ContactCustomerSheet';
|
||||
|
||||
type OrderItem = {
|
||||
productName: string;
|
||||
@@ -145,7 +146,11 @@ export default function OrderDetailPage() {
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
||||
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const canPay = order?.status === 'PENDING_PAY' && !isReship;
|
||||
const canRefund =
|
||||
order &&
|
||||
!canPay &&
|
||||
['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
@@ -402,6 +407,16 @@ export default function OrderDetailPage() {
|
||||
申请退款
|
||||
</button>
|
||||
)}
|
||||
{canPay && (
|
||||
<button
|
||||
type="button"
|
||||
className="order-detail-action-primary"
|
||||
onClick={() => navigate(`/pay?orderId=${order.id}`)}
|
||||
>
|
||||
<span className="material-symbols-outlined">payments</span>
|
||||
去付款 ¥{formatMoney(Number(order.payAmount))}
|
||||
</button>
|
||||
)}
|
||||
{canConfirmReceive && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -416,18 +431,11 @@ export default function OrderDetailPage() {
|
||||
</footer>
|
||||
|
||||
{showCs && (
|
||||
<div className="modal-overlay" onClick={() => setShowCs(false)}>
|
||||
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-grabber" />
|
||||
<h3 className="headline-md" style={{ marginBottom: 12 }}>联系客服</h3>
|
||||
<p className="text-variant body-md" style={{ marginBottom: 16 }}>
|
||||
preV1 Mock:客服工作时间 9:00-18:00,请描述订单号 {order.orderNo}
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary btn-block" onClick={() => setShowCs(false)}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ContactCustomerSheet
|
||||
orderId={order.id}
|
||||
orderNo={order.orderNo}
|
||||
onClose={() => setShowCs(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function OrderListPage() {
|
||||
const items = (o.items as Array<Record<string, unknown>>) || [];
|
||||
const item = items[0];
|
||||
const isReshipDemo = i === 0 && tab === 'all';
|
||||
const isPendingPay = String(o.status) === 'PENDING_PAY';
|
||||
return (
|
||||
<div key={String(o.id)} className="card">
|
||||
<div className="card-row" style={{ marginBottom: 8 }}>
|
||||
@@ -63,7 +64,16 @@ export default function OrderListPage() {
|
||||
<div className="amount-lg">¥{Number(o.payAmount)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ textAlign: 'right', marginTop: 12 }}>
|
||||
<div style={{ textAlign: 'right', marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{isPendingPay && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-pill"
|
||||
onClick={() => navigate(`/pay?orderId=${o.id}`)}
|
||||
>
|
||||
去付款
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
|
||||
className="btn btn-outline btn-pill"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ProductDetailContentDto } from '@dukang/shared-types';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
@@ -11,13 +13,9 @@ type Product = ProductImageSource & {
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
benefitAmount?: number;
|
||||
detailContent?: ProductDetailContentDto | null;
|
||||
};
|
||||
|
||||
const FEATURES = [
|
||||
{ icon: 'water_drop', title: '泉水酿造', desc: '甘冽清甜 灵动自然' },
|
||||
{ icon: 'grain', title: '精选五谷', desc: '传统比例 匠心发酵' },
|
||||
] as const;
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -28,6 +26,12 @@ export default function ProductDetailPage() {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
track('product_detail_view', { refType: 'PRODUCT', refId: id, productId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
setHeaderSolid(window.scrollY > 100);
|
||||
@@ -41,6 +45,8 @@ export default function ProductDetailPage() {
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
const detail = product.detailContent ?? {};
|
||||
const features = detail.features ?? [];
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
@@ -107,31 +113,31 @@ export default function ProductDetailPage() {
|
||||
<h3>商品详情</h3>
|
||||
</div>
|
||||
|
||||
{detailImages[0] && (
|
||||
<AppImage src={detailImages[0]} alt="" wrapperClassName="product-detail-banner" />
|
||||
)}
|
||||
{detailImages.map((src, index) => (
|
||||
<AppImage key={`${src}-${index}`} src={src} alt="" wrapperClassName="product-detail-banner" />
|
||||
))}
|
||||
|
||||
<div className="product-detail-copy">
|
||||
<div className="product-detail-story">
|
||||
<h4>千年杜康 · 唯有此处</h4>
|
||||
<p>
|
||||
选自白水杜康核心产区,取山泉之灵气,集五谷之精华。古法酿造工艺,历经九九八十一道工序,方得这一口醇厚绵甜。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="product-detail-features">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} className="product-detail-feature">
|
||||
<span className="material-symbols-outlined">{f.icon}</span>
|
||||
<div className="product-detail-feature-title">{f.title}</div>
|
||||
<div className="product-detail-feature-desc">{f.desc}</div>
|
||||
{(detail.storyTitle || detail.storyText || features.length > 0) && (
|
||||
<div className="product-detail-copy">
|
||||
{(detail.storyTitle || detail.storyText) && (
|
||||
<div className="product-detail-story">
|
||||
{detail.storyTitle && <h4>{detail.storyTitle}</h4>}
|
||||
{detail.storyText && <p>{detail.storyText}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailImages.length > 1 && (
|
||||
<AppImage src={detailImages[1]} alt="" wrapperClassName="product-detail-banner" />
|
||||
{features.length > 0 && (
|
||||
<div className="product-detail-features">
|
||||
{features.map((f) => (
|
||||
<div key={`${f.title}-${f.icon}`} className="product-detail-feature">
|
||||
<span className="material-symbols-outlined">{f.icon}</span>
|
||||
<div className="product-detail-feature-title">{f.title}</div>
|
||||
<div className="product-detail-feature-desc">{f.desc}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import { STITCH_STORE_MAP, getStoreGalleryImages } from '../lib/store-images';
|
||||
|
||||
type StoreMedia = { url: string; mediaType?: string; sortOrder?: number };
|
||||
@@ -55,7 +56,10 @@ export default function StoreDetailPage() {
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
|
||||
if (id) {
|
||||
request<StoreDetail>('USER_H5', `/stores/${id}`).then(setStore);
|
||||
track('store_detail_view', { refType: 'STORE', refId: id, storeId: id });
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { track } from '../lib/analytics';
|
||||
import TabMainHeader from '../components/TabMainHeader';
|
||||
import RegionPicker from '../components/RegionPicker';
|
||||
import {
|
||||
@@ -64,6 +65,10 @@ export default function StoreListPage() {
|
||||
|
||||
const cityCode = useMemo(() => resolveCityCode(region, cities), [region, cities]);
|
||||
|
||||
useEffect(() => {
|
||||
track('store_list_view', { pagePath: '/stores' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
request<OpenCity[]>('USER_H5', '/catalog/cities').then(setCities);
|
||||
}, []);
|
||||
|
||||
@@ -5541,3 +5541,254 @@
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-msg--hint {
|
||||
color: var(--color-success, #2d6a4f);
|
||||
}
|
||||
|
||||
/* Contact customer sheet */
|
||||
.contact-customer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.contact-customer-sheet {
|
||||
background: #faf9f7;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 20px 20px calc(16px + env(safe-area-inset-bottom));
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contact-customer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.contact-customer-head h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.contact-customer-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.contact-customer-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.contact-customer-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(226, 190, 188, 0.3);
|
||||
border-radius: 12px;
|
||||
background: #f4f3f1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.contact-customer-option-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: rgba(166, 29, 36, 0.1);
|
||||
color: #a61d24;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-customer-option-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
.contact-customer-option-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.contact-customer-chevron {
|
||||
margin-left: auto;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.contact-customer-cancel {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: #e3e2e0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1c1b;
|
||||
}
|
||||
|
||||
/* Customer service chat page */
|
||||
.customer-service-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #faf9f7;
|
||||
}
|
||||
|
||||
.customer-service-order-card {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
.customer-service-order-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #5a413f;
|
||||
}
|
||||
|
||||
.customer-service-order-no {
|
||||
margin: 4px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
.customer-service-bubble.is-user {
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px rgba(166, 29, 36, 0.2);
|
||||
}
|
||||
|
||||
.customer-service-quick {
|
||||
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;
|
||||
}
|
||||
|
||||
.customer-service-inputbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
border-top: 1px solid #e3e2e0;
|
||||
}
|
||||
|
||||
.customer-service-input {
|
||||
flex: 1;
|
||||
border: 1px solid #e3e2e0;
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.customer-service-send {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 18px;
|
||||
background: #a61d24;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.customer-service-send:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@dukang/shared-ui': path.resolve(__dirname, '../../packages/shared-ui/src'),
|
||||
'@dukang/shared-types': path.resolve(__dirname, '../../packages/shared-types/src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user