{
  "config": {
    "argv": [
      "/opt/hostedtoolcache/node/20.20.2/x64/bin/node",
      "/home/runner/work/dr-card-web/dr-card-web/node_modules/.bin/playwright",
      "test",
      "tests/e2e/test-gate.spec.ts"
    ],
    "configFile": "/home/runner/work/dr-card-web/dr-card-web/playwright.config.ts",
    "rootDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
    "failOnFlakyTests": false,
    "forbidOnly": true,
    "fullyParallel": true,
    "globalSetup": null,
    "globalTeardown": null,
    "globalTimeout": 0,
    "grep": {},
    "grepInvert": null,
    "maxFailures": 0,
    "metadata": {
      "ci": {
        "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/b90810f6a3efba9d779b434bffd0900ef4295624",
        "commitHash": "b90810f6a3efba9d779b434bffd0900ef4295624",
        "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/234",
        "prTitle": "chore(release): promote redeem code project selection dev → test",
        "prBaseHash": "db5f72063c3148c95786e49640bc7c49c189681d",
        "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38699828892"
      },
      "gitCommit": {
        "shortHash": "b90810f",
        "hash": "b90810f6a3efba9d779b434bffd0900ef4295624",
        "subject": "Merge 23f2eb16f13888c0feb4386da4a503017aa2431e into db5f72063c3148c95786e49640bc7c49c189681d",
        "body": "Merge 23f2eb16f13888c0feb4386da4a503017aa2431e into db5f72063c3148c95786e49640bc7c49c189681d\n",
        "author": {
          "name": "Tomz Dang",
          "email": "dangjingtao@163.com",
          "time": 1792049751000
        },
        "committer": {
          "name": "GitHub",
          "email": "noreply@github.com",
          "time": 1792049751000
        },
        "branch": "HEAD"
      },
      "gitDiff": "diff --git a/docs/engineering/card-code-redeem-contract.md b/docs/engineering/card-code-redeem-contract.md\nindex 4950923..2d17c04 100644\n--- a/docs/engineering/card-code-redeem-contract.md\n+++ b/docs/engineering/card-code-redeem-contract.md\n@@ -1,67 +1,39 @@\n-# 兑换卡券｜H5 ↔ 后台正式协议对照\n+# 兑换码换卡券｜H5 ↔ 后台现行协议（2026-10-14）\n \n-> 来源：2026-10-12 后台同事交接的「兑换码核兑接口」，由用户在本线程提供。此文档记录**已确认的对接合同**，不宣称已部署、已完成数据库联调或真机验收。H5 修改仅在 GitHub `dr-card-ui` 进行，本地 API 项目只读。\n+> 本文件记录 H5 当前协议。10-12 旧版仅传 code 并返回本地用户卡券四字段回执，已被新协议替代。仅修改 H5，不修改后台；真机和实际到账留给 test 验收。\n \n-## 1. 用户兑换码核兑\n+## 1. 交互\n \n-```http\n-POST /api/cardredemptioncodes/redeem\n-Authorization: Bearer <accessToken>\n-Content-Type: application/json\n-```\n-\n-```json\n-{ \"code\": \"ABC123456789\" }\n-```\n-\n-- 仅提交 `code`；`user_id`、`card_id`、`status` 都由服务端确定。\n-- **不少于 10 位**；不额外限制输入字符类型、大小写或前端最大长度，也不自动删除空格、转换大写。\n-- 后端当前数据库表中 `code` 字段长度为 64；这是存储约束，不是 H5 要主动增加的校验规则。\n-- 服务端核对状态 10 未兑换、20 已兑换、30 已过期、40 已禁用，验证有效期和卡模板，采用事务核兑发券。\n-- 客户端用标准 `code === 0` 判断业务成功，HTTP 200 + `code:500` 是失败，展示 `message`；HTTP 401 交给现有登录态处理。\n+输入原始兑换码（至少 10 字符）→ 全屏选择适用片区 → 确认后请求发券 → 成功页或错误反馈。\n \n-### 成功响应\n+- 选择页复用 ProjectSelectScreen，但顶部展示原始兑换码；核兑之前没有券详情，不虚构未知券名称、金额、图片和泡泡值。\n+- 项目列表每页 15 条；无关键词使用 GET /api/couponskbs/index；搜索使用 POST /api/coupons/getProjects，请求 shop_name / page / pageSize。\n+- 选中项目的 id 独立保存：后续搜索无关词、加载下一页或无结果，均不得清除已选状态。\n+- 选择项目不发券，只有确认按钮触发核兑；后端最终决定片区和兑换码是否适配。\n+- code 原样保留，不去空格，不改大小写；登录身份由 HTTP 鉴权提供。\n \n-```json\n-{\n-  \"code\": 0,\n-  \"msg\": \"success\",\n-  \"status\": \"succ\",\n-  \"data\": {\n-    \"user_card_id\": 21,\n-    \"card_id\": 4,\n-    \"card_name\": \"洗护体验卡\",\n-    \"card_sn\": \"RC00000000000014\"\n-  }\n-}\n-```\n+## 2. 用户核兑接口\n \n-H5 校验四个回执字段后显示成功页；任一关键字段缺失视为协议错误，不把未确认的发券结果当作成功。\n-\n-### 失败响应示例\n+POST /api/cardredemptioncodes/redeem\n+Authorization: Bearer accessToken\n \n-```json\n-{ \"code\": 500, \"message\": \"该兑换码已被使用\", \"data\": [] }\n-```\n+请求 JSON：\n+{\"code\":\"ABC123456789\",\"project_id\":123}\n \n-包括格式不足、兑换码无效／已使用／已过期／已禁用／尚未生效、模板不可用等；错误文本由后台提供。\n+成功 JSON：\n+{\"code\":0,\"data\":{\"coupons_id\":7}}\n \n-## 2. 兑换成功后的卡包展示\n+- 严格检查业务 code = 0 和成功响应的 coupons_id 正整数。\n+- 旧版 user_card_id / card_id / card_name / card_sn 不再是响应字段，不得继续在成功页展示旧名称或伪造卡券编号。\n+- 业务失败展示服务端 message；401 走现有身份处理。网络失败或超时不模拟成功；超时结果未知，提示先去卡包查看。\n+- 确认时锁定输入的 code，提交中禁止重复调用，失败可保留片区选择供用户处理。\n \n-**必须同时区分两种券来源，不混淆券 ID：**\n+## 3. 兑换后卡包\n \n-| 来源 | 读取接口 | 用途 |\n-|---|---|---|\n-| 本地用户卡包 | `GET /api/usercards/index` | 兑换码核兑券，`source=60`，`source_id` 对应兑换码 ID |\n-| 第三方优惠卡 | `POST /api/coupons/MyCoupons` | 既有第三方优惠卡列表；不会包含本地兑换码券 |\n+新协议兑换成功后可从 /card 查看第三方券，读取 POST /api/coupons/MyCoupons；coupons_id 为券模板标识，不能用作单张券的序列号、核销号或转赠 ID。\n \n-- 本地列表读取当前登录用户的分页数据，**完整分页后**筛选 `source===60`，防止在单页过滤遗漏后续兑换券。\n-- 本地卡券依据已返回的 `status=10/20/30` 对应「可用／已使用／已过期」；不将第三方记录的字段硬套成本地券。\n-- 后端没有在此协议里提供本地券使用／转赠业务入口，因此 H5 **不把本地 ID 当作第三方 MyCoupons 的核销或转赠 ID**，相应按钮不可用。\n-- 本地卡包与第三方列表各自加载与报错，不能因为一个来源失败就伪造另一个来源的数据。\n+历史 GET /api/usercards/index 的 source=60 本地卡券读取保留作为存量兼容，不再将新核兑结果假定为本地 source=60 卡券。两个读取源各自独立，不虚构到账。\n \n-## 3. 后端归属与验收边界\n+## 4. 验收边界\n \n-- 后端管理接口 `/admin/cardredemptioncodes/*` 不由 H5 调用；用户端旧 CRUD `/api/cardredemptioncodes/{index,add,update,delete,detail,select}` 已关闭。\n-- H5 只调用 `redeem` 与已确认的本地卡包读取接口，不修改后台实现。\n-- 真实 API 的环境部署、DB 联通、授权、发券到账和 App WebView 仍需在 test 环境验证；Mock 成功不是业务验收。\n+Vitest/Playwright/PR CI 负责前端请求体、项目搜索分页、已选项保持、失败及成功结果验证。只有在 test App WebView + 真实 API + 登录状态下，才能验证实际兑换码、第三方发券到账和卡包回显。没有实际验收不能宣称闭环已通过。\ndiff --git a/package.json b/package.json\nindex c783770..9dff200 100644\n--- a/package.json\n+++ b/package.json\n@@ -26,7 +26,7 @@\n     \"build:cf\": \"npm run typecheck && npm run verify:images && node scripts/build-h5.mjs cf\",\n     \"preview\": \"vite preview --host 0.0.0.0\",\n     \"test:e2e\": \"npm run test:e2e:formal\",\n-    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts\",\n+    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts\",\n     \"test:e2e:bridge-lab\": \"playwright test tests/e2e/bridge-lab.spec.ts\",\n     \"test:e2e:test-gate\": \"playwright test tests/e2e/test-gate.spec.ts\",\n     \"test:e2e:ui\": \"playwright test tests/e2e/formal-h5.spec.ts --ui\",\ndiff --git a/src/pages/Redeem.test.tsx b/src/pages/Redeem.test.tsx\nindex e4e75ab..9500f40 100644\n--- a/src/pages/Redeem.test.tsx\n+++ b/src/pages/Redeem.test.tsx\n@@ -1,5 +1,5 @@\n-import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { MemoryRouter } from 'react-router-dom'\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n+import { createMemoryRouter, RouterProvider } from 'react-router-dom'\n import type { ReactNode } from 'react'\n import { afterEach, describe, expect, it, vi } from 'vitest'\n \n@@ -8,20 +8,27 @@ const mocks = vi.hoisted(() => ({ redeemCardCode: vi.fn() }))\n vi.mock('../services/redeemCode', () => ({\n   redeemCardCode: mocks.redeemCardCode,\n }))\n-\n+vi.mock('./exchange/useCouponProjects', () => ({\n+  useCouponProjects: (keyword: string) => ({\n+    items: keyword === '天河'\n+      ? [{ id: 14, shop_name: 'Mock·广州天河店', activity_name: 'Mock·活动', discountcard_no: 'MOCK' }]\n+      : [{ id: 12, shop_name: 'Mock·南沙蕉门店', activity_name: 'Mock·活动', discountcard_no: 'MOCK' }],\n+    page: 1, lastPage: 1, total: 1,\n+    loading: false, loadingMore: false, hasMore: false,\n+    error: null, loadMoreError: null, restartRequired: false,\n+    reload: vi.fn(), loadMore: vi.fn(),\n+  }),\n+}))\n vi.mock('../app/config/runtime', async (importOriginal) => {\n   const original = await importOriginal<typeof import('../app/config/runtime')>()\n   return { ...original, runtimePolicy: { ...original.runtimePolicy, dataMode: 'api' } }\n })\n-\n vi.mock('../app/fixtures/useFixture', () => ({\n   useFixtureState: () => ({ state: null }),\n }))\n-\n vi.mock('../app/router/routes', () => ({\n   findRouteByPathname: () => ({ path: '/redeem' }),\n }))\n-\n vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n vi.mock('../components/mobile/PageContainer', () => ({\n   default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n@@ -30,65 +37,103 @@ vi.mock('../components/mobile/PageContainer', () => ({\n import Redeem from './Redeem'\n import { AppError } from '../lib/appError'\n \n-const receipt = {\n-  user_card_id: 21, card_id: 4,\n-  card_name: '洗护体验卡',\n-  card_sn: 'RC00000000000014',\n+const renderRedeem = () => {\n+  const router = createMemoryRouter(\n+    [{ path: '/before', element: <div>上一页</div> }, { path: '/redeem', element: <Redeem /> }],\n+    { initialEntries: ['/before', '/redeem'] },\n+  )\n+  render(<RouterProvider router={router} />)\n+  return router\n+}\n+const enterCode = (code = 'Abc- 123456') => {\n+  fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+    target: { value: code },\n+  })\n+  fireEvent.click(screen.getByRole('button', { name: '选择适用片区' }))\n+  return screen.getByRole('dialog', { name: '选择适用片区' })\n+}\n+const selectProject = (dialog: HTMLElement) => {\n+  fireEvent.click(within(dialog).getByRole('radio', { name: /Mock·南沙蕉门店/ }))\n+  return within(dialog).getByRole('button', { name: '确认兑换' })\n }\n+afterEach(() => mocks.redeemCardCode.mockReset())\n \n-const renderRedeem = () => render(\n-  <MemoryRouter initialEntries={['/redeem']}><Redeem /></MemoryRouter>,\n-)\n+describe('Redeem updated API and shared full-screen project selector', () => {\n+  it('does not request redemption until project selected, then submits original code and id', async () => {\n+    mocks.redeemCardCode.mockResolvedValue({ coupons_id: 7 })\n+    renderRedeem()\n+    const dialog = enterCode()\n+    expect(within(dialog).getByText('Abc- 123456')).toBeTruthy()\n+    expect(within(dialog).queryByText(/泡泡值余额/)).toBeNull()\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+    expect((within(dialog).getByRole('button', { name: '请选择适用片区' }) as HTMLButtonElement).disabled).toBe(true)\n \n-afterEach(() => mocks.redeemCardCode.mockReset())\n+    fireEvent.click(selectProject(dialog))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456', 12))\n+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull())\n+    expect(screen.getByText('兑换成功')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+    expect(screen.queryByText(/卡券编号：/)).toBeNull()\n+  })\n \n-describe('Redeem confirmed backend integration', () => {\n-  it('preserves exact code and displays actual issued card receipt only after backend success', async () => {\n-    mocks.redeemCardCode.mockResolvedValue(receipt)\n+  it('keeps selected project even when searching a different keyword', async () => {\n+    mocks.redeemCardCode.mockResolvedValue({ coupons_id: 15 })\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'Abc- 123456' },\n+    const dialog = enterCode('ABC123456789')\n+    selectProject(dialog)\n+    fireEvent.change(within(dialog).getByRole('searchbox', { name: '搜索片区或项目名称' }), {\n+      target: { value: '天河' },\n     })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456'))\n-    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n-    expect(screen.getByText('卡券编号：RC00000000000014')).toBeTruthy()\n-    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+    await waitFor(() => expect(within(dialog).getByRole('radio', { name: /Mock·广州天河店/ })).toBeTruthy())\n+    expect(within(dialog).getByRole('region', { name: '已选片区' }).textContent).toContain('南沙蕉门')\n+    fireEvent.click(within(dialog).getByRole('button', { name: '确认兑换' }))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('ABC123456789', 12))\n   })\n \n-  it('keeps the form for backend business errors and displays its error message', async () => {\n+  it('system/browser back closes the selector first without adding a history entry', async () => {\n+    const router = renderRedeem()\n+    const dialog = enterCode('ABC123456789')\n+    await router.navigate(-1)\n+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull())\n+    expect(router.state.location.pathname).toBe('/redeem')\n+    expect(screen.getByRole('textbox', { name: '兑换码' })).toHaveProperty('value', 'ABC123456789')\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+    expect(dialog.isConnected).toBe(false)\n+  })\n+\n+  it('keeps the picker and original code when the backend returns a business error', async () => {\n     mocks.redeemCardCode.mockRejectedValue(new Error('该兑换码已被使用'))\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'ABC123456789' },\n-    })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    expect(await screen.findByText('该兑换码已被使用')).toBeTruthy()\n+    const dialog = enterCode('ABC123456789')\n+    fireEvent.click(selectProject(dialog))\n+    expect(await within(dialog).findByText('该兑换码已被使用')).toBeTruthy()\n+    expect(within(dialog).getByText('ABC123456789')).toBeTruthy()\n     expect(screen.queryByText('兑换成功')).toBeNull()\n-    expect(screen.getByRole('button', { name: '确认兑换' })).toBeTruthy()\n+    expect((within(dialog).getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(false)\n+    fireEvent.click(within(dialog).getByRole('button', { name: '返回兑换码' }))\n+    expect(screen.getByRole('textbox', { name: '兑换码' })).toHaveProperty('value', 'ABC123456789')\n   })\n \n-  it('shows neutral user-facing text when backend receipt fails contract validation', async () => {\n+  it('avoids false success when receipt is not verifiable', async () => {\n     mocks.redeemCardCode.mockRejectedValue(new AppError({\n       kind: 'contract', message: '外部数据不符合 cardredemptioncodes.redeem.data 契约',\n     }))\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'ABC123456789' },\n-    })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    expect(await screen.findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n+    const dialog = enterCode('ABC123456789')\n+    fireEvent.click(selectProject(dialog))\n+    expect(await within(dialog).findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n     expect(screen.queryByText(/cardredemptioncodes.redeem.data/)).toBeNull()\n     expect(screen.queryByText('兑换成功')).toBeNull()\n   })\n \n-  it('disables submitting fewer than ten characters, but does not alter or uppercase content', () => {\n+  it('disables entry under ten chars, preserves formatting, and offers no picker', () => {\n     renderRedeem()\n     fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n       target: { value: 'a1-b2+3' },\n     })\n     expect((screen.getByRole('textbox', { name: '兑换码' }) as HTMLInputElement).value).toBe('a1-b2+3')\n-    expect((screen.getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '选择适用片区' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.queryByRole('dialog')).toBeNull()\n     expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/Redeem.tsx b/src/pages/Redeem.tsx\nindex 7aae9f5..de200d6 100644\n--- a/src/pages/Redeem.tsx\n+++ b/src/pages/Redeem.tsx\n@@ -1,11 +1,12 @@\n import { useEffect, useRef, useState } from 'react'\n-import { useNavigate } from 'react-router-dom'\n-import { AlertCircle, CheckCircle2, Loader2, Ticket, XCircle } from 'lucide-react'\n+import { useBlocker, useNavigate } from 'react-router-dom'\n+import { AlertCircle, CheckCircle2, Ticket, XCircle } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { isAppError } from '../services/http'\n import { runtimePolicy } from '../app/config/runtime'\n import { redeemCardCode, type RedemptionReceipt } from '../services/redeemCode'\n import PageContainer from '../components/mobile/PageContainer'\n+import ProjectSelectScreen from './exchange/ProjectSelectScreen'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n import {\n@@ -34,6 +35,19 @@ export default function Redeem() {\n   const [outcome, setOutcome] = useState<RedeemOutcome | null>(null)\n   const [errorMessage, setErrorMessage] = useState<string | null>(null)\n   const [receipt, setReceipt] = useState<RedemptionReceipt | null>(null)\n+  const [pendingCode, setPendingCode] = useState<string | null>(null)\n+  const submissionLock = useRef(false)\n+  // A full-screen in-page step must consume BrowserRouter POP/back without\n+  // pushing an artificial history entry (UX-E). Submission stays protected.\n+  const blocker = useBlocker(pendingCode !== null)\n+  useEffect(() => {\n+    if (blocker.state !== 'blocked') return\n+    if (!submissionLock.current && !submitting) {\n+      setPendingCode(null)\n+      setErrorMessage(null)\n+    }\n+    blocker.reset()\n+  }, [blocker, submitting])\n   const timers = useRef<number[]>([])\n \n   const trimmed = code\n@@ -46,6 +60,7 @@ export default function Redeem() {\n     if (!key || !(key in REDEEM_SAMPLE_CODES)) return\n     setCode(REDEEM_SAMPLE_CODES[key])\n     setReceipt(null)\n+    setPendingCode(null)\n     setOutcome(key)\n     setErrorMessage(null)\n     setSubmitting(false)\n@@ -58,42 +73,61 @@ export default function Redeem() {\n   }\n \n   const clear = () => {\n+    setPendingCode(null)\n     setReceipt(null)\n     setCode('')\n     setOutcome(null)\n     setErrorMessage(null)\n   }\n \n-  const submit = async () => {\n-    if (trimmed.length < REDEEM_CODE_RULE.minLength || submitting) return\n+  const openProjectSelector = () => {\n+    if (code.length < REDEEM_CODE_RULE.minLength || submitting) return\n+    // Freeze exactly what the user entered. Searching must not alter the code.\n+    setPendingCode(code)\n+    setErrorMessage(null)\n+    setOutcome(null)\n+  }\n+\n+  const submit = async (projectId: number) => {\n+    if (!pendingCode || pendingCode.length < REDEEM_CODE_RULE.minLength ||\n+        submitting || submissionLock.current) return\n+    const originalCode = pendingCode\n+    submissionLock.current = true\n     setOutcome(null)\n     setReceipt(null)\n     setErrorMessage(null)\n     setSubmitting(true)\n     if (runtimePolicy.dataMode === 'mock') {\n-      /** Mock 只在预览环境产生确定性演示结果，API 模式绝不模拟成功。 */\n+      /** Legacy preview fixture is explicitly mock-only; no real issue occurs here. */\n       track(() => {\n+        const next = resolveRedeemOutcome(originalCode)\n+        if (next === 'success') {\n+          setOutcome('success')\n+          setPendingCode(null)\n+        } else {\n+          setErrorMessage(REDEEM_FEEDBACK[next].text)\n+        }\n         setSubmitting(false)\n-        const next = resolveRedeemOutcome(trimmed)\n-        setOutcome(next)\n-        if (next !== 'success') track(() => setOutcome(null), 2000)\n+        submissionLock.current = false\n       }, 700)\n       return\n     }\n     try {\n-      const nextReceipt = await redeemCardCode(trimmed)\n+      const nextReceipt = await redeemCardCode(originalCode, projectId)\n       setReceipt(nextReceipt)\n+      setPendingCode(null)\n       setOutcome('success')\n     } catch (error) {\n       if (isAppError(error) && error.kind === 'contract') {\n         setErrorMessage('兑换结果暂时无法确认，请稍后重试')\n       } else if (isAppError(error) && (error.kind === 'network' || error.kind === 'timeout')) {\n-        setErrorMessage('网络异常，请稍后重试')\n+        setErrorMessage('网络异常，兑换结果可能尚未确认，请先到卡包查看')\n       } else {\n         setErrorMessage(error instanceof Error ? error.message : '兑换失败，请稍后重试')\n       }\n     } finally {\n       setSubmitting(false)\n+      submissionLock.current = false\n     }\n   }\n \n@@ -106,7 +140,7 @@ export default function Redeem() {\n           </span>\n           <h1 className=\"text-lg font-semibold text-text-primary\">兑换成功</h1>\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{REDEEM_FEEDBACK.success.text}</p>\n-          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">{receipt.card_name}</p>}\n+          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">已提交卡券兑换，可前往卡包查看</p>}\n         </div>\n \n         <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\">\n@@ -119,7 +153,7 @@ export default function Redeem() {\n           <div className=\"min-w-0 flex-1\">\n             <p className=\"text-xs text-text-tertiary\">兑换码</p>\n             <p className=\"mt-0.5 truncate text-base font-semibold tracking-[0.08em] text-text-primary\">{trimmed}</p>\n-            {receipt && <p className=\"mt-1 truncate text-xs text-text-secondary\">卡券编号：{receipt.card_sn}</p>}\n+\n           </div>\n         </section>\n \n@@ -172,17 +206,10 @@ export default function Redeem() {\n       <button\n         type=\"button\"\n         disabled={trimmed.length < REDEEM_CODE_RULE.minLength || submitting}\n-        onClick={submit}\n+        onClick={openProjectSelector}\n         className=\"mt-6 flex h-12 w-full items-center justify-center gap-2 rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n       >\n-        {submitting ? (\n-          <>\n-            <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden />\n-            提交中\n-          </>\n-        ) : (\n-          '确认兑换'\n-        )}\n+        选择适用片区\n       </button>\n \n       {feedback && (\n@@ -198,6 +225,21 @@ export default function Redeem() {\n         </div>\n       )}\n \n+      {pendingCode !== null && (\n+        <ProjectSelectScreen\n+          mode=\"code\"\n+          code={pendingCode}\n+          submitting={submitting}\n+          submitError={errorMessage}\n+          onClose={() => {\n+            if (submitting) return\n+            setPendingCode(null)\n+            setErrorMessage(null)\n+          }}\n+          onConfirm={(projectId) => { void submit(projectId) }}\n+        />\n+      )}\n+\n       <DebugPanel route={route} />\n     </PageContainer>\n   )\ndiff --git a/src/pages/exchange/ProjectSelectScreen.tsx b/src/pages/exchange/ProjectSelectScreen.tsx\nindex 4e91488..8a57c2b 100644\n--- a/src/pages/exchange/ProjectSelectScreen.tsx\n+++ b/src/pages/exchange/ProjectSelectScreen.tsx\n@@ -1,19 +1,19 @@\n import { useEffect, useMemo, useRef, useState, type UIEvent } from 'react'\n import { createPortal } from 'react-dom'\n-import { Check, ChevronLeft, Search, X } from 'lucide-react'\n+import { Check, ChevronLeft, Search, Ticket, X } from 'lucide-react'\n import type { CouponRedeemView } from '../../services/coupons'\n import type { CouponProject } from '../../services/couponProjects'\n import { useCouponProjects } from './useCouponProjects'\n \n-interface Props {\n-  coupon: CouponRedeemView\n-  fallbackImage: string\n-  balance: number | null\n+type Props = {\n   submitting: boolean\n   submitError: string | null\n   onClose: () => void\n   onConfirm: (projectId: number) => void\n-}\n+} & (\n+  | { mode: 'code'; code: string }\n+  | { mode?: 'coupon'; coupon: CouponRedeemView; fallbackImage: string; balance: number | null }\n+)\n \n function ProjectOption({ project, checked, onChoose }: {\n   project: CouponProject\n@@ -45,9 +45,8 @@ function ProjectOption({ project, checked, onChoose }: {\n }\n \n /** Full-screen, in-page exchange step. Selection survives every search and page change. */\n-export default function ProjectSelectScreen({\n-  coupon, fallbackImage, balance, submitting, submitError, onClose, onConfirm,\n-}: Props) {\n+export default function ProjectSelectScreen(props: Props) {\n+  const { submitting, submitError, onClose, onConfirm } = props\n   const [input, setInput] = useState('')\n   const [query, setQuery] = useState('')\n   const [selected, setSelected] = useState<CouponProject | null>(null)\n@@ -131,7 +130,7 @@ export default function ProjectSelectScreen({\n             type=\"button\"\n             disabled={submitting}\n             onClick={onClose}\n-            aria-label=\"返回兑换专区\"\n+            aria-label={props.mode === 'code' ? '返回兑换码' : '返回兑换专区'}\n             className=\"flex h-10 w-10 items-center justify-center rounded-full active:bg-[#F7EEE3] disabled:opacity-50\"\n           >\n             <ChevronLeft size={23} strokeWidth={1.9} aria-hidden />\n@@ -139,21 +138,39 @@ export default function ProjectSelectScreen({\n           <h1 className=\"text-center text-[16px] font-semibold\">选择适用片区</h1>\n         </div>\n \n-        <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n-          <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n-            <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n-          </div>\n-          <div className=\"flex items-center gap-3\">\n-            <img src={coupon.image || fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n-            <div className=\"min-w-0 flex-1\">\n-              <p className=\"truncate text-[15px] font-semibold leading-5\">{coupon.name}</p>\n-              <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{coupon.desc}</p>\n-              <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n-                <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{coupon.cost}</span>泡泡值\n-              </p>\n+        {props.mode === 'code' ? (\n+          <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] py-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待核兑兑换码\">\n+            <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+              <span>待核兑兑换码</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n             </div>\n-          </div>\n-        </section>\n+            <div className=\"flex items-center gap-3\">\n+              <span className=\"flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-[10px] bg-[#FFF2E9] text-[#DF4800]\" aria-hidden>\n+                <Ticket size={28} strokeWidth={1.7} />\n+              </span>\n+              <div className=\"min-w-0 flex-1\">\n+                <p className=\"text-[15px] font-semibold leading-5\">兑换码兑换卡券</p>\n+                <p className=\"mt-1 break-all text-[12px] leading-[19px] text-[#8B8379]\">{props.code}</p>\n+                <p className=\"mt-[5px] text-[11px] text-[#996B30]\">选择片区后确认核兑</p>\n+              </div>\n+            </div>\n+          </section>\n+        ) : (\n+          <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n+            <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+              <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n+            </div>\n+            <div className=\"flex items-center gap-3\">\n+              <img src={props.coupon.image || props.fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n+              <div className=\"min-w-0 flex-1\">\n+                <p className=\"truncate text-[15px] font-semibold leading-5\">{props.coupon.name}</p>\n+                <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{props.coupon.desc}</p>\n+                <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n+                  <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{props.coupon.cost}</span>泡泡值\n+                </p>\n+              </div>\n+            </div>\n+          </section>\n+        )}\n \n         <div className=\"px-4 pb-[10px] pt-[15px]\">\n           <div className=\"flex h-[46px] items-center gap-[10px] rounded-full border border-[#EEE6DC] bg-white px-[15px] focus-within:border-[#ED9C69]\">\n@@ -241,20 +258,24 @@ export default function ProjectSelectScreen({\n \n       <footer className=\"shrink-0 border-t border-[#F1E9DF] bg-[#FFFBF5] px-4 pb-[max(16px,env(safe-area-inset-bottom))] pt-3\">\n         {submitError && <p role=\"alert\" className=\"mb-2 text-[12px] text-danger-text\">{submitError}</p>}\n-        {coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n-        {!coupon.soldOut && balance !== null && balance < coupon.cost && (\n-          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n-        )}\n-        {!coupon.soldOut && balance === null && (\n-          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+        {props.mode !== 'code' && (\n+          <>\n+            {props.coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n+            {!props.coupon.soldOut && props.balance !== null && props.balance < props.coupon.cost && (\n+              <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n+            )}\n+            {!props.coupon.soldOut && props.balance === null && (\n+              <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+            )}\n+            <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n+              <span>泡泡值余额 <b className=\"text-[#38302A]\">{props.balance?.toLocaleString() ?? '--'}</b></span>\n+              <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{props.coupon.cost}</b> 泡泡值</span>\n+            </div>\n+          </>\n         )}\n-        <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n-          <span>泡泡值余额 <b className=\"text-[#38302A]\">{balance?.toLocaleString() ?? '--'}</b></span>\n-          <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{coupon.cost}</b> 泡泡值</span>\n-        </div>\n         <button\n           type=\"button\"\n-          disabled={!selected || submitting || balance === null || balance < coupon.cost || coupon.soldOut}\n+          disabled={!selected || submitting || (props.mode !== 'code' && (props.balance === null || props.balance < props.coupon.cost || props.coupon.soldOut))}\n           onClick={() => { if (selected) onConfirm(selected.id) }}\n           className=\"h-[49px] w-full rounded-full bg-[#DF4800] text-[15px] font-semibold text-white active:bg-[#C83D00] disabled:bg-[#DCCFC1]\"\n         >\ndiff --git a/src/services/redeemCode.test.ts b/src/services/redeemCode.test.ts\nindex 0ef54fb..e8f7a29 100644\n--- a/src/services/redeemCode.test.ts\n+++ b/src/services/redeemCode.test.ts\n@@ -4,35 +4,37 @@ import { REDEEM_CARD_CODE_PATH, redeemCardCode } from './redeemCode'\n \n afterEach(() => vi.restoreAllMocks())\n \n-const receipt = {\n-  user_card_id: 21,\n-  card_id: 4,\n-  card_name: '洗护体验卡',\n-  card_sn: 'RC00000000000014',\n-}\n-\n-describe('redeemCardCode confirmed backend contract', () => {\n-  it('sends original code without changing whitespace/case and returns validated receipt', async () => {\n+describe('redeemCardCode updated backend contract', () => {\n+  it('preserves exact code, sends selected project_id, and parses coupons_id', async () => {\n     const request = vi.spyOn(httpClient, 'request').mockResolvedValue({\n-      code: 0, msg: 'success', status: 'succ', data: receipt,\n+      code: 0, msg: 'success', status: 'succ', data: { coupons_id: 7 },\n     })\n-    await expect(redeemCardCode('Abc- 123456')).resolves.toEqual(receipt)\n+    await expect(redeemCardCode('Abc- 123456', 31)).resolves.toEqual({ coupons_id: 7 })\n     expect(request).toHaveBeenCalledWith({\n-      method: 'POST', url: REDEEM_CARD_CODE_PATH, data: { code: 'Abc- 123456' },\n+      method: 'POST', url: REDEEM_CARD_CODE_PATH,\n+      data: { code: 'Abc- 123456', project_id: 31 },\n     })\n   })\n \n-  it('HTTP 200 business failures are not success', async () => {\n+  it('rejects invalid project_id without sending a request', async () => {\n+    const request = vi.spyOn(httpClient, 'request')\n+    await expect(redeemCardCode('ABC123456789', 0)).rejects.toThrow('请选择适用片区')\n+    expect(request).not.toHaveBeenCalled()\n+  })\n+\n+  it('does not treat a backend business failure as success', async () => {\n     vi.spyOn(httpClient, 'request').mockResolvedValue({\n       code: 500, message: '该兑换码已被使用', data: [],\n     })\n-    await expect(redeemCardCode('ABC123456789')).rejects.toThrow('该兑换码已被使用')\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow('该兑换码已被使用')\n   })\n \n-  it('rejects code=0 with incomplete user-card receipt', async () => {\n-    vi.spyOn(httpClient, 'request').mockResolvedValue({\n-      code: 0, msg: 'success', status: 'succ', data: { card_id: 4 },\n-    })\n-    await expect(redeemCardCode('ABC123456789')).rejects.toThrow()\n+  it('rejects old four-field receipts or malformed coupons_id', async () => {\n+    const request = vi.spyOn(httpClient, 'request')\n+    request.mockResolvedValueOnce({\n+      code: 0, data: { user_card_id: 21, card_id: 4, card_name: '旧卡', card_sn: 'OLD001' },\n+    }).mockResolvedValueOnce({ code: 0, data: { coupons_id: null } })\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow()\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow()\n   })\n })\ndiff --git a/src/services/redeemCode.ts b/src/services/redeemCode.ts\nindex b71b1df..f856de4 100644\n--- a/src/services/redeemCode.ts\n+++ b/src/services/redeemCode.ts\n@@ -6,20 +6,18 @@ import { httpClient } from './http'\n export const REDEEM_CARD_CODE_PATH = '/api/cardredemptioncodes/redeem'\n \n const redemptionReceiptSchema = z.object({\n-  user_card_id: z.number().int().positive(),\n-  card_id: z.number().int().positive(),\n-  card_name: z.string(),\n-  card_sn: z.string().min(1),\n+  coupons_id: z.number().int().positive(),\n }).passthrough()\n \n export type RedemptionReceipt = z.infer<typeof redemptionReceiptSchema>\n \n-/** 用户只传原始 code，登录身份由统一 HTTP 层携带，成功后返回后台发券回执。 */\n-export async function redeemCardCode(code: string): Promise<RedemptionReceipt> {\n+/** 保留原始 code，并按所选片区提交 project_id；登录身份由 HTTP 层携带。 */\n+export async function redeemCardCode(code: string, projectId: number): Promise<RedemptionReceipt> {\n+  if (!Number.isInteger(projectId) || projectId <= 0) throw new Error('请选择适用片区')\n   const payload = await httpClient.request<unknown>({\n     method: 'POST',\n     url: REDEEM_CARD_CODE_PATH,\n-    data: { code },\n+    data: { code, project_id: projectId },\n   })\n   return parseApiEnvelope(payload, redemptionReceiptSchema, {\n     contract: 'cardredemptioncodes.redeem',\ndiff --git a/tests/e2e/redeem-code-project-selector.spec.ts b/tests/e2e/redeem-code-project-selector.spec.ts\nnew file mode 100644\nindex 0000000..74d1c9d\n--- /dev/null\n+++ b/tests/e2e/redeem-code-project-selector.spec.ts\n@@ -0,0 +1,29 @@\n+import { expect, test } from '@playwright/test'\n+\n+test('@formal-h5 code redemption opens full-screen project selector without calling issue first', async ({ page }) => {\n+  await page.goto('/redeem', { waitUntil: 'domcontentloaded' })\n+  const input = page.getByRole('textbox', { name: '兑换码' })\n+  await expect(input).toBeVisible()\n+  await input.fill('Abc- 123456')\n+  await page.getByRole('button', { name: '选择适用片区' }).click()\n+\n+  const dialog = page.getByRole('dialog', { name: '选择适用片区' })\n+  await expect(dialog).toBeVisible()\n+  await expect(dialog.getByRole('region', { name: '待核兑兑换码' })).toContainText('Abc- 123456')\n+  await expect(dialog.getByText('泡泡值余额')).toHaveCount(0)\n+  await expect(dialog.getByRole('button', { name: '请选择适用片区' })).toBeDisabled()\n+  const project = dialog.getByRole('radio', { name: /Mock·深圳南山科技园/ })\n+  await expect(project).toBeVisible()\n+  await project.click()\n+  await expect(dialog.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  await dialog.getByRole('searchbox', { name: '搜索片区或项目名称' }).fill('天河')\n+  await expect(dialog.getByRole('radio', { name: /Mock·广州天河体育西/ })).toBeVisible()\n+  await expect(dialog.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  await expect(dialog.getByRole('button', { name: '确认兑换' })).toBeEnabled()\n+\n+  // Close before requesting a side-effect. The original code and URL remain unchanged.\n+  await dialog.getByRole('button', { name: '返回兑换码' }).click()\n+  await expect(dialog).toHaveCount(0)\n+  await expect(input).toHaveValue('Abc- 123456')\n+  expect(new URL(page.url()).pathname).toBe('/redeem')\n+})",
      "actualWorkers": 2
    },
    "preserveOutput": "always",
    "projects": [
      {
        "outputDir": "/home/runner/work/dr-card-web/dr-card-web/test-results/artifacts",
        "repeatEach": 1,
        "retries": 1,
        "metadata": {
          "ci": {
            "commitHref": "https://github.com/sanchuang-dev/dr-card-web/commit/b90810f6a3efba9d779b434bffd0900ef4295624",
            "commitHash": "b90810f6a3efba9d779b434bffd0900ef4295624",
            "prHref": "https://github.com/sanchuang-dev/dr-card-web/pull/234",
            "prTitle": "chore(release): promote redeem code project selection dev → test",
            "prBaseHash": "db5f72063c3148c95786e49640bc7c49c189681d",
            "buildHref": "https://github.com/sanchuang-dev/dr-card-web/actions/runs/38699828892"
          },
          "gitCommit": {
            "shortHash": "b90810f",
            "hash": "b90810f6a3efba9d779b434bffd0900ef4295624",
            "subject": "Merge 23f2eb16f13888c0feb4386da4a503017aa2431e into db5f72063c3148c95786e49640bc7c49c189681d",
            "body": "Merge 23f2eb16f13888c0feb4386da4a503017aa2431e into db5f72063c3148c95786e49640bc7c49c189681d\n",
            "author": {
              "name": "Tomz Dang",
              "email": "dangjingtao@163.com",
              "time": 1792049751000
            },
            "committer": {
              "name": "GitHub",
              "email": "noreply@github.com",
              "time": 1792049751000
            },
            "branch": "HEAD"
          },
          "gitDiff": "diff --git a/docs/engineering/card-code-redeem-contract.md b/docs/engineering/card-code-redeem-contract.md\nindex 4950923..2d17c04 100644\n--- a/docs/engineering/card-code-redeem-contract.md\n+++ b/docs/engineering/card-code-redeem-contract.md\n@@ -1,67 +1,39 @@\n-# 兑换卡券｜H5 ↔ 后台正式协议对照\n+# 兑换码换卡券｜H5 ↔ 后台现行协议（2026-10-14）\n \n-> 来源：2026-10-12 后台同事交接的「兑换码核兑接口」，由用户在本线程提供。此文档记录**已确认的对接合同**，不宣称已部署、已完成数据库联调或真机验收。H5 修改仅在 GitHub `dr-card-ui` 进行，本地 API 项目只读。\n+> 本文件记录 H5 当前协议。10-12 旧版仅传 code 并返回本地用户卡券四字段回执，已被新协议替代。仅修改 H5，不修改后台；真机和实际到账留给 test 验收。\n \n-## 1. 用户兑换码核兑\n+## 1. 交互\n \n-```http\n-POST /api/cardredemptioncodes/redeem\n-Authorization: Bearer <accessToken>\n-Content-Type: application/json\n-```\n-\n-```json\n-{ \"code\": \"ABC123456789\" }\n-```\n-\n-- 仅提交 `code`；`user_id`、`card_id`、`status` 都由服务端确定。\n-- **不少于 10 位**；不额外限制输入字符类型、大小写或前端最大长度，也不自动删除空格、转换大写。\n-- 后端当前数据库表中 `code` 字段长度为 64；这是存储约束，不是 H5 要主动增加的校验规则。\n-- 服务端核对状态 10 未兑换、20 已兑换、30 已过期、40 已禁用，验证有效期和卡模板，采用事务核兑发券。\n-- 客户端用标准 `code === 0` 判断业务成功，HTTP 200 + `code:500` 是失败，展示 `message`；HTTP 401 交给现有登录态处理。\n+输入原始兑换码（至少 10 字符）→ 全屏选择适用片区 → 确认后请求发券 → 成功页或错误反馈。\n \n-### 成功响应\n+- 选择页复用 ProjectSelectScreen，但顶部展示原始兑换码；核兑之前没有券详情，不虚构未知券名称、金额、图片和泡泡值。\n+- 项目列表每页 15 条；无关键词使用 GET /api/couponskbs/index；搜索使用 POST /api/coupons/getProjects，请求 shop_name / page / pageSize。\n+- 选中项目的 id 独立保存：后续搜索无关词、加载下一页或无结果，均不得清除已选状态。\n+- 选择项目不发券，只有确认按钮触发核兑；后端最终决定片区和兑换码是否适配。\n+- code 原样保留，不去空格，不改大小写；登录身份由 HTTP 鉴权提供。\n \n-```json\n-{\n-  \"code\": 0,\n-  \"msg\": \"success\",\n-  \"status\": \"succ\",\n-  \"data\": {\n-    \"user_card_id\": 21,\n-    \"card_id\": 4,\n-    \"card_name\": \"洗护体验卡\",\n-    \"card_sn\": \"RC00000000000014\"\n-  }\n-}\n-```\n+## 2. 用户核兑接口\n \n-H5 校验四个回执字段后显示成功页；任一关键字段缺失视为协议错误，不把未确认的发券结果当作成功。\n-\n-### 失败响应示例\n+POST /api/cardredemptioncodes/redeem\n+Authorization: Bearer accessToken\n \n-```json\n-{ \"code\": 500, \"message\": \"该兑换码已被使用\", \"data\": [] }\n-```\n+请求 JSON：\n+{\"code\":\"ABC123456789\",\"project_id\":123}\n \n-包括格式不足、兑换码无效／已使用／已过期／已禁用／尚未生效、模板不可用等；错误文本由后台提供。\n+成功 JSON：\n+{\"code\":0,\"data\":{\"coupons_id\":7}}\n \n-## 2. 兑换成功后的卡包展示\n+- 严格检查业务 code = 0 和成功响应的 coupons_id 正整数。\n+- 旧版 user_card_id / card_id / card_name / card_sn 不再是响应字段，不得继续在成功页展示旧名称或伪造卡券编号。\n+- 业务失败展示服务端 message；401 走现有身份处理。网络失败或超时不模拟成功；超时结果未知，提示先去卡包查看。\n+- 确认时锁定输入的 code，提交中禁止重复调用，失败可保留片区选择供用户处理。\n \n-**必须同时区分两种券来源，不混淆券 ID：**\n+## 3. 兑换后卡包\n \n-| 来源 | 读取接口 | 用途 |\n-|---|---|---|\n-| 本地用户卡包 | `GET /api/usercards/index` | 兑换码核兑券，`source=60`，`source_id` 对应兑换码 ID |\n-| 第三方优惠卡 | `POST /api/coupons/MyCoupons` | 既有第三方优惠卡列表；不会包含本地兑换码券 |\n+新协议兑换成功后可从 /card 查看第三方券，读取 POST /api/coupons/MyCoupons；coupons_id 为券模板标识，不能用作单张券的序列号、核销号或转赠 ID。\n \n-- 本地列表读取当前登录用户的分页数据，**完整分页后**筛选 `source===60`，防止在单页过滤遗漏后续兑换券。\n-- 本地卡券依据已返回的 `status=10/20/30` 对应「可用／已使用／已过期」；不将第三方记录的字段硬套成本地券。\n-- 后端没有在此协议里提供本地券使用／转赠业务入口，因此 H5 **不把本地 ID 当作第三方 MyCoupons 的核销或转赠 ID**，相应按钮不可用。\n-- 本地卡包与第三方列表各自加载与报错，不能因为一个来源失败就伪造另一个来源的数据。\n+历史 GET /api/usercards/index 的 source=60 本地卡券读取保留作为存量兼容，不再将新核兑结果假定为本地 source=60 卡券。两个读取源各自独立，不虚构到账。\n \n-## 3. 后端归属与验收边界\n+## 4. 验收边界\n \n-- 后端管理接口 `/admin/cardredemptioncodes/*` 不由 H5 调用；用户端旧 CRUD `/api/cardredemptioncodes/{index,add,update,delete,detail,select}` 已关闭。\n-- H5 只调用 `redeem` 与已确认的本地卡包读取接口，不修改后台实现。\n-- 真实 API 的环境部署、DB 联通、授权、发券到账和 App WebView 仍需在 test 环境验证；Mock 成功不是业务验收。\n+Vitest/Playwright/PR CI 负责前端请求体、项目搜索分页、已选项保持、失败及成功结果验证。只有在 test App WebView + 真实 API + 登录状态下，才能验证实际兑换码、第三方发券到账和卡包回显。没有实际验收不能宣称闭环已通过。\ndiff --git a/package.json b/package.json\nindex c783770..9dff200 100644\n--- a/package.json\n+++ b/package.json\n@@ -26,7 +26,7 @@\n     \"build:cf\": \"npm run typecheck && npm run verify:images && node scripts/build-h5.mjs cf\",\n     \"preview\": \"vite preview --host 0.0.0.0\",\n     \"test:e2e\": \"npm run test:e2e:formal\",\n-    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts\",\n+    \"test:e2e:formal\": \"playwright test tests/e2e/formal-h5.spec.ts tests/e2e/uxe-exchange.spec.ts tests/e2e/redeem-code-project-selector.spec.ts\",\n     \"test:e2e:bridge-lab\": \"playwright test tests/e2e/bridge-lab.spec.ts\",\n     \"test:e2e:test-gate\": \"playwright test tests/e2e/test-gate.spec.ts\",\n     \"test:e2e:ui\": \"playwright test tests/e2e/formal-h5.spec.ts --ui\",\ndiff --git a/src/pages/Redeem.test.tsx b/src/pages/Redeem.test.tsx\nindex e4e75ab..9500f40 100644\n--- a/src/pages/Redeem.test.tsx\n+++ b/src/pages/Redeem.test.tsx\n@@ -1,5 +1,5 @@\n-import { fireEvent, render, screen, waitFor } from '@testing-library/react'\n-import { MemoryRouter } from 'react-router-dom'\n+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'\n+import { createMemoryRouter, RouterProvider } from 'react-router-dom'\n import type { ReactNode } from 'react'\n import { afterEach, describe, expect, it, vi } from 'vitest'\n \n@@ -8,20 +8,27 @@ const mocks = vi.hoisted(() => ({ redeemCardCode: vi.fn() }))\n vi.mock('../services/redeemCode', () => ({\n   redeemCardCode: mocks.redeemCardCode,\n }))\n-\n+vi.mock('./exchange/useCouponProjects', () => ({\n+  useCouponProjects: (keyword: string) => ({\n+    items: keyword === '天河'\n+      ? [{ id: 14, shop_name: 'Mock·广州天河店', activity_name: 'Mock·活动', discountcard_no: 'MOCK' }]\n+      : [{ id: 12, shop_name: 'Mock·南沙蕉门店', activity_name: 'Mock·活动', discountcard_no: 'MOCK' }],\n+    page: 1, lastPage: 1, total: 1,\n+    loading: false, loadingMore: false, hasMore: false,\n+    error: null, loadMoreError: null, restartRequired: false,\n+    reload: vi.fn(), loadMore: vi.fn(),\n+  }),\n+}))\n vi.mock('../app/config/runtime', async (importOriginal) => {\n   const original = await importOriginal<typeof import('../app/config/runtime')>()\n   return { ...original, runtimePolicy: { ...original.runtimePolicy, dataMode: 'api' } }\n })\n-\n vi.mock('../app/fixtures/useFixture', () => ({\n   useFixtureState: () => ({ state: null }),\n }))\n-\n vi.mock('../app/router/routes', () => ({\n   findRouteByPathname: () => ({ path: '/redeem' }),\n }))\n-\n vi.mock('../components/mobile/DebugPanel', () => ({ default: () => null }))\n vi.mock('../components/mobile/PageContainer', () => ({\n   default: ({ children }: { children: ReactNode }) => <div>{children}</div>,\n@@ -30,65 +37,103 @@ vi.mock('../components/mobile/PageContainer', () => ({\n import Redeem from './Redeem'\n import { AppError } from '../lib/appError'\n \n-const receipt = {\n-  user_card_id: 21, card_id: 4,\n-  card_name: '洗护体验卡',\n-  card_sn: 'RC00000000000014',\n+const renderRedeem = () => {\n+  const router = createMemoryRouter(\n+    [{ path: '/before', element: <div>上一页</div> }, { path: '/redeem', element: <Redeem /> }],\n+    { initialEntries: ['/before', '/redeem'] },\n+  )\n+  render(<RouterProvider router={router} />)\n+  return router\n+}\n+const enterCode = (code = 'Abc- 123456') => {\n+  fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n+    target: { value: code },\n+  })\n+  fireEvent.click(screen.getByRole('button', { name: '选择适用片区' }))\n+  return screen.getByRole('dialog', { name: '选择适用片区' })\n+}\n+const selectProject = (dialog: HTMLElement) => {\n+  fireEvent.click(within(dialog).getByRole('radio', { name: /Mock·南沙蕉门店/ }))\n+  return within(dialog).getByRole('button', { name: '确认兑换' })\n }\n+afterEach(() => mocks.redeemCardCode.mockReset())\n \n-const renderRedeem = () => render(\n-  <MemoryRouter initialEntries={['/redeem']}><Redeem /></MemoryRouter>,\n-)\n+describe('Redeem updated API and shared full-screen project selector', () => {\n+  it('does not request redemption until project selected, then submits original code and id', async () => {\n+    mocks.redeemCardCode.mockResolvedValue({ coupons_id: 7 })\n+    renderRedeem()\n+    const dialog = enterCode()\n+    expect(within(dialog).getByText('Abc- 123456')).toBeTruthy()\n+    expect(within(dialog).queryByText(/泡泡值余额/)).toBeNull()\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+    expect((within(dialog).getByRole('button', { name: '请选择适用片区' }) as HTMLButtonElement).disabled).toBe(true)\n \n-afterEach(() => mocks.redeemCardCode.mockReset())\n+    fireEvent.click(selectProject(dialog))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456', 12))\n+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull())\n+    expect(screen.getByText('兑换成功')).toBeTruthy()\n+    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+    expect(screen.queryByText(/卡券编号：/)).toBeNull()\n+  })\n \n-describe('Redeem confirmed backend integration', () => {\n-  it('preserves exact code and displays actual issued card receipt only after backend success', async () => {\n-    mocks.redeemCardCode.mockResolvedValue(receipt)\n+  it('keeps selected project even when searching a different keyword', async () => {\n+    mocks.redeemCardCode.mockResolvedValue({ coupons_id: 15 })\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'Abc- 123456' },\n+    const dialog = enterCode('ABC123456789')\n+    selectProject(dialog)\n+    fireEvent.change(within(dialog).getByRole('searchbox', { name: '搜索片区或项目名称' }), {\n+      target: { value: '天河' },\n     })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('Abc- 123456'))\n-    expect(await screen.findByText('洗护体验卡')).toBeTruthy()\n-    expect(screen.getByText('卡券编号：RC00000000000014')).toBeTruthy()\n-    expect(screen.getByRole('button', { name: '查看我的卡包' })).toBeTruthy()\n+    await waitFor(() => expect(within(dialog).getByRole('radio', { name: /Mock·广州天河店/ })).toBeTruthy())\n+    expect(within(dialog).getByRole('region', { name: '已选片区' }).textContent).toContain('南沙蕉门')\n+    fireEvent.click(within(dialog).getByRole('button', { name: '确认兑换' }))\n+    await waitFor(() => expect(mocks.redeemCardCode).toHaveBeenCalledWith('ABC123456789', 12))\n   })\n \n-  it('keeps the form for backend business errors and displays its error message', async () => {\n+  it('system/browser back closes the selector first without adding a history entry', async () => {\n+    const router = renderRedeem()\n+    const dialog = enterCode('ABC123456789')\n+    await router.navigate(-1)\n+    await waitFor(() => expect(screen.queryByRole('dialog', { name: '选择适用片区' })).toBeNull())\n+    expect(router.state.location.pathname).toBe('/redeem')\n+    expect(screen.getByRole('textbox', { name: '兑换码' })).toHaveProperty('value', 'ABC123456789')\n+    expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n+    expect(dialog.isConnected).toBe(false)\n+  })\n+\n+  it('keeps the picker and original code when the backend returns a business error', async () => {\n     mocks.redeemCardCode.mockRejectedValue(new Error('该兑换码已被使用'))\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'ABC123456789' },\n-    })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    expect(await screen.findByText('该兑换码已被使用')).toBeTruthy()\n+    const dialog = enterCode('ABC123456789')\n+    fireEvent.click(selectProject(dialog))\n+    expect(await within(dialog).findByText('该兑换码已被使用')).toBeTruthy()\n+    expect(within(dialog).getByText('ABC123456789')).toBeTruthy()\n     expect(screen.queryByText('兑换成功')).toBeNull()\n-    expect(screen.getByRole('button', { name: '确认兑换' })).toBeTruthy()\n+    expect((within(dialog).getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(false)\n+    fireEvent.click(within(dialog).getByRole('button', { name: '返回兑换码' }))\n+    expect(screen.getByRole('textbox', { name: '兑换码' })).toHaveProperty('value', 'ABC123456789')\n   })\n \n-  it('shows neutral user-facing text when backend receipt fails contract validation', async () => {\n+  it('avoids false success when receipt is not verifiable', async () => {\n     mocks.redeemCardCode.mockRejectedValue(new AppError({\n       kind: 'contract', message: '外部数据不符合 cardredemptioncodes.redeem.data 契约',\n     }))\n     renderRedeem()\n-    fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n-      target: { value: 'ABC123456789' },\n-    })\n-    fireEvent.click(screen.getByRole('button', { name: '确认兑换' }))\n-    expect(await screen.findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n+    const dialog = enterCode('ABC123456789')\n+    fireEvent.click(selectProject(dialog))\n+    expect(await within(dialog).findByText('兑换结果暂时无法确认，请稍后重试')).toBeTruthy()\n     expect(screen.queryByText(/cardredemptioncodes.redeem.data/)).toBeNull()\n     expect(screen.queryByText('兑换成功')).toBeNull()\n   })\n \n-  it('disables submitting fewer than ten characters, but does not alter or uppercase content', () => {\n+  it('disables entry under ten chars, preserves formatting, and offers no picker', () => {\n     renderRedeem()\n     fireEvent.change(screen.getByRole('textbox', { name: '兑换码' }), {\n       target: { value: 'a1-b2+3' },\n     })\n     expect((screen.getByRole('textbox', { name: '兑换码' }) as HTMLInputElement).value).toBe('a1-b2+3')\n-    expect((screen.getByRole('button', { name: '确认兑换' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect((screen.getByRole('button', { name: '选择适用片区' }) as HTMLButtonElement).disabled).toBe(true)\n+    expect(screen.queryByRole('dialog')).toBeNull()\n     expect(mocks.redeemCardCode).not.toHaveBeenCalled()\n   })\n })\ndiff --git a/src/pages/Redeem.tsx b/src/pages/Redeem.tsx\nindex 7aae9f5..de200d6 100644\n--- a/src/pages/Redeem.tsx\n+++ b/src/pages/Redeem.tsx\n@@ -1,11 +1,12 @@\n import { useEffect, useRef, useState } from 'react'\n-import { useNavigate } from 'react-router-dom'\n-import { AlertCircle, CheckCircle2, Loader2, Ticket, XCircle } from 'lucide-react'\n+import { useBlocker, useNavigate } from 'react-router-dom'\n+import { AlertCircle, CheckCircle2, Ticket, XCircle } from 'lucide-react'\n import DebugPanel from '../components/mobile/DebugPanel'\n import { isAppError } from '../services/http'\n import { runtimePolicy } from '../app/config/runtime'\n import { redeemCardCode, type RedemptionReceipt } from '../services/redeemCode'\n import PageContainer from '../components/mobile/PageContainer'\n+import ProjectSelectScreen from './exchange/ProjectSelectScreen'\n import { useFixtureState } from '../app/fixtures/useFixture'\n import { findRouteByPathname } from '../app/router/routes'\n import {\n@@ -34,6 +35,19 @@ export default function Redeem() {\n   const [outcome, setOutcome] = useState<RedeemOutcome | null>(null)\n   const [errorMessage, setErrorMessage] = useState<string | null>(null)\n   const [receipt, setReceipt] = useState<RedemptionReceipt | null>(null)\n+  const [pendingCode, setPendingCode] = useState<string | null>(null)\n+  const submissionLock = useRef(false)\n+  // A full-screen in-page step must consume BrowserRouter POP/back without\n+  // pushing an artificial history entry (UX-E). Submission stays protected.\n+  const blocker = useBlocker(pendingCode !== null)\n+  useEffect(() => {\n+    if (blocker.state !== 'blocked') return\n+    if (!submissionLock.current && !submitting) {\n+      setPendingCode(null)\n+      setErrorMessage(null)\n+    }\n+    blocker.reset()\n+  }, [blocker, submitting])\n   const timers = useRef<number[]>([])\n \n   const trimmed = code\n@@ -46,6 +60,7 @@ export default function Redeem() {\n     if (!key || !(key in REDEEM_SAMPLE_CODES)) return\n     setCode(REDEEM_SAMPLE_CODES[key])\n     setReceipt(null)\n+    setPendingCode(null)\n     setOutcome(key)\n     setErrorMessage(null)\n     setSubmitting(false)\n@@ -58,42 +73,61 @@ export default function Redeem() {\n   }\n \n   const clear = () => {\n+    setPendingCode(null)\n     setReceipt(null)\n     setCode('')\n     setOutcome(null)\n     setErrorMessage(null)\n   }\n \n-  const submit = async () => {\n-    if (trimmed.length < REDEEM_CODE_RULE.minLength || submitting) return\n+  const openProjectSelector = () => {\n+    if (code.length < REDEEM_CODE_RULE.minLength || submitting) return\n+    // Freeze exactly what the user entered. Searching must not alter the code.\n+    setPendingCode(code)\n+    setErrorMessage(null)\n+    setOutcome(null)\n+  }\n+\n+  const submit = async (projectId: number) => {\n+    if (!pendingCode || pendingCode.length < REDEEM_CODE_RULE.minLength ||\n+        submitting || submissionLock.current) return\n+    const originalCode = pendingCode\n+    submissionLock.current = true\n     setOutcome(null)\n     setReceipt(null)\n     setErrorMessage(null)\n     setSubmitting(true)\n     if (runtimePolicy.dataMode === 'mock') {\n-      /** Mock 只在预览环境产生确定性演示结果，API 模式绝不模拟成功。 */\n+      /** Legacy preview fixture is explicitly mock-only; no real issue occurs here. */\n       track(() => {\n+        const next = resolveRedeemOutcome(originalCode)\n+        if (next === 'success') {\n+          setOutcome('success')\n+          setPendingCode(null)\n+        } else {\n+          setErrorMessage(REDEEM_FEEDBACK[next].text)\n+        }\n         setSubmitting(false)\n-        const next = resolveRedeemOutcome(trimmed)\n-        setOutcome(next)\n-        if (next !== 'success') track(() => setOutcome(null), 2000)\n+        submissionLock.current = false\n       }, 700)\n       return\n     }\n     try {\n-      const nextReceipt = await redeemCardCode(trimmed)\n+      const nextReceipt = await redeemCardCode(originalCode, projectId)\n       setReceipt(nextReceipt)\n+      setPendingCode(null)\n       setOutcome('success')\n     } catch (error) {\n       if (isAppError(error) && error.kind === 'contract') {\n         setErrorMessage('兑换结果暂时无法确认，请稍后重试')\n       } else if (isAppError(error) && (error.kind === 'network' || error.kind === 'timeout')) {\n-        setErrorMessage('网络异常，请稍后重试')\n+        setErrorMessage('网络异常，兑换结果可能尚未确认，请先到卡包查看')\n       } else {\n         setErrorMessage(error instanceof Error ? error.message : '兑换失败，请稍后重试')\n       }\n     } finally {\n       setSubmitting(false)\n+      submissionLock.current = false\n     }\n   }\n \n@@ -106,7 +140,7 @@ export default function Redeem() {\n           </span>\n           <h1 className=\"text-lg font-semibold text-text-primary\">兑换成功</h1>\n           <p className=\"max-w-[260px] text-sm leading-6 text-text-secondary\">{REDEEM_FEEDBACK.success.text}</p>\n-          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">{receipt.card_name}</p>}\n+          {receipt && <p className=\"max-w-[260px] text-sm font-medium text-text-primary\">已提交卡券兑换，可前往卡包查看</p>}\n         </div>\n \n         <section className=\"mx-4 mt-5 flex items-center gap-3 rounded-container bg-surface p-3 shadow-sm\">\n@@ -119,7 +153,7 @@ export default function Redeem() {\n           <div className=\"min-w-0 flex-1\">\n             <p className=\"text-xs text-text-tertiary\">兑换码</p>\n             <p className=\"mt-0.5 truncate text-base font-semibold tracking-[0.08em] text-text-primary\">{trimmed}</p>\n-            {receipt && <p className=\"mt-1 truncate text-xs text-text-secondary\">卡券编号：{receipt.card_sn}</p>}\n+\n           </div>\n         </section>\n \n@@ -172,17 +206,10 @@ export default function Redeem() {\n       <button\n         type=\"button\"\n         disabled={trimmed.length < REDEEM_CODE_RULE.minLength || submitting}\n-        onClick={submit}\n+        onClick={openProjectSelector}\n         className=\"mt-6 flex h-12 w-full items-center justify-center gap-2 rounded-full bg-primary text-base font-medium text-text-inverse active:bg-primary-pressed disabled:bg-disabled disabled:text-text-disabled\"\n       >\n-        {submitting ? (\n-          <>\n-            <Loader2 className=\"h-4 w-4 animate-spin\" aria-hidden />\n-            提交中\n-          </>\n-        ) : (\n-          '确认兑换'\n-        )}\n+        选择适用片区\n       </button>\n \n       {feedback && (\n@@ -198,6 +225,21 @@ export default function Redeem() {\n         </div>\n       )}\n \n+      {pendingCode !== null && (\n+        <ProjectSelectScreen\n+          mode=\"code\"\n+          code={pendingCode}\n+          submitting={submitting}\n+          submitError={errorMessage}\n+          onClose={() => {\n+            if (submitting) return\n+            setPendingCode(null)\n+            setErrorMessage(null)\n+          }}\n+          onConfirm={(projectId) => { void submit(projectId) }}\n+        />\n+      )}\n+\n       <DebugPanel route={route} />\n     </PageContainer>\n   )\ndiff --git a/src/pages/exchange/ProjectSelectScreen.tsx b/src/pages/exchange/ProjectSelectScreen.tsx\nindex 4e91488..8a57c2b 100644\n--- a/src/pages/exchange/ProjectSelectScreen.tsx\n+++ b/src/pages/exchange/ProjectSelectScreen.tsx\n@@ -1,19 +1,19 @@\n import { useEffect, useMemo, useRef, useState, type UIEvent } from 'react'\n import { createPortal } from 'react-dom'\n-import { Check, ChevronLeft, Search, X } from 'lucide-react'\n+import { Check, ChevronLeft, Search, Ticket, X } from 'lucide-react'\n import type { CouponRedeemView } from '../../services/coupons'\n import type { CouponProject } from '../../services/couponProjects'\n import { useCouponProjects } from './useCouponProjects'\n \n-interface Props {\n-  coupon: CouponRedeemView\n-  fallbackImage: string\n-  balance: number | null\n+type Props = {\n   submitting: boolean\n   submitError: string | null\n   onClose: () => void\n   onConfirm: (projectId: number) => void\n-}\n+} & (\n+  | { mode: 'code'; code: string }\n+  | { mode?: 'coupon'; coupon: CouponRedeemView; fallbackImage: string; balance: number | null }\n+)\n \n function ProjectOption({ project, checked, onChoose }: {\n   project: CouponProject\n@@ -45,9 +45,8 @@ function ProjectOption({ project, checked, onChoose }: {\n }\n \n /** Full-screen, in-page exchange step. Selection survives every search and page change. */\n-export default function ProjectSelectScreen({\n-  coupon, fallbackImage, balance, submitting, submitError, onClose, onConfirm,\n-}: Props) {\n+export default function ProjectSelectScreen(props: Props) {\n+  const { submitting, submitError, onClose, onConfirm } = props\n   const [input, setInput] = useState('')\n   const [query, setQuery] = useState('')\n   const [selected, setSelected] = useState<CouponProject | null>(null)\n@@ -131,7 +130,7 @@ export default function ProjectSelectScreen({\n             type=\"button\"\n             disabled={submitting}\n             onClick={onClose}\n-            aria-label=\"返回兑换专区\"\n+            aria-label={props.mode === 'code' ? '返回兑换码' : '返回兑换专区'}\n             className=\"flex h-10 w-10 items-center justify-center rounded-full active:bg-[#F7EEE3] disabled:opacity-50\"\n           >\n             <ChevronLeft size={23} strokeWidth={1.9} aria-hidden />\n@@ -139,21 +138,39 @@ export default function ProjectSelectScreen({\n           <h1 className=\"text-center text-[16px] font-semibold\">选择适用片区</h1>\n         </div>\n \n-        <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n-          <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n-            <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n-          </div>\n-          <div className=\"flex items-center gap-3\">\n-            <img src={coupon.image || fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n-            <div className=\"min-w-0 flex-1\">\n-              <p className=\"truncate text-[15px] font-semibold leading-5\">{coupon.name}</p>\n-              <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{coupon.desc}</p>\n-              <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n-                <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{coupon.cost}</span>泡泡值\n-              </p>\n+        {props.mode === 'code' ? (\n+          <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] py-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待核兑兑换码\">\n+            <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+              <span>待核兑兑换码</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n             </div>\n-          </div>\n-        </section>\n+            <div className=\"flex items-center gap-3\">\n+              <span className=\"flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-[10px] bg-[#FFF2E9] text-[#DF4800]\" aria-hidden>\n+                <Ticket size={28} strokeWidth={1.7} />\n+              </span>\n+              <div className=\"min-w-0 flex-1\">\n+                <p className=\"text-[15px] font-semibold leading-5\">兑换码兑换卡券</p>\n+                <p className=\"mt-1 break-all text-[12px] leading-[19px] text-[#8B8379]\">{props.code}</p>\n+                <p className=\"mt-[5px] text-[11px] text-[#996B30]\">选择片区后确认核兑</p>\n+              </div>\n+            </div>\n+          </section>\n+        ) : (\n+          <section className=\"mx-4 mt-[7px] rounded-[15px] border border-[#F2E9DF] bg-white px-[13px] pb-3 pt-[13px] shadow-[0_3px_16px_rgba(70,51,30,.035)]\" aria-label=\"待兑换券详情\">\n+            <div className=\"mb-[10px] flex items-center justify-between text-[11px] text-[#99826B]\">\n+              <span>待兑换体验券</span><span className=\"font-medium text-[#7B756F]\">×1</span>\n+            </div>\n+            <div className=\"flex items-center gap-3\">\n+              <img src={props.coupon.image || props.fallbackImage} alt=\"\" className=\"h-[67px] w-[67px] shrink-0 rounded-[10px] border border-[#F3EFE7] object-cover\" />\n+              <div className=\"min-w-0 flex-1\">\n+                <p className=\"truncate text-[15px] font-semibold leading-5\">{props.coupon.name}</p>\n+                <p className=\"mt-1 truncate text-[11px] leading-[17px] text-[#8B8379]\">{props.coupon.desc}</p>\n+                <p className=\"mt-[7px] text-[11px] font-medium text-[#996B30]\">\n+                  <span className=\"mr-1 text-[17px] font-bold text-[#DF4800]\">{props.coupon.cost}</span>泡泡值\n+                </p>\n+              </div>\n+            </div>\n+          </section>\n+        )}\n \n         <div className=\"px-4 pb-[10px] pt-[15px]\">\n           <div className=\"flex h-[46px] items-center gap-[10px] rounded-full border border-[#EEE6DC] bg-white px-[15px] focus-within:border-[#ED9C69]\">\n@@ -241,20 +258,24 @@ export default function ProjectSelectScreen({\n \n       <footer className=\"shrink-0 border-t border-[#F1E9DF] bg-[#FFFBF5] px-4 pb-[max(16px,env(safe-area-inset-bottom))] pt-3\">\n         {submitError && <p role=\"alert\" className=\"mb-2 text-[12px] text-danger-text\">{submitError}</p>}\n-        {coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n-        {!coupon.soldOut && balance !== null && balance < coupon.cost && (\n-          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n-        )}\n-        {!coupon.soldOut && balance === null && (\n-          <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+        {props.mode !== 'code' && (\n+          <>\n+            {props.coupon.soldOut && <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">体验券已兑完</p>}\n+            {!props.coupon.soldOut && props.balance !== null && props.balance < props.coupon.cost && (\n+              <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">泡泡值不足，暂时无法兑换</p>\n+            )}\n+            {!props.coupon.soldOut && props.balance === null && (\n+              <p role=\"status\" className=\"mb-2 text-[12px] text-danger-text\">当前余额不可用，请稍后重试</p>\n+            )}\n+            <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n+              <span>泡泡值余额 <b className=\"text-[#38302A]\">{props.balance?.toLocaleString() ?? '--'}</b></span>\n+              <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{props.coupon.cost}</b> 泡泡值</span>\n+            </div>\n+          </>\n         )}\n-        <div className=\"mb-3 flex items-baseline justify-between gap-2 text-[12px] text-[#93867A]\">\n-          <span>泡泡值余额 <b className=\"text-[#38302A]\">{balance?.toLocaleString() ?? '--'}</b></span>\n-          <span>本次消耗 <b className=\"text-[14px] text-[#DF4800]\">{coupon.cost}</b> 泡泡值</span>\n-        </div>\n         <button\n           type=\"button\"\n-          disabled={!selected || submitting || balance === null || balance < coupon.cost || coupon.soldOut}\n+          disabled={!selected || submitting || (props.mode !== 'code' && (props.balance === null || props.balance < props.coupon.cost || props.coupon.soldOut))}\n           onClick={() => { if (selected) onConfirm(selected.id) }}\n           className=\"h-[49px] w-full rounded-full bg-[#DF4800] text-[15px] font-semibold text-white active:bg-[#C83D00] disabled:bg-[#DCCFC1]\"\n         >\ndiff --git a/src/services/redeemCode.test.ts b/src/services/redeemCode.test.ts\nindex 0ef54fb..e8f7a29 100644\n--- a/src/services/redeemCode.test.ts\n+++ b/src/services/redeemCode.test.ts\n@@ -4,35 +4,37 @@ import { REDEEM_CARD_CODE_PATH, redeemCardCode } from './redeemCode'\n \n afterEach(() => vi.restoreAllMocks())\n \n-const receipt = {\n-  user_card_id: 21,\n-  card_id: 4,\n-  card_name: '洗护体验卡',\n-  card_sn: 'RC00000000000014',\n-}\n-\n-describe('redeemCardCode confirmed backend contract', () => {\n-  it('sends original code without changing whitespace/case and returns validated receipt', async () => {\n+describe('redeemCardCode updated backend contract', () => {\n+  it('preserves exact code, sends selected project_id, and parses coupons_id', async () => {\n     const request = vi.spyOn(httpClient, 'request').mockResolvedValue({\n-      code: 0, msg: 'success', status: 'succ', data: receipt,\n+      code: 0, msg: 'success', status: 'succ', data: { coupons_id: 7 },\n     })\n-    await expect(redeemCardCode('Abc- 123456')).resolves.toEqual(receipt)\n+    await expect(redeemCardCode('Abc- 123456', 31)).resolves.toEqual({ coupons_id: 7 })\n     expect(request).toHaveBeenCalledWith({\n-      method: 'POST', url: REDEEM_CARD_CODE_PATH, data: { code: 'Abc- 123456' },\n+      method: 'POST', url: REDEEM_CARD_CODE_PATH,\n+      data: { code: 'Abc- 123456', project_id: 31 },\n     })\n   })\n \n-  it('HTTP 200 business failures are not success', async () => {\n+  it('rejects invalid project_id without sending a request', async () => {\n+    const request = vi.spyOn(httpClient, 'request')\n+    await expect(redeemCardCode('ABC123456789', 0)).rejects.toThrow('请选择适用片区')\n+    expect(request).not.toHaveBeenCalled()\n+  })\n+\n+  it('does not treat a backend business failure as success', async () => {\n     vi.spyOn(httpClient, 'request').mockResolvedValue({\n       code: 500, message: '该兑换码已被使用', data: [],\n     })\n-    await expect(redeemCardCode('ABC123456789')).rejects.toThrow('该兑换码已被使用')\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow('该兑换码已被使用')\n   })\n \n-  it('rejects code=0 with incomplete user-card receipt', async () => {\n-    vi.spyOn(httpClient, 'request').mockResolvedValue({\n-      code: 0, msg: 'success', status: 'succ', data: { card_id: 4 },\n-    })\n-    await expect(redeemCardCode('ABC123456789')).rejects.toThrow()\n+  it('rejects old four-field receipts or malformed coupons_id', async () => {\n+    const request = vi.spyOn(httpClient, 'request')\n+    request.mockResolvedValueOnce({\n+      code: 0, data: { user_card_id: 21, card_id: 4, card_name: '旧卡', card_sn: 'OLD001' },\n+    }).mockResolvedValueOnce({ code: 0, data: { coupons_id: null } })\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow()\n+    await expect(redeemCardCode('ABC123456789', 12)).rejects.toThrow()\n   })\n })\ndiff --git a/src/services/redeemCode.ts b/src/services/redeemCode.ts\nindex b71b1df..f856de4 100644\n--- a/src/services/redeemCode.ts\n+++ b/src/services/redeemCode.ts\n@@ -6,20 +6,18 @@ import { httpClient } from './http'\n export const REDEEM_CARD_CODE_PATH = '/api/cardredemptioncodes/redeem'\n \n const redemptionReceiptSchema = z.object({\n-  user_card_id: z.number().int().positive(),\n-  card_id: z.number().int().positive(),\n-  card_name: z.string(),\n-  card_sn: z.string().min(1),\n+  coupons_id: z.number().int().positive(),\n }).passthrough()\n \n export type RedemptionReceipt = z.infer<typeof redemptionReceiptSchema>\n \n-/** 用户只传原始 code，登录身份由统一 HTTP 层携带，成功后返回后台发券回执。 */\n-export async function redeemCardCode(code: string): Promise<RedemptionReceipt> {\n+/** 保留原始 code，并按所选片区提交 project_id；登录身份由 HTTP 层携带。 */\n+export async function redeemCardCode(code: string, projectId: number): Promise<RedemptionReceipt> {\n+  if (!Number.isInteger(projectId) || projectId <= 0) throw new Error('请选择适用片区')\n   const payload = await httpClient.request<unknown>({\n     method: 'POST',\n     url: REDEEM_CARD_CODE_PATH,\n-    data: { code },\n+    data: { code, project_id: projectId },\n   })\n   return parseApiEnvelope(payload, redemptionReceiptSchema, {\n     contract: 'cardredemptioncodes.redeem',\ndiff --git a/tests/e2e/redeem-code-project-selector.spec.ts b/tests/e2e/redeem-code-project-selector.spec.ts\nnew file mode 100644\nindex 0000000..74d1c9d\n--- /dev/null\n+++ b/tests/e2e/redeem-code-project-selector.spec.ts\n@@ -0,0 +1,29 @@\n+import { expect, test } from '@playwright/test'\n+\n+test('@formal-h5 code redemption opens full-screen project selector without calling issue first', async ({ page }) => {\n+  await page.goto('/redeem', { waitUntil: 'domcontentloaded' })\n+  const input = page.getByRole('textbox', { name: '兑换码' })\n+  await expect(input).toBeVisible()\n+  await input.fill('Abc- 123456')\n+  await page.getByRole('button', { name: '选择适用片区' }).click()\n+\n+  const dialog = page.getByRole('dialog', { name: '选择适用片区' })\n+  await expect(dialog).toBeVisible()\n+  await expect(dialog.getByRole('region', { name: '待核兑兑换码' })).toContainText('Abc- 123456')\n+  await expect(dialog.getByText('泡泡值余额')).toHaveCount(0)\n+  await expect(dialog.getByRole('button', { name: '请选择适用片区' })).toBeDisabled()\n+  const project = dialog.getByRole('radio', { name: /Mock·深圳南山科技园/ })\n+  await expect(project).toBeVisible()\n+  await project.click()\n+  await expect(dialog.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  await dialog.getByRole('searchbox', { name: '搜索片区或项目名称' }).fill('天河')\n+  await expect(dialog.getByRole('radio', { name: /Mock·广州天河体育西/ })).toBeVisible()\n+  await expect(dialog.getByRole('region', { name: '已选片区' })).toContainText('深圳南山科技园')\n+  await expect(dialog.getByRole('button', { name: '确认兑换' })).toBeEnabled()\n+\n+  // Close before requesting a side-effect. The original code and URL remain unchanged.\n+  await dialog.getByRole('button', { name: '返回兑换码' }).click()\n+  await expect(dialog).toHaveCount(0)\n+  await expect(input).toHaveValue('Abc- 123456')\n+  expect(new URL(page.url()).pathname).toBe('/redeem')\n+})",
          "actualWorkers": 2
        },
        "id": "chromium-mobile",
        "name": "chromium-mobile",
        "testDir": "/home/runner/work/dr-card-web/dr-card-web/tests/e2e",
        "testIgnore": [],
        "testMatch": [
          "**/*.@(spec|test).?(c|m)[jt]s?(x)"
        ],
        "timeout": 30000
      }
    ],
    "quiet": false,
    "reporter": [
      [
        "line",
        null
      ],
      [
        "html",
        {
          "outputFolder": "playwright-report",
          "open": "never"
        }
      ],
      [
        "json",
        {
          "outputFile": "test-results/playwright-results.json"
        }
      ]
    ],
    "reportSlowTests": {
      "max": 5,
      "threshold": 300000
    },
    "shard": null,
    "tags": [],
    "updateSnapshots": "missing",
    "updateSourceMethod": "patch",
    "version": "1.63.0",
    "workers": 2,
    "webServer": null
  },
  "suites": [
    {
      "title": "test-gate.spec.ts",
      "file": "test-gate.spec.ts",
      "column": 0,
      "line": 0,
      "specs": [
        {
          "title": "@test-gate build identity matches the production-like test policy",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 40,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:49.901Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-c3b6e0ce5d8c5d8734e5",
          "file": "test-gate.spec.ts",
          "line": 31,
          "column": 1
        },
        {
          "title": "@test-gate browser entry does not start the app router",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 862,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:49.881Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8ed37c8041402a2a6b34",
          "file": "test-gate.spec.ts",
          "line": 40,
          "column": 1
        },
        {
          "title": "@test-gate / shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 1055,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:49.979Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-b030d57053896c93a550",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /profile shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 738,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:52.029Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8605535a2807e17fcccc",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate /settings shows the unsupported-host notice in a browser",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 755,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:52.220Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-4a2fdc9884c1725de4f8",
          "file": "test-gate.spec.ts",
          "line": 51,
          "column": 3
        },
        {
          "title": "@test-gate gated browser entry issues no business API request",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 1,
                  "parallelIndex": 1,
                  "status": "passed",
                  "duration": 705,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:52.776Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-09f38a2b8ed735e192d8",
          "file": "test-gate.spec.ts",
          "line": 65,
          "column": 1
        },
        {
          "title": "@test-gate injected native host bypasses the notice and starts the app",
          "ok": true,
          "tags": [
            "test-gate"
          ],
          "tests": [
            {
              "timeout": 30000,
              "annotations": [],
              "expectedStatus": "passed",
              "projectId": "chromium-mobile",
              "projectName": "chromium-mobile",
              "results": [
                {
                  "workerIndex": 0,
                  "parallelIndex": 0,
                  "status": "passed",
                  "duration": 691,
                  "errors": [],
                  "stdout": [],
                  "stderr": [],
                  "retry": 0,
                  "startTime": "2026-10-15T07:36:52.985Z",
                  "annotations": [],
                  "attachments": []
                }
              ],
              "status": "expected"
            }
          ],
          "id": "37c772ca39f4c8e5dd7a-8caf237c4d42513fff2f",
          "file": "test-gate.spec.ts",
          "line": 80,
          "column": 1
        }
      ]
    }
  ],
  "errors": [],
  "stats": {
    "startTime": "2026-10-15T07:36:49.033Z",
    "duration": 4689.58,
    "expected": 7,
    "skipped": 0,
    "unexpected": 0,
    "flaky": 0
  }
}