门店账户多账号

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
+182 -40
View File
@@ -7,9 +7,22 @@ import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
type Row = {
id: string; phone: string; name: string; status: string; createdAt: string;
store?: { id: string; name: string; status: string; cityName: string };
id: string;
phone: string;
name: string;
status: string;
createdAt: string;
storeCount?: number;
staffCount?: number;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
store?: StoreBrief | null;
stores?: StoreBrief[];
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
};
type StoreOption = { id: string; name: string };
@@ -41,17 +54,53 @@ export default function StoreAccountsPage() {
const columns: ColumnsType<Row> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{ title: '门店', dataIndex: ['store', 'name'], width: 140 },
{ title: '门店状态', dataIndex: ['store', 'status'], width: 90, render: (s) => s ? <Tag>{STORE_STATUS_LABELS[s] || s}</Tag> : '—' },
{ title: '账号状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
{
title: '绑定门店',
width: 180,
render: (_, row) =>
row.stores?.length
? row.stores.map((s) => s.name).join('、')
: row.store?.name ?? '—',
},
{
title: '门店数',
dataIndex: 'storeCount',
width: 70,
render: (n, row) => n ?? row.stores?.length ?? 0,
},
{
title: '子账号',
dataIndex: 'staffCount',
width: 70,
render: (n) => n ?? 0,
},
{
title: '收款户名',
dataIndex: 'bankAccountName',
width: 120,
render: (v) => v || '—',
},
{
title: '账号状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}></Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/store-accounts/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
@@ -61,48 +110,141 @@ export default function StoreAccountsPage() {
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={0}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary">
</Typography.Text>
</Space>
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}></Button>
<Button
type="primary"
onClick={() => {
void loadStores();
setCreateOpen(true);
}}
>
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="phone" label="手机"><Input allowClear /></Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
<Select
allowClear
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</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 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="门店账户" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Select defaultValue={detail.status} style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/store-accounts/${detail.id}`, { method: 'PUT', body: JSON.stringify({ status }) });
message.success('已更新');
void reload();
}} />
)}>
<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="门店主账号"
width={520}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (
<Select
defaultValue={detail.status}
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
await request(`/admin/store-accounts/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({ status }),
});
message.success('已更新');
void reload();
}}
/>
)
}
>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="门店">{detail.store?.name}</Descriptions.Item>
</Descriptions>
<>
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="姓名">{detail.name}</Descriptions.Item>
<Descriptions.Item label="手机">{detail.phone}</Descriptions.Item>
<Descriptions.Item label="收款户名">{detail.bankAccountName || '—'}</Descriptions.Item>
<Descriptions.Item label="收款账号">{detail.bankAccountNo || '—'}</Descriptions.Item>
<Descriptions.Item label="开户行">{detail.bankBranch || '—'}</Descriptions.Item>
<Descriptions.Item label="绑定门店">
{(detail.stores ?? []).map((s) => (
<Tag key={s.id}>
{s.name}
{s.status ? `${STORE_STATUS_LABELS[s.status] || s.status}` : ''}
</Tag>
))}
{!detail.stores?.length ? '—' : null}
</Descriptions.Item>
</Descriptions>
{detail.staff?.length ? (
<>
<Typography.Title level={5} style={{ marginTop: 24 }}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
]}
/>
</>
) : null}
</>
)}
</Drawer>
<Modal title="新建门店账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}>
<Modal
title="新建门店账户"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/store-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="storeId" label="门店" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={stores.map((s) => ({ value: s.id, label: s.name }))} />
<Select
showSearch
optionFilterProp="label"
options={stores.map((s) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
@@ -310,6 +310,16 @@ export default function StoreCreatePage() {
setFieldErrors({ phone: phoneMsg });
return;
}
if (phoneCheck.needConfirm) {
const ok = window.confirm(
phoneCheck.message ??
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
return;
}
}
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
@@ -381,6 +391,8 @@ export default function StoreCreatePage() {
setFieldErrors({});
let confirmBindExisting = false;
try {
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
@@ -397,6 +409,18 @@ export default function StoreCreatePage() {
}
if (phoneCheck.needConfirm) {
const ok = window.confirm(
phoneCheck.message ??
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
return;
}
confirmBindExisting = true;
}
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
return;
@@ -440,6 +464,8 @@ export default function StoreCreatePage() {
bankBranch: form.bankBranch.trim(),
...(confirmBindExisting ? { confirmBindExisting: true } : {}),
}),
});
+4
View File
@@ -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 />} />
+7 -2
View File
@@ -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
View File
@@ -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;
}
}
+13 -4
View File
@@ -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,
};
+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>
);
}
+160
View File
@@ -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;
}