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
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api } from "../lib/client";
import type { Workspace } from "../lib/types";
export function HomePage() {
const { user, workspaces, loading, reload } = useAuth();
const navigate = useNavigate();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!loading && !user) navigate("/login", { replace: true });
}, [loading, user, navigate]);
async function createWorkspace(event: React.FormEvent) {
event.preventDefault();
if (!name.trim()) return;
setBusy(true);
setError(null);
try {
const ws = await api.post<Workspace>("/workspaces", { name: name.trim() });
setName("");
await reload();
navigate(`/w/${ws.id}`);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
} finally {
setBusy(false);
}
}
return (
<Layout>
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section>
<h2 className="mb-3 text-sm font-medium text-ink-500">我的工作区</h2>
{loading ? (
<p className="text-sm text-ink-500">加载中…</p>
) : workspaces.length === 0 ? (
<div className="card text-sm text-ink-500">
还没有工作区。右侧创建一个,个人用也一样——先把"任务"放进去。
</div>
) : (
<ul className="space-y-3">
{workspaces.map((ws) => (
<li key={ws.id}>
<Link
to={`/w/${ws.id}`}
className="card flex items-center justify-between transition hover:border-ink-500"
>
<div>
<p className="font-medium">{ws.name}</p>
<p className="mt-1 text-xs text-ink-500">
{ws.slug} · {ws.plan === "free" ? "免费版" : ws.plan}
</p>
</div>
<span className="rounded-full bg-ink-100 px-2 py-1 text-xs text-ink-700">
{roleLabel(ws.role)}
</span>
</Link>
</li>
))}
</ul>
)}
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">新建工作区</h2>
<form className="mt-3 space-y-3" onSubmit={createWorkspace}>
<input
className="input"
placeholder="例如:达道科技 / 我的事项"
value={name}
maxLength={64}
onChange={(e) => setName(e.target.value)}
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<button className="btn-primary w-full" disabled={busy || !name.trim()}>
{busy ? "创建中…" : "创建"}
</button>
</form>
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-500">
收到邀请链接(/join/…)直接打开即可加入。
</p>
</aside>
</div>
</Layout>
);
}
function roleLabel(role: string): string {
return { owner: "负责人", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api, session } from "../lib/client";
import type { Workspace } from "../lib/types";
/** 邀请链接落地页:/join/:wsId/:code。未登录先去登录,登录后再回来。 */
export function JoinPage() {
const { wsId, code } = useParams<{ wsId: string; code: string }>();
const { user, loading, reload } = useAuth();
const navigate = useNavigate();
const [message, setMessage] = useState("正在加入工作区…");
const [failed, setFailed] = useState(false);
const started = useRef(false);
useEffect(() => {
if (loading) return;
if (!session.access) {
navigate(`/login?next=${encodeURIComponent(`/join/${wsId}/${code}`)}`, { replace: true });
return;
}
if (!user || started.current || !wsId || !code) return;
started.current = true;
void (async () => {
try {
const ws = await api.post<Workspace>(`/workspaces/${wsId}/join`, { code });
await reload();
setMessage(`已加入「${ws.name}」,正在跳转…`);
setTimeout(() => navigate(`/w/${wsId}`, { replace: true }), 600);
} catch (err) {
setFailed(true);
setMessage(err instanceof ApiError ? err.message : "加入失败");
}
})();
}, [loading, user, wsId, code, navigate, reload]);
return (
<Layout>
<div className="mx-auto mt-10 max-w-md">
<div className="card text-center">
<p className={failed ? "text-sm text-red-600" : "text-sm text-ink-700"}>{message}</p>
{failed && (
<button className="btn-ghost mt-4" onClick={() => navigate("/")}>
返回工作区列表
</button>
)}
</div>
</div>
</Layout>
);
}
+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>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Layout } from "../components/Layout";
export function PagePlaceholder({ title, hint }: { title: string; hint?: string }) {
return (
<Layout>
<div className="card">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="mt-2 text-sm text-ink-500">{hint ?? "M1 开发中"}</p>
</div>
</Layout>
);
}
+147
View File
@@ -0,0 +1,147 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { useAuth } from "../App";
import { Layout } from "../components/Layout";
import { ApiError, api } from "../lib/client";
import type { Invitation, Member, Page, Workspace } from "../lib/types";
const ROLE_LABEL: Record<string, string> = {
owner: "负责人",
admin: "管理员",
member: "成员",
guest: "访客",
};
export function WorkspacePage() {
const { wsId } = useParams<{ wsId: string }>();
const { reload } = useAuth();
const [ws, setWs] = useState<Workspace | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [invitation, setInvitation] = useState<Invitation | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
if (!wsId) return;
try {
const [detail, page] = await Promise.all([
api.get<Workspace>(`/workspaces/${wsId}`),
api.get<Page<Member>>(`/workspaces/${wsId}/members`),
]);
setWs(detail);
setMembers(page.items);
setError(null);
} catch (err) {
setError(err instanceof ApiError ? err.message : "加载失败");
}
}, [wsId]);
useEffect(() => {
void load();
}, [load]);
async function createInvite(role: string) {
if (!wsId) return;
setBusy(true);
setError(null);
try {
setInvitation(await api.post<Invitation>(`/workspaces/${wsId}/invitations`, { role }));
await load();
await reload();
} catch (err) {
setError(err instanceof ApiError ? err.message : "生成邀请失败");
} finally {
setBusy(false);
}
}
const canInvite = ws?.role === "owner" || ws?.role === "admin";
return (
<Layout>
<div className="mb-4 flex items-center gap-3 text-sm text-ink-500">
<Link to="/" className="hover:text-ink-900">
← 工作区
</Link>
{ws && <span className="rounded-full bg-ink-100 px-2 py-0.5 text-xs">{ROLE_LABEL[ws.role]}</span>}
</div>
{error && <p className="mb-4 text-sm text-red-600">{error}</p>}
<div className="grid gap-6 md:grid-cols-[1.6fr_1fr]">
<section className="card">
<h1 className="text-lg font-semibold">{ws?.name ?? "加载中…"}</h1>
<p className="mt-1 text-xs text-ink-500">
{ws ? `${ws.slug} · ${ws.plan === "free" ? "免费版" : ws.plan}` : ""}
</p>
<h2 className="mt-6 text-sm font-medium text-ink-500">成员({members.length})</h2>
<ul className="mt-3 divide-y divide-ink-100">
{members.map((m) => (
<li key={m.id} className="flex items-center justify-between py-2 text-sm">
<div>
<span className="font-medium">{m.nickname || "未命名"}</span>
<span className="ml-2 text-xs text-ink-500">{m.phone}</span>
</div>
<span className="text-xs text-ink-500">{ROLE_LABEL[m.role] ?? m.role}</span>
</li>
))}
</ul>
<div className="mt-8 rounded-lg border border-dashed border-ink-100 p-4 text-sm text-ink-500">
<p className="font-medium text-ink-700">下一步(M1)</p>
<p className="mt-1">
任务列表、看板、工单升级、知识沉淀会依次落在这里。骨架先把身份、租户、成员跑通。
</p>
</div>
</section>
<aside className="card h-fit">
<h2 className="text-sm font-medium">邀请成员</h2>
{canInvite ? (
<div className="mt-3 space-y-3">
<div className="flex gap-2">
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("member")}
>
邀请成员
</button>
{ws?.role === "owner" && (
<button
className="btn-ghost flex-1"
disabled={busy}
onClick={() => createInvite("admin")}
>
邀请管理员
</button>
)}
</div>
{invitation && (
<div className="rounded-lg bg-ink-50 p-3 text-xs">
<p className="font-medium text-ink-700">邀请码 {invitation.code}</p>
<p className="mt-1 break-all text-ink-500">
{`${window.location.origin}/join/${wsId}/${invitation.code}`}
</p>
<button
className="btn-ghost mt-2 w-full"
onClick={() => {
void navigator.clipboard.writeText(
`${window.location.origin}/join/${wsId}/${invitation.code}`,
);
}}
>
复制邀请链接
</button>
</div>
)}
</div>
) : (
<p className="mt-2 text-xs text-ink-500">只有负责人和管理员能邀请成员。</p>
)}
</aside>
</div>
</Layout>
);
}