chore: initial app template scaffold

Astro + tRPC + Drizzle monorepo starter with auth/user vertical slice.
No product domain logic; extend via the user slice pattern.
This commit is contained in:
noelorin 2026-09-24 15:21:45 +08:00
commit a5ba7124dc
186 changed files with 18401 additions and 0 deletions

29
.dockerignore Normal file
View File

@ -0,0 +1,29 @@
# 依赖与缓存
node_modules
**/node_modules
.yarn/install-state.gz
.turbo
**/.turbo
# 构建产物(镜像内重新 build)
**/dist
**/.astro
output
# VCS / 本地环境
.git
.env
.env.*
!.env.example
*.db
local.db
*.log
*.tsbuildinfo
**/*.tsbuildinfo
.DS_Store
# 本地文档与元数据(不影响构建)
AGENTS.md
DESIGN.md
README.md
.prettierrc

10
.env.example Normal file
View File

@ -0,0 +1,10 @@
# Turso 数据库连接
# 本地开发用 sqlite,线上用 turso
TURSO_DATABASE_URL=file:local.db
# 生产环境(替换为实际 turso URL)
# TURSO_DATABASE_URL=libsql://<your-db>.turso.io
# TURSO_AUTH_TOKEN=your_token_here
# SEO 规范源(canonical / sitemap / og:url 使用的真实域名,生产必填)
# PUBLIC_SITE_URL=https://tool.example.com

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
node_modules/
dist/
.astro/
*.local
.env
.env.*
!.env.example
.playwright-cli/
# build / tooling caches
.turbo/
.tsbuildinfo*
*.tsbuildinfo
.yarn/install-state.gz
*.log
**/.turbo/
output/
# local database
local.db
*.db
!*.db.sql
# build outputs already covered by dist/, but ensure package-level
**/dist/
storybook-static/
**/storybook-static/
.DS_Store

1
.node-version Normal file
View File

@ -0,0 +1 @@
26.7.0

1
.prettierrc Normal file
View File

@ -0,0 +1 @@
{"semi":false,"singleQuote":true,"trailingComma":"all","printWidth":100}

1
.yarnrc.yml Normal file
View File

@ -0,0 +1 @@
nodeLinker: node-modules

92
AGENTS.md Normal file
View File

@ -0,0 +1,92 @@
# AGENTS.md — app-template 总览
面向 AI 编码代理的仓库导览。修改任何代码前,先读本文件与目标子包的 `AGENTS.md`。
## 项目是什么
**可复用全栈脚手架 / 开发模板**(不绑定产品业务)。只保留鉴权 + `user` 垂直示例,演示完整分层接线。视觉基线见 `DESIGN.md`。
技术栈:Astro 7(SSR)+ React 19 islands · tRPC v11 · Drizzle ORM + Turso/libSQL · Tailwind CSS 4 · TypeScript 7 · Biome · Yarn 4 workspaces + Turbo。
## 模块地图
| 包 | 职责 | 依赖 |
|----|------|------|
| `@app/types` | Zod 入参 schema + 共享输出类型 | zod |
| `@app/models` | Drizzle 表定义(唯一 schema 源) | drizzle-orm |
| `@app/db` | libSQL/Turso 客户端单例 + drizzle-kit | models |
| `@app/dao` | 数据访问层(`userDao` 等) | db, models |
| `@app/trpc` | tRPC 路由 + service 业务逻辑(调用 DAO) | types, dao |
| `@app/web` | Astro 前端 + React islands + 本地 tRPC HTTP 适配 | trpc, types, design-tokens, seo-geo, i18n, ui |
| `@app/ui` | shadcn 风格基础 UI 组件(源码直出 + Storybook) | design-tokens, react (peer) |
| `@app/design-tokens` | 共享 CSS 设计令牌(`tokens.css`) | — |
| `@app/seo-geo` | SEO/GEO 纯函数(meta、sitemap、JSON-D、llms.txt) | zod, schema-dts, escape-html, fast-xml-builder, serialize-javascript, mdast-util-to-markdown |
| `@app/i18n` | 文案与 locale 纯函数(zh-CN / en、t()、Accept-Language) | — |
| `@app/adsense` | AdSense 脚本加载 + `AdSlot` React 组件 | react (peer) |
依赖方向(只允许向下):
```
web ──► trpc ──► dao ──► db ──► models
│ │ │
│ └─────────────────┴──► types
├──► design-tokens
├──► seo-geo
├──► i18n
├──► adsense
└──► ui
```
禁止循环依赖。跨包只通过 `package.json` 的 `exports` 入口引用,不要 deep import。
## 目录约定
- 源码在各包 `src/`;构建产物在 `dist/`(git 忽略,勿手改)。
- 环境变量模板见 `.env.example`:`TURSO_DATABASE_URL`、`TURSO_AUTH_TOKEN`、`PUBLIC_SITE_URL`。
- 根 `.env` 由 `packages/db/drizzle.config.ts` 手动加载(drizzle-kit 在 monorepo 中不会自动读根 env)。
- **模板不掺杂业务逻辑**:禁止把产品领域实体/页面写进本仓库;下游业务仓库再扩展。
## 常用命令
```bash
yarn install
yarn typecheck # 全仓 typecheck
yarn lint # Biome
yarn build # 全仓 build(依赖拓扑)
yarn build:web # 仅 @app/web + 上游依赖闭包(日常/CI 更快)
yarn dev:web # Astro :4321
yarn dev:api # 独立 tRPC :4000(web 内嵌 /api/trpc 时通常不需要)
yarn db:generate # Drizzle 迁移生成
yarn db:push # 推 schema
yarn db:studio
```
改某个包时可先 `yarn workspace @app/<name> typecheck`。
## 全局约定(必须遵守)
1. **TypeScript 很严**:`strict` + `noUncheckedIndexedAccess` + `exactOptionalPropertyTypes` + `noPropertyAccessFromIndexSignature`。可选属性传 `undefined` 时类型要写 `?: T | undefined`。
2. **ESM only**(`"type": "module"`)。源码内相对 import 带 `.js` 后缀(TS bundler 解析)。
3. **格式化/静态检查**:Biome(2 空格、行宽 100)。不要引入 ESLint/Prettier。
4. **校验**:对外输入用 Zod 4,定义放在 `@app/types`,router/service 共用。
5. **数据库模型**只放 `@app/models`;**查询/SQL** 只放 `@app/dao`;`@app/db` 只建连接单例,不建表、不含业务规则。
6. **UI**:设计令牌从 `@app/design-tokens` 引入;组件优先用 `@app/ui`(shadcn 风格,源码在 `packages/ui`)+ `cn()`。视觉遵循 `DESIGN.md`。
7. **构建顺序**:改 `types`/`models` 后需先 build 上游,再让下游 typecheck(Turbo `dependsOn: ["^build"]` 已配置)。
8. **注释**:只写非显而易见的 WHY;默认不写注释。
9. **新增业务实体**:复制 `user` 垂直切片(types → models → dao → trpc → web),不要跳层。
## 各包入口
细节见各包 `AGENTS.md`:
- [packages/types/AGENTS.md](packages/types/AGENTS.md)
- [packages/models/AGENTS.md](packages/models/AGENTS.md)
- [packages/db/AGENTS.md](packages/db/AGENTS.md)
- [packages/dao/AGENTS.md](packages/dao/AGENTS.md)
- [packages/trpc/AGENTS.md](packages/trpc/AGENTS.md)
- [app/web/AGENTS.md](app/web/AGENTS.md)
- [packages/design-tokens/AGENTS.md](packages/design-tokens/AGENTS.md)
- [packages/seo-geo/AGENTS.md](packages/seo-geo/AGENTS.md)
- [packages/i18n/AGENTS.md](packages/i18n/AGENTS.md)
- [packages/adsense/AGENTS.md](packages/adsense/AGENTS.md)
- [packages/ui/AGENTS.md](packages/ui/AGENTS.md)

43
DESIGN.md Normal file
View File

@ -0,0 +1,43 @@
# App Template — 设计方向
脚手架视觉基线:**深空工业中控终端**。不绑定具体产品叙事;下游可在此 palette / type 上换肤,但 **`@app/design-tokens` 色板 token 路径约定不变**(改值可以,改结构需同步全局)。
## Style anchor
深色工业 HUD / 控制台:近黑底、信号黄强调、发丝线框、等宽铭牌。
## Palette
| 角色 | 值 |
|------|-----|
| 背景(近黑) | `#0E0F0E` |
| 表面 | `#191919` |
| 下沉 | `#121212` |
| 线框 | `#2E2E2E` |
| 线框(强) | `#35373C` |
| 墨色 | `#F2F2F2` |
| 弱化文字 | `#999999` |
| 更弱文字 | `#666666` |
| 强调(信号黄) | `#FFFA00` |
| 信号绿 | `#00FFA2` |
| 信号品红 | `#FF1AAC` |
| 亮色背景(对照) | `#FAFAFA` |
## Typography
- Display:`Space Grotesk` + `Noto Sans SC` 900
- 正文 CJK:`Noto Sans SC` / `PingFang SC`
- 等宽:`JetBrains Mono`(编号、快捷键、路径、状态读出)
- 字阶:10–11px 铭牌 / 13–14px 正文 / 28–48px 标题;标签全大写 + `0.16–0.22em` 字距
## Layout
- 左侧固定竖栏(品牌 + 导航 + 主题)
- 内容壳 + 顶栏;分隔用点线 `u-dotline` / 虚线
- 按钮 `u-btn`;卡片 `u-card`;间距 4px 节奏;小圆角 3–12px
- **克制装饰**:禁贴纸堆叠;色彩以 tokens 为准
## Interaction
- 主题:系统 / 亮 / 暗(`app-theme`)
- 语言:cookie `app-lang` + Accept-Language
- SSR 输出结构与内容;动效只在浏览器 effect 内绑定
- 尊重 `prefers-reduced-motion`
## 范围
`tokens.css` · `globals.css` · `AppLayout.astro` · `ThemeSwitch` · `UserMenu` · `home` / `dashboard` 视图 · `ui/*`

71
Dockerfile Normal file
View File

@ -0,0 +1,71 @@
# syntax=docker/dockerfile:1
# 与 .node-version 对齐;可用 build-arg 覆盖
ARG NODE_VERSION=26.7.0
FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /app
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
TURBO_TELEMETRY_DISABLED=1 \
DO_NOT_TRACK=1
# Node 25+ 官方镜像不再内置 corepack,需显式安装以使用 yarn@4
RUN npm install -g corepack && corepack enable
# ---- 依赖层:只拷清单,最大化 yarn 缓存 ----
FROM base AS deps
COPY package.json yarn.lock .yarnrc.yml ./
COPY app/web/package.json app/web/package.json
# workspaces: packages/* — 清单缺一不可,否则 yarn 解析 workspace:* 失败或漏链
COPY packages/adsense/package.json packages/adsense/package.json
COPY packages/dao/package.json packages/dao/package.json
COPY packages/db/package.json packages/db/package.json
COPY packages/design-tokens/package.json packages/design-tokens/package.json
COPY packages/i18n/package.json packages/i18n/package.json
COPY packages/models/package.json packages/models/package.json
COPY packages/seo-geo/package.json packages/seo-geo/package.json
COPY packages/trpc/package.json packages/trpc/package.json
COPY packages/types/package.json packages/types/package.json
COPY packages/ui/package.json packages/ui/package.json
RUN --mount=type=cache,target=/root/.yarn/berry/cache \
yarn install --immutable
# ---- 构建层:只 build web 依赖闭包 + 裁剪生产依赖 ----
FROM deps AS build
COPY . .
# Astro 构建期内联 PUBLIC_*;空串会导致 canonical/sitemap 抛 Invalid URL,
# 因此给 localhost 默认值,生产用 --build-arg PUBLIC_SITE_URL=https://example.com 覆盖
ARG PUBLIC_SITE_URL=http://localhost:4321
ENV PUBLIC_SITE_URL=${PUBLIC_SITE_URL} \
ASTRO_TELEMETRY_DISABLED=1 \
TURBO_TELEMETRY_DISABLED=1
# --filter=@app/web...:web + 其上游依赖;跳过 adsense 等无关包
# --cache-dir 指到 /tmp:与 .turbo 分离,rm -rf .turbo 不会清掉 BuildKit 缓存挂载
# 缓存挂载需 BuildKit(DOCKER_BUILDKIT=1)
RUN --mount=type=cache,id=turbo-cache,target=/tmp/turbo-cache \
yarn turbo run build --filter=@app/web... --cache-dir=/tmp/turbo-cache \
&& yarn workspaces focus --production @app/web \
&& rm -rf .git .turbo output \
&& rm -rf packages/*/src packages/*/*.tsbuildinfo \
&& rm -rf app/web/src app/web/node_modules/.vite
# ---- 运行层:仅产物 + 生产 node_modules ----
FROM node:${NODE_VERSION}-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
HOSTNAME=0.0.0.0 \
PORT=4321 \
# file: 库与 session 的可写位置(/app 属主为 root,node 用户不可写)
TMPDIR=/tmp
COPY --from=build --chown=node:node /app/package.json /app/yarn.lock /app/.yarnrc.yml ./
COPY --from=build --chown=node:node /app/app/web/package.json app/web/package.json
COPY --from=build --chown=node:node /app/app/web/dist app/web/dist
COPY --from=build --chown=node:node /app/packages packages
COPY --from=build --chown=node:node /app/node_modules node_modules
# 工作区内可能存在的局部 node_modules(会话缓存写在其 .astro 下)
COPY --from=build --chown=node:node /app/app/web/node_modules app/web/node_modules
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 4321
CMD ["node", "app/web/dist/server/entry.mjs"]

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# App Template — Astro + tRPC + Drizzle Monorepo
可复用全栈脚手架:**不绑定产品业务**,只保留鉴权 + `user` 垂直示例,演示完整分层接线。
```
.
├── app/
│ └── web/ @app/web — Astro SSR + React islands + i18n 路由
├── packages/
│ ├── types/ @app/types — Zod 入参 schema + 共享 DTO
│ ├── models/ @app/models — Drizzle 表定义(唯一 schema 源)
│ ├── db/ @app/db — libSQL/Turso 连接单例 + drizzle-kit
│ ├── dao/ @app/dao — 数据访问层
│ ├── trpc/ @app/trpc — tRPC 路由 + service
│ ├── ui/ @app/ui — shadcn 风格基础组件 + Storybook
│ ├── design-tokens/ @app/design-tokens
│ ├── seo-geo/ @app/seo-geo — SEO/GEO 纯函数
│ ├── i18n/ @app/i18n — zh-CN / en 文案与 locale 工具
│ └── adsense/ @app/adsense — AdSense 加载(可选)
└── docs/
```
## 快速开始
```bash
yarn install
cp .env.example .env
# 填入 TURSO_DATABASE_URL / TURSO_AUTH_TOKEN / PUBLIC_SITE_URL
yarn db:push # 推 schema
yarn dev:web # Astro :4321
```
## 技术栈
| 层 | 技术 |
|------|------|
| 前端 | Astro 7 + React 19 islands |
| API | tRPC v11 |
| ORM | Drizzle + @libsql/client(Turso) |
| 样式 | Tailwind CSS 4 + design-tokens |
| 校验 | Zod 4 |
| 工程 | TypeScript 7 · Biome · Yarn 4 · Turbo |
## 分层(必须遵守)
`web → trpc → dao → db → models`,横切 `types`。禁止循环依赖;跨包只走 `package.json` 的 `exports`。
新增业务实体请复制 `user` 垂直切片:`types` schema → `models` 表 → `dao` 查询 → `trpc` service/router → `web` 页面。细节见根目录 `AGENTS.md`。

