Skip to content

React 四页与路由 ​

目录:frontend/yagang-identity-app(一个很小的 React SPA,只做四个页面)

页面路由后端端点
登录/Account/LoginPOST /Account/Login(页面 URL 与处理端点同名)
同意/Account/ConsentPOST /connect/authorize(submit.Accept / submit.Deny)
登出确认/Account/LogoutPOST /connect/logout(Confirm=Yes)
外部账号确认/Account/ExternalLoginConfirmGET /Account/ExternalLoginPending(展示)+ POST /Account/ExternalLoginRegister(建号并绑定)

UI 框架:Ant Design v6 ​

四页都用 antd v6(antd + @ant-design/icons,见 package.json),全局配置在 main.tsx:

tsx
import { App as AntdApp, ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import 'antd/dist/reset.css'

<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#2563eb', borderRadius: 8 } }}>
  <AntdApp>
    <RouterProvider router={router} />
  </AntdApp>
</ConfigProvider>

组件分工(四个页面都用原生 <form> 提交,antd 只负责外观与交互):

页面antd 组件必须保持原生的部分
登录Card / Input / Input.Password / Button / Alert / Collapse / Divider / Space / Typography<form method="post" action="/Account/Login"> + 3 个隐藏字段(ReturnUrl、__RequestVerificationToken、用户名/密码是可见字段)
同意Card / Descriptions / Tag / Button / Alert / Collapse / Typography<form method="post"> + 隐藏字段
登出同上同上
外部账号确认Card / Input / Button / Alert / Typography<form method="post" action="/Account/ExternalLoginRegister"> + 隐藏字段(ReturnUrl、__RequestVerificationToken)

四个页面为什么都用原生表单,而不是 antd Form + fetch

  • 登录:服务端成功/失败都要 302(成功回 ReturnUrl 继续授权,失败回登录页带 ?error=), 必须是浏览器顶层导航;用 fetch 只能"拿 JSON 再自己导航",还得把令牌放请求头。
  • 同意 / 登出:服务端 AuthorizationController.Accept / Deny / LogoutPost 上有 [FormValueRequired("submit.Accept")] 与 [ValidateAntiForgeryToken],靠表单字段分流;响应 302 回客户端, 同样必须让浏览器处理 → 按钮用 antd Button 的 htmlType="submit" name="submit.Accept" value="Yes" (antd 会把 name/value 透传到 <button>)。
  • 令牌:都来自 GET /Account/Antiforgery,放进 <input type="hidden" name="__RequestVerificationToken"> (AntiforgeryOptions.FormFieldName)——HTML 表单没法自定义请求头,所以统一走"表单字段"这条通道。

构建与托管方式 ​

vite.config.ts:

ts
const accountApiPrefixes = ['/Account/Antiforgery', '/Account/ExternalProviders', '/Account/ExternalLogin']

export default defineConfig({
  plugins: [react({ compiler: true })],
  base: '/',
  server: {
    port: 5175,
    proxy: {
      '/connect': { target: 'https://localhost:7016', changeOrigin: true, secure: false },
      '/Account': {
        target: 'https://localhost:7016',
        changeOrigin: true,
        secure: false,
        bypass: (request) =>
          request.method === 'GET' && !accountApiPrefixes.some((prefix) => (request.url ?? '').startsWith(prefix))
            ? request.url
            : undefined,
      },
    },
  },
  build: {
    outDir: '../../backend/Yagang.Identity/Yagang.Identity.Api/wwwroot',
    emptyOutDir: true,
  },
})

三个设计点:

点作用
base = '/'资源 URL 直接挂在站点根(/assets/...),页面 URL 才能是 /Account/Login 这样的绝对路径
build.outDir 指向 OP 的 wwwroot一次 pnpm build 就把产物放进 OP 的静态目录(index.html 在站点根)
dev proxy 把 /connect 与 /Account 的接口代理到 7016开发服务器也保持"同源",无需 CORS;bypass 把页面导航(GET /Account/Login 等)留给 Vite 自己渲染 SPA

.env 里 VITE_OIDC_AUTHORITY= 留空 → 页面里的 ${AUTHORITY}/connect/... 变成相对路径(同源)。

路由与 SPA 回落 ​

tsx
export const router = createBrowserRouter(
  [
    { path: '/', loader: () => redirect('/Account/Login') },
    { path: '/Account/Login', lazy: () => import('../pages/Login') },
    { path: '/Account/ExternalLoginConfirm', lazy: () => import('../pages/ExternalLoginConfirm') },
    { path: '/Account/Consent', lazy: () => import('../pages/Consent') },
    { path: '/Account/Logout', lazy: () => import('../pages/Logout') },
  ],
  { basename: import.meta.env.BASE_URL.replace(/\/$/, '') },   // base = '/' → basename 为空
)
  • history 模式下 /Account/Consent、/Account/ExternalLoginConfirm 在磁盘上没有文件 → 由 OP 的 SPA fallback(app.MapFallback)返回 wwwroot/index.html;新增页面不用改后端,前端的 * 404 路由也能生效。
  • 例外:/api、/connect、/callback 前缀的未匹配路径返回真 404(不返回 SPA 壳),保证 API 404 与页面 404 可区分。
  • 四个页面 URL 都带 /Account 前缀(页面 URL 与处理端点同名);basename 必须配,否则 React Router 会把它们当成未知路径走 ErrorBoundary。

常见翻车

配了 base 忘记配 basename → 页面白屏/报 ErrorBoundary; 配了 base 但在 fallback 前缀排除里误伤了页面路径(本项目页面都在 /Account/*,不在排除列表)→ 刷新页面会 404。

登录页(pages/Login.tsx) ​

职责:读 ReturnUrl、渲染表单(隐藏字段带 ReturnUrl + 防伪令牌)、可选的外部登录按钮。 提交与错误处理都在服务端(302),页面只负责把 ?error= 映射成中文提示。

tsx
// 展示用参数从 ReturnUrl 里解析(那才是本次真正的授权请求);直接打开登录页时没有 ReturnUrl → 参数面板显示(无)
const { returnUrl, authParams } = (() => {
  const search = new URLSearchParams(window.location.search)
  const url = search.get('ReturnUrl') || search.get('returnUrl') || ''
  const query = url.includes('?') ? url.slice(url.indexOf('?')) : ''
  return { returnUrl: url, authParams: readAuthParams(query) }
})()

const loginReturnUrl = returnUrl.startsWith('/') ? returnUrl : '/'   // 表单隐藏字段的值

(原先"没有 ReturnUrl 时按当前 URL 的 query 重建 authorize 地址并删 prompt"的兜底是表单 POST 改造前的残留,2026-10-02 已删除;见 ② 登录页。)

外部登录按钮:

tsx
useEffect(() => {
  fetch(`${AUTHORITY}/Account/ExternalProviders`, { credentials: 'include' })
    .then(res => res.ok ? res.json() : [])
    .then((data) => setProviders(Array.isArray(data) ? data : []))
    .catch(() => setProviders([]))
}, [])
tsx
{providers.length > 0 && (
  <Space orientation="vertical" style={{ width: '100%' }}>
    {providers.map((provider) => (
      // Identity UI 同款:每个提供方一个 POST 表单(provider 作为按钮 name/value),不是 GET 跳转
      <form key={provider.name} method="post" style={{ width: '100%' }}
        action={`${AUTHORITY}/Account/ExternalLogin?returnUrl=${encodeURIComponent(loginReturnUrl)}`}>
        <input type="hidden" name={formFieldName} value={token} />
        <Button icon={<GithubOutlined />} block size="large" htmlType="submit"
          name="provider" value={provider.name} disabled={!token}>
          使用 {provider.displayName || provider.name} 登录
        </Button>
      </form>
    ))}
  </Space>
)}

登录表单本体(就是三个隐藏字段 + antd 输入框 + 提交中的 loading):

tsx
<form method="post" action={`${AUTHORITY}/Account/Login`} onSubmit={handleSubmit}>
  <input type="hidden" name="ReturnUrl" value={loginReturnUrl} />
  <input type="hidden" name={formFieldName} value={token} />   {/* __RequestVerificationToken */}
  <Input id="username" name="username" autoComplete="username" defaultValue={prefilledUsername} required ... />
  <Input.Password id="password" name="password" autoComplete="current-password" required ... />
  <Button type="primary" htmlType="submit" disabled={!token} loading={submitting}>登录</Button>
</form>

两个与 Identity 默认 UI 对齐的小细节:autoComplete="username" / "current-password"(密码管理器 / Passkey 只认这组值); prefilledUsername 来自失败重定向里的 ?username=(/Account/Login 失败时 302 回本页带上,等价 ModelState 回显)。

细节:fetch("/Account/ExternalProviders") 不会因为返回 [] 而报错(HTTP 200), 所以"未配置 GitHub"时页面安静地只显示账号密码表单——不会出现坏掉的按钮。

  • 把 URL 上的全部 query 参数(授权参数)原样转成隐藏字段;
  • 另外塞一个 __RequestVerificationToken(来自 GET /Account/Antiforgery);
  • 用普通表单 POST(不是 fetch)提交回 ~/connect/authorize,让浏览器自己跟着 302 走。
tsx
<form method="post" action={`${AUTHORITY}/connect/authorize`} onSubmit={handleSubmit}>
  {Object.entries(params).map(([key, value]) => <input key={key} type="hidden" name={key} value={value} />)}
  <input type="hidden" name="__RequestVerificationToken" value={token} />
  <Flex gap={12}>
    <Button type="primary" htmlType="submit" name="submit.Accept" value="Yes"
            disabled={!token} loading={submitting === 'accept'}>同意</Button>
    <Button danger htmlType="submit" name="submit.Deny" value="No"
            disabled={!token} loading={submitting === 'deny'}>拒绝</Button>
  </Flex>
  <Spin spinning={submitting !== null} fullscreen description="正在提交…" />
</form>

为什么必须是普通表单(而不是 fetch): Accept 成功后的响应是 302 到 redirect_uri?code=...。若用 fetch,浏览器会把它当 XHR 重定向, JS 拿不到 code、也不会导航——必须让浏览器自己处理。

为什么按钮名要叫 submit.Accept / submit.Deny: 服务端 [FormValueRequired("submit.Accept")] 靠它分流(见 AuthorizationController)。

提交中的 loading 用 loading,别用 disabled

浏览器是在 submit 事件之后才收集表单数据的,而 disabled 控件不进入表单数据。 把"同意"按钮 disabled={submitting} 掉 → submit.Accept 字段直接消失 → 服务端 400。 antd 的 loading 只加 class + 拦后续点击(内部 click.preventDefault()),不加 disabled 属性,所以是安全的。

登出页(pages/Logout.tsx) ​

与同意页同构(隐藏字段 + 防伪 token + 普通表单 POST 到 ~/connect/logout),只有一个按钮 Confirm=Yes: onSubmit 里切 submitting → 按钮 loading + <Spin spinning={submitting} fullscreen description="正在退出…" />。 同样的坑:别给 Confirm 按钮加 disabled(字段会丢,见下)。

外部账号确认页(pages/ExternalLoginConfirm.tsx) ​

用户在 GitHub 验证过身份、但该外部账号还没绑定任何本地账号时才会到达这一页:

  1. 挂载时 GET /Account/ExternalLoginPending 读 external cookie 里的 provider / 显示名 / 邮箱(邮箱不写在 URL 里;cookie 缺失或过期 → 404 → 页面提示"外部登录信息已过期"并提供"返回登录"按钮);
  2. 同时 GET /Account/Antiforgery 取防伪令牌;
  3. 用户确认邮箱后,表单 POST 到 POST /Account/ExternalLoginRegister,服务端创建本地账号 + AddLoginAsync 绑定 + 登录。

页面上的提示文案明确写着"不会自动关联到已有账号":这是这一页存在的唯一理由, 也是与"按邮箱自动关联"这种危险做法划清界限的地方(详见 ⑥ GitHub 外部登录)。

四页共用的 loading:渐进增强(form 也能转圈) ​

完整对比与业界证据见 表单登录与 loading(业界做法);这里只记三条硬规则:

  1. 不 preventDefault(除非已经在提交中):否则浏览器不导航,服务端的 302 没人跟随。
  2. 提交按钮用 loading,不要用 disabled(原因见上一条 warning)。
  3. 用 SubmitEvent 的 nativeEvent.submitter 判断点了哪个按钮,只给被点的那个加 loading;键盘 Enter 没有 submitter (同意页此时不会被当成"同意"提交,服务端会重新渲染同意页——与 Razor 版同意页一致, 前提是重定向时参数从 Form 取,见③ 同意流程)。
tsx
function handleSubmit(event: SubmitEvent<HTMLFormElement>) {
  if (submitting) { event.preventDefault(); return }   // 防连点 / 防回车重复提交

  const submitter = event.nativeEvent.submitter
  const name = submitter instanceof HTMLButtonElement ? submitter.name : ''
  setSubmitting(name === 'submit.Deny' ? 'deny' : name === 'submit.Accept' ? 'accept' : null)
}

四个页面都配了 <Spin spinning={…} fullscreen description="正在登录… / 正在提交… / 正在退出… / 正在创建账号…" />: 浏览器在新文档接管前不会卸载本页,所以这个转圈会一直显示到服务端响应完成——MSA / Google 登录页也是这个效果。

样式 ​

App.css 里只有很朴素的卡片样式 + 两个类:

css
.actions { display: flex; gap: .5rem; margin-top: .75rem; }
button.secondary { background: transparent; border: 1px solid currentColor; }

.actions 用来并排"同意/拒绝"或外部登录按钮。

改动页面后的生效路径 ​

bash
cd frontend/yagang-identity-app
pnpm build          # 产物写入 OP 的 wwwroot(hash 文件名)
# OP 无需重启(静态文件),浏览器刷新即可

调试建议

想快速迭代界面时用 pnpm dev(5175):四个页面 URL 与 7016 完全一致(Vite 自己渲染 SPA),/connect 与 /Account 的接口由 dev proxy 转发到 OP。 注意 dev server 只适合调 UI:/Account 的接口请求由 vite proxy 转发给 OP(页面导航由 bypass 放行给 SPA), 所以表单 POST 能正常提交(本项目按 Identity UI 不再做 Origin 校验)—— 完整链路请在 7016 托管的构建产物上验证(改完页面 pnpm build 后刷新即可)。

OpenIddict + ASP.NET Core Identity 授权服务器,React 登录页,逐步拆解登录链路