feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档

- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件
- alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT
- tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行)
- web: React18+Vite 骨架(登录、工作区、邀请落地页)
- android: Compose 骨架(登录、工作区列表)
- 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
Steven
2026-10-28 02:38:53 +08:00
commit 0c53aa7062
72 changed files with 5320 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api, session, type Tokens } from "../lib/client";
import type { User } from "../lib/types";
export function LoginPage() {
const { reload } = useAuth();
const navigate = useNavigate();
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [devCode, setDevCode] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [countdown, setCountdown] = useState(0);
useEffect(() => {
if (countdown <= 0) return;
const timer = setTimeout(() => setCountdown((v) => v - 1), 1000);
return () => clearTimeout(timer);
}, [countdown]);
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
async function sendCode() {
setError(null);
setBusy(true);
try {
const data = await api.post<{ dev_code?: string }>("/auth/sms/send", { phone });
setDevCode(data.dev_code ?? null);
setCountdown(60);
} catch (err) {
setError(err instanceof ApiError ? err.message : "发送失败");
} finally {
setBusy(false);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
setError(null);
setBusy(true);
try {
const data = await api.post<{ user: User; tokens: Tokens }>("/auth/login", { phone, code });
session.save(data.tokens);
await reload();
navigate("/", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "登录失败");
} finally {
setBusy(false);
}
}
return (
<Layout>
<div className="mx-auto mt-10 max-w-sm">
<div className="card">
<h1 className="text-lg font-semibold">登录 / 注册</h1>
<p className="mt-1 text-sm text-ink-500">手机号验证码登录,未注册会自动创建账号。</p>
<form className="mt-6 space-y-4" onSubmit={submit}>
<div>
<label className="label" htmlFor="phone">
手机号
</label>
<input
id="phone"
className="input"
inputMode="numeric"
placeholder="13800138000"
value={phone}
onChange={(e) => setPhone(e.target.value.trim())}
maxLength={11}
/>
</div>
<div>
<label className="label" htmlFor="code">
验证码
</label>
<div className="flex gap-2">
<input
id="code"
className="input"
inputMode="numeric"
placeholder="6 位数字"
value={code}
onChange={(e) => setCode(e.target.value.trim())}
maxLength={6}
/>
<button
type="button"
className="btn-ghost shrink-0"
disabled={!phoneValid || busy || countdown > 0}
onClick={sendCode}
>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</button>
</div>
</div>
{devCode && (
<p className="rounded-lg bg-accent-500/10 px-3 py-2 text-xs text-accent-600">
开发环境验证码:{devCode}(生产环境会走短信通道,不回显)
</p>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<button className="btn-primary w-full" disabled={busy || !phoneValid || code.length < 4}>
{busy ? "处理中…" : "登录"}
</button>
</form>
</div>
</div>
</Layout>
);
}