2
app/web/.env.example Normal file
View File

@ -0,0 +1,2 @@
# Point to the tRPC API server
PUBLIC_API_URL=http://localhost:4000

3
app/web/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
dist/
.astro/

74
app/web/AGENTS.md Normal file
View File

@ -0,0 +1,74 @@
# AGENTS.md — @app/web
## 职责
Astro SSR 前端(Node standalone):页面、布局、React islands、tRPC HTTP 适配、SEO 文本端点、**i18n 路由**。
脚手架基线页面:首页、鉴权、仪表盘(user 列表示例)、设置。视觉遵循仓库根 `DESIGN.md` 与 `@app/design-tokens`。
## i18n
- 语言:`zh-CN`(`/zh-CN/...`)、`en`(`/en/...`)。**中文也带前缀**,不把中文留在裸 `/`。
- **单套路由**:`src/pages/[locale]/index.astro`、`[locale]/dashboard.astro` + `src/views/*`;禁止再复制 `pages/zh-CN`、`pages/en` 目录。
- 文案:`@app/i18n` 的 `t(locale, key)`;React island 传 `locale` prop,不引入 i18next。
- 裸 `/`:`src/middleware.ts` 按 cookie `app-lang` → `Accept-Language` 跳 `/zh-CN` 或 `/en`;响应 `Vary: Accept-Language`;非法/大小写 `zh-cn` 规范化。
- 手动切换:header 语言链接写 cookie 后跳转(布局内联脚本)。
- 设计说明:`docs/superpowers/specs/2026-09-24-i18n-design.md`。
## 结构
```
src/
pages/
[locale]/index.astro | dashboard.astro | auth/* | settings.astro
api/trpc/[...trpc].ts | api/users.ts
sitemap.xml.ts | robots.txt.ts | llms.txt.ts | llms-full.txt.ts
views/
home.astro | dashboard.astro | auth.astro | error.astro
middleware.ts # 裸 / 语言检测跳转 + locale 规范化
layouts/AppLayout.astro
components/
UserList.tsx | UserMenu.tsx | ThemeSwitch.tsx | AuthForm.tsx | errors/*
lib/
trpc.ts | seo.ts(getSiteSeo(locale))| theme.ts | utils.ts(cn re-export)
styles/globals.css
astro.config.ts
```
路径别名
- `~/*`、`@/*` → `src/*`
- `@app/i18n` / `@app/trpc` / `@app/types` / `@app/db` / `@app/seo-geo` → 各包 `dist`(astro.config.ts)
- `@app/ui` → `packages/ui/src`(源码直出)
- typecheck 时 tsconfig paths 指向各包 `src`——**改上游后先 build 再 dev/build web**
## 页面与 SEO
- 布局统一走 `AppLayout.astro`,必传 `locale`,通过 `seo` prop 传 `PageSeoInput`。
- 站点 SEO:`lib/seo.ts` 的 `getSiteSeo(locale)`;sitemap 双语 URL + hreflang `alternates`。
- 新公开页面:登记 `PUBLIC_PATHS` / views,并同步 `getSiteSeo` 的 `llms.pages` 与 sitemap。
## React islands
- 需要交互才加 `client:load` / `client:visible`。
- 文案:`import { t } from "@app/i18n"` + `locale` prop;不要绑 i18next Provider。
- tRPC:组件内 `QueryClientProvider` + trpc Provider(参考 `UserList.tsx`)。
- UI:`@app/ui`(通用基础组件)+ `cn()`(`~/lib/utils` re-export);颜色用 CSS 变量。
## 修改指南
- **加文案**:先改 `packages/i18n/src/messages/zh-CN.ts`,再补 `en.ts`。
- **加业务页面**:在下游仓库扩展,勿把产品业务写回模板。
- **调 API**:优先 tRPC;`api/users.ts` 仅演示。
- **主题**:`localStorage` key `app-theme`;语言 cookie `app-lang`。
- Astro 保留字:组件 props 避免 `slot`。
## 命令
```bash
yarn workspace @app/web typecheck
yarn workspace @app/web lint
yarn workspace @app/web dev # :4321
yarn workspace @app/web build
yarn dev:web
```

54
app/web/astro.config.ts Normal file
View File

@ -0,0 +1,54 @@
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import node from "@astrojs/node";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "astro/config";
const __dirname = dirname(fileURLToPath(import.meta.url));
// monorepo 根 .env(Astro 默认只读 app/web/.env,SSR 里 @app/db 要 process.env.TURSO_*)
try {
const env = readFileSync(resolve(__dirname, "../../.env"), "utf-8");
for (const line of env.split("\n")) {
const [key, ...vals] = line.split("=");
if (key && !key.startsWith("#")) {
process.env[key.trim()] ??= vals.join("=").trim();
}
}
} catch {}
// SSR + Node standalone;@app/* 别名指向各包 dist(dev/build 前需先 build 上游)
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [react()],
// 路由:动态 [locale]/… 单套视图;/ 与非法前缀在 middleware 处理
vite: {
plugins: [tailwindcss()],
optimizeDeps: {
// 预打包 react CJS,避免浏览器拿裸 CJS 导致 island 无 createRoot
include: ["react", "react-dom", "react-dom/client"],
},
resolve: {
alias: {
"@/*": resolve(__dirname, "src"),
"@app/i18n": resolve(__dirname, "../../packages/i18n/dist/index.js"),
"@app/trpc": resolve(__dirname, "../../packages/trpc/dist/index.js"),
"@app/types": resolve(__dirname, "../../packages/types/dist/index.js"),
"@app/db": resolve(__dirname, "../../packages/db/dist/index.js"),
"@app/seo-geo": resolve(__dirname, "../../packages/seo-geo/dist/index.js"),
"@app/ui": resolve(__dirname, "../../packages/ui/src/index.ts"),
},
dedupe: ["react", "react-dom"],
},
ssr: {
noExternal: ["@app/trpc", "@app/db", "@app/i18n", "@app/ui"],
},
},
server: {
port: 4321,
host: false,
},
});

21
app/web/components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "~/components",
"utils": "~/lib/utils",
"ui": "../../packages/ui/src",
"lib": "~/lib",
"hooks": "~/hooks"
},
"iconLibrary": "lucide"
}

