门店账户多账号

This commit is contained in:
2026-07-12 12:24:34 +08:00
parent 06b1cb22e0
commit 54a15d6da7
39 changed files with 1962 additions and 311 deletions
+4 -3
View File
@@ -5,6 +5,7 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
import { routeAfterShopLogin } from './SelectStorePage';
import {
bindShopWechatAfterSmsLogin,
fetchClientConfig,
@@ -58,7 +59,7 @@ export default function LoginPage() {
applySession(session);
stripOAuthParamsFromLocation();
setSearchParams({}, { replace: true });
navigate('/');
routeAfterShopLogin(session, navigate);
}
})
.catch((e) => setMsg(formatWechatError(e)));
@@ -115,7 +116,7 @@ export default function LoginPage() {
await bindShopWechatAfterSmsLogin();
return;
}
navigate('/');
routeAfterShopLogin(data, navigate);
} catch (e) {
setMsg(e instanceof Error ? e.message : '登录失败');
} finally {
@@ -135,7 +136,7 @@ export default function LoginPage() {
const session = await loginShopWithWechat();
if (session) {
applySession(session);
navigate('/');
routeAfterShopLogin(session, navigate);
}
} catch (e) {
setMsg(formatWechatError(e));
+22 -5
View File
@@ -1,19 +1,21 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { request } from '../lib/api';
import { getStoreProfile, request } from '../lib/api';
export default function MinePage() {
const navigate = useNavigate();
const { resetSession } = useStoreSession();
const { resetSession, store: sessionStore } = useStoreSession();
const profile = sessionStore ?? getStoreProfile();
const [store, setStore] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
request('SHOP_H5', '/shop/store').then(setStore);
request('SHOP_H5', '/shop/store').then(setStore).catch(() => setStore(null));
}, []);
const openTime = String(store?.openTime || '09:30');
const closeTime = String(store?.closeTime || '22:00');
const multiStore = (profile?.stores?.length ?? 0) > 1;
return (
<div className="shop-mine-page">
@@ -28,7 +30,7 @@ export default function MinePage() {
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value name">{String(store?.name || '—')}</p>
<p className="shop-mine-info-value name">{String(store?.name || profile?.storeName || '—')}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
@@ -42,7 +44,7 @@ export default function MinePage() {
<div className="shop-mine-info-row">
<div>
<p className="shop-mine-info-label"></p>
<p className="shop-mine-info-value">{String(store?.phone || '—')}</p>
<p className="shop-mine-info-value">{String(store?.phone || profile?.phone || '—')}</p>
</div>
<span className="material-symbols-outlined shop-mine-lock">lock</span>
</div>
@@ -55,6 +57,21 @@ export default function MinePage() {
</div>
</div>
<div className="shop-mine-actions">
{multiStore ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/select-store')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>swap_horiz</span>
</button>
) : null}
{profile?.isPrimary ? (
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
</button>
) : null}
</div>
<div className="shop-mine-help">
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>help</span>
<p></p>
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import {
needsStoreSelection,
request,
selectStore,
type ShopSessionPayload,
type ShopStoreOption,
} from '../lib/api';
export default function SelectStorePage() {
const navigate = useNavigate();
const { applySession, store, authenticated } = useStoreSession();
const [stores, setStores] = useState<ShopStoreOption[]>(store?.stores ?? []);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState('');
useEffect(() => {
if (!authenticated) {
navigate('/login', { replace: true });
return;
}
void request<ShopStoreOption[]>('SHOP_H5', '/shop/auth/stores')
.then((list) => setStores(list))
.catch((e) => setMsg(e instanceof Error ? e.message : '加载门店失败'));
}, [authenticated, navigate]);
async function onSelect(storeId: string) {
setLoading(true);
setMsg('');
try {
const session = await selectStore(storeId);
applySession(session);
navigate('/', { replace: true });
} catch (e) {
setMsg(e instanceof Error ? e.message : '选店失败');
} finally {
setLoading(false);
}
}
// 仅一家店时自动选
useEffect(() => {
if (stores.length === 1 && needsStoreSelection({ store, stores })) {
void onSelect(stores[0].storeId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stores.length]);
return (
<div className="shop-select-store-page">
<header className="shop-select-store-header">
<h1></h1>
<p></p>
</header>
{msg ? <p className="shop-select-store-msg">{msg}</p> : null}
<ul className="shop-select-store-list">
{stores.map((item) => (
<li key={item.storeId}>
<button
type="button"
className="shop-select-store-item"
disabled={loading}
onClick={() => void onSelect(item.storeId)}
>
<span className="shop-select-store-name">{item.name}</span>
<span className="shop-select-store-meta">
{[item.district, item.address].filter(Boolean).join(' · ') || item.status}
</span>
</button>
</li>
))}
</ul>
{!stores.length && !msg ? <p className="shop-select-store-empty"></p> : null}
</div>
);
}
/** After login/wechat: route to select-store or home */
export function routeAfterShopLogin(
session: ShopSessionPayload,
navigate: (path: string, opts?: { replace?: boolean }) => void,
) {
if (needsStoreSelection(session)) {
navigate('/select-store', { replace: true });
return;
}
navigate('/', { replace: true });
}
+153
View File
@@ -0,0 +1,153 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { STORE_STAFF_ROLE_LABELS, type StoreStaffRole } from '@dukang/shared-types';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { getStoreProfile, request } from '../lib/api';
type StaffItem = {
id: string;
name: string;
phone: string;
staffRole: StoreStaffRole;
status: string;
storeIds: string[];
stores: Array<{ storeId: string; name: string }>;
};
type StoreOption = { storeId: string; name: string };
export default function StaffPage() {
const navigate = useNavigate();
const { store } = useStoreSession();
const profile = store ?? getStoreProfile();
const [list, setList] = useState<StaffItem[]>([]);
const [ownedStores, setOwnedStores] = useState<StoreOption[]>([]);
const [msg, setMsg] = useState('');
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({ phone: '', name: '', storeIds: [] as string[] });
async function reload() {
const [staff, stores] = await Promise.all([
request<StaffItem[]>('SHOP_H5', '/shop/staff'),
request<StoreOption[]>('SHOP_H5', '/shop/auth/stores'),
]);
setList(staff);
setOwnedStores(stores);
}
useEffect(() => {
if (!profile?.isPrimary) {
navigate('/mine', { replace: true });
return;
}
void reload().catch((e) => setMsg(e instanceof Error ? e.message : '加载失败'));
}, [navigate, profile?.isPrimary]);
async function createStaff() {
setMsg('');
try {
await request('SHOP_H5', '/shop/staff', {
method: 'POST',
body: JSON.stringify({
phone: form.phone.trim(),
name: form.name.trim(),
storeIds: form.storeIds.length ? form.storeIds : ownedStores.map((s) => s.storeId),
}),
});
setShowForm(false);
setForm({ phone: '', name: '', storeIds: [] });
await reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : '创建失败');
}
}
async function toggleStatus(item: StaffItem) {
const next = item.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE';
try {
await request('SHOP_H5', `/shop/staff/${item.id}`, {
method: 'PUT',
body: JSON.stringify({ status: next }),
});
await reload();
} catch (e) {
setMsg(e instanceof Error ? e.message : '更新失败');
}
}
function toggleStoreId(storeId: string) {
setForm((prev) => ({
...prev,
storeIds: prev.storeIds.includes(storeId)
? prev.storeIds.filter((id) => id !== storeId)
: [...prev.storeIds, storeId],
}));
}
return (
<div className="shop-staff-page">
<header className="shop-staff-header">
<button type="button" className="shop-staff-back" onClick={() => navigate('/mine')}>
</button>
<h1></h1>
<button type="button" className="shop-staff-add" onClick={() => setShowForm((v) => !v)}>
{showForm ? '取消' : '添加'}
</button>
</header>
{msg ? <p className="shop-staff-msg">{msg}</p> : null}
{showForm ? (
<div className="shop-staff-form">
<input
placeholder="手机号"
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
/>
<input
placeholder="姓名"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
/>
<p className="shop-staff-form-label"></p>
<div className="shop-staff-store-picks">
{ownedStores.map((s) => (
<label key={s.storeId}>
<input
type="checkbox"
checked={form.storeIds.includes(s.storeId) || form.storeIds.length === 0}
onChange={() => toggleStoreId(s.storeId)}
/>
{s.name}
</label>
))}
</div>
<button type="button" onClick={() => void createStaff()}>
</button>
</div>
) : null}
<ul className="shop-staff-list">
{list.map((item) => (
<li key={item.id} className="shop-staff-item">
<div>
<strong>{item.name}</strong>
<span>{item.phone}</span>
<span>
{STORE_STAFF_ROLE_LABELS[item.staffRole] ?? item.staffRole} ·{' '}
{item.status === 'ACTIVE' ? '启用' : '停用'}
</span>
<span>{item.stores.map((s) => s.name).join('、')}</span>
</div>
<button type="button" onClick={() => void toggleStatus(item)}>
{item.status === 'ACTIVE' ? '停用' : '启用'}
</button>
</li>
))}
</ul>
{!list.length ? <p className="shop-staff-empty"></p> : null}
</div>
);
}