门店账户多账号
This commit is contained in:
@@ -2,6 +2,7 @@ import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import AuthGate from './components/AuthGate';
|
||||
import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SelectStorePage from './pages/SelectStorePage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||
@@ -9,12 +10,15 @@ import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
|
||||
@@ -3,9 +3,10 @@ import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
|
||||
const PUBLIC_PATHS = new Set(['/login']);
|
||||
const SELECT_STORE_PATH = '/select-store';
|
||||
|
||||
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
const { ready, authenticated } = useStoreSession();
|
||||
const { ready, authenticated, needsSelectStore } = useStoreSession();
|
||||
const location = useLocation();
|
||||
|
||||
if (!ready) {
|
||||
@@ -17,7 +18,11 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
if (authenticated && location.pathname === '/login') {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={needsSelectStore ? SELECT_STORE_PATH : '/'} replace />;
|
||||
}
|
||||
|
||||
if (authenticated && needsSelectStore && location.pathname !== SELECT_STORE_PATH) {
|
||||
return <Navigate to={SELECT_STORE_PATH} replace />;
|
||||
}
|
||||
|
||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type StoreSessionContextValue = {
|
||||
ready: boolean;
|
||||
authenticated: boolean;
|
||||
needsSelectStore: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
applySession: (session: ShopSessionPayload) => void;
|
||||
resetSession: () => void;
|
||||
@@ -28,17 +29,29 @@ const StoreSessionContext = createContext<StoreSessionContextValue | null>(null)
|
||||
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
const [ready, setReady] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [needsSelectStore, setNeedsSelectStore] = useState(false);
|
||||
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
||||
|
||||
const applySession = useCallback((session: ShopSessionPayload) => {
|
||||
saveAuth(session);
|
||||
setAuthenticated(true);
|
||||
if (session.store) setStore(session.store);
|
||||
const nextStore = session.store
|
||||
? {
|
||||
...session.store,
|
||||
stores: session.stores ?? session.store.stores,
|
||||
isPrimary: session.account?.isPrimary ?? session.store.isPrimary,
|
||||
}
|
||||
: null;
|
||||
setStore(nextStore);
|
||||
const storeId = nextStore?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? nextStore?.stores ?? [];
|
||||
setNeedsSelectStore(stores.length > 1 && !storeId);
|
||||
}, []);
|
||||
|
||||
const resetSession = useCallback(() => {
|
||||
clearAuth();
|
||||
setAuthenticated(false);
|
||||
setNeedsSelectStore(false);
|
||||
setStore(null);
|
||||
}, []);
|
||||
|
||||
@@ -50,6 +63,7 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
if (cancelled) return;
|
||||
setAuthenticated(result.authenticated);
|
||||
setStore(result.store);
|
||||
setNeedsSelectStore(result.needsSelectStore);
|
||||
} catch {
|
||||
if (!cancelled) resetSession();
|
||||
} finally {
|
||||
@@ -62,8 +76,8 @@ export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||
}, [resetSession]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ ready, authenticated, store, applySession, resetSession }),
|
||||
[ready, authenticated, store, applySession, resetSession],
|
||||
() => ({ ready, authenticated, needsSelectStore, store, applySession, resetSession }),
|
||||
[ready, authenticated, needsSelectStore, store, applySession, resetSession],
|
||||
);
|
||||
|
||||
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||
|
||||
+100
-17
@@ -1,12 +1,22 @@
|
||||
export const apiBase = '/api/v1';
|
||||
const CLIENT_APP = 'SHOP_H5';
|
||||
|
||||
export type ShopStoreOption = {
|
||||
storeId: string;
|
||||
name: string;
|
||||
status: string;
|
||||
district?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export type StoreSessionStore = {
|
||||
id: string;
|
||||
storeId: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
storeName: string;
|
||||
isPrimary?: boolean;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type StoreProfile = {
|
||||
@@ -15,13 +25,25 @@ export type StoreProfile = {
|
||||
name: string;
|
||||
phone: string;
|
||||
status?: string;
|
||||
store?: { id: string; name: string };
|
||||
isPrimary?: boolean;
|
||||
account?: {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: boolean;
|
||||
hasWechat?: boolean;
|
||||
};
|
||||
store?: ShopStoreOption | { id: string; name: string } | null;
|
||||
stores?: ShopStoreOption[];
|
||||
};
|
||||
|
||||
export type ShopSessionPayload = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
store?: StoreSessionStore;
|
||||
stores?: ShopStoreOption[];
|
||||
account?: StoreProfile['account'];
|
||||
selectedStoreId?: string;
|
||||
};
|
||||
|
||||
const ACCESS_TOKEN = 'accessToken';
|
||||
@@ -73,8 +95,27 @@ export function saveAuth(data: ShopSessionPayload) {
|
||||
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||
if (data.store) {
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store));
|
||||
localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
const profile: StoreSessionStore = {
|
||||
...data.store,
|
||||
stores: data.stores ?? data.store.stores,
|
||||
isPrimary: data.account?.isPrimary ?? data.store.isPrimary,
|
||||
};
|
||||
localStorage.setItem(STORE_PROFILE, JSON.stringify(profile));
|
||||
if (data.store.phone) localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||
} else if (data.account) {
|
||||
localStorage.setItem(
|
||||
STORE_PROFILE,
|
||||
JSON.stringify({
|
||||
id: data.account.id,
|
||||
storeId: '',
|
||||
name: data.account.name,
|
||||
phone: data.account.phone,
|
||||
storeName: '',
|
||||
isPrimary: data.account.isPrimary,
|
||||
stores: data.stores ?? [],
|
||||
} satisfies StoreSessionStore),
|
||||
);
|
||||
localStorage.setItem(LAST_PHONE, data.account.phone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,15 +142,45 @@ export function isLoggedIn() {
|
||||
}
|
||||
|
||||
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||
const selected =
|
||||
me.store && 'storeId' in me.store
|
||||
? me.store
|
||||
: me.store && 'id' in me.store
|
||||
? { storeId: String((me.store as { id: string }).id), name: me.store.name }
|
||||
: null;
|
||||
return {
|
||||
id: me.id,
|
||||
storeId: me.storeId,
|
||||
name: me.name,
|
||||
phone: me.phone,
|
||||
storeName: me.store?.name ?? me.name,
|
||||
id: me.account?.id ?? me.id,
|
||||
storeId: selected?.storeId ?? me.storeId ?? '',
|
||||
name: me.account?.name ?? me.name,
|
||||
phone: me.account?.phone ?? me.phone,
|
||||
storeName: selected?.name ?? '',
|
||||
isPrimary: me.account?.isPrimary ?? me.isPrimary,
|
||||
stores: me.stores ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
export function needsStoreSelection(session: {
|
||||
store?: StoreSessionStore | null;
|
||||
stores?: ShopStoreOption[];
|
||||
selectedStoreId?: string;
|
||||
}): boolean {
|
||||
const storeId = session.store?.storeId || session.selectedStoreId || '';
|
||||
const stores = session.stores ?? session.store?.stores ?? [];
|
||||
if (stores.length > 1 && !storeId) return true;
|
||||
if (!storeId && stores.length !== 1) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function selectStore(storeId: string): Promise<ShopSessionPayload> {
|
||||
const data = await requestWithAuthRetry<ShopSessionPayload>('/shop/auth/select-store', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ storeId }),
|
||||
});
|
||||
saveAuth(data);
|
||||
touchShopSession();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function rawRequest<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
@@ -190,13 +261,17 @@ export async function request<T>(clientApp: string, path: string, options: Reque
|
||||
return requestWithAuthRetry<T>(path, options);
|
||||
}
|
||||
|
||||
export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> {
|
||||
export async function ensureSession(): Promise<{
|
||||
authenticated: boolean;
|
||||
store: StoreSessionStore | null;
|
||||
needsSelectStore: boolean;
|
||||
}> {
|
||||
if (!isLoggedIn()) {
|
||||
return { authenticated: false, store: null };
|
||||
return { authenticated: false, store: null, needsSelectStore: false };
|
||||
}
|
||||
if (isShopSessionExpired()) {
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
try {
|
||||
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||
@@ -205,21 +280,29 @@ export async function ensureSession(): Promise<{ authenticated: boolean; store:
|
||||
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||
store,
|
||||
stores: me.stores,
|
||||
account: me.account,
|
||||
});
|
||||
touchShopSession();
|
||||
return { authenticated: true, store };
|
||||
return {
|
||||
authenticated: true,
|
||||
store,
|
||||
needsSelectStore: needsStoreSelection({ store, stores: me.stores }),
|
||||
};
|
||||
} catch (e) {
|
||||
const err = e as Error & { status?: number };
|
||||
if (err.status === 401) {
|
||||
const refreshed = await refreshSession();
|
||||
if (refreshed?.store) {
|
||||
return { authenticated: true, store: refreshed.store };
|
||||
if (refreshed) {
|
||||
return {
|
||||
authenticated: true,
|
||||
store: refreshed.store ?? getStoreProfile(),
|
||||
needsSelectStore: needsStoreSelection(refreshed),
|
||||
};
|
||||
}
|
||||
clearAuth({ keepProfile: true });
|
||||
return { authenticated: false, store: getStoreProfile() };
|
||||
return { authenticated: false, store: getStoreProfile(), needsSelectStore: false };
|
||||
}
|
||||
const cached = getStoreProfile();
|
||||
if (cached) return { authenticated: true, store: cached };
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,17 +36,26 @@ export async function checkNeedsWechatAuth(profile: ShopAccountProfile | null):
|
||||
|
||||
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||
if (!result.accessToken || !result.refreshToken) return null;
|
||||
const store = result.store;
|
||||
const store = result.store as Record<string, unknown> | undefined;
|
||||
const stores = Array.isArray((result as { stores?: unknown }).stores)
|
||||
? ((result as { stores: ShopSessionPayload['stores'] }).stores)
|
||||
: undefined;
|
||||
const account = (result as { account?: ShopSessionPayload['account'] }).account;
|
||||
return {
|
||||
accessToken: result.accessToken,
|
||||
refreshToken: result.refreshToken,
|
||||
stores,
|
||||
account,
|
||||
selectedStoreId: (result as { selectedStoreId?: string }).selectedStoreId,
|
||||
store: store
|
||||
? {
|
||||
id: String(store.id ?? ''),
|
||||
id: String(store.id ?? account?.id ?? ''),
|
||||
storeId: String(store.storeId ?? ''),
|
||||
name: String(store.name ?? ''),
|
||||
phone: String(store.phone ?? ''),
|
||||
name: String(store.name ?? account?.name ?? ''),
|
||||
phone: String(store.phone ?? account?.phone ?? ''),
|
||||
storeName: String(store.storeName ?? store.name ?? ''),
|
||||
isPrimary: account?.isPrimary,
|
||||
stores,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2183,3 +2183,163 @@
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-select-store-page,
|
||||
.shop-staff-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px 16px 40px;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.shop-select-store-header h1,
|
||||
.shop-staff-header h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-headline);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.shop-select-store-header p {
|
||||
margin: 8px 0 20px;
|
||||
color: var(--color-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-select-store-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.shop-select-store-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-select-store-name {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-select-store-meta {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.shop-select-store-msg,
|
||||
.shop-staff-msg {
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-mine-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.shop-mine-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.shop-staff-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.shop-staff-back,
|
||||
.shop-staff-add {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-staff-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shop-staff-form input {
|
||||
height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.shop-staff-store-picks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-staff-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shop-staff-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shop-staff-item div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.shop-staff-item strong {
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.shop-staff-item button {
|
||||
align-self: center;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user