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:
commit
a5ba7124dc
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
26.7.0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"semi":false,"singleQuote":true,"trailingComma":"all","printWidth":100}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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/*`
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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`。
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Point to the tRPC API server
|
||||||
|
PUBLIC_API_URL=http://localhost:4000
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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") };
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
// 布局入口:AppLayout.astro 由页面直接导入
|
||||||
|
export {};
|
||||||
|
|
@ -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}`;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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" }),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { cn } from "@app/ui";
|
||||||
|
|
@ -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;
|
||||||
|
});
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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} />
|
||||||
|
|
@ -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" />
|
||||||
|
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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` 通过
|
||||||
|
|
@ -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/*'
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
/** `@app/adsense` 核心入口:脚本加载 + 队列命令(React 组件走 `@app/adsense/react`)。 */
|
||||||
|
export { loadAdSense, pushAdSense } from "./loader.js";
|
||||||
|
export type { AdSensePushCommand } from "./types.js";
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/** `@app/adsense/react` 入口:给 Astro island / 纯 React 用的广告位组件。 */
|
||||||
|
export { AdSlot, type AdSlotProps } from "./AdSlot.js";
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** 向 AdSense 队列 push 的命令参数(一般空对象即可,由 <ins> 上的 data-* 决定广告位)。 */
|
||||||
|
export interface AdSensePushCommand {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
/** AdSense 命令队列;官方脚本接管前可能不存在。 */
|
||||||
|
adsbygoogle?: unknown[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"composite": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"paths": {}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
# Turso remote database
|
||||||
|
TURSO_DATABASE_URL=libsql://your-db-name.turso.io
|
||||||
|
TURSO_AUTH_TOKEN=your-turso-auth-token
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
drizzle/
|
||||||
|
.env
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"],
|
||||||
|
"paths": {
|
||||||
|
"@app/models": ["../models/src/index.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
);
|
||||||
|
|
@ -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 });
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
/** 用户语言偏好 cookie(手动切换后写入;middleware 优先于 Accept-Language)。 */
|
||||||
|
export const COOKIE_LANG = "app-lang";
|
||||||
|
|
||||||
|
export const COOKIE_LANG_MAX_AGE = 60 * 60 * 24 * 365;
|
||||||
|
|
@ -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
Loading…
Reference in New Issue