42
app/web/package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "@app/web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
},
"dependencies": {
"@app/design-tokens": "workspace:*",
"@app/i18n": "workspace:*",
"@app/seo-geo": "workspace:*",
"@app/trpc": "workspace:*",
"@app/types": "workspace:*",
"@app/ui": "workspace:*",
"@astrojs/node": "^11.1.6",
"@astrojs/react": "^4.3.0",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/react-query": "^5.75.0",
"@trpc/client": "^11.19.0",
"@trpc/react-query": "^11.19.0",
"astro": "^7.3.3",
"gsap": "^3.15.0",
"lenis": "^1.3.26",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwindcss": "^4.3.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@biomejs/biome": "^2.1.1",
"@trpc/server": "^11.19.0",
"@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,342 @@
/**
* 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。
* Provider 包在组件内部(与 UserList 同模式)。
*/
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { trpc, createTRPCReactClient } from "../lib/trpc";
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
toast,
} from "@app/ui";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
},
});
const trpcClient = createTRPCReactClient();
export type AuthFormMode = "login" | "register" | "forgot" | "reset" | "change";
export interface AuthFormProps {
locale: Locale;
mode: AuthFormMode;
/** 登录成功后的跳转地址 */
next?: string | undefined;
/** 重置页预填令牌 */
defaultToken?: string | undefined;
}
export default function AuthForm(props: AuthFormProps) {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<AuthFormInner {...props} />
</trpc.Provider>
</QueryClientProvider>
);
}
function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [token, setToken] = useState(defaultToken ?? "");
const [resetToken, setResetToken] = useState<string | null>(null);
const login = trpc.auth.login.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.loginOk"));
window.location.href = next?.startsWith("/") ? next : home(locale);
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const register = trpc.auth.register.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.registerOk"));
window.location.href = next?.startsWith("/") ? next : home(locale);
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const requestReset = trpc.auth.requestPasswordReset.useMutation({
onSuccess: (data) => {
toast.success(t(locale, "auth.msg.resetRequested"));
if (data.resetToken) {
setResetToken(data.resetToken);
setToken(data.resetToken);
}
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const resetPassword = trpc.auth.resetPassword.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.resetOk"));
window.location.href = `${home(locale)}/auth/login`;
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const changePassword = trpc.auth.changePassword.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.passwordOk"));
setCurrentPassword("");
setNewPassword("");
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const busy =
login.isPending ||
register.isPending ||
requestReset.isPending ||
resetPassword.isPending ||
changePassword.isPending;
function onSubmit(e: React.FormEvent) {
e.preventDefault();
if (busy) return;
if (mode === "login") {
login.mutate({ email, password });
} else if (mode === "register") {
register.mutate({ name, email, password });
} else if (mode === "forgot") {
requestReset.mutate({ email });
} else if (mode === "reset") {
resetPassword.mutate({ token, newPassword });
} else if (mode === "change") {
changePassword.mutate({ currentPassword, newPassword });
}
}
const titles: Record<AuthFormMode, { title: string; desc: string }> = {
login: {
title: t(locale, "auth.login.heading"),
desc: t(locale, "auth.login.subtitle"),
},
register: {
title: t(locale, "auth.register.heading"),
desc: t(locale, "auth.register.subtitle"),
},
forgot: {
title: t(locale, "auth.forgot.heading"),
desc: t(locale, "auth.forgot.subtitle"),
},
reset: {
title: t(locale, "auth.reset.heading"),
desc: t(locale, "auth.reset.subtitle"),
},
change: {
title: t(locale, "auth.settings.changeHeading"),
desc: t(locale, "auth.settings.hint"),
},
};
const homeBase = home(locale);
return (
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{titles[mode].title}</CardTitle>
<CardDescription>{titles[mode].desc}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
{mode === "register" && (
<div className="space-y-2">
<Label htmlFor="name">{t(locale, "auth.field.name")}</Label>
<Input
id="name"
name="name"
autoComplete="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
{(mode === "login" || mode === "register" || mode === "forgot") && (
<div className="space-y-2">
<Label htmlFor="email">{t(locale, "auth.field.email")}</Label>
<Input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
)}
{mode === "login" && (
<div className="space-y-2">
<Label htmlFor="password">{t(locale, "auth.field.password")}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{mode === "register" && (
<div className="space-y-2">
<Label htmlFor="password">{t(locale, "auth.field.password")}</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
minLength={8}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{mode === "change" && (
<>
<div className="space-y-2">
<Label htmlFor="currentPassword">
{t(locale, "auth.field.currentPassword")}
</Label>
<Input
id="currentPassword"
name="currentPassword"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">
{t(locale, "auth.field.newPassword")}
</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
</>
)}
{mode === "reset" && (
<>
<div className="space-y-2">
<Label htmlFor="token">{t(locale, "auth.field.token")}</Label>
<Input
id="token"
name="token"
required
value={token}
onChange={(e) => setToken(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">
{t(locale, "auth.field.newPassword")}
</Label>
<Input
id="newPassword"
name="newPassword"
type="password"
autoComplete="new-password"
minLength={8}
required
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
</div>
</>
)}
<Button type="submit" className="w-full" disabled={busy}>
{mode === "login" && t(locale, "auth.login.submit")}
{mode === "register" && t(locale, "auth.register.submit")}
{mode === "forgot" && t(locale, "auth.forgot.submit")}
{mode === "reset" && t(locale, "auth.reset.submit")}
{mode === "change" && t(locale, "auth.settings.changeSubmit")}
</Button>
</form>
{mode === "forgot" && resetToken && (
<div className="mt-4 space-y-2 rounded-md border border-[var(--color-border)] p-3">
<p className="text-xs text-[var(--color-muted-foreground)]">
{t(locale, "auth.forgot.tokenHint")}
</p>
<code className="u-mono block break-all text-[11px]">{resetToken}</code>
<a
className="u-linelink text-sm"
href={`${homeBase}/auth/reset-password?token=${encodeURIComponent(resetToken)}`}
>
{t(locale, "auth.forgot.toReset")}
</a>
</div>
)}
<div className="mt-4 flex flex-wrap justify-between gap-2 text-sm">
{mode === "login" && (
<>
<a className="u-linelink" href={`${homeBase}/auth/forgot-password`}>
{t(locale, "auth.forgot.title")}
</a>
<span className="text-[var(--color-muted-foreground)]">
{t(locale, "auth.login.noAccount")}{" "}
<a className="u-linelink" href={`${homeBase}/auth/register`}>
{t(locale, "auth.login.toRegister")}
</a>
</span>
</>
)}
{mode === "register" && (
<span className="text-[var(--color-muted-foreground)]">
{t(locale, "auth.register.hasAccount")}{" "}
<a className="u-linelink" href={`${homeBase}/auth/login`}>
{t(locale, "auth.register.toLogin")}
</a>
</span>
)}
{(mode === "forgot" || mode === "reset") && (
<a className="u-linelink" href={`${homeBase}/auth/login`}>
{t(locale, "auth.forgot.toLogin")}
</a>
)}
</div>
</CardContent>
</Card>
);
}
function home(locale: Locale): string {
return locale === "zh-CN" ? "/zh-CN" : "/en";
}

View File

@ -0,0 +1,53 @@
import { useEffect, useState } from "react";
import {
applyTheme,
readThemeMode,
watchSystemTheme,
writeThemeMode,
type ThemeMode,
} from "~/lib/theme";
const ORDER: ThemeMode[] = ["system", "light", "dark"];
const LABEL: Record<ThemeMode, string> = {
system: "SYS",
light: "LIGHT",
dark: "DARK",
};
const ICON: Record<ThemeMode, string> = {
system: "◎",
light: "☀",
dark: "☾",
};
export default function ThemeSwitch() {
const [mode, setMode] = useState<ThemeMode>("system");
useEffect(() => {
const current = readThemeMode();
setMode(current);
applyTheme(current);
return watchSystemTheme(() => {
if (readThemeMode() === "system") applyTheme("system");
});
}, []);
const cycle = () => {
const next = ORDER[(ORDER.indexOf(mode) + 1) % ORDER.length] ?? "system";
setMode(next);
writeThemeMode(next);
applyTheme(next);
};
return (
<button
type="button"
onClick={cycle}
aria-label={`主题模式:${LABEL[mode]},点击切换`}
title="主题:系统 / 亮色 / 暗色"
className="u-btn u-mono text-[11px] tracking-[0.14em]"
>
<span aria-hidden="true">{ICON[mode]}</span>
{LABEL[mode]}
</button>
);
}

View File

@ -0,0 +1,183 @@
/**
* 用户 CRUD 演示岛:tRPC + React Query。
* Provider 包在组件内部,便于 Astro 以 client:visible 挂载而不污染全局。
*/
import type { RouterOutputs } from "@app/trpc";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Input,
Label,
Separator,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { createTRPCReactClient, trpc } from "../lib/trpc";
const queryClient = new QueryClient();
const trpcClient = createTRPCReactClient();
type User = RouterOutputs["user"]["list"]["users"][number];
export default function UserList() {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<UserListInner />
</trpc.Provider>
</QueryClientProvider>
);
}
function UserListInner() {
const users = trpc.user.list.useQuery({ limit: 20, offset: 0 });
const createUser = trpc.user.create.useMutation({
onSuccess: () => {
users.refetch();
toast.success("用户创建成功");
},
onError: (err) => {
toast.error(`创建失败: ${err.message}`);
},
});
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name || !email || !password) return;
await createUser.mutateAsync({ name, email, password });
setName("");
setEmail("");
setPassword("");
};
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>用户列表</CardTitle>
<CardDescription>当前共 {users.data?.total ?? "—"} 位用户</CardDescription>
</CardHeader>
<CardContent className="p-0">
{users.isPending ? (
<div className="flex items-center justify-center py-12 text-sm text-[var(--color-muted-foreground)]">
加载中…
</div>
) : users.isError ? (
<div className="p-6 text-sm text-[var(--color-destructive)]">
加载失败: {users.error.message}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[120px]">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.data?.users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-[var(--color-muted-foreground)]"
>
暂无数据
</TableCell>
</TableRow>
) : (
users.data?.users.map((u: User) => (
<TableRow key={u.id}>
<TableCell className="font-mono text-xs">
{u.id.slice(0, 8)}…
</TableCell>
<TableCell>{u.name}</TableCell>
<TableCell>{u.email}</TableCell>
<TableCell>
<Badge variant="secondary">活跃</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">新建用户</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">名称</Label>
<Input
id="name"
placeholder="输入名称"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">邮箱</Label>
<Input
id="email"
type="email"
placeholder="input@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">密码</Label>
<Input
id="password"
type="password"
placeholder="至少 8 位"
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
</div>
<Separator />
<div className="flex gap-2">
<Button type="submit" disabled={createUser.isPending}>
{createUser.isPending ? "创建中…" : "创建用户"}
</Button>
{createUser.isError && (
<span className="text-sm text-[var(--color-destructive)] self-center">
{createUser.error.message}
</span>
)}
</div>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,125 @@
/**
* 顶栏用户菜单:接 `auth.me` / `auth.logout`,未登录显示登录/注册入口。
*/
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import {
Avatar,
AvatarFallback,
AvatarImage,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
toast,
} from "@app/ui";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createTRPCReactClient, trpc } from "../lib/trpc";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
const trpcClient = createTRPCReactClient();
export interface UserMenuProps {
locale?: Locale | undefined;
name?: string | undefined;
email?: string | undefined;
avatarUrl?: string | undefined;
}
function home(locale: Locale): string {
return locale === "zh-CN" ? "/zh-CN" : "/en";
}
export default function UserMenu({ locale = "zh-CN", avatarUrl }: UserMenuProps) {
return (
<QueryClientProvider client={queryClient}>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<UserMenuInner locale={locale} avatarUrl={avatarUrl} />
</trpc.Provider>
</QueryClientProvider>
);
}
function UserMenuInner({ locale, avatarUrl }: { locale: Locale; avatarUrl?: string | undefined }) {
const me = trpc.auth.me.useQuery();
const logout = trpc.auth.logout.useMutation({
onSuccess: () => {
toast.success(t(locale, "auth.msg.logoutOk"));
queryClient.clear();
window.location.reload();
},
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
});
const base = home(locale);
const user = me.data?.user ?? null;
if (me.isPending) {
return <span className="u-mono hidden text-[11px] sm:block opacity-60">…</span>;
}
if (!user) {
return (
<span className="flex items-center gap-2">
<a className="u-linelink" href={`${base}/auth/login`}>
{t(locale, "auth.menu.login")}
</a>
<a className="u-linelink hidden sm:inline" href={`${base}/auth/register`}>
{t(locale, "auth.menu.register")}
</a>
</span>
);
}
const name = user.name;
const initials = name.slice(0, 1);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="u-btn u-btn--ghost px-2">
<Avatar className="size-6">
{avatarUrl && <AvatarImage src={avatarUrl} alt={name} />}
<AvatarFallback
className="u-mono text-[10px]"
style={{ background: "var(--sink)", color: "var(--edge-as-text)" }}
>
{initials}
</AvatarFallback>
</Avatar>
<span className="u-mono hidden text-[11px] sm:block">{name}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">{name}</span>
<span className="u-mono text-[11px] opacity-70">{user.email}</span>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<a href={`${base}/dashboard`}>{t(locale, "auth.menu.dashboard")}</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={`${base}/settings`}>{t(locale, "auth.menu.settings")}</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
logout.mutate();
}}
disabled={logout.isPending}
>
{t(locale, "auth.menu.logout")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -0,0 +1,47 @@
/** 403 禁止访问 — 锁形图标;布局与其它错误组件一致。 */
import { createAppError } from "~/lib/errors";
import { ErrorShell } from "./ErrorShell";
export interface Error403Props {
label: string;
title: string;
description: string;
homeHref: string;
homeLabel: string;
}
function LockIcon() {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<path d="M8 11V8a4 4 0 0 1 8 0v3" />
<circle cx="12" cy="16" r="1.25" fill="currentColor" stroke="none" />
</svg>
);
}
export function Error403(props: Error403Props) {
return (
<ErrorShell
error={createAppError(403)}
label={props.label}
title={props.title}
description={props.description}
homeHref={props.homeHref}
homeLabel={props.homeLabel}
icon={<LockIcon />}
/>
);
}
export default Error403;

View File

@ -0,0 +1,49 @@
/** 404 未找到 — 断链/路径图标;布局与其它错误组件一致。 */
import { createAppError } from "~/lib/errors";
import { ErrorShell } from "./ErrorShell";
export interface Error404Props {
label: string;
title: string;
description: string;
homeHref: string;
homeLabel: string;
}
function PathBreakIcon() {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
<path d="M8.5 8.5 L11 11" />
<path d="M13 13 L15.5 15.5" strokeDasharray="2 2.5" />
<path d="M15 8l4 4M19 8l-4 4" />
</svg>
);
}
export function Error404(props: Error404Props) {
return (
<ErrorShell
error={createAppError(404)}
label={props.label}
title={props.title}
description={props.description}
homeHref={props.homeHref}
homeLabel={props.homeLabel}
icon={<PathBreakIcon />}
/>
);
}
export default Error404;

View File

@ -0,0 +1,53 @@
/** 500 及其它内部错误 — 警示三角图标;布局与其它错误组件一致。 */
import type { ReactNode } from "react";
import { createAppError, type AppError } from "~/lib/errors";
import { ErrorShell } from "./ErrorShell";
export interface Error500Props {
label: string;
title: string;
description: string;
homeHref: string;
homeLabel: string;
/** 可选:重试按钮等 */
action?: ReactNode;
/** 组件级 boundary 传入时保留原始 status/kind */
error?: AppError;
}
function AlertTriangleIcon() {
return (
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5 L21 19.5 H3 Z" />
<path d="M12 10v4" />
<circle cx="12" cy="16.5" r="1" fill="currentColor" stroke="none" />
</svg>
);
}
export function Error500(props: Error500Props) {
return (
<ErrorShell
error={props.error ?? createAppError(500)}
label={props.label}
title={props.title}
description={props.description}
homeHref={props.homeHref}
homeLabel={props.homeLabel}
icon={<AlertTriangleIcon />}
{...(props.action !== undefined ? { action: props.action } : {})}
/>
);
}
export default Error500;

View File

@ -0,0 +1,68 @@
/**
* 组件级 ErrorBoundary:捕获子树异常 → toAppError → 按 status 渲染 403/404/500。
* 文案由 i18n 经 props 传入(与路由级错误页同一套 key)。
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { toAppError, type AppError, type AppErrorStatus } from "~/lib/errors";
import { Error403 } from "./Error403";
import { Error404 } from "./Error404";
import { Error500 } from "./Error500";
export interface ErrorTexts {
/** 面板标签,如 ERROR / 系统错误 */
label: string;
title: string;
description: string;
homeLabel: string;
}
export interface ErrorBoundaryProps {
children: ReactNode;
homeHref: string;
texts: Record<AppErrorStatus, ErrorTexts>;
/** 可选:错误时回调(埋点等) */
onError?: (error: AppError, info: ErrorInfo) => void;
/** 自定义 fallback;不传则用内置三组件 */
fallback?: (error: AppError) => ReactNode;
}
interface ErrorBoundaryState {
error: AppError | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
override state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { error: toAppError(error) };
}
override componentDidCatch(error: unknown, info: ErrorInfo): void {
const appError = toAppError(error);
this.props.onError?.(appError, info);
if (import.meta.env.DEV) {
console.error("[ErrorBoundary]", appError, info);
}
}
override render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error);
const texts = this.props.texts[error.status];
const common = {
label: texts.label,
title: texts.title,
description: texts.description,
homeHref: this.props.homeHref,
homeLabel: texts.homeLabel,
};
if (error.status === 403) return <Error403 {...common} />;
if (error.status === 404) return <Error404 {...common} />;
return <Error500 {...common} error={error} />;
}
}
export default ErrorBoundary;

View File

@ -0,0 +1,97 @@
/**
* 错误展示共享外壳:三套错误组件布局一致,仅图标与文案随 AppError 变化。
* 可独立 SSR(无 client:*),也可被 ErrorBoundary 在客户端渲染。
*/
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
import type { AppError } from "~/lib/errors";
export interface ErrorShellProps {
error: AppError;
label: string;
title: string;
description: string;
icon: ReactNode;
homeHref: string;
homeLabel: string;
action?: ReactNode;
className?: string;
}
export function ErrorShell({
error,
label,
title,
description,
icon,
homeHref,
homeLabel,
action,
className,
}: ErrorShellProps) {
return (
<div
role="alert"
data-error-status={error.status}
data-error-kind={error.kind}
className={cn(
"u-card relative mx-auto flex w-full max-w-lg flex-col gap-0 overflow-hidden p-0",
className,
)}
>
<span
className="u-card__spine"
aria-hidden="true"
style={{ background: "var(--edge)" }}
/>
<div className="flex flex-col gap-6 p-8 sm:p-10">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<span
className="flex size-11 shrink-0 items-center justify-center rounded-[6px] border border-[var(--line-strong)] bg-[var(--sink)]"
aria-hidden="true"
>
{icon}
</span>
<div className="flex flex-col gap-1.5">
<p className="u-label u-label-edge">{label}</p>
<p
className="u-mono text-[11px] tracking-[0.14em]"
style={{ color: "var(--ink-3)" }}
>
{error.code} · HTTP {error.status}
</p>
</div>
</div>
<span
className="u-mono rounded-[4px] px-2 py-1 text-[11px] font-medium tracking-[0.12em]"
style={{ background: "var(--edge)", color: "#101110" }}
>
{error.status}
</span>
</div>
<div>
<h1 className="text-[28px] font-black leading-tight tracking-tight sm:text-[32px]">
{title}
</h1>
<span className="u-edge-bar mt-4" aria-hidden="true" />
<p
className="mt-4 text-[13px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{description}
</p>
</div>
<div className="flex flex-wrap items-center gap-3 pt-1">
<a href={homeHref} className="u-btn u-btn--edge">
{homeLabel}
</a>
{action}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,48 @@
/**
* 按 AppError 分发到 403 / 404 / 500 三组件;文案走 i18n prefix。
* 路由级 .astro 与组件级共用。
*/
import type { Locale } from "@app/i18n";
import { t } from "@app/i18n";
import { errorI18nPrefix, type AppError, type AppErrorStatus } from "~/lib/errors";
import { Error403 } from "./Error403";
import { Error404 } from "./Error404";
import { Error500 } from "./Error500";
export interface ErrorViewProps {
error: AppError;
locale: Locale;
homeHref: string;
}
export function ErrorView({ error, locale, homeHref }: ErrorViewProps) {
const prefix = errorI18nPrefix(error);
const label = t(locale, `${prefix}.label` as never);
const title = t(locale, `${prefix}.title` as never);
const description = t(locale, `${prefix}.desc` as never);
const homeLabel = t(locale, "error.home");
const common = { label, title, description, homeHref, homeLabel };
if (error.status === 403) return <Error403 {...common} />;
if (error.status === 404) return <Error404 {...common} />;
return <Error500 {...common} error={error} />;
}
export function errorTextsFor(locale: Locale): Record<
AppErrorStatus,
{
label: string;
title: string;
description: string;
homeLabel: string;
}
> {
const homeLabel = t(locale, "error.home");
const of = (kind: "forbidden" | "notFound" | "internal") => ({
label: t(locale, `error.${kind}.label` as never),
title: t(locale, `error.${kind}.title` as never),
description: t(locale, `error.${kind}.desc` as never),
homeLabel,
});
return { 403: of("forbidden"), 404: of("notFound"), 500: of("internal") };
}

View File

@ -0,0 +1,11 @@
/** 错误组件桶导出。 */
export { ErrorShell, type ErrorShellProps } from "./ErrorShell";
export { Error403, type Error403Props } from "./Error403";
export { Error404, type Error404Props } from "./Error404";
export { Error500, type Error500Props } from "./Error500";
export {
ErrorBoundary,
type ErrorBoundaryProps,
type ErrorTexts,
} from "./ErrorBoundary";
export { ErrorView, errorTextsFor, type ErrorViewProps } from "./ErrorView";

12
app/web/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_API_URL: string;
/** 站点生产域名,如 https://tool.example.com(SEO 规范源) */
readonly PUBLIC_SITE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -0,0 +1,217 @@
---
import "../styles/globals.css";
import UserMenu from "~/components/UserMenu";
import ThemeSwitch from "~/components/ThemeSwitch";
import {
buildDefaultJsonLd,
buildHeadTags,
renderHeadTagsHtml,
toJsonLdScript,
type PageSeoInput,
} from "@app/seo-geo";
import { COOKIE_LANG, COOKIE_LANG_MAX_AGE, type Locale, t } from "@app/i18n";
import { getSiteSeo } from "~/lib/seo";
export interface Props {
title: string;
locale: Locale;
brand?: string;
nav?: { label: string; href: string; n: string }[];
switchHref?: string;
switchLabel?: string;
seo?: PageSeoInput;
}
const {
title,
locale,
brand,
nav: navProp,
switchHref,
switchLabel,
seo,
} = Astro.props;
const brandName = brand ?? t(locale, "meta.brand");
const nav = navProp ?? [];
const siteSeo = getSiteSeo(locale);
const pageSeoInput: PageSeoInput = {
...(seo ?? {}),
title: seo?.title ?? title,
path: seo?.path ?? Astro.url.pathname,
};
const headTags = buildHeadTags(siteSeo, pageSeoInput);
const headHtml = renderHeadTagsHtml(headTags);
const jsonLdHtml = toJsonLdScript(buildDefaultJsonLd(siteSeo, pageSeoInput));
const pageTitle = headTags.find((tTag) => tTag.key === "og:title")?.value ?? title;
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const brandHref = homeHref;
const brandInitial = locale === "zh-CN" ? "模" : "A";
---
<!doctype html>
<html lang={locale} class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Noto+Sans+SC:wght@400;500;600;700;900&family=Space+Grotesk:wght@500;600;700&display=swap"
rel="stylesheet"
/>
<link rel="alternate" type="text/plain" href="/llms.txt" title="llms.txt" />
<link rel="alternate" hreflang="zh-CN" href={`${siteSeo.siteUrl}/zh-CN`} />
<link rel="alternate" hreflang="en" href={`${siteSeo.siteUrl}/en`} />
<link rel="alternate" hreflang="x-default" href={`${siteSeo.siteUrl}/zh-CN`} />
<title set:html={pageTitle} />
<Fragment set:html={headHtml} />
<Fragment set:html={jsonLdHtml} />
<script is:inline define:vars={{ cookieName: COOKIE_LANG, maxAge: COOKIE_LANG_MAX_AGE, nextLocale: locale === "zh-CN" ? "en" : "zh-CN" }}>
(function () {
function apply(mode) {
var dark =
mode === "dark" ||
(mode !== "light" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.classList.toggle("dark", dark);
document.documentElement.classList.toggle("light", !dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
}
var mode = "system";
try {
var t = localStorage.getItem("app-theme");
if (t === "light" || t === "dark" || t === "system") mode = t;
} catch (e) {}
apply(mode);
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", function () {
try {
if ((localStorage.getItem("app-theme") || "system") === "system") {
apply("system");
}
} catch (e) {
apply("system");
}
});
// 语言切换:写偏好 cookie 后由 <a> 跳转(捕获阶段)
document.addEventListener(
"click",
function (e) {
var el = e.target && e.target.closest ? e.target.closest("[data-switch-locale]") : null;
if (!el) return;
var target = el.getAttribute("data-switch-locale") || nextLocale;
document.cookie =
cookieName + "=" + target + "; path=/; max-age=" + maxAge + "; SameSite=Lax";
},
true,
);
})();
</script>
</head>
<body>
<div aria-hidden="true" class="bg-base pointer-events-none fixed inset-0 z-0"></div>
<div
aria-hidden="true"
class="u-grid-field pointer-events-none fixed inset-0 z-0"
style="opacity: 0.18;"
>
</div>
<div aria-hidden="true" class="bg-glow pointer-events-none fixed inset-0 z-0"></div>
<div aria-hidden="true" class="bg-vignette pointer-events-none fixed inset-0 z-0"></div>
<aside class="side-rail" aria-label={t(locale, "nav.primary")}>
<a href={brandHref} class="side-rail__brand" aria-label={brandName}>
<span
class="brand-signal flex size-8 items-center justify-center text-[12px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="side-rail__brand-text">{brandName}</span>
</a>
<div class="side-rail__rule" aria-hidden="true"></div>
<nav class="side-rail__nav">
<a href={brandHref} class="side-rail__link is-home">
<span class="u-mono">00</span>
<span>{t(locale, "nav.home")}</span>
</a>
{
nav.map((item) => (
<a href={item.href} class="side-rail__link">
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
))
}
</nav>
<div class="side-rail__rule" aria-hidden="true"></div>
<div class="side-rail__foot">
<ThemeSwitch client:visible />
</div>
</aside>
<div class="site-shell">
<header class="site-header">
<div class="site-header__inner">
<a href={brandHref} class="site-header__brand md:hidden" aria-label={brandName}>
<span
class="brand-signal flex size-7 items-center justify-center text-[11px] font-bold"
style="background: var(--edge); color: #101110;"
>
{brandInitial}
</span>
<span class="text-[14px] font-semibold tracking-[0.04em]">{brandName}</span>
</a>
<nav class="site-header__nav" aria-label={t(locale, "nav.primary")}>
{
nav.map((item) => (
<a href={item.href} class="u-linelink">
<span class="u-mono">{item.n}</span>
<span>{item.label}</span>
</a>
))
}
</nav>
<div class="site-header__actions">
{
switchHref && (
<a
href={switchHref}
class="u-linelink"
data-switch-locale={locale === "zh-CN" ? "en" : "zh-CN"}
hreflang={locale === "zh-CN" ? "en" : "zh-CN"}
aria-label={t(locale, "locale.switch")}
>
{switchLabel ?? (locale === "zh-CN" ? "EN" : "中文")}
</a>
)
}
<span class="md:hidden">
<ThemeSwitch client:visible />
</span>
<UserMenu client:visible locale={locale} />
</div>
</div>
<div class="site-header__dash" aria-hidden="true"></div>
</header>
<main class="page-main">
<slot />
</main>
<footer class="site-footer">
<div class="u-dotline" aria-hidden="true"></div>
<div class="site-footer__inner">
<p class="u-mono">{t(locale, "meta.brand")}</p>
<p class="u-label">{t(locale, "home.footer")}</p>
</div>
</footer>
</div>
</body>
</html>

View File

@ -0,0 +1,2 @@
// 布局入口:AppLayout.astro 由页面直接导入
export {};

88
app/web/src/lib/errors.ts Normal file
View File

@ -0,0 +1,88 @@
/**
* 统一应用错误对象:检测 / 分发文案与展示组件。
* 组件级(ErrorBoundary)与路由级(404/403/500 页)共用。
*/
export type AppErrorStatus = 403 | 404 | 500;
export type AppErrorKind = "forbidden" | "notFound" | "internal";
export interface AppError {
readonly name: "AppError";
readonly status: AppErrorStatus;
readonly kind: AppErrorKind;
/** 展示用短码,如 FORBIDDEN / NOT_FOUND / INTERNAL */
readonly code: string;
readonly message: string;
/** 原始错误(组件级 boundary 捕获时保留) */
readonly cause?: unknown;
}
const KIND_BY_STATUS: Record<AppErrorStatus, AppErrorKind> = {
403: "forbidden",
404: "notFound",
500: "internal",
};
const CODE_BY_STATUS: Record<AppErrorStatus, string> = {
403: "FORBIDDEN",
404: "NOT_FOUND",
500: "INTERNAL",
};
const DEFAULT_MESSAGE: Record<AppErrorStatus, string> = {
403: "Access denied",
404: "Not found",
500: "Internal error",
};
export function createAppError(
status: AppErrorStatus,
options?: { message?: string; cause?: unknown },
): AppError {
const err: AppError = {
name: "AppError",
status,
kind: KIND_BY_STATUS[status],
code: CODE_BY_STATUS[status],
message: options?.message ?? DEFAULT_MESSAGE[status],
};
if (options && "cause" in options) {
return { ...err, cause: options.cause };
}
return err;
}
export function isAppError(value: unknown): value is AppError {
return (
typeof value === "object" &&
value !== null &&
(value as { name?: unknown }).name === "AppError" &&
typeof (value as { status?: unknown }).status === "number" &&
((value as { status: number }).status === 403 ||
(value as { status: number }).status === 404 ||
(value as { status: number }).status === 500)
);
}
/** 未知异常 → AppError(非 403/404 一律归为 500)。 */
export function toAppError(value: unknown): AppError {
if (isAppError(value)) return value;
if (value instanceof Error) {
return createAppError(500, { message: value.message, cause: value });
}
return createAppError(500, { cause: value });
}
/** 按 HTTP status / 数字字符串归一到三类之一。 */
export function statusToAppError(status: number | string): AppError {
const n = typeof status === "string" ? Number.parseInt(status, 10) : status;
if (n === 403) return createAppError(403);
if (n === 404) return createAppError(404);
return createAppError(500);
}
/** i18n key 前缀:`error.${kind}` → title/desc/label。 */
export function errorI18nPrefix(error: AppError): `error.${AppErrorKind}` {
return `error.${error.kind}`;
}

63
app/web/src/lib/seo.ts Normal file
View File

@ -0,0 +1,63 @@
/** 站点 SEO 按 locale 构造(唯一配置源仍在此,喂给 @app/seo-geo)。 */
import type { PageSeoInput, SiteSeoConfig } from "@app/seo-geo";
import { type Locale, t } from "@app/i18n";
const rawSiteUrl = import.meta.env.PUBLIC_SITE_URL?.replace(/\/+$/, "");
const siteUrl = rawSiteUrl && rawSiteUrl.length > 0 ? rawSiteUrl : "http://localhost:4321";
/** 无 locale 的公开路径(basePath);受保护页面不进 llms/sitemap 列表。 */
const PUBLIC_PATHS = ["/"] as const;
function pathForLocale(locale: Locale, basePath: string): string {
if (basePath === "/") return locale === "zh-CN" ? "/zh-CN" : "/en";
return locale === "zh-CN" ? `/zh-CN${basePath}` : `/en${basePath}`;
}
export function getSiteSeo(locale: Locale): SiteSeoConfig {
const name = t(locale, "meta.brand");
const isZh = locale === "zh-CN";
const description = isZh
? "Astro + tRPC + Drizzle monorepo 应用脚手架,开箱即用的分层基线。"
: "Astro + tRPC + Drizzle monorepo application scaffold with a ready vertical baseline.";
return {
siteUrl,
name,
description,
locale: isZh ? "zh-CN" : "en",
defaultImage: "/og-default.png",
organization: { name, url: siteUrl },
llms: {
summary: isZh
? "这是可复用的应用脚手架模板:Astro SSR + React islands + tRPC + Drizzle/Turso,附带鉴权与用户垂直示例。"
: "Reusable application scaffold: Astro SSR + React islands + tRPC + Drizzle/Turso, with auth and a user vertical example.",
details: [
isZh
? "分层:types → models → db → dao → trpc → web。业务实体按 user 切片扩展,禁止跨层。"
: "Layers: types → models → db → dao → trpc → web. Extend domains via the user slice; never skip layers.",
isZh
? "不绑定具体产品业务;works 等领域逻辑应在业务仓库中添加。"
: "No product domain baked in; add domains such as works in your product repo.",
],
pages: PUBLIC_PATHS.map((basePath) => ({
path: pathForLocale(locale, basePath),
title: basePath === "/" ? `${name}${isZh ? "首页" : " home"}` : name,
description,
})),
discouraged: isZh
? ["/dashboard、/settings 等需登录页面", "/auth/* 等认证页", "/api/* 接口响应"]
: [
"/dashboard, /settings and other app pages",
"/auth/* and auth pages",
"/api/* responses",
],
},
};
}
/** 兼容旧引用:默认中文。 */
export const siteSeo = getSiteSeo("zh-CN");
export function pageSeo(input: PageSeoInput): PageSeoInput {
return input;
}

45
app/web/src/lib/theme.ts Normal file
View File

@ -0,0 +1,45 @@
export type ThemeMode = "system" | "light" | "dark";
const KEY = "app-theme";
export function readThemeMode(): ThemeMode {
try {
const v = localStorage.getItem(KEY);
if (v === "light" || v === "dark" || v === "system") return v;
} catch {
/* noop */
}
return "system";
}
export function writeThemeMode(mode: ThemeMode) {
try {
localStorage.setItem(KEY, mode);
} catch {
/* noop */
}
}
export function systemPrefersDark(): boolean {
return window.matchMedia("(prefers-color-scheme: dark)").matches;
}
export function resolveTheme(mode: ThemeMode): "light" | "dark" {
if (mode === "system") return systemPrefersDark() ? "dark" : "light";
return mode;
}
export function applyTheme(mode: ThemeMode): "light" | "dark" {
const resolved = resolveTheme(mode);
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.classList.toggle("light", resolved === "light");
root.style.colorScheme = resolved;
return resolved;
}
export function watchSystemTheme(onChange: () => void): () => void {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}

21
app/web/src/lib/trpc.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* 前端 tRPC 客户端:React Query 绑定 + batch HTTP 链到 `/api/trpc`。
* 类型来自 `@app/trpc` 的 AppRouter,改 procedure 后前端自动跟上。
*/
import { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@app/trpc";
export const trpc = createTRPCReact<AppRouter>();
export function createTRPCReactClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
// 便于服务端日志区分 island 流量
headers: () => ({ "x-trpc-source": "react-island" }),
}),
],
});
}

1
app/web/src/lib/utils.ts Normal file
View File

@ -0,0 +1 @@
export { cn } from "@app/ui";

84
app/web/src/middleware.ts Normal file
View File

@ -0,0 +1,84 @@
import { defineMiddleware } from "astro:middleware";
import { COOKIE_LANG, localePath, resolvePreferredLocale } from "@app/i18n";
import { COOKIE_SESSION } from "@app/types";
import { authService } from "@app/trpc";
const SKIP_PREFIX = ["/api", "/_astro", "/favicon", "/robots", "/sitemap", "/llms"];
/** 需登录的基础路径(无 locale 前缀)。按需增删。 */
const PROTECTED_BASES = ["/dashboard", "/settings"] as const;
const LOCALE_SEGMENTS = new Set(["zh-CN", "en"]);
function shouldSkip(pathname: string): boolean {
if (pathname !== "/" && pathname.includes(".")) return true;
return SKIP_PREFIX.some((p) => pathname.startsWith(p));
}
function basePathOf(pathname: string): string {
const segs = pathname.split("/").filter(Boolean);
const head = segs[0];
if (head && LOCALE_SEGMENTS.has(head)) {
return `/${segs.slice(1).join("/")}`;
}
// 规范化前的 zh-cn 等在后续 redirect 处理,这里先按原样判断
if (head && head.toLowerCase() === "zh-cn") {
return `/${segs.slice(1).join("/")}`;
}
return `/${segs.join("/")}`;
}
function isProtectedPath(pathname: string): boolean {
const base = basePathOf(pathname);
const normalized = base === "/" ? "/" : base.replace(/\/$/, "");
return PROTECTED_BASES.some((p) => normalized === p || normalized.startsWith(`${p}/`));
}
function localeOf(pathname: string): "zh-CN" | "en" {
const head = pathname.split("/").filter(Boolean)[0];
if (head === "en") return "en";
return "zh-CN";
}
export const onRequest = defineMiddleware(async (context, next) => {
const { request, url, cookies } = context;
const path = url.pathname;
if (shouldSkip(path)) {
return next();
}
// 裸 `/`:cookie → Accept-Language → /zh-CN 或 /en
if (path === "/") {
if (request.method !== "GET") return next();
const preferred = resolvePreferredLocale(
cookies.get(COOKIE_LANG)?.value,
request.headers.get("accept-language"),
);
return context.redirect(localePath(preferred, "/"));
}
// 小写 zh-cn → 规范 zh-CN(保留 rest)
const seg = path.split("/").filter(Boolean)[0];
if (seg && seg.toLowerCase() === "zh-cn" && seg !== "zh-CN") {
const rest = path.slice(seg.length) || "/";
return context.redirect(`/zh-CN${rest === "/" ? "" : rest}`);
}
// 指定路由鉴权:无有效 session 则跳登录
if (request.method === "GET" && isProtectedPath(path)) {
const token = cookies.get(COOKIE_SESSION)?.value;
const user = await authService.getSessionUser(token);
if (!user) {
const locale = localeOf(path);
const nextParam = encodeURIComponent(path + url.search);
return context.redirect(`${localePath(locale, "/auth/login")}?next=${nextParam}`);
}
}
const response = await next();
if (request.method === "GET") {
response.headers.set("Vary", "Accept-Language");
}
return response;
});

View File

@ -0,0 +1,13 @@
---
import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n";
import ErrorViewPage from "~/views/error.astro";
/** 显式 403 页;middleware / 业务可用 rewrite 或 redirect 到此。 */
const locale = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
Astro.response.status = 403;
---
<ErrorViewPage locale={locale} status={403} />

View File

@ -0,0 +1,13 @@
---
import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n";
import ErrorViewPage from "~/views/error.astro";
// Astro 全局 404:按 cookie / Accept-Language 选 locale(无 [locale] 段)
const locale = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
Astro.response.status = 404;
---
<ErrorViewPage locale={locale} status={404} />

View File

@ -0,0 +1,12 @@
---
import { COOKIE_LANG, resolvePreferredLocale } from "@app/i18n";
import ErrorViewPage from "~/views/error.astro";
const locale = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
Astro.response.status = 500;
---
<ErrorViewPage locale={locale} status={500} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/auth/forgot-password"));
}
const locale: Locale = normalized;
---
<AuthView locale={locale} mode="forgot" />

View File

@ -0,0 +1,20 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/auth/login"));
}
const locale: Locale = normalized;
const next = Astro.url.searchParams.get("next");
---
<AuthView locale={locale} mode="login" next={next ?? undefined} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/auth/register"));
}
const locale: Locale = normalized;
---
<AuthView locale={locale} mode="register" />

