Merge branch 'dev_new_ljy' into dev

This commit is contained in:
ljy
2026-07-09 23:53:20 +08:00
17 changed files with 344 additions and 226 deletions
@@ -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,16 +119,23 @@ export default function OssUploadField({
}
async function pickWechatImage() {
await weixinSdk.init();
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album'],
});
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() {
@@ -140,7 +145,6 @@ export default function OssUploadField({
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
setError(text);
toastError(text);
return;
}
+1 -4
View File
@@ -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;
-7
View File
@@ -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' }),
});
}
+7 -1
View File
@@ -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 {
+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 { 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 = {
+4 -14
View File
@@ -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);
}
+89 -97
View File
@@ -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>
);
}
+9 -10
View File
@@ -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);
}
+71 -27
View File
@@ -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 }}>