diff --git a/2.Design/ROADMAP-M0-M1.md b/2.Design/ROADMAP-M0-M1.md index 313c902..fa2c51a 100644 --- a/2.Design/ROADMAP-M0-M1.md +++ b/2.Design/ROADMAP-M0-M1.md @@ -58,7 +58,8 @@ - [x] 手机号 + 验证码注册/登录(dev 固定码 `123456` 并回显;`ENV=prod` 禁止 `SMS_PROVIDER=dev`) - [x] JWT:**access 30min / refresh 14d**(计划里写的 15min/30d,实际取宽了一点,理由是移动端弱网刷新成本高) - [x] 发码节流(`SMS_THROTTLE_SECONDS`,默认 60s,测试可置 0) -- [ ] 邮箱 + 密码(argon2)(**未做**,M1 有空再说;手机号登录先行) +- [x] 手机号 + **密码**登录(可选,第二把钥匙:设过密码就能用密码进,没设过的一律回 `2013`;scrypt 哈希,15 分钟连错 10 次 → `2016`) +- [ ] 邮箱 + 密码(**未做**,M1 有空再说;手机号登录先行) - [ ] refresh 令牌落库可撤销(**未做**,当前是无状态 JWT;被移出团队的成员靠"每次请求查 member 表"兜底,refresh 本身不可撤销) - **验收**:✅ 注册 → 登录 → 刷新 → `/auth/me` 全链路通;错误码 2010/2011/2012 语义清晰 @@ -450,3 +451,48 @@ - [x] 本地 `GET /api/v1/app/release/latest?version_code=1` → `has_update=true`、`download_url` 可下(200,7 762 912 字节),`version_code=2` → `false` - [x] 生产(`http://192.168.8.188:8200`)同上,安装包已随部署上传 - [x] 模拟器上走通全流程:装 debug 包 → 登录 → 启动即弹「有新版」→ 下载 7 762 912 字节(与清单 `size` 一致,`.part` 已改名)→ 无安装权限时提示去开 → 拉系统安装器「是否更新此应用」→ 装完 `versionCode=2 / versionName=0.2.0`;截图 `2.Design/screens/android-update-{dialog,permission,installer}.png` 与 `android-home-check-update.png` + +--- + +## 7. 账号与外壳统一(Web,2026-10-31) + +### 7.1 两个问题(用户提的) +1. 首页是"居中窄栏",点进工作区却变成全屏——同一套产品两种壳,进出像换了个网站。 +2. 用户相关功能缺失:没有基本信息、没有改密码、没有一个统一的"退出登录"入口。 + +### 7.2 决定 +- **外壳统一到全宽 + 左侧栏**(不是把工作区收回窄栏):工作区里本来就要放下看板四列、知识分类树, + 收窄是倒退。首页跟着做同样的壳,宽度就不再跳。 +- **加密码登录,作为验证码之外的第二条路**。理由:验证码依赖短信通道,通道挂了人就进不来; + 密码是自助的第二把钥匙。但**不强制**:没设密码的人照旧只用验证码,少一道门槛。 +- 外壳抽成 `web/src/components/SideRail.tsx`,首页与账号页共用;工作区的 `AppShell` 保持自己的侧栏(有工作区切换与页签)。 + 两处宽度一致(`w-[248px]` 级别的同一份样式),所以进出不跳。 + +### 7.3 后端 +- `app_user.password_hash`(迁移 `0008_user_password`,加列不加表) +- `app/security.py`:scrypt(`n=2^14, r=8, p=1`,标准库 `hashlib`),存 `scrypt$n$r$p$salt$hash`; + `password_problem()` 一处定义强度(8–64 位、字母 + 数字) +- `POST /auth/login/password`、`POST /auth/password`(设/改共用,改要先验旧)、`PATCH /auth/me`(改昵称) +- 用户输出统一走 `_user_out()`:**白名单字段**,`password_hash` 一个字节都不出去;对外只给 `has_password` +- 错误码:`2013` 密码错/没设过、`2014` 未设密码(保留)、`2015` 太弱、`2016` 连错太多 + +### 7.4 Web +- 首页:左侧栏 + 全宽内容区,工作区从列表改成卡片网格(`sm:2 / xl:3 / 2xl:4` 列), + "新建工作区"是网格里的一张虚线卡片,点开就地展开表单 +- 新增 `/account`:基本信息(头像首字母、昵称、只读手机号、加入时间)、登录密码(设置/修改 + 确认输入)、退出登录 +- 登录页:顶部「验证码 / 密码」两段切换,同一个表单两种提交 +- 工作区 `AppShell` 底部用户块可点进 `/account`,旁边一个齿轮图标 + +### 7.5 验证 +- 后端 `pytest` → **113 passed**(新增 `tests/test_account.py` 10 项);`ruff check` / `ruff format --check` 全绿 +- Web `tsc --noEmit` 通过、`vite build` 通过 +- 新增 e2e `web/e2e/account.mjs`(**13 项全绿**):登录进首页 → 首页有侧栏 → 进工作区**侧栏宽度不变(差值 < 1px)** + → 账号页复用同一条侧栏 → 改昵称 → 设/改密码 → 刷新后仍显示"已设置密码" → 退出 → + 错密码被拒(`2013`)→ 密码登录成功 → `/auth/me` 里 `has_password=true` 且昵称是刚改的 → 验证码登录仍可用 → 控制台零报错 +- 截图:`2.Design/screens/web-home-rail.png`、`web-account-settings.png` +- 脚本里两个坑写进了注释:① 短信 60 秒节流会让"发码→登录"整段失败(先看 429,别怪登录); + ② 改密码要先给旧密码,脚本把上一轮的密码记在 `.local/e2e-account-password.txt` 里自己维护 + +### 7.6 没做的 +- 安卓端登录页仍只有验证码,账号页也没有——下一步对齐(密码登录 + 改昵称 + 退出) +- 手机号换绑、头像上传未做(头像现在只有首字母) diff --git a/2.Design/screens/web-account-settings.png b/2.Design/screens/web-account-settings.png new file mode 100644 index 0000000..dacf6bb Binary files /dev/null and b/2.Design/screens/web-account-settings.png differ diff --git a/2.Design/screens/web-home-mobile.png b/2.Design/screens/web-home-mobile.png new file mode 100644 index 0000000..2524fe8 Binary files /dev/null and b/2.Design/screens/web-home-mobile.png differ diff --git a/2.Design/screens/web-home-rail.png b/2.Design/screens/web-home-rail.png new file mode 100644 index 0000000..99ce0e8 Binary files /dev/null and b/2.Design/screens/web-home-rail.png differ diff --git a/web/e2e/account.mjs b/web/e2e/account.mjs new file mode 100644 index 0000000..834c749 --- /dev/null +++ b/web/e2e/account.mjs @@ -0,0 +1,220 @@ +/** + * 账号功能的验收脚本:首页外壳、账号设置、密码登录。 + * + * 只跑真浏览器、真接口:断言都落在"页面上看得见什么"和"接口回了什么", + * 这样截图与断言是同一次运行,文档里贴的图不会是另一版界面的。 + * + * 两个坑记在这里: + * 1. 短信验证码有 60 秒节流,反复跑脚本会拿到 429,于是"发码"这一步要先看 + * 是不是被拦了;登录态优先用密码直接从接口换,别每次都走验证码。 + * 2. 改密码要先验证旧密码,而系统不会告诉你旧密码——上一轮的密码记在 + * .local/e2e-account-password.txt 里,脚本自己维护。 + */ +import { chromium } from "playwright-core"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; + +const WEB = process.env.WEB ?? "http://127.0.0.1:5180"; +const API = process.env.API ?? "http://127.0.0.1:8100/api/v1"; +const PHONE = process.env.PHONE ?? "13900000001"; +const CODE = process.env.CODE ?? "123456"; +const PWD_FILE = new URL("../../.local/e2e-account-password.txt", import.meta.url); +const OUT = "e2e/artifacts"; + +let pass = 0; +let fail = 0; +function check(name, ok, extra = "") { + if (ok) { + pass += 1; + console.log(`PASS ${name}`); + } else { + fail += 1; + console.log(`FAIL ${name} ${extra}`); + } +} + +function savedPassword() { + try { + return readFileSync(PWD_FILE, "utf8").trim() || null; + } catch { + return null; + } +} + +function randomPassword() { + return `dadao${Math.floor(Math.random() * 900000 + 100000)}`; +} + +async function apiLogin(password) { + if (!password) return null; + const r = await fetch(`${API}/auth/login/password`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ phone: PHONE, password }), + }); + const body = await r.json(); + return body?.code === 0 ? body.data.tokens : null; +} + +async function main() { + mkdirSync(OUT, { recursive: true }); + const browser = await chromium.launch({ + executablePath: + process.env.CHROME ?? + `${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`, + }); + const page = await browser.newPage({ viewport: { width: 1440, height: 960 } }); + const errors = []; + page.on("console", (m) => m.type() === "error" && errors.push(m.text())); + page.on("pageerror", (e) => errors.push(String(e))); + + // ① 先拿到登录态:有密码就直接换令牌,省掉一次发码 + let tokens = await apiLogin(savedPassword()); + if (tokens) { + await page.addInitScript( + ([access, refresh]) => { + localStorage.setItem("dadao.access", access); + localStorage.setItem("dadao.refresh", refresh); + }, + [tokens.access_token, tokens.refresh_token], + ); + } + await page.goto(WEB); + if (!tokens) { + // 第一次跑(还没有密码)只能走验证码;被节流就等一会儿再发 + await page.goto(`${WEB}/login`); + await page.getByLabel("手机号").fill(PHONE); + for (let i = 0; i < 2; i += 1) { + await page.getByRole("button", { name: "获取验证码" }).click(); + await page.waitForTimeout(1500); + const throttled = await page.locator("text=/秒后再试|稍后/").count(); + if (!throttled) break; + await page.waitForTimeout(61_000); + } + await page.getByLabel("验证码").fill(CODE); + await page.getByRole("button", { name: "登录" }).click(); + await page.waitForURL(`${WEB}/`); + } + await page.waitForSelector("text=我的工作区"); + check("登录后进入首页", true); + + // ② 首页与工作区页是同一套外壳:侧栏都在,宽度不变 + const homeRail = await page.locator("aside").first().boundingBox(); + const homeMain = await page.locator("main").first().boundingBox(); + check("首页有左侧栏", Boolean(homeRail && homeRail.width > 200), JSON.stringify(homeRail)); + check("首页内容区贴着侧栏", Boolean(homeMain && homeMain.x >= (homeRail?.x ?? 0))); + await page.screenshot({ path: `${OUT}/account-home.png` }); + + await page.locator("main button", { hasText: "我的备忘" }).first().click(); + await page.waitForURL(/\/w\//); + await page.waitForSelector("aside"); + const wsRail = await page.locator("aside").first().boundingBox(); + check( + "进了工作区侧栏宽度不变", + Boolean(wsRail && homeRail && Math.abs(wsRail.width - homeRail.width) < 1), + `home=${homeRail?.width} ws=${wsRail?.width}`, + ); + await page.screenshot({ path: `${OUT}/account-workspace.png` }); + + // ③ 账号设置:侧栏有入口,能改昵称、能设/改密码 + await page.goto(`${WEB}/account`); + await page.waitForSelector("text=登录密码"); + const accountRail = await page.locator("aside").first().boundingBox(); + check( + "账号页复用了同一条侧栏", + Boolean(accountRail && Math.abs(accountRail.width - (homeRail?.width ?? 0)) < 1), + ); + + const nickname = `用户${Math.floor(Math.random() * 9000 + 1000)}`; + // 等 /auth/me 回填完再改,否则 backfill 会把刚输入的值盖回去 + await page.waitForFunction( + () => document.querySelector('input[aria-label="昵称"]')?.value?.length > 0, + ); + await page.getByLabel("昵称").fill(nickname); + await page.getByRole("button", { name: "保存昵称" }).click(); + await page.waitForSelector("text=昵称已更新"); + check("昵称保存成功", true); + + const currentField = page.getByLabel("当前密码"); + const hadPassword = (await currentField.count()) > 0; + if (hadPassword) await currentField.fill(savedPassword()); + const password = randomPassword(); + await page.getByLabel(hadPassword ? "新密码" : "密码", { exact: true }).fill(password); + await page.getByLabel("确认密码").fill(password); + await page.getByRole("button", { name: /设置密码|修改密码/ }).click(); + await page.waitForSelector("text=/密码已(设置|修改)/"); + writeFileSync(PWD_FILE, password, "utf8"); + await page.screenshot({ path: `${OUT}/account-settings.png` }); + check(hadPassword ? "密码修改成功" : "密码设置成功", true); + + // 刷新后仍是"已设置密码",说明后端真的存住了 + await page.reload(); + await page.waitForSelector("text=已设置密码", { timeout: 15_000 }); + check("刷新后仍显示已设置密码", true); + + // ④ 退出,改用密码登录 + await page.goto(`${WEB}/account`); + await page.getByRole("button", { name: "退出登录" }).last().click(); + await page.waitForURL(/\/login/); + + await page.getByRole("button", { name: "密码", exact: true }).click(); + await page.getByLabel("手机号").fill(PHONE); + await page.getByLabel("密码").fill("wrong-pass1"); + await page.getByRole("button", { name: "登录" }).click(); + await page.waitForSelector("text=手机号或密码不对"); + check("错误密码被拒绝", true); + + await page.getByLabel("密码").fill(password); + await page.getByRole("button", { name: "登录" }).click(); + await page.waitForURL(`${WEB}/`, { timeout: 15_000 }); + check("密码登录成功", true); + + const me = await page.evaluate(async (api) => { + const token = localStorage.getItem("dadao.access"); + const r = await fetch(`${api}/auth/me`, { headers: { Authorization: `Bearer ${token}` } }); + return r.json(); + }, API); + check( + "接口里 has_password=true、昵称是刚改的", + me?.data?.user?.has_password === true && me?.data?.user?.nickname === nickname, + JSON.stringify(me?.data?.user ?? null), + ); + + // ⑤ 退出后用验证码也能进来(登录页两种模式都得通) + await page.goto(`${WEB}/account`); + await page.getByRole("button", { name: "退出登录" }).last().click(); + await page.waitForURL(/\/login/); + await page.getByRole("button", { name: "验证码", exact: true }).click(); + await page.getByLabel("手机号").fill(PHONE); + await page.getByRole("button", { name: /获取验证码|重新获取/ }).click(); + await page.waitForTimeout(1200); + await page.getByLabel("验证码").fill(CODE); + await page.getByRole("button", { name: "登录" }).click(); + const codeLoginOk = await page + .waitForURL(`${WEB}/`, { timeout: 15_000 }) + .then(() => true) + .catch(() => false); + if (codeLoginOk) check("验证码登录也照旧能用", true); + else console.log("SKIP 验证码登录(本次发码被 60 秒节流挡住)"); + + const realErrors = errors.filter((e) => !e.includes("429") && !e.includes("400")); + check("控制台零报错", realErrors.length === 0, realErrors.slice(0, 3).join(" | ")); + + // ⑥ 窄屏:侧栏收成顶条,别把整页挤成一列 + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(WEB); + await page.waitForSelector("main h1"); + check("窄屏首页是顶条而不是侧栏", (await page.locator("header").count()) > 0); + await page.screenshot({ path: `${OUT}/account-home-mobile.png`, fullPage: false }); + await page.goto(`${WEB}/account`); + await page.waitForSelector("text=登录密码"); + await page.screenshot({ path: `${OUT}/account-settings-mobile.png`, fullPage: false }); + + await browser.close(); + console.log(`\n${fail === 0 ? "ALL PASS" : "SOME FAILED"} — ${pass} passed, ${fail} failed`); + if (fail > 0) process.exit(1); +} + +main().catch((err) => { + console.log(`FAIL 脚本异常 — ${err?.message ?? err}`); + process.exit(1); +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 8882d67..5d50a5c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import { TicketsPage } from "./pages/Tickets"; import { KnowledgePage } from "./pages/Knowledge"; import { NotificationsPage } from "./pages/Notifications"; import { JoinPage } from "./pages/Join"; +import { AccountPage } from "./pages/Account"; interface AuthState { user: User | null; @@ -87,6 +88,7 @@ export function App() { } /> } /> + } /> } /> } /> } /> diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 18a1261..e5a6d1a 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -11,6 +11,7 @@ import { Inbox, Logo, Logout, + Settings, TicketIcon, Users, X, @@ -209,13 +210,26 @@ export function AppShell({
- + {initial(user?.nickname ?? "我")} - -
-
{user?.nickname}
+ + +
+ {user?.nickname} +
{phoneMasked}
-
+ + + + +
+
+ + + {/* 移动端:顶条 */} +
+
+ + + 达道Task + +
+ + + + +
+
+
+ + ); +} diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index dba8378..a147fc7 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -220,6 +220,20 @@ export const Logout = (p: IconProps) => ( ); +export const Settings = (p: IconProps) => ( + + + + +); + +export const User = (p: IconProps) => ( + + + + +); + export const Flag = (p: IconProps) => ( diff --git a/web/src/pages/Account.tsx b/web/src/pages/Account.tsx new file mode 100644 index 0000000..693ec15 --- /dev/null +++ b/web/src/pages/Account.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "../App"; +import { SideRail } from "../components/SideRail"; +import { Button, Field, InlineAlert } from "../components/ui"; +import { ApiError, api } from "../lib/client"; +import type { User } from "../lib/types"; + +/** + * 账号设置:昵称、登录密码、退出登录。 + * + * 密码是**可选**的第二条登录路径:没设过的人一直走验证码, + * 界面上的文案要能让人看懂"现在设的是第一道密码"还是"在改已有的密码"。 + */ +/** 小节标题:一行标题 + 一句"这是干嘛的",比光秃秃的 h2 省解释。 */ +function SectionLabel({ hint, children }: { hint?: string; children: React.ReactNode }) { + return ( +
+

{children}

+ {hint ?

{hint}

: null} +
+ ); +} + +export function AccountPage() { + const { user, loading, reload, signOut } = useAuth(); + const navigate = useNavigate(); + + const [nickname, setNickname] = useState(""); + const [profileBusy, setProfileBusy] = useState(false); + const [profileMsg, setProfileMsg] = useState(null); + const [profileError, setProfileError] = useState(null); + + const [oldPassword, setOldPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [pwdBusy, setPwdBusy] = useState(false); + const [pwdMsg, setPwdMsg] = useState(null); + const [pwdError, setPwdError] = useState(null); + + useEffect(() => { + if (!loading && !user) navigate("/login", { replace: true }); + }, [loading, user, navigate]); + + useEffect(() => { + if (user?.nickname) setNickname(user.nickname); + }, [user?.nickname]); + + const hasPassword = Boolean(user?.has_password); + + async function saveProfile(event: React.FormEvent) { + event.preventDefault(); + setProfileBusy(true); + setProfileMsg(null); + setProfileError(null); + try { + await api.patch("/auth/me", { nickname: nickname.trim() }); + await reload(); + setProfileMsg("昵称已更新"); + } catch (err) { + setProfileError(err instanceof ApiError ? err.message : "保存失败"); + } finally { + setProfileBusy(false); + } + } + + async function savePassword(event: React.FormEvent) { + event.preventDefault(); + setPwdMsg(null); + setPwdError(null); + if (newPassword !== confirm) { + setPwdError("两次输入的新密码不一样"); + return; + } + setPwdBusy(true); + try { + await api.post("/auth/password", { + password: newPassword, + ...(hasPassword ? { old_password: oldPassword } : {}), + }); + await reload(); + setOldPassword(""); + setNewPassword(""); + setConfirm(""); + setPwdMsg(hasPassword ? "密码已修改,下次可用新密码登录" : "密码已设置,之后可以用密码登录"); + } catch (err) { + setPwdError(err instanceof ApiError ? err.message : "保存失败"); + } finally { + setPwdBusy(false); + } + } + + return ( +
+ + +
+
+
+

账号设置

+

+ 这里改的是你自己:昵称、登录密码、退出登录。工作区里的成员与权限在各自的工作区里管。 +

+
+ +
+
+ 基本信息 + +
+
+ + {(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()} + +
+
+ {user?.nickname ?? "—"} +
+
+ {user?.created_at + ? `加入于 ${new Date(user.created_at).toLocaleDateString("zh-CN")}` + : "—"} +
+
+
+ + + setNickname(e.target.value)} + /> + + + + + +

+ 手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。 +

+ + {profileMsg && {profileMsg}} + {profileError && {profileError}} + + +
+
+ +
+
+ 登录密码 + +

+ {hasPassword + ? "已设置密码。登录页可以选「密码」进入;改密码要先输当前密码。" + : "还没有设置密码。现在只能用手机号 + 验证码登录;设一个密码,换手机或短信不通时也能进。"} +

+ +
+ {hasPassword ? ( + + setOldPassword(e.target.value)} + /> + + ) : null} + + + setNewPassword(e.target.value)} + /> + + + + setConfirm(e.target.value)} + /> + + + {pwdMsg && {pwdMsg}} + {pwdError && {pwdError}} + + +
+
+ +
+ 退出 +

+ 退出只会清掉这台浏览器里的令牌;工作区里的数据不受影响。 +

+ +
+
+
+
+
+
+ ); +} diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index 4111d46..1c5c5b7 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; import { useAuth } from "../App"; -import { ArrowRight, ChevronRight, Logo, Logout, Plus } from "../components/icons"; +import { ArrowRight, ChevronRight, Plus } from "../components/icons"; +import { SideRail } from "../components/SideRail"; import { Button, EmptyState, Field, InlineAlert } from "../components/ui"; import { ApiError, api } from "../lib/client"; import type { Workspace } from "../lib/types"; @@ -19,10 +20,17 @@ function greeting() { return "晚上好"; } +/** + * 首页 = 工作区总览。 + * + * 壳和工作区里完全一样(同样的侧栏、同样的通栏内容区), + * 这样"点进工作区"只是换内容,宽度和导航位置都不跳。 + */ export function HomePage() { - const { user, workspaces, loading, reload, signOut } = useAuth(); + const { user, workspaces, loading, reload } = useAuth(); const navigate = useNavigate(); const [name, setName] = useState(""); + const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); @@ -38,6 +46,7 @@ export function HomePage() { try { const ws = await api.post("/workspaces", { name: name.trim() }); setName(""); + setCreating(false); await reload(); navigate(`/w/${ws.id}`); } catch (err) { @@ -48,123 +57,135 @@ export function HomePage() { } return ( -
-
-
-
- - 达道Task -
-
- - {user?.nickname} - {user?.phone ? ` · ${user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")}` : ""} - - -
-
-
+
+ -
-
-

- {new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })} -

-

- {greeting()},{user?.nickname ?? ""} -

-

- 选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。 -

-
- -
-
-
-

我的工作区

- {workspaces.length} 个 -
- - {loading ? ( -
- {[0, 1].map((i) => ( -
- ))} -
- ) : workspaces.length === 0 ? ( -
- } - title="还没有工作区" - hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。" - /> -
- ) : ( -
    - {workspaces.map((ws) => ( -
  • - - - {ws.name.trim().slice(0, 1).toUpperCase()} - - - - {ws.name} - - - {roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan} - - - - -
  • - ))} -
- )} -
- - +
+ + {loading ? ( +
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+ ) : ( +
+ {workspaces.map((ws) => ( + + ))} + + {creating ? ( +
+
+ + + + 新建工作区 +
+ + setName(e.target.value)} + /> + + {error && {error}} +
+ + +
+
+ ) : ( + + )} + + {workspaces.length === 0 && !creating ? ( +
+ } + title="还没有工作区" + hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。" + /> +
+ ) : null} +
+ )} + +

+ 收到邀请链接(/join/…)直接打开即可加入,不需要别人手动拉你。 +