View File

@ -0,0 +1,20 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/auth/reset-password"));
}
const locale: Locale = normalized;
const token = Astro.url.searchParams.get("token") ?? undefined;
---
<AuthView locale={locale} mode="reset" token={token} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Dashboard from "~/views/dashboard.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/dashboard"));
}
const locale: Locale = normalized;
---
<Dashboard locale={locale} />

View File

@ -0,0 +1,20 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import Home from "~/views/home.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
// 非法/缺失前缀:按 cookie + Accept-Language 自动匹配语言
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/"));
}
const locale: Locale = normalized;
---
<Home locale={locale} />

View File

@ -0,0 +1,19 @@
---
import { COOKIE_LANG, isLocale, localePath, resolvePreferredLocale, type Locale } from "@app/i18n";
import AuthView from "~/views/auth.astro";
const raw = Astro.params["locale"];
const normalized = raw === "zh-cn" ? "zh-CN" : raw;
if (!normalized || !isLocale(normalized)) {
const preferred = resolvePreferredLocale(
Astro.cookies.get(COOKIE_LANG)?.value,
Astro.request.headers.get("accept-language"),
);
return Astro.redirect(localePath(preferred, "/settings"));
}
const locale: Locale = normalized;
---
<AuthView locale={locale} mode="change" />

