diff --git a/apps/h5-partner/src/App.tsx b/apps/h5-partner/src/App.tsx index 7d2d6f0..ba2dd67 100644 --- a/apps/h5-partner/src/App.tsx +++ b/apps/h5-partner/src/App.tsx @@ -1,67 +1,44 @@ -import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; -import { useEffect } from 'react'; -import TabLayout from './layouts/TabLayout'; -import LoginPage from './pages/LoginPage'; -import HomePage from './pages/HomePage'; -import StoreListPage from './pages/StoreListPage'; -import StoreDetailPage from './pages/StoreDetailPage'; -import StoreCreatePage from './pages/StoreCreatePage'; -import OrderListPage from './pages/OrderListPage'; -import OrderDetailPage from './pages/OrderDetailPage'; -import CenterPage from './pages/CenterPage'; -import BillsPage from './pages/BillsPage'; -import ReshipPage from './pages/ReshipPage'; -import WeeklyReportPage from './pages/WeeklyReportPage'; -import LeaderboardPage from './pages/LeaderboardPage'; -import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth'; -import { isLoggedIn } from './lib/api'; -import { isWechatEnv } from './lib/weixin'; - -function WechatOAuthHandler() { - const location = useLocation(); - const navigate = useNavigate(); - - useEffect(() => { - if (!isWechatEnv() || !location.search.includes('code=')) return; - if (location.pathname === '/login') return; - void handlePartnerWechatCallback() - .then((result) => { - if (!result || !handlePartnerWechatLoginResult(result)) return; - const params = new URLSearchParams(location.search); - params.delete('code'); - params.delete('state'); - const qs = params.toString(); - navigate(`${location.pathname}${qs ? `?${qs}` : ''}`, { replace: true }); - }) - .catch(() => { - /* 页面内组件会提示 */ - }); - }, [location.pathname, location.search, navigate]); - - return null; -} - -export default function App() { - return ( - <> - - - } /> - }> - } /> - } /> - } /> - } /> - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - ); -} +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useEffect } from 'react'; +import LoginPage from './pages/LoginPage'; +import PartnerAppRoutes from './PartnerAppRoutes'; +import { handlePartnerWechatCallback, handlePartnerWechatLoginResult } from './lib/wechat-auth'; +import { isLoggedIn } from './lib/api'; +import { isWechatEnv } from './lib/weixin'; +import { useLocation, useNavigate } from 'react-router-dom'; + +function WechatOAuthHandler() { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + if (!isWechatEnv() || !location.search.includes('code=')) return; + if (location.pathname === '/login') return; + void handlePartnerWechatCallback() + .then((result) => { + if (!result || !handlePartnerWechatLoginResult(result)) return; + const params = new URLSearchParams(location.search); + params.delete('code'); + params.delete('state'); + const qs = params.toString(); + navigate(`${location.pathname}${qs ? `?${qs}` : ''}`, { replace: true }); + }) + .catch(() => { + /* 页面内组件会提示 */ + }); + }, [location.pathname, location.search, navigate]); + + return null; +} + +export default function App() { + return ( + <> + + + } /> + : } /> + + + ); +} diff --git a/apps/h5-partner/src/PartnerAppRoutes.tsx b/apps/h5-partner/src/PartnerAppRoutes.tsx new file mode 100644 index 0000000..89f1b55 --- /dev/null +++ b/apps/h5-partner/src/PartnerAppRoutes.tsx @@ -0,0 +1,76 @@ +import { Navigate, Route, Routes } from 'react-router-dom'; +import { usePartnerSession } from './contexts/PartnerSessionContext'; +import { isLoggedIn } from './lib/api'; +import { isSubAccount } from './lib/partnerAccess'; +import TabLayout from './layouts/TabLayout'; +import SubAccountLayout from './layouts/SubAccountLayout'; +import HomePage from './pages/HomePage'; +import StoreListPage from './pages/StoreListPage'; +import StoreDetailPage from './pages/StoreDetailPage'; +import StoreCreatePage from './pages/StoreCreatePage'; +import OrderListPage from './pages/OrderListPage'; +import OrderDetailPage from './pages/OrderDetailPage'; +import CenterPage from './pages/CenterPage'; +import BillsPage from './pages/BillsPage'; +import SettlementPage from './pages/SettlementPage'; +import ReshipPage from './pages/ReshipPage'; +import WeeklyReportPage from './pages/WeeklyReportPage'; +import LeaderboardPage from './pages/LeaderboardPage'; +import StaffListPage from './pages/StaffListPage'; +import StaffCreatePage from './pages/StaffCreatePage'; + +function SessionLoading() { + return
加载中...
; +} + +function PrimaryRoutes() { + return ( + + }> + } /> + } /> + } /> + } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +function SubAccountRoutes() { + return ( + + }> + } /> + } /> + } /> + + } /> + + ); +} + +export default function PartnerAppRoutes() { + const { account, loading, loggedIn } = usePartnerSession(); + + if (!loggedIn && !isLoggedIn()) { + return ; + } + if (loading) { + return ; + } + if (isSubAccount(account)) { + return ; + } + return ; +} diff --git a/apps/h5-partner/src/components/OssUploadField.tsx b/apps/h5-partner/src/components/OssUploadField.tsx index fd2a959..1ac2406 100644 --- a/apps/h5-partner/src/components/OssUploadField.tsx +++ b/apps/h5-partner/src/components/OssUploadField.tsx @@ -26,6 +26,17 @@ type OssUploadFieldProps = { const DEFAULT_MAX_MB = 10; +function formatWechatUploadError(e: unknown): string { + const msg = e instanceof Error ? e.message : '无法打开相册'; + if (/invalid signature/i.test(msg)) { + return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试'; + } + if (/permission|denied|拒绝/i.test(msg)) { + return '微信选图权限被拒绝,请在微信设置中允许相册/相机访问后重试'; + } + return msg; +} + export default function OssUploadField({ value, onChange, @@ -48,7 +59,7 @@ export default function OssUploadField({ const resolvedAccept = accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*'); - const useWechatPicker = isWechatEnv() && mediaType !== 'VIDEO'; + const useWechatPicker = isWechatEnv() && mediaType === 'IMAGE'; const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true; useEffect(() => { @@ -100,6 +111,22 @@ export default function OssUploadField({ } } + function openNativeFilePicker() { + inputRef.current?.click(); + } + + async function pickWechatImage() { + const files = await weixinSdk.chooseImages({ + count: 1, + sourceType: ['album', 'camera'], + }); + if (files?.[0]) { + await uploadSelectedFile(files[0]); + return; + } + throw new Error('未能获取图片,请重试'); + } + async function pickFile() { if (uploading || authorizing) return; setError(''); @@ -111,27 +138,17 @@ export default function OssUploadField({ if (useWechatPicker) { try { - const files = await weixinSdk.chooseImages({ - count: 1, - sourceType: ['album', 'camera'], - }); - if (files?.[0]) { - await uploadSelectedFile(files[0]); - return; - } - setError('未能获取图片,请重试'); + await pickWechatImage(); } catch (e) { const msg = e instanceof Error ? e.message : '无法打开相册'; - if (!/cancel/i.test(msg)) { - setError(/permission|auth|denied|授权|拒绝/i.test(msg) - ? '微信选图授权失败,请刷新页面后重试' - : msg); - } + if (/cancel/i.test(msg)) return; + setError(formatWechatUploadError(e)); + openNativeFilePicker(); } return; } - inputRef.current?.click(); + openNativeFilePicker(); } const isImage = mediaType === 'IMAGE' && value; @@ -165,6 +182,7 @@ export default function OssUploadField({ ref={inputRef} type="file" accept={resolvedAccept} + capture={mediaType === 'FILE' && isWechatEnv() ? 'environment' : undefined} className="partner-oss-upload-input" disabled={busy} onChange={(e) => { diff --git a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx index ceb2183..04b85a2 100644 --- a/apps/h5-partner/src/contexts/PartnerSessionContext.tsx +++ b/apps/h5-partner/src/contexts/PartnerSessionContext.tsx @@ -2,13 +2,10 @@ import { createContext, useCallback, useContext, useEffect, useState, type React import { toAppPath } from '@dukang/weixin-sdk'; import { clearAuth, isLoggedIn, request } from '../lib/api'; -export type PartnerAccount = { - id: string; - name: string; - phone: string; - isPrimary?: boolean; - companyName?: string; - hasWechat?: boolean; +import type { PartnerMe, PartnerStaffRole } from '@dukang/shared-types'; + +export type PartnerAccount = PartnerMe & { + staffRole?: PartnerStaffRole; }; type PartnerSessionValue = { diff --git a/apps/h5-partner/src/layouts/SubAccountLayout.tsx b/apps/h5-partner/src/layouts/SubAccountLayout.tsx new file mode 100644 index 0000000..598b7c5 --- /dev/null +++ b/apps/h5-partner/src/layouts/SubAccountLayout.tsx @@ -0,0 +1,41 @@ +import { NavLink, Outlet } from 'react-router-dom'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; + +const TABS = [ + { to: '/stores/new', icon: 'add_business', label: '录入新店' }, + { to: '/stores', end: true, icon: 'store', label: '我的门店' }, +] as const; + +export default function SubAccountLayout() { + const { account, logout } = usePartnerSession(); + + return ( + <> +
+
+
+

{account?.name || '拓店账号'}

+

{account?.companyName || '门店入驻'}

+
+ +
+
+ + + + ); +} diff --git a/apps/h5-partner/src/lib/leaderboard.ts b/apps/h5-partner/src/lib/leaderboard.ts index bf9f7c9..bc00108 100644 --- a/apps/h5-partner/src/lib/leaderboard.ts +++ b/apps/h5-partner/src/lib/leaderboard.ts @@ -1,15 +1,12 @@ -export type LeaderboardEntry = { - rank: number; - name: string; - role: string; - totalStores: number; - monthStores: number; -}; +import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types'; +import { request } from './api'; -/** preV1:贡献榜 Mock,待 `/partner/dashboard/leaderboard` 接入后替换 */ -export const LEADERBOARD_MOCK: LeaderboardEntry[] = [ - { rank: 1, name: '陈经理', role: '内部员工', totalStores: 45, monthStores: 8 }, - { rank: 2, name: '李华', role: '城市合伙人', totalStores: 32, monthStores: 5 }, - { rank: 3, name: '王拓', role: '推广员', totalStores: 18, monthStores: 3 }, - { rank: 4, name: '赵敏', role: '内部员工', totalStores: 12, monthStores: 2 }, -]; +export function fetchPartnerLeaderboard(period: PartnerLeaderboardPeriod = 'total') { + return request( + 'PARTNER_H5', + `/partner/dashboard/leaderboard?period=${period}`, + ); +} + +export type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse }; +export type { PartnerLeaderboardEntry } from '@dukang/shared-types'; diff --git a/apps/h5-partner/src/lib/partnerAccess.ts b/apps/h5-partner/src/lib/partnerAccess.ts new file mode 100644 index 0000000..efcbe32 --- /dev/null +++ b/apps/h5-partner/src/lib/partnerAccess.ts @@ -0,0 +1,22 @@ +import type { PartnerMe } from '@dukang/shared-types'; + +export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean { + return account?.isPrimary !== false; +} + +export function isSubAccount(account: PartnerMe | null | undefined): boolean { + return !!account && account.isPrimary === false; +} + +export function partnerHomePath(account: PartnerMe | null | undefined): string { + return isSubAccount(account) ? '/stores/new' : '/'; +} + +export const SUB_ACCOUNT_ALLOWED_PREFIXES = ['/stores', '/login']; + +export function isSubAccountPath(pathname: string): boolean { + if (pathname === '/login') return true; + return SUB_ACCOUNT_ALLOWED_PREFIXES.some( + (prefix) => prefix !== '/login' && (pathname === prefix || pathname.startsWith(`${prefix}/`)), + ); +} diff --git a/apps/h5-partner/src/lib/staff.ts b/apps/h5-partner/src/lib/staff.ts new file mode 100644 index 0000000..3a09dd4 --- /dev/null +++ b/apps/h5-partner/src/lib/staff.ts @@ -0,0 +1,37 @@ +import type { + CreatePartnerStaffRequest, + PartnerStaffItem, + UpdatePartnerStaffRequest, +} from '@dukang/shared-types'; +import { request } from './api'; + +export function listPartnerStaff() { + return request('PARTNER_H5', '/partner/staff'); +} + +export function createPartnerStaff(body: CreatePartnerStaffRequest) { + return request('PARTNER_H5', '/partner/staff', { + method: 'POST', + body: JSON.stringify(body), + }); +} + +export function updatePartnerStaff(id: string, body: UpdatePartnerStaffRequest) { + return request('PARTNER_H5', `/partner/staff/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }); +} + +export function deletePartnerStaff(id: string) { + return request<{ ok: boolean }>('PARTNER_H5', `/partner/staff/${id}`, { + 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' }), + }); +} diff --git a/apps/h5-partner/src/lib/storeDraft.ts b/apps/h5-partner/src/lib/storeDraft.ts index 850a1e2..783ec1e 100644 --- a/apps/h5-partner/src/lib/storeDraft.ts +++ b/apps/h5-partner/src/lib/storeDraft.ts @@ -116,6 +116,16 @@ export function validateStoreStep1( return null; } +export function validateStoreStep2( + form: Pick, +): string | null { + if (!form.coverUrl.trim()) return '请上传门头照'; + const envCount = form.envPhotoUrls.filter((u) => u.trim()).length; + if (envCount < 3) return '请上传至少 3 张环境照片'; + if (!form.contractUrl.trim()) return '请上传签约合同'; + return null; +} + export function validateStoreStep3( form: Pick, ): string | null { diff --git a/apps/h5-partner/src/lib/storePhone.ts b/apps/h5-partner/src/lib/storePhone.ts new file mode 100644 index 0000000..087fbcd --- /dev/null +++ b/apps/h5-partner/src/lib/storePhone.ts @@ -0,0 +1,11 @@ +import type { PartnerStorePhoneAvailableResponse } from '@dukang/shared-types'; +import { request } from './api'; + +export function checkStorePhoneAvailable(phone: string) { + return request( + 'PARTNER_H5', + `/partner/stores/phone-available?phone=${encodeURIComponent(phone.trim())}`, + ); +} + +export type { PartnerStorePhoneAvailableResponse }; diff --git a/apps/h5-partner/src/lib/weeklyReport.ts b/apps/h5-partner/src/lib/weeklyReport.ts new file mode 100644 index 0000000..6c755ba --- /dev/null +++ b/apps/h5-partner/src/lib/weeklyReport.ts @@ -0,0 +1,9 @@ +import type { PartnerWeeklyReportResponse } from '@dukang/shared-types'; +import { request } from './api'; + +export function fetchPartnerWeeklyReport(startDate?: string) { + const query = startDate ? `?startDate=${encodeURIComponent(startDate)}` : ''; + return request('PARTNER_H5', `/partner/reports/weekly${query}`); +} + +export type { PartnerWeeklyReportResponse }; diff --git a/apps/h5-partner/src/pages/BillsPage.tsx b/apps/h5-partner/src/pages/BillsPage.tsx index 50b160c..2132f08 100644 --- a/apps/h5-partner/src/pages/BillsPage.tsx +++ b/apps/h5-partner/src/pages/BillsPage.tsx @@ -1,10 +1,11 @@ import { useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import PageHeader from '@dukang/shared-ui/PageHeader'; import { isLoggedIn, request } from '../lib/api'; export default function BillsPage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [bills, setBills] = useState>>([]); const [confirmed, setConfirmed] = useState(false); const [submitting, setSubmitting] = useState(false); @@ -14,7 +15,10 @@ export default function BillsPage() { request>>('PARTNER_H5', '/partner/settlement/bills').then(setBills); }, [navigate]); - const bill = bills[0]; + const billId = searchParams.get('id'); + const bill = billId + ? bills.find((b) => String(b.id) === billId) ?? bills[0] + : bills[0]; function confirmBill() { if (!confirmed) return; @@ -27,7 +31,7 @@ export default function BillsPage() { return (
- navigate('/center')} /> + navigate('/center/settlement')} />
diff --git a/apps/h5-partner/src/pages/CenterPage.tsx b/apps/h5-partner/src/pages/CenterPage.tsx index d83693d..ca25ebf 100644 --- a/apps/h5-partner/src/pages/CenterPage.tsx +++ b/apps/h5-partner/src/pages/CenterPage.tsx @@ -2,10 +2,12 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { isLoggedIn, request } from '../lib/api'; import { usePartnerSession } from '../contexts/PartnerSessionContext'; +import { isPrimaryAccount } from '../lib/partnerAccess'; export default function CenterPage() { const navigate = useNavigate(); const { account, logout } = usePartnerSession(); + const showPrimaryMenus = isPrimaryAccount(account); const me = account as unknown as Record | null; const [bills, setBills] = useState>>([]); @@ -101,7 +103,7 @@ export default function CenterPage() {
chevron_right - +
description
财务对账 @@ -132,6 +134,15 @@ export default function CenterPage() {
chevron_right + {showPrimaryMenus && ( + +
+
group
+ 子账号管理 +
+ chevron_right + + )}
diff --git a/apps/h5-partner/src/pages/HomePage.tsx b/apps/h5-partner/src/pages/HomePage.tsx index 9dbc058..2a823e5 100644 --- a/apps/h5-partner/src/pages/HomePage.tsx +++ b/apps/h5-partner/src/pages/HomePage.tsx @@ -1,26 +1,34 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; +import type { PartnerLeaderboardEntry } from '@dukang/shared-types'; import { isLoggedIn, request } from '../lib/api'; -import { LEADERBOARD_MOCK } from '../lib/leaderboard'; -import { storeStatusLabel } from '../lib/storeStatus'; +import { fetchPartnerLeaderboard } from '../lib/leaderboard'; +import { storeStatusLabel, storeStatusPillClass } from '../lib/storeStatus'; function fmtMoney(n: number) { return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } +const HOME_STORE_PREVIEW_LIMIT = 6; + export default function HomePage() { const navigate = useNavigate(); const [dash, setDash] = useState | null>(null); const [stores, setStores] = useState>>([]); + const [leaderboardPreview, setLeaderboardPreview] = useState([]); useEffect(() => { if (!isLoggedIn()) { navigate('/login'); return; } request>('PARTNER_H5', '/partner/dashboard').then(setDash); request>>('PARTNER_H5', '/partner/stores').then(setStores); + fetchPartnerLeaderboard('month') + .then((data) => setLeaderboardPreview(data.list.slice(0, 2))) + .catch(() => setLeaderboardPreview([])); }, [navigate]); const storeCount = Number(dash?.storeCount || stores.length || 0); const orderCount = Number(dash?.orderCount || 0); + const pendingAuditCount = Number(dash?.pendingAuditCount || 0); const activeStores = stores.filter((s) => String(s.status).toUpperCase() === 'OPEN').length; const abnormalStores = Math.max(0, storeCount - activeStores); const revenue = orderCount * 128.45; @@ -34,9 +42,7 @@ export default function HomePage() { return created.getMonth() === now.getMonth() && created.getFullYear() === now.getFullYear(); }).length; - const recentFromDash = (dash?.recentStores as Array> | undefined) ?? []; - const displayStores = stores.length > 0 ? stores : recentFromDash; - const previewLeaderboard = LEADERBOARD_MOCK.slice(0, 2); + const previewStores = stores.slice(0, HOME_STORE_PREVIEW_LIMIT); return (
@@ -107,7 +113,7 @@ export default function HomePage() {
补发处理 - +
account_balance
@@ -146,6 +152,35 @@ export default function HomePage() {
+
+
+

辖区门店

+ 查看全部 +
+ {previewStores.length === 0 ? ( +
+

暂无门店

+ 录入新店 +
+ ) : ( + previewStores.map((s) => { + const storeId = String(s.id); + const status = String(s.status || 'PAUSED').toUpperCase(); + return ( + +
+

{String(s.name || '未命名门店')}

+

{String(s.address || s.district || '')}

+
+ + {storeStatusLabel(status)} + + + ); + }) + )} +
+

拓店情况

@@ -155,23 +190,12 @@ export default function HomePage() { {monthNew}
+
+

待审核门店

+ {pendingAuditCount} +
- {displayStores.length > 0 && ( -
    -
  • - 门店名称 - 状态 -
  • - {displayStores.slice(0, 5).map((s) => ( -
  • - {String(s.name || '未命名门店')} - {storeStatusLabel(String(s.status))} -
  • - ))} -
- )} -

@@ -180,22 +204,26 @@ export default function HomePage() {

查看全部
- {previewLeaderboard.map((entry) => ( -
-
{entry.rank}
-
-

- {entry.name} - ({entry.role}) -

-

累计拓店 {entry.totalStores} 间

+ {leaderboardPreview.length === 0 ? ( +

暂无排行数据

+ ) : ( + leaderboardPreview.map((entry) => ( +
+
{entry.rank}
+
+

+ {entry.name} + ({entry.roleLabel}) +

+

累计拓店 {entry.totalStores} 间

+
+
+

{entry.periodStores} 间

+

本月新增

+
-
-

{entry.monthStores} 间

-

本月新增

-
-
- ))} + )) + )}

diff --git a/apps/h5-partner/src/pages/LeaderboardPage.tsx b/apps/h5-partner/src/pages/LeaderboardPage.tsx index e2f2885..bcc3985 100644 --- a/apps/h5-partner/src/pages/LeaderboardPage.tsx +++ b/apps/h5-partner/src/pages/LeaderboardPage.tsx @@ -1,36 +1,129 @@ -import { useNavigate } from 'react-router-dom'; +import { useEffect, useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import type { PartnerLeaderboardPeriod, PartnerLeaderboardResponse } from '@dukang/shared-types'; import PageHeader from '@dukang/shared-ui/PageHeader'; -import { LEADERBOARD_MOCK } from '../lib/leaderboard'; +import { fetchPartnerLeaderboard } from '../lib/leaderboard'; +import { usePartnerSession } from '../contexts/PartnerSessionContext'; +import { isPrimaryAccount } from '../lib/partnerAccess'; + +const PERIOD_TABS: { key: PartnerLeaderboardPeriod; label: string }[] = [ + { key: 'month', label: '本月' }, + { key: 'lastMonth', label: '上月' }, + { key: 'total', label: '累计' }, +]; + +function periodStatLabel(period: PartnerLeaderboardPeriod): string { + if (period === 'month') return '本月拓店'; + if (period === 'lastMonth') return '上月拓店'; + return '累计拓店'; +} + +function periodSubLabel(period: PartnerLeaderboardPeriod): string { + if (period === 'total') return '累计'; + if (period === 'month') return '本月新增'; + return '上月新增'; +} export default function LeaderboardPage() { const navigate = useNavigate(); + const { account } = usePartnerSession(); + const showStaffFab = isPrimaryAccount(account); + const [period, setPeriod] = useState('month'); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + fetchPartnerLeaderboard(period) + .then(setData) + .catch((e) => { + setData(null); + setError(e instanceof Error ? e.message : '加载失败'); + }) + .finally(() => setLoading(false)); + }, [period]); return (

navigate('/')} /> -

- 按拓店数排行,数据每周更新 -

- -
- {LEADERBOARD_MOCK.map((entry) => ( -
-
{entry.rank}
-
-

- {entry.name} - ({entry.role}) -

-

累计拓店 {entry.totalStores} 间

-
-
-

{entry.monthStores} 间

-

本月新增

-
-
+
+ {PERIOD_TABS.map((tab) => ( + ))}
+ +

+ 按拓店数排行,数据实时更新 +

+ + {loading &&
加载中...
} + {!loading && error &&
{error}
} + + {!loading && !error && data && ( +
+ {data.self && ( +
+
+
第 {data.self.rank} 名
+
+

+ {data.self.name} + {data.self.isSelf ? ' (我)' : ''} +

+

{data.self.roleLabel}

+
+
+
+ {periodStatLabel(period)}: {data.self.periodStores} 间 + {data.self.beatPercent != null && ( + 击败 {data.self.beatPercent}% 合伙人 + )} +
+
+ )} + +

实时排行

+ + {data.list.length === 0 ? ( +
暂无排行数据
+ ) : ( + data.list.map((entry) => ( +
+
{entry.rank}
+
+

+ {entry.name} + + ({entry.roleLabel}) + +

+

累计拓店 {entry.totalStores} 间

+
+
+

{entry.periodStores} 间

+

{periodSubLabel(period)}

+
+
+ )) + )} +
+ )} + + {showStaffFab && ( + + add + + )}
); } diff --git a/apps/h5-partner/src/pages/LoginPage.tsx b/apps/h5-partner/src/pages/LoginPage.tsx index 8bfa3f5..5d56e11 100644 --- a/apps/h5-partner/src/pages/LoginPage.tsx +++ b/apps/h5-partner/src/pages/LoginPage.tsx @@ -1,6 +1,8 @@ import { useEffect, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { request, saveAuth } from '../lib/api'; +import { partnerHomePath } from '../lib/partnerAccess'; +import type { PartnerMe } from '@dukang/shared-types'; import { bindPartnerWechatAfterSmsLogin, fetchClientConfig, @@ -132,18 +134,20 @@ export default function LoginPage() { body: JSON.stringify({ phone, scene: 'PARTNER_LOGIN' }), }); } - const data = await request<{ accessToken: string }>('PARTNER_H5', '/partner/auth/login/sms', { + const data = await request<{ accessToken: string; partner?: PartnerMe }>('PARTNER_H5', '/partner/auth/login/sms', { method: 'POST', body: JSON.stringify({ phone, code }), }); saveAuth(data); persistRememberAccount(phone); + const home = partnerHomePath(data.partner); if (isWechatEnv() && wxAuthorize) { setMsg('登录成功,正在关联微信…'); await bindPartnerWechatAfterSmsLogin(); + navigate(home); return; } - navigate('/'); + navigate(home); } catch (e) { setMsg(formatPartnerError(e)); } finally { diff --git a/apps/h5-partner/src/pages/SettlementPage.tsx b/apps/h5-partner/src/pages/SettlementPage.tsx new file mode 100644 index 0000000..71b645c --- /dev/null +++ b/apps/h5-partner/src/pages/SettlementPage.tsx @@ -0,0 +1,207 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import PageHeader from '@dukang/shared-ui/PageHeader'; +import type { PartnerBillDto } from '@dukang/shared-types'; +import { isLoggedIn, request } from '../lib/api'; + +type StatusFilter = 'all' | 'pending' | 'settled' | 'reviewing'; + +function fmtMoney(n: number) { + return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function billStatusLabel(status: string) { + switch (status) { + case 'DRAFT': return '待结算'; + case 'CONFIRMED': return '审核中'; + case 'PAID': return '已结算'; + default: return status; + } +} + +function billStatusClass(status: string) { + switch (status) { + case 'DRAFT': return 'partner-settlement-status--pending'; + case 'CONFIRMED': return 'partner-settlement-status--reviewing'; + case 'PAID': return 'partner-settlement-status--settled'; + default: return ''; + } +} + +function billPeriodLabel(periodStart: string) { + const d = new Date(periodStart); + if (Number.isNaN(d.getTime())) return '—'; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; +} + +function billMonthLabel(year: number, month: number) { + return `${year}年${month}月`; +} + +export default function SettlementPage() { + const navigate = useNavigate(); + const [bills, setBills] = useState([]); + const now = new Date(); + const [month, setMonth] = useState({ year: now.getFullYear(), month: now.getMonth() + 1 }); + const [statusFilter, setStatusFilter] = useState('all'); + + useEffect(() => { + if (!isLoggedIn()) { navigate('/login'); return; } + request('PARTNER_H5', '/partner/settlement/bills').then(setBills); + }, [navigate]); + + const summary = useMemo(() => { + const pending = bills.filter((b) => b.status === 'DRAFT'); + const settled = bills.filter((b) => b.status === 'PAID'); + const currentMonth = bills.filter((b) => { + const d = new Date(b.periodStart); + return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); + }); + return { + pendingTotal: pending.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + settledTotal: settled.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + monthEstimate: currentMonth.reduce((s, b) => s + Number(b.totalAmount || 0), 0), + }; + }, [bills, now]); + + const filteredBills = useMemo(() => { + return bills.filter((b) => { + const d = new Date(b.periodStart); + const matchMonth = d.getFullYear() === month.year && d.getMonth() + 1 === month.month; + if (!matchMonth) return false; + if (statusFilter === 'all') return true; + if (statusFilter === 'pending') return b.status === 'DRAFT'; + if (statusFilter === 'settled') return b.status === 'PAID'; + if (statusFilter === 'reviewing') return b.status === 'CONFIRMED'; + return true; + }); + }, [bills, month, statusFilter]); + + const monthOptions = useMemo(() => { + const opts: Array<{ year: number; month: number }> = []; + for (let i = 0; i < 6; i++) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1); + opts.push({ year: d.getFullYear(), month: d.getMonth() + 1 }); + } + return opts; + }, [now]); + + function openBill(bill: PartnerBillDto) { + navigate(bill.id ? `/center/bills?id=${bill.id}` : '/center/bills'); + } + + const statusTabs: Array<{ key: StatusFilter; label: string }> = [ + { key: 'all', label: '全部' }, + { key: 'pending', label: '待结算' }, + { key: 'settled', label: '已结算' }, + { key: 'reviewing', label: '审核中' }, + ]; + + return ( +
+ navigate('/')} /> + +
+
+
+ account_balance_wallet +
+

待结算总额

+
+ ¥ + {fmtMoney(summary.pendingTotal)} +
+
+
+

累计已结算

+

¥{fmtMoney(summary.settledTotal)}

+
+
+
+

本月预估收益

+

+ ¥{fmtMoney(summary.monthEstimate)} +

+
+
+
+ +
+ + +
+ +
+ {statusTabs.map((tab) => ( + + ))} +
+ +
+

对账明细记录

+ + {filteredBills.length === 0 && ( +
暂无对账记录
+ )} + +
+ {filteredBills.map((bill) => ( + + ))} +
+ + {filteredBills.length > 0 && ( +

已展示全部记录

+ )} +
+
+
+ ); +} diff --git a/apps/h5-partner/src/pages/StaffCreatePage.tsx b/apps/h5-partner/src/pages/StaffCreatePage.tsx new file mode 100644 index 0000000..22dc937 --- /dev/null +++ b/apps/h5-partner/src/pages/StaffCreatePage.tsx @@ -0,0 +1,139 @@ +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'; + +const ROLE_OPTIONS = Object.values(PartnerStaffRole); + +export default function StaffCreatePage() { + const navigate = useNavigate(); + const [name, setName] = useState(''); + const [phone, setPhone] = useState(''); + const [code, setCode] = useState(''); + const [staffRole, setStaffRole] = useState(PartnerStaffRole.INTERNAL); + const [sending, setSending] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [msg, setMsg] = useState(''); + + async function sendCode() { + if (!/^1[3-9]\d{9}$/.test(phone.trim())) { + setMsg('请输入正确的11位手机号'); + return; + } + setSending(true); + setMsg(''); + try { + await sendStaffAddSms(phone.trim()); + setMsg('验证码已发送'); + } catch (e) { + setMsg(e instanceof Error ? e.message : '发送失败'); + } finally { + setSending(false); + } + } + + async function submit() { + if (!name.trim()) { + setMsg('请填写真实姓名'); + return; + } + if (!/^1[3-9]\d{9}$/.test(phone.trim())) { + setMsg('请输入正确的11位手机号'); + return; + } + if (!code.trim()) { + setMsg('请输入验证码'); + return; + } + setSubmitting(true); + setMsg(''); + try { + await createPartnerStaff({ + name: name.trim(), + phone: phone.trim(), + staffRole, + code: code.trim(), + }); + navigate('/center/staff'); + } catch (e) { + setMsg(e instanceof Error ? e.message : '创建失败'); + } finally { + setSubmitting(false); + } + } + + return ( +
+ navigate('/center/staff')} /> + +
+ {msg &&

{msg}

} + +
+ +
+ setName(e.target.value)} + /> + person +
+
+ +
+ +
+ setPhone(e.target.value)} + inputMode="numeric" + /> + +
+ setCode(e.target.value)} + inputMode="numeric" + /> +
+ +
+ +
+ + expand_more +
+
+ +

+ 创建即代表同意合伙人管理协议。子账号默认禁用,需手动启用。 +

+
+ +
+ +
+
+ ); +} diff --git a/apps/h5-partner/src/pages/StaffListPage.tsx b/apps/h5-partner/src/pages/StaffListPage.tsx new file mode 100644 index 0000000..5aaa5f2 --- /dev/null +++ b/apps/h5-partner/src/pages/StaffListPage.tsx @@ -0,0 +1,156 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import PageHeader from '@dukang/shared-ui/PageHeader'; +import { + AccountStatus, + PARTNER_STAFF_ROLE_LABELS, + PartnerStaffRole, + type PartnerStaffItem, +} from '@dukang/shared-types'; +import { deletePartnerStaff, listPartnerStaff, updatePartnerStaff } from '../lib/staff'; + +export default function StaffListPage() { + const navigate = useNavigate(); + const [staff, setStaff] = useState([]); + const [q, setQ] = useState(''); + const [error, setError] = useState(''); + const [busyId, setBusyId] = useState(null); + + const loadStaff = useCallback(() => { + return listPartnerStaff().then(setStaff).catch((e) => { + setError(e instanceof Error ? e.message : '加载失败'); + }); + }, []); + + useEffect(() => { + void loadStaff(); + }, [loadStaff]); + + const filtered = useMemo(() => staff.filter((item) => { + if (!q.trim()) return true; + const keyword = q.trim(); + return item.name.includes(keyword) || item.phone.includes(keyword); + }), [staff, q]); + + async function toggleStatus(item: PartnerStaffItem) { + const next = item.status === AccountStatus.ACTIVE ? AccountStatus.DISABLED : AccountStatus.ACTIVE; + setBusyId(item.id); + setError(''); + try { + await updatePartnerStaff(item.id, { status: next }); + await loadStaff(); + } catch (e) { + setError(e instanceof Error ? e.message : '操作失败'); + } finally { + setBusyId(null); + } + } + + async function removeStaff(item: PartnerStaffItem) { + const ok = window.confirm(`确认删除子账号「${item.name}」?`); + if (!ok) return; + setBusyId(item.id); + setError(''); + try { + await deletePartnerStaff(item.id); + await loadStaff(); + } catch (e) { + setError(e instanceof Error ? e.message : '删除失败'); + } finally { + setBusyId(null); + } + } + + async function editStaff(item: PartnerStaffItem) { + const name = window.prompt('修改姓名', item.name); + if (name == null || !name.trim()) return; + const roleOptions = Object.values(PartnerStaffRole); + const roleLabels = roleOptions.map((r) => PARTNER_STAFF_ROLE_LABELS[r]).join(' / '); + const roleInput = window.prompt(`分配角色(${roleLabels})`, item.staffRole); + if (roleInput == null) return; + const staffRole = roleOptions.find((r) => r === roleInput || PARTNER_STAFF_ROLE_LABELS[r] === roleInput); + if (!staffRole) { + setError('无效的角色'); + return; + } + setBusyId(item.id); + setError(''); + try { + await updatePartnerStaff(item.id, { name: name.trim(), staffRole }); + await loadStaff(); + } catch (e) { + setError(e instanceof Error ? e.message : '编辑失败'); + } finally { + setBusyId(null); + } + } + + return ( +
+ navigate('/center')} /> + +
+
+ search + setQ(e.target.value)} /> +
+
+ + {error &&

{error}

} + +
+ {filtered.length === 0 && ( +
+

暂无子账号

+

点击下方按钮添加首个子账号

+
+ )} + + {filtered.map((item) => { + const active = item.status === AccountStatus.ACTIVE; + const busy = busyId === item.id; + return ( +
+
{item.name.slice(0, 1)}
+
+
+ {item.name} + + {PARTNER_STAFF_ROLE_LABELS[item.staffRole]} + +
+

{item.phone}

+
+
+ +
+ + +
+
+
+ ); + })} +
+ +
+ + person_add + 添加子账号 + +
+
+ ); +} diff --git a/apps/h5-partner/src/pages/StoreCreatePage.tsx b/apps/h5-partner/src/pages/StoreCreatePage.tsx index 6644c9e..a669e9d 100644 --- a/apps/h5-partner/src/pages/StoreCreatePage.tsx +++ b/apps/h5-partner/src/pages/StoreCreatePage.tsx @@ -1,367 +1,929 @@ import { useEffect, useMemo, useState } from 'react'; + import { useNavigate, useSearchParams } from 'react-router-dom'; + import PageHeader from '@dukang/shared-ui/PageHeader'; + import ChinaRegionPicker from '../components/ChinaRegionPicker'; + import OssUploadField from '../components/OssUploadField'; + import { request } from '../lib/api'; + import { resolveRegionBinding } from '../lib/china-region'; + +import { checkStorePhoneAvailable } from '../lib/storePhone'; + import { fetchPartnerCities, type OpenCityOption } from '../lib/upload'; + import { + fetchPartnerProfile, + handlePartnerWechatCallback, + savePartnerWechatAuth, + } from '../lib/wechat-auth'; + import { isWechatEnv, weixinSdk } from '../lib/weixin'; + +import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk'; + import { + clearStoreDraft, + defaultStoreForm, + loadStoreDraft, + saveStoreDraft, + type StoreDraftForm, + validateStoreStep1, + + validateStoreStep2, + validateStoreStep3, + } from '../lib/storeDraft'; + + const STEPS = ['基本信息', '照片上传', '结算资质'] as const; + + +type FieldErrors = { + + phone?: string; + +}; + + + +function isPhoneConflictMessage(message: string) { + + return message.includes('手机号已绑定'); + +} + + + export default function StoreCreatePage() { + const navigate = useNavigate(); + const [params, setParams] = useSearchParams(); + const saved = loadStoreDraft(); + const [form, setForm] = useState(saved?.form ?? defaultStoreForm()); - const [error, setError] = useState(''); + + const [submitError, setSubmitError] = useState(''); + + const [fieldErrors, setFieldErrors] = useState({}); + const [submitting, setSubmitting] = useState(false); + + const [checkingPhone, setCheckingPhone] = useState(false); + const [cities, setCities] = useState([]); + const [citiesError, setCitiesError] = useState(''); + const [wechatReady, setWechatReady] = useState(false); + + const stepFromUrl = Number(params.get('step') || 0); + const step = stepFromUrl >= 1 && stepFromUrl <= 3 ? stepFromUrl : (saved?.step ?? 1); + + useEffect(() => { + if (!stepFromUrl && saved?.step) { + setParams({ step: String(saved.step) }, { replace: true }); + } + }, [stepFromUrl, saved?.step, setParams]); + + useEffect(() => { + saveStoreDraft({ step, form }); + }, [step, form]); + + useEffect(() => { + if (step !== 2 || !isWechatEnv()) return; + void fetchPartnerProfile() + .then((me) => setWechatReady(!!me.hasWechat)) + .catch(() => setWechatReady(false)); + void weixinSdk.init().catch(() => { + /* OssUploadField 点击时会再次初始化 */ + }); + }, [step]); - useEffect(() => { - if (!isWechatEnv() || !params.get('code')) return; - void handlePartnerWechatCallback() - .then((result) => { - if (result && savePartnerWechatAuth(result)) { - setWechatReady(true); - } - }) - .catch((e) => setError(e instanceof Error ? e.message : '微信授权失败')); - }, [params]); + useEffect(() => { - void fetchPartnerCities() - .then((items) => { - setCities(items); - if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置'); + + if (!isWechatEnv() || !params.get('code')) return; + + void handlePartnerWechatCallback() + + .then((result) => { + + if (result && savePartnerWechatAuth(result)) { + + setWechatReady(true); + + } + + stripOAuthParamsFromLocation(); + + const next = new URLSearchParams(params); + + next.delete('code'); + + next.delete('state'); + + setParams(next, { replace: true }); + + void weixinSdk.init().catch(() => { + + /* OssUploadField 点击时会再次初始化 */ + + }); + }) + + .catch((e) => setSubmitError(e instanceof Error ? e.message : '微信授权失败')); + + }, [params, setParams]); + + + + useEffect(() => { + + void fetchPartnerCities() + + .then((items) => { + + setCities(items); + + if (!items.length) setCitiesError('暂无开城城市,请联系总部在后台配置'); + + }) + .catch((e) => setCitiesError(e instanceof Error ? e.message : '加载开城城市失败')); + }, []); + + const regionBindingHint = useMemo(() => { + if (!form.regionCodes.length) return null; + const binding = resolveRegionBinding(form.regionCodes, cities); + if (!binding) return null; + if (binding.cityId && binding.matchedCity) { + return `已匹配开城城市:${binding.matchedCity.name}`; + } + return '所选地区暂未开城,请联系总部配置对应区划'; + }, [form.regionCodes, cities]); + + function patchForm(patch: Partial) { + setForm((prev) => ({ ...prev, ...patch })); - setError(''); + + setSubmitError(''); + + if ('phone' in patch) { + + setFieldErrors((prev) => ({ ...prev, phone: undefined })); + + } + } + + function bindRegionSelection(codes: string[]) { + const binding = resolveRegionBinding(codes, cities); + if (!binding) { + patchForm({ regionCodes: codes, cityId: '', province: '', city: '', district: '' }); + return; + } + patchForm({ + regionCodes: codes, + cityId: binding.cityId ?? '', + province: binding.region.province, + city: binding.region.city, + district: binding.region.district, + }); + } + + function goStep(n: number) { + setParams({ step: String(n) }); - setError(''); + + setSubmitError(''); + } - function handleNext() { + + + async function handleNext() { + if (step === 1) { + const msg = validateStoreStep1(form); - if (msg) { setError(msg); return; } + + if (msg) { + + setSubmitError(msg); + + return; + + } + + setCheckingPhone(true); + + setSubmitError(''); + + setFieldErrors({}); + + try { + + const phoneCheck = await checkStorePhoneAvailable(form.phone.trim()); + + if (!phoneCheck.available) { + + setFieldErrors({ + + phone: phoneCheck.message ?? '该手机号已绑定门店,请更换', + + }); + + return; + + } + + } catch (e) { + + setSubmitError(e instanceof Error ? e.message : '手机号校验失败'); + + return; + + } finally { + + setCheckingPhone(false); + + } + } + + if (step === 2) { + + const msg = validateStoreStep2(form); + + if (msg) { + + setSubmitError(msg); + + return; + + } + + } + goStep(step + 1); + } + + async function submit() { + const msg = validateStoreStep3(form); - if (msg) { setError(msg); return; } + + if (msg) { + + setSubmitError(msg); + + return; + + } + const step1Msg = validateStoreStep1(form); - if (step1Msg) { setError(step1Msg); return; } + + if (step1Msg) { + + setSubmitError(step1Msg); + + goStep(1); + + return; + + } + + const step2Msg = validateStoreStep2(form); + + if (step2Msg) { + + setSubmitError(step2Msg); + + goStep(2); + + return; + + } + + setSubmitting(true); - setError(''); + + setSubmitError(''); + + setFieldErrors({}); + try { + + const phoneCheck = await checkStorePhoneAvailable(form.phone.trim()); + + if (!phoneCheck.available) { + + setFieldErrors({ + + phone: phoneCheck.message ?? '该手机号已绑定门店,请更换', + + }); + + goStep(1); + + return; + + } + + + const envPhotoUrls = form.envPhotoUrls.map((u) => u.trim()).filter(Boolean); + const result = await request<{ store: { id: string } }>('PARTNER_H5', '/partner/stores', { + method: 'POST', + body: JSON.stringify({ + cityId: form.cityId, + province: form.province, + city: form.city, + name: form.name.trim(), + phone: form.phone.trim(), + district: form.district.trim(), + address: form.address.trim(), + intro: form.intro.trim() || undefined, + coverUrl: form.coverUrl.trim() || undefined, + envPhotoUrls: envPhotoUrls.length ? envPhotoUrls : undefined, + contractUrl: form.contractUrl.trim() || undefined, + bankAccountName: form.bankAccountName.trim(), + bankAccountNo: form.bankAccountNo.replace(/\s/g, ''), + bankBranch: form.bankBranch.trim(), + }), + }); + clearStoreDraft(); + navigate(`/stores/${result.store.id}`); + } catch (e) { - setError(e instanceof Error ? e.message : '提交失败'); + + const message = e instanceof Error ? e.message : '提交失败'; + + if (isPhoneConflictMessage(message)) { + + setFieldErrors({ phone: message }); + + goStep(1); + + return; + + } + + setSubmitError(message); + } finally { + setSubmitting(false); + } + } + + const progress = step === 1 ? 0 : step === 2 ? 50 : 100; + const nextDisabled = submitting || checkingPhone; + + + return ( +
+ navigate('/stores')} /> + + - {(error || citiesError) && ( -

{error || citiesError}

+ + + {(submitError || citiesError) && ( + +

{submitError || citiesError}

+ )} + + {step === 1 && ( + <> +
+
+
+

门店基本信息

+
+
+ + + {regionBindingHint && ( +

+ {regionBindingHint} +

+ )} +
+
+ +
+ store + patchForm({ name: e.target.value })} /> +
+
+
+ +
+ call - patchForm({ phone: e.target.value })} /> + + patchForm({ phone: e.target.value })} + + /> +
+ + {fieldErrors.phone && ( + +

{fieldErrors.phone}

+ + )} +
+
+ +