Files
order_site/apps/frontend/src/App.tsx
T
2026-11-02 12:04:32 +08:00

344 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Suspense, lazy, useEffect } from 'react'
import { Spin } from 'antd'
import {
BrowserRouter,
HashRouter,
Navigate,
Outlet,
Route,
Routes,
useLocation,
} from 'react-router'
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
import { isCollectMaterialLinkPath } from '@/utils/collect-link'
import { isRedeemPath } from '@/utils/redeem'
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
import { hasWorkerSession } from '@/utils/worker-auth'
import { resolveLegacyWorkerPath } from '@/utils/worker-routes'
import AdminLayout from '@/layouts/AdminLayout'
import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage'))
const AdminFinanceLedgerPage = lazy(() => import('@/pages/admin/AdminFinanceLedgerPage'))
const AdminSalesProfitPage = lazy(() => import('@/pages/admin/AdminSalesProfitPage'))
const AdminCostCenterPage = lazy(() => import('@/pages/admin/AdminCostCenterPage'))
const AdminFulfillmentChannelPage = lazy(() => import('@/pages/admin/AdminFulfillmentChannelPage'))
const AdminFundPage = lazy(() => import('@/pages/admin/AdminFundPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage'))
const AdminDouyinMashidaCertificatePage = lazy(
() => import('@/pages/admin/AdminDouyinMashidaCertificatePage'),
)
const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage'))
const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage'))
const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage'))
const AdminPlatformFulfillmentPage = lazy(
() => import('@/pages/admin/platform/AdminPlatformFulfillmentPage'),
)
const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage'))
const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage'))
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
const CollectMaterialLinkEntryPage = lazy(
() => import('@/pages/collect/CollectMaterialLinkEntryPage'),
)
const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage'))
const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage'))
const WorkerAfterSalesPage = lazy(() => import('@/pages/worker/WorkerAfterSalesPage'))
const WorkerConsignmentPage = lazy(() => import('@/pages/worker/WorkerConsignmentPage'))
const WorkerProfilePage = lazy(() => import('@/pages/worker/WorkerProfilePage'))
function RequireAdmin() {
const location = useLocation()
if (!hasAdminSession()) {
return <Navigate to="/admin/login" replace state={{ from: location }} />
}
return <Outlet />
}
function RequireRole({ roles }: { roles: Array<'admin' | 'operator' | 'support' | 'finance'> }) {
if (!roles.includes(getAdminRole())) {
return (
<Navigate to={getAdminRole() === 'finance' ? '/admin/finance' : '/admin/dashboard'} replace />
)
}
return <Outlet />
}
function LoginRoute() {
if (hasAdminSession()) {
return <Navigate to="/admin/dashboard" replace />
}
return <AdminLoginPage />
}
function RequireWorker() {
const location = useLocation()
if (!hasWorkerSession()) {
return <Navigate to="/" replace state={{ from: location }} />
}
return <Outlet />
}
export default function App() {
const legacyWorkerPath = resolveLegacyWorkerPath(window.location.hash)
if (legacyWorkerPath) {
return <WorkerSiteRedirect path={legacyWorkerPath} />
}
// 路径式领取链接(`https://<域名>/claim/<token>`,不带 `#`)复用主站现成的领取页:
// 买家输 UID → 出二维码 → 扫码绑定 → 比对 UID → 发货完成。
// 必须早于站点判断,用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
// 注意:主站打手领取页是 HashRouter 里的 `#/claim/:token`,它的 pathname 是 `/`,
// 不会命中这里(判定见 utils/claim-path.isClaimPath),两个入口互不影响。
if (isClaimPath(window.location.pathname)) {
return <ClaimApplication />
}
// H5 领取入口(`https://<域名>/h5` 及其子路径,不带 `#`)。
// 判定是**命名空间级**:`/h5` 本身(只有前缀、没有订单号)也算命中。
// 否则 `https://<域名>/h5` 会落回主站 HashRouter,被后台路由接管成
// `https://<域名>/h5/#/admin/dashboard` 后台登录页(买家点领取链接看到后台登录,历史缺陷)。
if (isH5ClaimPath(window.location.pathname)) {
return <H5ClaimApplication />
}
// 固定兑换链接是路径式(`https://<域名>/redeem`,不带 `#`),必须早于站点判断,
// 用独立的 BrowserRouter 分支承载,主站 HashRouter 不受影响。
if (isRedeemPath(window.location.pathname)) {
return <RedeemApplication />
}
// 补资料页(`/supplement`):路径式公开页,先于站点判断,
// 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。
if (isCollectMaterialLinkPath(window.location.pathname)) {
return <CollectMaterialLinkApplication />
}
if (isWorkerSite()) {
return <WorkerApplication />
}
return <AdminApplication />
}
function AdminApplication() {
return (
<HashRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/" element={<Navigate to="/admin/dashboard" replace />} />
<Route path="/admin/login" element={<LoginRoute />} />
<Route element={<RequireAdmin />}>
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/dashboard" replace />} />
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
<Route path="dashboard" element={<AdminDashboardPage />} />
<Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'finance']} />}>
<Route path="finance" element={<AdminFinancePage />} />
<Route path="finance/ledger" element={<AdminFinanceLedgerPage />} />
<Route path="sales-profit" element={<AdminSalesProfitPage />} />
<Route path="cost-center" element={<AdminCostCenterPage />} />
<Route path="fulfillment-channel" element={<AdminFulfillmentChannelPage />} />
</Route>
<Route element={<RequireRole roles={['admin', 'operator', 'support', 'finance']} />}>
<Route path="fund" element={<AdminFundPage />} />
</Route>
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
{/* 旧「配置 → 对接客服」地址已并入接单平台 tab,书签继续可用。 */}
<Route
path="consignment-contacts"
element={<Navigate to="/admin/worker-platform?tab=consignment-contacts" replace />}
/>
<Route element={<RequireRole roles={['admin', 'operator', 'support']} />}>
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
<Route
path="douyin-mashida-certificates"
element={<AdminDouyinMashidaCertificatePage />}
/>
<Route path="dev-mock" element={<AdminDevMockPage />} />
</Route>
<Route element={<RequireRole roles={['admin']} />}>
<Route path="users" element={<AdminUsersPage />} />
<Route path="platform-shops" element={<AdminPlatformShopsPage />} />
<Route path="platform-fulfillment" element={<AdminPlatformFulfillmentPage />} />
<Route path="audit-logs" element={<AdminAuditLogsPage />} />
</Route>
</Route>
</Route>
<Route path="/claim/:token" element={<ClaimPage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
</HashRouter>
)
}
function RedeemApplication() {
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/redeem" element={<RedeemPage />} />
<Route path="*" element={<Navigate to="/redeem" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
/**
* 路径式领取页(`/claim/<token>`)的独立路由树。
*
* 复用主站 HashRouter 里的**同一个** `ClaimPage`:它只依赖 `useParams` 取 token,
* 内部不 navigate、也不读 hash,所以换成 BrowserRouter 承载不需要复制页面。
*
* `isClaimPath` 是命名空间判定(`/claim` 及子路径都命中),所以必须保留兜底路由:
* 只有前缀(`/claim`)或多段路径(`/claim/a/b`)时 `ClaimPage` 取不到 token,
* 由页面自己展示「链接无效」,既不会路由空屏,也不会漏回后台路由。
*/
function ClaimApplication() {
useClearStrayHash()
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/claim/:token" element={<ClaimPage />} />
<Route path="*" element={<ClaimPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
/**
* 补资料页(`/supplement`)的独立路由树。
*
* - `/supplement`:输入平台订单号;
* - `/supplement/<订单号>`:直接打开对应订单的补资料页;
* - 其余子路径:交给补资料页展示「订单号无效」。
*
* 路径式公开页,不带 hash;不依赖后台会话。
*/
function CollectMaterialLinkApplication() {
useClearStrayHash()
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/supplement" element={<CollectMaterialLinkEntryPage />} />
<Route path="/supplement/:code" element={<CollectMaterialLinkPage />} />
<Route path="*" element={<CollectMaterialLinkPage />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
/**
* H5 领取入口(`/h5` 命名空间)的独立路由树。
*
* - `/h5`:只有前缀、没有订单号 → 入口页(买家补订单号 / 粘贴完整链接);
* - `/h5/<订单号>`:与分享链接完全相同的领取流程;
* - 其余子路径(如 `/h5/a/b`):没有订单号可领,回入口页重新输入,
* 避免"前缀下面任何一条路径"漏回后台路由。
*
* 全程路径式(不带 `#`):后台 HashRouter 永远拿不到 `/h5` 这几个路径。
*/
function H5ClaimApplication() {
useClearStrayHash()
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/h5" element={<H5ClaimEntryPage />} />
<Route path="/h5/:orderNo" element={<ClaimPage />} />
<Route path="*" element={<Navigate to="/h5" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
/**
* 公开领取页是**路径式**的、从不使用 hash 路由,但历史链接可能带着后台残留的 hash
* (例如 `https://<域名>/h5#/admin/dashboard`)。
* 挂载时把 hash 抹掉,避免买家在地址栏看到、并转发一条"看着像后台地址"的领取链接。
*/
function useClearStrayHash() {
useEffect(() => {
if (!window.location.hash) return
const { pathname, search } = window.location
window.history.replaceState(window.history.state, '', `${pathname}${search}`)
}, [])
}
function WorkerApplication() {
return (
<BrowserRouter>
<Suspense fallback={<RouteLoading />}>
<Routes>
<Route path="/" element={<WorkerLoginPage />} />
<Route element={<RequireWorker />}>
<Route element={<WorkerLayout />}>
<Route path="hall" element={<WorkerHallPage />} />
<Route path="orders" element={<WorkerOrdersPage />} />
<Route path="orders/after-sales" element={<WorkerAfterSalesPage />} />
<Route path="consignment" element={<WorkerConsignmentPage />} />
<Route path="profile" element={<WorkerProfilePage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</BrowserRouter>
)
}
function WorkerSiteRedirect({ path }: { path: string }) {
useEffect(() => {
window.location.replace(`${WORKER_SITE_ORIGIN}${path}`)
}, [path])
return <RouteLoading />
}
function RouteLoading() {
return (
<div className="route-loading">
<Spin />
</div>
)
}