首页原来是居中窄栏,点进工作区又变全屏,进出像换了个网站。现在把首页也做成 全宽 + 左侧栏(SideRail 抽出来给首页和账号页共用),工作区卡片改成网格, 新建工作区是网格里的虚线卡片——宽度不再跳。 账号页 /account:基本信息(头像首字母、昵称、只读手机号、加入时间)、 登录密码(设置/修改 + 确认输入)、退出登录。工作区侧栏底部的用户块点进去就是它。 e2e account.mjs 14 项全绿,其中一条断言进工作区后侧栏宽度和首页差值 < 1px。
This commit is contained in:
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route path="/w/:wsId" element={<TasksPage />} />
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Inbox,
|
||||
Logo,
|
||||
Logout,
|
||||
Settings,
|
||||
TicketIcon,
|
||||
Users,
|
||||
X,
|
||||
@@ -209,13 +210,26 @@ export function AppShell({
|
||||
|
||||
<div className="border-t border-line-200 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
|
||||
<Link
|
||||
to="/account"
|
||||
title="账号设置"
|
||||
className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700 transition hover:bg-ink-300"
|
||||
>
|
||||
{initial(user?.nickname ?? "我")}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
|
||||
</Link>
|
||||
<Link to="/account" className="group min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink-800 group-hover:text-primary-700">
|
||||
{user?.nickname}
|
||||
</div>
|
||||
<div className="truncate text-2xs text-ink-400">{phoneMasked}</div>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
title="账号设置"
|
||||
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<Settings size={15} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={signOut}
|
||||
title="退出登录"
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { useAuth } from "../App";
|
||||
import { Logout, Logo, Settings } from "./icons";
|
||||
|
||||
/**
|
||||
* 工作区里侧栏长什么样,工作区外面(工作区列表、账号设置)就长什么样。
|
||||
*
|
||||
* 之前首页是居中窄栏、进了工作区又是全宽,宽度跳变一次、导航位置换一次,
|
||||
* 像是两个产品。这里把外壳收成一处,首页与账号页复用它。
|
||||
*/
|
||||
export function SideRail({ activeId }: { activeId?: string | null }) {
|
||||
const { user, workspaces, signOut } = useAuth();
|
||||
const masked = user?.phone ? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2") : "";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 桌面:左侧栏 */}
|
||||
<aside className="sticky top-0 z-20 hidden h-screen w-60 shrink-0 flex-col border-r border-line-200 bg-paper-50 lg:flex xl:w-[264px]">
|
||||
<div className="px-5 pb-4 pt-5">
|
||||
<Link to="/" className="flex items-center gap-2.5">
|
||||
<Logo size={30} />
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-semibold tracking-tight text-ink-900">达道Task</div>
|
||||
<div className="text-2xs text-ink-400">任务 · 工单 · 知识</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 px-3">
|
||||
<div className="px-2 pb-1 text-2xs font-medium text-ink-400">我的工作区</div>
|
||||
</div>
|
||||
|
||||
<nav className="mt-1 flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
|
||||
{workspaces.map((w) => {
|
||||
const isActive = w.id === activeId;
|
||||
return (
|
||||
<Link
|
||||
key={w.id}
|
||||
to={`/w/${w.id}`}
|
||||
className={`flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs transition ${
|
||||
isActive
|
||||
? "bg-paper-200/70 font-medium text-ink-900"
|
||||
: "text-ink-600 hover:bg-paper-100"
|
||||
}`}
|
||||
>
|
||||
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-line-100 text-2xs font-semibold text-ink-600">
|
||||
{w.name.trim().slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="truncate">{w.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{workspaces.length === 0 ? (
|
||||
<p className="px-2 py-1.5 text-2xs leading-relaxed text-ink-400">
|
||||
还没有工作区,右边建一个就能开始。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="my-1 h-px bg-line-100" />
|
||||
<Link
|
||||
to="/account"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-xs text-ink-500 transition hover:bg-paper-100 hover:text-ink-800"
|
||||
>
|
||||
<Settings size={14} className="text-ink-400" />
|
||||
账号设置
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-line-200 px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-ink-200 text-2xs font-semibold text-ink-700">
|
||||
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium text-ink-800">{user?.nickname}</div>
|
||||
<div className="truncate text-2xs text-ink-400">{masked}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
title="退出登录"
|
||||
className="rounded-md p-1.5 text-ink-400 transition hover:bg-ink-100 hover:text-ink-700"
|
||||
>
|
||||
<Logout size={15} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* 移动端:顶条 */}
|
||||
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/95 backdrop-blur lg:hidden">
|
||||
<div className="flex items-center justify-between px-4 py-2.5">
|
||||
<Link to="/" className="flex items-center gap-2">
|
||||
<Logo size={24} />
|
||||
<span className="text-sm font-semibold text-ink-900">达道Task</span>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to="/account"
|
||||
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
|
||||
aria-label="账号设置"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</Link>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="rounded-md p-1.5 text-ink-400 hover:bg-ink-100"
|
||||
aria-label="退出登录"
|
||||
>
|
||||
<Logout size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -220,6 +220,20 @@ export const Logout = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Settings = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 3.5v2M12 18.5v2M20.5 12h-2M5.5 12h-2M17.9 6.1l-1.4 1.4M7.5 16.5l-1.4 1.4M17.9 17.9l-1.4-1.4M7.5 7.5 6.1 6.1" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const User = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<circle cx="12" cy="8.5" r="3.5" />
|
||||
<path d="M5 20c0-3.3 3.1-5.5 7-5.5s7 2.2 7 5.5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Flag = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 21V4.5M6 5h10.5l-1.7 3.6L16.5 12H6" />
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-ink-800">{children}</h2>
|
||||
{hint ? <p className="mt-0.5 text-2xs text-ink-400">{hint}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>(null);
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const [pwdBusy, setPwdBusy] = useState(false);
|
||||
const [pwdMsg, setPwdMsg] = useState<string | null>(null);
|
||||
const [pwdError, setPwdError] = useState<string | null>(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<User>("/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 (
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
<SideRail />
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
|
||||
<div className="mb-7">
|
||||
<h1 className="font-display text-2xl font-semibold">账号设置</h1>
|
||||
<p className="mt-2 text-sm text-ink-500">
|
||||
这里改的是你自己:昵称、登录密码、退出登录。工作区里的成员与权限在各自的工作区里管。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,1fr)]">
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="别人在成员列表里看到的名字">基本信息</SectionLabel>
|
||||
|
||||
<form className="mt-4 space-y-4" onSubmit={saveProfile}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="grid h-12 w-12 place-items-center rounded-full bg-primary-900 text-base font-semibold text-paper-50">
|
||||
{(user?.nickname ?? "我").trim().slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium text-ink-800">
|
||||
{user?.nickname ?? "—"}
|
||||
</div>
|
||||
<div className="text-2xs text-ink-400">
|
||||
{user?.created_at
|
||||
? `加入于 ${new Date(user.created_at).toLocaleDateString("zh-CN")}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Field label="昵称">
|
||||
<input
|
||||
className="input"
|
||||
value={nickname}
|
||||
maxLength={32}
|
||||
aria-label="昵称"
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="手机号">
|
||||
<input
|
||||
className="input bg-paper-100 text-ink-500"
|
||||
value={user?.phone ?? ""}
|
||||
readOnly
|
||||
aria-label="手机号"
|
||||
/>
|
||||
</Field>
|
||||
<p className="-mt-2 text-2xs leading-relaxed text-ink-400">
|
||||
手机号就是登录账号,暂不支持自助换号;换号等于换账号,需要单独处理。
|
||||
</p>
|
||||
|
||||
{profileMsg && <InlineAlert tone="success">{profileMsg}</InlineAlert>}
|
||||
{profileError && <InlineAlert>{profileError}</InlineAlert>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={profileBusy || !nickname.trim() || nickname.trim() === user?.nickname}
|
||||
>
|
||||
{profileBusy ? "保存中…" : "保存昵称"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="space-y-4">
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="验证码始终可用,密码是可选的第二条路">登录密码</SectionLabel>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
||||
{hasPassword
|
||||
? "已设置密码。登录页可以选「密码」进入;改密码要先输当前密码。"
|
||||
: "还没有设置密码。现在只能用手机号 + 验证码登录;设一个密码,换手机或短信不通时也能进。"}
|
||||
</p>
|
||||
|
||||
<form className="mt-4 space-y-3" onSubmit={savePassword}>
|
||||
{hasPassword ? (
|
||||
<Field label="当前密码">
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
autoComplete="current-password"
|
||||
value={oldPassword}
|
||||
aria-label="当前密码"
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
<Field label={hasPassword ? "新密码" : "密码"}>
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
placeholder="至少 8 位,字母和数字都要有"
|
||||
value={newPassword}
|
||||
aria-label={hasPassword ? "新密码" : "密码"}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="再输一次">
|
||||
<input
|
||||
type="password"
|
||||
className="input"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
aria-label="确认密码"
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{pwdMsg && <InlineAlert tone="success">{pwdMsg}</InlineAlert>}
|
||||
{pwdError && <InlineAlert>{pwdError}</InlineAlert>}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={
|
||||
pwdBusy ||
|
||||
newPassword.length < 8 ||
|
||||
!confirm ||
|
||||
(hasPassword && !oldPassword)
|
||||
}
|
||||
>
|
||||
{pwdBusy ? "保存中…" : hasPassword ? "修改密码" : "设置密码"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card p-5">
|
||||
<SectionLabel hint="在这台设备上清掉登录状态">退出</SectionLabel>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-500">
|
||||
退出只会清掉这台浏览器里的令牌;工作区里的数据不受影响。
|
||||
</p>
|
||||
<Button variant="ghost" size="sm" className="mt-3" onClick={signOut}>
|
||||
退出登录
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+137
-116
@@ -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<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
@@ -38,6 +46,7 @@ export function HomePage() {
|
||||
try {
|
||||
const ws = await api.post<Workspace>("/workspaces", { name: name.trim() });
|
||||
setName("");
|
||||
setCreating(false);
|
||||
await reload();
|
||||
navigate(`/w/${ws.id}`);
|
||||
} catch (err) {
|
||||
@@ -48,123 +57,135 @@ export function HomePage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-paper-100">
|
||||
<header className="sticky top-0 z-20 border-b border-line-200 bg-paper-50/90 backdrop-blur">
|
||||
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-3 lg:px-10">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Logo size={26} />
|
||||
<span className="text-sm font-semibold tracking-tight text-ink-900">达道Task</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="hidden text-xs text-ink-500 sm:inline">
|
||||
{user?.nickname}
|
||||
{user?.phone ? ` · ${user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1****$2")}` : ""}
|
||||
</span>
|
||||
<Button variant="subtle" size="sm" onClick={signOut} title="退出登录">
|
||||
<Logout size={14} />
|
||||
退出
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex min-h-full flex-col bg-paper-100 lg:flex-row">
|
||||
<SideRail />
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl px-5 py-8 lg:px-10 lg:py-12">
|
||||
<div className="mb-9">
|
||||
<p className="text-xs font-medium tracking-wide text-clay-600">
|
||||
{new Date().toLocaleDateString("zh-CN", { month: "long", day: "numeric", weekday: "long" })}
|
||||
</p>
|
||||
<h1 className="mt-1.5 font-display text-3xl font-semibold">
|
||||
{greeting()},{user?.nickname ?? ""}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-xl text-sm leading-relaxed text-ink-500">
|
||||
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 lg:grid-cols-[1.55fr_1fr]">
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
|
||||
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{[0, 1].map((i) => (
|
||||
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
|
||||
))}
|
||||
</div>
|
||||
) : workspaces.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Plus size={18} />}
|
||||
title="还没有工作区"
|
||||
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="grid gap-3 sm:grid-cols-2">
|
||||
{workspaces.map((ws) => (
|
||||
<li key={ws.id}>
|
||||
<Link
|
||||
to={`/w/${ws.id}`}
|
||||
className="group card flex items-center gap-3 p-4 transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
|
||||
{ws.name.trim().slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink-900">
|
||||
{ws.name}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-2xs text-ink-400">
|
||||
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="card h-fit p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-7 w-7 place-items-center rounded-lg bg-clay-50 text-clay-600">
|
||||
<Plus size={15} />
|
||||
</span>
|
||||
<h2 className="text-sm font-medium text-ink-800">新建工作区</h2>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-ink-400">
|
||||
一个工作区对应一支团队或一个生活领域。成员之间共享任务、工单与知识。
|
||||
<main className="min-w-0 flex-1">
|
||||
<div className="w-full px-5 py-6 lg:px-8 lg:py-8 xl:px-12 2xl:px-16">
|
||||
<div className="mb-7">
|
||||
<p className="text-xs font-medium tracking-wide text-clay-600">
|
||||
{new Date().toLocaleDateString("zh-CN", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
weekday: "long",
|
||||
})}
|
||||
</p>
|
||||
<h1 className="mt-1.5 font-display text-3xl font-semibold">
|
||||
{greeting()},{user?.nickname ?? ""}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-relaxed text-ink-500">
|
||||
选一个工作区继续。任务、工单、知识都在里面,他们是同一件事的三个阶段。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-4 space-y-3" onSubmit={createWorkspace}>
|
||||
<Field label="名称">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:达道科技 / 我的事项"
|
||||
value={name}
|
||||
maxLength={64}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
<Button type="submit" variant="primary" className="w-full" disabled={busy || !name.trim()}>
|
||||
{busy ? "创建中…" : "创建"}
|
||||
{busy ? null : <ArrowRight size={14} />}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 border-t border-line-100 pt-3 text-2xs leading-relaxed text-ink-400">
|
||||
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,
|
||||
不需要别人手动拉你。
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-medium text-ink-600">我的工作区</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xs text-ink-400">{workspaces.length} 个</span>
|
||||
{!creating ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus size={14} />
|
||||
新建
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="card h-[92px] animate-pulse bg-paper-50" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
{workspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => navigate(`/w/${ws.id}`)}
|
||||
className="group card flex items-center gap-3 p-4 text-left transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-pop"
|
||||
>
|
||||
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-xl bg-primary-900 text-sm font-semibold text-paper-50">
|
||||
{ws.name.trim().slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-ink-900">{ws.name}</span>
|
||||
<span className="mt-0.5 block text-2xs text-ink-400">
|
||||
{roleLabel(ws.role)} · {ws.plan === "free" ? "免费版" : ws.plan}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight
|
||||
size={16}
|
||||
className="shrink-0 text-ink-300 transition group-hover:translate-x-0.5 group-hover:text-primary-500"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{creating ? (
|
||||
<form
|
||||
onSubmit={createWorkspace}
|
||||
className="card space-y-3 border-clay-500/30 p-4 sm:col-span-2 xl:col-span-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md bg-clay-50 text-clay-600">
|
||||
<Plus size={14} />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-ink-800">新建工作区</span>
|
||||
</div>
|
||||
<Field label="名称">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="例如:达道科技 / 我的事项"
|
||||
value={name}
|
||||
maxLength={64}
|
||||
autoFocus
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
{error && <InlineAlert>{error}</InlineAlert>}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary" size="sm" disabled={busy || !name.trim()}>
|
||||
{busy ? "创建中…" : "创建"}
|
||||
{busy ? null : <ArrowRight size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreating(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex min-h-[92px] items-center justify-center gap-2 rounded-xl border border-dashed border-line-300 text-sm text-ink-400 transition hover:border-primary-300 hover:text-primary-600"
|
||||
>
|
||||
<Plus size={16} />
|
||||
新建工作区
|
||||
</button>
|
||||
)}
|
||||
|
||||
{workspaces.length === 0 && !creating ? (
|
||||
<div className="card sm:col-span-2 xl:col-span-3">
|
||||
<EmptyState
|
||||
icon={<Plus size={18} />}
|
||||
title="还没有工作区"
|
||||
hint="个人用也一样:先建一个,把「任务」放进去。需要别人帮忙时再升级成工单。"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-6 text-2xs leading-relaxed text-ink-400">
|
||||
收到邀请链接(<span className="font-mono">/join/…</span>)直接打开即可加入,不需要别人手动拉你。
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user