View File

@ -0,0 +1,51 @@
/**
* Astro 内嵌 tRPC HTTP 适配:React islands 与外部客户端统一走 `/api/trpc/*`。
* 与 `@app/trpc` 的 `server.ts` 共用 appRouter,二选一部署即可。
* 从 cookie 解析 session,并把 procedure 写入的 Set-Cookie 合并进响应。
*/
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { APIRoute } from "astro";
import { appRouter } from "@app/trpc";
import { COOKIE_SESSION } from "@app/types";
export const prerender = false;
function parseCookies(header: string | null): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const eq = part.indexOf("=");
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = part.slice(eq + 1).trim();
if (key) out[key] = decodeURIComponent(val);
}
return out;
}
export const ALL: APIRoute = async (ctx) => {
const setCookies: string[] = [];
const cookies = parseCookies(ctx.request.headers.get("cookie"));
const response = await fetchRequestHandler({
endpoint: "/api/trpc",
req: ctx.request,
router: appRouter,
createContext: () => ({
sessionToken: cookies[COOKIE_SESSION],
setCookie: (value: string) => {
setCookies.push(value);
},
}),
});
if (setCookies.length === 0) return response;
const headers = new Headers(response.headers);
for (const c of setCookies) headers.append("Set-Cookie", c);
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
};

View File

@ -0,0 +1,22 @@
/** 演示用 REST 转发:同进程内改写为 tRPC user.list,勿在此堆业务。 */
import type { APIRoute } from "astro";
export const prerender = false;
export const GET: APIRoute = async ({ request }) => {
// 内部转发到 tRPC handler(同进程内,避免跨网络)
const url = new URL(request.url);
url.pathname = "/api/trpc/user.list";
const req = new Request(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ json: { limit: 20, offset: 0 } }),
});
// 调用同一个 Astro 进程的 tRPC endpoint
const res = await fetch(req);
const data = await res.json();
return new Response(JSON.stringify(data), {
headers: { "Content-Type": "application/json" },
});
};

View File

@ -0,0 +1,63 @@
/** GEO 端点:llms-full.txt(中文默认 + 英文节选)。 */
import type { APIRoute } from "astro";
import { buildLlmsFullTxt, buildLlmsTxt } from "@app/seo-geo";
import { type Locale, t } from "@app/i18n";
import { getSiteSeo } from "~/lib/seo";
export const prerender = false;
function buildFor(locale: Locale): string {
const siteSeo = getSiteSeo(locale);
const isZh = locale === "zh-CN";
if (!isZh) {
return buildLlmsTxt(siteSeo);
}
return buildLlmsFullTxt(siteSeo, {
title: `${t(locale, "meta.brand")} · scaffold index`,
sections: [
{
heading: t(locale, "home.stackTitle"),
path: "/zh-CN",
summary: t(locale, "home.stackBody"),
points: [
"Astro 7 SSR + React 19 islands",
"tRPC v11 + Drizzle ORM + Turso/libSQL",
"Tailwind CSS 4 + Biome + Yarn 4 workspaces + Turbo",
],
},
{
heading: t(locale, "home.layerTitle"),
path: "/zh-CN",
summary: t(locale, "home.layerBody"),
points: [
"types → models → db → dao → trpc → web",
"user / auth 为垂直示例,可替换为业务实体",
],
},
],
faqs: [
{
question: "这个模板包含业务逻辑吗?",
answer: "不包含产品业务。仅保留鉴权与 user 垂直示例,用于演示分层接线;具体业务应在下游仓库添加。",
},
{
question: "如何新增业务实体?",
answer: "按 user 切片扩展:@app/types 加 schema → @app/models 建表 → @app/dao 加查询 → @app/trpc 加 service/router → @app/web 挂页面。",
},
],
});
}
export const GET: APIRoute = ({ url }) => {
const locale: Locale = url.pathname.startsWith("/en") ? "en" : "zh-CN";
const body = buildFor(locale);
return new Response(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
Vary: "Accept-Language",
},
});
};

View File

@ -0,0 +1,19 @@
/** GEO 端点:llms.txt。`?lang=en` 或路径无 zh 时输出英文(端点本身无 locale 前缀)。 */
import type { APIRoute } from "astro";
import { buildLlmsTxt } from "@app/seo-geo";
import { getSiteSeo } from "~/lib/seo";
export const prerender = false;
export const GET: APIRoute = ({ url }) => {
const lang = url.searchParams.get("lang");
const locale = lang === "en" ? "en" : "zh-CN";
const body = buildLlmsTxt(getSiteSeo(locale));
return new Response(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
Vary: "Accept-Language",
},
});
};

View File

