fix(h5-partner): resolve merge conflicts for auth and sub-accounts
Merge partner session model (AuthGate, ensureSession, WeChat OAuth in Context) with sub-account routing, admin partner-account tree CRUD, upload lock, and partner staff flows. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -9,6 +9,7 @@ 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 { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
|
type ParentAccount = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
type AccountTreeRow = {
|
type AccountTreeRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,10 +17,11 @@ type AccountTreeRow = {
|
|||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
isPrimary: number;
|
isPrimary: number;
|
||||||
staffRole: string | null;
|
staffRole?: string | null;
|
||||||
parentAccountId: string | null;
|
parentAccountId?: string | null;
|
||||||
|
parent?: ParentAccount | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt?: string | null;
|
||||||
partner?: { id: string; companyName: string };
|
partner?: { id: string; companyName: string };
|
||||||
children?: AccountTreeRow[];
|
children?: AccountTreeRow[];
|
||||||
};
|
};
|
||||||
@@ -49,20 +51,29 @@ function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): Ac
|
|||||||
return phoneOk && statusOk;
|
return phoneOk && statusOk;
|
||||||
};
|
};
|
||||||
|
|
||||||
const walk = (list: AccountTreeRow[]): AccountTreeRow[] =>
|
const walk = (list: AccountTreeRow[]): AccountTreeRow[] => {
|
||||||
list
|
const result: AccountTreeRow[] = [];
|
||||||
.map((row) => {
|
for (const row of list) {
|
||||||
const children = row.children?.length ? walk(row.children) : undefined;
|
const children = row.children?.length ? walk(row.children) : undefined;
|
||||||
if (match(row) || (children && children.length > 0)) {
|
if (match(row) || (children && children.length > 0)) {
|
||||||
return { ...row, children: children?.length ? children : undefined };
|
result.push({ ...row, children: children?.length ? children : undefined });
|
||||||
}
|
}
|
||||||
return null;
|
}
|
||||||
})
|
return result;
|
||||||
.filter((row): row is AccountTreeRow => row !== null);
|
};
|
||||||
|
|
||||||
return walk(rows);
|
return walk(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function accountTypeLabel(isPrimary: number) {
|
||||||
|
return isPrimary === 1 ? '主账号' : '子账号';
|
||||||
|
}
|
||||||
|
|
||||||
|
function staffRoleLabel(role?: string | null) {
|
||||||
|
if (!role) return '-';
|
||||||
|
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
export default function PartnerAccountsPage() {
|
export default function PartnerAccountsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
@@ -167,7 +178,7 @@ export default function PartnerAccountsPage() {
|
|||||||
primary={row.name}
|
primary={row.name}
|
||||||
secondary={
|
secondary={
|
||||||
row.parentAccountId
|
row.parentAccountId
|
||||||
? `子账号 · ${PARTNER_STAFF_ROLE_LABELS[row.staffRole as keyof typeof PARTNER_STAFF_ROLE_LABELS] || row.staffRole || '—'}`
|
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||||
: row.isPrimary === 1
|
: row.isPrimary === 1
|
||||||
? '主账号'
|
? '主账号'
|
||||||
: '合伙人账号'
|
: '合伙人账号'
|
||||||
@@ -183,16 +194,29 @@ export default function PartnerAccountsPage() {
|
|||||||
render: (_, row) => row.partner?.companyName || '—',
|
render: (_, row) => row.partner?.companyName || '—',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '标识',
|
title: '账号类型',
|
||||||
|
dataIndex: 'isPrimary',
|
||||||
|
width: 90,
|
||||||
|
render: (v, row) => (
|
||||||
|
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}>
|
||||||
|
{row.parentAccountId ? '子账号' : accountTypeLabel(v)}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '角色',
|
||||||
|
dataIndex: 'staffRole',
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (_, row) =>
|
render: (role) => staffRoleLabel(role),
|
||||||
row.parentAccountId ? (
|
},
|
||||||
<Tag>子账号</Tag>
|
{
|
||||||
) : row.isPrimary === 1 ? (
|
title: '所属主账号',
|
||||||
<Tag color="blue">主账号</Tag>
|
width: 140,
|
||||||
) : (
|
render: (_, row) => (
|
||||||
<Tag>账号</Tag>
|
row.isPrimary === 1 || !row.parentAccountId
|
||||||
),
|
? '-'
|
||||||
|
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
@@ -282,7 +306,7 @@ export default function PartnerAccountsPage() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={treeData}
|
dataSource={treeData}
|
||||||
scroll={{ x: 1100 }}
|
scroll={{ x: 1200 }}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowKeys,
|
expandedRowKeys,
|
||||||
@@ -320,12 +344,24 @@ export default function PartnerAccountsPage() {
|
|||||||
<Form.Item label="开城合伙人">
|
<Form.Item label="开城合伙人">
|
||||||
<Input value={detail.partner?.companyName} disabled />
|
<Input value={detail.partner?.companyName} disabled />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
{detail.parentAccountId ? (
|
<Form.Item label="账号类型">
|
||||||
<Form.Item label="账号类型">
|
<Input
|
||||||
<Input
|
value={
|
||||||
value={`子账号 · ${PARTNER_STAFF_ROLE_LABELS[detail.staffRole as keyof typeof PARTNER_STAFF_ROLE_LABELS] || detail.staffRole || '—'}`}
|
detail.parentAccountId
|
||||||
disabled
|
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
||||||
/>
|
: accountTypeLabel(detail.isPrimary)
|
||||||
|
}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
{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>
|
</Form.Item>
|
||||||
) : null}
|
) : null}
|
||||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
import { toastError } from '../lib/toast';
|
import { enqueueUpload } from '../lib/upload-lock';
|
||||||
import {
|
import {
|
||||||
authorizePartnerWechat,
|
authorizePartnerWechat,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
@@ -92,13 +92,12 @@ export default function OssUploadField({
|
|||||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||||
const text = `文件不能超过 ${DEFAULT_MAX_MB}MB`;
|
const text = `文件不能超过 ${DEFAULT_MAX_MB}MB`;
|
||||||
setError(text);
|
setError(text);
|
||||||
toastError(text);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
||||||
onChange?.(result.url);
|
onChange?.(result.url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : '上传失败');
|
setError(e instanceof Error ? e.message : '上传失败');
|
||||||
@@ -116,7 +115,6 @@ export default function OssUploadField({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : '微信授权失败';
|
const text = e instanceof Error ? e.message : '微信授权失败';
|
||||||
setError(text);
|
setError(text);
|
||||||
toastError(text);
|
|
||||||
setAuthorizing(false);
|
setAuthorizing(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,16 +124,23 @@ export default function OssUploadField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickWechatImage() {
|
async function pickWechatImage() {
|
||||||
await weixinSdk.init();
|
setUploading(true);
|
||||||
const files = await weixinSdk.chooseImages({
|
setError('');
|
||||||
count: 1,
|
try {
|
||||||
sourceType: ['album', 'camera'],
|
await weixinSdk.init();
|
||||||
});
|
await enqueueUpload(async () => {
|
||||||
if (files?.[0]) {
|
const files = await weixinSdk.chooseImages({
|
||||||
await uploadSelectedFile(files[0]);
|
count: 1,
|
||||||
return;
|
sourceType: ['album', 'camera'],
|
||||||
|
});
|
||||||
|
if (!files?.[0]) {
|
||||||
|
throw new Error('未能获取图片,请重试');
|
||||||
|
}
|
||||||
|
await uploadSelectedFile(files[0]);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
}
|
}
|
||||||
throw new Error('未能获取图片,请重试');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickFile() {
|
async function pickFile() {
|
||||||
@@ -145,7 +150,6 @@ export default function OssUploadField({
|
|||||||
if (useWechatPicker && needsAuth) {
|
if (useWechatPicker && needsAuth) {
|
||||||
const text = '请先完成微信授权后再上传照片';
|
const text = '请先完成微信授权后再上传照片';
|
||||||
setError(text);
|
setError(text);
|
||||||
toastError(text);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk';
|
import { stripOAuthParamsFromLocation, toAppPath } from '@dukang/weixin-sdk';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||||
|
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
import {
|
import {
|
||||||
clearAuth,
|
clearAuth,
|
||||||
ensureSession,
|
ensureSession,
|
||||||
@@ -17,7 +18,6 @@ import {
|
|||||||
type PartnerSessionPayload,
|
type PartnerSessionPayload,
|
||||||
type PartnerSessionProfile,
|
type PartnerSessionProfile,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
|
||||||
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
import { fetchClientConfig, processPartnerWechatOAuthCallback } from '../lib/wechat-auth';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
|
|
||||||
@@ -40,6 +40,17 @@ type PartnerSessionValue = {
|
|||||||
|
|
||||||
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
const PartnerSessionContext = createContext<PartnerSessionValue | null>(null);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
@@ -49,13 +60,7 @@ export function PartnerSessionProvider({ children }: { children: ReactNode }) {
|
|||||||
saveAuth(session);
|
saveAuth(session);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
if (session.partner) {
|
if (session.partner) {
|
||||||
setAccount({
|
setAccount(accountFromProfile(session.partner));
|
||||||
id: session.partner.id,
|
|
||||||
name: session.partner.name,
|
|
||||||
phone: session.partner.phone,
|
|
||||||
companyName: session.partner.companyName,
|
|
||||||
isPrimary: session.partner.isPrimary,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PartnerMe } from '@dukang/shared-types';
|
import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types';
|
||||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||||
import { showPartnerToast } from './toast';
|
import { showPartnerToast } from './toast';
|
||||||
|
|
||||||
@@ -22,7 +22,9 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
|
|||||||
'/partner/auth/login/wechat',
|
'/partner/auth/login/wechat',
|
||||||
];
|
];
|
||||||
|
|
||||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'>;
|
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'> & {
|
||||||
|
staffRole?: PartnerStaffRole;
|
||||||
|
};
|
||||||
|
|
||||||
export type PartnerSessionPayload = {
|
export type PartnerSessionPayload = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
@@ -31,7 +33,7 @@ export type PartnerSessionPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ApiRequestOptions = RequestInit & {
|
export type ApiRequestOptions = RequestInit & {
|
||||||
/** 为 true 时不弹出 toast(由调用方自行展示) */
|
/** 保留字段,兼容旧调用;错误提示由页面自行处理 */
|
||||||
silent?: boolean;
|
silent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
|||||||
phone: me.phone,
|
phone: me.phone,
|
||||||
companyName: me.companyName,
|
companyName: me.companyName,
|
||||||
isPrimary: me.isPrimary,
|
isPrimary: me.isPrimary,
|
||||||
|
staffRole: me.staffRole ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type {
|
import {
|
||||||
CreatePartnerStaffRequest,
|
PartnerStaffRole,
|
||||||
PartnerStaffItem,
|
type CreatePartnerStaffRequest,
|
||||||
UpdatePartnerStaffRequest,
|
type PartnerStaffItem,
|
||||||
|
type UpdatePartnerStaffRequest,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from './api';
|
import { request } from './api';
|
||||||
|
|
||||||
@@ -12,7 +13,11 @@ export function listPartnerStaff() {
|
|||||||
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
export function createPartnerStaff(body: CreatePartnerStaffRequest) {
|
||||||
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
return request<PartnerStaffItem>('PARTNER_H5', '/partner/staff', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
name: body.name,
|
||||||
|
phone: body.phone,
|
||||||
|
staffRole: PartnerStaffRole.INTERNAL,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,10 +33,3 @@ export function deletePartnerStaff(id: string) {
|
|||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendStaffAddSms(phone: string) {
|
|
||||||
return request('PARTNER_H5', '/partner/auth/sms/send', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone, scene: 'PARTNER_STAFF_ADD' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
bankBranch: '',
|
bankBranch: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
function normalizeStringArray(raw: unknown, length: number): string[] {
|
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
||||||
const items = raw.map((item) => String(item ?? ''));
|
const items = raw.map((item) => String(item ?? ''));
|
||||||
while (items.length < length) items.push('');
|
while (items.length < length) items.push('');
|
||||||
@@ -138,6 +138,12 @@ export function validateStoreStep2(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
|
||||||
|
const envPhotoUrls = normalizeStringArray(urls, 3);
|
||||||
|
envPhotoUrls[index] = url;
|
||||||
|
return envPhotoUrls;
|
||||||
|
}
|
||||||
|
|
||||||
export function validateStoreStep3(
|
export function validateStoreStep3(
|
||||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** 串行化上传,避免微信 JSSDK 并发选图/读图导致卡死 */
|
||||||
|
let uploadChain: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
export function enqueueUpload<T>(task: () => Promise<T>): Promise<T> {
|
||||||
|
const next = uploadChain.then(task, task);
|
||||||
|
uploadChain = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { apiBase, request } from './api';
|
import { apiBase, request } from './api';
|
||||||
import { showPartnerToast } from './toast';
|
|
||||||
|
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||||
|
|
||||||
@@ -10,8 +11,7 @@ export type UploadFileResult = {
|
|||||||
mock: boolean;
|
mock: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
async function uploadFileToOssInner(
|
||||||
export async function uploadFileToOss(
|
|
||||||
file: File,
|
file: File,
|
||||||
options: { bizType: string; mediaType?: OssMediaType },
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
): Promise<UploadFileResult> {
|
): Promise<UploadFileResult> {
|
||||||
@@ -25,29 +25,48 @@ export async function uploadFileToOss(
|
|||||||
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_H5' };
|
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_H5' };
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
if (token) headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
const controller = new AbortController();
|
||||||
method: 'POST',
|
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||||
headers,
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.code === 401) {
|
|
||||||
localStorage.removeItem('accessToken');
|
|
||||||
showPartnerToast('未登录', 'error');
|
|
||||||
throw new Error('未登录');
|
|
||||||
}
|
|
||||||
if (json.code !== 0) {
|
|
||||||
showPartnerToast(json.message || '上传失败', 'error');
|
|
||||||
throw new Error(json.message || '上传失败');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = json.data as UploadFileResult;
|
try {
|
||||||
return {
|
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||||
url: data.url,
|
method: 'POST',
|
||||||
ossKey: data.ossKey,
|
headers,
|
||||||
bucket: data.bucket,
|
body: formData,
|
||||||
mock: data.mock,
|
signal: controller.signal,
|
||||||
};
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code === 401) {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
throw new Error('未登录');
|
||||||
|
}
|
||||||
|
if (json.code !== 0) {
|
||||||
|
throw new Error(json.message || '上传失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = json.data as UploadFileResult;
|
||||||
|
return {
|
||||||
|
url: data.url,
|
||||||
|
ossKey: data.ossKey,
|
||||||
|
bucket: data.bucket,
|
||||||
|
mock: data.mock,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||||
|
throw new Error('上传超时,请检查网络后重试');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||||
|
export function uploadFileToOss(
|
||||||
|
file: File,
|
||||||
|
options: { bizType: string; mediaType?: OssMediaType },
|
||||||
|
): Promise<UploadFileResult> {
|
||||||
|
return uploadFileToOssInner(file, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OpenCityOption = {
|
export type OpenCityOption = {
|
||||||
|
|||||||
@@ -107,6 +107,6 @@ export async function processPartnerWechatOAuthCallback(): Promise<PartnerSessio
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
/** @deprecated 使用 handlePartnerWechatLoginResult */
|
||||||
export function savePartnerWechatAuth(result: WechatLoginResult): boolean {
|
export function savePartnerWechatAuth(result: WechatLoginResult): PartnerSessionPayload | null {
|
||||||
return !!handlePartnerWechatLoginResult(result);
|
return handlePartnerWechatLoginResult(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
loginPartnerWithWechat,
|
loginPartnerWithWechat,
|
||||||
} from '../lib/wechat-auth';
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
import { isWechatEnv } from '../lib/weixin';
|
||||||
import { toastError } from '../lib/toast';
|
|
||||||
|
|
||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||||
@@ -86,9 +85,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
const text = '请先勾选并同意用户协议';
|
setMsg('请先勾选并同意用户协议');
|
||||||
setMsg(text);
|
|
||||||
toastError(text);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -129,9 +126,7 @@ export default function LoginPage() {
|
|||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = formatPartnerError(e);
|
setMsg(formatPartnerError(e));
|
||||||
setMsg(text);
|
|
||||||
toastError(text);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +155,7 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
await finishLoginNavigate();
|
await finishLoginNavigate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = formatPartnerError(e);
|
setMsg(formatPartnerError(e));
|
||||||
setMsg(text);
|
|
||||||
toastError(text);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -173,7 +166,6 @@ export default function LoginPage() {
|
|||||||
setMsg('');
|
setMsg('');
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||||
toastError(WECHAT_INAPP_REQUIRED_MSG);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWxLoading(true);
|
setWxLoading(true);
|
||||||
@@ -184,9 +176,7 @@ export default function LoginPage() {
|
|||||||
await finishLoginNavigate();
|
await finishLoginNavigate();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = formatWechatError(e);
|
setMsg(formatWechatError(e));
|
||||||
setMsg(text);
|
|
||||||
toastError(text);
|
|
||||||
} finally {
|
} finally {
|
||||||
setWxLoading(false);
|
setWxLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +1,141 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole } from '@dukang/shared-types';
|
import { createPartnerStaff } from '../lib/staff';
|
||||||
import { createPartnerStaff, sendStaffAddSms } from '../lib/staff';
|
|
||||||
import { toastError, toastSuccess } from '../lib/toast';
|
import { toastError, toastSuccess } from '../lib/toast';
|
||||||
|
|
||||||
const ROLE_OPTIONS = Object.values(PartnerStaffRole);
|
type FieldErrors = {
|
||||||
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function StaffCreatePage() {
|
export default function StaffCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [code, setCode] = useState('');
|
|
||||||
const [staffRole, setStaffRole] = useState<PartnerStaffRole>(PartnerStaffRole.INTERNAL);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [submitError, setSubmitError] = useState('');
|
||||||
|
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||||
|
|
||||||
async function sendCode() {
|
function patchName(value: string) {
|
||||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
setName(value);
|
||||||
const text = '请输入正确的11位手机号';
|
setSubmitError('');
|
||||||
setMsg(text);
|
setFieldErrors((prev) => ({ ...prev, name: undefined }));
|
||||||
toastError(text);
|
}
|
||||||
return;
|
|
||||||
}
|
function patchPhone(value: string) {
|
||||||
setSending(true);
|
setPhone(value);
|
||||||
setMsg('');
|
setSubmitError('');
|
||||||
try {
|
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||||
await sendStaffAddSms(phone.trim());
|
|
||||||
setMsg('验证码已发送');
|
|
||||||
} catch {
|
|
||||||
/* request 已 toast */
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
if (!name.trim()) {
|
const trimmedName = name.trim();
|
||||||
const text = '请填写真实姓名';
|
const trimmedPhone = phone.trim();
|
||||||
setMsg(text);
|
const nextFieldErrors: FieldErrors = {};
|
||||||
toastError(text);
|
|
||||||
return;
|
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
|
||||||
}
|
if (!trimmedPhone) {
|
||||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
nextFieldErrors.phone = '请填写手机号码';
|
||||||
const text = '请输入正确的11位手机号';
|
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||||
setMsg(text);
|
nextFieldErrors.phone = '请输入正确的11位手机号';
|
||||||
toastError(text);
|
}
|
||||||
return;
|
|
||||||
}
|
if (Object.keys(nextFieldErrors).length) {
|
||||||
if (!code.trim()) {
|
setFieldErrors(nextFieldErrors);
|
||||||
const text = '请输入验证码';
|
|
||||||
setMsg(text);
|
|
||||||
toastError(text);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setMsg('');
|
setSubmitError('');
|
||||||
|
setFieldErrors({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await createPartnerStaff({
|
await createPartnerStaff({
|
||||||
name: name.trim(),
|
name: trimmedName,
|
||||||
phone: phone.trim(),
|
phone: trimmedPhone,
|
||||||
staffRole,
|
|
||||||
code: code.trim(),
|
|
||||||
});
|
});
|
||||||
toastSuccess('子账号创建成功');
|
toastSuccess('子账号创建成功');
|
||||||
navigate('/center/staff');
|
navigate('/center/staff');
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
const message = e instanceof Error ? e.message : '创建失败';
|
||||||
|
if (message.includes('手机号')) {
|
||||||
|
setFieldErrors({ phone: message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitError(message);
|
||||||
|
toastError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-no-tab partner-staff-create-page">
|
<div className="partner-page-sticky">
|
||||||
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
||||||
|
|
||||||
<main style={{ padding: '16px 20px 120px' }}>
|
{submitError && (
|
||||||
{msg && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{msg}</p>}
|
<p className="partner-form-error" role="alert">{submitError}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
<label className="partner-form-label">真实姓名 *</label>
|
<div className="partner-section-title">
|
||||||
<div className="partner-form-input-wrap">
|
<div className="partner-section-bar" />
|
||||||
|
<h2 className="headline-md">子账号信息</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="partner-field">
|
||||||
|
<label>真实姓名 <span className="text-primary">*</span></label>
|
||||||
|
<div className="partner-field-input">
|
||||||
|
<span className="material-symbols-outlined">person</span>
|
||||||
<input
|
<input
|
||||||
className="partner-form-input"
|
|
||||||
placeholder="请输入员工姓名"
|
placeholder="请输入员工姓名"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => patchName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<span className="material-symbols-outlined">person</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{fieldErrors.name && (
|
||||||
|
<p className="partner-field-error" role="alert">{fieldErrors.name}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="partner-form-card">
|
<div className="partner-field">
|
||||||
<label className="partner-form-label">手机号码 *</label>
|
<label>手机号码 <span className="text-primary">*</span></label>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="partner-field-input">
|
||||||
|
<span className="material-symbols-outlined">call</span>
|
||||||
<input
|
<input
|
||||||
className="partner-form-input"
|
type="tel"
|
||||||
placeholder="请输入11位手机号"
|
placeholder="请输入11位手机号"
|
||||||
value={phone}
|
value={phone}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
onChange={(e) => patchPhone(e.target.value)}
|
||||||
inputMode="numeric"
|
|
||||||
/>
|
/>
|
||||||
<button type="button" className="btn btn-outline" disabled={sending} onClick={() => void sendCode()}>
|
|
||||||
{sending ? '发送中' : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
{fieldErrors.phone && (
|
||||||
className="partner-form-input"
|
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||||
style={{ marginTop: 8 }}
|
)}
|
||||||
placeholder="验证码"
|
|
||||||
value={code}
|
|
||||||
onChange={(e) => setCode(e.target.value)}
|
|
||||||
inputMode="numeric"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="partner-form-card">
|
<div className="partner-info-banner">
|
||||||
<label className="partner-form-label">分配角色 *</label>
|
<div className="partner-bills-icon">
|
||||||
<div className="partner-form-input-wrap">
|
<span className="material-symbols-outlined">info</span>
|
||||||
<select
|
|
||||||
className="partner-form-input"
|
|
||||||
value={staffRole}
|
|
||||||
onChange={(e) => setStaffRole(e.target.value as PartnerStaffRole)}
|
|
||||||
>
|
|
||||||
{ROLE_OPTIONS.map((role) => (
|
|
||||||
<option key={role} value={role}>{PARTNER_STAFF_ROLE_LABELS[role]}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="material-symbols-outlined">expand_more</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="label-md text-variant" style={{ marginTop: 0, lineHeight: 1.5 }}>
|
||||||
<p className="label-md text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
子账号创建后默认禁用,需在子账号列表中手动启用后方可登录。
|
||||||
创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。
|
|
||||||
</p>
|
</p>
|
||||||
</main>
|
|
||||||
|
|
||||||
<div className="partner-sticky-footer">
|
|
||||||
<button type="button" className="partner-btn-primary" disabled={submitting} onClick={() => void submit()}>
|
|
||||||
<span className="material-symbols-outlined">person_add</span>
|
|
||||||
{submitting ? '创建中...' : '确认创建'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer className="partner-sticky-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="partner-btn-primary"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>person_add</span>
|
||||||
|
{submitting ? '创建中…' : '确认创建'}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
type PartnerStaffItem,
|
type PartnerStaffItem,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
||||||
import { toastError } from '../lib/toast';
|
|
||||||
|
|
||||||
export default function StaffListPage() {
|
export default function StaffListPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -18,8 +17,8 @@ export default function StaffListPage() {
|
|||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadStaff = useCallback(() => {
|
const loadStaff = useCallback(() => {
|
||||||
return listPartnerStaff().then(setStaff).catch(() => {
|
return listPartnerStaff().then(setStaff).catch((e) => {
|
||||||
/* request 已 toast */
|
setError(e instanceof Error ? e.message : '加载失败');
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -40,8 +39,8 @@ export default function StaffListPage() {
|
|||||||
try {
|
try {
|
||||||
await updatePartnerStaff(item.id, { status: next });
|
await updatePartnerStaff(item.id, { status: next });
|
||||||
await loadStaff();
|
await loadStaff();
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
setError(e instanceof Error ? e.message : '操作失败');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -55,8 +54,8 @@ export default function StaffListPage() {
|
|||||||
try {
|
try {
|
||||||
await deletePartnerStaff(item.id);
|
await deletePartnerStaff(item.id);
|
||||||
await loadStaff();
|
await loadStaff();
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
setError(e instanceof Error ? e.message : '删除失败');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -71,7 +70,7 @@ export default function StaffListPage() {
|
|||||||
if (roleInput == null) return;
|
if (roleInput == null) return;
|
||||||
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
|
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
|
||||||
if (!staffRole) {
|
if (!staffRole) {
|
||||||
toastError('无效的角色');
|
setError('无效的角色');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBusyId(item.id);
|
setBusyId(item.id);
|
||||||
@@ -79,8 +78,8 @@ export default function StaffListPage() {
|
|||||||
try {
|
try {
|
||||||
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
|
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
|
||||||
await loadStaff();
|
await loadStaff();
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
setError(e instanceof Error ? e.message : '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
|
|
||||||
validateStoreStep3,
|
validateStoreStep3,
|
||||||
|
|
||||||
|
patchEnvPhotoAt,
|
||||||
|
|
||||||
} from '../lib/storeDraft';
|
} from '../lib/storeDraft';
|
||||||
|
|
||||||
|
|
||||||
@@ -63,6 +65,14 @@ function isPhoneConflictMessage(message: string) {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function isPhoneValidationMessage(message: string) {
|
||||||
|
|
||||||
|
return message.includes('联系电话') || message.includes('手机号');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function StoreCreatePage() {
|
export default function StoreCreatePage() {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -93,7 +103,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
function reportFormError(message: string) {
|
function reportFormError(message: string) {
|
||||||
setSubmitError(message);
|
setSubmitError(message);
|
||||||
toastError(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const stepFromUrl = Number(params.get('step') || 0);
|
const stepFromUrl = Number(params.get('step') || 0);
|
||||||
@@ -153,7 +162,6 @@ export default function StoreCreatePage() {
|
|||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
const msg = e instanceof Error ? e.message : '加载开城城市失败';
|
const msg = e instanceof Error ? e.message : '加载开城城市失败';
|
||||||
setCitiesError(msg);
|
setCitiesError(msg);
|
||||||
toastError(msg);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
@@ -196,6 +204,22 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function patchEnvPhotoUrl(index: number, url: string) {
|
||||||
|
|
||||||
|
setForm((prev) => ({
|
||||||
|
|
||||||
|
...prev,
|
||||||
|
|
||||||
|
envPhotoUrls: patchEnvPhotoAt(prev.envPhotoUrls, index, url),
|
||||||
|
|
||||||
|
}));
|
||||||
|
|
||||||
|
setSubmitError('');
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function bindRegionSelection(codes: string[]) {
|
function bindRegionSelection(codes: string[]) {
|
||||||
|
|
||||||
const binding = resolveRegionBinding(codes, cities);
|
const binding = resolveRegionBinding(codes, cities);
|
||||||
@@ -260,11 +284,11 @@ export default function StoreCreatePage() {
|
|||||||
if (!phoneCheck.available) {
|
if (!phoneCheck.available) {
|
||||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
||||||
setFieldErrors({ phone: phoneMsg });
|
setFieldErrors({ phone: phoneMsg });
|
||||||
toastError(phoneMsg);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||||
return;
|
return;
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
@@ -304,6 +328,10 @@ export default function StoreCreatePage() {
|
|||||||
const step1Msg = validateStoreStep1(form);
|
const step1Msg = validateStoreStep1(form);
|
||||||
|
|
||||||
if (step1Msg) {
|
if (step1Msg) {
|
||||||
|
if (isPhoneValidationMessage(step1Msg)) {
|
||||||
|
setFieldErrors({ phone: step1Msg });
|
||||||
|
return;
|
||||||
|
}
|
||||||
reportFormError(step1Msg);
|
reportFormError(step1Msg);
|
||||||
goStep(1);
|
goStep(1);
|
||||||
return;
|
return;
|
||||||
@@ -341,13 +369,16 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
goStep(1);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
||||||
|
|
||||||
@@ -396,11 +427,10 @@ export default function StoreCreatePage() {
|
|||||||
const message = e instanceof Error ? e.message : '提交失败';
|
const message = e instanceof Error ? e.message : '提交失败';
|
||||||
if (isPhoneConflictMessage(message)) {
|
if (isPhoneConflictMessage(message)) {
|
||||||
setFieldErrors({ phone: message });
|
setFieldErrors({ phone: message });
|
||||||
toastError(message);
|
|
||||||
goStep(1);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSubmitError(message);
|
setSubmitError(message);
|
||||||
|
toastError(message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -677,15 +707,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
onWechatReadyChange={() => { void refresh(); }}
|
onWechatReadyChange={() => { void refresh(); }}
|
||||||
|
|
||||||
onChange={(nextUrl) => {
|
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||||
|
|
||||||
const envPhotoUrls = [...form.envPhotoUrls];
|
|
||||||
|
|
||||||
envPhotoUrls[index] = nextUrl;
|
|
||||||
|
|
||||||
patchForm({ envPhotoUrls });
|
|
||||||
|
|
||||||
}}
|
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -757,22 +779,44 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="partner-form-card partner-store-phone-summary">
|
<section className="partner-form-card">
|
||||||
|
|
||||||
<div className="partner-store-phone-summary-main">
|
<div className="partner-field">
|
||||||
|
|
||||||
<span className="label-md text-muted">门店登录手机号</span>
|
<label>门店登录手机号 <span className="text-primary">*</span></label>
|
||||||
|
|
||||||
<span className="body-md">{form.phone.trim() || '未填写'}</span>
|
<div className="partner-field-input">
|
||||||
|
|
||||||
|
<span className="material-symbols-outlined">call</span>
|
||||||
|
|
||||||
|
<input
|
||||||
|
|
||||||
|
type="tel"
|
||||||
|
|
||||||
|
placeholder="请输入11位手机号"
|
||||||
|
|
||||||
|
value={form.phone}
|
||||||
|
|
||||||
|
onChange={(e) => patchForm({ phone: e.target.value })}
|
||||||
|
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fieldErrors.phone && (
|
||||||
|
|
||||||
|
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||||
|
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||||
|
|
||||||
|
该手机号将作为门店端登录账号,提交前会再次校验是否已被占用。
|
||||||
|
|
||||||
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" className="partner-weekly-link" onClick={() => goStep(1)}>
|
|
||||||
|
|
||||||
修改
|
|
||||||
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="partner-form-card">
|
<section className="partner-form-card">
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function StoreDetailPage() {
|
|||||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||||
const [statusSaving, setStatusSaving] = useState(false);
|
const [statusSaving, setStatusSaving] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState('');
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -52,6 +53,7 @@ export default function StoreDetailPage() {
|
|||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
setStatusSaving(true);
|
setStatusSaving(true);
|
||||||
|
setActionError('');
|
||||||
try {
|
try {
|
||||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -60,8 +62,8 @@ export default function StoreDetailPage() {
|
|||||||
setStatus(next);
|
setStatus(next);
|
||||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||||
toastSuccess('状态已更新');
|
toastSuccess('状态已更新');
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||||
} finally {
|
} finally {
|
||||||
setStatusSaving(false);
|
setStatusSaving(false);
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export default function StoreDetailPage() {
|
|||||||
async function saveBasic() {
|
async function saveBasic() {
|
||||||
if (!id || saving || status === 'CLOSED') return;
|
if (!id || saving || status === 'CLOSED') return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
|
setActionError('');
|
||||||
try {
|
try {
|
||||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -82,8 +85,8 @@ export default function StoreDetailPage() {
|
|||||||
});
|
});
|
||||||
applyStore(data);
|
applyStore(data);
|
||||||
toastSuccess('已保存');
|
toastSuccess('已保存');
|
||||||
} catch {
|
} catch (e) {
|
||||||
/* request 已 toast */
|
setActionError(e instanceof Error ? e.message : '保存失败');
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,7 @@ export default function StoreDetailPage() {
|
|||||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||||
|
|
||||||
<main style={{ padding: '16px 20px' }}>
|
<main style={{ padding: '16px 20px' }}>
|
||||||
|
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||||
{!subReadonly && (
|
{!subReadonly && (
|
||||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ export interface PartnerStaffItem {
|
|||||||
export interface CreatePartnerStaffRequest {
|
export interface CreatePartnerStaffRequest {
|
||||||
phone: string;
|
phone: string;
|
||||||
name: string;
|
name: string;
|
||||||
staffRole: PartnerStaffRole;
|
|
||||||
code: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatePartnerStaffRequest {
|
export interface UpdatePartnerStaffRequest {
|
||||||
|
|||||||
@@ -55,7 +55,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
if (!login.accessToken) throw new Error('login missing token');
|
if (!login.accessToken) throw new Error('login missing token');
|
||||||
|
|
||||||
console.log('4. Admin partner logs list');
|
console.log('4. PARTNER /partner/me after login');
|
||||||
|
const me = await req('PARTNER_H5', '/partner/me', { token: login.accessToken });
|
||||||
|
if (!me?.id || !me?.phone) throw new Error('partner/me missing profile');
|
||||||
|
|
||||||
|
console.log('5. Admin partner logs list');
|
||||||
const admin = await req('HQ_WEB', '/admin/auth/login/password', {
|
const admin = await req('HQ_WEB', '/admin/auth/login/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ export class CreatePartnerStaffDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
/** 未传时服务端默认 INTERNAL */
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(Object.values(PartnerStaffRole))
|
@IsIn(Object.values(PartnerStaffRole))
|
||||||
staffRole: string;
|
staffRole?: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
code: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdatePartnerStaffDto {
|
export class UpdatePartnerStaffDto {
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PartnerStaffRole, SmsScene } from '@dukang/shared-types';
|
import { PartnerStaffRole } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { AuthService } from './auth.service';
|
|
||||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PartnerStaffService {
|
export class PartnerStaffService {
|
||||||
constructor(
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly authService: AuthService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async listStaff(parentAccountId: bigint) {
|
async listStaff(parentAccountId: bigint) {
|
||||||
const rows = await this.prisma.partnerAccount.findMany({
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
@@ -40,8 +36,6 @@ export class PartnerStaffService {
|
|||||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||||
|
|
||||||
await this.authService.verifySmsCode(phone, dto.code, SmsScene.PARTNER_STAFF_ADD);
|
|
||||||
|
|
||||||
const name = dto.name.trim();
|
const name = dto.name.trim();
|
||||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||||
|
|
||||||
@@ -50,7 +44,7 @@ export class PartnerStaffService {
|
|||||||
partnerId: parent.partnerId,
|
partnerId: parent.partnerId,
|
||||||
phone,
|
phone,
|
||||||
name,
|
name,
|
||||||
staffRole: dto.staffRole as PartnerStaffRole,
|
staffRole: (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL,
|
||||||
isPrimary: 0,
|
isPrimary: 0,
|
||||||
parentAccountId: parent.id,
|
parentAccountId: parent.id,
|
||||||
status: 'DISABLED',
|
status: 'DISABLED',
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ export class AdminPartnersService {
|
|||||||
if (query.phone) where.phone = { contains: query.phone };
|
if (query.phone) where.phone = { contains: query.phone };
|
||||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||||
|
if (query.isPrimary === '0' || query.isPrimary === '1') {
|
||||||
|
where.isPrimary = Number(query.isPrimary);
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.partnerAccount.findMany({
|
this.prisma.partnerAccount.findMany({
|
||||||
@@ -87,6 +90,7 @@ export class AdminPartnersService {
|
|||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
partner: { select: { id: true, companyName: true } },
|
partner: { select: { id: true, companyName: true } },
|
||||||
|
parent: { select: { id: true, name: true, phone: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
this.prisma.partnerAccount.count({ where }),
|
this.prisma.partnerAccount.count({ where }),
|
||||||
@@ -152,6 +156,7 @@ export class AdminPartnersService {
|
|||||||
address: true,
|
address: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
parent: { select: { id: true, name: true, phone: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!account) throw new NotFoundException('开城合伙人账号不存在');
|
if (!account) throw new NotFoundException('开城合伙人账号不存在');
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
isPrimary?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminBenefitCouponsQueryDto extends PaginationQueryDto {
|
export class AdminBenefitCouponsQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
Reference in New Issue
Block a user