53 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
jacy 76270b20bf Merge branches 'dev_jacy' and 'dev' of git.yqidian.com:jacy/dukang into dev
CI / verify (pull_request) Has been cancelled
2026-07-17 10:43:19 +08:00
jacy c2914c37e5 fix(partner): grant store staff open/close and media permissions by default
CI / verify (pull_request) Has been cancelled
Default new sub-accounts to store:create+store:manage, backfill empty permissions on /partner/me, and treat legacy store staff as allowed to mutate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:43:15 +08:00
jacy 9f9b7cb2bd merge(dev_jacy): partner store manage and env photo dedupe 2026-07-17 10:36:31 +08:00
jacy de396442a4 feat(partner): let staff manage stores and fix env photo dupes
CI / verify (pull_request) Has been cancelled
Allow store:create/manage sub-accounts to edit, open/close, and re-upload media; dedupe ENV photos on write/read and replace via media API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:36:29 +08:00
jacy 02efe6fa14 merge(dev_jacy): fix login agreement visibility 2026-07-17 10:20:36 +08:00
jacy 14b867a3a5 fix(auth): show user agreement before login actions
CI / verify (pull_request) Has been cancelled
Quick login required agreement without a checkbox; move the consent row above CTAs across partner/shop/user/mini login pages so it stays visible on short screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:20:32 +08:00
jacy f750fea667 merge(dev_jacy): skip unauthorized partner home APIs 2026-07-17 10:13:45 +08:00
jacy 55e91158b3 fix(partner): skip unauthorized home APIs for sub-accounts
CI / verify (pull_request) Has been cancelled
Gate dashboard/stores/orders by permission and move dashboard auth to method level so leaderboard stays open without toasting 403s.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:13:43 +08:00
jacy 19cb13d26c merge(dev_jacy): partner sub-account team leaderboard 2026-07-17 10:02:44 +08:00
jacy 04253b9120 feat(partner): let sub-accounts view team leaderboard
CI / verify (pull_request) Has been cancelled
Expose same-primary ranking on home/me for all staff including warehouse roles, and open the leaderboard API without business permission gates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 10:02:42 +08:00
jacy aaf6ee81d5 merge(dev_jacy): fix mini-user production API origin 2026-07-17 09:30:12 +08:00
jacy 972bdde650 fix(mini-user): restore production API origin for H5 builds
CI / verify (pull_request) Has been cancelled
Production builds were baking localhost:3000 after the local-dev default change, causing failed to fetch on product list.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:30:09 +08:00
jacy c0d66cfe50 merge(dev_jacy): admin订单配送/仓库自动发货/跨城起购与mini-user联调 2026-07-17 09:08:20 +08:00
jacy 00b52b9aaa 用户端跨城订单界面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 09:06:53 +08:00
jacy e9537ce052 后端仓库配送逻辑完善
CI / verify (pull_request) Has been cancelled
2026-07-17 08:57:19 +08:00
jacy c204fb0a92 仓库自动配送开关
CI / verify (pull_request) Has been cancelled
2026-07-17 08:50:18 +08:00
jacy c643b0b979 webadmin端订单修改 2026-07-17 08:45:33 +08:00
jacy ed44e88e93 miniuser服务器连接地址修改
CI / verify (pull_request) Has been cancelled
2026-07-17 08:34:37 +08:00
jacy 57172d54d5 合伙人页面功
CI / verify (pull_request) Has been cancelled
2026-07-17 08:20:31 +08:00
jacy 608e405468 城市合伙人去掉订单补发功能,酒的订单如果没有配置仓库应该是看不到的
CI / verify (pull_request) Has been cancelled
2026-07-17 08:04:53 +08:00
jacy b36f2ea63a 城市合伙人H5端结算审核 2026-07-17 07:56:43 +08:00
jacy 7fa2c6fbed 城市合伙人端页面优化
CI / verify (pull_request) Has been cancelled
2026-07-17 07:45:20 +08:00
jacy bde85a7b82 仓库管理配置 小飞侠配置
CI / verify (pull_request) Has been cancelled
2026-07-16 21:40:25 +08:00
jacy b4ec9387a0 merge(dev_jacy): 仓配可调配履约与三分支推单 2026-07-16 17:51:32 +08:00
jacy f6d97b4ee8 feat(trade): 仓配可调配履约与三分支推单
CI / verify (pull_request) Has been cancelled
同城有仓按仓库绑定承运商自动推单或自管填单,无仓/跨城走总部快递;新增仓配注册表、FulfillmentService 及三端运单追踪。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-16 17:51:20 +08:00
jacy f1dc23c54c 合伙人 H5 改造
CI / verify (pull_request) Has been cancelled
2026-07-16 15:04:56 +08:00
jacy 9e6dc8c85c merge(dev_jacy): partner UX, shop wechat bind, remove H5 title whitespace 2026-07-16 11:40:55 +08:00
jacy fd5427c7ea 页面头部的白色留白去掉
CI / verify (pull_request) Has been cancelled
2026-07-16 11:38:44 +08:00
jacy 4de570f5f4 合伙人端的子账号管理页面优化
CI / verify (pull_request) Has been cancelled
2026-07-16 11:24:48 +08:00
jacy 22845e5d5f 门店端增加绑定微信按钮
CI / verify (pull_request) Has been cancelled
2026-07-16 08:30:04 +08:00
jacy 8d7507e2d8 杜康发布 skills 2026-07-16 08:28:46 +08:00
jacy 48995aace4 merge(dev_jacy): hide H5 in-page titles vs WeChat chrome 2026-07-16 08:05:46 +08:00
jacy 5a26841d3a fix(h5): hide in-page nav titles that duplicate WeChat chrome
CI / verify (pull_request) Has been cancelled
Keep back buttons and header layout. mini-user only hides titles on H5; partner/shop hide .app-page-title and legal titles; shared PageHeader defaults showTitle=false.
2026-07-16 08:05:44 +08:00
201 changed files with 10125 additions and 3366 deletions
+89
View File
@@ -0,0 +1,89 @@
---
name: dukang-release
description: >-
杜康好客生产发布流程:dev_jacy 提交合并到 dev,并用 deploy.sh 发版。
Use when the user says 发布、上传、deploy、上线、发版, or @杜康发布 / @dukang-release.
---
# 杜康发布
将已完成改动发到生产(`aliyun-smarthr`)。**仅用户明确要求发布时执行。**
## 分支约定
| 分支 | 用途 |
|------|------|
| `dev_jacy` | 日常开发 / 提交 |
| `dev` | **发布分支**(服务器拉这个) |
默认:`dev_jacy` → merge → `dev` → 部署。勿 force push `main`/`dev`
## 标准流程(Windows
PowerShell 下 **分支名必须加引号**(否则 `checkout``dev` 会被粘成 `checkoutdev`)。
### 1. 提交(在 `dev_jacy`
```powershell
git status -sb
git add <files>
git commit -m @"
fix(scope): summary
Optional body.
"@
```
- Conventional Commits;不提交 `.env` / `dist` / `node_modules`
- 用户未要求 commit 时不要擅自提交
### 2. 推送并合并到 `dev`
```powershell
git push -u origin HEAD
git checkout "dev"
git pull origin "dev"
git merge "dev_jacy" -m "merge(dev_jacy): <简述>"
git push origin "dev"
git checkout "dev_jacy"
```
### 3. 远程发版
本机无 WSL bash 时用 **Git Bash**
```powershell
& "C:\Program Files\Git\bin\bash.exe" "<repo>/deploy/deploy.sh" --branch "dev" -- --skip-db
```
- 默认加 `--skip-db`(无迁移时)
- 有 Prisma 迁移时去掉 `--skip-db`,按用户确认是否 `--seed` / `--accept-data-loss`
- `deploy/deploy.env` 提供 `DEPLOY_HOST` 等(勿提交密钥)
### 4. 等待完成
后台跑部署时轮询终端输出,成功标志:
- 出现 `发版完成`
- `exit_code: 0`
- `mini-user(h5): 200`(或对应前端 200
说明:API 健康检查短暂 `FAIL`/`000` 多为重启中,以 `发版完成` + `exit_code: 0` 为准。
### 5. 回复用户
一句说明:已合并 commit、发版结果、建议微信强刷验证。
## 常用变体
| 场景 | 做法 |
|------|------|
| 仅部署已推送的 `dev` | 跳过 commit/merge,直接跑 `deploy.sh --branch "dev" -- --skip-db` |
| 检查服务器 | `deploy.sh --branch "dev" --check` |
| 含 DB | `deploy.sh --branch "dev" --`(无 skip-db)或按用户给 seed 参数 |
## 禁止
- 不 amend 已推送 commit;不 force push `main`/`master`/`dev`
- 不把 `.env` / 私钥打进仓库
- 用户只说「修好」未说发布时 → **只改代码,不发版**
+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
@@ -90,6 +90,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 需求 Skill | `.cursor/skills/dukang-project/` | @dukang-project |
| preV1 Skill | `.cursor/skills/dukang-prev1/` | Mock / Feature Flag |
| 任务卡 Skill | `.cursor/skills/dukang-task-card/` | DLV-W* / P1-* / M*-* |
| 发布 Skill | `.cursor/skills/dukang-release/` | @杜康发布 / @dukang-release |
## 子 Agent(按职责选用)
@@ -115,6 +116,7 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| `dukang-project` | PRD/需求评审、原型对照、任务卡规划(**不用于日常编码**) |
| `dukang-prev1` | Mock 开关、preV1 裁剪、Feature Flag |
| `dukang-task-card` | 领取任务卡 `DLV-W*-*` / `P1-*` / `M*-*` 并按 DoD 交付 |
| `dukang-release`(杜康发布) | `dev_jacy``dev` 合并 + `deploy.sh` 发版 |
## 快速指令
+4
View File
@@ -18,6 +18,7 @@ import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import FulfillmentProvidersPage from './pages/FulfillmentProvidersPage';
import StoreMediaPage from './pages/StoreMediaPage';
import PromoCodesPage from './pages/PromoCodesPage';
import PromoCodeDetailLayout from './pages/promo/PromoCodeDetailLayout';
@@ -30,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';
@@ -74,6 +76,7 @@ export default function App() {
<Route path="/cities" element={<CitiesPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/fulfillment-providers" element={<FulfillmentProvidersPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
@@ -87,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 />} />
@@ -292,7 +292,7 @@ export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(row) => {
@@ -56,6 +56,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/fulfillment-providers', label: '仓配管理' },
],
},
{
@@ -90,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',
+27 -1
View File
@@ -79,6 +79,11 @@ export type DashboardStats = {
partnersTotal: number;
redeemToday: number;
deliveriesTotal: number;
pendingPayouts?: number;
/** 合伙人已确认、待总部审核打款 */
pendingBills?: number;
pendingPartnerDraftBills?: number;
openTickets?: number;
ordersByStatus: Array<{ status: string; count: number }>;
};
@@ -116,15 +121,36 @@ export type AdminUserRow = {
orderCount: number;
};
export type AdminOrderItem = {
productName: string;
productSpec: string;
productImage: string;
unitPrice: number;
quantity: number;
};
export type AdminOrderRow = {
id: string;
orderNo: string;
status: string;
deliveryType: string;
payAmount: number;
productName?: string;
productSpec?: string;
quantity?: number;
receiverName: string;
receiverPhone: string;
createdAt: string;
cityId?: string;
city?: { id: string; name: string; code: string };
fulfillmentWarehouseId?: string | null;
fulfillmentWarehouse?: { id: string; name: string } | null;
user?: { id: string; userNo: string; phone: string | null; nickname: string | null };
delivery?: { provider: string; trackingNo: string | null; providerOrderNo: string | null };
delivery?: {
provider: string;
trackingNo: string | null;
providerOrderNo: string | null;
logisticsCompany?: string | null;
manualQueryUrl?: string | null;
};
};
@@ -224,7 +224,7 @@ export default function CityPartnersPage() {
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
@@ -482,7 +482,7 @@ export default function CityPartnersPage() {
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
+151 -42
View File
@@ -6,10 +6,12 @@ import {
Descriptions,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
@@ -17,10 +19,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_FULFILLMENT_MODE_LABELS,
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseFulfillmentMode,
WarehouseManagerType,
WarehouseStatus,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
@@ -39,6 +44,13 @@ type Row = {
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
fulfillmentMode?: string;
fulfillmentProviderId?: string | null;
fulfillmentProviderName?: string | null;
manualCarrierLabel?: string | null;
manualQueryUrlTemplate?: string | null;
lng?: number | null;
lat?: number | null;
createdAt: string;
};
@@ -48,6 +60,79 @@ type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
function FulfillmentFields({
mode,
providerOptions,
onModeChange,
}: {
mode: WarehouseFulfillmentMode;
providerOptions: FulfillmentProviderDto[];
onModeChange: (mode: WarehouseFulfillmentMode) => void;
}) {
const autoShip = mode === WarehouseFulfillmentMode.API_AUTO;
return (
<>
<Form.Item
name="fulfillmentMode"
label="支付后自动发货"
extra={
autoShip
? '同城订单支付成功后,自动推送仓配承运商(如小飞侠)并进入配送中'
: '支付后仅分配仓库,保持待发货,由仓管或总部手工填运单'
}
getValueProps={(v) => ({ checked: v === WarehouseFulfillmentMode.API_AUTO })}
getValueFromEvent={(checked: boolean) =>
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL
}
>
<Switch
checkedChildren="开"
unCheckedChildren="关"
onChange={(checked) => {
onModeChange(
checked ? WarehouseFulfillmentMode.API_AUTO : WarehouseFulfillmentMode.MANUAL,
);
}}
/>
</Form.Item>
{autoShip && (
<Form.Item
name="fulfillmentProviderId"
label="仓配承运商"
rules={[{ required: true, message: '自动发货须选择承运商' }]}
>
<Select
placeholder={providerOptions.length ? '选择已注册承运商' : '请先在仓配管理注册'}
options={providerOptions.map((p) => ({ value: p.id, label: `${p.name} (${p.code})` }))}
/>
</Form.Item>
)}
{!autoShip && (
<>
<Form.Item name="manualCarrierLabel" label="默认承运商名称">
<Input placeholder="如 顺丰速运" />
</Form.Item>
<Form.Item
name="manualQueryUrlTemplate"
label="物流查询链接模板"
extra="可用 {trackingNo} 占位符"
>
<Input placeholder="https://example.com/track?no={trackingNo}" />
</Form.Item>
</>
)}
<Space>
<Form.Item name="lng" label="经度">
<InputNumber step={0.001} placeholder="API推单寄件坐标" />
</Form.Item>
<Form.Item name="lat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
);
}
export default function CityWarehousesPage() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
@@ -73,6 +158,13 @@ export default function CityWarehousesPage() {
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const [createFulfillmentMode, setCreateFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [editFulfillmentMode, setEditFulfillmentMode] = useState<WarehouseFulfillmentMode>(
WarehouseFulfillmentMode.MANUAL,
);
const [providerOptions, setProviderOptions] = useState<FulfillmentProviderDto[]>([]);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
@@ -88,6 +180,9 @@ export default function CityWarehousesPage() {
useEffect(() => {
void loadCities();
void request<FulfillmentProviderDto[]>('/admin/fulfillment-providers/active-api')
.then(setProviderOptions)
.catch(() => {});
}, [loadCities]);
async function openEdit(row: Row) {
@@ -102,23 +197,29 @@ export default function CityWarehousesPage() {
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
status: row.status,
fulfillmentMode: row.fulfillmentMode ?? WarehouseFulfillmentMode.MANUAL,
fulfillmentProviderId: row.fulfillmentProviderId ?? undefined,
manualCarrierLabel: row.manualCarrierLabel ?? undefined,
manualQueryUrlTemplate: row.manualQueryUrlTemplate ?? undefined,
lng: row.lng ?? undefined,
lat: row.lat ?? undefined,
});
setEditFulfillmentMode((row.fulfillmentMode as WarehouseFulfillmentMode) ?? WarehouseFulfillmentMode.MANUAL);
setEditOpen(true);
}
function onManagerTypeChange(
type: WarehouseManagerType,
form: typeof createForm | typeof editForm,
setType: (v: WarehouseManagerType) => void,
v: WarehouseManagerType,
form: typeof createForm,
setType: (t: WarehouseManagerType) => void,
) {
setType(type);
if (type !== WarehouseManagerType.PARTNER) {
setType(v);
if (v !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{
title: '城市',
width: 100,
@@ -128,17 +229,17 @@ export default function CityWarehousesPage() {
</span>
),
},
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '仓库', dataIndex: 'name', width: 140, ellipsis: true },
{ title: '地址', dataIndex: 'address', width: 180, ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓类型',
dataIndex: 'managerType',
width: 100,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
title: '管仓',
width: 90,
render: (_, row) => WAREHOUSE_MANAGER_LABELS[row.managerType] || row.managerType,
},
{
title: '管仓合伙人',
title: '合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
@@ -149,6 +250,16 @@ export default function CityWarehousesPage() {
'—'
),
},
{
title: '自动发货',
width: 130,
render: (_, row) =>
row.fulfillmentMode === 'API_AUTO' ? (
<Tag color="blue">{row.fulfillmentProviderName || '自动'}</Tag>
) : (
<Tag>{WAREHOUSE_FULFILLMENT_MODE_LABELS[WarehouseFulfillmentMode.MANUAL]}</Tag>
),
},
{
title: '状态',
dataIndex: 'status',
@@ -190,21 +301,23 @@ export default function CityWarehousesPage() {
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setPartners([]);
setCreateFulfillmentMode(WarehouseFulfillmentMode.MANUAL);
createForm.setFieldsValue({
managerType: WarehouseManagerType.HQ,
status: WarehouseStatus.ACTIVE,
fulfillmentMode: WarehouseFulfillmentMode.MANUAL,
});
setCreateOpen(true);
}}
>
</Button>
</Space>
@@ -212,7 +325,7 @@ export default function CityWarehousesPage() {
type="info"
showIcon
style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
message="同城有仓订单:支付后按「自动发货」开关决定是否推仓配 API;关闭则待人工填单。跨城/无仓仍走总部快递填单。"
/>
<Form
@@ -225,7 +338,7 @@ export default function CityWarehousesPage() {
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" />
<Input allowClear />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
@@ -233,47 +346,33 @@ export default function CityWarehousesPage() {
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓类型">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
<Form.Item name="managerType" label="管仓">
<Select allowClear style={{ width: 120 }} options={MANAGER_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
<Select allowClear style={{ width: 100 }} options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
scroll={{ x: 1400 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
@@ -282,7 +381,7 @@ export default function CityWarehousesPage() {
/>
<Modal
title="新仓库"
title="新仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
@@ -341,6 +440,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={createFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setCreateFulfillmentMode}
/>
</Form>
</Modal>
@@ -399,6 +503,11 @@ export default function CityWarehousesPage() {
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
<FulfillmentFields
mode={editFulfillmentMode}
providerOptions={providerOptions}
onModeChange={setEditFulfillmentMode}
/>
</Form>
</Modal>
</div>
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Button, Card, Col, Descriptions, Modal, Row, Space, Statistic, Table, Typography, message,
} from 'antd';
@@ -164,6 +165,47 @@ export default function DashboardPage() {
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待审核合伙人打款"
extra={<Link to="/finance/partner-bills"></Link>}
>
<Statistic
value={stats?.pendingBills ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingBills ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
<Typography.Text type="secondary">
{(stats?.pendingPartnerDraftBills ?? 0) > 0
? `(另有 ${stats?.pendingPartnerDraftBills} 笔待合伙人确认)`
: ''}
</Typography.Text>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card
loading={loading}
title="待打款门店结算"
extra={<Link to="/finance/store-bills"></Link>}
>
<Statistic
value={stats?.pendingPayouts ?? 0}
suffix="笔"
valueStyle={{ color: (stats?.pendingPayouts ?? 0) > 0 ? '#fa8c16' : undefined }}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={8}>
<Card loading={loading} title="待处理工单">
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="已合并访客账号" loading={loading}>
@@ -0,0 +1,274 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Alert,
Button,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
FULFILLMENT_PROVIDER_STATUS_LABELS,
FULFILLMENT_PROVIDER_TYPE_LABELS,
FulfillmentProviderStatus,
FulfillmentProviderType,
isXfxProviderCode,
type FulfillmentProviderDto,
} from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
const TYPE_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}));
const STATUS_OPTIONS = Object.entries(FULFILLMENT_PROVIDER_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}));
const SIGN_OPTIONS = [
{ value: 'MD5', label: 'MD5' },
{ value: 'HMAC-SHA256', label: 'HMAC-SHA256' },
];
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
export default function FulfillmentProvidersPage() {
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
const [form] = Form.useForm();
const watchedCode = Form.useWatch('code', form);
const watchedType = Form.useWatch('type', form);
const showXfxFields = useMemo(
() => isXfxProviderCode(String(watchedCode || '')) && watchedType === FulfillmentProviderType.API,
[watchedCode, watchedType],
);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await request<FulfillmentProviderDto[]>('/admin/fulfillment-providers');
setRows(res);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
function openCreate() {
setEditRow(null);
form.resetFields();
form.setFieldsValue({
code: 'XFX',
name: '小飞侠',
type: FulfillmentProviderType.API,
status: FulfillmentProviderStatus.ACTIVE,
apiUrl: DEFAULT_XFX_API_URL,
signType: 'MD5',
});
setOpen(true);
}
function openEdit(row: FulfillmentProviderDto) {
setEditRow(row);
const xfx = row.xiaofeixiaConfig;
form.setFieldsValue({
code: row.code,
name: row.name,
type: row.type,
status: row.status,
apiUrl: xfx?.apiUrl || DEFAULT_XFX_API_URL,
mchId: xfx?.mchId || '',
apiKey: '',
signType: xfx?.signType || 'MD5',
appId: xfx?.appId || '',
});
setOpen(true);
}
async function submit() {
const v = await form.validateFields();
const payload: Record<string, unknown> = {
name: v.name,
type: v.type,
status: v.status,
};
if (isXfxProviderCode(String(v.code)) && v.type === FulfillmentProviderType.API) {
payload.xiaofeixiaConfig = {
apiUrl: v.apiUrl,
mchId: v.mchId,
apiKey: v.apiKey || undefined,
signType: v.signType,
appId: v.appId || undefined,
};
}
if (editRow) {
await request(`/admin/fulfillment-providers/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
message.success('已更新');
} else {
await request('/admin/fulfillment-providers', {
method: 'POST',
body: JSON.stringify({
code: v.code,
...payload,
}),
});
message.success('已创建');
}
setOpen(false);
void load();
}
const columns: ColumnsType<FulfillmentProviderDto> = [
{ title: '编码', dataIndex: 'code', width: 100 },
{ title: '名称', dataIndex: 'name' },
{
title: '类型',
dataIndex: 'type',
render: (v) => FULFILLMENT_PROVIDER_TYPE_LABELS[v as FulfillmentProviderType] || v,
},
{
title: '状态',
dataIndex: 'status',
render: (v) => (
<Tag color={v === 'ACTIVE' ? 'green' : 'default'}>
{FULFILLMENT_PROVIDER_STATUS_LABELS[v as FulfillmentProviderStatus] || v}
</Tag>
),
},
{
title: '接口配置',
render: (_, row) => {
if (isXfxProviderCode(row.code)) {
const cfg = row.xiaofeixiaConfig;
if (!cfg?.apiUrl) return <Tag></Tag>;
return (
<span title={cfg.apiUrl}>
{cfg.hasApiKey ? '已配置' : '缺 Key'} · {cfg.mchId || '无商户号'}
</span>
);
}
return row.hasConfig ? '已配置' : '—';
},
},
{ title: '更新时间', dataIndex: 'updatedAt', width: 170, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
使 API
</Typography.Text>
</div>
<Button type="primary" onClick={openCreate}>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="小飞侠配置已从「系统设置 → 同城配送」迁至本页。寄件地址以仓库联系人/地址为准。"
/>
<Table rowKey="id" loading={loading} columns={columns} dataSource={rows} pagination={false} />
<Modal
title={editRow ? '编辑承运商' : '注册承运商'}
open={open}
onCancel={() => setOpen(false)}
onOk={() => void submit()}
width={560}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input disabled={Boolean(editRow)} placeholder="如 XFX、JD、SF" />
</Form.Item>
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如 小飞侠、京东物流" />
</Form.Item>
<Form.Item name="type" label="类型" rules={[{ required: true }]}>
<Select options={TYPE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
{showXfxFields && (
<>
<Typography.Title level={5} style={{ marginTop: 8 }}>
</Typography.Title>
<Form.Item
name="apiUrl"
label="API 地址"
rules={[{ required: true, message: '请填写小飞侠 API 地址' }]}
extra="推单请求将发往此地址"
>
<Input placeholder={DEFAULT_XFX_API_URL} />
</Form.Item>
<Form.Item
name="mchId"
label="商户号"
rules={[{ required: true, message: '请填写商户号' }]}
>
<Input />
</Form.Item>
<Form.Item
name="apiKey"
label="API Key"
rules={editRow ? [] : [{ required: true, message: '请填写 API Key' }]}
extra={
editRow?.xiaofeixiaConfig?.hasApiKey
? '已配置密钥;留空则保持不变'
: undefined
}
>
<Input.Password placeholder={editRow ? '留空则不修改' : '请输入'} />
</Form.Item>
<Form.Item name="signType" label="签名类型" initialValue="MD5">
<Select options={SIGN_OPTIONS} />
</Form.Item>
<Form.Item name="appId" label="AppID(可选)">
<Input />
</Form.Item>
</>
)}
</Form>
</Modal>
</div>
);
}
+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>
);
}
+453 -89
View File
@@ -9,6 +9,7 @@ import {
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Table,
@@ -17,8 +18,14 @@ import {
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request, type AdminOrderRow, type Paginated } from '../lib/api';
import { DELIVERY_PROVIDER_LABELS, ORDER_STATUS_COLORS, ORDER_STATUS_LABELS, fmtTime } from '../lib/constants';
import { request, type AdminOrderItem, type AdminOrderRow, type Paginated } from '../lib/api';
import {
ADMIN_OPTIONS_PAGE_SIZE,
DELIVERY_PROVIDER_LABELS,
ORDER_STATUS_COLORS,
ORDER_STATUS_LABELS,
fmtTime,
} from '../lib/constants';
type ShipDefaults = {
provider: string;
@@ -33,6 +40,21 @@ type ShipDefaults = {
payMode: string;
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = {
id: string;
name: string;
cityId?: string;
cityName?: string;
status?: string;
contactName: string;
contactPhone: string;
address: string;
lng?: number | null;
lat?: number | null;
};
type OrderDetail = AdminOrderRow & {
receiverAddress?: string;
receiverProvince?: string;
@@ -53,16 +75,60 @@ type OrderDetail = AdminOrderRow & {
benefitAmount?: number;
paidAt?: string | null;
payExpireAt?: string | null;
items?: Array<Record<string, unknown>>;
productImage?: string;
listUnitPrice?: number;
items?: AdminOrderItem[];
payment?: Record<string, unknown> | null;
statusLogs?: Array<{ fromStatus: string | null; toStatus: string; createdAt: string }>;
benefitCoupons?: Array<Record<string, unknown>>;
city?: { name: string; code: string };
fulfillmentWarehouse?: {
id: string;
name: string;
contactName?: string;
contactPhone?: string;
address?: string;
lng?: number | null;
lat?: number | null;
} | null;
};
type ShipMode = 'WAREHOUSE' | 'EXPRESS';
function orderProductRows(detail: OrderDetail): AdminOrderItem[] {
if (detail.items?.length) return detail.items;
if (!detail.productName) return [];
return [{
productName: detail.productName,
productSpec: detail.productSpec ?? '',
productImage: detail.productImage ?? '',
unitPrice: Number(detail.listUnitPrice ?? 0),
quantity: detail.quantity ?? 1,
}];
}
function canShip(row: { status: string; delivery?: { trackingNo?: string | null } | null }) {
return ['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(row.status) && !row.delivery?.trackingNo;
}
function warehouseToDefaults(wh: WarehouseOption, base?: ShipDefaults | null): ShipDefaults {
return {
provider: 'XFX',
providerLabel: '小飞侠',
fromName: wh.contactName || base?.fromName || '杜康仓库',
fromMobile: wh.contactPhone || base?.fromMobile || '13800000000',
fromAddress: wh.address || base?.fromAddress || '',
fromAddressDetail: wh.name || base?.fromAddressDetail || '',
fromLng: wh.lng != null ? Number(wh.lng) : (base?.fromLng ?? 113.665),
fromLat: wh.lat != null ? Number(wh.lat) : (base?.fromLat ?? 34.757),
weight: base?.weight ?? 2,
payMode: base?.payMode ?? '1',
};
}
export default function OrdersPage() {
const [form] = Form.useForm();
const [shipForm] = Form.useForm();
const [logisticsForm] = Form.useForm();
const [data, setData] = useState<Paginated<AdminOrderRow> | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
@@ -74,6 +140,12 @@ export default function OrdersPage() {
const [batchDeleting, setBatchDeleting] = useState(false);
const [shipDefaults, setShipDefaults] = useState<ShipDefaults | null>(null);
const [shipping, setShipping] = useState(false);
const [logisticsShipping, setLogisticsShipping] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [shipModalOpen, setShipModalOpen] = useState(false);
const [shipTarget, setShipTarget] = useState<OrderDetail | null>(null);
const [shipMode, setShipMode] = useState<ShipMode>('EXPRESS');
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const selectedOrders = useMemo(
() => (data?.items ?? []).filter((row) => selectedRowKeys.includes(row.id)),
@@ -87,6 +159,7 @@ export default function OrdersPage() {
const qs = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
if (values.orderNo) qs.set('orderNo', values.orderNo);
if (values.status) qs.set('status', values.status);
if (values.cityId) qs.set('cityId', values.cityId);
if (values.receiverPhone) qs.set('receiverPhone', values.receiverPhone);
const res = await request<Paginated<AdminOrderRow>>(`/admin/orders?${qs}`);
setData(res);
@@ -101,40 +174,100 @@ export default function OrdersPage() {
useEffect(() => {
void request<ShipDefaults>('/admin/orders/ship-defaults').then(setShipDefaults).catch(() => {});
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => {});
}, []);
function applyShipDefaults(defaults: ShipDefaults, warehouseId?: string | null) {
shipForm.setFieldsValue({
warehouseId: warehouseId || undefined,
provider: defaults.provider,
weight: defaults.weight,
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
}
async function loadWarehouses() {
const res = await request<Paginated<WarehouseOption>>(
`/admin/city-warehouses?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&status=ACTIVE`,
).catch(() => null);
const rows = (res?.items ?? []).filter((w) => !w.status || w.status === 'ACTIVE');
setWarehouses(rows);
return rows;
}
async function openDetail(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`);
setDetail(res);
setDrawerOpen(true);
const defaults = shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null);
}
async function openShipModal(id: string) {
const res = await request<OrderDetail>(`/admin/orders/${id}`);
setShipTarget(res);
setDetail(res);
logisticsForm.setFieldsValue({
logisticsCompany: '',
trackingNo: '',
manualQueryUrl: '',
});
const rows = await loadWarehouses();
const preferredWarehouseId = res.fulfillmentWarehouseId || res.fulfillmentWarehouse?.id;
const preferred =
rows.find((w) => w.id === preferredWarehouseId) ||
rows.find((w) => w.cityId === (res.city?.id || res.cityId)) ||
rows[0];
const defaults = preferred
? warehouseToDefaults(preferred, shipDefaults)
: (shipDefaults ?? await request<ShipDefaults>('/admin/orders/ship-defaults').catch(() => null));
if (defaults) {
setShipDefaults(defaults);
shipForm.setFieldsValue({
provider: defaults.provider,
weight: defaults.weight,
payMode: defaults.payMode,
fromName: defaults.fromName,
fromMobile: defaults.fromMobile,
fromAddress: defaults.fromAddress,
fromAddressDetail: defaults.fromAddressDetail,
fromLng: defaults.fromLng,
fromLat: defaults.fromLat,
});
applyShipDefaults(defaults, preferred?.id);
} else {
shipForm.setFieldsValue({ warehouseId: preferred?.id, provider: 'XFX' });
}
setShipMode(preferred ? 'WAREHOUSE' : 'EXPRESS');
setShipModalOpen(true);
}
async function onWarehouseChange(warehouseId: string) {
const wh = warehouses.find((w) => w.id === warehouseId);
if (!wh) return;
applyShipDefaults(warehouseToDefaults(wh, shipDefaults), warehouseId);
}
async function submitShip() {
if (!detail) return;
if (!shipTarget) return;
const values = await shipForm.validateFields();
setShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${detail.id}/ship`, {
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/ship`, {
method: 'POST',
body: JSON.stringify(values),
body: JSON.stringify({
provider: values.provider || 'XFX',
warehouseId: values.warehouseId,
weight: values.weight,
payMode: values.payMode,
remark: values.remark,
fromName: values.fromName,
fromMobile: values.fromMobile,
fromAddress: values.fromAddress,
fromAddressDetail: values.fromAddressDetail,
fromLng: values.fromLng,
fromLat: values.fromLat,
}),
});
message.success('发货成功');
message.success('选仓发货成功');
setShipTarget(res);
setDetail(res);
setShipModalOpen(false);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '发货失败');
@@ -143,6 +276,37 @@ export default function OrdersPage() {
}
}
async function submitLogisticsShip() {
if (!shipTarget) return;
const values = await logisticsForm.validateFields(['logisticsCompany', 'trackingNo', 'manualQueryUrl']);
const logisticsCompany = String(values.logisticsCompany ?? '').trim();
const trackingNo = String(values.trackingNo ?? '').trim();
if (!logisticsCompany || !trackingNo) {
message.error('请填写快递公司与运单号');
return;
}
setLogisticsShipping(true);
try {
const res = await request<OrderDetail>(`/admin/orders/${shipTarget.id}/logistics-ship`, {
method: 'POST',
body: JSON.stringify({
logisticsCompany,
trackingNo,
manualQueryUrl: String(values.manualQueryUrl ?? '').trim() || undefined,
}),
});
message.success('快递单已录入');
setShipTarget(res);
setDetail(res);
setShipModalOpen(false);
void load();
} catch (e) {
message.error(e instanceof Error ? e.message : '填单失败');
} finally {
setLogisticsShipping(false);
}
}
async function confirmBatchDelete() {
if (!selectedRowKeys.length) return;
setBatchDeleting(true);
@@ -168,6 +332,25 @@ export default function OrdersPage() {
const columns: ColumnsType<AdminOrderRow> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180 },
{
title: '城市',
width: 90,
render: (_, row) => row.city?.name || '—',
},
{
title: '商品',
width: 180,
ellipsis: true,
render: (_, row) => {
const name = row.productName || '—';
const qty = row.quantity != null ? ` ×${row.quantity}` : '';
return (
<span title={row.productSpec ? `${name}${row.productSpec}` : name}>
{name}{qty}
</span>
);
},
},
{
title: '状态',
dataIndex: 'status',
@@ -204,11 +387,19 @@ export default function OrdersPage() {
},
{
title: '操作',
width: 80,
width: 140,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
<Space size={0}>
<Button type="link" size="small" onClick={() => openDetail(row.id)}>
</Button>
{canShip(row) && (
<Button type="link" size="small" onClick={() => void openShipModal(row.id)}>
</Button>
)}
</Space>
),
},
];
@@ -232,6 +423,16 @@ export default function OrdersPage() {
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={Object.entries(ORDER_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="receiverPhone" label="收货手机">
<Input allowClear />
</Form.Item>
@@ -248,7 +449,7 @@ export default function OrdersPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1500 }}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as string[]),
@@ -272,6 +473,11 @@ export default function OrdersPage() {
onClose={() => setDrawerOpen(false)}
extra={detail && (
<Space>
{canShip(detail) && (
<Button type="primary" onClick={() => void openShipModal(detail.id)}>
</Button>
)}
<Typography.Text type="secondary"></Typography.Text>
<Select
value={detail.status}
@@ -295,6 +501,13 @@ export default function OrdersPage() {
<>
<Descriptions column={1} bordered size="small" title="基本信息">
<Descriptions.Item label="订单号">{detail.orderNo}</Descriptions.Item>
<Descriptions.Item label="开城城市">
{detail.city?.name || '—'}
{detail.city?.code ? `${detail.city.code}` : ''}
</Descriptions.Item>
<Descriptions.Item label="履约仓">
{detail.fulfillmentWarehouse?.name || '未分配'}
</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={ORDER_STATUS_COLORS[detail.status] || 'default'}>
{ORDER_STATUS_LABELS[detail.status] || detail.status}
@@ -307,6 +520,51 @@ export default function OrdersPage() {
<Descriptions.Item label="下单时间">{new Date(detail.createdAt).toLocaleString('zh-CN')}</Descriptions.Item>
</Descriptions>
<Typography.Title level={5} style={{ marginTop: 16, marginBottom: 8 }}></Typography.Title>
<Table
size="small"
rowKey={(_, i) => String(i)}
pagination={false}
dataSource={orderProductRows(detail)}
locale={{ emptyText: '无商品信息' }}
columns={[
{
title: '商品',
dataIndex: 'productName',
render: (name: string, row) => (
<Space>
{row.productImage ? (
<img
src={row.productImage}
alt=""
style={{ width: 40, height: 40, objectFit: 'cover', borderRadius: 4 }}
/>
) : null}
<span>
{name || '—'}
{row.productSpec ? (
<Typography.Text type="secondary" style={{ display: 'block', fontSize: 12 }}>
{row.productSpec}
</Typography.Text>
) : null}
</span>
</Space>
),
},
{
title: '单价',
dataIndex: 'unitPrice',
width: 90,
render: (v: number) => `¥${v}`,
},
{
title: '数量',
dataIndex: 'quantity',
width: 70,
},
]}
/>
<Descriptions column={1} bordered size="small" title="收货信息" style={{ marginTop: 16 }}>
<Descriptions.Item label="收货人">{detail.receiverName} {detail.receiverPhone}</Descriptions.Item>
<Descriptions.Item label="地址">{detail.receiverAddress}</Descriptions.Item>
@@ -324,74 +582,22 @@ export default function OrdersPage() {
{detail.delivery && (
<Descriptions column={1} bordered size="small" title="配送" style={{ marginTop: 16 }}>
<Descriptions.Item label="快递公司">
{DELIVERY_PROVIDER_LABELS[detail.delivery.provider] || detail.delivery.provider}
{detail.delivery.logisticsCompany ||
DELIVERY_PROVIDER_LABELS[detail.delivery.provider] ||
detail.delivery.provider}
</Descriptions.Item>
<Descriptions.Item label="运单号">{detail.delivery.trackingNo || '—'}</Descriptions.Item>
<Descriptions.Item label="三方单号">{detail.delivery.providerOrderNo || '—'}</Descriptions.Item>
{detail.delivery.manualQueryUrl && (
<Descriptions.Item label="查询链接">
<a href={detail.delivery.manualQueryUrl} target="_blank" rel="noreferrer">
</a>
</Descriptions.Item>
)}
</Descriptions>
)}
{['PENDING_SHIP', 'OUT_WAREHOUSE'].includes(detail.status) && !detail.delivery?.trackingNo && (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}></Typography.Title>
<Form form={shipForm} layout="vertical" size="small">
<Form.Item name="provider" label="快递公司" rules={[{ required: true }]}>
<Select
options={[{ value: 'XFX', label: '小飞侠' }]}
/>
</Form.Item>
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: '寄付' },
{ value: '2', label: '到付' },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
<Collapse
ghost
items={[{
key: 'from',
label: '寄件信息(默认仓库,可修改)',
children: (
<>
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Form.Item name="fromLng" label="经度">
<InputNumber step={0.001} />
</Form.Item>
<Form.Item name="fromLat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
),
}]}
/>
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
</Button>
</Form>
</div>
)}
{detail.statusLogs && detail.statusLogs.length > 0 && (
<>
<Typography.Title level={5} style={{ marginTop: 16 }}></Typography.Title>
@@ -412,6 +618,150 @@ export default function OrdersPage() {
)}
</Drawer>
<Modal
title={shipTarget ? `配送发货 · ${shipTarget.orderNo}` : '配送发货'}
open={shipModalOpen}
onCancel={() => setShipModalOpen(false)}
width={560}
destroyOnClose={false}
forceRender
footer={
<Space>
<Button onClick={() => setShipModalOpen(false)}></Button>
{shipMode === 'WAREHOUSE' ? (
<Button type="primary" loading={shipping} onClick={() => void submitShip()}>
</Button>
) : (
<Button type="primary" loading={logisticsShipping} onClick={() => void submitLogisticsShip()}>
</Button>
)}
</Space>
}
>
{shipTarget && (
<>
<Descriptions size="small" column={1} style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{shipTarget.city?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="收货">
{shipTarget.receiverName} {shipTarget.receiverPhone}
</Descriptions.Item>
<Descriptions.Item label="商品">
{shipTarget.productName || '—'}
{shipTarget.quantity != null ? ` ×${shipTarget.quantity}` : ''}
</Descriptions.Item>
</Descriptions>
<Radio.Group
value={shipMode}
onChange={(e) => setShipMode(e.target.value as ShipMode)}
optionType="button"
buttonStyle="solid"
style={{ marginBottom: 16 }}
options={[
{ value: 'WAREHOUSE', label: '选仓配送' },
{ value: 'EXPRESS', label: '填写快递单号' },
]}
/>
<div style={{ display: shipMode === 'WAREHOUSE' ? 'block' : 'none' }}>
<Form form={shipForm} layout="vertical" size="small" preserve>
<Form.Item
name="warehouseId"
label="选择仓库"
rules={[{ required: true, message: '请选择仓库' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder={warehouses.length ? '选择仓库' : '暂无可用仓库'}
options={warehouses.map((w) => ({
value: w.id,
label: w.cityName ? `${w.cityName} · ${w.name}` : w.name,
}))}
onChange={(id) => void onWarehouseChange(id)}
/>
</Form.Item>
<Form.Item name="provider" label="承运商" rules={[{ required: true }]} initialValue="XFX">
<Select options={[{ value: 'XFX', label: '小飞侠' }]} />
</Form.Item>
<Form.Item name="weight" label="重量(kg)" rules={[{ required: true }]}>
<InputNumber min={0.01} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="payMode" label="付费方式" rules={[{ required: true }]}>
<Select
options={[
{ value: '1', label: '寄付' },
{ value: '2', label: '到付' },
]}
/>
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} placeholder="可选" />
</Form.Item>
<Collapse
ghost
items={[{
key: 'from',
label: '寄件信息(默认仓库,可修改)',
children: (
<>
<Form.Item name="fromName" label="寄件人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromMobile" label="寄件手机" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddress" label="寄件区域" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="fromAddressDetail" label="寄件详细地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Space>
<Form.Item name="fromLng" label="经度">
<InputNumber step={0.001} />
</Form.Item>
<Form.Item name="fromLat" label="纬度">
<InputNumber step={0.001} />
</Form.Item>
</Space>
</>
),
}]}
/>
</Form>
</div>
<div style={{ display: shipMode === 'EXPRESS' ? 'block' : 'none' }}>
<Form form={logisticsForm} layout="vertical" size="small" preserve>
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 0 }}>
</Typography.Paragraph>
<Form.Item
name="logisticsCompany"
label="快递公司"
rules={[{ required: true, message: '请填写快递公司' }]}
>
<Input placeholder="如 顺丰速运、京东物流" />
</Form.Item>
<Form.Item
name="trackingNo"
label="运单号"
rules={[{ required: true, message: '请填写运单号' }]}
>
<Input placeholder="请输入运单号" />
</Form.Item>
<Form.Item name="manualQueryUrl" label="物流查询链接(可选)">
<Input placeholder="https://..." />
</Form.Item>
</Form>
</div>
</>
)}
</Modal>
<Modal
title={`确认批量删除(${selectedOrders.length} 笔)`}
open={batchDeleteOpen}
@@ -437,13 +787,27 @@ export default function OrdersPage() {
dataSource={selectedOrders}
columns={[
{ title: '订单号', dataIndex: 'orderNo', width: 170 },
{
title: '城市',
width: 80,
render: (_, row) => row.city?.name || '—',
},
{
title: '商品',
width: 160,
ellipsis: true,
render: (_, row) =>
row.productName
? `${row.productName}${row.quantity != null ? ` ×${row.quantity}` : ''}`
: '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (s) => (
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
<Tag color={ORDER_STATUS_COLORS[s] || 'default'}>{ORDER_STATUS_LABELS[s] || s}</Tag>
),
},
{ title: '实付', dataIndex: 'payAmount', width: 90, render: (v) => `¥${v}` },
{ title: '收货人', dataIndex: 'receiverName', width: 90 },
@@ -154,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create', 'store:manage'] });
setSubOpen(true);
}
+93 -16
View File
@@ -4,6 +4,7 @@ import {
Card,
DatePicker,
Form,
Input,
Modal,
Select,
Space,
@@ -29,6 +30,7 @@ type Row = {
status: string;
periodStart: string;
periodEnd: string;
rejectReason?: string | null;
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
@@ -36,15 +38,17 @@ type Row = {
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '已确认',
DRAFT: '待合伙人确认',
CONFIRMED: '待打款审核',
PAID: '已打款',
REJECTED: '已驳回',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
CONFIRMED: 'blue',
CONFIRMED: 'orange',
PAID: 'green',
REJECTED: 'red',
};
export default function PartnerBillsPage() {
@@ -66,6 +70,10 @@ export default function PartnerBillsPage() {
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectTarget, setRejectTarget] = useState<Row | null>(null);
const [rejectForm] = Form.useForm();
const [rejecting, setRejecting] = useState(false);
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -95,7 +103,7 @@ export default function PartnerBillsPage() {
async function confirmBill(id: string) {
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
message.success('已确认');
message.success('已确认,进入待打款审核');
reload();
}
@@ -104,10 +112,33 @@ export default function PartnerBillsPage() {
method: 'POST',
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
});
message.success('已标记打款');
message.success('已通过并标记打款');
reload();
}
function openReject(row: Row) {
setRejectTarget(row);
rejectForm.resetFields();
setRejectOpen(true);
}
async function submitReject(values: { reason: string }) {
if (!rejectTarget) return;
setRejecting(true);
try {
await request(`/admin/partner-bills/${rejectTarget.id}/reject`, {
method: 'POST',
body: JSON.stringify({ reason: values.reason.trim() }),
});
message.success('已驳回打款申请');
setRejectOpen(false);
setRejectTarget(null);
reload();
} finally {
setRejecting(false);
}
}
async function exportExcel() {
setExporting(true);
try {
@@ -163,24 +194,38 @@ export default function PartnerBillsPage() {
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
width: 120,
render: (s, row) => (
<Space direction="vertical" size={0}>
<Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>
{s === 'REJECTED' && row.rejectReason ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.rejectReason}
</Typography.Text>
) : null}
</Space>
),
},
{
title: '操作',
width: 160,
width: 220,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Space size={0} wrap>
{row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button>
)}
{row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
<>
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
<Button type="link" size="small" danger onClick={() => openReject(row)}>
</Button>
</>
)}
</Space>
),
@@ -194,7 +239,7 @@ export default function PartnerBillsPage() {
</Typography.Title>
<Typography.Text type="secondary">
T+30 × ×
T+30
</Typography.Text>
</Space>
@@ -242,7 +287,7 @@ export default function PartnerBillsPage() {
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
style={{ width: 140 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
@@ -280,7 +325,7 @@ export default function PartnerBillsPage() {
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1280 }}
pagination={{
current: page,
pageSize,
@@ -320,6 +365,38 @@ export default function PartnerBillsPage() {
</Button>
</Form>
</Modal>
<Modal
title="驳回打款申请"
open={rejectOpen}
onCancel={() => {
setRejectOpen(false);
setRejectTarget(null);
}}
footer={null}
destroyOnClose
>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
{rejectTarget?.billNo} · {rejectTarget ? partnerName(rejectTarget) : ''}
<br />
H5
</Typography.Paragraph>
<Form form={rejectForm} layout="vertical" onFinish={(v) => void submitReject(v)}>
<Form.Item
name="reason"
label="驳回理由"
rules={[
{ required: true, message: '请填写驳回理由' },
{ max: 500, message: '不超过 500 字' },
]}
>
<Input.TextArea rows={4} placeholder="请说明驳回原因,便于合伙人核对后重新申请" maxLength={500} showCount />
</Form.Item>
<Button type="primary" danger htmlType="submit" block loading={rejecting}>
</Button>
</Form>
</Modal>
</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,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert, Button, Card, Col, Descriptions, Form, Input, InputNumber, Row, Select, Space,
Tabs, Tag, Typography, message,
@@ -15,6 +16,8 @@ type XfxConfig = {
hasApiKey: boolean;
signType: string;
ready: boolean;
source?: string;
hint?: string;
};
type ApiResult = {
@@ -115,8 +118,9 @@ export default function XiaofeixiaTestPage() {
<div>
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
HQ APIcmd 100101~100301
{' '}<code>XIAOFEIXIA_MCH_ID</code><code>XIAOFEIXIA_API_KEY</code>
HQ APIcmd 100101~100301
<Link to="/fulfillment-providers"></Link>
使
</Typography.Paragraph>
{config && (
@@ -132,13 +136,17 @@ export default function XiaofeixiaTestPage() {
<Descriptions.Item label="状态">
{config.ready ? <Tag color="green"></Tag> : <Tag color="orange"></Tag>}
</Descriptions.Item>
<Descriptions.Item label="来源" span={3}>
{config.source === 'fulfillment_provider' ? '仓配管理' : '环境变量(兼容)'}
{config.hint ? ` · ${config.hint}` : ''}
</Descriptions.Item>
</Descriptions>
{!config.ready && (
<Alert
type="warning"
showIcon
style={{ marginTop: 12 }}
message="请在 server/dukang-api/.env 中配置 XIAOFEIXIA_MCH_ID 与 XIAOFEIXIA_API_KEY 后重启 API"
message="请先在仓配管理中注册小飞侠并填写 API 地址、商户号与 API Key"
/>
)}
</Card>
+22 -9
View File
@@ -1,6 +1,6 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { usePartnerSession } from './contexts/PartnerSessionContext';
import { isSubAccount } from './lib/partnerAccess';
import { getPartnerNavKind, isSubAccount } from './lib/partnerAccess';
import TabLayout from './layouts/TabLayout';
import SubAccountLayout from './layouts/SubAccountLayout';
import HomePage from './pages/HomePage';
@@ -13,7 +13,6 @@ import CenterPage from './pages/CenterPage';
import PartnerMePage from './pages/PartnerMePage';
import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import StaffListPage from './pages/StaffListPage';
@@ -26,7 +25,6 @@ function PrimaryRoutes() {
<Route element={<TabLayout />}>
<Route path="/" element={<HomePage />} />
<Route path="/stores" element={<StoreListPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/center" element={<CenterPage />} />
</Route>
<Route path="/stores/new" element={<StoreCreatePage />} />
@@ -35,10 +33,10 @@ function PrimaryRoutes() {
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route path="/orders" element={<OrderListPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
@@ -46,15 +44,30 @@ function PrimaryRoutes() {
}
function SubAccountRoutes() {
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
return (
<Routes>
<Route element={<SubAccountLayout />}>
<Route path="/stores" element={<StoreListPage />} />
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
<Route element={<SubAccountLayout navKind={navKind} />}>
<Route path="/" element={<HomePage />} />
{isWarehouse ? (
<Route path="/orders" element={<OrderListPage tabRoot />} />
) : (
<Route path="/stores" element={<StoreListPage />} />
)}
<Route path="/me" element={<PartnerMePage />} />
</Route>
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
{!isWarehouse && (
<>
<Route path="/stores/new" element={<StoreCreatePage />} />
<Route path="/stores/:id" element={<StoreDetailPage />} />
</>
)}
<Route path="/leaderboard" element={<LeaderboardPage />} />
{isWarehouse && <Route path="/orders/:id" element={<OrderDetailPage />} />}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+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>
)}
@@ -50,7 +50,13 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
staffRole: profile.staffRole,
permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId,
primaryPhone: profile.primaryPhone,
primaryName: profile.primaryName,
hasWechat: profile.hasWechat,
wxNickname: profile.wxNickname,
wxAvatarUrl: profile.wxAvatarUrl,
managedWarehouseId: profile.managedWarehouseId,
hasWarehouseAccess: profile.hasWarehouseAccess,
};
}
@@ -1,17 +1,30 @@
import { NavLink, Outlet } from 'react-router-dom';
import type { PartnerNavKind } from '../lib/partnerAccess';
const TABS = [
{ to: '/stores', end: true, icon: 'store', label: '门店管理' },
{ to: '/stores/new', icon: 'add_business', label: '录入门店' },
{ to: '/me', icon: 'person', label: '我的' },
const STORE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/stores', icon: 'store', label: '门店管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
export default function SubAccountLayout() {
const WAREHOUSE_STAFF_TABS = [
{ to: '/', end: true, icon: 'dashboard', label: '首页' },
{ to: '/orders', icon: 'receipt_long', label: '订单管理' },
{ to: '/me', icon: 'person', label: '个人中心' },
] as const;
type SubAccountLayoutProps = {
navKind: PartnerNavKind;
};
export default function SubAccountLayout({ navKind }: SubAccountLayoutProps) {
const tabs = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_TABS : STORE_STAFF_TABS;
return (
<>
<Outlet />
<nav className="app-tabbar">
{TABS.map((tab) => (
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
+29 -5
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 = [
@@ -24,7 +24,20 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
export type PartnerSessionProfile = Pick<
PartnerMe,
'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId' | 'hasWechat'
| 'id'
| 'name'
| 'phone'
| 'companyName'
| 'isPrimary'
| 'permissions'
| 'primaryAccountId'
| 'primaryPhone'
| 'primaryName'
| 'hasWechat'
| 'wxNickname'
| 'wxAvatarUrl'
| 'managedWarehouseId'
| 'hasWarehouseAccess'
> & {
staffRole?: PartnerStaffRole;
};
@@ -64,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));
}
@@ -77,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 }) {
@@ -109,7 +127,13 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
staffRole: me.staffRole ?? undefined,
permissions: me.permissions,
primaryAccountId: me.primaryAccountId,
primaryPhone: me.primaryPhone,
primaryName: me.primaryName,
hasWechat: me.hasWechat,
wxNickname: me.wxNickname,
wxAvatarUrl: me.wxAvatarUrl,
managedWarehouseId: me.managedWarehouseId,
hasWarehouseAccess: me.hasWarehouseAccess,
};
}
+17
View File
@@ -0,0 +1,17 @@
/** 合伙人端客服热线(可后续改为环境变量) */
export const PARTNER_SUPPORT_PHONE = '400-000-0000';
export function dialPhone(phone: string) {
const normalized = phone.replace(/\s+/g, '');
if (!normalized) return false;
window.location.href = `tel:${normalized}`;
return true;
}
export function contactSupport() {
return dialPhone(PARTNER_SUPPORT_PHONE);
}
export function contactPartnerPhone(phone?: string) {
return dialPhone(phone ?? '');
}
+1
View File
@@ -5,6 +5,7 @@ export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'tota
return request<PartnerLeaderboardResponse>(
'PARTNER_H5',
`/partner/dashboard/leaderboard?period=${period}`,
{ silent: true },
);
}
+80 -5
View File
@@ -1,5 +1,7 @@
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export type PartnerNavKind = 'primary' | 'store_staff' | 'warehouse_staff';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false;
}
@@ -17,15 +19,88 @@ export function hasPartnerPermission(
return account.permissions?.includes(permission) ?? false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return isSubAccount(account) ? '/stores/new?step=1' : '/';
export function hasAnyPartnerPermission(
account: PartnerMe | null | undefined,
permissions: PartnerPermissionKey[],
): boolean {
return permissions.some((p) => hasPartnerPermission(account, p));
}
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/me', '/login'];
/** 与后端 GET /partner/dashboard 权限点一致 */
export function canAccessPartnerDashboard(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, [
'store:create',
'store:manage',
'order:view',
'warehouse:manage',
]);
}
export function isSubAccountPath(pathname: string): boolean {
/** 门店列表/拓店统计所需权限 */
export function canAccessPartnerStores(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['store:create', 'store:manage']);
}
/** 编辑资料 / 开闭店 / 重新上传:主账号、门店权限,或历史未配权限的门店类子账号 */
export function canManagePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasAnyPartnerPermission(account, ['store:manage', 'store:create'])) return true;
// 合伙人端早期创建的子账号可能 permissions 为空,按门店员工放开
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 录入新店 */
export function canCreatePartnerStore(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
if (isWarehouseStaff(account)) return false;
if (hasPartnerPermission(account, 'store:create')) return true;
return !Array.isArray(account.permissions) || account.permissions.length === 0;
}
/** 与后端 GET /partner/orders 权限点一致 */
export function canAccessPartnerOrders(account: PartnerMe | null | undefined): boolean {
return hasAnyPartnerPermission(account, ['order:view', 'warehouse:manage']);
}
/** 仓库管理员:warehouse:manage,或仅有 order:view(无门店权限) */
export function isWarehouseStaff(account: PartnerMe | null | undefined): boolean {
if (!account || isPrimaryAccount(account)) return false;
if (hasPartnerPermission(account, 'warehouse:manage')) return true;
const hasStore =
hasPartnerPermission(account, 'store:create') || hasPartnerPermission(account, 'store:manage');
if (hasPartnerPermission(account, 'order:view') && !hasStore) return true;
return false;
}
export function getPartnerNavKind(account: PartnerMe | null | undefined): PartnerNavKind {
if (!account || isPrimaryAccount(account)) return 'primary';
if (isWarehouseStaff(account)) return 'warehouse_staff';
return 'store_staff';
}
/** 是否已配置管仓:主账号看 hasWarehouseAccess;子账号还需有仓库相关权限 */
export function hasWarehouseAccess(account: PartnerMe | null | undefined): boolean {
if (!account) return false;
if (account.hasWarehouseAccess === false) return false;
if (account.hasWarehouseAccess === true) return true;
// 旧缓存无字段时保守:主账号未知视为无,避免误展示全城订单
return false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return '/';
}
const STORE_STAFF_PREFIXES = ['/', '/stores', '/me', '/leaderboard', '/login'];
const WAREHOUSE_STAFF_PREFIXES = ['/', '/orders', '/me', '/leaderboard', '/login'];
export function isSubAccountPath(pathname: string, navKind: PartnerNavKind = 'store_staff'): boolean {
if (pathname === '/login') return true;
return SUB_ACCOUNT_ALLOWED_PREFIXES.some(
const prefixes = navKind === 'warehouse_staff' ? WAREHOUSE_STAFF_PREFIXES : STORE_STAFF_PREFIXES;
return prefixes.some(
(prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)),
);
}
+6 -7
View File
@@ -1,9 +1,5 @@
import {
PartnerStaffRole,
type CreatePartnerStaffRequest,
type PartnerStaffItem,
type UpdatePartnerStaffRequest,
} from '@dukang/shared-types';
import { PartnerStaffRole, DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS } from '@dukang/shared-types';
import type { CreatePartnerStaffRequest, PartnerStaffItem, UpdatePartnerStaffRequest } from '@dukang/shared-types';
import { request } from './api';
export function listPartnerStaff() {
@@ -25,7 +21,10 @@ export function createPartnerStaff(body: CreatePartnerStaffRequest) {
name: body.name,
phone: body.phone,
smsCode: body.smsCode,
staffRole: PartnerStaffRole.INTERNAL,
staffRole: body.staffRole ?? PartnerStaffRole.INTERNAL,
permissions: body.permissions?.length
? body.permissions
: [...DEFAULT_PARTNER_STORE_STAFF_PERMISSIONS],
}),
});
}
+4
View File
@@ -14,6 +14,8 @@ export type PartnerProfile = {
phone: string;
companyName?: string;
hasWechat?: boolean;
wxNickname?: string | null;
wxAvatarUrl?: string | null;
isPrimary?: boolean;
};
@@ -82,6 +84,8 @@ export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessio
companyName: partner.companyName ? String(partner.companyName) : undefined,
isPrimary: partner.isPrimary !== false,
hasWechat: true,
wxNickname: partner.wxNickname ? String(partner.wxNickname) : undefined,
wxAvatarUrl: partner.wxAvatarUrl ? String(partner.wxAvatarUrl) : undefined,
}
: undefined,
};
+148 -49
View File
@@ -1,42 +1,96 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function billStatusLabel(status: string) {
switch (status) {
case 'DRAFT': return '待确认';
case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status;
}
}
function canApplyPayment(status: string) {
return status === 'DRAFT' || status === 'REJECTED';
}
export default function BillsPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [confirmed, setConfirmed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
async function loadBills() {
setLoading(true);
try {
const list = await request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills');
setBills(Array.isArray(list) ? list : []);
} catch {
setBills([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
void loadBills();
}, [navigate]);
const billId = searchParams.get('id');
const actionable = useMemo(
() => bills.filter((b) => canApplyPayment(b.status)),
[bills],
);
const bill = billId
? bills.find((b) => String(b.id) === billId) ?? bills[0]
: bills[0];
? bills.find((b) => String(b.id) === billId) ?? actionable[0] ?? bills[0]
: actionable[0] ?? bills[0];
function confirmBill() {
if (!confirmed) return;
const status = String(bill?.status || '');
const showApplyForm = !!bill && canApplyPayment(status);
const isReviewing = status === 'CONFIRMED';
const isRejected = status === 'REJECTED';
const isPaid = status === 'PAID';
async function confirmBill() {
if (!bill || !confirmed || !canApplyPayment(String(bill.status))) return;
setSubmitting(true);
setTimeout(() => {
try {
await request('PARTNER_H5', `/partner/settlement/bills/${bill.id}/confirm`, {
method: 'POST',
});
toastSuccess('申请已提交,请耐心等待总部打款审核');
setConfirmed(false);
await loadBills();
} catch (e) {
toastError(e instanceof Error ? e.message : '提交失败');
} finally {
setSubmitting(false);
alert('申请已提交,请耐心等待打款审核。(preV1 演示)');
}, 1500);
}
}
return (
<div className="partner-bills-page">
<PageHeader title="待确认账单" onBack={() => navigate('/center/settlement')} />
<PageHeader title="账单确认" onBack={() => navigate('/center/settlement')} />
<div className="partner-bill-stepper">
<div className="partner-stepper-inner">
<div className="partner-stepper-line" aria-hidden>
<div className="partner-stepper-line-fill" style={{ width: '50%' }} />
<div
className="partner-stepper-line-fill"
style={{ width: isPaid ? '100%' : isReviewing || isRejected ? '75%' : '50%' }}
/>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--done">
@@ -45,17 +99,28 @@ export default function BillsPage() {
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm partner-step-circle--active">2</div>
<div className={`partner-step-circle partner-step-circle--sm${showApplyForm || isReviewing || isRejected || isPaid ? ' partner-step-circle--done' : ' partner-step-circle--active'}`}>
{showApplyForm && !confirmed ? '2' : (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<span className="partner-step-label partner-step-label--active"></span>
</div>
<div className="partner-step">
<div className="partner-step-circle partner-step-circle--sm">3</div>
<span className="partner-step-label"></span>
<div className={`partner-step-circle partner-step-circle--sm${isPaid ? ' partner-step-circle--done' : isReviewing || isRejected ? ' partner-step-circle--active' : ''}`}>
{isPaid ? (
<span className="material-symbols-outlined" style={{ fontSize: 14, fontVariationSettings: "'FILL' 1" }}>check</span>
) : '3'}
</div>
<span className={`partner-step-label${isReviewing || isRejected || isPaid ? ' partner-step-label--active' : ''}`}>
{isRejected ? '已驳回' : isPaid ? '已打款' : '申请打款'}
</span>
</div>
</div>
</div>
{!bill && <div className="empty"></div>}
{loading && <div className="empty"></div>}
{!loading && !bill && <div className="empty"></div>}
{bill && (
<section className="partner-bill-card">
@@ -66,25 +131,46 @@ export default function BillsPage() {
<p className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em', marginBottom: 4 }}>SETTLEMENT PERIOD</p>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(bill.billNo || '月度结算账单')}</h2>
</div>
<span className="partner-status-pill partner-status-pill--paused">{String(bill.status || '待确认')}</span>
<span className={`partner-status-pill${isRejected ? ' partner-status-pill--closed' : isReviewing ? ' partner-status-pill--paused' : isPaid ? ' partner-status-pill--open' : ' partner-status-pill--paused'}`}>
{billStatusLabel(status)}
</span>
</div>
{isRejected && bill.rejectReason && (
<div className="partner-info-banner" style={{ marginBottom: 16, background: 'rgba(166,29,36,0.06)' }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>error</span>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600, marginBottom: 4 }}></p>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>{bill.rejectReason}</p>
</div>
</div>
)}
{isReviewing && (
<div className="partner-info-banner" style={{ marginBottom: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>hourglass_top</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
)}
<div className="partner-bill-amount">
<p className="text-muted body-md" style={{ marginBottom: 8 }}></p>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'center', gap: 4 }}>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 20 }}>¥</span>
<span className="amount-xl">{Number(bill.totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="amount-xl">{fmtMoney(Number(bill.totalAmount || 0))}</span>
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.82).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.orderCommission || 0))}</span>
</div>
<div className="partner-info-row">
<span className="text-variant body-md"></span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {(Number(bill.totalAmount || 0) * 0.18).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}</span>
<span className="body-md" style={{ fontWeight: 700 }}>¥ {fmtMoney(Number(bill.redeemCommission || 0))}</span>
</div>
</div>
</div>
@@ -93,45 +179,58 @@ export default function BillsPage() {
{bills.length > 1 && (
<div style={{ padding: '0 20px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.slice(1).map((b) => (
<div key={String(b.id)} className="partner-store-card" style={{ margin: '0 0 12px' }}>
<h3 className="headline-md" style={{ marginBottom: 12 }}></h3>
{bills.map((b) => (
<button
key={String(b.id)}
type="button"
className="partner-store-card"
style={{ margin: '0 0 12px', width: '100%', textAlign: 'left', cursor: 'pointer' }}
onClick={() => navigate(`/center/bills?id=${b.id}`)}
>
<div className="partner-store-card-header" style={{ marginBottom: 0 }}>
<div>
<p className="body-md">{String(b.billNo)}</p>
<p className="label-md text-muted">{String(b.status)}</p>
<p className="body-md">{b.billNo}</p>
<p className="label-md text-muted">{billStatusLabel(b.status)}</p>
{b.status === 'REJECTED' && b.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 4 }}>{b.rejectReason}</p>
) : null}
</div>
<span className="amount-lg" style={{ fontSize: 18 }}>¥{Number(b.totalAmount).toFixed(2)}</span>
</div>
</div>
</button>
))}
</div>
)}
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
</p>
</div>
{showApplyForm && (
<>
<div className="partner-info-banner" style={{ marginTop: 16 }}>
<span className="material-symbols-outlined text-primary" style={{ fontVariationSettings: "'FILL' 1" }}>info</span>
<p className="body-md text-variant" style={{ lineHeight: 1.5 }}>
{isRejected
? '请根据驳回理由核对后重新确认并申请打款。'
: '账单确认后将正式进入打款审核。如有异议,请在确认前联系城市运营经理核实数据。'}
</p>
</div>
{bill && (
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={confirmBill}
>
{submitting ? '正在提交...' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
<footer className="partner-bill-footer">
<label className="partner-checkbox-row" style={{ marginBottom: 16 }}>
<input type="checkbox" checked={confirmed} onChange={(e) => setConfirmed(e.target.checked)} />
<span></span>
</label>
<button
type="button"
className="partner-btn-primary"
disabled={!confirmed || submitting}
style={{ opacity: confirmed ? 1 : 0.5 }}
onClick={() => void confirmBill()}
>
{submitting ? '正在提交...' : isRejected ? '重新申请打款' : '确认并申请打款'}
{!submitting && <span className="material-symbols-outlined">payments</span>}
</button>
</footer>
</>
)}
</div>
);
+265 -133
View File
@@ -1,157 +1,289 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { contactSupport } from '../lib/contact';
import { isPrimaryAccount } from '../lib/partnerAccess';
import { listPartnerStaff } from '../lib/staff';
import { toastError, toastSuccess } from '../lib/toast';
import {
authorizePartnerWechat,
fetchClientConfig,
handlePartnerWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function CenterPage() {
const navigate = useNavigate();
const { account, logout } = usePartnerSession();
const showPrimaryMenus = isPrimaryAccount(account);
const me = account as unknown as Record<string, unknown> | null;
const [bills, setBills] = useState<Array<Record<string, unknown>>>([]);
type CenterPageProps = {
/** 子账号个人中心复用 */
variant?: 'primary' | 'sub';
roleLabel?: string;
};
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function CenterPage({ variant = 'primary', roleLabel }: CenterPageProps) {
const { account, logout, refresh, applySession } = usePartnerSession();
const isPrimary = variant === 'primary' && isPrimaryAccount(account);
const [authorizing, setAuthorizing] = useState(false);
const [bills, setBills] = useState<PartnerBillDto[]>([]);
const [staffCount, setStaffCount] = useState(0);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/settlement/bills').then(setBills);
}, [navigate]);
document.title = isPrimary ? '合伙人中心' : '个人中心';
}, [isPrimary]);
const pendingBills = bills.filter((b) => String(b.status).includes('PENDING') || String(b.status).includes('CONFIRM'));
useEffect(() => {
if (!isPrimary) return;
void request<PartnerBillDto[]>('PARTNER_H5', '/partner/settlement/bills')
.then(setBills)
.catch(() => setBills([]));
void listPartnerStaff()
.then((list) => setStaffCount(list.length))
.catch(() => setStaffCount(0));
}, [isPrimary]);
const finance = useMemo(() => {
const pending = bills.filter((b) => b.status === 'DRAFT' || b.status === 'REJECTED');
const settled = bills.filter((b) => b.status === 'PAID');
const paidCount = settled.length;
const rejectedCount = bills.filter((b) => b.status === 'REJECTED').length;
return {
pendingBillCount: pending.length,
rejectedCount,
pendingTotal: bills
.filter((b) => b.status === 'DRAFT' || b.status === 'CONFIRMED' || b.status === 'REJECTED')
.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
balance: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0),
paidCount,
};
}, [bills]);
function handleContactPartner() {
if (isPrimary) {
if (!contactSupport()) toastError('暂无客服电话');
return;
}
if (!account?.primaryPhone) {
toastError('暂无合伙人联系方式');
return;
}
window.location.href = `tel:${account.primaryPhone}`;
}
async function handleAvatarAuth() {
if (authorizing) return;
if (!isWechatEnv()) {
toastError('请在微信内打开以授权头像昵称');
return;
}
setAuthorizing(true);
try {
const config = await fetchClientConfig();
if (!isWxAuthorizeEnabled(config)) {
toastError('微信授权暂未开启');
return;
}
const result = await authorizePartnerWechat();
if (result) {
const session = handlePartnerWechatLoginResult(result);
if (session) applySession(session);
await refresh();
toastSuccess('微信授权成功');
}
// result 为空表示已跳转 OAuth,无需额外处理
} catch (e) {
toastError(e instanceof Error ? e.message : '微信授权失败');
} finally {
setAuthorizing(false);
}
}
const locationLabel = [account?.companyName].filter(Boolean).join(' · ') || '—';
const displayName = account?.wxNickname?.trim() || account?.name || '合伙人';
const avatarUrl = account?.wxAvatarUrl?.trim() || '';
const badgeLabel = roleLabel || (isPrimary ? '城市合伙人' : '拓店员');
return (
<div className="page partner-center-page">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="page partner-center-page partner-home--flush-top">
<section className="partner-profile-card">
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{String(me?.name || '合伙人')}</h2>
<span className="partner-role-badge"></span>
<button
type="button"
className="partner-profile-avatar"
onClick={() => void handleAvatarAuth()}
disabled={authorizing}
aria-label={avatarUrl ? '更新微信头像' : '微信授权获取头像'}
>
{avatarUrl ? (
<img src={avatarUrl} alt="" className="partner-profile-avatar-img" referrerPolicy="no-referrer" />
) : (
<span className="material-symbols-outlined">person</span>
)}
{!avatarUrl && (
<span className="partner-profile-avatar-hint"></span>
)}
</button>
<div className="partner-profile-main">
<div className="partner-profile-title-row">
<h2 className="partner-profile-name">{displayName}</h2>
<span className="partner-role-badge">{badgeLabel}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 4, color: 'var(--color-subtle-gray)' }}>
<div className="partner-profile-location">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>location_on</span>
<span className="body-md">{String(me?.companyName || '郑州')}</span>
<span className="body-md line-2-clamp">{locationLabel}</span>
</div>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{String(me?.phone || '')}</p>
</div>
</section>
<section>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 20px 8px' }}>
<h3 className="headline-md"></h3>
<Link to="/center/bills" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center' }}>
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>chevron_right</span>
</Link>
</div>
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">pending_actions</span>
</div>
<div>
<p className="body-md" style={{ fontWeight: 500 }}></p>
<p className="label-md text-primary"> {pendingBills.length || bills.length} </p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="label-md" style={{ opacity: 0.8, marginBottom: 4 }}> ()</p>
<span className="amount-xl" style={{ color: '#fff', fontSize: 32 }}>
{bills.length > 0 ? Number(bills[0].totalAmount || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) : '0.00'}
</span>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-md">
<span className="text-primary">¥</span>
{pendingBills.reduce((s, b) => s + Number(b.totalAmount || 0), 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
</p>
</div>
<div className="partner-finance-card">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span className="headline-md">{bills.length} </span>
<span className="material-symbols-outlined text-muted">history</span>
</div>
</div>
</div>
</section>
<section className="partner-menu-section">
<h3 className="headline-md" style={{ marginBottom: 8 }}></h3>
<div className="partner-menu-card">
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">store</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/orders" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">receipt_long</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/center/settlement" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">description</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{pendingBills.length > 0 && <span className="label-md text-primary" style={{ background: 'rgba(166,29,36,0.1)', padding: '2px 8px', borderRadius: 999 }}>{pendingBills.length}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/reports/weekly" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">monitoring</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/reshipments" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">assignment_return</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">military_tech</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
{showPrimaryMenus && (
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">group</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
{!isPrimary && (
<p className="label-md text-muted" style={{ marginTop: 4 }}>{account?.phone || ''}</p>
)}
</div>
</section>
{isPrimary && (
<>
<section className="partner-assets-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
<Link to="/center/settlement" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{finance.pendingBillCount > 0 && (
<Link to="/center/bills" className="partner-bills-banner">
<div className="partner-bills-banner-left">
<div className="partner-bills-icon">
<span className="material-symbols-outlined">receipt_long</span>
</div>
<div>
<p className="body-md text-primary" style={{ fontWeight: 600 }}>
{finance.rejectedCount > 0 ? '待处理账单' : '待确认账单'}
</p>
<p className="label-md text-primary" style={{ opacity: 0.85 }}>
{finance.rejectedCount > 0
? `您有 ${finance.pendingBillCount} 笔账单待处理(含 ${finance.rejectedCount} 笔已驳回)`
: `您有 ${finance.pendingBillCount} 笔账单待确认`}
</p>
</div>
</div>
<span className="material-symbols-outlined text-primary">chevron_right</span>
</Link>
)}
<div className="partner-finance-grid">
<div className="partner-finance-card partner-finance-card--hero">
<p className="partner-finance-hero-label"> ()</p>
<p className="partner-finance-hero-amount">{fmtMoney(finance.balance)}</p>
</div>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted"></p>
<p className="partner-finance-value text-primary">¥ {fmtMoney(finance.pendingTotal)}</p>
</Link>
<Link to="/center/settlement" className="partner-finance-card">
<p className="label-md text-muted" style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>history</span>
</p>
<p className="partner-finance-value">{finance.paidCount} </p>
</Link>
</div>
</section>
<section className="partner-menu-section">
<div className="partner-section-head">
<h3 className="headline-md"></h3>
</div>
<div className="partner-menu-card">
<Link to="/center/staff" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">badge</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
{staffCount > 0 && <span className="partner-menu-badge">{staffCount}</span>}
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<Link to="/stores?audit=pending" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">storefront</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<Link to="/stores" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">description</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<div className="partner-menu-item-right">
<span className="partner-menu-status"></span>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</div>
</Link>
<button
type="button"
className="partner-menu-item"
onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}
>
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">support_agent</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
</>
)}
{!isPrimary && (
<section className="partner-menu-section">
<div className="partner-menu-card">
<Link to="/leaderboard" className="partner-menu-item">
<div className="partner-menu-item-left">
<div className="partner-menu-icon">
<span className="material-symbols-outlined">emoji_events</span>
</div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</Link>
<button type="button" className="partner-menu-item" onClick={() => { if (!contactSupport()) toastError('暂无客服电话'); }}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">support_agent</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
<button type="button" className="partner-menu-item" onClick={handleContactPartner}>
<div className="partner-menu-item-left">
<div className="partner-menu-icon"><span className="material-symbols-outlined">call</span></div>
<span className="body-md" style={{ fontSize: 16 }}></span>
</div>
<span className="material-symbols-outlined text-muted">chevron_right</span>
</button>
</div>
</section>
)}
<button type="button" className="partner-logout-btn" onClick={logout}>
<span className="material-symbols-outlined">logout</span>
退
</button>
<div style={{ textAlign: 'center', opacity: 0.3, padding: '32px 0' }}>
<div className="partner-center-footer">
<p className="label-md text-muted"> · </p>
</div>
</div>
+306 -235
View File
@@ -1,279 +1,350 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { PartnerLeaderboardEntry } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
import { fetchPartnerLeaderboard } from '../lib/leaderboard';
import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus';
import {
canAccessPartnerDashboard,
canAccessPartnerOrders,
canAccessPartnerStores,
getPartnerNavKind,
hasWarehouseAccess,
} from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const HOME_STORE_PREVIEW_LIMIT = 6;
const LEADERBOARD_PREVIEW_LIMIT = 3;
function OrderSummarySection({
title,
todayCount,
pendingShip,
shipping,
completed,
ordersLink = '/orders',
}: {
title?: string;
todayCount: number;
pendingShip: number;
shipping: number;
completed: number;
ordersLink?: string;
}) {
return (
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md">{title || `今日订单 ${todayCount}`}</h2>
<Link to={ordersLink} className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
);
}
function isPendingShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
function isShipping(status: string) {
const s = status.toUpperCase();
return s.includes('SHIP') || s === 'OUT_WAREHOUSE' || s === 'PENDING_RECEIVE';
}
function isCompleted(status: string) {
return status.toUpperCase() === 'COMPLETED';
}
function isToday(dateStr: string) {
const d = new Date(dateStr);
const now = new Date();
return d.getFullYear() === now.getFullYear()
&& d.getMonth() === now.getMonth()
&& d.getDate() === now.getDate();
}
function summarizeOrders(orders: Array<Record<string, unknown>>) {
let pendingShip = 0;
let shipping = 0;
let completed = 0;
let todayCount = 0;
for (const o of orders) {
const status = String(o.status || '');
if (isToday(String(o.createdAt || ''))) todayCount += 1;
if (isPendingShip(status)) pendingShip += 1;
else if (isShipping(status)) shipping += 1;
else if (isCompleted(status)) completed += 1;
}
return { pendingShip, shipping, completed, todayCount };
}
function LeaderboardPreview({ entries }: { entries: PartnerLeaderboardEntry[] }) {
return (
<section className="partner-bento-card partner-leaderboard-preview">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 20 }}>emoji_events</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
{entries.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
entries.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className={`partner-leaderboard-avatar partner-leaderboard-avatar--rank-${Math.min(entry.rank, 3)}`}>
<span className="material-symbols-outlined">person</span>
<span className="partner-leaderboard-avatar-badge">{entry.rank}</span>
</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted">: {entry.totalStores} </p>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
</div>
</div>
))
)}
</section>
);
}
export default function HomePage() {
const navigate = useNavigate();
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isPrimary = navKind === 'primary';
const isWarehouse = navKind === 'warehouse_staff';
const warehouseOk = hasWarehouseAccess(account);
const canOrders = canAccessPartnerOrders(account) && warehouseOk && (isPrimary || isWarehouse);
const canDashboard = canAccessPartnerDashboard(account) && !isWarehouse;
const canStores = canAccessPartnerStores(account) && !isWarehouse;
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardPreview, setLeaderboardPreview] = useState<PartnerLeaderboardEntry[]>([]);
const [notifOpen, setNotifOpen] = useState(false);
const [orders, setOrders] = useState<Array<Record<string, unknown>>>([]);
const [leaderboardEntries, setLeaderboardEntries] = useState<PartnerLeaderboardEntry[]>([]);
useEffect(() => {
document.title = '工作台';
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard').then(setDash);
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores').then(setStores);
// 等 session 带上账号后再按权限发请求,避免无权限接口弹错
if (!account) return;
if (canOrders) {
request<{ list: Array<Record<string, unknown>> }>('PARTNER_H5', '/partner/orders', { silent: true })
.then((data) => setOrders(Array.isArray(data.list) ? data.list : []))
.catch(() => setOrders([]));
} else {
setOrders([]);
}
if (canDashboard) {
request<Record<string, unknown>>('PARTNER_H5', '/partner/dashboard', { silent: true })
.then(setDash)
.catch(() => setDash(null));
} else {
setDash(null);
}
if (canStores) {
request<Array<Record<string, unknown>>>('PARTNER_H5', '/partner/stores', { silent: true })
.then((data) => setStores(Array.isArray(data) ? data : []))
.catch(() => setStores([]));
} else {
setStores([]);
}
// 主账号与全部子账号均可查看同团队贡献榜(后端不校验业务权限点)
fetchPartnerLeaderboard('month')
.then((data) => setLeaderboardPreview(data.list.slice(0, 2)))
.catch(() => setLeaderboardPreview([]));
}, [navigate]);
.then((data) => {
setLeaderboardEntries(data.list.slice(0, LEADERBOARD_PREVIEW_LIMIT));
})
.catch(() => {
setLeaderboardEntries([]);
});
}, [navigate, account, canOrders, canDashboard, canStores]);
const storeCount = Number(dash?.storeCount || stores.length || 0);
const orderCount = Number(dash?.orderCount || 0);
const orderStats = useMemo(() => summarizeOrders(orders), [orders]);
const pendingAuditCount = Number(dash?.pendingAuditCount || 0);
const notifications = Array.isArray(dash?.notifications)
? (dash.notifications as Array<Record<string, unknown>>)
: [];
const hasUnread = notifications.length > 0;
const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length;
const abnormalStores = Math.max(0, storeCount - activeStores);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
const pendingShip = Math.ceil(orderCount * 0.04);
const shipping = Math.ceil(orderCount * 0.12);
const completed = Math.max(0, orderCount - pendingShip - shipping);
const monthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear();
}).length;
const lastMonthNew = stores.filter((s) => {
const created = new Date(String(s.createdAt || ''));
const now = new Date();
const last = new Date(now.getFullYear(), now.getMonth() - 1, 1);
return created.getMonth() === last.getMonth() && created.getFullYear() === last.getFullYear();
}).length;
const monthGrowthPct = lastMonthNew > 0
? Math.round(((monthNew - lastMonthNew) / lastMonthNew) * 100)
: (monthNew > 0 ? 100 : 0);
const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT);
const orderCount = Number(dash?.orderCount || orderStats.todayCount || 0);
const revenue = orderCount * 128.45;
const profit = revenue * 0.25;
return (
<div className="page partner-home">
<header className="partner-home-header">
<h1 className="app-page-title"></h1>
<div className="partner-home-header-actions">
<button
type="button"
className="partner-notif-btn"
aria-label="通知"
onClick={() => setNotifOpen((v) => !v)}
>
<span className="material-symbols-outlined">notifications</span>
{hasUnread && <span className="partner-notif-dot" />}
</button>
<div className="partner-profile-avatar" style={{ width: 40, height: 40 }}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person</span>
</div>
</div>
</header>
{notifOpen && (
<section className="partner-form-card" style={{ margin: '0 16px 12px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<h2 className="headline-md"></h2>
<button type="button" className="label-md text-muted" onClick={() => setNotifOpen(false)}></button>
</div>
{notifications.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
notifications.slice(0, 8).map((n) => (
<Link
key={String(n.id)}
to={`/stores/${n.storeId}`}
className="partner-home-store-row"
style={{ marginBottom: 8 }}
onClick={() => setNotifOpen(false)}
>
<div className="partner-home-store-info">
<p className="body-md" style={{ fontWeight: 600 }}>{String(n.title || '门店审核')}</p>
<p className="label-md text-muted">{String(n.content || '')}</p>
</div>
</Link>
))
)}
</section>
)}
<div className="page partner-home partner-home--flush-top">
<main className="partner-home-body">
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="partner-revenue-label"></p>
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
</section>
<div className="partner-bento">
<section className="partner-bento-card">
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
{isPrimary && (
<>
<section className="partner-revenue-card">
<p className="partner-revenue-label">
(CNY)
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>info</span>
</p>
<div className="partner-revenue-amount">{fmtMoney(revenue)}</div>
<div className="partner-revenue-grid">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
<p className="partner-revenue-label"></p>
<p className="headline-md" style={{ color: '#fff', marginTop: 4 }}>¥ {fmtMoney(profit)}</p>
</div>
</div>
<div className="partner-store-stat">
<span className="partner-dot partner-dot--red" />
<div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</section>
<section className="partner-bento-card">
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reshipments" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--amber">
<span className="material-symbols-outlined">assignment_return</span>
{pendingShip > 0 && <span className="partner-quick-badge-count">{pendingShip}</span>}
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</div>
<section>
<div className="partner-order-summary-header">
<h2 className="headline-md"> {orderCount}</h2>
<Link to="/orders" className="label-md text-primary" style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>chevron_right</span>
</Link>
</div>
<div className="partner-order-stats">
<div className="partner-order-stat">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{pendingShip}</p>
</div>
<div className="partner-order-stat partner-order-stat--blue">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{shipping}</p>
</div>
<div className="partner-order-stat partner-order-stat--green">
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<p className="headline-lg" style={{ fontSize: 24, fontWeight: 600 }}>{completed}</p>
</div>
</div>
</section>
<section className="partner-home-store-section">
<div className="partner-home-store-header">
<h2 className="headline-md"></h2>
<Link to="/stores" className="label-md text-primary"></Link>
</div>
{previewStores.length === 0 ? (
<div className="partner-home-store-empty">
<p className="label-md text-muted"></p>
<Link to="/stores/new" className="label-md text-primary"></Link>
</div>
) : (
previewStores.map((s) => {
const storeId = String(s.id);
const status = String(s.status || 'PAUSED').toUpperCase();
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
return (
<Link key={storeId} to={`/stores/${storeId}`} className="partner-home-store-row">
<div className="partner-home-store-info">
<p className="headline-md">{String(s.name || '未命名门店')}</p>
<p className="label-md text-muted">{String(s.address || s.district || '')}</p>
</section>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className={`partner-status-pill ${audit !== 'APPROVED' ? storeStatusPillClass(audit === 'REJECTED' ? 'CLOSED' : 'PAUSED') : storeStatusPillClass(status)}`}>
{audit === 'PENDING' ? '待审核' : audit === 'REJECTED' ? '已驳回' : storeStatusLabel(status)}
</span>
<span className="partner-quick-action-label"></span>
</Link>
);
})
)}
</section>
<section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
<div className="partner-leaderboard-section">
<div className="partner-leaderboard-header">
<h3 className="headline-md" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 18 }}>military_tech</span>
</h3>
<Link to="/leaderboard" className="label-md text-primary"></Link>
</div>
{leaderboardPreview.length === 0 ? (
<p className="label-md text-muted"></p>
) : (
leaderboardPreview.map((entry) => (
<div key={entry.accountId} className="partner-leaderboard-row partner-leaderboard-row--compact">
<div className="partner-leaderboard-rank">{entry.rank}</div>
<div className="partner-leaderboard-info">
<p className="body-md" style={{ fontWeight: 600 }}>
{entry.name}
<span className="label-md text-muted" style={{ marginLeft: 4 }}>({entry.roleLabel})</span>
</p>
<p className="label-md text-muted"> {entry.totalStores} </p>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
</div>
<div className="partner-leaderboard-stat">
<p className="headline-md text-primary">{entry.periodStores} </p>
<p className="label-md text-muted"></p>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/center/settlement" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--green">
<span className="material-symbols-outlined">account_balance</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/reports/weekly" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--blue">
<span className="material-symbols-outlined">monitoring</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
</div>
</section>
</>
)}
{isWarehouse && (
<p className="headline-md" style={{ marginBottom: 8 }}></p>
)}
{canOrders && (
<OrderSummarySection
title={isWarehouse ? '今日订单' : undefined}
todayCount={orderStats.todayCount}
pendingShip={orderStats.pendingShip}
shipping={orderStats.shipping}
completed={orderStats.completed}
/>
)}
{(isPrimary || isWarehouse) && !warehouseOk && (
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-warehouse-denied">
<span className="material-symbols-outlined text-primary">warehouse</span>
<p className="body-md" style={{ fontWeight: 600, marginTop: 8 }}></p>
<p className="label-md text-muted" style={{ marginTop: 4, lineHeight: 1.5 }}>
</p>
</div>
</section>
)}
{canStores && (
<>
<section className="partner-bento-card" style={{ marginBottom: 16 }}>
<div className="partner-bento-header">
<h2 className="headline-md"></h2>
<span className="amount-lg" style={{ fontSize: 24 }}>{storeCount}</span>
</div>
<div className="partner-store-stats">
<div className="partner-store-stat">
<span className="partner-dot partner-dot--green" />
<div>
<p className="label-md text-muted"></p>
<p className="headline-md">{activeStores}</p>
</div>
</div>
))
)}
</div>
<div className="partner-store-stat">
<span className="partner-dot partner-dot--red" />
<div>
<p className="label-md text-muted">/</p>
<p className="headline-md">{abnormalStores}</p>
</div>
</div>
</div>
</section>
<p className="label-md text-muted" style={{ paddingTop: 16, borderTop: '1px solid rgba(226,190,188,0.1)' }}>
{String(dash?.companyName || '郑州合伙人')} ·
</p>
</section>
<section className="partner-expansion-card">
<h2 className="headline-md" style={{ marginBottom: 16 }}></h2>
<div className="partner-expansion-split">
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<div className="partner-expansion-value-row">
<span className="headline-lg" style={{ fontSize: 24 }}>{monthNew}</span>
{monthGrowthPct !== 0 && (
<span className={`partner-expansion-trend${monthGrowthPct > 0 ? ' partner-expansion-trend--up' : ' partner-expansion-trend--down'}`}>
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>
{monthGrowthPct > 0 ? 'trending_up' : 'trending_down'}
</span>
{Math.abs(monthGrowthPct)}%
</span>
)}
</div>
</div>
<div>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="headline-lg" style={{ fontSize: 24 }}>{pendingAuditCount}</span>
</div>
</div>
</section>
</>
)}
<LeaderboardPreview entries={leaderboardEntries} />
</main>
</div>
);
@@ -47,7 +47,7 @@ export default function LeaderboardPage() {
return (
<div className="page-no-tab">
<PageHeader title="合伙人贡献榜" onBack={() => navigate('/')} />
<PageHeader title="团队贡献榜" onBack={() => navigate('/')} />
<div className="partner-leaderboard-tabs">
{PERIOD_TABS.map((tab) => (
+39 -179
View File
@@ -1,32 +1,42 @@
import { useEffect, useState } 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';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
function AgreementCheckbox({
agreed,
onChange,
inputRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
inputRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
<input type="checkbox" checked={agreed} onChange={(e) => onChange(e.target.checked)} />
<span>
{' '}
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>{' '}
{' '}
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
}
function loadRememberedPhone(): { phone: string; remember: boolean } {
@@ -50,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());
@@ -70,24 +70,14 @@ 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);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
const quickName = savedProfile?.name ?? '城市合伙人';
const quickCompany = savedProfile?.companyName ?? '';
const quickPhone = savedProfile?.phone || phone;
const agreementRef = useRef<HTMLLabelElement>(null);
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -147,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));
@@ -163,110 +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>}
{canWechatQuick ? (
<button
type="button"
className="partner-btn-primary"
disabled={wxLoading}
onClick={() => void wechatLogin()}
>
<span className="material-symbols-outlined">chat</span>
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
</button>
) : (
<p className="partner-auth-msg" style={{ textAlign: 'center', 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">
@@ -321,45 +202,24 @@ export default function LoginPage() {
<span></span>
</label>
<AgreementCheckbox
agreed={agreed}
onChange={setAgreed}
inputRef={agreementRef}
/>
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center' }}>{msg}</p>}
<button type="button" className="partner-btn-primary" onClick={() => void login()} disabled={loading}>
<span>{loading ? '登录中...' : '登录'}</span>
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
</button>
{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>
</>
)}
<label className="partner-checkbox-row">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span>
{' '}
<Link to="/legal/user-agreement" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>{' '}
{' '}
<Link to="/legal/privacy-policy" className="text-primary" style={{ fontWeight: 600 }} onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
<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>
+63 -50
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
const TIMELINE = [
{ key: 'confirm', label: '待确认' },
@@ -28,21 +29,55 @@ function statusBanner(status: string) {
return { title: status, desc: '', icon: 'receipt_long' };
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
export default function OrderDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [order, setOrder] = useState<Record<string, unknown> | null>(null);
const [shipping, setShipping] = useState(false);
useEffect(() => {
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
document.title = '订单详情';
}, []);
useEffect(() => {
if (id) void request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
}, [id]);
async function reload() {
if (!id) return;
const next = await request<Record<string, unknown>>('PARTNER_H5', `/partner/orders/${id}`);
setOrder(next);
}
async function advance(status: string) {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
if (id) request('PARTNER_H5', `/partner/orders/${id}`).then(setOrder);
if (!id) return;
setShipping(true);
try {
await request('PARTNER_H5', `/partner/orders/${id}/mock-advance-delivery`, {
method: 'POST',
body: JSON.stringify({ targetStatus: status }),
});
await reload();
toastSuccess('已更新配送状态');
} finally {
setShipping(false);
}
}
function handleContactCourier() {
const delivery = order?.delivery as Record<string, unknown> | undefined;
const trackingNo = String(delivery?.trackingNo || delivery?.providerOrderNo || '').trim();
if (trackingNo) {
void navigator.clipboard?.writeText(trackingNo);
toastSuccess(`运单号已复制:${trackingNo}`);
return;
}
toastError('暂无配送员联系方式');
}
if (!order) return <div className="empty">...</div>;
@@ -50,6 +85,7 @@ export default function OrderDetailPage() {
const banner = statusBanner(String(order.status));
const currentIdx = statusIndex(String(order.status));
const payAmount = Number(order.payAmount || 0);
const showShip = canShip(String(order.status));
return (
<div className="partner-order-detail">
@@ -91,9 +127,6 @@ export default function OrderDetailPage() {
<div style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--color-surface-container)' }}>
<span className="material-symbols-outlined text-muted" style={{ fontSize: 32 }}>liquor</span>
</div>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(166,29,36,0.8)', textAlign: 'center', padding: '2px 0' }}>
<span className="label-md" style={{ color: '#fff', fontSize: 10 }}></span>
</div>
</div>
<div style={{ flex: 1 }}>
<h3 className="headline-md">{String(order.productName || '杜康好酒')}</h3>
@@ -106,39 +139,6 @@ export default function OrderDetailPage() {
</div>
</section>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, margin: '0 20px' }}>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-heritage-red)' }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
<div className="partner-info-row">
<span className="label-md text-muted"></span>
<span className="text-primary" style={{ fontWeight: 700, fontSize: 12 }}>¥{(payAmount * 0.04).toFixed(2)}</span>
</div>
</div>
<div className="partner-detail-section" style={{ margin: 0, borderTop: '2px solid var(--color-aged-amber)' }}>
<p className="label-md text-muted" style={{ marginBottom: 4 }}></p>
<span className="amount-lg" style={{ color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</span>
</div>
</div>
<section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
</h4>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.orderNo)}</span>
</div>
<div className="partner-info-row">
<span className="text-muted body-md"></span>
<span className="body-md">{String(order.status)}</span>
</div>
</section>
<section className="partner-detail-section">
<h4 className="headline-md" style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 4, height: 16, background: 'var(--color-heritage-red)', borderRadius: 2 }} />
@@ -156,21 +156,34 @@ export default function OrderDetailPage() {
</section>
<footer className="partner-order-footer">
<div>
<span className="label-md text-muted"></span>
<p className="headline-md text-primary" style={{ fontWeight: 700 }}>¥{(payAmount * 0.08).toFixed(2)}</p>
<div className="partner-order-footer-actions">
{showShip && (
<button
type="button"
className="btn btn-primary partner-order-ship-btn--footer"
disabled={shipping}
onClick={() => void advance('OUT_WAREHOUSE')}
>
{shipping ? '发货中…' : '确认发货'}
</button>
)}
<button
type="button"
className="btn btn-outline"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}
onClick={handleContactCourier}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</div>
<button type="button" className="btn btn-outline" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 24px' }}>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>support_agent</span>
</button>
</footer>
{import.meta.env.DEV && (
<details className="partner-dev-tools">
<summary>Dev: Mock </summary>
{['OUT_WAREHOUSE', 'SHIPPING', 'PENDING_RECEIVE', 'COMPLETED'].map((s) => (
<button key={s} type="button" onClick={() => advance(s)}>{s}</button>
<button key={s} type="button" onClick={() => void advance(s)}>{s}</button>
))}
</details>
)}
+173 -42
View File
@@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { hasWarehouseAccess } from '../lib/partnerAccess';
type DateFilter = 'today' | '7d' | '30d';
type StatusFilter = 'ALL' | 'PENDING_SHIP' | 'SHIPPING' | 'COMPLETED' | 'ABNORMAL';
@@ -29,17 +31,52 @@ const STATUS_FILTERS: { key: StatusFilter; label: string }[] = [
{ key: 'ABNORMAL', label: '异常' },
];
export default function OrderListPage() {
type OrderListPageProps = {
/** Tab 根页:无返回顶栏 */
tabRoot?: boolean;
};
type OrdersResponse = {
list: Array<Record<string, unknown>>;
hasWarehouseAccess?: boolean;
message?: string;
};
export default function OrderListPage({ tabRoot = false }: OrderListPageProps) {
const navigate = useNavigate();
const [data, setData] = useState<{ list: Array<Record<string, unknown>> }>({ list: [] });
const { account } = usePartnerSession();
const warehouseOk = hasWarehouseAccess(account);
const [data, setData] = useState<OrdersResponse>({ list: [] });
const [tab, setTab] = useState<'orders' | 'coupons'>('orders');
const [dateFilter, setDateFilter] = useState<DateFilter>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [shippingId, setShippingId] = useState<string | null>(null);
const [shipForm, setShipForm] = useState({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
const [shipModalId, setShipModalId] = useState<string | null>(null);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
document.title = '订单管理';
}, []);
useEffect(() => {
if (!isLoggedIn()) { navigate('/login'); return; }
request('PARTNER_H5', '/partner/orders').then(setData);
}, [navigate]);
if (!warehouseOk) {
setData({ list: [], hasWarehouseAccess: false, message: '未配置仓库管理权限' });
setLoaded(true);
return;
}
request<OrdersResponse>('PARTNER_H5', '/partner/orders')
.then((res) => {
setData({
list: Array.isArray(res.list) ? res.list : [],
hasWarehouseAccess: res.hasWarehouseAccess,
message: res.message,
});
})
.catch(() => setData({ list: [] }))
.finally(() => setLoaded(true));
}, [navigate, warehouseOk]);
const filtered = useMemo(() => data.list.filter((o) => {
if (statusFilter === 'ALL') return true;
@@ -51,13 +88,68 @@ export default function OrderListPage() {
return true;
}), [data.list, statusFilter]);
async function handleShip(orderId: string, e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setShipModalId(orderId);
setShipForm({ logisticsCompany: '', trackingNo: '', manualQueryUrl: '' });
}
async function submitManualShip() {
if (!shipModalId) return;
if (!shipForm.logisticsCompany.trim() || !shipForm.trackingNo.trim()) {
window.alert('请填写快递公司和运单号');
return;
}
setShippingId(shipModalId);
try {
await request('PARTNER_H5', `/partner/orders/${shipModalId}/manual-ship`, {
method: 'POST',
body: JSON.stringify(shipForm),
});
setShipModalId(null);
const next = await request<OrdersResponse>('PARTNER_H5', '/partner/orders');
setData({
list: Array.isArray(next.list) ? next.list : [],
hasWarehouseAccess: next.hasWarehouseAccess,
message: next.message,
});
} catch (err) {
window.alert(err instanceof Error ? err.message : '发货失败');
} finally {
setShippingId(null);
}
}
function canShip(status: string) {
const s = status.toUpperCase();
return s.includes('PENDING') || s === 'PAID' || s === 'PENDING_SHIP';
}
if (loaded && !warehouseOk) {
return (
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-warehouse-denied" style={{ margin: 20, padding: 32, textAlign: 'center' }}>
<span className="material-symbols-outlined text-primary" style={{ fontSize: 40 }}>warehouse</span>
<p className="headline-md" style={{ marginTop: 12 }}></p>
<p className="body-md text-muted" style={{ marginTop: 8, lineHeight: 1.6 }}>
</p>
</div>
</div>
);
}
return (
<div className="page-no-tab partner-orders-page">
<PageHeader title="订单中心" onBack={() => navigate('/')} />
<div className={`page partner-orders-page${tabRoot ? '' : ' page-no-tab'}`}>
{!tabRoot && <PageHeader title="订单中心" onBack={() => navigate('/')} />}
<div className="partner-segment">
<button type="button" className={tab === 'orders' ? 'active' : ''} onClick={() => setTab('orders')}></button>
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
{!tabRoot && (
<button type="button" className={tab === 'coupons' ? 'active' : ''} onClick={() => setTab('coupons')}></button>
)}
</div>
{tab === 'orders' && (
@@ -82,50 +174,45 @@ export default function OrderListPage() {
{filtered.map((o) => {
const st = orderStatusLabel(String(o.status));
const payAmount = Number(o.payAmount || 0);
const orderId = String(o.id);
return (
<Link key={String(o.id)} to={`/orders/${o.id}`} className="partner-order-card">
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
</div>
<div className="partner-order-product">
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined text-muted">liquor</span>
<div key={orderId} className="partner-order-card-wrap">
<Link to={`/orders/${orderId}`} className="partner-order-card">
<div className="partner-order-card-top">
<span className="label-md text-muted">NO. {String(o.orderNo)}</span>
<span className="label-md" style={{ color: st.color, fontWeight: 600 }}>{st.label}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
<span className="partner-benefit-tag">
<span className="material-symbols-outlined" style={{ fontSize: 14 }}>confirmation_number</span>
¥{Math.round(payAmount * 0.2)}
</span>
<div className="partner-order-product">
<div className="partner-order-product-img" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="material-symbols-outlined text-muted">liquor</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<h3 className="headline-md line-2-clamp">{String(o.productName || '杜康好酒')}</h3>
<p className="text-variant body-md" style={{ marginTop: 4 }}>¥{payAmount.toFixed(2)}</p>
</div>
</div>
</div>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div className="partner-order-commission">
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥{(payAmount * 0.04).toFixed(2)}</p>
<div className="partner-order-address">
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>location_on</span>
<p className="line-2-clamp">{String(o.receiverAddress || '收货地址')}</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-md text-primary" style={{ marginTop: 4 }}>¥0.00</p>
</div>
<div>
<p className="label-md text-muted"></p>
<p className="headline-md" style={{ marginTop: 4, color: 'var(--color-aged-amber)' }}>¥{Math.round(payAmount * 0.2)}</p>
</div>
</div>
</Link>
</Link>
{canShip(String(o.status)) && !(o.delivery as { trackingNo?: string } | undefined)?.trackingNo && (
<button
type="button"
className="partner-order-ship-btn"
disabled={shippingId === orderId}
onClick={(e) => void handleShip(orderId, e)}
>
</button>
)}
</div>
);
})}
</>
)}
{tab === 'coupons' && (
{tab === 'coupons' && !tabRoot && (
<div style={{ padding: '0 20px' }}>
<div className="partner-revenue-card" style={{ marginBottom: 16 }}>
<p className="partner-revenue-label"></p>
@@ -134,6 +221,50 @@ export default function OrderListPage() {
<p className="text-muted body-md text-center"> preV1 </p>
</div>
)}
{shipModalId && (
<div className="partner-ship-modal-backdrop" role="presentation" onClick={() => setShipModalId(null)}>
<div className="partner-ship-modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3 className="headline-md"></h3>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.logisticsCompany}
onChange={(e) => setShipForm((f) => ({ ...f, logisticsCompany: e.target.value }))}
placeholder="如 顺丰速运"
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.trackingNo}
onChange={(e) => setShipForm((f) => ({ ...f, trackingNo: e.target.value }))}
/>
</label>
<label className="partner-ship-field">
<span></span>
<input
value={shipForm.manualQueryUrl}
onChange={(e) => setShipForm((f) => ({ ...f, manualQueryUrl: e.target.value }))}
placeholder="https://..."
/>
</label>
<div className="partner-ship-actions">
<button type="button" className="partner-btn-secondary" onClick={() => setShipModalId(null)}>
</button>
<button
type="button"
className="partner-btn-primary"
disabled={shippingId === shipModalId}
onClick={() => void submitManualShip()}
>
{shippingId === shipModalId ? '提交中…' : '确认发货'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+7 -97
View File
@@ -1,102 +1,12 @@
import { useEffect, useState } from 'react';
import { PARTNER_STAFF_ROLE_LABELS, type PartnerStaffRole } from '@dukang/shared-types';
import { request } from '../lib/api';
import { getPartnerNavKind } from '../lib/partnerAccess';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { toastError, toastSuccess } from '../lib/toast';
import CenterPage from './CenterPage';
/** 子账号个人中心:复用精简版 Center 布局 */
export default function PartnerMePage() {
const { account, refresh, logout } = usePartnerSession();
const [name, setName] = useState(account?.name ?? '');
const [saving, setSaving] = useState(false);
const { account } = usePartnerSession();
const navKind = getPartnerNavKind(account);
const isWarehouse = navKind === 'warehouse_staff';
useEffect(() => {
setName(account?.name ?? '');
}, [account?.name]);
const roleLabel = account?.staffRole
? PARTNER_STAFF_ROLE_LABELS[account.staffRole as PartnerStaffRole] || account.staffRole
: '拓店账号';
async function handleSave() {
const trimmed = name.trim();
if (!trimmed) {
toastError('请输入姓名');
return;
}
if (trimmed === account?.name) {
toastSuccess('已保存');
return;
}
setSaving(true);
try {
await request('PARTNER_H5', '/partner/me', {
method: 'PUT',
body: JSON.stringify({ name: trimmed }),
});
await refresh();
toastSuccess('已保存');
} catch (e) {
toastError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
return (
<div className="page partner-me-page">
<section className="partner-profile-card" style={{ margin: '16px var(--space-page)' }}>
<div className="partner-profile-avatar">
<span className="material-symbols-outlined">person</span>
</div>
<div style={{ flex: 1 }}>
<h2 className="headline-lg" style={{ fontSize: 20 }}>{account?.name || '我的'}</h2>
<p className="label-md text-muted" style={{ marginTop: 4 }}>{roleLabel}</p>
{account?.companyName ? (
<p className="body-md text-muted" style={{ marginTop: 4 }}>{account.companyName}</p>
) : null}
</div>
</section>
<section className="partner-form-section" style={{ padding: '0 var(--space-page)' }}>
<label className="partner-form-label" htmlFor="me-name"></label>
<input
id="me-name"
className="partner-form-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="请输入姓名"
maxLength={32}
/>
<label className="partner-form-label" style={{ marginTop: 16 }}></label>
<input
className="partner-form-input"
value={account?.phone ?? ''}
readOnly
disabled
/>
<p className="label-md text-muted" style={{ marginTop: 4 }}></p>
</section>
<div style={{ padding: '24px var(--space-page) 100px' }}>
<button
type="button"
className="partner-btn-primary"
style={{ width: '100%' }}
disabled={saving}
onClick={() => void handleSave()}
>
{saving ? '保存中…' : '保存'}
</button>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={logout}
>
退
</button>
</div>
</div>
);
return <CenterPage variant="sub" roleLabel={isWarehouse ? '仓库管理员' : '拓店员'} />;
}
+13 -4
View File
@@ -4,7 +4,7 @@ import PageHeader from '@dukang/shared-ui/PageHeader';
import type { PartnerBillDto } from '@dukang/shared-types';
import { isLoggedIn, request } from '../lib/api';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing';
type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing' | 'rejected';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
@@ -15,6 +15,7 @@ function billStatusLabel(status: string) {
case 'DRAFT': return '待结算';
case 'CONFIRMED': return '审核中';
case 'PAID': return '已结算';
case 'REJECTED': return '已驳回';
default: return status;
}
}
@@ -24,6 +25,7 @@ function billStatusClass(status: string) {
case 'DRAFT': return 'partner-settlement-status--pending';
case 'CONFIRMED': return 'partner-settlement-status--reviewing';
case 'PAID': return 'partner-settlement-status--settled';
case 'REJECTED': return 'partner-settlement-status--rejected';
default: return '';
}
}
@@ -70,9 +72,10 @@ export default function SettlementPage() {
const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month;
if (!matchMonth) return false;
if (statusFilter === 'all') return true;
if (statusFilter === 'pending') return b.status === 'DRAFT';
if (statusFilter === 'pending') return b.status === 'DRAFT' || b.status === 'REJECTED';
if (statusFilter === 'settled') return b.status === 'PAID';
if (statusFilter === 'reviewing') return b.status === 'CONFIRMED';
if (statusFilter === 'rejected') return b.status === 'REJECTED';
return true;
});
}, [bills, month, statusFilter]);
@@ -93,8 +96,9 @@ export default function SettlementPage() {
const statusTabs: Array<{ key: StatusFilter; label: string }> = [
{ key: 'all', label: '全部' },
{ key: 'pending', label: '待结算' },
{ key: 'settled', label: '已结算' },
{ key: 'reviewing', label: '审核中' },
{ key: 'rejected', label: '已驳回' },
{ key: 'settled', label: '已结算' },
];
return (
@@ -188,7 +192,12 @@ export default function SettlementPage() {
<span className="partner-settlement-dot" />
¥{fmtMoney(Number(bill.orderCommission || 0))}
</p>
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' ? ' text-primary' : ''}`}>
{bill.status === 'REJECTED' && bill.rejectReason ? (
<p className="label-md text-primary" style={{ marginTop: 6 }}>
{bill.rejectReason}
</p>
) : null}
<p className={`partner-settlement-item-amount${bill.status === 'DRAFT' || bill.status === 'REJECTED' ? ' text-primary' : ''}`}>
¥{fmtMoney(Number(bill.totalAmount || 0))}
</p>
</div>
+9 -41
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,10 +17,7 @@ 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 { isSubAccount } from '../lib/partnerAccess';
import {
@@ -80,18 +77,10 @@ export default function StoreCreatePage() {
const navigate = useNavigate();
const { account, refresh } = usePartnerSession();
const subAccountLayout = isSubAccount(account);
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);
@@ -145,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()
@@ -526,7 +504,9 @@ export default function StoreCreatePage() {
try {
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
const envPhotoUrls = Array.from(
new Set(form.envPhotoUrls.map((u) => u.trim()).filter(Boolean)),
).slice(0, 3);
const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', {
@@ -610,7 +590,7 @@ export default function StoreCreatePage() {
return (
<div className={`partner-page-sticky${subAccountLayout ? ' partner-page-sticky--tabbar' : ''}`}>
<div className="partner-page-sticky">
<PageHeader title="录入新门店" onBack={() => navigate('/stores')} />
@@ -896,13 +876,9 @@ export default function StoreCreatePage() {
value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })}
label="点击或拖拽上传"
label="从系统相册选择"
/>
@@ -932,10 +908,6 @@ export default function StoreCreatePage() {
value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/>
@@ -952,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
@@ -964,10 +936,6 @@ export default function StoreCreatePage() {
value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本"
@@ -1094,7 +1062,7 @@ export default function StoreCreatePage() {
<footer className={`partner-sticky-footer${subAccountLayout ? ' partner-sticky-footer--above-tabbar' : ''}`}>
<footer className="partner-sticky-footer">
{step > 1 && (
+134 -20
View File
@@ -5,7 +5,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
import { request } from '../lib/api';
import { toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -16,18 +18,34 @@ import {
} from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of urls) {
const url = raw.trim();
if (!url || seen.has(url)) continue;
seen.add(url);
out.push(url);
}
return out;
}
export default function StoreDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { account } = usePartnerSession();
const subReadonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [loadError, setLoadError] = useState('');
const [form, setForm] = useState({ name: '', phone: '', address: '', intro: '' });
const [coverUrl, setCoverUrl] = useState('');
const [envPhotoUrls, setEnvPhotoUrls] = useState<string[]>(['', '', '']);
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
const [statusSaving, setStatusSaving] = useState(false);
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState('');
function applyStore(data: Record<string, unknown>) {
@@ -39,6 +57,15 @@ export default function StoreDetailPage() {
intro: String(data.intro || ''),
});
setStatus(String(data.status || 'OPEN').toUpperCase() as StoreStatusValue);
setCoverUrl(String(data.coverUrl || ''));
const envFromMedia = Array.isArray(data.media)
? uniqueEnvUrls(
(data.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
}
useEffect(() => {
@@ -113,6 +140,42 @@ export default function StoreDetailPage() {
}
}
async function saveMedia() {
if (!id || mediaSaving || status === 'CLOSED') return;
const auditStatus = String(store?.auditStatus || 'APPROVED').toUpperCase();
if (auditStatus === 'PENDING') {
setActionError('门店审核中,暂不可修改资料');
return;
}
const nextCover = coverUrl.trim();
const nextEnv = uniqueEnvUrls(envPhotoUrls);
if (!nextCover) {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < ENV_SLOT_COUNT) {
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
setActionError('');
try {
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/media`, {
method: 'PUT',
body: JSON.stringify({
coverUrl: nextCover,
envPhotoUrls: nextEnv,
}),
});
applyStore(data);
toastSuccess(auditStatus === 'REJECTED' ? '照片已更新并重新提交审核' : '照片已更新');
} catch (e) {
setActionError(e instanceof Error ? e.message : '照片更新失败');
} finally {
setMediaSaving(false);
}
}
if (loadError) {
return (
<div className="partner-detail-page">
@@ -124,13 +187,17 @@ export default function StoreDetailPage() {
if (!store) return <div className="empty">...</div>;
const envPhotos = Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>).filter((m) => m.bizType === 'ENV')
: [];
const envPhotos = uniqueEnvUrls(
Array.isArray(store.media)
? (store.media as Array<{ url?: string; bizType?: string }>)
.filter((m) => m.bizType === 'ENV')
.map((m) => String(m.url || ''))
: [],
);
const auditStatus = String(store.auditStatus || 'APPROVED').toUpperCase();
const auditPending = auditStatus === 'PENDING';
const auditRejected = auditStatus === 'REJECTED';
const readOnly = subReadonly || status === 'CLOSED' || auditPending;
const readOnly = !canMutate || status === 'CLOSED' || auditPending;
const canOpen = canPartnerOpenStore(auditStatus);
return (
@@ -170,7 +237,7 @@ export default function StoreDetailPage() {
)}
</section>
{!subReadonly && (
{canMutate && (
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h3 className="label-md text-muted" style={{ textTransform: 'uppercase', letterSpacing: '0.1em' }}></h3>
@@ -200,13 +267,27 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12, marginBottom: 16 }}></h3>
<div className="partner-cover">
<AppImage
src={store.coverUrl ? String(store.coverUrl) : null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
{!canMutate || readOnly ? (
<div className="partner-cover">
<AppImage
src={coverUrl || null}
alt={form.name}
wrapperClassName="app-image--fill"
/>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<p className="label-md text-muted" style={{ marginBottom: 8 }}></p>
<OssUploadField
wide
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
onChange={setCoverUrl}
label="从系统相册选择"
/>
</div>
)}
<div className="partner-field">
<label></label>
<input disabled={readOnly} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} style={{ width: '100%', padding: '12px 16px', border: '1px solid rgba(226,190,188,0.5)', borderRadius: 8, fontSize: 16, fontWeight: 500 }} />
@@ -234,13 +315,46 @@ export default function StoreDetailPage() {
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 16 }}>
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">{envPhotos.length ? `已上传 ${envPhotos.length}` : '暂无照片'}</span>
<span className="label-md text-muted">
{canMutate && !readOnly
? `${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
</span>
</div>
{envPhotos.length > 0 ? (
{canMutate && !readOnly ? (
<>
<div className="partner-upload-grid">
{envPhotoUrls.map((url, index) => (
<OssUploadField
key={index}
compact
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 16 }}
disabled={mediaSaving}
onClick={() => void saveMedia()}
>
<span className="material-symbols-outlined" style={{ fontSize: 18, verticalAlign: 'middle', marginRight: 4 }}>
upload
</span>
{mediaSaving ? '上传中…' : '重新上传照片'}
</button>
</>
) : envPhotos.length > 0 ? (
<div className="partner-photo-grid">
{envPhotos.map((photo, index) => (
<div key={index} className="partner-cover" style={{ aspectRatio: '1' }}>
<AppImage src={photo.url ? String(photo.url) : null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
{envPhotos.map((url, index) => (
<div key={`${url}-${index}`} className="partner-cover" style={{ aspectRatio: '1', marginBottom: 0 }}>
<AppImage src={url || null} alt={`环境图 ${index + 1}`} wrapperClassName="app-image--fill" />
</div>
))}
</div>
@@ -264,7 +378,7 @@ export default function StoreDetailPage() {
<footer className="partner-save-footer">
<button type="button" className="partner-save-cancel" onClick={() => navigate('/stores')}></button>
{!subReadonly && (
{canMutate && (
<button type="button" className="partner-save-submit" onClick={() => void saveBasic()} disabled={readOnly || saving}>
<span className="material-symbols-outlined">save</span>
{saving ? '保存中…' : auditRejected ? '保存并重新提交' : '保存修改'}
+15 -15
View File
@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { isLoggedIn, request } from '../lib/api';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { isSubAccount } from '../lib/partnerAccess';
import { canCreatePartnerStore, canManagePartnerStore } from '../lib/partnerAccess';
import {
canPartnerOpenStore,
storeAuditLabel,
@@ -25,11 +25,14 @@ const FILTERS: { key: StatusFilter; label: string }[] = [
export default function StoreListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { account } = usePartnerSession();
const readonly = isSubAccount(account);
const canMutate = canManagePartnerStore(account);
const canCreate = canCreatePartnerStore(account);
const [stores, setStores] = useState<Array<Record<string, unknown>>>([]);
const [q, setQ] = useState('');
const [filter, setFilter] = useState<StatusFilter>('ALL');
const initialFilter = (searchParams.get('audit') === 'pending' ? 'PENDING_AUDIT' : 'ALL') as StatusFilter;
const [filter, setFilter] = useState<StatusFilter>(initialFilter);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [error, setError] = useState('');
@@ -42,6 +45,10 @@ export default function StoreListPage() {
void loadStores();
}, [navigate, loadStores]);
useEffect(() => {
document.title = canMutate ? '门店管理' : '我的门店';
}, [canMutate]);
const filtered = useMemo(() => stores.filter((s) => {
const matchQ = !q || String(s.name).includes(q) || String(s.address).includes(q);
const audit = String(s.auditStatus || 'APPROVED').toUpperCase();
@@ -78,16 +85,7 @@ export default function StoreListPage() {
}
return (
<div className="page partner-store-page">
<header className="header app-page-header">
<h1 className="app-page-title"></h1>
</header>
<div className="partner-page-title-block">
<h2>{readonly ? '我的门店' : '门店管理'}</h2>
<p className="text-muted body-md">{readonly ? '查看您录入的合作门店' : '管理您的合作门店及其运营状态'}</p>
</div>
<div className="page partner-store-page partner-home--flush-top">
{error && <p className="partner-form-error" role="alert" style={{ margin: '0 20px 12px' }}>{error}</p>}
<div className="partner-sticky-filter">
@@ -104,12 +102,14 @@ export default function StoreListPage() {
</div>
</div>
{canCreate && (
<Link to="/stores/new" className="partner-fab-link">
<button type="button" className="partner-btn-primary">
<span className="material-symbols-outlined">add_business</span>
</button>
</Link>
)}
{filtered.length === 0 && <div className="empty"></div>}
@@ -149,7 +149,7 @@ export default function StoreListPage() {
</div>
</div>
</Link>
{!readonly && (
{canMutate && (
<div className="partner-store-card-actions">
<button
type="button"
+415 -35
View File
@@ -13,13 +13,23 @@ html {
/* ── Partner auth ── */
.partner-auth-page {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px var(--space-page) var(--space-page);
justify-content: flex-start;
padding: 48px var(--space-page) calc(24px + env(safe-area-inset-bottom, 0px));
background-color: var(--color-background);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
@media (min-height: 720px) {
.partner-auth-page {
justify-content: center;
}
}
.partner-auth-page--quick {
@@ -166,14 +176,27 @@ html {
.partner-checkbox-row {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
margin-top: var(--space-md);
font-size: 12px;
line-height: 1.5;
color: var(--color-on-surface-variant);
}
.partner-checkbox-row input { margin-top: 0; accent-color: var(--color-heritage-red); }
.partner-checkbox-row input {
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red);
}
.partner-checkbox-row--agreement {
margin-top: 8px;
margin-bottom: 4px;
padding: 8px 0;
}
.partner-auth-footer {
margin-top: var(--space-lg);
@@ -621,9 +644,6 @@ html {
.partner-expansion-split {
display: flex;
border-bottom: 1px solid rgba(226, 190, 188, 0.2);
padding-bottom: var(--space-md);
margin-bottom: var(--space-md);
}
.partner-expansion-split > div {
@@ -635,6 +655,28 @@ html {
border-left: 1px solid rgba(226, 190, 188, 0.2);
}
.partner-expansion-value-row {
display: flex;
align-items: center;
gap: 8px;
}
.partner-expansion-trend {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 12px;
font-weight: 600;
}
.partner-expansion-trend--up {
color: #16a34a;
}
.partner-expansion-trend--down {
color: var(--color-heritage-red);
}
.partner-form-error {
margin: 0 20px 12px;
padding: 10px 12px;
@@ -845,6 +887,57 @@ html {
color: #fff;
}
.partner-leaderboard-preview .partner-leaderboard-header {
margin-bottom: 12px;
}
.partner-leaderboard-avatar {
position: relative;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-surface-container-low);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.partner-leaderboard-avatar .material-symbols-outlined {
font-size: 22px;
color: var(--color-subtle-gray);
font-variation-settings: 'FILL' 1;
}
.partner-leaderboard-avatar-badge {
position: absolute;
left: -2px;
top: -2px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #9ca3af;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px #fff;
}
.partner-leaderboard-avatar--rank-1 .partner-leaderboard-avatar-badge {
background: #d4a017;
}
.partner-leaderboard-avatar--rank-2 .partner-leaderboard-avatar-badge {
background: #9ca3af;
}
.partner-leaderboard-avatar--rank-3 .partner-leaderboard-avatar-badge {
background: #cd7f32;
}
.partner-leaderboard-info {
flex: 1;
min-width: 0;
@@ -1296,7 +1389,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-footer--above-tabbar {
bottom: calc(56px + env(safe-area-inset-bottom, 0px));
z-index: 90;
z-index: 110;
}
.partner-stepper {
@@ -1752,12 +1845,13 @@ nav.app-tabbar .app-tabbar-label {
max-width: 480px;
margin: 0 auto;
transform: none;
background: rgba(250, 249, 247, 0.92);
background: rgba(250, 249, 247, 0.96);
backdrop-filter: blur(8px);
border-top: 1px solid rgba(226, 190, 188, 0.2);
box-shadow: 0 -4px 20px rgba(166, 29, 36, 0.05);
padding: 16px var(--space-page) calc(16px + env(safe-area-inset-bottom, 0px));
z-index: 60;
/* 高于 .app-tabbar(100),防止偶发叠层遮挡「下一步/提交」 */
z-index: 110;
display: flex;
align-items: center;
justify-content: center;
@@ -1801,7 +1895,7 @@ nav.app-tabbar .app-tabbar-label {
.partner-sticky-filter {
position: sticky;
top: 56px;
top: 0;
z-index: 40;
background: rgba(250, 249, 247, 0.95);
backdrop-filter: blur(8px);
@@ -2401,6 +2495,14 @@ nav.app-tabbar .app-tabbar-label {
justify-content: center;
position: relative;
flex-shrink: 0;
padding: 0;
cursor: pointer;
overflow: hidden;
}
.partner-profile-avatar:disabled {
opacity: 0.7;
cursor: wait;
}
.partner-profile-avatar .material-symbols-outlined {
@@ -2409,22 +2511,81 @@ nav.app-tabbar .app-tabbar-label {
font-variation-settings: 'FILL' 1;
}
.partner-profile-avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.partner-profile-avatar-hint {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 2px 0;
font-size: 10px;
font-weight: 600;
line-height: 1.2;
text-align: center;
color: #fff;
background: rgba(166, 29, 36, 0.82);
}
.partner-profile-main {
flex: 1;
min-width: 0;
}
.partner-profile-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.partner-profile-name {
margin: 0;
font-size: 20px;
font-weight: 700;
line-height: 1.3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.partner-profile-location {
display: flex;
align-items: center;
gap: 4px;
margin-top: 4px;
color: var(--color-subtle-gray);
}
.partner-role-badge {
flex-shrink: 0;
background: var(--color-secondary-container);
color: var(--color-on-secondary-container);
padding: 4px 8px;
border-radius: 8px;
font-family: var(--font-label);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
letter-spacing: 0.02em;
white-space: nowrap;
line-height: 1.2;
}
.partner-assets-section {
margin-top: var(--space-lg);
}
.partner-bills-banner {
margin: 0 var(--space-page) 12px;
padding: 16px;
background: rgba(166, 29, 36, 0.05);
border: 1px solid rgba(166, 29, 36, 0.1);
background: rgba(166, 29, 36, 0.06);
border: 1px solid rgba(166, 29, 36, 0.12);
border-radius: var(--radius-md);
display: flex;
align-items: center;
@@ -2463,13 +2624,100 @@ nav.app-tabbar .app-tabbar-label {
border-radius: var(--radius-md);
box-shadow: var(--shadow-card);
border: 1px solid rgba(166, 29, 36, 0.05);
text-decoration: none;
color: inherit;
display: block;
}
.partner-finance-card--hero {
grid-column: span 2;
background: var(--color-heritage-red);
grid-column: 1 / -1;
background: linear-gradient(135deg, #a61d24 0%, #820012 100%);
color: #fff;
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.25);
border: none;
padding: 20px 24px;
}
.partner-finance-hero-label {
font-size: 13px;
opacity: 0.88;
margin: 0 0 8px;
}
.partner-finance-hero-amount {
font-size: 32px;
font-weight: 700;
letter-spacing: 0.02em;
margin: 0;
line-height: 1.2;
}
.partner-finance-value {
margin-top: 8px;
font-size: 18px;
font-weight: 700;
color: var(--color-on-surface);
}
.partner-section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 var(--space-page) 8px;
}
.partner-menu-section .partner-section-head {
margin: 0 0 8px;
}
.partner-menu-item-right {
display: flex;
align-items: center;
gap: 6px;
}
.partner-menu-badge {
font-size: 12px;
font-weight: 600;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.08);
padding: 2px 8px;
border-radius: 999px;
}
.partner-menu-status {
font-size: 12px;
color: #2563eb;
font-weight: 500;
}
.partner-logout-btn {
width: calc(100% - 2 * var(--space-page));
margin: var(--space-lg) var(--space-page) 0;
padding: 16px;
border: 1px solid rgba(166, 29, 36, 0.08);
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-heritage-red);
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.partner-logout-btn .material-symbols-outlined {
color: var(--color-heritage-red);
}
.partner-center-footer {
text-align: center;
opacity: 0.35;
padding: 24px 0 8px;
}
.partner-menu-section {
@@ -2488,14 +2736,28 @@ nav.app-tabbar .app-tabbar-label {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 16px;
border: none;
border-bottom: 1px solid var(--color-surface-container);
border-radius: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
text-decoration: none;
cursor: pointer;
box-sizing: border-box;
-webkit-appearance: none;
appearance: none;
}
.partner-menu-item:last-child { border-bottom: none; }
.partner-menu-item:active {
background: rgba(166, 29, 36, 0.04);
}
.partner-menu-item-left {
display: flex;
align-items: center;
@@ -2513,24 +2775,6 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-heritage-red);
}
.partner-logout-btn {
width: calc(100% - 40px);
margin: var(--space-lg) var(--space-page);
padding: 16px;
border: none;
border-radius: var(--radius-md);
background: var(--color-card);
box-shadow: var(--shadow-card);
color: var(--color-error);
font-size: 16px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
/* ── Settlement page ── */
.partner-settlement-page {
min-height: 100vh;
@@ -2778,6 +3022,11 @@ nav.app-tabbar .app-tabbar-label {
color: var(--color-success-green);
}
.partner-settlement-status--rejected {
background: rgba(166, 29, 36, 0.12);
color: var(--color-heritage-red);
}
.partner-settlement-item-meta {
display: flex;
align-items: center;
@@ -3065,3 +3314,134 @@ body {
padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15);
}
.partner-warehouse-denied {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 20px 12px;
}
.partner-warehouse-denied .material-symbols-outlined {
font-size: 36px;
font-variation-settings: 'FILL' 0;
}
.partner-home--flush-top.partner-store-page,
.partner-home--flush-top.partner-center-page {
padding-top: 12px;
}
.partner-home--flush-top .partner-sticky-filter {
padding-top: 12px;
}
.partner-leaderboard-self-tag {
display: inline-block;
margin-left: 6px;
padding: 1px 6px;
font-size: 10px;
font-weight: 700;
color: var(--color-heritage-red);
background: rgba(166, 29, 36, 0.12);
border-radius: 4px;
vertical-align: middle;
}
.partner-order-card-wrap {
margin: 0 var(--space-page) 12px;
}
.partner-order-ship-btn {
display: block;
width: 100%;
margin-top: -4px;
padding: 10px 16px;
border: none;
border-radius: 0 0 var(--radius-md) var(--radius-md);
background: var(--color-heritage-red);
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.partner-order-ship-btn:disabled {
opacity: 0.6;
}
.partner-order-footer-actions {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
}
.partner-order-ship-btn--footer {
width: 100%;
justify-content: center;
}
.partner-ship-modal-backdrop {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: 16px;
}
.partner-ship-modal {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
padding: 20px;
}
.partner-ship-field {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 12px;
font-size: 13px;
}
.partner-ship-field input {
padding: 10px 12px;
border: 1px solid var(--color-outline-variant);
border-radius: var(--radius-sm);
}
.partner-ship-actions {
display: flex;
gap: 12px;
margin-top: 20px;
}
.partner-ship-actions .partner-btn-secondary,
.partner-ship-actions .partner-btn-primary {
flex: 1;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
.app-page-title {
display: none !important;
}
/* 仅含标题的顶栏一并收起,去掉空白条;有返回/操作的顶栏保留 */
header:has(> .app-page-title:only-child),
.header.app-page-header:has(> .app-page-title:only-child),
.app-page-header:has(> .app-page-title:only-child) {
display: none !important;
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
border: none !important;
box-shadow: none !important;
}
+5 -2
View File
@@ -35,8 +35,11 @@
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
/* 与微信系统标题重复,仅保留顶栏高度与返回 */
visibility: hidden;
font-size: 0;
line-height: 0;
color: transparent;
}
.legal-h5-body {
+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 }) {
+25 -2
View File
@@ -9,6 +9,8 @@ export type ShopAccountProfile = {
storeId: string;
name: string;
phone: string;
/** 是否已绑定微信(来自 /shop/auth/me account.hasWechat */
hasWechat?: boolean;
wxOpenId?: string | null;
store?: { name: string };
};
@@ -18,7 +20,27 @@ export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
}
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me');
const me = await request<{
id?: string;
storeId?: string;
name?: string;
phone?: string;
account?: {
id?: string;
name?: string;
phone?: string;
hasWechat?: boolean;
};
store?: { name?: string } | null;
}>('SHOP_H5', '/shop/auth/me');
return {
id: String(me.account?.id ?? me.id ?? ''),
storeId: String(me.storeId ?? ''),
name: String(me.account?.name ?? me.name ?? ''),
phone: String(me.account?.phone ?? me.phone ?? ''),
hasWechat: !!me.account?.hasWechat,
store: me.store?.name ? { name: String(me.store.name) } : undefined,
};
}
export function needsWechatAuth(
@@ -26,7 +48,8 @@ export function needsWechatAuth(
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
): boolean {
if (config && !isWxAuthorizeEnabled(config)) return false;
return isWechatEnv() && !!profile && !profile.wxOpenId;
const unbound = !!profile && !(profile.hasWechat || profile.wxOpenId);
return isWechatEnv() && unbound;
}
export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null): Promise<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>
);
}
+48 -195
View File
@@ -1,76 +1,60 @@
import { useEffect, useState } 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';
getLastPhone,
request,
saveRememberedSession,
type ShopSessionPayload,
} from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
function maskPhone(phone: string) {
if (phone.length < 7) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
function formatWechatError(e: unknown): string {
const text = e instanceof Error ? e.message : '微信登录失败';
if (text.includes('首次登录') || text.includes('手机验证码')) {
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
}
return text;
function ShopAgreementCheckbox({
agreed,
onChange,
labelRef,
}: {
agreed: boolean;
onChange: (next: boolean) => void;
labelRef?: RefObject<HTMLLabelElement>;
}) {
return (
<label className="shop-login-agreement" ref={labelRef}>
<input
type="checkbox"
checked={agreed}
onChange={(e) => onChange(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
);
}
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);
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;
const agreementRef = useRef<HTMLLabelElement>(null);
function ensureAgreed() {
if (!agreed) {
setMsg('请先阅读并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -109,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 : '登录失败');
@@ -124,98 +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>}
{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">
@@ -270,6 +157,12 @@ export default function LoginPage() {
{msg && <p className="shop-login-msg">{msg}</p>}
<ShopAgreementCheckbox
agreed={agreed}
onChange={setAgreed}
labelRef={agreementRef}
/>
<button
type="button"
className="shop-login-submit"
@@ -279,45 +172,10 @@ 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>
<label className="shop-login-agreement">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
</main>
<footer className="shop-login-footer">
@@ -325,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>
);
+90 -2
View File
@@ -1,21 +1,95 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
import {
authorizeShopWechat,
fetchClientConfig,
fetchShopAccount,
handleShopWechatCallback,
handleShopWechatLoginResult,
} from '../lib/wechat-auth';
import { isWechatEnv } from '../lib/weixin';
export default function MinePage() {
const navigate = useNavigate();
const { resetSession, store: sessionStore } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const { resetSession, store: sessionStore, applySession } = useStoreSession();
const profile = sessionStore ?? getStoreProfile();
const [store, setStore] = useState<Record<string, unknown> | null>(null);
const [hasWechat, setHasWechat] = useState<boolean | null>(null);
const [wxAuthorize, setWxAuthorize] = useState(false);
const [binding, setBinding] = useState(false);
const [bindMsg, setBindMsg] = useState('');
useEffect(() => {
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
}, []);
useEffect(() => {
fetchClientConfig()
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
.catch(() => setWxAuthorize(false));
}, []);
useEffect(() => {
fetchShopAccount()
.then((me) => setHasWechat(!!(me.hasWechat || me.wxOpenId)))
.catch(() => setHasWechat(null));
}, []);
useEffect(() => {
if (!isWechatEnv() || !wxAuthorize || !searchParams.get('code')) return;
void handleShopWechatCallback()
.then((result) => {
if (!result) return;
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
}
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
})
.catch((e) => setBindMsg(e instanceof Error ? e.message : '微信绑定失败'));
}, [applySession, searchParams, setSearchParams, wxAuthorize]);
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
const multiStore = (profile?.stores?.length ?? 0) > 1;
const showBindWechat = wxAuthorize && hasWechat === false;
async function bindWechat() {
if (binding) return;
setBindMsg('');
if (!isWechatEnv()) {
setBindMsg('请在微信内打开门店端以绑定微信');
return;
}
setBinding(true);
try {
const result = await authorizeShopWechat();
if (!result) {
// 跳转公众号 OAuth,回跳后由上面 useEffect 处理
return;
}
const session = handleShopWechatLoginResult(result);
if (session) {
applySession(session);
setHasWechat(true);
setBindMsg('微信绑定成功');
} else {
setBindMsg('绑定未完成,请重试');
}
} catch (e) {
setBindMsg(e instanceof Error ? e.message : '微信绑定失败');
} finally {
setBinding(false);
}
}
return (
<div className="shop-mine-page">
@@ -77,6 +151,20 @@ export default function MinePage() {
<p></p>
</div>
{bindMsg ? <p className="shop-mine-bind-msg">{bindMsg}</p> : null}
{showBindWechat ? (
<button
type="button"
className="shop-mine-bind-wechat"
disabled={binding}
onClick={() => void bindWechat()}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>chat</span>
{binding ? '绑定中…' : '绑定微信'}
</button>
) : null}
<button
type="button"
className="shop-mine-logout"
+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>
+74 -22
View File
@@ -188,7 +188,7 @@
.shop-login-submit {
width: 100%;
margin-top: 32px;
margin-top: 16px;
padding: 16px;
border: none;
border-radius: var(--radius-md);
@@ -248,14 +248,18 @@
.shop-login-agreement {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
max-width: 280px;
margin: 32px auto 0;
max-width: 100%;
margin: 16px 0 0;
padding: 8px 0;
}
.shop-login-agreement input {
margin-top: 0;
margin-top: 2px;
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-heritage-red);
}
@@ -455,6 +459,15 @@
margin-top: auto;
}
.shop-quick-actions .shop-login-agreement {
margin: 0 0 16px;
max-width: 100%;
}
.shop-quick-actions .shop-quick-login-btn {
margin-top: 4px;
}
.shop-quick-login-btn {
width: 100%;
height: 56px;
@@ -1489,25 +1502,13 @@
}
.shop-records-header {
position: fixed;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-page);
background: rgba(250, 249, 247, 0.9);
backdrop-filter: blur(10px);
z-index: 50;
/* 页内标题已隐藏:不再占位;保留类名以免改 JSX */
display: none !important;
}
.shop-records-filters {
position: sticky;
top: 56px;
top: 0;
z-index: 40;
background: var(--color-surface);
border-bottom: 1px solid var(--color-surface-container-highest);
@@ -1578,7 +1579,7 @@
}
.shop-records-main {
padding-top: 56px;
padding-top: 0;
}
.shop-records-summary {
@@ -2153,8 +2154,38 @@
color: var(--color-subtle-gray);
}
.shop-mine-bind-msg {
margin: 0 0 8px;
text-align: center;
font-size: 13px;
color: var(--color-heritage-red);
}
.shop-mine-bind-wechat {
margin-top: 8px;
width: 100%;
height: 48px;
border: none;
border-radius: var(--radius-md);
background: #07c160;
color: #fff;
font-family: var(--font-headline);
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
}
.shop-mine-bind-wechat:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.shop-mine-logout {
margin-top: 24px;
margin-top: 12px;
width: 100%;
height: 48px;
border: 1px solid var(--color-heritage-red);
@@ -2702,3 +2733,24 @@
cursor: pointer;
}
/* 微信 H5 系统标题已展示:隐藏页内重复标题,保留返回键与操作区 */
.app-page-title {
display: none !important;
}
/* 仅含标题的顶栏一并收起,去掉空白条;有返回/操作的顶栏保留 */
header:has(> .app-page-title:only-child),
.shop-home-header:has(> .app-page-title:only-child),
.shop-mine-header:has(> .app-page-title:only-child),
.shop-records-header:has(> .app-page-title:only-child),
.app-page-header:has(> .app-page-title:only-child) {
display: none !important;
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
border: none !important;
box-shadow: none !important;
}
+5 -2
View File
@@ -35,8 +35,11 @@
.legal-h5-title {
margin: 0;
font-size: 17px;
font-weight: 600;
/* 与微信系统标题重复,仅保留顶栏高度与返回 */
visibility: hidden;
font-size: 0;
line-height: 0;
color: transparent;
}
.legal-h5-body {
+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>
@@ -5,11 +5,10 @@ type SubPageHeaderProps = {
export default function SubPageHeader({ title, onBack }: SubPageHeaderProps) {
return (
<header className="sub-page-header">
<header className="sub-page-header" aria-label={title}>
<button type="button" className="sub-page-header-back" aria-label="返回" onClick={onBack}>
<span className="material-symbols-outlined">arrow_back</span>
</button>
<h1 className="app-page-title">{title}</h1>
</header>
);
}
@@ -7,9 +7,13 @@ type TabMainHeaderProps = {
};
export default function TabMainHeader({ title, extra, className = '' }: TabMainHeaderProps) {
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
if (!extra) {
return null;
}
return (
<header className={`tab-main-header${className ? ` ${className}` : ''}`}>
<h1 className="app-page-title">{title}</h1>
<header className={`tab-main-header${className ? ` ${className}` : ''}`} aria-label={title}>
{extra ? <div className="tab-main-header-extra">{extra}</div> : null}
</header>
);
+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>
);
}
+20 -21
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
import AppImage from '@dukang/shared-ui/AppImage';
import type { WechatLoginResult } from '@dukang/shared-types';
@@ -31,6 +31,7 @@ export default function LoginPage() {
const [wxSessionKey, setWxSessionKey] = useState<string | null>(null);
const [bindMode, setBindMode] = useState(false);
const [wxAuthorize, setWxAuthorize] = useState(false);
const agreementRef = useRef<HTMLLabelElement>(null);
const { sendCode, sending, codeCooldown, sentHint, error: smsError, setError: setSmsError, clearMessages } =
useSmsCode();
@@ -73,6 +74,7 @@ export default function LoginPage() {
function ensureAgreed() {
if (!agreed) {
setMsg('请先勾选并同意用户协议');
agreementRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
return false;
}
return true;
@@ -194,6 +196,23 @@ export default function LoginPage() {
{displayMsg || sentHint}
</p>
)}
<label className="login-agreement" ref={agreementRef}>
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
<button
type="button"
className="login-sms-btn"
@@ -219,26 +238,6 @@ export default function LoginPage() {
</>
)}
</main>
<footer className="login-footer">
<label className="login-agreement">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
/>
<span>
<Link to="/legal/user-agreement" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy-policy" onClick={(e) => e.stopPropagation()}>
</Link>
</span>
</label>
</footer>
</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 },
+82 -21
View File
@@ -18,6 +18,15 @@ type OrderItem = {
type OrderDelivery = {
provider?: string;
trackingNo?: string;
logisticsCompany?: string;
manualQueryUrl?: string;
providerOrderNo?: string;
};
type TrackNode = {
trackInfo?: string;
createdAt?: string;
};
type OrderPayment = {
@@ -101,8 +110,10 @@ function progressActiveIndex(status: string) {
}
}
function deliveryProviderLabel(provider?: string) {
if (!provider || provider === 'MOCK' || provider === 'XIAOFEIXIA') return '小飞侠配送';
function deliveryProviderLabel(provider?: string, company?: string) {
if (company) return company;
if (!provider || provider === 'MOCK' || provider === 'XFX' || provider === 'XIAOFEIXIA') return '小飞侠配送';
if (provider === 'LOGISTICS') return '快递配送';
return provider;
}
@@ -124,13 +135,27 @@ export default function OrderDetailPage() {
const [copyHint, setCopyHint] = useState('');
const [shareToast, setShareToast] = useState('');
const [confirming, setConfirming] = useState(false);
const [trackNodes, setTrackNodes] = useState<TrackNode[]>([]);
const isReship = order?.orderType === 'RESHIPMENT';
async function loadTrack() {
if (!id) return;
try {
const data = await request<{ nodes?: TrackNode[] }>('USER_H5', `/trade/orders/${id}/track`);
setTrackNodes(data.nodes ?? []);
} catch {
setTrackNodes([]);
}
}
async function loadOrder() {
if (!id) return;
const data = await request<Order>('USER_H5', `/trade/orders/${id}`);
setOrder(data);
if (data.delivery?.trackingNo || data.delivery?.provider === 'XFX') {
void loadTrack();
}
}
useEffect(() => {
@@ -153,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);
@@ -182,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 (
@@ -353,11 +363,49 @@ export default function OrderDetailPage() {
</div>
<div className="order-detail-kv">
<span></span>
<span>{deliveryProviderLabel(order.delivery?.provider)}</span>
<span>
{deliveryProviderLabel(order.delivery?.provider, order.delivery?.logisticsCompany)}
</span>
</div>
{order.delivery?.trackingNo && (
<div className="order-detail-kv">
<span></span>
<span>{order.delivery.trackingNo}</span>
</div>
)}
{order.delivery?.manualQueryUrl && (
<div className="order-detail-kv">
<span></span>
<a
href={order.delivery.manualQueryUrl}
target="_blank"
rel="noreferrer"
className="order-detail-link"
>
</a>
</div>
)}
</div>
</section>
{trackNodes.length > 0 && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
<span className="material-symbols-outlined">timeline</span>
</h3>
<div className="order-detail-kv-list order-detail-kv-list--bordered">
{trackNodes.map((node, index) => (
<div key={`${node.trackInfo}-${index}`} className="order-detail-kv">
<span>{node.createdAt ? formatDateTime(node.createdAt) : '—'}</span>
<span>{node.trackInfo || '—'}</span>
</div>
))}
</div>
</section>
)}
{!isReship && (
<section className="order-detail-card">
<h3 className="order-detail-card-title">
@@ -407,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 && (
+273 -108
View File
@@ -917,6 +917,11 @@
z-index: 40;
}
/* 首页有城市栏顶栏时 top=56;无额外内容已不渲染顶栏时贴顶 */
.home-page:not(:has(.tab-main-header)) .home-aroma-nav {
top: 0;
}
.home-aroma-tab {
border: none;
background: none;
@@ -1553,8 +1558,9 @@
.login-agreement {
display: flex;
align-items: center;
align-items: flex-start;
gap: 8px;
margin: 12px 0 16px;
font-family: var(--font-label);
font-size: 12px;
font-weight: 500;
@@ -1566,8 +1572,8 @@
.login-agreement input {
margin-top: 0;
width: 16px;
height: 16px;
width: 18px;
height: 18px;
flex-shrink: 0;
accent-color: var(--color-heritage-red);
}
@@ -5780,7 +5786,6 @@
color: #1a1c1b;
}
/* Customer service chat page */
.customer-service-page {
min-height: 100vh;
display: flex;
@@ -5788,141 +5793,301 @@
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 系统标题已展示:隐藏页内重复标题;仅标题顶栏收起 */
.app-page-title {
display: none !important;
}
header:has(> .app-page-title:only-child),
.app-page-header:has(> .app-page-title:only-child) {
display: none !important;
height: 0 !important;
min-height: 0 !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
border: none !important;
box-shadow: none !important;
}
+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;
+5
View File
@@ -177,6 +177,11 @@
color: var(--hq-muted);
}
.login-agreement {
margin: 8px 0 12px;
padding: 4px 0;
}
.login-remember {
align-items: center;
padding: 0 4px;
+18 -18
View File
@@ -212,24 +212,6 @@ export default function LoginPage() {
<Text></Text>
</View>
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
</Button>
{msg ? <Text className="login-msg">{msg}</Text> : null}
<View className="login-divider">
<Text></Text>
</View>
<View
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
<View className={`login-checkbox${agreed ? ' is-checked' : ''}`} />
<Text className="login-agreement-text">
@@ -255,6 +237,24 @@ export default function LoginPage() {
</Text>
</Text>
</View>
{msg ? <Text className="login-msg">{msg}</Text> : null}
<Button className="hq-btn hq-btn--primary hq-btn--block login-submit" loading={loading} onClick={smsLogin}>
</Button>
<View className="login-divider">
<Text></Text>
</View>
<View
className={`login-wechat-btn${wxLoading ? ' is-disabled' : ''}`}
onClick={wxLoading ? undefined : wechatLogin}
>
<WechatIcon />
<Text>{wxLoading ? '登录中...' : '微信一键授权'}</Text>
</View>
</View>
<View className="login-footer">
+17 -6
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` | 默认 `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`
@@ -30,17 +33,25 @@ pnpm --filter @dukang/mini-user dev:weapp
| 小程序 appid | `project.config.json``wxda31c8e8e85051e7` |
| 后端须配置 | `WX_MINI_APP_ID` / `WX_MINI_APP_SECRET`(与上表一致) |
**本地联调(不接真实微信)**API 指到本机且开启 Mock
**本地联调(不接真实微信)**保持默认即可(API → `localhost:3000`),并开启 Mock
```bash
# 终端 1
pnpm dev:api # server/.env 保持 MOCK_WECHAT=true
pnpm dev:api # server/.env 保持 MOCK_SMS=true / MOCK_WECHAT=true
# 终端 2
$env:VITE_API_TARGET="http://localhost:3000"; pnpm dev:mini-user:weapp
pnpm dev:mini-user # H5 预览 :5177
# 或
pnpm --filter @dukang/mini-user dev:weapp
```
**连远程 API**`dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret,并部署含 `code2Session` 小程序凭证逻辑的后端。
**连远程 API**
```bash
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
```
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
## 页面结构(18 页)
+11 -3
View File
@@ -2,8 +2,16 @@ import { createRequire } from 'node:module';
import path from 'node:path';
import { defineConfig } from '@tarojs/cli';
/** 小程序/H5 请求的后端 origin(不含 /api);本地联调可用 VITE_API_TARGET 覆盖 */
const API_ORIGIN = process.env.VITE_API_TARGET ?? 'https://dkapi.runxian.top';
/** 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 ??
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
@@ -89,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>
);
}
+6 -2
View File
@@ -10,6 +10,8 @@ type PageNavBarProps = {
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 内页自定义导航栏(返回 + 标题 + 右侧操作),适配刘海屏 */
export default function PageNavBar({
title,
@@ -19,16 +21,18 @@ export default function PageNavBar({
right,
}: PageNavBarProps) {
const metrics = useNavBarMetrics();
const showTitle = !isH5 && titleVisible;
return (
<View
className={`page-nav-bar${solid ? ' page-nav-bar--solid' : ''}`}
style={subPageNavBarStyle(metrics)}
aria-label={title}
>
<Text
className={`page-nav-bar__title${titleVisible ? ' page-nav-bar__title--visible' : ''}`}
className={`page-nav-bar__title${showTitle ? ' page-nav-bar__title--visible' : ''}`}
>
{title}
{showTitle ? title : ''}
</Text>
<View
className="page-nav-bar__content"
@@ -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>
);
}
@@ -9,6 +9,8 @@ type SubPageHeaderProps = {
right?: ReactNode;
};
const isH5 = process.env.TARO_ENV === 'h5';
/** 子页面固定实底顶栏(确认订单 / 地址 / 支付 / 订单列表等) */
export default function SubPageHeader({ title, onBack, right }: SubPageHeaderProps) {
const metrics = useNavBarMetrics();
@@ -27,8 +29,8 @@ export default function SubPageHeader({ title, onBack, right }: SubPageHeaderPro
}
return (
<View className="sub-page-header" style={subPageNavBarStyle(metrics)}>
<Text className="sub-page-header__title">{title}</Text>
<View className="sub-page-header" style={subPageNavBarStyle(metrics)} aria-label={title}>
{!isH5 ? <Text className="sub-page-header__title">{title}</Text> : null}
<View
className="sub-page-header__content"
style={subPageNavContentStyle(metrics)}
@@ -7,18 +7,24 @@ type TabMainHeaderProps = {
extra?: ReactNode;
};
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让 */
/**
* Tab
* / H5
*/
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
const metrics = useNavBarMetrics();
if (!extra) {
return null;
}
return (
<View className="tab-main-header" style={navBarStyle(metrics)}>
<Text className="tab-main-header__title">{title}</Text>
<View
className="tab-main-header__content"
style={tabNavContentStyle(metrics)}
>
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
{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,
+4 -10
View File
@@ -63,8 +63,10 @@ export default function BenefitPage() {
return (
<PageShell variant="tab" className="benefit-page">
<View className="benefit-header" style={navBarStyle(metrics)}>
<Text className="benefit-header-title"></Text>
<View className="benefit-header" style={navBarStyle(metrics)} aria-label="好客权益">
{process.env.TARO_ENV !== 'h5' ? (
<Text className="benefit-header-title"></Text>
) : null}
<View
className="benefit-header__content"
style={tabNavContentStyle(metrics)}
@@ -104,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 -19
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,32 +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">
<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>
@@ -85,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 (
+212 -109
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,50 +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>
{showWechatLogin ? (
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
{displayMsg ? (
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
{displayMsg}
</Text>
) : 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' : ''}`}>
@@ -376,20 +518,6 @@ export default function LoginPage() {
</Text>
) : null}
<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"
@@ -404,46 +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>
<View className="login-footer">
<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 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 -95
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,7 +257,7 @@ export default function MinePage() {
if (!authed) {
return (
<PageShell variant="tab" className="mine-page">
<PageShell variant="tab" className="mine-page no-tab-header">
<TabMainHeader title="我的" />
<View className="mine-header">
<View className="mine-header-texture" />
@@ -227,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>
@@ -241,39 +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">
<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>
@@ -370,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>
);
}
@@ -42,6 +42,9 @@ type OrderPreview = {
payAmount: number;
benefitAmount: number;
city?: { localMinQty: number; crossMinQty: number };
quantityOk?: boolean;
quantityMessage?: string | null;
minQty?: number;
};
function formatAddress(a: Address) {
@@ -53,7 +56,7 @@ export default function OrderConfirmPage() {
const checkoutCtx = readCheckoutContext(router.params);
const productId = checkoutCtx.productId ?? '';
const forceCross = checkoutCtx.cross === true;
const [quantity, setQuantity] = useState(Math.max(2, Number(checkoutCtx.qty || 2)));
const [quantity, setQuantity] = useState(Math.max(1, Number(checkoutCtx.qty || 2)));
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressId, setAddressId] = useState(checkoutCtx.addressId || '');
const [preview, setPreview] = useState<OrderPreview | null>(null);
@@ -91,7 +94,7 @@ export default function OrderConfirmPage() {
.then((data) => {
if (!cancelled) {
setPreview(data);
setMsg('');
setMsg(data.quantityOk === false ? (data.quantityMessage || '') : '');
}
})
.catch((e) => {
@@ -115,18 +118,14 @@ export default function OrderConfirmPage() {
);
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
const minQty =
preview?.minQty ??
(isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2));
const quantityOk = preview ? preview.quantityOk !== false && quantity >= minQty : false;
const canSubmit = !!addressId && !!preview && quantityOk && !loading && !previewLoading;
function updateQuantity(next: number) {
if (next < minQty) {
setMsg(
!isCross
? `同城配送至少购买 ${minQty}`
: `跨城配送至少购买 ${minQty} 瓶(1箱)`,
);
return;
}
setMsg('');
if (next < 1) return;
setQuantity(next);
}
@@ -158,8 +157,18 @@ export default function OrderConfirmPage() {
}
async function submit() {
if (!addressId) {
setMsg('请选择收货地址');
if (!canSubmit) {
if (!addressId) {
setMsg('请选择收货地址');
return;
}
if (!quantityOk) {
setMsg(
isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱)`
: `同城配送至少购买 ${minQty}`,
);
}
return;
}
@@ -206,6 +215,13 @@ export default function OrderConfirmPage() {
}
const productImage = preview?.product ? getProductMainImage(preview.product) : '';
const submitLabel = loading
? '提交中…'
: !addressId
? '请选择地址'
: !quantityOk
? `至少购买 ${minQty}`
: '提交订单';
return (
<PageShell variant="sub" className="order-confirm-page" hasFixedFooter>
@@ -241,7 +257,8 @@ export default function OrderConfirmPage() {
{isCross ? (
<View className="order-card">
<Text className="u-muted">
{quantity < minQty ? `;跨城至少购买 ${minQty} 瓶(1箱)` : ''}
</Text>
</View>
) : null}
@@ -269,7 +286,7 @@ export default function OrderConfirmPage() {
<Text></Text>
<View className="order-qty-controls">
<View
className="order-qty-btn"
className={`order-qty-btn${quantity <= 1 ? ' order-qty-btn--disabled' : ''}`}
onClick={() => updateQuantity(quantity - 1)}
>
<Text></Text>
@@ -283,6 +300,13 @@ export default function OrderConfirmPage() {
</View>
</View>
</View>
{!quantityOk ? (
<Text className="order-qty-hint">
{isCross
? `跨城配送至少购买 ${minQty} 瓶(1箱),请调整数量`
: `同城配送至少购买 ${minQty} 瓶,请调整数量`}
</Text>
) : null}
</View>
<View className="order-card">
@@ -320,10 +344,13 @@ export default function OrderConfirmPage() {
</Text>
</View>
<View
className="order-confirm-submit"
onClick={() => !loading && void submit()}
className={`order-confirm-submit${canSubmit ? '' : ' order-confirm-submit--disabled'}`}
onClick={() => {
if (!canSubmit) return;
void submit();
}}
>
<Text>{loading ? '提交中…' : !addressId ? '请选择地址' : '提交订单'}</Text>
<Text>{submitLabel}</Text>
</View>
</View>
</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">

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