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:
@@ -0,0 +1,89 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, Route, Routes, useNavigate } from "react-router-dom";
|
||||
import { api, authExpired, session } from "./lib/client";
|
||||
import type { User, Workspace } from "./lib/types";
|
||||
import { LoginPage } from "./pages/Login";
|
||||
import { HomePage } from "./pages/Home";
|
||||
import { WorkspacePage } from "./pages/Workspace";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
workspaces: Workspace[];
|
||||
loading: boolean;
|
||||
reload: () => Promise<void>;
|
||||
signOut: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthState | null>(null);
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth 必须在 AuthProvider 内使用");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
if (!session.access) {
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await api.get<{ user: User; workspaces: Workspace[] }>("/auth/me");
|
||||
setUser(me.user);
|
||||
setWorkspaces(me.workspaces);
|
||||
} catch {
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
const onExpired = () => {
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
navigate("/login", { replace: true });
|
||||
};
|
||||
authExpired.addEventListener("expired", onExpired);
|
||||
return () => authExpired.removeEventListener("expired", onExpired);
|
||||
}, [reload, navigate]);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
session.clear();
|
||||
setUser(null);
|
||||
setWorkspaces([]);
|
||||
navigate("/login", { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
const value = useMemo<AuthState>(
|
||||
() => ({ user, workspaces, loading, reload, signOut }),
|
||||
[user, workspaces, loading, reload, signOut],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/w/:wsId" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { user, signOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-full max-w-5xl flex-col px-6 py-6">
|
||||
<header className="mb-6 flex items-center justify-between">
|
||||
<Link to="/" className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-semibold tracking-tight">达道Task</span>
|
||||
<span className="text-xs text-ink-500">任务 → 工单 → 知识,一条流</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-3 text-sm text-ink-500">
|
||||
{user && <span>{user.nickname}</span>}
|
||||
{user ? (
|
||||
<button className="btn-ghost" onClick={signOut}>
|
||||
退出
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn-ghost" onClick={() => navigate("/login")}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
<footer className="mt-8 text-xs text-ink-500">
|
||||
达道 · 从任务到知识,一条道走到底
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
@apply bg-ink-50 text-ink-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
@apply rounded-xl border border-ink-100 bg-white p-5 shadow-sm;
|
||||
}
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-medium
|
||||
transition disabled:cursor-not-allowed disabled:opacity-50;
|
||||
}
|
||||
.btn-primary {
|
||||
@apply btn bg-ink-700 text-white hover:bg-ink-900;
|
||||
}
|
||||
.btn-ghost {
|
||||
@apply btn border border-ink-100 bg-white text-ink-700 hover:bg-ink-50;
|
||||
}
|
||||
.input {
|
||||
@apply w-full rounded-lg border border-ink-100 bg-white px-3 py-2 text-sm
|
||||
outline-none focus:border-ink-500 focus:ring-2 focus:ring-ink-500/20;
|
||||
}
|
||||
.label {
|
||||
@apply mb-1 block text-xs font-medium text-ink-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 接口客户端。
|
||||
*
|
||||
* 三条约定:
|
||||
* 1. 后端永远返回 {code,message,data,request_id},code !== 0 一律抛出。
|
||||
* 2. 写请求自动带 Idempotency-Key,网络抖动重试不会重复建数据。
|
||||
* 3. 401 统一清理本地令牌并回到登录页,不在业务里各写一遍。
|
||||
*/
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly code: number,
|
||||
message: string,
|
||||
readonly requestId?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface Tokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
const ACCESS_KEY = "dadao.access";
|
||||
const REFRESH_KEY = "dadao.refresh";
|
||||
|
||||
export const session = {
|
||||
get access(): string | null {
|
||||
return localStorage.getItem(ACCESS_KEY);
|
||||
},
|
||||
get refresh(): string | null {
|
||||
return localStorage.getItem(REFRESH_KEY);
|
||||
},
|
||||
save(tokens: Tokens) {
|
||||
localStorage.setItem(ACCESS_KEY, tokens.access_token);
|
||||
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
|
||||
},
|
||||
clear() {
|
||||
localStorage.removeItem(ACCESS_KEY);
|
||||
localStorage.removeItem(REFRESH_KEY);
|
||||
},
|
||||
};
|
||||
|
||||
export const authExpired = new EventTarget();
|
||||
|
||||
type Method = "GET" | "POST" | "PATCH" | "PUT" | "DELETE";
|
||||
|
||||
interface RequestOptions {
|
||||
method?: Method;
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
/** 幂等键;不传则对写请求自动生成 */
|
||||
idempotencyKey?: string;
|
||||
}
|
||||
|
||||
async function raw<T>(path: string, opts: RequestOptions = {}): Promise<T> {
|
||||
const method: Method = opts.method ?? "GET";
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
|
||||
|
||||
const token = session.access;
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const isWrite = method !== "GET";
|
||||
if (isWrite) {
|
||||
headers["Idempotency-Key"] = opts.idempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
const resp = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
signal: opts.signal,
|
||||
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
||||
});
|
||||
|
||||
let body: { code: number; message: string; data: T; request_id: string } | null = null;
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
throw new ApiError(resp.status, `服务返回了非 JSON 响应(HTTP ${resp.status})`);
|
||||
}
|
||||
if (!resp.ok || body.code !== 0) {
|
||||
if (resp.status === 401) {
|
||||
session.clear();
|
||||
authExpired.dispatchEvent(new Event("expired"));
|
||||
}
|
||||
throw new ApiError(body.code, body.message, body.request_id);
|
||||
}
|
||||
return body.data;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string, signal?: AbortSignal) => raw<T>(path, { signal }),
|
||||
post: <T>(path: string, body?: unknown) => raw<T>(path, { method: "POST", body }),
|
||||
patch: <T>(path: string, body?: unknown) => raw<T>(path, { method: "PATCH", body }),
|
||||
del: <T>(path: string) => raw<T>(path, { method: "DELETE" }),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
phone: string;
|
||||
nickname: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
plan: string;
|
||||
role: Role;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type Role = "owner" | "admin" | "member" | "guest";
|
||||
|
||||
export interface Member {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: Role;
|
||||
status: string;
|
||||
nickname: string;
|
||||
phone: string;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
code: string;
|
||||
role: Role;
|
||||
expires_at: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user