feat(web): 重做设计系统与全部页面,前端观感对齐当下审美
用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用: - tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色 sage/wheat/rust/sky,字号阶、圆角阶、投影、动效 - index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏 - 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader) - 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标 - 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地 - 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮 e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label), 三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
This commit is contained in:
+134
-95
@@ -1,16 +1,17 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Layout } from "../components/Layout";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { WorkspaceNav } from "../components/WorkspaceNav";
|
||||
import { ChevronRight, TicketIcon } from "../components/icons";
|
||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { STATUS_LABEL } from "../lib/parse";
|
||||
import type { Item, Ticket } from "../lib/types";
|
||||
|
||||
const ROLES = [
|
||||
{ key: "assigned", label: "派给我的" },
|
||||
{ key: "requested", label: "我发起的" },
|
||||
{ key: "all", label: "全部" },
|
||||
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
||||
{ key: "requested", label: "我发起的", desc: "你等别人的" },
|
||||
{ key: "all", label: "全部", desc: "工作区所有工单" },
|
||||
] as const;
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
@@ -31,7 +32,6 @@ export function TicketsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [badge, setBadge] = useState(0);
|
||||
const reqId = useRef(0);
|
||||
|
||||
const load = useCallback(
|
||||
@@ -44,9 +44,11 @@ export function TicketsPage() {
|
||||
const qs = new URLSearchParams({ role });
|
||||
if (status) qs.set("status", status);
|
||||
if (nextCursor) qs.set("cursor", nextCursor);
|
||||
const page = await api.get<{ tickets: Ticket[]; next_cursor: string | null; has_more: boolean }>(
|
||||
`/workspaces/${wsId}/tickets?${qs.toString()}`,
|
||||
);
|
||||
const page = await api.get<{
|
||||
tickets: Ticket[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>(`/workspaces/${wsId}/tickets?${qs.toString()}`);
|
||||
if (mine !== reqId.current) return;
|
||||
setTickets((prev) => (nextCursor ? [...prev, ...page.tickets] : page.tickets));
|
||||
setCursor(page.has_more ? page.next_cursor : null);
|
||||
@@ -70,109 +72,149 @@ export function TicketsPage() {
|
||||
version: ticket.version,
|
||||
...body,
|
||||
});
|
||||
setBadge((n) => n + 1);
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条刚被别人改过,已为你刷新列表");
|
||||
void load(null);
|
||||
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
const currentRole = ROLES.find((r) => r.key === role);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">工单</h1>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
待接 → 办理中 → 待验收 → 完成;验收通过后,当初卡住的那条任务会自动回到待办
|
||||
</p>
|
||||
</div>
|
||||
<WorkspaceNav wsId={wsId} refreshKey={badge} />
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{ROLES.map((r) => (
|
||||
<button
|
||||
key={r.key}
|
||||
onClick={() => setRole(r.key)}
|
||||
className={`rounded-full px-3 py-1 text-sm transition ${
|
||||
role === r.key
|
||||
? "bg-ink-900 text-white"
|
||||
: "bg-ink-100 text-ink-700 hover:bg-ink-100/70"
|
||||
}`}
|
||||
<AppShell wsId={wsId} active="tickets">
|
||||
<PageHeader
|
||||
title="工单"
|
||||
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
||||
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
||||
actions={
|
||||
<select
|
||||
className="input h-9 w-36 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="按状态筛选"
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
className="input ml-auto w-36"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 inline-flex items-center gap-0.5 rounded-xl border border-line-200 bg-paper-50 p-0.5">
|
||||
{ROLES.map((r) => {
|
||||
const active = role === r.key;
|
||||
return (
|
||||
<button
|
||||
key={r.key}
|
||||
onClick={() => setRole(r.key)}
|
||||
title={r.desc}
|
||||
className={`rounded-[9px] px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
active
|
||||
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
||||
: "text-ink-500 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 rounded-lg bg-red-50 p-2 text-sm text-red-700">{error}</p>}
|
||||
|
||||
{tickets.length === 0 && !loading && (
|
||||
<div className="card text-center text-sm text-ink-500">
|
||||
这里还空着。任务卡在别人手里时,在任务详情里点「升级成工单」就会出现在这儿。
|
||||
{error && (
|
||||
<div className="mb-4">
|
||||
<InlineAlert>{error}</InlineAlert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{tickets.map((t) => (
|
||||
<li key={t.id} className="card">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(t.id)}>
|
||||
<p className="truncate text-sm font-medium">{t.title}</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
{STATUS_LABEL[t.status]} · 更新 {new Date(t.updated_at).toLocaleString("zh-CN")}
|
||||
{t.ticket.reject_count > 0 ? ` · 退回 ${t.ticket.reject_count} 次` : ""}
|
||||
{t.due_at
|
||||
? ` · 截止 ${new Date(t.due_at).toLocaleDateString("zh-CN")}`
|
||||
: ""}
|
||||
</p>
|
||||
</button>
|
||||
<div className="flex shrink-0 gap-2 text-xs">
|
||||
{t.status === "open" && (
|
||||
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "accept")}>
|
||||
接收
|
||||
</button>
|
||||
)}
|
||||
{t.status === "accepted" && (
|
||||
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "start")}>
|
||||
开始
|
||||
</button>
|
||||
)}
|
||||
{t.status === "pending_verify" && (
|
||||
<button className="btn-primary px-2 py-1" onClick={() => void quickAction(t, "verify", { approve: true })}>
|
||||
通过
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-ghost px-2 py-1" onClick={() => setOpenId(t.id)}>
|
||||
详情
|
||||
{loading && tickets.length === 0 ? (
|
||||
<div className="card overflow-hidden">
|
||||
<SkeletonRows rows={3} />
|
||||
</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<TicketIcon size={18} />}
|
||||
title={role === "assigned" ? "没有等你的工单" : "这里还空着"}
|
||||
hint={
|
||||
role === "assigned"
|
||||
? "别人把事交给你办时,这里会出现;要主动找人帮忙,去任务详情点「升级成工单」。"
|
||||
: currentRole?.desc
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{tickets.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="card group p-0 transition hover:border-line-300 hover:shadow-pop"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 p-4">
|
||||
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(t.id)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusPill status={t.status} label={STATUS_LABEL[t.status]} />
|
||||
{t.ticket.reject_count > 0 && (
|
||||
<span className="pill bg-rust-50 text-rust-500">
|
||||
退回 {t.ticket.reject_count} 次
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 truncate text-sm font-medium text-ink-900 group-hover:text-primary-700">
|
||||
{t.title}
|
||||
</p>
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-400">
|
||||
<span>更新 {new Date(t.updated_at).toLocaleString("zh-CN")}</span>
|
||||
{t.due_at ? (
|
||||
<span>截止 {new Date(t.due_at).toLocaleDateString("zh-CN")}</span>
|
||||
) : null}
|
||||
{t.tags.map((tag) => (
|
||||
<span key={tag.id}>#{tag.name}</span>
|
||||
))}
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{t.status === "open" && (
|
||||
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "accept")}>
|
||||
接收
|
||||
</Button>
|
||||
)}
|
||||
{t.status === "accepted" && (
|
||||
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "start")}>
|
||||
开始
|
||||
</Button>
|
||||
)}
|
||||
{t.status === "pending_verify" && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => void quickAction(t, "verify", { approve: true })}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpenId(t.id)}>
|
||||
详情
|
||||
<ChevronRight size={13} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{cursor && (
|
||||
<div className="mt-4 text-center">
|
||||
<button className="btn-ghost" disabled={loading} onClick={() => void load(cursor)}>
|
||||
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
|
||||
{loading ? "加载中…" : "加载更多"}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -182,20 +224,17 @@ export function TicketsPage() {
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onChanged={(updated: Item) =>
|
||||
setTickets((prev) =>
|
||||
prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)),
|
||||
)
|
||||
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
||||
}
|
||||
onDeleted={(id) => {
|
||||
setTickets((prev) => prev.filter((t) => t.id !== id));
|
||||
setOpenId(null);
|
||||
}}
|
||||
onTicketChanged={() => {
|
||||
setBadge((n) => n + 1);
|
||||
void load(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Layout>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user