diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx
index e1012d31..bea398d1 100644
--- a/apps/frontend/src/App.tsx
+++ b/apps/frontend/src/App.tsx
@@ -39,6 +39,7 @@ 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 NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
@@ -103,7 +104,10 @@ export default function App() {
return
}
- // 路径式 H5 订单号领取链接(`https://<域名>/h5/<订单号>`,不带 `#`)
+ // H5 领取入口(`https://<域名>/h5` 及其子路径,不带 `#`)。
+ // 判定是**命名空间级**:`/h5` 本身(只有前缀、没有订单号)也算命中。
+ // 否则 `https://<域名>/h5` 会落回主站 HashRouter,被后台路由接管成
+ // `https://<域名>/h5/#/admin/dashboard` 后台登录页(买家点领取链接看到后台登录,历史缺陷)。
if (isH5ClaimPath(window.location.pathname)) {
return
}
@@ -186,10 +190,13 @@ function RedeemApplication() {
* 复用主站 HashRouter 里的**同一个** `ClaimPage`:它只依赖 `useParams` 取 token,
* 内部不 navigate、也不读 hash,所以换成 BrowserRouter 承载不需要复制页面。
*
- * 只在 `isClaimPath` 命中时才会挂载,所以这里的兜底路由正常走不到;
- * 真走到也复用同一个页面(token 为空时页面自己展示「链接无效」),不会出现路由空屏。
+ * `isClaimPath` 是命名空间判定(`/claim` 及子路径都命中),所以必须保留兜底路由:
+ * 只有前缀(`/claim`)或多段路径(`/claim/a/b`)时 `ClaimPage` 取不到 token,
+ * 由页面自己展示「链接无效」,既不会路由空屏,也不会漏回后台路由。
*/
function ClaimApplication() {
+ useClearStrayHash()
+
return (
}>
@@ -202,19 +209,46 @@ function ClaimApplication() {
)
}
+/**
+ * H5 领取入口(`/h5` 命名空间)的独立路由树。
+ *
+ * - `/h5`:只有前缀、没有订单号 → 入口页(买家补订单号 / 粘贴完整链接);
+ * - `/h5/<订单号>`:与分享链接完全相同的领取流程;
+ * - 其余子路径(如 `/h5/a/b`):没有订单号可领,回入口页重新输入,
+ * 避免"前缀下面任何一条路径"漏回后台路由。
+ *
+ * 全程路径式(不带 `#`):后台 HashRouter 永远拿不到 `/h5` 这几个路径。
+ */
function H5ClaimApplication() {
+ useClearStrayHash()
+
return (
}>
+ } />
} />
- } />
+ } />
)
}
+/**
+ * 公开领取页是**路径式**的、从不使用 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 (
diff --git a/apps/frontend/src/pages/claim/H5ClaimEntryPage.tsx b/apps/frontend/src/pages/claim/H5ClaimEntryPage.tsx
new file mode 100644
index 00000000..2e2a670b
--- /dev/null
+++ b/apps/frontend/src/pages/claim/H5ClaimEntryPage.tsx
@@ -0,0 +1,56 @@
+import { SearchOutlined } from '@ant-design/icons'
+import { Button, Card, Form, Input, Typography } from 'antd'
+import { useNavigate } from 'react-router'
+
+import { showError } from '@/lib/feedback'
+import { resolveH5ClaimOrderNoFromInput } from '@/utils/claim-path'
+
+/**
+ * H5 领取入口页(`https://<域名>/h5`,不带订单号)。
+ *
+ * 这条前缀**只允许用来领取**:买家输入订单号后跳到 `/h5/<订单号>`,走与分享链接完全相同的领取流程。
+ *
+ * 为什么必须有这个页面:后台配置的 `claimLinkBase` / `certLink` 都是 `https://<域名>/h5`,
+ * 平台/客服可能直接把基址给买家。历史上这条路径没有命中任何领取判定,于是落回主站 HashRouter
+ * 被后台路由接管,买家点开看到的是 `https://<域名>/h5/#/admin/dashboard` 后台登录页。
+ * 现在 `/h5` 整段前缀由领取应用接管,这里给出「补订单号」的入口,把链接救回来。
+ */
+export default function H5ClaimEntryPage() {
+ const navigate = useNavigate()
+
+ function handleSubmit(values: { orderNo?: string }) {
+ const orderNo = resolveH5ClaimOrderNoFromInput(values.orderNo)
+ if (!orderNo) {
+ showError('没有识别到订单号,请粘贴完整领取链接或直接输入订单号')
+ return
+ }
+
+ void navigate(`/h5/${encodeURIComponent(orderNo)}`)
+ }
+
+ return (
+
+
+
+ 订单领取
+
+
+ 输入订单号即可进入领取流程;也可以直接粘贴完整的领取链接(形如 https://…/h5/订单号)。
+
+
+
+
+
+ }>
+ 开始领取
+
+
+
+
+ )
+}
diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css
index cf773882..d306b8ac 100644
--- a/apps/frontend/src/styles/client.css
+++ b/apps/frontend/src/styles/client.css
@@ -499,6 +499,13 @@
margin-bottom: 0;
}
+/* ===== H5 领取入口页(`/h5`,只有前缀没有订单号) ===== */
+
+/* 复用领取页的卡片与背景,只把单卡在视口内垂直居中 */
+.h5-claim-entry {
+ justify-content: center;
+}
+
/* ===== 买家页面响应式断点 ===== */
@media (max-width: 720px) {
diff --git a/apps/frontend/src/utils/claim-path.ts b/apps/frontend/src/utils/claim-path.ts
index 10d8f290..8ff49def 100644
--- a/apps/frontend/src/utils/claim-path.ts
+++ b/apps/frontend/src/utils/claim-path.ts
@@ -1,10 +1,20 @@
/**
- * 路径式领取链接(`https://<域名>/claim/`,不带 `#`)的路径判定。
+ * 路径式公开领取链接的路径判定与解析。
*
* 与 `utils/redeem.ts` 同理:把路径解析抽成纯函数,路由与页面只负责调用。
* 刻意不依赖 React / antd,也不使用 `@/` 别名,
* 方便 `node --experimental-strip-types --test` 直接跑单测。
*
+ * 判定分两层,两层职责不同,不能混用:
+ * - **命名空间判定**(`isClaimPath` / `isH5ClaimPath`):`/claim`、`/h5` 整棵前缀都算命中,
+ * 用在路由分发上,把这些公开前缀**整段**交给领取页。
+ * 必须是命名空间级:`https://<域名>/h5`(只有前缀、没有订单号)如果判定为"不命中",
+ * 就会落回主站 HashRouter 被后台路由接管,买家点领取链接看到的是
+ * `https://<域名>/h5/#/admin/dashboard` 后台登录页(历史缺陷,必须堵死)。
+ * - **业务参数解析**(`resolveClaimToken` / `resolveH5ClaimOrderNo`):严格要求
+ * `/claim/<一段 token>`、`/h5/<一段订单号>`;命名空间本身(`/claim`、`/h5`)与多段路径
+ * 都取不到参数,交由页面展示"链接无效"或让买家手动补订单号。
+ *
* 注意两条领取入口**互不影响**:
* - 路径式(买家分享链接):`https://<域名>/claim/`,`pathname` 为 `/claim/`;
* - hash 式(主站打手领取页):`https://<域名>/#/claim/`,`pathname` 为 `/`,
@@ -24,19 +34,21 @@
* 返回**解码后**的 token;不命中时返回空串。
*/
export function resolveClaimToken(pathname: unknown): string {
- const normalized = String(pathname ?? '').trim()
- if (!normalized.startsWith('/')) return ''
-
- const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
+ const segments = splitPathSegments(pathname)
if (segments.length !== 2 || segments[0] !== 'claim') return ''
return decodeTokenSegment(segments[1])
}
-/** 是否为路径式领取链接(`/claim/`) */
+/**
+ * 是否为路径式领取链接(`/claim` 命名空间)。
+ *
+ * 命名空间级判定:`/claim`、`/claim/`、`/claim/`、`/claim/a/b` 全部命中,
+ * 保证 `/claim` 开头的路径永远由领取页接管,不会漏进后台路由。
+ */
export function isClaimPath(pathname: unknown): boolean {
- return resolveClaimToken(pathname) !== ''
+ return splitPathSegments(pathname)[0] === 'claim'
}
/**
@@ -44,23 +56,70 @@ export function isClaimPath(pathname: unknown): boolean {
*
* 只认 `/h5/<一段 orderNo>`:
* - `/h5/2623001922675146` 命中,结尾多余斜杠容忍(`/h5/2623001922675146/`);
- * - `/h5`、`/h5/`、`/h5/a/b` 都不命中;
+ * - `/h5`、`/h5/`、`/h5/a/b` 都不命中(此时页面用入口页让买家补订单号);
* - 查询串 / hash 片段(如 `/h5/123?x=1`)会被丢弃后取第一段。
*/
export function resolveH5ClaimOrderNo(pathname: unknown): string {
- const normalized = String(pathname ?? '').trim()
- if (!normalized.startsWith('/')) return ''
-
- const segments = normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
+ const segments = splitPathSegments(pathname)
if (segments.length !== 2 || segments[0] !== 'h5') return ''
return decodeTokenSegment(segments[1])
}
-/** 是否为 H5 订单号领取链接(`/h5/`) */
+/**
+ * 是否为 H5 订单号领取链接(`/h5` 命名空间)。
+ *
+ * 命名空间级判定:`/h5`、`/h5/`、`/h5/<订单号>`、`/h5/a/b` 全部命中。
+ * `/h5` 本身也要命中——它是由后台配置的 `claimLinkBase` / `certLink`(`https://<域名>/h5`)
+ * 直接落到买家手里的形态,必须走领取入口页,绝不允许被后台路由接管。
+ */
export function isH5ClaimPath(pathname: unknown): boolean {
- return resolveH5ClaimOrderNo(pathname) !== ''
+ return splitPathSegments(pathname)[0] === 'h5'
+}
+
+/**
+ * 从「买家手输 / 粘贴」的内容里取 H5 领取订单号(`/h5` 入口页用)。
+ *
+ * - 纯订单号:`2628601934580683` → 原样返回;
+ * - 粘贴整条链接:`https://ks.khhao.com/h5/2628601934580683` → 取 `2628601934580683`;
+ * - 缺协议的域名:`ks.khhao.com/h5/123` → 取 `123`;
+ * - 只有前缀的链接:`https://ks.khhao.com/h5` → 返回空串(没有可领取的订单,
+ * 由页面提示买家里重新粘贴完整链接)。
+ */
+export function resolveH5ClaimOrderNoFromInput(value: unknown): string {
+ const text = String(value ?? '').trim()
+ if (!text) return ''
+
+ // 含 `/`、`?`、`#` 或协议前缀时按链接处理:只认路径里的订单号,
+ // 避免把整条 URL 当成订单号发去后端(那样必然查不到单)。
+ const looksLikeLink = /[/?#]/.test(text) || /^[a-z][a-z0-9+.-]*:/i.test(text)
+ if (!looksLikeLink) return text
+
+ return resolveH5ClaimOrderNo(extractPathname(text))
+}
+
+/** 归一化 `pathname` → 路径段数组:丢弃查询串 / hash 片段与结尾斜杠,非 `/` 开头视为不命中。 */
+function splitPathSegments(pathname: unknown): string[] {
+ const normalized = String(pathname ?? '').trim()
+ if (!normalized.startsWith('/')) return []
+
+ return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
+}
+
+/** 从用户输入里取 pathname(容忍缺协议、非法 URL);解析失败时原样返回,交给上层判定。 */
+function extractPathname(text: string): string {
+ try {
+ return new URL(text).pathname
+ } catch {
+ // 没写协议(`ks.khhao.com/h5/123`):补一个协议再解析
+ }
+
+ try {
+ return new URL(`http://${text}`).pathname
+ } catch {
+ return text
+ }
}
function decodeTokenSegment(segment: string): string {
diff --git a/apps/frontend/tests/claim-path.test.ts b/apps/frontend/tests/claim-path.test.ts
index b0bbaa31..f3a1ee15 100644
--- a/apps/frontend/tests/claim-path.test.ts
+++ b/apps/frontend/tests/claim-path.test.ts
@@ -6,20 +6,24 @@ import {
isH5ClaimPath,
resolveClaimToken,
resolveH5ClaimOrderNo,
+ resolveH5ClaimOrderNoFromInput,
} from '../src/utils/claim-path.ts'
-test('isClaimPath:只认路径式 /claim/,容忍结尾斜杠', () => {
+test('isClaimPath:/claim 命名空间整段命中(含只有前缀、多段路径)', () => {
assert.equal(isClaimPath('/claim/abc123'), true)
assert.equal(isClaimPath('/claim/abc123/'), true)
assert.equal(isClaimPath('/claim/abc123//'), true)
+ // 只有前缀 / 没有 token 也要命中:否则会被主站 HashRouter 接管跳到后台登录
+ assert.equal(isClaimPath('/claim'), true)
+ assert.equal(isClaimPath('/claim/'), true)
+ assert.equal(isClaimPath('/claim/a/b'), true)
})
-test('isClaimPath:/claim、/claim/、多段路径、其它路径都不算', () => {
- assert.equal(isClaimPath('/claim'), false)
- assert.equal(isClaimPath('/claim/'), false)
- assert.equal(isClaimPath('/claim/a/b'), false)
+test('isClaimPath:其它路径、非路径开头的串都不算', () => {
assert.equal(isClaimPath('/redeem'), false)
+ assert.equal(isClaimPath('/h5/123'), false)
assert.equal(isClaimPath('/admin/claim/abc'), false)
+ assert.equal(isClaimPath('/claimx/abc'), false)
assert.equal(isClaimPath('/'), false)
assert.equal(isClaimPath(''), false)
assert.equal(isClaimPath(null), false)
@@ -41,21 +45,74 @@ test('resolveClaimToken:取出并解码 token 片段', () => {
assert.equal(resolveClaimToken('/claim/%E4%B8%AD%E6%96%87'), '中文')
// 非法百分号编码退回原始片段,交给后端判定
assert.equal(resolveClaimToken('/claim/100%'), '100%')
+ // 只有前缀 / 多段路径取不到 token,由页面展示「链接无效」
+ assert.equal(resolveClaimToken('/claim'), '')
+ assert.equal(resolveClaimToken('/claim/a/b'), '')
assert.equal(resolveClaimToken('/redeem'), '')
})
-test('isH5ClaimPath:只认路径式 /h5/,容忍结尾斜杠', () => {
+test('isH5ClaimPath:/h5 命名空间整段命中(含只有前缀、多段路径)', () => {
assert.equal(isH5ClaimPath('/h5/2623001922675146'), true)
assert.equal(isH5ClaimPath('/h5/2623001922675146/'), true)
- assert.equal(isH5ClaimPath('/h5'), false)
- assert.equal(isH5ClaimPath('/h5/'), false)
- assert.equal(isH5ClaimPath('/h5/a/b'), false)
- assert.equal(isH5ClaimPath('/claim/123'), false)
+ // 关键回归:`https://<域名>/h5`(后台配置的链接基址)必须命领取入口,不能落回后台路由
+ assert.equal(isH5ClaimPath('/h5'), true)
+ assert.equal(isH5ClaimPath('/h5/'), true)
+ assert.equal(isH5ClaimPath('/h5/a/b'), true)
+ assert.equal(isH5ClaimPath('/h5#/admin/dashboard'), true)
})
-test('resolveH5ClaimOrderNo:正确解析并解码订单号', () => {
+test('isH5ClaimPath:相邻前缀与其它路径都不算', () => {
+ assert.equal(isH5ClaimPath('/h5x'), false)
+ assert.equal(isH5ClaimPath('/h5x/1'), false)
+ assert.equal(isH5ClaimPath('/claim/123'), false)
+ assert.equal(isH5ClaimPath('/admin/h5/1'), false)
+ assert.equal(isH5ClaimPath('/'), false)
+ assert.equal(isH5ClaimPath(undefined), false)
+})
+
+test('resolveH5ClaimOrderNo:只认 /h5/<一段订单号>,前缀本身与多段路径返回空串', () => {
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146'), '2623001922675146')
assert.equal(resolveH5ClaimOrderNo('/h5/2623001922675146?from=sms'), '2623001922675146')
assert.equal(resolveH5ClaimOrderNo('/h5/ORDER-001/'), 'ORDER-001')
+ assert.equal(resolveH5ClaimOrderNo('/h5/%E4%B8%AD%E6%96%87'), '中文')
+ assert.equal(resolveH5ClaimOrderNo('/h5'), '')
+ assert.equal(resolveH5ClaimOrderNo('/h5/'), '')
+ assert.equal(resolveH5ClaimOrderNo('/h5/a/b'), '')
assert.equal(resolveH5ClaimOrderNo('/claim/123'), '')
})
+
+test('resolveH5ClaimOrderNoFromInput:纯订单号原样返回', () => {
+ assert.equal(resolveH5ClaimOrderNoFromInput('2628601934580683'), '2628601934580683')
+ assert.equal(resolveH5ClaimOrderNoFromInput(' 2628601934580683 '), '2628601934580683')
+ assert.equal(resolveH5ClaimOrderNoFromInput('ORDER-001'), 'ORDER-001')
+ assert.equal(resolveH5ClaimOrderNoFromInput(''), '')
+ assert.equal(resolveH5ClaimOrderNoFromInput(null), '')
+})
+
+test('resolveH5ClaimOrderNoFromInput:粘贴整条链接时取路径里的订单号', () => {
+ assert.equal(
+ resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683'),
+ '2628601934580683',
+ )
+ // 缺协议 / 带查询串 / 带后台残留 hash 都要能取出订单号
+ assert.equal(
+ resolveH5ClaimOrderNoFromInput('ks.khhao.com/h5/2628601934580683'),
+ '2628601934580683',
+ )
+ assert.equal(
+ resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683?from=sms'),
+ '2628601934580683',
+ )
+ assert.equal(
+ resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/2628601934580683#/admin/dashboard'),
+ '2628601934580683',
+ )
+})
+
+test('resolveH5ClaimOrderNoFromInput:链接里没有订单号时返回空串(不把整条 URL 当订单号)', () => {
+ assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5'), '')
+ assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5/'), '')
+ assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/h5#/admin/dashboard'), '')
+ // 不是 /h5 领取链接的地址同样取不到订单号
+ assert.equal(resolveH5ClaimOrderNoFromInput('https://ks.khhao.com/claim/abc123'), '')
+})