React 四页与路由
目录:frontend/yagang-identity-app(一个很小的 React SPA,只做四个页面)
| 页面 | 路由 | 后端端点 |
|---|---|---|
| 登录 | /Account/Login | POST /Account/Login(页面 URL 与处理端点同名) |
| 同意 | /Account/Consent | POST /connect/authorize(submit.Accept / submit.Deny) |
| 登出确认 | /Account/Logout | POST /connect/logout(Confirm=Yes) |
| 外部账号确认 | /Account/ExternalLoginConfirm | GET /Account/ExternalLoginPending(展示)+ POST /Account/ExternalLoginRegister(建号并绑定) |
UI 框架:Ant Design v6
四页都用 antd v6(antd + @ant-design/icons,见 package.json),全局配置在 main.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 回客户端, 同样必须让浏览器处理 → 按钮用 antdButton的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:
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 回落
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= 映射成中文提示。
// 展示用参数从 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 已删除;见 ② 登录页。)
外部登录按钮:
useEffect(() => {
fetch(`${AUTHORITY}/Account/ExternalProviders`, { credentials: 'include' })
.then(res => res.ok ? res.json() : [])
.then((data) => setProviders(Array.isArray(data) ? data : []))
.catch(() => setProviders([]))
}, []){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):
<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"时页面安静地只显示账号密码表单——不会出现坏掉的按钮。
同意页(pages/Consent.tsx)
- 把 URL 上的全部 query 参数(授权参数)原样转成隐藏字段;
- 另外塞一个
__RequestVerificationToken(来自GET /Account/Antiforgery); - 用普通表单 POST(不是 fetch)提交回
~/connect/authorize,让浏览器自己跟着 302 走。
<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 验证过身份、但该外部账号还没绑定任何本地账号时才会到达这一页:
- 挂载时
GET /Account/ExternalLoginPending读 external cookie 里的 provider / 显示名 / 邮箱(邮箱不写在 URL 里;cookie 缺失或过期 → 404 → 页面提示"外部登录信息已过期"并提供"返回登录"按钮); - 同时
GET /Account/Antiforgery取防伪令牌; - 用户确认邮箱后,表单 POST 到
POST /Account/ExternalLoginRegister,服务端创建本地账号 +AddLoginAsync绑定 + 登录。
页面上的提示文案明确写着"不会自动关联到已有账号":这是这一页存在的唯一理由, 也是与"按邮箱自动关联"这种危险做法划清界限的地方(详见 ⑥ GitHub 外部登录)。
四页共用的 loading:渐进增强(form 也能转圈)
完整对比与业界证据见 表单登录与 loading(业界做法);这里只记三条硬规则:
- 不
preventDefault(除非已经在提交中):否则浏览器不导航,服务端的 302 没人跟随。 - 提交按钮用
loading,不要用disabled(原因见上一条 warning)。 - 用
SubmitEvent的nativeEvent.submitter判断点了哪个按钮,只给被点的那个加 loading;键盘 Enter 没有 submitter (同意页此时不会被当成"同意"提交,服务端会重新渲染同意页——与 Razor 版同意页一致, 前提是重定向时参数从Form取,见③ 同意流程)。
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 里只有很朴素的卡片样式 + 两个类:
.actions { display: flex; gap: .5rem; margin-top: .75rem; }
button.secondary { background: transparent; border: 1px solid currentColor; }.actions 用来并排"同意/拒绝"或外部登录按钮。
改动页面后的生效路径
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 后刷新即可)。