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