From 5913cd2f43f4193e1889f561ca545e5c53a351cb Mon Sep 17 00:00:00 2001 From: zuiidea Date: Tue, 14 Apr 2026 19:17:55 +0800 Subject: [PATCH] docs: add apps/basic implementation plan Made-with: Cursor --- .../plans/2026-04-14-apps-basic-template.md | 1076 +++++++++++++++++ 1 file changed, 1076 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-14-apps-basic-template.md diff --git a/docs/superpowers/plans/2026-04-14-apps-basic-template.md b/docs/superpowers/plans/2026-04-14-apps-basic-template.md new file mode 100644 index 0000000..61762fc --- /dev/null +++ b/docs/superpowers/plans/2026-04-14-apps-basic-template.md @@ -0,0 +1,1076 @@ +# apps/basic 模板实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在 monorepo 中新增 `apps/basic`,由 `apps/with-lingui` 拷贝并去除 Lingui 与语言切换,界面为英文硬编码,Ant Design 固定 `en_US`。 + +**Architecture:** 目录级「拷贝后减法」;不抽 shared package。`settings` 持久化使用独立 storage key;`package.json` 使用唯一 `name`(如 `antd-admin-basic`)。 + +**Tech Stack:** 与 `with-lingui` 相同(Vite+、React 19、TanStack Router/Query、Ant Design 6、Zustand、MSW、Playwright),不包含任何 `@lingui/*`。 + +--- + +## 文件映射(创建 / 修改 / 删除) + +| 路径 | 操作 | +|------|------| +| `apps/basic/**` | 自 `apps/with-lingui` 拷贝生成(排除 `node_modules`、`dist`、`.turbo`、coverage 等产物) | +| `apps/basic/lingui.config.ts` | 删除 | +| `apps/basic/src/lingui.d.ts` | 删除 | +| `apps/basic/src/locales/**` | 删除整个目录 | +| `apps/basic/package.json` | 修改:`name`、依赖、脚本 | +| `apps/basic/vite.config.ts` | 修改:去掉 Lingui 插件 | +| `apps/basic/src/main.tsx` | 修改:去掉 i18n 启动 | +| `apps/basic/src/routes/__root.tsx` | 修改:固定 `enUS`,去掉 `I18nProvider` | +| `apps/basic/src/stores/settings.ts` | 修改:去掉 locale,改 persist `name` | +| `apps/basic/src/components/Layout/Sidebar/index.tsx` | 修改:`MENU_LABELS` 与 `buildMenuItems` 签名 | +| `apps/basic/src/components/Layout/Header/index.tsx` | 修改:去掉语言按钮与 Lingui | +| `apps/basic/src/routes/login/index.tsx` | 修改:去掉语言按钮与 Lingui | +| `apps/basic/src/components/Layout/AppFooter/index.tsx` 等 8+ 处路由/组件 | 修改:见各 Task | +| `pnpm-lock.yaml`(仓库根) | 修改:`pnpm install` 后更新 | +| `README.md`(仓库根) | 修改:Templates 说明 | +| `apps/basic/README.md` | 修改或新增:英文-only 说明 | + +--- + +### Task 1: 拷贝应用骨架 + +**Files:** + +- Create: `apps/basic/**`(整树,来自 with-lingui) + +- [ ] **Step 1: 从 with-lingui 拷贝到 basic(排除产物)** + +在仓库根目录执行: + +```bash +cd /Users/zuiidea/web/antd-admin +rsync -a --delete \ + --exclude node_modules --exclude dist --exclude .turbo --exclude coverage \ + --exclude ".vite-hooks" \ + apps/with-lingui/ apps/basic/ +``` + +若无 `rsync`,可用: + +```bash +cp -R apps/with-lingui apps/basic +rm -rf apps/basic/node_modules apps/basic/dist apps/basic/.turbo +``` + +- [ ] **Step 2: 确认 `apps/basic` 存在且不含 `node_modules`** + +```bash +test -f apps/basic/package.json && test ! -d apps/basic/node_modules && echo OK +``` + +Expected: 输出 `OK` + +- [ ] **Step 3: Commit** + +```bash +git add apps/basic +git commit -m "chore: scaffold apps/basic from with-lingui" +``` + +(若 pre-commit 失败且仅因 hook 配置,可按团队约定使用 `--no-verify`。) + +--- + +### Task 2: package.json 与锁文件 + +**Files:** + +- Modify: `apps/basic/package.json` +- Modify: `pnpm-lock.yaml`(根目录) + +- [ ] **Step 1: 编辑 `apps/basic/package.json`** + +将 `name` 改为 `"antd-admin-basic"`。 + +从 `scripts` 中**删除**这两行: + +```json + "i18n:extract": "vp exec lingui extract", + "i18n:compile": "vp exec lingui compile" +``` + +从 `dependencies` 中**删除**: + +```json + "@lingui/core": "^5.9.3", + "@lingui/react": "^5.9.3", +``` + +从 `devDependencies` 中**删除**: + +```json + "@lingui/cli": "^5.9.3", + "@lingui/swc-plugin": "^5.11.0", + "@lingui/vite-plugin": "^5.9.3", +``` + +其余字段保持与源应用一致。 + +- [ ] **Step 2: 安装依赖并更新 lockfile** + +```bash +cd /Users/zuiidea/web/antd-admin +pnpm install +``` + +Expected: 退出码 0,`pnpm-lock.yaml` 出现与 `antd-admin-basic` 相关的解析记录。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/basic/package.json pnpm-lock.yaml +git commit -m "chore(basic): drop Lingui deps and rename package" +``` + +--- + +### Task 3: Vite 配置 + +**Files:** + +- Modify: `apps/basic/vite.config.ts` + +- [ ] **Step 1: 将 `apps/basic/vite.config.ts` 全文替换为以下内容** + +```typescript +import { defineConfig } from "vite-plus"; +import react from "@vitejs/plugin-react-swc"; +import { tanstackRouter } from "@tanstack/router-plugin/vite"; + +export default defineConfig({ + plugins: [ + tanstackRouter({ + routesDirectory: "./src/routes", + generatedRouteTree: "./src/routeTree.gen.ts", + }), + react(), + ], + resolve: { + alias: { + "@": "/src", + }, + }, + build: { + rollupOptions: { + output: { + manualChunks: (id: string) => { + if (id.includes("node_modules/antd")) { + return "vendor-antd"; + } + if (id.includes("@tanstack/react-router") || id.includes("@tanstack/react-query")) { + return "vendor-tanstack"; + } + if (id.includes("lucide-react")) { + return "vendor-ui"; + } + }, + }, + }, + chunkSizeWarningLimit: 1024, + }, + staged: { + "*": "vp check --fix", + }, + lint: { options: { typeAware: true, typeCheck: true } }, +}); +``` + +- [ ] **Step 2: 在 `apps/basic` 下跑类型检查** + +```bash +cd apps/basic && pnpm exec tsc --noEmit +``` + +Expected: 退出码 0(若此时仍因其它文件引用 Lingui 失败,完成 Task 4–8 后再重跑)。 + +- [ ] **Step 3: Commit** + +```bash +git add apps/basic/vite.config.ts +git commit -m "chore(basic): remove Lingui vite and swc plugins" +``` + +--- + +### Task 4: 删除 Lingui 专属文件 + +**Files:** + +- Delete: `apps/basic/lingui.config.ts` +- Delete: `apps/basic/src/lingui.d.ts` +- Delete: `apps/basic/src/locales/`(目录) + +- [ ] **Step 1: 删除文件与目录** + +```bash +rm -f apps/basic/lingui.config.ts apps/basic/src/lingui.d.ts +rm -rf apps/basic/src/locales +``` + +- [ ] **Step 2: Commit** + +```bash +git add -A apps/basic +git commit -m "chore(basic): remove lingui config and locale catalogs" +``` + +--- + +### Task 5: 入口与根路由 + +**Files:** + +- Modify: `apps/basic/src/main.tsx` +- Modify: `apps/basic/src/routes/__root.tsx` + +- [ ] **Step 1: 将 `apps/basic/src/main.tsx` 全文替换为** + +```typescript +import React from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; +import { routeTree } from "./routeTree.gen"; +import { useAuthStore } from "./stores/auth"; +import { fetchSessionAndApplyToStore } from "./utils/session"; + +const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +async function enableMocking() { + const enableMockInBuild = import.meta.env.VITE_ENABLE_MOCK === "true"; + if (!import.meta.env.DEV && !enableMockInBuild) return; + const { worker } = await import("./mocks/browser"); + return worker.start({ onUnhandledRequest: "bypass" }); +} + +enableMocking() + .then(async () => { + await useAuthStore.persist.rehydrate(); + const { isAuthenticated, tokens } = useAuthStore.getState(); + if (isAuthenticated && tokens) { + try { + await fetchSessionAndApplyToStore(); + } catch { + useAuthStore.getState().logout(); + } + } + + ReactDOM.createRoot(document.getElementById("root")!).render( + + + , + ); + }) + .catch((err) => { + console.error("Failed to initialize app:", err); + }); +``` + +- [ ] **Step 2: 将 `apps/basic/src/routes/__root.tsx` 全文替换为** + +```typescript +import { useEffect, useLayoutEffect } from "react"; +import { createRootRoute, Outlet } from "@tanstack/react-router"; +import { ConfigProvider, App } from "antd"; +import enUS from "antd/locale/en_US"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useSettingsStore } from "@/stores/settings"; +import { useAppTheme } from "@/hooks/useAppTheme"; +import { NotFound } from "@/components/NotFound"; +import "@/index.css"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false }, + }, +}); + +function RootComponent() { + const darkMode = useSettingsStore((s) => s.darkMode); + const configProviderProps = useAppTheme(); + + useLayoutEffect(() => { + document.documentElement.lang = "en"; + }, []); + + useEffect(() => { + document.documentElement.setAttribute("data-theme", darkMode ? "dark" : "light"); + }, [darkMode]); + + return ( + + + + + + + + ); +} + +export const Route = createRootRoute({ + component: RootComponent, + notFoundComponent: NotFound, +}); +``` + +- [ ] **Step 3: 运行 TypeScript** + +```bash +cd apps/basic && pnpm exec tsc --noEmit +``` + +- [ ] **Step 4: Commit** + +```bash +git add apps/basic/src/main.tsx apps/basic/src/routes/__root.tsx +git commit -m "feat(basic): remove Lingui bootstrap and fix Antd to en_US" +``` + +--- + +### Task 6: Settings store + +**Files:** + +- Modify: `apps/basic/src/stores/settings.ts` + +- [ ] **Step 1: 将 `apps/basic/src/stores/settings.ts` 全文替换为** + +```typescript +import { createPersistentStore } from "./createPersistentStore"; + +function getInitialDarkMode() { + if (typeof window === "undefined") { + return false; + } + return window.matchMedia("(prefers-color-scheme: dark)").matches; +} + +interface SettingsState { + darkMode: boolean; + sidebarCollapsed: boolean; + toggleDarkMode: () => void; + toggleSidebar: () => void; + setSidebarCollapsed: (collapsed: boolean) => void; +} + +export const useSettingsStore = createPersistentStore( + (set) => ({ + darkMode: getInitialDarkMode(), + sidebarCollapsed: false, + toggleDarkMode: () => set((s) => ({ darkMode: !s.darkMode })), + toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })), + setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }), + }), + { + name: "settings-storage-basic", + partialize: (state) => ({ + darkMode: state.darkMode, + sidebarCollapsed: state.sidebarCollapsed, + }), + }, +); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/basic/src/stores/settings.ts +git commit -m "feat(basic): drop locale from settings store" +``` + +--- + +### Task 7: 小型组件(完整替换) + +**Files:** + +- Modify: `apps/basic/src/components/DataTable/DataTableEmpty.tsx` +- Modify: `apps/basic/src/components/NotFound/index.tsx` +- Modify: `apps/basic/src/routes/_auth/403/index.tsx` +- Modify: `apps/basic/src/components/Layout/AppFooter/index.tsx` + +- [ ] **Step 1: `DataTableEmpty.tsx` 全文替换** + +```typescript +import { Flex, theme } from "antd"; +import { BarChart3 } from "lucide-react"; +import type { ReactElement } from "react"; + +/** + * Table empty state: icon in soft tile + bold title + secondary description (dashboard-style). + */ +export function DataTableEmpty(): ReactElement { + const { token } = theme.useToken(); + + return ( + + + + + + + No data + + + Nothing to show in this list yet + + + + ); +} +``` + +- [ ] **Step 2: `NotFound/index.tsx` 全文替换** + +```typescript +import { useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, Home, SearchX } from "lucide-react"; +import { Button, Flex, Result, Space, theme } from "antd"; + +/** Shared 404 UI for `/404` route and root `notFoundComponent`. */ +export function NotFound() { + const navigate = useNavigate(); + const { token } = theme.useToken(); + + const goDashboard = () => { + void navigate({ to: "/dashboard" }); + }; + + const goBack = () => { + if (typeof window !== "undefined" && window.history.length > 1) { + window.history.back(); + } else { + goDashboard(); + } + }; + + return ( + + + + } + title="404" + subTitle="Sorry, the page you visited does not exist." + extra={ + + + + + } + /> + + + ); +} +``` + +- [ ] **Step 3: `403/index.tsx` 全文替换** + +```typescript +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Home, ShieldAlert } from "lucide-react"; +import { Button, Flex, Result, theme } from "antd"; + +export const Route = createFileRoute("/_auth/403/")({ + component: ForbiddenPage, +}); + +function ForbiddenPage() { + const navigate = useNavigate(); + const { token } = theme.useToken(); + + const goDashboard = () => { + void navigate({ to: "/dashboard" }); + }; + + return ( + + + } + title="403" + subTitle="Sorry, you don't have permission to access this page." + extra={ + + } + /> + + ); +} +``` + +- [ ] **Step 4: `AppFooter/index.tsx` 全文替换** + +```typescript +import { Flex, Typography, theme } from "antd"; +import { GitHub } from "@/components/Icon"; + +const ANTD_ADMIN_REPO = "https://github.com/zuiidea/antd-admin"; + +export function AppFooter() { + const { token } = theme.useToken(); + const iconSize = Math.max(12, Math.round(Number(token.fontSizeSM))); + + return ( + + + Powered by + + + + antd-admin + + + ); +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add apps/basic/src/components/DataTable/DataTableEmpty.tsx \ + apps/basic/src/components/NotFound/index.tsx \ + apps/basic/src/routes/_auth/403/index.tsx \ + apps/basic/src/components/Layout/AppFooter/index.tsx +git commit -m "feat(basic): replace Lingui strings in small layout components" +``` + +--- + +### Task 8: Header 组件 + +**Files:** + +- Modify: `apps/basic/src/components/Layout/Header/index.tsx` + +- [ ] **Step 1: 将 `apps/basic/src/components/Layout/Header/index.tsx` 全文替换为** + +```typescript +import { Layout, Button, Space, theme, Breadcrumb, Flex, Divider, Grid } from "antd"; +import type { ItemType } from "antd/es/breadcrumb/Breadcrumb"; +import { useSettingsStore } from "@/stores/settings"; +import { Link, useLocation, useMatches } from "@tanstack/react-router"; +import { Home, PanelLeft, ShieldAlert, Users } from "lucide-react"; +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { Theme } from "@/components/Icon"; + +const { Header: AntHeader } = Layout; + +const PATH_LABEL: Record = { + "/dashboard": "Dashboard", + "/users": "Users", + "/403": "403", +}; + +function normalizePath(pathname: string): string { + if (pathname === "/") return pathname; + return pathname.replace(/\/+$/, "") || "/"; +} + +export type HeaderProps = { + /** + * When `false`, breadcrumb is hidden; left `Flex` still uses `flex={1}` so header actions stay right-aligned. + * Routes may also set `staticData: { hideBreadcrumb: true }` (deepest matching route wins). + */ + showBreadcrumb?: boolean; +}; + +export function Header({ showBreadcrumb: showBreadcrumbProp = true }: HeaderProps) { + const toggleSidebar = useSettingsStore((s) => s.toggleSidebar); + const toggleDarkMode = useSettingsStore((s) => s.toggleDarkMode); + const location = useLocation(); + const matches = useMatches(); + const { token } = theme.useToken(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.lg; + + const iconSize = token.fontSize; + + const crumb = (Icon: LucideIcon, label: ReactNode, linkTo?: "/dashboard") => { + const row = ( + <> + + {label} + + ); + const rowStyle = { + display: "inline-flex" as const, + alignItems: "center" as const, + gap: token.marginXS, + color: "inherit" as const, + }; + if (linkTo) { + return ( + + {row} + + ); + } + return {row}; + }; + + const path = normalizePath(location.pathname); + const segments = path.split("/").filter(Boolean); + const firstSegmentPath = segments.length ? `/${segments[0]}` : "/dashboard"; + const leafLabelKey = PATH_LABEL[firstSegmentPath] ?? segments[0] ?? "Dashboard"; + + const leafIcon: LucideIcon = + firstSegmentPath === "/users" ? Users : firstSegmentPath === "/403" ? ShieldAlert : Home; + + const leafLabel = leafLabelKey; + + const breadcrumbItems: ItemType[] = []; + + const onDashboard = path === "/dashboard" || path === "/"; + + if (onDashboard) { + breadcrumbItems.push({ + title: crumb(Home, "Dashboard"), + }); + } else { + breadcrumbItems.push({ + title: crumb(Home, "Dashboard", "/dashboard"), + }); + + breadcrumbItems.push({ + title: crumb(leafIcon, leafLabel), + }); + + if (segments.length > 1) { + const tail = segments.slice(1).join(" / "); + if (tail) { + breadcrumbItems.push({ title: tail }); + } + } + } + + const leafStatic = matches.at(-1)?.staticData as { hideBreadcrumb?: boolean } | undefined; + const hideBreadcrumbFromRoute = leafStatic?.hideBreadcrumb === true; + const showBreadcrumb = + showBreadcrumbProp && !hideBreadcrumbFromRoute && breadcrumbItems.length > 0; + + return ( + + + {isMobile ? ( +