fix;提交代码子账号
This commit is contained in:
@@ -3,12 +3,23 @@ import {
|
||||
Button, Drawer, Form, Input, Modal, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type ParentAccount = { id: string; name: string; phone: string };
|
||||
|
||||
type Row = {
|
||||
id: string; phone: string; name: string; status: string; isPrimary: number; createdAt: string;
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
status: string;
|
||||
isPrimary: number;
|
||||
staffRole?: string | null;
|
||||
parentAccountId?: string | null;
|
||||
parent?: ParentAccount | null;
|
||||
createdAt: string;
|
||||
partner?: { id: string; companyName: string };
|
||||
};
|
||||
|
||||
@@ -26,6 +37,21 @@ type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
type Detail = Row & { bills?: BillRow[]; orders?: OrderRow[] };
|
||||
|
||||
const ACCOUNT_TYPE_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '主账号' },
|
||||
{ value: '0', label: '子账号' },
|
||||
];
|
||||
|
||||
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() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
@@ -37,6 +63,7 @@ export default function PartnerAccountsPage() {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.isPrimary) qs.set('isPrimary', filters.isPrimary);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
@@ -71,7 +98,27 @@ export default function PartnerAccountsPage() {
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140 },
|
||||
{ title: '主账号', dataIndex: 'isPrimary', width: 80, render: (v) => (v === 1 ? '是' : '否') },
|
||||
{
|
||||
title: '账号类型',
|
||||
dataIndex: 'isPrimary',
|
||||
width: 90,
|
||||
render: (v) => <Tag color={v === 1 ? 'blue' : 'default'}>{accountTypeLabel(v)}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 100,
|
||||
render: (role) => staffRoleLabel(role),
|
||||
},
|
||||
{
|
||||
title: '所属主账号',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
row.isPrimary === 1
|
||||
? '-'
|
||||
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
@@ -113,9 +160,12 @@ export default function PartnerAccountsPage() {
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isPrimary" label="账号类型">
|
||||
<Select allowClear style={{ width: 110 }} options={ACCOUNT_TYPE_OPTIONS.filter((item) => item.value !== '')} />
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer
|
||||
title="编辑开城合伙人账户"
|
||||
@@ -134,6 +184,20 @@ export default function PartnerAccountsPage() {
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号类型">
|
||||
<Input value={accountTypeLabel(detail.isPrimary)} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="角色">
|
||||
<Input value={staffRoleLabel(detail.staffRole)} disabled />
|
||||
</Form.Item>
|
||||
{detail.isPrimary !== 1 && (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input
|
||||
value={detail.parent ? `${detail.parent.name} / ${detail.parent.phone}` : '-'}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||
import { toastError } from '../lib/toast';
|
||||
import { enqueueUpload } from '../lib/upload-lock';
|
||||
import {
|
||||
authorizePartnerWechat,
|
||||
fetchClientConfig,
|
||||
@@ -87,13 +87,12 @@ export default function OssUploadField({
|
||||
if (file.size > DEFAULT_MAX_MB * 1024 * 1024) {
|
||||
const text = `文件不能超过 ${DEFAULT_MAX_MB}MB`;
|
||||
setError(text);
|
||||
toastError(text);
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
const result = await uploadFileToOss(file, { bizType, mediaType });
|
||||
const result = await enqueueUpload(() => uploadFileToOss(file, { bizType, mediaType }));
|
||||
onChange?.(result.url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '上传失败');
|
||||
@@ -111,7 +110,6 @@ export default function OssUploadField({
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '微信授权失败';
|
||||
setError(text);
|
||||
toastError(text);
|
||||
setAuthorizing(false);
|
||||
}
|
||||
}
|
||||
@@ -121,15 +119,23 @@ export default function OssUploadField({
|
||||
}
|
||||
|
||||
async function pickWechatImage() {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await uploadSelectedFile(files[0]);
|
||||
return;
|
||||
setUploading(true);
|
||||
setError('');
|
||||
try {
|
||||
await enqueueUpload(async () => {
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (!files?.[0]) {
|
||||
throw new Error('未能获取图片,请重试');
|
||||
}
|
||||
const result = await uploadFileToOss(files[0], { bizType, mediaType });
|
||||
onChange?.(result.url);
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
throw new Error('未能获取图片,请重试');
|
||||
}
|
||||
|
||||
async function pickFile() {
|
||||
@@ -139,7 +145,6 @@ export default function OssUploadField({
|
||||
if (useWechatPicker && needsAuth) {
|
||||
const text = '请先完成微信授权后再上传照片';
|
||||
setError(text);
|
||||
toastError(text);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { PartnerMe } from '@dukang/shared-types';
|
||||
import { isOnAppPath, toAppPath } from '@dukang/weixin-sdk';
|
||||
import { showPartnerToast } from './toast';
|
||||
|
||||
export const apiBase = '/api/v1';
|
||||
|
||||
@@ -15,7 +14,7 @@ export type PartnerAuthPayload = {
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = RequestInit & {
|
||||
/** 为 true 时不弹出 toast(由调用方自行展示) */
|
||||
/** 保留字段,兼容旧调用;错误提示由页面自行处理 */
|
||||
silent?: boolean;
|
||||
};
|
||||
|
||||
@@ -52,7 +51,6 @@ export async function request<T>(
|
||||
if (res.status === 401 || json.code === 401) {
|
||||
if (token && localStorage.getItem('accessToken') === token) {
|
||||
clearAuth();
|
||||
if (!silent) showPartnerToast(message, 'error');
|
||||
if (typeof window !== 'undefined' && !isOnAppPath('/login')) {
|
||||
window.location.href = toAppPath('/login');
|
||||
}
|
||||
@@ -60,7 +58,6 @@ export async function request<T>(
|
||||
throw new Error(message);
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
if (!silent) showPartnerToast(message, 'error');
|
||||
throw new Error(message);
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -28,10 +28,3 @@ export function deletePartnerStaff(id: string) {
|
||||
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' }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
||||
bankBranch: '',
|
||||
});
|
||||
|
||||
function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||
export function normalizeStringArray(raw: unknown, length: number): string[] {
|
||||
if (!Array.isArray(raw)) return Array.from({ length }, () => '');
|
||||
const items = raw.map((item) => String(item ?? ''));
|
||||
while (items.length < length) items.push('');
|
||||
@@ -126,6 +126,12 @@ export function validateStoreStep2(
|
||||
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(
|
||||
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
|
||||
): 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 { showPartnerToast } from './toast';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type OssMediaType = 'IMAGE' | 'VIDEO' | 'FILE';
|
||||
|
||||
@@ -10,8 +11,7 @@ export type UploadFileResult = {
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
/** 经 API 服务端转存 OSS,避免浏览器直传跨域 */
|
||||
export async function uploadFileToOss(
|
||||
async function uploadFileToOssInner(
|
||||
file: File,
|
||||
options: { bizType: string; mediaType?: OssMediaType },
|
||||
): Promise<UploadFileResult> {
|
||||
@@ -25,29 +25,48 @@ export async function uploadFileToOss(
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'PARTNER_H5' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
showPartnerToast('未登录', 'error');
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
showPartnerToast(json.message || '上传失败', 'error');
|
||||
throw new Error(json.message || '上传失败');
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
|
||||
const data = json.data as UploadFileResult;
|
||||
return {
|
||||
url: data.url,
|
||||
ossKey: data.ossKey,
|
||||
bucket: data.bucket,
|
||||
mock: data.mock,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
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 = {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { isWechatEnv } from '../lib/weixin';
|
||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||
@@ -93,9 +92,7 @@ export default function LoginPage() {
|
||||
|
||||
function ensureAgreed() {
|
||||
if (!agreed) {
|
||||
const text = '请先勾选并同意用户协议';
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
setMsg('请先勾选并同意用户协议');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -136,9 +133,7 @@ export default function LoginPage() {
|
||||
});
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
const text = formatPartnerError(e);
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
setMsg(formatPartnerError(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,9 +160,7 @@ export default function LoginPage() {
|
||||
const account = await refresh();
|
||||
navigate(partnerHomePath(account ?? data.partner));
|
||||
} catch (e) {
|
||||
const text = formatPartnerError(e);
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
setMsg(formatPartnerError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -178,7 +171,6 @@ export default function LoginPage() {
|
||||
setMsg('');
|
||||
if (!isWechatEnv()) {
|
||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
||||
toastError(WECHAT_INAPP_REQUIRED_MSG);
|
||||
return;
|
||||
}
|
||||
setWxLoading(true);
|
||||
@@ -189,9 +181,7 @@ export default function LoginPage() {
|
||||
navigate(partnerHomePath(account ?? undefined));
|
||||
}
|
||||
} catch (e) {
|
||||
const text = formatWechatError(e);
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
setMsg(formatWechatError(e));
|
||||
} finally {
|
||||
setWxLoading(false);
|
||||
}
|
||||
|
||||
@@ -1,149 +1,141 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
import { PARTNER_STAFF_ROLE_LABELS, PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { createPartnerStaff, sendStaffAddSms } from '../lib/staff';
|
||||
import { createPartnerStaff } from '../lib/staff';
|
||||
import { toastError, toastSuccess } from '../lib/toast';
|
||||
|
||||
const ROLE_OPTIONS = Object.values(PartnerStaffRole);
|
||||
type FieldErrors = {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
export default function StaffCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
const [name, setName] = 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 [msg, setMsg] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
async function sendCode() {
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||
const text = '请输入正确的11位手机号';
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
return;
|
||||
}
|
||||
setSending(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await sendStaffAddSms(phone.trim());
|
||||
setMsg('验证码已发送');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
function patchName(value: string) {
|
||||
setName(value);
|
||||
setSubmitError('');
|
||||
setFieldErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}
|
||||
|
||||
function patchPhone(value: string) {
|
||||
setPhone(value);
|
||||
setSubmitError('');
|
||||
setFieldErrors((prev) => ({ ...prev, phone: undefined }));
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) {
|
||||
const text = '请填写真实姓名';
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
return;
|
||||
}
|
||||
if (!/^1[3-9]\d{9}$/.test(phone.trim())) {
|
||||
const text = '请输入正确的11位手机号';
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
return;
|
||||
}
|
||||
if (!code.trim()) {
|
||||
const text = '请输入验证码';
|
||||
setMsg(text);
|
||||
toastError(text);
|
||||
const trimmedName = name.trim();
|
||||
const trimmedPhone = phone.trim();
|
||||
const nextFieldErrors: FieldErrors = {};
|
||||
|
||||
if (!trimmedName) nextFieldErrors.name = '请填写真实姓名';
|
||||
if (!trimmedPhone) {
|
||||
nextFieldErrors.phone = '请填写手机号码';
|
||||
} else if (!/^1[3-9]\d{9}$/.test(trimmedPhone)) {
|
||||
nextFieldErrors.phone = '请输入正确的11位手机号';
|
||||
}
|
||||
|
||||
if (Object.keys(nextFieldErrors).length) {
|
||||
setFieldErrors(nextFieldErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
setSubmitError('');
|
||||
setFieldErrors({});
|
||||
|
||||
try {
|
||||
await createPartnerStaff({
|
||||
name: name.trim(),
|
||||
phone: phone.trim(),
|
||||
staffRole,
|
||||
code: code.trim(),
|
||||
name: trimmedName,
|
||||
phone: trimmedPhone,
|
||||
});
|
||||
toastSuccess('子账号创建成功');
|
||||
navigate('/center/staff');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : '创建失败';
|
||||
if (message.includes('手机号')) {
|
||||
setFieldErrors({ phone: message });
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
toastError(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-no-tab partner-staff-create-page">
|
||||
<div className="partner-page-sticky">
|
||||
<PageHeader title="添加子账号" onBack={() => navigate('/center/staff')} />
|
||||
|
||||
<main style={{ padding: '16px 20px 120px' }}>
|
||||
{msg && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{msg}</p>}
|
||||
{submitError && (
|
||||
<p className="partner-form-error" role="alert">{submitError}</p>
|
||||
)}
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">真实姓名 *</label>
|
||||
<div className="partner-form-input-wrap">
|
||||
<section className="partner-form-card">
|
||||
<div className="partner-section-title">
|
||||
<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
|
||||
className="partner-form-input"
|
||||
placeholder="请输入员工姓名"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => patchName(e.target.value)}
|
||||
/>
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
</div>
|
||||
{fieldErrors.name && (
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">手机号码 *</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div className="partner-field">
|
||||
<label>手机号码 <span className="text-primary">*</span></label>
|
||||
<div className="partner-field-input">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<input
|
||||
className="partner-form-input"
|
||||
type="tel"
|
||||
placeholder="请输入11位手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
inputMode="numeric"
|
||||
onChange={(e) => patchPhone(e.target.value)}
|
||||
/>
|
||||
<button type="button" className="btn btn-outline" disabled={sending} onClick={() => void sendCode()}>
|
||||
{sending ? '发送中' : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
className="partner-form-input"
|
||||
style={{ marginTop: 8 }}
|
||||
placeholder="验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
inputMode="numeric"
|
||||
/>
|
||||
{fieldErrors.phone && (
|
||||
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="partner-form-card">
|
||||
<label className="partner-form-label">分配角色 *</label>
|
||||
<div className="partner-form-input-wrap">
|
||||
<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 className="partner-info-banner">
|
||||
<div className="partner-bills-icon">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
</div>
|
||||
|
||||
<p className="label-md text-muted" style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。
|
||||
<p className="label-md text-variant" style={{ marginTop: 0, lineHeight: 1.5 }}>
|
||||
子账号创建后默认禁用,需在子账号列表中手动启用后方可登录。
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
type PartnerStaffItem,
|
||||
} from '@dukang/shared-types';
|
||||
import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff';
|
||||
import { toastError } from '../lib/toast';
|
||||
|
||||
export default function StaffListPage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -18,8 +17,8 @@ export default function StaffListPage() {
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
|
||||
const loadStaff = useCallback(() => {
|
||||
return listPartnerStaff().then(setStaff).catch(() => {
|
||||
/* request 已 toast */
|
||||
return listPartnerStaff().then(setStaff).catch((e) => {
|
||||
setError(e instanceof Error ? e.message : '加载失败');
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -40,8 +39,8 @@ export default function StaffListPage() {
|
||||
try {
|
||||
await updatePartnerStaff(item.id, { status: next });
|
||||
await loadStaff();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -55,8 +54,8 @@ export default function StaffListPage() {
|
||||
try {
|
||||
await deletePartnerStaff(item.id);
|
||||
await loadStaff();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '删除失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -71,7 +70,7 @@ export default function StaffListPage() {
|
||||
if (roleInput == null) return;
|
||||
const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput);
|
||||
if (!staffRole) {
|
||||
toastError('无效的角色');
|
||||
setError('无效的角色');
|
||||
return;
|
||||
}
|
||||
setBusyId(item.id);
|
||||
@@ -79,8 +78,8 @@ export default function StaffListPage() {
|
||||
try {
|
||||
await updatePartnerStaff(item.id, { name: name.trim(), staffRole });
|
||||
await loadStaff();
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
|
||||
validateStoreStep3,
|
||||
|
||||
patchEnvPhotoAt,
|
||||
|
||||
} from '../lib/storeDraft';
|
||||
|
||||
|
||||
@@ -73,6 +75,14 @@ function isPhoneConflictMessage(message: string) {
|
||||
|
||||
|
||||
|
||||
function isPhoneValidationMessage(message: string) {
|
||||
|
||||
return message.includes('联系电话') || message.includes('手机号');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default function StoreCreatePage() {
|
||||
|
||||
const navigate = useNavigate();
|
||||
@@ -99,7 +109,6 @@ export default function StoreCreatePage() {
|
||||
|
||||
function reportFormError(message: string) {
|
||||
setSubmitError(message);
|
||||
toastError(message);
|
||||
}
|
||||
|
||||
const stepFromUrl = Number(params.get('step') || 0);
|
||||
@@ -201,7 +210,6 @@ export default function StoreCreatePage() {
|
||||
.catch((e) => {
|
||||
const msg = e instanceof Error ? e.message : '加载开城城市失败';
|
||||
setCitiesError(msg);
|
||||
toastError(msg);
|
||||
});
|
||||
|
||||
}, []);
|
||||
@@ -244,6 +252,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[]) {
|
||||
|
||||
const binding = resolveRegionBinding(codes, cities);
|
||||
@@ -308,11 +332,11 @@ export default function StoreCreatePage() {
|
||||
if (!phoneCheck.available) {
|
||||
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
|
||||
setFieldErrors({ phone: phoneMsg });
|
||||
toastError(phoneMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
} catch {
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
|
||||
} finally {
|
||||
@@ -352,6 +376,10 @@ export default function StoreCreatePage() {
|
||||
const step1Msg = validateStoreStep1(form);
|
||||
|
||||
if (step1Msg) {
|
||||
if (isPhoneValidationMessage(step1Msg)) {
|
||||
setFieldErrors({ phone: step1Msg });
|
||||
return;
|
||||
}
|
||||
reportFormError(step1Msg);
|
||||
goStep(1);
|
||||
return;
|
||||
@@ -389,13 +417,16 @@ export default function StoreCreatePage() {
|
||||
|
||||
});
|
||||
|
||||
goStep(1);
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean);
|
||||
|
||||
@@ -444,11 +475,10 @@ export default function StoreCreatePage() {
|
||||
const message = e instanceof Error ? e.message : '提交失败';
|
||||
if (isPhoneConflictMessage(message)) {
|
||||
setFieldErrors({ phone: message });
|
||||
toastError(message);
|
||||
goStep(1);
|
||||
return;
|
||||
}
|
||||
setSubmitError(message);
|
||||
toastError(message);
|
||||
} finally {
|
||||
|
||||
setSubmitting(false);
|
||||
@@ -725,15 +755,7 @@ export default function StoreCreatePage() {
|
||||
|
||||
onWechatReadyChange={setWechatReady}
|
||||
|
||||
onChange={(nextUrl) => {
|
||||
|
||||
const envPhotoUrls = [...form.envPhotoUrls];
|
||||
|
||||
envPhotoUrls[index] = nextUrl;
|
||||
|
||||
patchForm({ envPhotoUrls });
|
||||
|
||||
}}
|
||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||
|
||||
/>
|
||||
|
||||
@@ -805,22 +827,44 @@ export default function StoreCreatePage() {
|
||||
|
||||
</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>
|
||||
|
||||
<button type="button" className="partner-weekly-link" onClick={() => goStep(1)}>
|
||||
|
||||
修改
|
||||
|
||||
</button>
|
||||
|
||||
</section>
|
||||
|
||||
<section className="partner-form-card">
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function StoreDetailPage() {
|
||||
const [status, setStatus] = useState<StoreStatusValue>('OPEN');
|
||||
const [statusSaving, setStatusSaving] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [actionError, setActionError] = useState('');
|
||||
|
||||
function applyStore(data: Record<string, unknown>) {
|
||||
setStore(data);
|
||||
@@ -52,6 +53,7 @@ export default function StoreDetailPage() {
|
||||
if (!ok) return;
|
||||
}
|
||||
setStatusSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const updated = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/status`, {
|
||||
method: 'PUT',
|
||||
@@ -60,8 +62,8 @@ export default function StoreDetailPage() {
|
||||
setStatus(next);
|
||||
setStore((prev) => (prev ? { ...prev, ...updated, status: next } : prev));
|
||||
toastSuccess('状态已更新');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '状态更新失败');
|
||||
} finally {
|
||||
setStatusSaving(false);
|
||||
}
|
||||
@@ -70,6 +72,7 @@ export default function StoreDetailPage() {
|
||||
async function saveBasic() {
|
||||
if (!id || saving || status === 'CLOSED') return;
|
||||
setSaving(true);
|
||||
setActionError('');
|
||||
try {
|
||||
const data = await request<Record<string, unknown>>('PARTNER_H5', `/partner/stores/${id}/basic`, {
|
||||
method: 'PUT',
|
||||
@@ -82,8 +85,8 @@ export default function StoreDetailPage() {
|
||||
});
|
||||
applyStore(data);
|
||||
toastSuccess('已保存');
|
||||
} catch {
|
||||
/* request 已 toast */
|
||||
} catch (e) {
|
||||
setActionError(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -110,6 +113,7 @@ export default function StoreDetailPage() {
|
||||
<PageHeader title="门店详情" onBack={() => navigate('/stores')} />
|
||||
|
||||
<main style={{ padding: '16px 20px' }}>
|
||||
{actionError && <p className="partner-form-error" role="alert" style={{ marginBottom: 12 }}>{actionError}</p>}
|
||||
{!subReadonly && (
|
||||
<section className="partner-form-card" style={{ margin: '0 0 16px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
|
||||
Reference in New Issue
Block a user