@ -0,0 +1,16 @@
/** SEO 端点:输出 robots.txt(默认允许抓取 + 指向 sitemap)。 */
import type { APIRoute } from "astro";
import { buildDefaultRobotsInput, buildRobotsTxt } from "@app/seo-geo";
import { siteSeo } from "~/lib/seo";
export const prerender = false;
export const GET: APIRoute = () => {
const body = buildRobotsTxt(buildDefaultRobotsInput(siteSeo));
return new Response(body, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};

View File

@ -0,0 +1,47 @@
/** SEO 站点地图:双语 path + hreflang alternates。 */
import type { APIRoute } from "astro";
import { buildSitemapXml, type SitemapEntry } from "@app/seo-geo";
import { alternatePath, LOCALES } from "@app/i18n";
import { siteSeo } from "~/lib/seo";
export const prerender = false;
function entryFor(
basePath: string,
opts: { changefreq: SitemapEntry["changefreq"]; priority: number },
): SitemapEntry {
const alternates: NonNullable<SitemapEntry["alternates"]> = [
...LOCALES.map((locale) => ({
href: alternatePath(basePath, locale),
hreflang: locale as string,
})),
{ href: alternatePath(basePath, "zh-CN"), hreflang: "x-default" },
];
return {
url: alternatePath(basePath, "zh-CN"),
changefreq: opts.changefreq,
priority: opts.priority,
alternates,
};
}
export const GET: APIRoute = async () => {
const entries: SitemapEntry[] = [
entryFor("/", { changefreq: "weekly", priority: 1.0 }),
{
url: "/en",
changefreq: "weekly",
priority: 1.0,
alternates: entryFor("/", { changefreq: "weekly", priority: 1.0 }).alternates,
},
];
const xml = await buildSitemapXml(siteSeo, entries);
return new Response(xml, {
headers: {
"Content-Type": "application/xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
Vary: "Accept-Language",
},
});
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,41 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import AuthForm from "~/components/AuthForm";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
mode: "login" | "register" | "forgot" | "reset" | "change";
next?: string | undefined;
token?: string | undefined;
}
const { locale, mode, next, token } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const titleKey =
mode === "login"
? "auth.login.title"
: mode === "register"
? "auth.register.title"
: mode === "forgot"
? "auth.forgot.title"
: mode === "reset"
? "auth.reset.title"
: "auth.settings.title";
---
<AppLayout
title={`${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{ title: `${t(locale, titleKey as never)} · ${t(locale, "meta.brand")}`, path: Astro.url.pathname, robots: ["noindex", "nofollow"] }}
>
<div class="flex min-h-[60vh] items-center justify-center px-4 py-16">
<AuthForm client:load locale={locale} mode={mode} next={next} defaultToken={token} />
</div>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,41 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { Toaster } from "@app/ui";
import UserList from "~/components/UserList";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
---
<AppLayout
title={t(locale, "dashboard.title")}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${t(locale, "dashboard.title")} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<div class="space-y-10 py-8">
<section class="u-reveal">
<p class="u-label u-label-edge">{t(locale, "dashboard.label")}</p>
<h2 class="mt-2 text-[32px] font-black tracking-tight">{t(locale, "dashboard.heading")}</h2>
<span class="u-edge-bar mt-4"></span>
<p class="mt-4 max-w-2xl text-[14px] leading-relaxed" style="color: var(--ink-2);">
{t(locale, "dashboard.desc")}
</p>
<div class="u-card mt-8 p-6">
<UserList client:visible />
</div>
</section>
</div>
<Toaster client:load />
</AppLayout>

View File

@ -0,0 +1,35 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import { ErrorView } from "~/components/errors/ErrorView";
import { createAppError, type AppErrorStatus } from "~/lib/errors";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
status: AppErrorStatus;
}
const { locale, status } = Astro.props;
const other: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const error = createAppError(status);
const homeHref = locale === "zh-CN" ? "/zh-CN" : "/en";
const titleKey = `error.${error.kind}.title` as never;
const pageTitle = t(locale, titleKey);
---
<AppLayout
title={`${pageTitle} · ${t(locale, "meta.brand")}`}
locale={locale}
nav={[]}
switchHref={alternatePath(Astro.url.pathname, other)}
switchLabel={t(other, `locale.${other}` as never)}
seo={{
title: `${pageTitle} · ${t(locale, "meta.brand")}`,
path: Astro.url.pathname,
robots: ["noindex", "nofollow"],
}}
>
<div class="flex min-h-[55vh] items-center justify-center px-4 py-16">
<ErrorView error={error} locale={locale} homeHref={homeHref} />
</div>
</AppLayout>

View File

@ -0,0 +1,55 @@
---
import { alternatePath, type Locale, t } from "@app/i18n";
import AppLayout from "~/layouts/AppLayout.astro";
export interface Props {
locale: Locale;
}
const { locale } = Astro.props;
const tr = (key: Parameters<typeof t>[1]) => t(locale, key);
const otherLocale: Locale = locale === "zh-CN" ? "en" : "zh-CN";
const switchHref = alternatePath(Astro.url.pathname, otherLocale);
const dashHref = locale === "zh-CN" ? "/zh-CN/dashboard" : "/en/dashboard";
const loginHref = locale === "zh-CN" ? "/zh-CN/auth/login" : "/en/auth/login";
---
<AppLayout
title={tr("home.title")}
locale={locale}
nav={[]}
switchHref={switchHref}
switchLabel={t(otherLocale, `locale.${otherLocale}` as never)}
>
<section class="space-y-10 py-10">
<div class="u-reveal">
<p class="u-label u-label-edge">{tr("meta.tagline")}</p>
<h1 class="mt-3 text-[40px] font-black tracking-tight leading-[1.05]">
{tr("home.heroLine1")}<br />{tr("home.heroLine2")}
</h1>
<span class="u-edge-bar mt-5"></span>
<p class="mt-5 max-w-xl text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.subtitle")}
</p>
<div class="mt-8 flex flex-wrap gap-3">
<a href={dashHref} class="u-btn u-btn--edge">{tr("home.ctaDashboard")}</a>
<a href={loginHref} class="u-btn u-btn--ghost">{tr("home.ctaLogin")}</a>
</div>
</div>
<div class="grid gap-6 md:grid-cols-2">
<section class="u-card p-6 u-reveal">
<p class="u-label">{tr("home.stackTitle")}</p>
<p class="mt-3 text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.stackBody")}
</p>
</section>
<section class="u-card p-6 u-reveal">
<p class="u-label">{tr("home.layerTitle")}</p>
<p class="mt-3 text-[14px] leading-relaxed" style="color: var(--ink-2);">
{tr("home.layerBody")}
</p>
</section>
</div>
</section>
</AppLayout>

20
app/web/tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"composite": false,
"jsx": "react-jsx",
"jsxImportSource": "react",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {
"@app/i18n": ["../../packages/i18n/src/index.ts"],
"@app/trpc": ["../../packages/trpc/src/index.ts"],
"@app/types": ["../../packages/types/src/index.ts"],
"@app/db": ["../../packages/db/src/index.ts"],
"@app/ui": ["../../packages/ui/src/index.ts"],
"@/*": ["./src/*"],
"~/*": ["./src/*"]
}
},
"include": ["src", ".astro/types.d.ts"]
}

44
biome.json Normal file
View File

@ -0,0 +1,44 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.14/schema.json",
"assist": {
"actions": {
"source": {
"organizeImports": "off"
}
}
},
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"complexity": {
"useLiteralKeys": "off"
},
"correctness": {
"noUnusedImports": "warn",
"noUnusedVariables": "warn"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 4,
"lineWidth": 100
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"includes": [
"**",
"!**/storybook-static",
"!**/dist",
"!**/.turbo",
"!**/node_modules",
"!**/*.astro"
]
}
}

26
commitlint.config.mjs Normal file
View File

@ -0,0 +1,26 @@
/** @type {import('@commitlint/types').UserConfig} */
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [
2,
"always",
[
"feat",
"fix",
"docs",
"style",
"refactor",
"perf",
"test",
"build",
"ci",
"chore",
"revert",
],
],
"subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]],
"header-max-length": [2, "always", 100],
"body-max-line-length": [2, "always", 120],
},
};

View File

@ -0,0 +1,92 @@
# i18n 设计 — 方案 A(zh-CN + en · 路径前缀)
日期:2026-09-24
状态:已批准(用户选 A;追加 `/` 系统语言检测跳转)
## 目标
- 中文:`/zh-CN`、`/zh-CN/dashboard`(**中文也带前缀,跳 `/zh-CN`,不留在裸 `/`)**
- 英文:`/en`、`/en/dashboard`
- 访问 **`/`** 时:有 `app-lang` cookie 按 cookie 跳 `/zh-CN` 或 `/en`;否则按 `Accept-Language` → 中文 `/zh-CN`、英文 `/en`
- 用户手动切换语言写入 cookie,优先于系统语言
- 文案集中在 `@app/i18n`;Astro / React 共用同一套静态 messages(无 i18next、无 Context 强绑)
## 非目标
- 不改 tRPC / DAO / DB
- 不引入 i18next / astro-i18next
- 第一阶段不做复数 ICU、后端按 locale 存文案
## 包结构
```
packages/i18n/
package.json # @app/dao 风格:纯 TS,无运行时重依赖
src/
locales.ts # LOCALES、DEFAULT_LOCALE、Locale 类型
cookie.ts # COOKIE_LANG = "app-lang"
accept-language.ts # parsePreferredLocale(header)
messages/
zh-CN.ts # 扁平命名空间对象(权威 key 源)
en.ts # 必须 keyof zh-CN
t.ts # t(locale, key, params?) 简单 {name} 插值
index.ts
AGENTS.md
```
- `type MessageKey = keyof typeof zhCN`
- `t` 缺 key 编译期失败(en 与 zh 同构)
- 无 IO、无 env;与 seo-geo 同为纯函数包
## 路由与 Middleware(@app/web)
```
astro.config.ts
i18n: {
defaultLocale: "zh-CN",
locales: ["zh-CN", "en"],
routing: { prefixDefaultLocale: true, redirectToDefaultLocale: false }
}
src/middleware.ts
GET 且 path === "/"
cookie app-lang=en → redirect /en
cookie app-lang=zh-CN → redirect /zh-CN
无 cookie:
Accept-Language 偏好 en → /en
否则(含 zh) → /zh-CN
响应 Vary: Accept-Language
src/pages/
[locale]/index.astro | dashboard.astro # 单套动态前缀(locale 校验)
views/home.astro | dashboard.astro # 双语共用
```
不再使用 `pages/zh-CN` / `pages/en` 双目录;也不依赖 Astro 文件式 i18n 双份路由。
语言切换:链接到对侧 URL + `document.cookie = app-lang=...; path=/; max-age=...`
(island `LocaleSwitch` 或 header 内联均可)
## 文案分层
| 层 | 做法 |
|----|------|
| Astro 布局/页面 | `t(locale, "nav.home")` |
| React island | props 传入 `locale`,组件内 `t(locale, ...)` 或传入已解析文案 |
| tools 目录 | `category` 改为稳定 id(`text\|code\|dev\|net\|image`),显示名走 i18n |
| SEO | `siteSeo` 按 locale 构造;sitemap 每 URL 填 `alternates`(`zh-CN` ↔ `en`) |
## SEO
- `<html lang>`:`zh-CN` / `en`
- hreflang:`x-default` → `/...`;`zh-CN` → `/...`;`en` → `/en/...`
- `lib/seo.ts` 提供 `getSiteSeo(locale)`,避免写死单 locale
- llms.txt / sitemap 至少覆盖双语文档首页(工具页路径可后补 en 副本)
## 验收
1. `Accept-Language: en` 访问 `/` → 302 `/en`
2. `Accept-Language: zh-CN` 访问 `/` → 200 中文
3. cookie `app-lang=en` 访问 `/` → 302 `/en`(覆盖系统语言)
4. `/en` 与 `/` 文案、`lang`、导航一致切换
5. `yarn typecheck` / `yarn build` 通过

9
lefthook.yml Normal file
View File

@ -0,0 +1,9 @@
commit-msg:
jobs:
- name: commitlint
run: yarn exec commitlint --edit {1}
pre-push:
jobs:
- name: verify
run: yarn turbo run typecheck lint --filter='./packages/*'

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "app-template",
"private": true,
"workspaces": [
"packages/*",
"app/*"
],
"scripts": {
"prepare": "lefthook install",
"dev": "turbo run dev",
"dev:web": "turbo run dev --filter=@app/web",
"dev:api": "turbo run dev --filter=@app/trpc",
"storybook": "yarn workspace @app/ui storybook",
"build": "turbo run build",
"build:web": "turbo run build --filter=@app/web...",
"db:generate": "turbo run db:generate",
"db:push": "turbo run db:push",
"db:studio": "turbo run db:studio",
"typecheck": "turbo run typecheck",
"lint": "turbo run lint",
"clean": "turbo run clean",
"format": "turbo run format",
"_dev": "yarn workspace @app/web dev",
"_dev:api": "yarn workspace @app/trpc dev",
"_build": "yarn workspace @app/trpc build && yarn workspace @app/web build",
"_typecheck": "yarn workspace @app/types run typecheck && yarn workspace @app/db run typecheck && yarn workspace @app/trpc run typecheck && yarn workspace @app/web run typecheck",
"_lint": "yarn workspace @app/types run lint && yarn workspace @app/db run lint && yarn workspace @app/trpc run lint && yarn workspace @app/web run lint"
},
"packageManager": "yarn@4.17.1",
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@biomejs/biome": "^2.1.1",
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@emnapi/runtime": "^1.11.3",
"@storybook/addon-a11y": "^9.1.10",
"@storybook/addon-docs": "^9.1.10",
"@storybook/react": "^9.1.10",
"@storybook/react-vite": "^9.1.10",
"@typescript/typescript6": "^6.0.2",
"lefthook": "^2.1.14",
"storybook": "^9.1.10",
"turbo": "^2.11.2",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,42 @@
# AGENTS.md — @app/adsense
## 职责
Google AdSense 接入:**幂等脚本加载** + React 广告位组件。与业务/框架解耦,可被 Astro island 或纯 React 使用。
## 结构
```
src/
loader.ts # loadAdSense / pushAdSense
types.ts # AdSensePushCommand + Window.adsbygoogle
react/AdSlot.tsx # <ins class="adsbygoogle"> 组件
index.ts # 核心导出
react/index.ts # React 入口(exports: "@app/adsense/react")
```
## 导出
- `@app/adsense`:`loadAdSense`、`pushAdSense`、类型
- `@app/adsense/react`:`AdSlot`
## 行为要点
- `loadAdSense(adClient)`:仅浏览器生效;SSR no-op。同一 `adClient` 复用 Promise;页面已有脚本会识别复用;**不支持同时加载多个发布商 ID**。
- `pushAdSense()`:向 `window.adsbygoogle` 队列 push,渲染已挂载的 `<ins>`。
- `AdSlot` 属性用 `adSlot` / `adClient`(刻意避开 Astro 的 `slot` / `client:*` 保留字)。挂载后自动 load + push,按 `adClient#adSlot` 去重。
## 修改指南
- 保持加载幂等;失败时重置 `scriptPromise` 以便重试(见 `loader.ts`)。
- 不要在本包读 env 或写死 publisher ID——由调用方传入 `adClient`。
- SSR 安全:凡触碰 `document` / `window` 先判 `typeof`。
- 新广告格式优先扩展 `AdSlot` props(`format` / `layout` / `layoutKey`…),不要让调用方手写 `<ins>`。
## 命令
```bash
yarn workspace @app/adsense typecheck
yarn workspace @app/adsense lint
yarn workspace @app/adsense build
```

View File

@ -0,0 +1,34 @@
{
"name": "@app/adsense",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./react": {
"types": "./dist/react/index.d.ts",
"import": "./dist/react/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "^19.1.4",
"react": "^19.1.0",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,3 @@
/** `@app/adsense` 核心入口:脚本加载 + 队列命令(React 组件走 `@app/adsense/react`)。 */
export { loadAdSense, pushAdSense } from "./loader.js";
export type { AdSensePushCommand } from "./types.js";

View File

@ -0,0 +1,70 @@
import type { AdSensePushCommand } from "./types.js";
const SCRIPT_MARKER = "adsbygoogle.js";
let scriptPromise: Promise<void> | null = null;
let scriptClient: string | null = null;
function findExistingScript(adClient: string): HTMLScriptElement | null {
for (const el of Array.from(document.scripts)) {
if (el.src.includes(SCRIPT_MARKER) && el.src.includes(adClient)) {
return el;
}
}
return null;
}
/**
* 加载 Google AdSense 官方脚本(幂等)。
* - 仅浏览器环境生效,SSR 中为 no-op
* - 同一 adClient 复用同一 Promise;已在页面中的脚本会被识别并跳过
*/
export function loadAdSense(adClient: string): Promise<void> {
if (typeof document === "undefined") {
return Promise.resolve();
}
if (scriptPromise) {
if (scriptClient === adClient) {
return scriptPromise;
}
return Promise.reject(
new Error(
`[adsense] 脚本已按 client=${scriptClient} 加载,不支持同时加载多个发布商 ID`,
),
);
}
const existing = findExistingScript(adClient);
if (existing) {
scriptClient = adClient;
scriptPromise = Promise.resolve();
return scriptPromise;
}
scriptClient = adClient;
scriptPromise = new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.async = true;
script.crossOrigin = "anonymous";
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=${encodeURIComponent(adClient)}`;
script.onload = () => resolve();
script.onerror = () => {
// 失败必须重置状态,否则后续调用会一直复用 rejected Promise 无法重试
scriptPromise = null;
scriptClient = null;
script.remove();
reject(new Error("[adsense] AdSense 脚本加载失败"));
};
document.head.appendChild(script);
});
return scriptPromise;
}
/** 向队列 push 一条命令,触发对已挂载 <ins class="adsbygoogle"> 的渲染。 */
export function pushAdSense(command: AdSensePushCommand = {}): void {
if (typeof window === "undefined") {
return;
}
window.adsbygoogle ??= [];
window.adsbygoogle.push(command);
}

View File

@ -0,0 +1,76 @@
import { useEffect, useRef, type CSSProperties } from "react";
import { loadAdSense, pushAdSense } from "../loader.js";
export interface AdSlotProps {
/** 广告单元 ID(data-ad-slot) */
adSlot: string;
/** 发布商 ID(data-ad-client,形如 ca-pub-xxxxxxxx) */
adClient: string;
/** data-ad-format,默认 auto */
format?: string | undefined;
/** data-ad-layout */
layout?: string | undefined;
/** data-ad-layout-key */
layoutKey?: string | undefined;
/** data-full-width-responsive,未传时不输出该属性 */
fullWidthResponsive?: boolean | undefined;
className?: string | undefined;
style?: CSSProperties | undefined;
}
/**
* AdSense 广告位组件:渲染 <ins class="adsbygoogle">,
* 挂载后按需加载官方脚本并 push 渲染命令。
*
* 注意:属性名刻意避开 Astro 的 `slot` / `client:*` 保留字,
* 在 Astro island 中可直接透传。
*/
export function AdSlot({
adSlot,
adClient,
format = "auto",
layout,
layoutKey,
fullWidthResponsive,
className,
style,
}: AdSlotProps) {
const pushedKeyRef = useRef<string | null>(null);
// 按 adClient#adSlot 去重 push;StrictMode 双调用与依赖重跑都安全
useEffect(() => {
let cancelled = false;
const key = `${adClient}#${adSlot}`;
loadAdSense(adClient)
.then(() => {
if (cancelled || pushedKeyRef.current === key) {
return;
}
pushedKeyRef.current = key;
pushAdSense();
})
.catch((err: unknown) => {
console.error("[adsense]", err);
});
return () => {
cancelled = true;
};
}, [adClient, adSlot]);
return (
<ins
className={className ? `adsbygoogle ${className}` : "adsbygoogle"}
style={{ display: "block", ...style }}
data-ad-client={adClient}
data-ad-slot={adSlot}
data-ad-format={format}
data-ad-layout={layout}
data-ad-layout-key={layoutKey}
data-full-width-responsive={
fullWidthResponsive === undefined ? undefined : String(fullWidthResponsive)
}
/>
);
}

View File

@ -0,0 +1,2 @@
/** `@app/adsense/react` 入口:给 Astro island / 纯 React 用的广告位组件。 */
export { AdSlot, type AdSlotProps } from "./AdSlot.js";

View File

@ -0,0 +1,11 @@
/** 向 AdSense 队列 push 的命令参数(一般空对象即可,由 <ins> 上的 data-* 决定广告位)。 */
export interface AdSensePushCommand {
[key: string]: unknown;
}
declare global {
interface Window {
/** AdSense 命令队列;官方脚本接管前可能不存在。 */
adsbygoogle?: unknown[];
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src"]
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"jsx": "react-jsx",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"paths": {}
},
"include": ["src"]
}

39
packages/dao/AGENTS.md Normal file
View File

@ -0,0 +1,39 @@
# AGENTS.md — @app/dao
## 职责
**数据访问层(DAO)**:封装 Drizzle 查询(`userDao` / `sessionDao` / `passwordResetDao`)。service 只调本包,不直接 `db.select`。不含业务规则、HTTP/tRPC。
## 结构
```
src/
index.ts # 出口:daos + 行类型
users.ts # userDao
sessions.ts # sessionDao
password-resets.ts # passwordResetDao
```
## 依赖
- `@app/db`:`db` 单例(连接)
- `@app/models`:表定义与 `$infer*` 行类型
## 约定
- 一表一个 dao 对象;方法名 `list` / `getById` / `create` / `update` / `delete`。
- `exactOptionalPropertyTypes`:update 入参用 `Partial<Omit<Row, "id" | "createdAt" | "updatedAt">>`。
- `noUncheckedIndexedAccess`:`returning()[0]` 判空后抛错或 `?? null`。
- 不读 env、不建 client;环境变量校验在 `@app/db`。
## 修改指南
- 加查询:新建或扩展对应 `*.ts`,在 `index.ts` 导出。
- 新表:先改 `@app/models` → `yarn db:generate`/`db:push` → 本包加 dao。
## 命令
```bash
yarn workspace @app/dao typecheck
yarn workspace @app/dao build
```

31
packages/dao/package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "@app/dao",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
},
"dependencies": {
"@app/db": "workspace:*",
"@app/models": "workspace:*",
"drizzle-orm": "^0.45.3"
},
"devDependencies": {
"@types/node": "^22.15.29",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,7 @@
/** `@app/dao`:数据访问层出口。SQL 只写在本包,service 从这里引入。 */
export { userDao } from "./users.js";
export type { UserRow, ListUsersParams } from "./users.js";
export { sessionDao } from "./sessions.js";
export type { SessionRow } from "./sessions.js";
export { passwordResetDao } from "./password-resets.js";
export type { PasswordResetRow } from "./password-resets.js";

View File

@ -0,0 +1,38 @@
import { eq } from "drizzle-orm";
import { passwordResets } from "@app/models";
import { db } from "@app/db";
export type PasswordResetRow = typeof passwordResets.$inferSelect;
export const passwordResetDao = {
async getByToken(token: string): Promise<PasswordResetRow | null> {
const [row] = await db
.select()
.from(passwordResets)
.where(eq(passwordResets.token, token))
.limit(1);
return row ?? null;
},
async create(data: {
userId: string;
token: string;
expiresAt: Date;
}): Promise<PasswordResetRow> {
const rows = await db.insert(passwordResets).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to create password reset");
return row;
},
async markUsed(id: string): Promise<void> {
await db
.update(passwordResets)
.set({ usedAt: new Date() })
.where(eq(passwordResets.id, id));
},
async deleteByUserId(userId: string): Promise<void> {
await db.delete(passwordResets).where(eq(passwordResets.userId, userId));
},
};

View File

@ -0,0 +1,39 @@
import { desc, eq, lt } from "drizzle-orm";
import { sessions } from "@app/models";
import { db } from "@app/db";
export type SessionRow = typeof sessions.$inferSelect;
export const sessionDao = {
async getByToken(token: string): Promise<SessionRow | null> {
const [row] = await db.select().from(sessions).where(eq(sessions.token, token)).limit(1);
return row ?? null;
},
async create(data: { userId: string; token: string; expiresAt: Date }): Promise<SessionRow> {
const rows = await db.insert(sessions).values(data).returning();
const row = rows[0];
if (!row) throw new Error("Failed to create session");
return row;
},
async listByUserId(userId: string): Promise<SessionRow[]> {
return db
.select()
.from(sessions)
.where(eq(sessions.userId, userId))
.orderBy(desc(sessions.createdAt));
},
async deleteByToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.token, token));
},
async deleteByUserId(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
},
async deleteExpired(now: Date): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, now));
},
};

