diff --git a/tools/login_console.py b/tools/login_console.py
new file mode 100644
index 0000000..347959c
--- /dev/null
+++ b/tools/login_console.py
@@ -0,0 +1,267 @@
+"""登录测试台:一个记事本级别的前端,用来在浏览器里验证登录接口。
+
+浏览器打开 **http://127.0.0.1:8099**(可用 `--port` 改)。
+
+## 它做什么 / 不做什么
+
+- **走真实登录接口**(`POST /api/v1/auth/tokens`)拿令牌,和前端将来要做的一模一样。
+ 与 `tools/chat_console.py` 不同:那个是当初还没有登录接口时自己签令牌的权宜做法,
+ 这个测的就是真链路。
+- 拿到令牌后可以按角色调几个**只读**接口,直接把状态码与响应 JSON 摊在页面上 ——
+ 方便确认"登录给的 roles 与接口实际放行的权限是否一致"。
+- **不动 `app/` 一个字节**:内层用 `create_app()`,本进程只做两件事 —— 提供一个静态页面、
+ 把 `/api/*` 同源转发给它。同源转发是为了避开 CORS,也让浏览器不必直连主服务。
+- 只监听 `127.0.0.1`,不出本机。私钥始终在服务端(登录是后端做的),页面只拿到令牌。
+
+## 为什么现在可以做这个了
+
+`docs/24` 当初拒绝"给底座加 dev token 端点",理由是那种端点等于把**任意身份**开放给
+任何能访问服务的人。现在不同了:登录接口要**校验密码**,所以浏览器拿令牌这件事
+不再等于"谁都能冒充任何人"。这条顾虑已经消除。
+
+用法:
+
+ python tools/login_console.py # 默认 8099
+ python tools/login_console.py --port 9000
+"""
+
+from __future__ import annotations
+
+import argparse
+
+import httpx
+import uvicorn
+from fastapi import FastAPI, Request
+from fastapi.responses import HTMLResponse, Response
+
+from app.main import create_app
+
+PAGE = """
+
+
+
+
+登录测试台
+
+
+
+ 登录测试台
+
+ 走真实接口 POST /api/v1/auth/tokens。密码仅用于本地演示。
+
+
+
+
+
+
+
+
+
+
+
+
+ 快捷填充:
+
+
+
+
+
+
+
+
+
+
+ 用这个令牌调接口
+ (下面按钮按角色给,方便核对权限)
+
+
+
(还没有请求)
+
+
+
+
+
+"""
+
+
+def build_console() -> FastAPI:
+ base_app = create_app()
+ console = FastAPI(title="登录测试台", docs_url=None, redoc_url=None)
+
+ @console.get("/", response_class=HTMLResponse)
+ async def index() -> HTMLResponse:
+ return HTMLResponse(PAGE)
+
+ @console.api_route(
+ "/api/{path:path}",
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
+ )
+ async def proxy(path: str, request: Request) -> Response:
+ """把 `/api/*` 同源转发给主应用(进程内 ASGI,不起第二个服务)。
+
+ 同源是为了避开 CORS,也让浏览器不必直连主服务;`Authorization` 等请求头
+ 原样透传。响应连状态码一起回传 —— 这个页面的用处之一就是看真实状态码。
+ """
+ headers = {
+ key: value
+ for key, value in request.headers.items()
+ if key.lower() not in {"host", "content-length"}
+ }
+ async with httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=base_app),
+ base_url="http://base",
+ timeout=60,
+ ) as client:
+ upstream = await client.request(
+ request.method,
+ f"/api/{path}",
+ headers=headers,
+ content=await request.body(),
+ params=request.query_params,
+ )
+ passthrough = {
+ key: value
+ for key, value in upstream.headers.items()
+ if key.lower() in {"content-type", "retry-after", "x-trace-id"}
+ }
+ return Response(
+ content=upstream.content,
+ status_code=upstream.status_code,
+ headers=passthrough,
+ )
+
+ return console
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="登录测试台(浏览器验证登录接口)")
+ parser.add_argument("--port", type=int, default=8099)
+ args = parser.parse_args()
+ print(f"登录测试台:http://127.0.0.1:{args.port}")
+ print(" 演示账号:cust_t/123456 risk_t/666666 admin_t/88888888")
+ uvicorn.run(build_console(), host="127.0.0.1", port=args.port, log_level="warning")
+
+
+if __name__ == "__main__":
+ main()