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;
}