65
packages/dao/src/users.ts Normal file
View File

@ -0,0 +1,65 @@
import { desc, eq, sql } from "drizzle-orm";
import { users } from "@app/models";
import { db } from "@app/db";
export type UserRow = typeof users.$inferSelect;
export interface ListUsersParams {
limit: number;
offset: number;
}
export const userDao = {
async list(params: ListUsersParams): Promise<{ rows: UserRow[]; total: number }> {
const rows = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(params.limit)
.offset(params.offset);
const [countRow] = await db.select({ count: sql<number>`count(*)` }).from(users);
return { rows, total: Number(countRow?.["count"] ?? 0) };
},
async getById(id: string): Promise<UserRow | null> {
const [row] = await db.select().from(users).where(eq(users.id, id)).limit(1);
return row ?? null;
},
async getByEmail(email: string): Promise<UserRow | null> {
const [row] = await db
.select()
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
return row ?? null;
},
async create(
data: Omit<UserRow, "id" | "createdAt" | "updatedAt"> & { id?: string },
): Promise<UserRow> {
const payload = { ...data, email: data.email.toLowerCase() };
const rows = await db.insert(users).values(payload).returning();
const row = rows[0];
if (!row) throw new Error("Failed to create user");
return row;
},
async update(
id: string,
data: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">>,
): Promise<UserRow | null> {
const payload: Partial<Omit<UserRow, "id" | "createdAt" | "updatedAt">> = { ...data };
if (payload.email !== undefined) payload.email = payload.email.toLowerCase();
const [row] = await db
.update(users)
.set({ ...payload, updatedAt: new Date() })
.where(eq(users.id, id))
.returning();
return row ?? null;
},
async delete(id: string): Promise<void> {
await db.delete(users).where(eq(users.id, id));
},
};

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {}
},
"include": ["src"]
}

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"],
"paths": {
"@app/db": ["../db/src/index.ts"],
"@app/models": ["../models/src/index.ts"]
}
},
"include": ["src"]
}

3
packages/db/.env.example Normal file
View File

@ -0,0 +1,3 @@
# Turso remote database
TURSO_DATABASE_URL=libsql://your-db-name.turso.io
TURSO_AUTH_TOKEN=your-turso-auth-token

4
packages/db/.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules/
dist/
drizzle/
.env

38
packages/db/AGENTS.md Normal file
View File

@ -0,0 +1,38 @@
# AGENTS.md — @app/db
## 职责
创建并导出 **Drizzle + libSQL 客户端单例**,以及 drizzle-kit 配置。查询在 `@app/dao`,业务在 `@app/trpc` service。
## 结构
```
src/
client.ts # export const db = drizzle(client, { schema })
index.ts # 对外 re-export
drizzle.config.ts # dialect: turso;schema 指向 ../models/dist/index.js
```
## 行为要点
- 读环境变量:`TURSO_DATABASE_URL`(必填,缺失则启动抛错)、`TURSO_AUTH_TOKEN`(可选)。
- 本地开发常用 `TURSO_DATABASE_URL=file:local.db`。
- `schema` 来自 `@app/models`——**模型定义请去 models 改**,本包只聚合导入。
- **不写查询**;SQL/Drizzle query 在 `@app/dao`。
- `drizzle.config.ts` 会手动解析根目录 `.env`(monorepo 下 drizzle-kit 不自动加载)。改 env 路径时两处保持一致。
## 修改指南
- 需要新表时:改 `@app/models` → `yarn db:generate` / `db:push`,不要在本包手写 SQL 迁移(除非 drizzle-kit 生成物不够用)。
- 不要在模块顶层执行查询以外的副作用;保持「import 即得 client」。
- 多环境:本地 `file:`,线上 `libsql://...` + token;不要写死 URL。
## 命令
```bash
yarn workspace @app/db typecheck
yarn workspace @app/db build
yarn db:generate
yarn db:push
yarn db:studio
```

View File

@ -0,0 +1,29 @@
import { defineConfig } from "drizzle-kit";
import path from "node:path";
import { readFileSync } from "node:fs";
// 手动加载根目录 .env(monorepo 中 drizzle-kit 不会自动找到)
try {
const env = readFileSync(path.resolve(__dirname, "../../.env"), "utf-8");
for (const line of env.split("\n")) {
const [key, ...vals] = line.split("=");
if (key && !key.startsWith("#")) {
process.env[key.trim()] = vals.join("=").trim();
}
}
} catch {}
const url = process.env["TURSO_DATABASE_URL"];
if (!url) throw new Error("TURSO_DATABASE_URL is required");
export default defineConfig({
// 指向构建产物:drizzle-kit 以 CJS require 加载,无法解析 src 里 ESM 风格的 .js 后缀相对导入
schema: "../models/dist/index.js",
out: "./drizzle",
dialect: "turso",
// 本地 file:;线上 libsql:// + token(由根 .env 注入,勿写死)
dbCredentials: {
url,
authToken: process.env["TURSO_AUTH_TOKEN"],
},
});

