diff --git a/web/e2e/shots.mjs b/web/e2e/shots.mjs new file mode 100644 index 0000000..a80cf52 --- /dev/null +++ b/web/e2e/shots.mjs @@ -0,0 +1,84 @@ +import { chromium } from "playwright-core"; + +const API = "http://127.0.0.1:8100/api/v1"; +const WEB = "http://127.0.0.1:5180"; +const phone = "1370000" + String(Date.now()).slice(-4); + +async function call(path, body, token, method = "POST") { + const res = await fetch(API + path, { + method, + headers: { + "content-type": "application/json", + ...(token ? { authorization: "Bearer " + token } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const json = await res.json(); + if (json.code !== 0) throw new Error(path + " -> " + JSON.stringify(json).slice(0, 200)); + return json.data; +} + +const sent = await call("/auth/sms/send", { phone }); +const login = await call("/auth/login", { phone, code: sent.dev_code ?? "123456" }); +const token = login.tokens.access_token; +const ws = await call("/workspaces", { name: "海图工作室", slug: "haitu-" + Date.now().toString(36) }, token); +const wsId = ws.id; + +const tasks = [ + ["整理发票原件并寄给财务", "todo", 2, "#报销 财务"], + ["给客户出第三季度复盘报告", "doing", 3, "#客户 周报"], + ["把采集端的日志级别改回 info", "doing", 1, ""], + ["和供应商确认二次报价", "blocked", 2, "#采购"], + ["入职物料清单更新", "done", 0, ""], + ["评审新版落地页文案", "inbox", 1, "#设计"], +]; +for (const [title, status, priority, tags] of tasks) { + const item = await call(`/workspaces/${wsId}/items`, { title, status, priority }, token); + if (tags) { + const names = tags.split(" ").filter((t) => t.startsWith("#")).map((t) => t.slice(1)); + if (names.length) await call(`/workspaces/${wsId}/items/${item.id}`, { version: item.version, tag_names: names }, token, "PATCH"); + } +} + +const shop = await call(`/workspaces/${wsId}/items`, { title: "官网改版:首页轮播要换成新版素材", status: "todo", priority: 2 }, token); +await call(`/workspaces/${wsId}/items/${shop.id}/knowledge?doc_type=howto`, {}, token); +await call(`/workspaces/${wsId}/knowledge`, { title: "报销单必须贴发票原件,打印件不算", doc_type: "note" }, token); + +const me = await call("/workspaces/" + wsId + "/members", null, token, "GET").catch(() => null); + +const browser = await chromium.launch({ + executablePath: "C:\\Users\\Steven\\AppData\\Local\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe", +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); +await page.goto(WEB + "/login"); +await page.evaluate(([t, r]) => { + localStorage.setItem("dadao.access", t); + localStorage.setItem("dadao.refresh", r); +}, [login.tokens.access_token, login.tokens.refresh_token]); + +const shots = []; +async function shot(url, name, prep) { + await page.goto(WEB + url, { waitUntil: "networkidle" }); + if (prep) await prep(); + await page.waitForTimeout(600); + const file = `e2e/artifacts/ui-${name}.png`; + await page.screenshot({ path: file, fullPage: false }); + shots.push(file); +} + +await shot("/", "home"); +await shot(`/w/${wsId}`, "tasks"); +await shot(`/w/${wsId}/board`, "board"); +await shot(`/w/${wsId}/tickets`, "tickets"); +await shot(`/w/${wsId}/knowledge`, "knowledge"); +await shot(`/w/${wsId}/members`, "members"); +await shot(`/w/${wsId}`, "detail", async () => { + await page.getByRole("button", { name: "全部", exact: true }).click(); + await page.waitForTimeout(500); + await page.getByRole("button", { name: "把采集端的日志级别改回 info" }).first().click(); + await page.waitForTimeout(800); +}); +await shot("/login", "login"); + +console.log(shots.join("\n")); +await browser.close(); diff --git a/web/e2e/smoke.mjs b/web/e2e/smoke.mjs index 75e9993..8d52f69 100644 --- a/web/e2e/smoke.mjs +++ b/web/e2e/smoke.mjs @@ -74,10 +74,10 @@ try { // 4. 详情抽屉可编辑:改优先级 + 加评论 await page.getByRole("button", { name: "写周报初稿" }).click(); - const drawer = page.locator("aside"); + const drawer = page.getByRole("dialog"); await drawer.waitFor(); // 抽屉里现在有两个 select(优先级 + 升级工单选人),取第一个才是优先级 - await drawer.locator("select").first().selectOption("2"); + await drawer.getByLabel("优先级").selectOption("2"); await page.waitForTimeout(500); const commentBox = drawer.getByPlaceholder("补充一句进展…"); await commentBox.fill("E2E:初稿已出,等评审"); diff --git a/web/e2e/tickets.mjs b/web/e2e/tickets.mjs index 38e6db2..d26e687 100644 --- a/web/e2e/tickets.mjs +++ b/web/e2e/tickets.mjs @@ -117,9 +117,9 @@ try { await pageA.goto(`${APP}/w/${wsId}`, { waitUntil: "domcontentloaded" }); await pageA.getByRole("button", { name: "全部", exact: true }).click(); await pageA.getByRole("button", { name: "开通测试账号权限" }).click(); - const drawerA = pageA.locator("aside"); + const drawerA = pageA.getByRole("dialog"); await drawerA.waitFor(); - const memberPicker = drawerA.locator("select").nth(1); + const memberPicker = drawerA.getByLabel("选择办理人"); await memberPicker.selectOption({ index: 1 }); await drawerA.getByRole("button", { name: "升级成工单" }).click(); await drawerA.locator("text=已把工单交给").first().waitFor(); @@ -142,7 +142,7 @@ try { await ticketCard.getByRole("button", { name: "开始", exact: true }).waitFor(); await ticketCard.getByRole("button", { name: "开始", exact: true }).click(); await ticketCard.getByRole("button", { name: "详情", exact: true }).click(); - const drawerB = pageB.locator("aside"); + const drawerB = pageB.getByRole("dialog"); await drawerB.waitFor(); await drawerB.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("已经开了只读账号"); await drawerB.getByRole("button", { name: "提交验收" }).click(); @@ -156,7 +156,7 @@ try { const mineCard = pageA.locator("li.card").filter({ hasText: "开通测试账号权限" }).first(); await mineCard.waitFor(); await mineCard.getByRole("button", { name: "详情", exact: true }).click(); - const drawerVerify = pageA.locator("aside"); + const drawerVerify = pageA.getByRole("dialog"); await drawerVerify.waitFor(); await drawerVerify.getByPlaceholder("给对方的说明(提交或验收时一并发过去)").fill("确认可用"); await drawerVerify.getByRole("button", { name: "通过验收" }).click(); @@ -186,7 +186,7 @@ try { await browser.close(); } catch (err) { await pageA.screenshot({ path: `${SHOT_DIR}/tickets-failure.png` }).catch(() => {}); - const drawerText = await pageA.locator("aside").innerText().catch(() => ""); + const drawerText = await pageA.getByRole("dialog").innerText().catch(() => ""); if (drawerText) console.error(`\n[抽屉内容]\n${drawerText}`); if (consoleErrors.length > 0) console.error(`\n[控制台]\n${consoleErrors.join("\n")}`); const bodyText = await pageA.locator("body").innerText().catch(() => ""); diff --git a/web/src/App.tsx b/web/src/App.tsx index 60b5dd6..3d0e936 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; import { Navigate, Route, Routes, useNavigate } from "react-router-dom"; +import { ToastProvider } from "./components/ui"; import { api, authExpired, session } from "./lib/client"; import type { User, Workspace } from "./lib/types"; import { LoginPage } from "./pages/Login"; @@ -80,18 +81,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { export function App() { return ( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); } diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx new file mode 100644 index 0000000..0d46ef5 --- /dev/null +++ b/web/src/components/AppShell.tsx @@ -0,0 +1,253 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { useAuth } from "../App"; +import { api } from "../lib/client"; +import type { Workspace } from "../lib/types"; +import { + Book, + ChevronDown, + Columns, + Inbox, + Logo, + Logout, + TicketIcon, + Users, + X, +} from "./icons"; + +export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members"; + +const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [ + { key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: }, + { key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: }, + { key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: }, + { key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: }, + { key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: }, +]; + +function initial(name: string) { + return name.trim().slice(0, 1).toUpperCase() || "W"; +} + +const ROLE_TEXT: Record = { + owner: "所有者", + admin: "管理员", + member: "成员", + guest: "访客", +}; + +export function AppShell({ + wsId, + active, + children, +}: { + wsId: string; + active: Section; + children: ReactNode; +}) { + const { user, workspaces, signOut } = useAuth(); + const navigate = useNavigate(); + const [fetched, setFetched] = useState(null); + const [unread, setUnread] = useState(0); + const [switcher, setSwitcher] = useState(false); + + // 深链直接进来时 workspaces 还没加载好,单独取一次工作区信息 + const ws = workspaces.find((w) => w.id === wsId) ?? fetched; + + useEffect(() => { + if (ws || !wsId) return; + let alive = true; + api + .get(`/workspaces/${wsId}`) + .then((w) => { + if (alive) setFetched(w); + }) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [ws, wsId]); + + useEffect(() => { + if (!wsId) return; + let alive = true; + const load = async () => { + try { + const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`); + if (alive) setUnread(r.count); + } catch { + /* 未读数失败不该影响主流程 */ + } + }; + void load(); + const timer = window.setInterval(load, 30_000); + return () => { + alive = false; + window.clearInterval(timer); + }; + }, [wsId]); + + if (!ws) { + return ( +
+
+ + 正在打开工作区… +
+
+ ); + } + + const phoneMasked = user?.phone + ? user.phone.replace(/^(\d{3})\d{4}(\d{4})$/, "$1 **** $2") + : ""; + + return ( +
+ {/* —— 侧栏(桌面)—— */} + + + {/* —— 顶部条(移动端)—— */} +
+
+ + + {ws.name} + +
+ {unread > 0 ? ( + + {unread} + + ) : null} + +
+
+ +
+ +
+
{children}
+
+
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx deleted file mode 100644 index e89a22b..0000000 --- a/web/src/components/Layout.tsx +++ /dev/null @@ -1,34 +0,0 @@ -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 ( -
-
- - 达道Task - 任务 → 工单 → 知识,一条流 - -
- {user && {user.nickname}} - {user ? ( - - ) : ( - - )} -
-
-
{children}
-
- 达道 · 从任务到知识,一条道走到底 -
-
- ); -} diff --git a/web/src/components/TaskDetail.tsx b/web/src/components/TaskDetail.tsx index b477518..e03a3ce 100644 --- a/web/src/components/TaskDetail.tsx +++ b/web/src/components/TaskDetail.tsx @@ -3,6 +3,19 @@ import { useAuth } from "../App"; import { ApiError, api } from "../lib/client"; import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse"; import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types"; +import { + Book, + Clock, + Flag, + Link2, + Lock, + Sparkle, + TicketIcon, + Trash, + Users, +} from "./icons"; +import { Button } from "./ui"; +import { Sheet } from "./ui"; interface Comment { id: string; @@ -23,6 +36,17 @@ interface Props { onTicketChanged?: () => void; } +const ACTION_LABEL: Record = { + created: "创建", + status_changed: "状态变更", + promoted_to_ticket: "升级成工单", + ticket_done: "工单验收通过", + comment_added: "评论", + assigned: "指派", + knowledge_status_changed: "知识状态变更", + title_changed: "改标题", +}; + function toLocalInput(iso: string | null): string { if (!iso) return ""; const d = new Date(iso); @@ -30,6 +54,12 @@ function toLocalInput(iso: string | null): string { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } +function initials(name: string): string { + const n = name.trim(); + if (!n) return "?"; + return /^[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(-2); +} + export function TaskDetail({ wsId, itemId, @@ -249,14 +279,15 @@ export function TaskDetail({ const ticket = item?.kind === "ticket" ? item.ticket : undefined; const isAssignee = !!ticket && !!user && ticket.assignee_id === user.id; const isRequester = !!ticket && !!user && ticket.requester_id === user.id; + const pendingVerify = item?.status === "pending_verify"; - function actionButtons(t: TicketInfo) { + function actionButtons() { const buttons: { key: TicketAction | "approve" | "reject"; label: string; show: boolean }[] = [ - { key: "accept", label: "接收", show: t && isAssignee && item?.status === "open" }, + { key: "accept", label: "接收", show: isAssignee && item?.status === "open" }, { key: "start", label: "开始办理", show: isAssignee && item?.status === "accepted" }, { key: "submit", label: "提交验收", show: isAssignee && item?.status === "in_progress" }, - { key: "approve", label: "通过验收", show: isRequester || isAdmin }, - { key: "reject", label: "驳回", show: isRequester || isAdmin }, + { key: "approve", label: "通过验收", show: (isRequester || isAdmin) && pendingVerify }, + { key: "reject", label: "驳回", show: (isRequester || isAdmin) && pendingVerify }, { key: "cancel", label: "取消工单", @@ -265,16 +296,16 @@ export function TaskDetail({ ["open", "accepted", "in_progress", "pending_verify"].includes(item?.status ?? ""), }, ]; - const verifyOnly = item?.status !== "pending_verify"; return ( -
+
{buttons - .filter((b) => b.show && !(verifyOnly && (b.key === "approve" || b.key === "reject"))) + .filter((b) => b.show) .map((b) => ( - + ))}
); } + const meta = item + ? `${item.kind === "ticket" ? "工单" : item.kind === "knowledge" ? "知识" : "任务"} · ${ + STATUS_LABEL[item.status] + } · 版本 ${item.version}` + : "加载中…"; + return ( -
-