Merge #23 into dev from dev_jacy
fix(h5-partner): resolve merge conflicts for auth and sub-accounts * dev_jacy: (5 commits) webadmin增加显示子账号显示列表 主账号和子账号同一设备登录遇到storge问题 子账号登录微信授权 用户端“补发货”的bug修复 fix(h5-partner): resolve merge conflicts for auth and sub-accounts Signed-off-by: jacy <moonjie444@163.com> Merged-by: jacy <moonjie444@163.com> CR-link: https://codeup.aliyun.com/6a41ee78a7a8d2b1c6bfb02f/dukanghaoke/change/23
This commit is contained in:
@@ -5,6 +5,7 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
|
||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_UPDATE', label: '编辑合伙人账户' },
|
||||
{ value: 'PARTNER_ACCOUNT_DELETE', label: '删除合伙人子账号' },
|
||||
{ value: 'HQ_ACCOUNT_CREATE', label: '新增 HQ 管理员' },
|
||||
{ value: 'HQ_ACCOUNT_UPDATE', label: '编辑 HQ 管理员' },
|
||||
{ value: 'HQ_PERMISSION_UPDATE', label: '配置 HQ 权限' },
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type ParentAccount = { id: string; name: string; phone: string };
|
||||
|
||||
type Row = {
|
||||
type AccountTreeRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
@@ -20,7 +21,9 @@ type Row = {
|
||||
parentAccountId?: string | null;
|
||||
parent?: ParentAccount | null;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
children?: AccountTreeRow[];
|
||||
};
|
||||
|
||||
type BillRow = {
|
||||
@@ -33,15 +36,34 @@ type OrderRow = {
|
||||
user?: { userNo: string; phone: string | null };
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type Detail = AccountTreeRow & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '主账号' },
|
||||
{ value: '0', label: '子账号' },
|
||||
];
|
||||
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
|
||||
const phoneQ = phone?.trim();
|
||||
const match = (row: AccountTreeRow) => {
|
||||
const phoneOk = !phoneQ || row.phone.includes(phoneQ);
|
||||
const statusOk = !status || row.status === status;
|
||||
return phoneOk && statusOk;
|
||||
};
|
||||
|
||||
const walk = (list: AccountTreeRow[]): AccountTreeRow[] => {
|
||||
const result: AccountTreeRow[] = [];
|
||||
for (const row of list) {
|
||||
const children = row.children?.length ? walk(row.children) : undefined;
|
||||
if (match(row) || (children && children.length > 0)) {
|
||||
result.push({ ...row, children: children?.length ? children : undefined });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return walk(rows);
|
||||
}
|
||||
|
||||
function accountTypeLabel(isPrimary: number) {
|
||||
return isPrimary === 1 ? '主账号' : '子账号';
|
||||
@@ -56,33 +78,61 @@ export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-accounts',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.isPrimary) qs.set('isPrimary', filters.isPrimary);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
|
||||
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
const path = qs.toString() ? `/admin/partner-accounts/tree?${qs}` : '/admin/partner-accounts/tree';
|
||||
const res = await request<AccountTreeRow[]>(path);
|
||||
setTreeData(filterTree(res, filters.phone, filters.status));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTree();
|
||||
}, [loadTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!drawerOpen || !detail) return;
|
||||
editForm.setFieldsValue({
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
});
|
||||
}, [drawerOpen, detail, editForm]);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setPartners(res.items);
|
||||
}
|
||||
|
||||
async function openAccount(id: string) {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, status: d.status });
|
||||
setDrawerOpen(true);
|
||||
try {
|
||||
const d = await request<Detail>(`/admin/partner-accounts/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载账户详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAccount() {
|
||||
@@ -91,18 +141,67 @@ export default function PartnerAccountsPage() {
|
||||
await request(`/admin/partner-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
async function deleteSubAccount(row: AccountTreeRow) {
|
||||
await request(`/admin/partner-accounts/${row.id}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
void loadTree();
|
||||
}
|
||||
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL' });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
function collectExpandableKeys(rows: AccountTreeRow[]): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const row of rows) {
|
||||
if (row.children?.length) {
|
||||
keys.push(row.id);
|
||||
keys.push(...collectExpandableKeys(row.children));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AccountTreeRow> = [
|
||||
{
|
||||
title: '姓名 / 类型',
|
||||
width: 200,
|
||||
ellipsis: true,
|
||||
render: (_, row) => (
|
||||
<AdminCellLine
|
||||
primary={row.name}
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||
: row.isPrimary === 1
|
||||
? '主账号'
|
||||
: '合伙人账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 },
|
||||
{
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.partner?.companyName || '—',
|
||||
},
|
||||
{
|
||||
title: '账号类型',
|
||||
dataIndex: 'isPrimary',
|
||||
width: 90,
|
||||
render: (v) => <Tag color={v === 1 ? 'blue' : 'default'}>{accountTypeLabel(v)}</Tag>,
|
||||
render: (v, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}>
|
||||
{row.parentAccountId ? '子账号' : accountTypeLabel(v)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
@@ -114,7 +213,7 @@ export default function PartnerAccountsPage() {
|
||||
title: '所属主账号',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
row.isPrimary === 1
|
||||
row.isPrimary === 1 || !row.parentAccountId
|
||||
? '-'
|
||||
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
|
||||
),
|
||||
@@ -122,11 +221,19 @@ export default function PartnerAccountsPage() {
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>编辑</Button>
|
||||
{!row.parentAccountId && row.isPrimary === 1 ? (
|
||||
<Button type="link" size="small" onClick={() => openAddSub(row)}>添加子账号</Button>
|
||||
) : null}
|
||||
{row.parentAccountId ? (
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => void deleteSubAccount(row)}>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
@@ -152,26 +259,70 @@ export default function PartnerAccountsPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<Typography.Text type="secondary">主账号下可展开/收起子账号,支持添加与删除子账号</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters({
|
||||
phone: v.phone?.trim() || undefined,
|
||||
status: v.status || undefined,
|
||||
partnerId: v.partnerId || undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 180 }}
|
||||
placeholder="全部"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onFocus={() => void loadPartners()}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isPrimary" label="账号类型">
|
||||
<Select allowClear style={{ width: 110 }} options={ACCOUNT_TYPE_OPTIONS.filter((item) => item.value !== '')} />
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">查询</Button>
|
||||
<Button onClick={() => setExpandedRowKeys(collectExpandableKeys(treeData))}>全部展开</Button>
|
||||
<Button onClick={() => setExpandedRowKeys([])}>全部收起</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item><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 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table<AccountTreeRow>
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={treeData}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={false}
|
||||
expandable={{
|
||||
expandedRowKeys,
|
||||
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys as string[]),
|
||||
defaultExpandAllRows: false,
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title="编辑开城合伙人账户"
|
||||
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
extra={<Button type="primary" onClick={() => void saveAccount()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
@@ -180,24 +331,39 @@ export default function PartnerAccountsPage() {
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
key={detail.id}
|
||||
initialValues={{
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
}}
|
||||
>
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号类型">
|
||||
<Input value={accountTypeLabel(detail.isPrimary)} disabled />
|
||||
<Input
|
||||
value={
|
||||
detail.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
||||
: accountTypeLabel(detail.isPrimary)
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="角色">
|
||||
<Input value={staffRoleLabel(detail.staffRole)} disabled />
|
||||
</Form.Item>
|
||||
{detail.isPrimary !== 1 && (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input
|
||||
value={detail.parent ? `${detail.parent.name} / ${detail.parent.phone}` : '-'}
|
||||
disabled
|
||||
/>
|
||||
{detail.staffRole ? (
|
||||
<Form.Item label="角色">
|
||||
<Input value={staffRoleLabel(detail.staffRole)} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
) : null}
|
||||
{detail.parentAccountId && detail.parent ? (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
@@ -243,7 +409,7 @@ export default function PartnerAccountsPage() {
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
void loadTree();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
@@ -262,6 +428,46 @@ export default function PartnerAccountsPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subParent) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: subParent.partner?.id,
|
||||
parentAccountId: subParent.id,
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
staffRole: v.staffRole,
|
||||
}),
|
||||
});
|
||||
message.success('子账号已创建');
|
||||
setSubOpen(false);
|
||||
setExpandedRowKeys((keys) => [...new Set([...keys, subParent.id])]);
|
||||
void loadTree();
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+10
-27
@@ -1,32 +1,15 @@
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { Routes, Route } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import PartnerAppRoutes from './PartnerAppRoutes';
|
||||
import { isLoggedIn } from './lib/api';
|
||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from './lib/partnerAccess';
|
||||
|
||||
function LoginRoute() {
|
||||
const { account } = usePartnerSession();
|
||||
if (isLoggedIn()) {
|
||||
return <Navigate to={partnerHomePath(account)} replace />;
|
||||
}
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginRoute />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={isLoggedIn() ? <PartnerAppRoutes /> : <Navigate to="/login" replace state={{ from: location }} />}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <AppRoutes />;
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/*" element={<PartnerAppRoutes />} />
|
||||
</Routes>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { usePartnerSession } from './contexts/PartnerSessionContext';
|
||||
import { isLoggedIn } from './lib/api';
|
||||
import { isSubAccount } from './lib/partnerAccess';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import SubAccountLayout from './layouts/SubAccountLayout';
|
||||
@@ -19,10 +18,6 @@ import LeaderboardPage from './pages/LeaderboardPage';
|
||||
import StaffListPage from './pages/StaffListPage';
|
||||
import StaffCreatePage from './pages/StaffCreatePage';
|
||||
|
||||
function SessionLoading() {
|
||||
return <div className="empty">加载中...</div>;
|
||||
}
|
||||
|
||||
function PrimaryRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
@@ -55,20 +50,18 @@ function SubAccountRoutes() {
|
||||
<Route path="/stores/new" element={<StoreCreatePage />} />
|
||||
<Route path="/stores/:id" element={<StoreDetailPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/stores/new" replace />} />
|
||||
<Route path="*" element={<Navigate to="/stores/new?step=1" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PartnerAppRoutes() {
|
||||
const { account, loading } = usePartnerSession();
|
||||
const { account, authenticated } = usePartnerSession();
|
||||
|
||||
if (!isLoggedIn()) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
if (loading && !account) {
|
||||
return <SessionLoading />;
|
||||
if (!authenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSubAccount(account)) {
|
||||
return <SubAccountRoutes />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated, account } = usePartnerSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
return <div className="empty">加载中...</div>;
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to={partnerHomePath(account ?? getPartnerProfile())} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
const profile = getPartnerProfile();
|
||||
if (profile && hasPartnerWxSession()) {
|
||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
||||
}
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import {
|
||||
@@ -33,11 +33,15 @@ function formatWechatUploadError(e: unknown): string {
|
||||
const formatted = formatChooseImageFailMessage(msg);
|
||||
if (formatted) return formatted;
|
||||
if (/invalid signature/i.test(msg)) {
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
function acceptsImages(accept: string) {
|
||||
return accept.includes('image');
|
||||
}
|
||||
|
||||
export default function OssUploadField({
|
||||
value,
|
||||
onChange,
|
||||
@@ -50,7 +54,6 @@ export default function OssUploadField({
|
||||
wechatReady,
|
||||
onWechatReadyChange,
|
||||
}: OssUploadFieldProps) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [authorizing, setAuthorizing] = useState(false);
|
||||
@@ -60,7 +63,9 @@ export default function OssUploadField({
|
||||
|
||||
const resolvedAccept =
|
||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||
const useWechatPicker = isWechatEnv() && mediaType === 'IMAGE';
|
||||
const inWechat = isWechatEnv();
|
||||
const useWechatPicker =
|
||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -122,6 +127,7 @@ export default function OssUploadField({
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
await weixinSdk.init();
|
||||
await enqueueUpload(async () => {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
@@ -130,8 +136,7 @@ export default function OssUploadField({
|
||||
if (!files?.[0]) {
|
||||
throw new Error('未能获取图片,请重试');
|
||||
}
|
||||
const result = await uploadFileToOss(files[0], { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
await uploadSelectedFile(files[0]);
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
@@ -165,6 +170,7 @@ export default function OssUploadField({
|
||||
const isImage = mediaType === 'IMAGE' && value;
|
||||
const isFile = mediaType === 'FILE' && value;
|
||||
const busy = uploading || authorizing;
|
||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
||||
|
||||
const triggerProps = {
|
||||
type: 'button' as const,
|
||||
@@ -176,7 +182,7 @@ export default function OssUploadField({
|
||||
<div className="partner-oss-upload">
|
||||
{needsAuth && (
|
||||
<div className="partner-wechat-auth-hint" role="status">
|
||||
<p className="body-md">上传照片需先完成微信授权</p>
|
||||
<p className="body-md">上传照片需先完成微信授权绑定</p>
|
||||
<button
|
||||
type="button"
|
||||
className="partner-btn-outline"
|
||||
@@ -184,23 +190,23 @@ export default function OssUploadField({
|
||||
disabled={authorizing}
|
||||
onClick={() => void startWechatAuth()}
|
||||
>
|
||||
{authorizing ? '跳转授权中…' : '微信授权'}
|
||||
{authorizing ? '跳转授权中…' : '微信授权绑定'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
capture={mediaType === 'FILE' && isWechatEnv() ? 'environment' : undefined}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadSelectedFile(file);
|
||||
}}
|
||||
/>
|
||||
{!useWechatPicker && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={resolvedAccept}
|
||||
className="partner-oss-upload-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadSelectedFile(file);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isImage ? (
|
||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||
<img src={value} alt={label ?? '已上传'} />
|
||||
@@ -228,7 +234,7 @@ export default function OssUploadField({
|
||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
||||
</span>
|
||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : (label ?? (useWechatPicker ? '从相册选择' : '点击上传'))}
|
||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,77 +1,156 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { toAppPath } from '@dukang/weixin-sdk';
|
||||
import { clearAuth, isLoggedIn, request, saveAuth, type PartnerAuthPayload } from '../lib/api';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import {
|
||||
clearAuth,
|
||||
ensureSession,
|
||||
request,
|
||||
saveAuth,
|
||||
type PartnerSessionPayload,
|
||||
type PartnerSessionProfile,
|
||||
} from '../lib/api';
|
||||
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
|
||||
export type PartnerAccount = PartnerMe & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
type PartnerSessionValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
account: PartnerAccount | null;
|
||||
loading: boolean;
|
||||
/** @deprecated 使用 authenticated */
|
||||
loggedIn: boolean;
|
||||
applySession: (session: PartnerAuthPayload) => void;
|
||||
/** @deprecated 使用 ready */
|
||||
loading: boolean;
|
||||
applySession: (session: PartnerSessionPayload) => void;
|
||||
refresh: () => Promise<PartnerAccount | null>;
|
||||
logout: () => void;
|
||||
};
|
||||
|
||||
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
||||
|
||||
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [account, setAccount] = useState<PartnerAccount | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loggedIn, setLoggedIn] = useState(() => isLoggedIn());
|
||||
function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
||||
return {
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
phone: profile.phone,
|
||||
companyName: profile.companyName ?? '',
|
||||
isPrimary: profile.isPrimary ?? true,
|
||||
staffRole: profile.staffRole,
|
||||
};
|
||||
}
|
||||
|
||||
const applySession = useCallback((session: PartnerAuthPayload) => {
|
||||
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [account, setAccount] = useState<PartnerAccount | null>(null);
|
||||
|
||||
const applySession = useCallback((session: PartnerSessionPayload) => {
|
||||
saveAuth(session);
|
||||
setLoggedIn(true);
|
||||
setLoading(false);
|
||||
setAuthenticated(true);
|
||||
if (session.partner) {
|
||||
setAccount(session.partner as PartnerAccount);
|
||||
setAccount(accountFromProfile(session.partner));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (): Promise<PartnerAccount | null> => {
|
||||
if (!isLoggedIn()) {
|
||||
setAccount(null);
|
||||
setLoggedIn(false);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
setLoggedIn(true);
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true });
|
||||
setAccount(data);
|
||||
return data;
|
||||
const result = await ensureSession();
|
||||
setAuthenticated(result.authenticated);
|
||||
if (!result.authenticated || !result.partner) {
|
||||
setAccount(null);
|
||||
return null;
|
||||
}
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true });
|
||||
setAccount(me);
|
||||
return me;
|
||||
} catch {
|
||||
setAccount(null);
|
||||
if (!isLoggedIn()) {
|
||||
setLoggedIn(false);
|
||||
}
|
||||
setAuthenticated(false);
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
setAccount(null);
|
||||
setLoggedIn(false);
|
||||
setAuthenticated(false);
|
||||
window.location.href = toAppPath('/login');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (isWechatEnv() && params.get('code')) {
|
||||
try {
|
||||
const config = await fetchClientConfig();
|
||||
if (isWxAuthorizeEnabled(config)) {
|
||||
const session = await processPartnerWechatOAuthCallback();
|
||||
if (session && !cancelled) {
|
||||
applySession(session);
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null);
|
||||
if (me && !cancelled) setAccount(me);
|
||||
}
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
} catch {
|
||||
stripOAuthParamsFromLocation();
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ensureSession();
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
if (result.authenticated) {
|
||||
const me = await request<PartnerAccount>('PARTNER_H5', '/partner/me', { silent: true }).catch(() => null);
|
||||
if (!cancelled) setAccount(me);
|
||||
} else {
|
||||
setAccount(null);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
clearAuth({ keepProfile: true });
|
||||
setAuthenticated(false);
|
||||
setAccount(null);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setReady(true);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applySession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
ready,
|
||||
authenticated,
|
||||
account,
|
||||
loggedIn: authenticated,
|
||||
loading: !ready,
|
||||
applySession,
|
||||
refresh,
|
||||
logout,
|
||||
}),
|
||||
[ready, authenticated, account, applySession, refresh, logout],
|
||||
);
|
||||
|
||||
return (
|
||||
<PartnerSessionContext.Provider
|
||||
value={{ account, loading, loggedIn, applySession, refresh, logout }}
|
||||
>
|
||||
<PartnerSessionContext.Provider value={value}>
|
||||
{children}
|
||||
</PartnerSessionContext.Provider>
|
||||
);
|
||||
@@ -82,3 +161,5 @@ export function usePartnerSession(): PartnerSessionValue {
|
||||
if (!ctx) throw new Error('usePartnerSession 必须在 PartnerSessionProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export type { PartnerSessionProfile };
|
||||
|
||||
+193
-42
@@ -1,18 +1,34 @@
|
||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { showPartnerToast } from './toast';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'PARTNER_H5';
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
const REFRESH_TOKEN = 'refreshToken';
|
||||
const LAST_PHONE = 'partnerLastPhone';
|
||||
const PARTNER_PROFILE = 'partnerProfile';
|
||||
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||
|
||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName'> & {
|
||||
isPrimary?: PartnerMe['isPrimary'];
|
||||
/** 微信验证通过后的免登录时长 */
|
||||
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/partner/auth/token/refresh',
|
||||
'/partner/auth/sms/send',
|
||||
'/partner/auth/login/sms',
|
||||
'/partner/auth/login/wechat',
|
||||
];
|
||||
|
||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'> & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
export type PartnerAuthPayload = {
|
||||
export type PartnerSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
partner?: PartnerSessionProfile;
|
||||
};
|
||||
|
||||
@@ -34,54 +50,189 @@ export function getPartnerProfile(): PartnerSessionProfile | null {
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
clientApp: string,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const { silent, ...fetchOptions } = options;
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': clientApp,
|
||||
...(fetchOptions.headers as Record<string, string>),
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${apiBase}${path}`, { ...fetchOptions, headers });
|
||||
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
|
||||
const rawMessage = json.message;
|
||||
const message = Array.isArray(rawMessage)
|
||||
? rawMessage.join(';')
|
||||
: (rawMessage || (res.status === 401 ? '登录已过期,请重新登录' : '请求失败'));
|
||||
|
||||
if (res.status === 401 || json.code === 401) {
|
||||
if (token && localStorage.getItem('accessToken') === token) {
|
||||
clearAuth();
|
||||
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
|
||||
window.location.href = toAppPath('/login');
|
||||
}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return json.data as T;
|
||||
export function hasPartnerWxSession() {
|
||||
return localStorage.getItem(PARTNER_WX_BOUND) === '1';
|
||||
}
|
||||
|
||||
export function saveAuth(data: PartnerAuthPayload) {
|
||||
localStorage.setItem('accessToken', data.accessToken);
|
||||
export function isPartnerSessionExpired() {
|
||||
const raw = localStorage.getItem(SESSION_EXPIRES_AT);
|
||||
if (!raw) return false;
|
||||
return Date.now() > Number(raw);
|
||||
}
|
||||
|
||||
export function touchPartnerSession() {
|
||||
if (!hasPartnerWxSession()) return;
|
||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||
}
|
||||
|
||||
export function saveAuth(data: PartnerSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.partner) {
|
||||
localStorage.setItem(PARTNER_PROFILE, JSON.stringify(data.partner));
|
||||
localStorage.setItem(LAST_PHONE, data.partner.phone);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem(PARTNER_PROFILE);
|
||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||
saveAuth(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 }) {
|
||||
localStorage.removeItem(ACCESS_TOKEN);
|
||||
localStorage.removeItem(REFRESH_TOKEN);
|
||||
localStorage.removeItem(SESSION_EXPIRES_AT);
|
||||
localStorage.removeItem(PARTNER_WX_BOUND);
|
||||
if (!options?.keepProfile) {
|
||||
localStorage.removeItem(PARTNER_PROFILE);
|
||||
localStorage.removeItem(LAST_PHONE);
|
||||
}
|
||||
}
|
||||
|
||||
export function isLoggedIn() {
|
||||
return !!localStorage.getItem('accessToken');
|
||||
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||
}
|
||||
|
||||
function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
||||
return {
|
||||
id: me.id,
|
||||
name: me.name,
|
||||
phone: me.phone,
|
||||
companyName: me.companyName,
|
||||
isPrimary: me.isPrimary,
|
||||
staffRole: me.staffRole ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
token?: string | null,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': CLIENT_APP,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json().catch(() => ({ code: res.status, message: '网络异常' }));
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code === 401 ? 401 : json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
}
|
||||
|
||||
async function refreshSession(): Promise<PartnerSessionPayload | null> {
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const data = await rawRequest<PartnerSessionPayload>(
|
||||
'/partner/auth/token/refresh',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
},
|
||||
null,
|
||||
);
|
||||
saveAuth(data);
|
||||
touchPartnerSession();
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestWithAuthRetry<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
retried = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await rawRequest<T>(path, options);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const canRecover =
|
||||
err.status === 401 &&
|
||||
!retried &&
|
||||
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||
if (!canRecover) throw e;
|
||||
const refreshed = await refreshSession();
|
||||
if (!refreshed) {
|
||||
clearAuth({ keepProfile: true });
|
||||
throw e;
|
||||
}
|
||||
return requestWithAuthRetry<T>(path, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
clientApp: string,
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> {
|
||||
void clientApp;
|
||||
const { silent, ...fetchOptions } = options;
|
||||
try {
|
||||
return await requestWithAuthRetry<T>(path, fetchOptions);
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
const message = err.message || '请求失败';
|
||||
if (err.status === 401) {
|
||||
if (localStorage.getItem(ACCESS_TOKEN)) {
|
||||
clearAuth({ keepProfile: true });
|
||||
if (!silent) showPartnerToast(message, 'error');
|
||||
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
|
||||
window.location.href = toAppPath('/login');
|
||||
}
|
||||
}
|
||||
} else if (!silent) {
|
||||
showPartnerToast(message, 'error');
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{ authenticated: boolean; partner: PartnerSessionProfile | null }> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, partner: null };
|
||||
}
|
||||
if (isPartnerSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, partner: getPartnerProfile() };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<PartnerMe>('/partner/me');
|
||||
const partner = profileFromMe(me);
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
partner,
|
||||
});
|
||||
touchPartnerSession();
|
||||
return { authenticated: true, partner };
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed?.partner) {
|
||||
return { authenticated: true, partner: refreshed.partner };
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, partner: getPartnerProfile() };
|
||||
}
|
||||
const cached = getPartnerProfile();
|
||||
if (cached) return { authenticated: true, partner: cached };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated 使用 PartnerSessionPayload */
|
||||
export type PartnerAuthPayload = PartnerSessionPayload;
|
||||
|
||||
@@ -9,7 +9,7 @@ export function isSubAccount(account: PartnerMe | null | undefined): boolean {
|
||||
}
|
||||
|
||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return isSubAccount(account) ? '/stores/new' : '/';
|
||||
return isSubAccount(account) ? '/stores/new?step=1' : '/';
|
||||
}
|
||||
|
||||
export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/login'];
|
||||
|
||||
@@ -23,6 +23,10 @@ export type StoreDraft = {
|
||||
|
||||
export const STORE_DRAFT_KEY = 'partner_store_draft_v2';
|
||||
|
||||
export function storeDraftKey(accountId?: string): string {
|
||||
return accountId ? `${STORE_DRAFT_KEY}_${accountId}` : STORE_DRAFT_KEY;
|
||||
}
|
||||
|
||||
export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
regionCodes: [],
|
||||
cityId: '',
|
||||
@@ -72,9 +76,17 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadStoreDraft(): StoreDraft | null {
|
||||
export function loadStoreDraft(accountId?: string): StoreDraft | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
const key = storeDraftKey(accountId);
|
||||
let raw = localStorage.getItem(key);
|
||||
if (!raw && accountId) {
|
||||
raw = localStorage.getItem(STORE_DRAFT_KEY);
|
||||
if (raw) {
|
||||
localStorage.setItem(key, raw);
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (parsed.form && typeof parsed.form === 'object') {
|
||||
@@ -89,12 +101,12 @@ export function loadStoreDraft(): StoreDraft | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoreDraft(draft: StoreDraft) {
|
||||
localStorage.setItem(STORE_DRAFT_KEY, JSON.stringify(draft));
|
||||
export function saveStoreDraft(draft: StoreDraft, accountId?: string) {
|
||||
localStorage.setItem(storeDraftKey(accountId), JSON.stringify(draft));
|
||||
}
|
||||
|
||||
export function clearStoreDraft() {
|
||||
localStorage.removeItem(STORE_DRAFT_KEY);
|
||||
export function clearStoreDraft(accountId?: string) {
|
||||
localStorage.removeItem(storeDraftKey(accountId));
|
||||
}
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
|
||||
@@ -2,14 +2,15 @@ import type { ClientRuntimeConfig, WechatLoginResult } from '@dukang/shared-type
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { isWechatEnv, weixinSdk } from './weixin';
|
||||
import { request, saveAuth, type PartnerSessionProfile } from './api';
|
||||
import { request, saveWechatSession, type PartnerSessionPayload } from './api';
|
||||
|
||||
export type PartnerProfile = {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
companyName: string;
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
isPrimary?: boolean;
|
||||
};
|
||||
|
||||
export async function fetchClientConfig(): Promise<ClientRuntimeConfig> {
|
||||
@@ -20,7 +21,7 @@ export async function fetchPartnerProfile(): Promise<PartnerProfile> {
|
||||
return request<PartnerProfile>('PARTNER_H5', '/partner/me');
|
||||
}
|
||||
|
||||
/** 微信内上传照片前需完成公众号授权绑定 */
|
||||
/** 微信内上传照片前需完成公众号 OAuth 绑定 */
|
||||
export function needsWechatAuth(
|
||||
profile: PartnerProfile | null,
|
||||
config?: Pick<ClientRuntimeConfig, 'wxAuthorize'> | null,
|
||||
@@ -34,27 +35,30 @@ export async function checkNeedsWechatAuth(profile: PartnerProfile | null): Prom
|
||||
return needsWechatAuth(profile, config);
|
||||
}
|
||||
|
||||
/** 处理微信登录/绑定结果,返回是否已拿到 token 可进入首页 */
|
||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): boolean {
|
||||
if (!result.accessToken) return false;
|
||||
saveAuth({
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const partner = result.partner;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
partner: result.partner as PartnerSessionProfile | undefined,
|
||||
});
|
||||
return true;
|
||||
refreshToken: result.refreshToken,
|
||||
partner: partner
|
||||
? {
|
||||
id: String(partner.id ?? ''),
|
||||
name: String(partner.name ?? ''),
|
||||
phone: String(partner.phone ?? ''),
|
||||
companyName: partner.companyName ? String(partner.companyName) : undefined,
|
||||
isPrimary: partner.isPrimary !== false,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** 微信登录成功后拉取并缓存合伙人资料,供一键登录页展示 */
|
||||
export async function persistPartnerProfileAfterLogin(): Promise<void> {
|
||||
try {
|
||||
const profile = await fetchPartnerProfile();
|
||||
saveAuth({
|
||||
accessToken: localStorage.getItem('accessToken') ?? '',
|
||||
partner: profile,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
/** 处理微信登录/绑定结果,写入 7 天免登录 session */
|
||||
export function handlePartnerWechatLoginResult(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
const session = sessionFromWechatLogin(result);
|
||||
if (!session) return null;
|
||||
saveWechatSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function handlePartnerWechatCallback(): Promise<WechatLoginResult | null> {
|
||||
@@ -65,12 +69,12 @@ export async function handlePartnerWechatCallback(): Promise<WechatLoginResult |
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信授权登录(对齐 C 端:仅微信内置浏览器走 OAuth)。
|
||||
* 返回 true = 已登录;void = 已跳转授权页等待回调。
|
||||
* 微信一键登录(已绑定微信的合伙人账号免验证码)。
|
||||
* 返回 session = 已登录;void = 已跳转授权页等待回调。
|
||||
*/
|
||||
export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
||||
export async function loginPartnerWithWechat(): Promise<PartnerSessionPayload | null | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return false;
|
||||
if (!isWxAuthorizeEnabled(config)) return null;
|
||||
if (!isWechatEnv()) {
|
||||
throw new Error(WECHAT_INAPP_REQUIRED_MSG);
|
||||
}
|
||||
@@ -78,6 +82,14 @@ export async function loginPartnerWithWechat(): Promise<boolean | void> {
|
||||
if (result) return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** 短信登录成功后于微信内自动发起 OAuth,绑定 openId 便于后续免登 */
|
||||
export async function bindPartnerWechatAfterSmsLogin(): Promise<void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
if (!isWechatEnv()) return;
|
||||
await weixinSdk.login();
|
||||
}
|
||||
|
||||
export async function authorizePartnerWechat(): Promise<WechatLoginResult | void> {
|
||||
const config = await fetchClientConfig();
|
||||
if (!isWxAuthorizeEnabled(config)) return;
|
||||
@@ -87,7 +99,14 @@ export async function authorizePartnerWechat(): Promise<WechatLoginResult | void
|
||||
return weixinSdk.login();
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
||||
/** OAuth 回跳统一处理(登录页 / 录店页等) */
|
||||
export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessionPayload | null> {
|
||||
const result = await handlePartnerWechatCallback();
|
||||
if (!result) return null;
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||
export function savePartnerWechatAuth(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||
return handlePartnerWechatLoginResult(result);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { request, getLastPhone, getPartnerProfile, type PartnerAuthPayload } from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import type { PartnerAccount } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import type { PartnerMe } from '@dukang/shared-types';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import {
|
||||
getLastPhone,
|
||||
getPartnerProfile,
|
||||
hasPartnerWxSession,
|
||||
request,
|
||||
saveAuth,
|
||||
type PartnerSessionPayload,
|
||||
} from '../lib/api';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
import { partnerHomePath } from '../lib/partnerAccess';
|
||||
import {
|
||||
bindPartnerWechatAfterSmsLogin,
|
||||
fetchClientConfig,
|
||||
handlePartnerWechatCallback,
|
||||
loginPartnerWithWechat,
|
||||
} from '../lib/wechat-auth';
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
|
||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||
@@ -43,21 +48,17 @@ function formatPartnerError(e: unknown): string {
|
||||
return text;
|
||||
}
|
||||
|
||||
function toLoginSession(data: { accessToken: string; partner?: PartnerMe }): PartnerAuthPayload {
|
||||
return {
|
||||
accessToken: data.accessToken,
|
||||
partner: data.partner,
|
||||
};
|
||||
}
|
||||
|
||||
function toPartnerAccount(partner?: Record<string, unknown>): PartnerAccount | undefined {
|
||||
if (!partner || typeof partner.id !== 'string') return undefined;
|
||||
return partner as unknown as PartnerAccount;
|
||||
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 } = usePartnerSession();
|
||||
const { applySession, refresh, account } = usePartnerSession();
|
||||
const [params] = useSearchParams();
|
||||
const quick = params.get('quick') === '1';
|
||||
const savedProfile = getPartnerProfile();
|
||||
@@ -82,29 +83,6 @@ export default function LoginPage() {
|
||||
const quickCompany = savedProfile?.companyName ?? '';
|
||||
const quickPhone = savedProfile?.phone || phone;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
||||
void handlePartnerWechatCallback()
|
||||
.then((result) => {
|
||||
if (!result?.accessToken) return;
|
||||
const session: PartnerAuthPayload = {
|
||||
accessToken: result.accessToken,
|
||||
partner: toPartnerAccount(result.partner),
|
||||
};
|
||||
applySession(session);
|
||||
navigate(partnerHomePath(session.partner), { replace: true });
|
||||
})
|
||||
.catch((e) => setMsg(formatWechatError(e)));
|
||||
}, [applySession, navigate, wxAuthorize, params]);
|
||||
|
||||
function formatWechatError(e: unknown): string {
|
||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
@@ -152,28 +130,30 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function login(options?: { quick?: boolean }) {
|
||||
if (!options?.quick && !ensureAgreed()) return;
|
||||
async function finishLoginNavigate() {
|
||||
const me = await refresh();
|
||||
navigate(partnerHomePath(me ?? account ?? savedProfile));
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (!ensureAgreed()) return;
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
if (options?.quick) {
|
||||
await request('PARTNER_H5', '/partner/auth/sms/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: quickPhone, scene: 'PARTNER_LOGIN' }),
|
||||
silent: true,
|
||||
});
|
||||
}
|
||||
const loginPhone = options?.quick ? quickPhone : phone;
|
||||
const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
const data = await request<PartnerSessionPayload>('PARTNER_H5', '/partner/auth/login/sms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone: loginPhone, code }),
|
||||
body: JSON.stringify({ phone, code }),
|
||||
silent: true,
|
||||
});
|
||||
const session = toLoginSession(data);
|
||||
applySession(session);
|
||||
persistRememberAccount(loginPhone);
|
||||
navigate(partnerHomePath(session.partner), { replace: true });
|
||||
saveAuth(data);
|
||||
applySession(data);
|
||||
persistRememberAccount(phone);
|
||||
if (isWechatEnv() && wxAuthorize) {
|
||||
setMsg('登录成功,正在关联微信…');
|
||||
await bindPartnerWechatAfterSmsLogin();
|
||||
return;
|
||||
}
|
||||
await finishLoginNavigate();
|
||||
} catch (e) {
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
@@ -190,13 +170,10 @@ export default function LoginPage() {
|
||||
}
|
||||
setWxLoading(true);
|
||||
try {
|
||||
const ok = await loginPartnerWithWechat();
|
||||
if (ok) {
|
||||
applySession({
|
||||
accessToken: localStorage.getItem('accessToken') ?? '',
|
||||
partner: getPartnerProfile() ?? undefined,
|
||||
});
|
||||
navigate(partnerHomePath(getPartnerProfile()), { replace: true });
|
||||
const session = await loginPartnerWithWechat();
|
||||
if (session) {
|
||||
applySession(session);
|
||||
await finishLoginNavigate();
|
||||
}
|
||||
} catch (e) {
|
||||
setMsg(formatWechatError(e));
|
||||
@@ -206,6 +183,8 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
if (quick) {
|
||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasPartnerWxSession() && !!savedProfile;
|
||||
|
||||
return (
|
||||
<div className="partner-auth-page partner-auth-page--quick">
|
||||
<header className="partner-auth-brand">
|
||||
@@ -236,17 +215,37 @@ export default function LoginPage() {
|
||||
|
||||
<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>}
|
||||
<button type="button" className="partner-btn-primary" onClick={() => void login({ quick: true })} disabled={loading || !quickPhone}>
|
||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||
</button>
|
||||
{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' }}>Secured by Dukang Heritage</span>
|
||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
||||
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -316,13 +315,13 @@ export default function LoginPage() {
|
||||
|
||||
{wxAuthorize && (
|
||||
<>
|
||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
||||
<button type="button" className="partner-btn-wechat" onClick={wechatLogin} disabled={wxLoading}>
|
||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
||||
</svg>
|
||||
<span>{wxLoading ? '登录中...' : '微信一键授权'}</span>
|
||||
</button>
|
||||
<div 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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -335,7 +334,9 @@ export default function LoginPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Link to="/login?quick=1" className="partner-link">快捷登录</Link>
|
||||
{hasPartnerWxSession() && savedProfile && (
|
||||
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
||||
)}
|
||||
|
||||
<footer className="partner-auth-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
|
||||
@@ -17,19 +17,9 @@ import { checkStorePhoneAvailable } from '../lib/storePhone';
|
||||
|
||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||
|
||||
import {
|
||||
|
||||
fetchPartnerProfile,
|
||||
|
||||
handlePartnerWechatCallback,
|
||||
|
||||
savePartnerWechatAuth,
|
||||
|
||||
} from '../lib/wechat-auth';
|
||||
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
|
||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||
|
||||
import {
|
||||
|
||||
@@ -87,9 +77,15 @@ export default function StoreCreatePage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { account, refresh } = usePartnerSession();
|
||||
|
||||
const accountId = account?.id;
|
||||
|
||||
const wechatReady = !!account?.hasWechat;
|
||||
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const saved = loadStoreDraft();
|
||||
const saved = loadStoreDraft(accountId);
|
||||
|
||||
const [form, setForm] = useState<StoreDraftForm>(saved?.form ?? defaultStoreForm());
|
||||
|
||||
@@ -105,8 +101,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
const [citiesError, setCitiesError] = useState('');
|
||||
|
||||
const [wechatReady, setWechatReady] = useState(false);
|
||||
|
||||
function reportFormError(message: string) {
|
||||
setSubmitError(message);
|
||||
}
|
||||
@@ -131,9 +125,9 @@ export default function StoreCreatePage() {
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
saveStoreDraft({ step, form });
|
||||
saveStoreDraft({ step, form }, accountId);
|
||||
|
||||
}, [step, form]);
|
||||
}, [step, form, accountId]);
|
||||
|
||||
|
||||
|
||||
@@ -141,11 +135,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
if (step !== 2 || !isWechatEnv()) return;
|
||||
|
||||
void fetchPartnerProfile()
|
||||
|
||||
.then((me) => setWechatReady(!!me.hasWechat))
|
||||
|
||||
.catch(() => setWechatReady(false));
|
||||
void refresh();
|
||||
|
||||
void weixinSdk.init().catch(() => {
|
||||
|
||||
@@ -153,45 +143,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
}, [step]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
if (!isWechatEnv() || !params.get('code')) return;
|
||||
|
||||
void handlePartnerWechatCallback()
|
||||
|
||||
.then((result) => {
|
||||
|
||||
if (result && savePartnerWechatAuth(result)) {
|
||||
|
||||
setWechatReady(true);
|
||||
|
||||
}
|
||||
|
||||
stripOAuthParamsFromLocation();
|
||||
|
||||
const next = new URLSearchParams(params);
|
||||
|
||||
next.delete('code');
|
||||
|
||||
next.delete('state');
|
||||
|
||||
setParams(next, { replace: true });
|
||||
|
||||
void weixinSdk.init().catch(() => {
|
||||
|
||||
/* OssUploadField 点击时会再次初始化 */
|
||||
|
||||
});
|
||||
|
||||
})
|
||||
|
||||
.catch((e) => reportFormError(e instanceof Error ? e.message : '微信授权失败'));
|
||||
|
||||
}, [params, setParams]);
|
||||
}, [step, refresh]);
|
||||
|
||||
|
||||
|
||||
@@ -468,7 +420,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
clearStoreDraft();
|
||||
clearStoreDraft(accountId);
|
||||
toastSuccess('门店录入成功');
|
||||
navigate(`/stores/${result.store.id}`);
|
||||
} catch (e) {
|
||||
@@ -717,7 +669,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
onWechatReadyChange={() => { void refresh(); }}
|
||||
|
||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||
|
||||
@@ -753,7 +705,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
onWechatReadyChange={() => { void refresh(); }}
|
||||
|
||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||
|
||||
@@ -785,7 +737,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
wechatReady={wechatReady}
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
onWechatReadyChange={() => { void refresh(); }}
|
||||
|
||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import AppToast from '../components/AppToast';
|
||||
import { request } from '../lib/api';
|
||||
@@ -118,7 +118,6 @@ function fullReceiverAddress(order: Order) {
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [order, setOrder] = useState<Order | null>(null);
|
||||
const [showCs, setShowCs] = useState(false);
|
||||
@@ -126,7 +125,7 @@ export default function OrderDetailPage() {
|
||||
const [shareToast, setShareToast] = useState('');
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const isReship = order?.orderType === 'RESHIPMENT' || params.get('type') === 'reship';
|
||||
const isReship = order?.orderType === 'RESHIPMENT';
|
||||
|
||||
async function loadOrder() {
|
||||
if (!id) return;
|
||||
|
||||
@@ -20,9 +20,17 @@ const STATUS_LABEL: Record<string, string> = {
|
||||
SHIPPING: '配送中',
|
||||
PENDING_RECEIVE: '待收货',
|
||||
COMPLETED: '已完成',
|
||||
RESHIP: '补发中',
|
||||
};
|
||||
|
||||
function isReshipOrder(order: Record<string, unknown>) {
|
||||
return String(order.orderType) === 'RESHIPMENT';
|
||||
}
|
||||
|
||||
function orderStatusLabel(order: Record<string, unknown>) {
|
||||
if (isReshipOrder(order)) return '补发中';
|
||||
return STATUS_LABEL[String(order.status)] || String(order.status);
|
||||
}
|
||||
|
||||
export default function OrderListPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const tab = params.get('tab') || 'all';
|
||||
@@ -38,18 +46,24 @@ export default function OrderListPage() {
|
||||
<PageHeader title="我的订单" onBack={() => navigate('/mine')} />
|
||||
<OrderStatusTabs tabs={TABS} active={tab} onChange={(key) => setParams({ tab: key })} />
|
||||
{data.list.length === 0 && <div className="empty">暂无订单</div>}
|
||||
{data.list.map((o, i) => {
|
||||
{data.list.map((o) => {
|
||||
const items = (o.items as Array<Record<string, unknown>>) || [];
|
||||
const item = items[0];
|
||||
const isReshipDemo = i === 0 && tab === 'all';
|
||||
const isReship = isReshipOrder(o);
|
||||
const isPendingPay = String(o.status) === 'PENDING_PAY';
|
||||
return (
|
||||
<div key={String(o.id)} className="card">
|
||||
<div className="card-row" style={{ marginBottom: 8 }}>
|
||||
<span className="label-md text-muted">订单号: {String(o.orderNo)}</span>
|
||||
<span className="status-tag">{STATUS_LABEL[String(o.status)] || String(o.status)}</span>
|
||||
<span className="label-md text-muted">
|
||||
订单号: {String(o.orderNo)}
|
||||
{isReship && (
|
||||
<span className="tag-reship" style={{ marginLeft: 8 }}>
|
||||
补发单
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="status-tag">{orderStatusLabel(o)}</span>
|
||||
</div>
|
||||
{isReshipDemo && <span className="tag-reship" style={{ marginBottom: 8, display: 'inline-block' }}>补发示例</span>}
|
||||
{item && (
|
||||
<div className="card-row">
|
||||
<AppImage
|
||||
@@ -74,11 +88,8 @@ export default function OrderListPage() {
|
||||
去付款
|
||||
</button>
|
||||
)}
|
||||
<Link
|
||||
to={`/orders/${o.id}${isReshipDemo ? '?type=reship' : ''}`}
|
||||
className="btn btn-outline btn-pill"
|
||||
>
|
||||
{isReshipDemo ? '查看进度' : '查看详情'}
|
||||
<Link to={`/orders/${o.id}`} className="btn btn-outline btn-pill">
|
||||
查看详情
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const HqOperationAction = {
|
||||
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
||||
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
||||
PARTNER_ACCOUNT_UPDATE: 'PARTNER_ACCOUNT_UPDATE',
|
||||
PARTNER_ACCOUNT_DELETE: 'PARTNER_ACCOUNT_DELETE',
|
||||
HQ_ACCOUNT_CREATE: 'HQ_ACCOUNT_CREATE',
|
||||
HQ_ACCOUNT_UPDATE: 'HQ_ACCOUNT_UPDATE',
|
||||
HQ_PERMISSION_UPDATE: 'HQ_PERMISSION_UPDATE',
|
||||
@@ -50,6 +51,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_UPDATE]: '编辑合伙人账户',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_DELETE]: '删除合伙人子账号',
|
||||
[HqOperationAction.HQ_ACCOUNT_CREATE]: '新增 HQ 管理员',
|
||||
[HqOperationAction.HQ_ACCOUNT_UPDATE]: '编辑 HQ 管理员',
|
||||
[HqOperationAction.HQ_PERMISSION_UPDATE]: '配置 HQ 权限',
|
||||
|
||||
@@ -172,6 +172,11 @@ export class PartnerAuthController {
|
||||
}
|
||||
return this.authService.loginPartnerWechat(dto.code, ClientApp.PARTNER_H5, dto.platform ?? 'h5');
|
||||
}
|
||||
|
||||
@Post('token/refresh')
|
||||
refresh(@Body() dto: RefreshTokenDto) {
|
||||
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.PARTNER_H5);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('user')
|
||||
|
||||
@@ -385,6 +385,9 @@ export class AuthService {
|
||||
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
||||
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
||||
}
|
||||
if (payload.actorType === 'PARTNER' && clientApp === ClientApp.PARTNER_H5) {
|
||||
return this.buildPartnerSessionResponse(BigInt(payload.actorId), clientApp);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
@@ -409,6 +412,25 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
@@ -1393,7 +1415,7 @@ export class AuthService {
|
||||
phoneVerified,
|
||||
};
|
||||
const accessToken = this.jwtService.sign(payload);
|
||||
const refreshExpiresIn = actorType === 'STORE' ? '7d' : '30d';
|
||||
const refreshExpiresIn = actorType === 'STORE' || actorType === 'PARTNER' ? '7d' : '30d';
|
||||
const refreshToken = this.jwtService.sign(payload, { expiresIn: refreshExpiresIn });
|
||||
return {
|
||||
accessToken,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
@@ -54,6 +54,11 @@ export class AdminPartnersController {
|
||||
export class AdminPartnerAccountsController {
|
||||
constructor(private readonly service: AdminPartnersService) {}
|
||||
|
||||
@Get('tree')
|
||||
tree(@Query('partnerId') partnerId?: string) {
|
||||
return this.service.listPartnerAccountTree(partnerId ? BigInt(partnerId) : undefined);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: AdminPartnerAccountsQueryDto) {
|
||||
return this.service.listPartnerAccounts(query);
|
||||
@@ -85,4 +90,14 @@ export class AdminPartnerAccountsController {
|
||||
update(@Param('id') id: string, @Body() dto: UpdatePartnerAccountDto) {
|
||||
return this.service.updatePartnerAccount(BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.PARTNER_ACCOUNT_DELETE,
|
||||
refType: 'PARTNER_ACCOUNT',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.deletePartnerSubAccount(BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +98,64 @@ export class AdminPartnersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listPartnerAccountTree(partnerId?: bigint) {
|
||||
const where: Prisma.PartnerAccountWhereInput = {};
|
||||
if (partnerId) where.partnerId = partnerId;
|
||||
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
|
||||
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
const roots: TreeNode[] = [];
|
||||
|
||||
for (const account of accounts) {
|
||||
nodeMap.set(account.id.toString(), { ...account, children: [] });
|
||||
}
|
||||
|
||||
for (const account of accounts) {
|
||||
const node = nodeMap.get(account.id.toString())!;
|
||||
if (account.parentAccountId) {
|
||||
const parent = nodeMap.get(account.parentAccountId.toString());
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const mapNode = (node: TreeNode) => ({
|
||||
id: node.id,
|
||||
phone: node.phone,
|
||||
name: node.name,
|
||||
status: node.status,
|
||||
isPrimary: node.isPrimary,
|
||||
staffRole: node.staffRole,
|
||||
parentAccountId: node.parentAccountId,
|
||||
partner: node.partner,
|
||||
createdAt: node.createdAt,
|
||||
lastLoginAt: node.lastLoginAt,
|
||||
children: node.children.length ? node.children.map(mapNode) : undefined,
|
||||
});
|
||||
|
||||
return serializeBigInt(roots.map(mapNode));
|
||||
}
|
||||
|
||||
async detailPartnerAccount(id: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
partner: true,
|
||||
partner: {
|
||||
select: {
|
||||
id: true,
|
||||
companyName: true,
|
||||
contactPhone: true,
|
||||
address: true,
|
||||
},
|
||||
},
|
||||
parent: { select: { id: true, name: true, phone: true } },
|
||||
},
|
||||
});
|
||||
@@ -133,16 +186,49 @@ export class AdminPartnersService {
|
||||
}
|
||||
|
||||
async createPartnerAccount(dto: CreatePartnerAccountDto) {
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
if (dto.parentAccountId) {
|
||||
const parent = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: BigInt(dto.parentAccountId) },
|
||||
});
|
||||
if (!parent) throw new BadRequestException('主账号不存在');
|
||||
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号');
|
||||
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) {
|
||||
throw new BadRequestException('开城合伙人与主账号不匹配');
|
||||
}
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: parent.partnerId,
|
||||
phone,
|
||||
name: dto.name.trim(),
|
||||
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } });
|
||||
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: partner.id,
|
||||
phone: dto.phone,
|
||||
name: dto.name,
|
||||
phone,
|
||||
name: dto.name.trim(),
|
||||
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
|
||||
isPrimary: 0,
|
||||
},
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
@@ -169,4 +255,14 @@ export class AdminPartnersService {
|
||||
const account = await this.prisma.partnerAccount.update({ where: { id }, data });
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
async deletePartnerSubAccount(id: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||
if (!account) throw new NotFoundException('开城合伙人账号不存在');
|
||||
if (!account.parentAccountId) {
|
||||
throw new BadRequestException('仅可删除子账号');
|
||||
}
|
||||
await this.prisma.partnerAccount.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,9 +211,10 @@ export class UpdatePartnerAccountDto {
|
||||
}
|
||||
|
||||
export class CreatePartnerAccountDto {
|
||||
@ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partnerId: string;
|
||||
partnerId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -226,6 +227,11 @@ export class CreatePartnerAccountDto {
|
||||
@IsOptional()
|
||||
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
|
||||
staffRole?: string;
|
||||
|
||||
/** 主账号 ID;传入则创建子账号 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentAccountId?: string;
|
||||
}
|
||||
|
||||
export class CreateCityDto {
|
||||
|
||||
Reference in New Issue
Block a user