35
packages/db/package.json Normal file
View File

@ -0,0 +1,35 @@
{
"name": "@app/db",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@app/models": "workspace:*",
"@libsql/client": "^0.14.0",
"drizzle-orm": "^0.45.3"
},
"devDependencies": {
"@types/node": "^22.15.29",
"drizzle-kit": "^0.30.2",
"typescript": "^7.0.2"
}
}

17
packages/db/src/client.ts Normal file
View File

@ -0,0 +1,17 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import * as schema from "@app/models";
const url = process.env["TURSO_DATABASE_URL"];
if (!url) throw new Error("TURSO_DATABASE_URL is required");
const clientConfig: { url: string; authToken?: string } = { url };
const token = process.env["TURSO_AUTH_TOKEN"];
if (token) clientConfig.authToken = token;
const client = createClient(clientConfig);
/** Drizzle 客户端单例。模型定义请从 `@app/models` 引入;查询优先走 DAO。 */
export const db = drizzle(client, { schema });
export type Database = typeof db;

7
packages/db/src/index.ts Normal file
View File

@ -0,0 +1,7 @@
/**
* `@app/db`:Drizzle + libSQL 客户端单例。
* import 即得 client;查询写在 `@app/dao`,业务规则写在 `@app/trpc` service。
* 启动即校验 `TURSO_DATABASE_URL`,避免运行期半初始化。
*/
export { db } from "./client.js";
export type { Database } from "./client.js";

View File

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "./dist",
"rootDir": "./src",
"composite": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"paths": {}
},
"include": ["src"]
}

11
packages/db/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true,
"types": ["node"],
"paths": {
"@app/models": ["../models/src/index.ts"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,45 @@
# AGENTS.md — @app/design-tokens
## 职责
Monorepo 共享 **CSS 设计令牌**(Tailwind CSS v4 + shadcn/ui 语义色)。无 JS,构建只是把 `tokens.css` 拷到 `dist/`。
## 结构
```
src/tokens.css # 唯一真源
scripts/build.mjs # copy → dist/tokens.css
scripts/clean.mjs
README.md
```
## 消费方式
```css
@import "tailwindcss";
@import "@app/design-tokens/tokens.css";
```
(`web/src/styles/globals.css` 已如此。)导出:`.` 与 `./tokens.css`。
## 设计语义(改令牌前必读)
- 近黑中控 + 信号黄;**小圆角**(3–12px,拒绝大圆角 SaaS);数字等宽。
- 亮色/暗色两套:`:root, .light` 与 `.dark`。
- 语义变量:`--paper` `--panel` `--sink` `--line` `--line-strong` `--ink` `--ink-2` `--ink-3` `--edge`(及 `--edge-*` 派生)`--err` `--ok` `--warn`。
- 同时映射 shadcn 名:`--color-background` `--color-primary` `--color-border` 等,供 `ui/*` 组件使用。
- 视觉方向的更高层说明见根目录 `DESIGN.md`(注意:DESIGN 色值与 tokens 现值不完全一致时,**以 `tokens.css` 为准**)。
## 修改指南
- 只改 `src/tokens.css`,然后 `yarn workspace @app/design-tokens build`。
- 新增语义色:同时补 `.light` / `.dark`,并按需映射 `--color-*`。
- 不要在组件里 `@import` 单个片段或复制色值;一律用 CSS 变量。
- 圆角保持小尺度(sm 3px / md 6px / lg 8px / xl 12px),禁止大圆角 SaaS 卡。
## 命令
```bash
yarn workspace @app/design-tokens build
yarn workspace @app/design-tokens clean
```

View File

@ -0,0 +1,15 @@
# @app/design-tokens
Monorepo shared CSS design tokens for Tailwind CSS v4 and shadcn/ui.
Import the stylesheet from the consuming app's Tailwind entrypoint:
```css
@import "tailwindcss";
@import "@app/design-tokens/tokens.css";
```
Edit `src/tokens.css` as the single source of truth. It defines the neutral
scale, semantic colors, chart colors, and radius tokens for light and `.dark`
themes. Tailwind utility names such as `bg-background` and `text-primary` map to
these CSS custom properties.

View File

@ -0,0 +1,18 @@
{
"name": "@app/design-tokens",
"version": "0.1.0",
"private": true,
"type": "module",
"files": [
"dist"
],
"style": "./dist/tokens.css",
"exports": {
".": "./dist/tokens.css",
"./tokens.css": "./dist/tokens.css"
},
"scripts": {
"build": "node ./scripts/build.mjs",
"clean": "node ./scripts/clean.mjs"
}
}

View File

@ -0,0 +1,13 @@
/** 把 `src/tokens.css` 拷到 `dist/`(本包无编译,构建即拷贝)。 */
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageDirectory = dirname(fileURLToPath(import.meta.url));
const outputDirectory = resolve(packageDirectory, "../dist");
await mkdir(outputDirectory, { recursive: true });
await copyFile(
resolve(packageDirectory, "../src/tokens.css"),
resolve(outputDirectory, "tokens.css"),
);

View File

@ -0,0 +1,7 @@
/** 清理 dist 产物。 */
import { rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const packageDirectory = dirname(fileURLToPath(import.meta.url));
await rm(resolve(packageDirectory, "../dist"), { recursive: true, force: true });

View File

@ -0,0 +1,116 @@
@theme inline {
--font-sans:
"Space Grotesk", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "SF Mono", Consolas, ui-monospace, monospace;
--radius-sm: 3px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
}
/* 终末地设计语义
— 近黑中控台是主体,信号黄是唯一主强调
— 小圆角 + 斜切角(适当圆润,拒绝大圆角 SaaS)
— 数字等宽
— 强调色按表面派生(亮色文字必须下沉) */
:root,
.dark {
color-scheme: dark;
--radius: 6px;
--paper: #0e0f0e;
--panel: #191919;
--sink: #121212;
--line: #2e2e2e;
--line-strong: #35373c;
--ink: #f2f2f2;
--ink-2: #a3a6a1;
--ink-3: #7a7e78;
--edge: #fffa00;
--edge-rgb: 255, 250, 0;
--edge-deep: #e6e100;
--edge-on-paper: #fffa00;
--edge-as-text: #fffa00;
--sig-green: #00ffa2;
--sig-magenta: #ff1aac;
--sig-cyan: #5ad4ff;
--sig-acid: #b6ff00;
--sig-violet: #c084fc;
--err: #ff4d4d;
--ok: #00ffa2;
--warn: #ffb700;
--hazard: repeating-linear-gradient(-45deg, #191919 0 8px, #0e0f0e 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #0e0f0e 8px 16px);
--color-background: var(--paper);
--color-foreground: var(--ink);
--color-card: var(--panel);
--color-card-foreground: var(--ink);
--color-primary: var(--edge);
--color-primary-foreground: #101110;
--color-muted: var(--sink);
--color-muted-foreground: var(--ink-2);
--color-border: var(--line);
--color-ring: var(--edge);
--color-accent: var(--sink);
--color-accent-foreground: var(--ink);
--color-secondary: var(--line-strong);
--color-secondary-foreground: var(--ink);
--color-destructive: var(--err);
--color-destructive-foreground: #f2f2f2;
}
.light {
color-scheme: light;
--radius: 6px;
--paper: #f2f2f2;
--panel: #fafafa;
--sink: #e5e5e5;
--line: #d9d9d9;
--line-strong: #b3b3b3;
--ink: #141514;
--ink-2: #4a4d48;
--ink-3: #5c5f5a;
--edge: #fffa00;
--edge-rgb: 255, 250, 0;
--edge-deep: #e6e100;
--edge-on-paper: #d4cf00;
--edge-as-text: #6b6500;
--sig-green: #00c982;
--sig-magenta: #e01090;
--sig-cyan: #0a8ab8;
--sig-acid: #6a8f00;
--sig-violet: #7c3aed;
--err: #e03131;
--ok: #0f9f6e;
--warn: #d9822b;
--hazard: repeating-linear-gradient(-45deg, #e5e5e5 0 8px, #f2f2f2 8px 16px);
--hazard-edge: repeating-linear-gradient(-45deg, #fffa00 0 8px, #191919 8px 16px);
--color-background: var(--paper);
--color-foreground: var(--ink);
--color-card: var(--panel);
--color-card-foreground: var(--ink);
--color-primary: var(--edge);
--color-primary-foreground: #101110;
--color-muted: var(--sink);
--color-muted-foreground: var(--ink-2);
--color-border: var(--line);
--color-ring: var(--edge);
--color-accent: var(--sink);
--color-accent-foreground: var(--ink);
--color-secondary: var(--line-strong);
--color-secondary-foreground: var(--ink);
--color-destructive: var(--err);
--color-destructive-foreground: #f2f2f2;
}

36
packages/i18n/AGENTS.md Normal file
View File

@ -0,0 +1,36 @@
# AGENTS.md — @app/i18n
## 职责
**文案与 locale 纯函数库**:静态 messages、`t()`、Accept-Language 解析、cookie 名。
不依赖 Astro/React/DB;islands 与 `.astro` 共用同一套查表,**不要引入 i18next**。
## 结构
```
src/
locales.ts # Locale、LOCALES、DEFAULT_LOCALE、PREFIX_LOCALE
cookie.ts # COOKIE_LANG = "app-lang"
accept-language.ts # parsePreferredLocale / shouldRedirectRootToEn
preferred.ts # resolvePreferredLocale(cookie > Accept-Language > zh-CN)
messages/zh-CN.ts # key 权威源
messages/en.ts # 必须与 zh-CN 同构
t.ts # t(locale, key, params?)
index.ts
```
## 约定
- 中文 `/zh-CN`、英文 `/en` 前缀;裸 `/` 仅由 middleware 检测后 302,不挂页面。
- 页面路由:`@app/web` 用 **单套** `src/pages/[locale]/…` 动态前缀,不要复制 `zh-CN/`、`en/` 目录。
- 新增文案:先改 `zh-CN.ts`,再补 `en.ts`(类型强制 key 对齐)。
- 插值仅支持 `{name}`;无 ICU/复数(需要时再扩展)。
- React island:传 `locale` prop 或组件内 `t(locale, key)`,不靠 Provider。
- 路径检测与 302 在 `@app/web` 的 `middleware.ts`,本包只提供解析函数。
## 命令
```bash
yarn workspace @app/i18n typecheck
yarn workspace @app/i18n build
```

View File

@ -0,0 +1,26 @@
{
"name": "@app/i18n",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc --project tsconfig.build.json",
"typecheck": "tsc --noEmit",
"lint": "biome check ."
},
"devDependencies": {
"@types/node": "^22.15.29",
"typescript": "^7.0.2"
}
}

View File

@ -0,0 +1,61 @@
import { DEFAULT_LOCALE, type Locale, isLocale } from "./locales.js";
interface LangTag {
tag: string;
q: number;
}
/** 解析 Accept-Language,返回受支持的首选 locale;无法判定时返回 null。 */
export function parsePreferredLocale(header: string | null | undefined): Locale | null {
if (!header) return null;
const tags: LangTag[] = header
.split(",")
.map((part) => {
const [rawTag, ...params] = part.trim().split(";");
const tag = (rawTag ?? "").trim().toLowerCase();
let q = 1;
for (const p of params) {
const [k, v] = p.split("=");
if (k?.trim() === "q" && v) {
const n = Number(v.trim());
if (!Number.isNaN(n)) q = n;
}
}
return { tag, q };
})
.filter((t) => t.tag.length > 0 && t.q > 0)
.sort((a, b) => b.q - a.q);
for (const { tag } of tags) {
if (tag === "*") return DEFAULT_LOCALE;
if (isLocale(tag)) return tag;
// en-US → en;zh-Hans → 尝试主语言
const base = tag.split("-")[0] ?? "";
if (isLocale(base)) return base;
if (base === "zh") return "zh-CN";
}
return null;
}
/** 显式偏好 en(q ≥ 其它具体语言)才跳英文;zh* 一律 false。 */
export function shouldRedirectRootToEn(acceptLanguage: string | null | undefined): boolean {
if (!acceptLanguage) return false;
let enQ = 0;
let zhQ = 0;
for (const part of acceptLanguage.split(",")) {
const [rawTag, ...params] = part.trim().split(";");
const tag = (rawTag ?? "").trim().toLowerCase();
let q = 1;
for (const p of params) {
const [k, v] = p.split("=");
if (k?.trim() === "q" && v) {
const n = Number(v.trim());
if (!Number.isNaN(n)) q = n;
}
}
if (tag === "en" || tag.startsWith("en-")) enQ = Math.max(enQ, q);
if (tag === "zh" || tag.startsWith("zh-")) zhQ = Math.max(zhQ, q);
}
if (enQ <= 0) return false;
return enQ > zhQ;
}

View File

@ -0,0 +1,4 @@
/** 用户语言偏好 cookie(手动切换后写入;middleware 优先于 Accept-Language)。 */
export const COOKIE_LANG = "app-lang";
export const COOKIE_LANG_MAX_AGE = 60 * 60 * 24 * 365;

View File

@ -0,0 +1,15 @@
export {
LOCALES,
DEFAULT_LOCALE,
LOCALE_PREFIX,
PREFIX_LOCALE,
isLocale,
parsePathLocale,
alternatePath,
localePath,
type Locale,
} from "./locales.js";
export { COOKIE_LANG, COOKIE_LANG_MAX_AGE } from "./cookie.js";
export { parsePreferredLocale, shouldRedirectRootToEn } from "./accept-language.js";
export { resolvePreferredLocale } from "./preferred.js";
export { t, getMessages, type MessageKey, type Messages } from "./t.js";

Some files were not shown because too many files have changed in this diff Show More