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:
2026-07-10 11:24:48 +08:00
20 changed files with 378 additions and 264 deletions
@@ -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,
});
} }
}, []); }, []);
+6 -3
View File
@@ -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,
}; };
} }
+10 -12
View File
@@ -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' }),
});
}
+7 -1
View File
@@ -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 {
+11
View File
@@ -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;
}
+44 -25
View File
@@ -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 = {
+2 -2
View File
@@ -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);
} }
+4 -14
View File
@@ -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);
} }
+89 -97
View File
@@ -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>
); );
} }
+9 -10
View File
@@ -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);
} }
+71 -27
View File
@@ -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 }}>
-2
View File
@@ -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 {
+5 -1
View File
@@ -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 {