Files
DadaoTask/web/src/pages/Home.tsx
T
Steven 096acb00e7
CI / backend (push) Canceled after 0s
feat(web): 首页换成和工作区同一套外壳,补上账号设置页
首页原来是居中窄栏,点进工作区又变全屏,进出像换了个网站。现在把首页也做成
全宽 + 左侧栏(SideRail 抽出来给首页和账号页共用),工作区卡片改成网格,
新建工作区是网格里的虚线卡片——宽度不再跳。

账号页 /account:基本信息(头像首字母、昵称、只读手机号、加入时间)、
登录密码(设置/修改 + 确认输入)、退出登录。工作区侧栏底部的用户块点进去就是它。

e2e account.mjs 14 项全绿,其中一条断言进工作区后侧栏宽度和首页差值 < 1px。
2026-10-31 04:05:05 +08:00

194 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../App";
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";
function roleLabel(role: string): string {
return { owner: "所有者", admin: "管理员", member: "成员", guest: "访客" }[role] ?? role;
}
function greeting() {
const h = new Date().getHours();
if (h < 6) return "夜深了";
if (h < 11) return "早上好";
if (h < 14) return "中午好";
if (h < 18) return "下午好";
return "晚上好";
}
/**
* 首页 = 工作区总览。
*
* 壳和工作区里完全一样(同样的侧栏、同样的通栏内容区),
* 这样"点进工作区"只是换内容,宽度和导航位置都不跳。
*/
export function HomePage() {
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);
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("");
setCreating(false);
await reload();
navigate(`/w/${ws.id}`);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
} finally {
setBusy(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">
<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>
<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>
</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>
);
}