Commit 17eea8db by chunhong.mu

refactor(*): 改造为前端 Monorepo 模板工程

包命名通用化,移植小程序模板与公共代码,补齐 Claude Code 配置。

包结构:
- qqqf-admin → admin(Vue3 SPA 后台)
- official-site-web → site(Nuxt 3 官网)
- qqqf-mp → mini(UniApp 小程序)
- 新增 web(Vue3 SPA H5/客户端),删除空目录 qqqf-web

公共代码:
- @common/utils 补齐 common/date-time/mask/money/regex 纯工具函数
- @common/vue-kit 新增平台无关的 useLoading
- @common/sdk 去业务化:ClientId/Product 枚举改为 string 类型,移除硬编码的
  passport/wx-distributer 域名与监控上报地址凭证,改由 SdkConfig 与
  webMonitor.setupEndpoint() 注入;根入口不再导出小程序模块,
  避免 Web 项目引入 uni.* 类型

mini 包以 uniapp-mp-template 为准重建,SDK 接 @common/sdk:新增
stores/services/api-auth/composables/page-layout/utils-sdk(懒加载单例)

去业务化:所有 .env.* 清空为占位符(原含真实域名与 client secret),
config 中业务值改为 TODO 占位

Claude Code 配置:
- .claude/settings.json(权限白名单 + Figma MCP)
- .claude/skills 移植 4 个 skill 并适配 monorepo,
  api-diff-sync 的文档根目录由硬编码改为可配置
- 新增 CLAUDE.md,重写 ARCHITECTURE.md

顺带修复:
- 登录不持久化 Token:services/auth-service 补齐
  saveToken2Storage + sdk.core.loginByToken + store 三步
- 跨端崩溃:token-manager/web-monitor 的 window 访问加 typeof 守卫
- dev:project/build:project 对 Vue/Nuxt 项目忽略 --env,
  现按 dev:<env>/build:<env> 脚本执行
- .env.* 中的 NODE_ENV=production 导致 dev server 报警告,已移除
- unocss 0.58/0.64 与 Nuxt 3.21 的 Vite 7 不兼容,升级至 66.x
- App.vue 的 @import 改为 @use(Dart Sass 3.0 将移除 @import)
- 修复 pnpm-workspace.yaml 的 BOM 与 build-changed.ts 的 lint 报错

校验:pnpm lint 全绿,4 个包 type-check 通过,
4 个包 dev 与 build(含 build:project --env)全部通过

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
parent e90f4e4f
Showing with 5375 additions and 1389 deletions
{
"mcpServers": {
"figma": {
"url": "https://mcp.figma.com/mcp",
"type": "http"
}
},
"permissions": {
"allow": [
"Bash(node -e ' *)",
"Bash(node --version)",
"Bash(pnpm --version)",
"Bash(pnpm list *)",
"Bash(pnpm install)",
"Bash(pnpm dev:project *)",
"Bash(pnpm build:project *)",
"Bash(pnpm build:changed)",
"Bash(pnpm lint)",
"Bash(pnpm lint:fix)",
"Bash(pnpm changeset *)",
"Bash(pnpm -F * lint*)",
"Bash(pnpm -F * type-check)",
"Bash(pnpm -F * build*)",
"Bash(grep *)",
"Bash(rg *)",
"Bash(find *)",
"Bash(ls *)",
"Bash(dir *)",
"Bash(cat *)",
"Bash(head *)",
"Bash(tail *)",
"Bash(tree *)",
"Bash(git status)",
"Bash(git log *)",
"Bash(git diff *)",
"Bash(git branch *)",
"Bash(npx vue-tsc *)",
"Bash(npx eslint *)"
],
"defaultMode": "auto"
}
}
---
name: api-diff-sync
description: 当用户消息中出现形如 docs/api/后端/xxx.md 的接口改动文档路径时使用(不需要用户显式输入 /skill 名)。对比该改动文档与基线接口文档(docs/api/前端/api-doc.md)及各业务包现有代码,逐项列出改动点,等用户说「开工」后再修改代码,完成后把改动追加到 docs/api/联调记录.md。
---
# 接口改动文档 → 前端联调 Skill
## 一、先确定「接口文档根目录」
本 skill 依赖一个**接口文档根目录**(下称 `<DOC_ROOT>`)。模板默认约定为:
```
docs/api/
前端/api-doc.md ← 基线接口文档(前端整理,改动的对照物)
后端/api-doc-*.md ← 改动文档(后端针对某批次改动/新增出的说明,一次改动一份,文件名自定)
联调记录.md ← 汇总记录,固定路径,不随改动文档变化
资源/ ← 交互原型等参考资料(可选),改动文档里的编号对应这里的文件
```
**这个路径是可配置的**:不同项目可能把文档放在 `prd-md/<项目名>/文档/``doc/接口/` 或仓库外的目录。
执行前先按顺序确定 `<DOC_ROOT>`
1. 如果 `CLAUDE.md``ARCHITECTURE.md` 中写明了接口文档目录,用那个;
2. 否则如果 `docs/api/` 存在,用它;
3. 否则从用户给的路径反推:取其中包含 `前端/``后端/` 的上一级目录作为 `<DOC_ROOT>`
4. 都无法确定时,**直接问用户**,不要猜。
改动文档会持续新增,不要把流程写死成只认某几份现有文件——对 `<DOC_ROOT>/后端/` 下任意一份 `*.md` 都适用。
## 二、触发方式
只要用户消息里出现一个路径,同时满足:
1. 落在 `<DOC_ROOT>` 目录下(含任意层子目录;盘符/斜杠方向不敏感,允许 `/``\`);
2. 用户没有额外说明这是别的用途(比如明确说“只是想看看这个文件”)。
就应主动识别为这个 skill 的输入并执行,**不需要用户输入 `/api-diff-sync` 或专门确认要不要用这个 skill**。这与 req-diff 不同:req-diff 需要先问用户是否要用该 skill,这个 skill 不需要。
## 三、第 0 步:路径校验
判断路径是否落在 `<DOC_ROOT>`(含子目录)下:
- **在该目录下**:直接进入第 1 步,无需额外确认。
- **不在该目录下**:不要直接执行。先向用户说明“这个路径不在 `<DOC_ROOT>` 下,是否仍按这个流程处理”,等用户明确回复后再继续(无论回复是与否,都不要静默处理)。
## 四、第 1 步:分析阶段(只读,不动代码)
1. 读取用户给的改动文档(下称"改动文档",通常在 `<DOC_ROOT>/后端/` 下)。
2. 读取基线接口文档 `<DOC_ROOT>/前端/api-doc.md`,理解改动文档相对基线改了什么。若基线文档不存在,说明情况并只依据改动文档 + 代码现状做对比。
3. 改动文档中出现的编号(如 01/02/03/04)对应 `<DOC_ROOT>/资源/` 目录下的同编号文件,如需确认页面/字段的原始上下文可查阅。
4. 用 Grep/Explore 在业务代码中定位改动文档提到的每个接口的实际调用位置(接口名、字段名精确匹配,不要只凭猜测)。本仓库的接口调用集中在:
- `packages/*/src/api/`(小程序与 SPA:`api/auth.ts`、`api/home.ts` 等)
- `packages/site/api/`(Nuxt 官网)
- `common/sdk/src/services/`(SDK 层的通用登录/验证码服务)
注意一个接口可能被多个包调用,要全仓搜索,不要只查一个包。
5. 逐条产出改动清单,覆盖改动文档里的**每一个条目**(包括结论是"无需改动"的条目,也要显式列出并说明为什么不用改),标注:
- 对应改动文档章节
- 涉及的接口名
- 涉及的代码文件(哪个包 / 哪个文件)
- 结论:需要改代码 / 无需改代码 / 需要后端先提供缺失信息(比如废弃接口没给替代方案)
6. 把清单展示给用户,**不要调用 Write/Edit 等写入类工具**,等待用户回复。
只有用户明确说"开工"(或等价的明确肯定,如"可以改"“动手”)才能进入第 2 步。此前无论用户追问多少轮细节,都不算开工。
## 五、第 2 步:执行阶段(改代码)
- 按清单逐项修改代码。已有 `/** TODO: ... */` 标记的位置,优先检查是否就是改动文档里提到的待补齐点,是的话在原位置替改,不要另起代码。
- 遵守仓库分层约定(见 `ARCHITECTURE.md`):接口签名改动落在 `api/`,业务流程改动落在 `services/`,页面只跟着调用签名走。
- 如果一个接口被多个包调用,所有调用点都要同步改,不能只改一个。
- 明确"无需改动"的条目不动代码。
- 只做改动文档要求的改动,不顺带重构、不加不相关的容错、不建立面向未来的抽象。
## 六、第 3 步:更新联调记录
目标文件固定为 `<DOC_ROOT>/联调记录.md`(不在改动文档同目录——改动文档可能在 `后端/` 子目录下,联调记录始终在 `<DOC_ROOT>` 根目录)。
1. 读取该文件现有内容,不要整体重写、不要删除已有条目。文件不存在时新建,并按下面的结构初始化(`# 前端` / `# 后端` 两个顶级小节)。
2. 确定"操作人"小节:文件用 `## <操作人>` 区分不同人的记录。默认沿用当前 git 用户名对应的操作人小节;如果找不到匹配小节,在 `# 前端` 下新建一个 `## <操作人>` 小节。**不要假设操作人固定是某个人**——用户可能已手动改过归属。
3. 在该操作人小节下追加本次改动,编号规则:
- 找到该小节下已存在的最大 `改动N` 编号,新条目从 `N+1` 接续编号(不要另起 1,也不要新建独立文件)。
- 每条格式固定为三段:
```markdown
### 改动{N}
api文档:《{改动文档文件名}》
接口:{改动文档中的章节序号与接口名}
描述:{做了什么 / 为什么不用做 / 还差什么}
```
- 描述中提到具体代码文件时,用 markdown 相对链接引用,如 `[auth.ts](../../packages/mini/src/api/auth.ts)`,方便直接跳转,不要写纯文件名。
- 如果该条目需要后端先配合(比如缺替代接口、缺字段),标题改为 `### 🔴改动{N}(需后端配合,待跟进)`,并在描述末尾说明具体缺什么。
4. 排序规则:**只有一条**——需要后端配合的 🔴 条目整体排在该操作人小节的最前面;其余条目按改动文档中出现的原始顺序排列。追加新条目后,如果排序被打乱(比如新插入了一条 🔴),要重新排列该小节内所有条目以满足这条规则,而不是只在末尾追加。
5. `# 后端` 小节保持原样,不主动往里面写内容(除非用户明确要求)。
## 七、注意事项
- 不要因为要写联调记录就顺带润色或重排用户没有触碰过的其他操作人小节的内容。
- `MD060` 之类的 markdown lint 警告可以忽略,不必为了消除警告改变表格/标题结构。
- 如果一次改动文档里出现"废弃接口"但没给出替代方案,且现有代码仍在调用,不要擅自删除调用或改用猜测的替代接口——按"需后端配合"处理。
---
name: figma-restore
description: 从 Figma 设计稿还原 H5/Web 页面,支持 Vue3 / React / UniApp / Flutter / 纯 HTML,并添加路由。
---
# Figma → 页面还原 Skill
## ① 首次调用:一次性执行声明
**每次调用 `/figma-restore` 时,在第一条回复中输出以下声明,然后立即开始执行,不等待用户确认**
> **🚀 Figma 还原启动**
> 本次将自动执行以下操作,无需逐步确认:
> - 调用 Figma API 读取设计稿节点数据
> - 下载图片资产到目标包的静态资源目录(如 `packages/<pkg>/public/img/`,小程序为 `src/static/img/`)
> - 生成/更新组件与路由代码
>
> 如需中断,随时发送"停止"。
声明后**直接进入 Step 0**,不再为任何单步 API 调用、脚本执行或图片下载询问"是否继续"。
---
## ② 前置检查:Figma MCP 服务
在做任何还原操作之前,先验证 Figma MCP 是否可用。此检测**不区分操作系统**,在 CLI / VS Code 插件 / 桌面版中均可正常工作。
### 检测策略(按优先级递进)
#### 策略 1:工具可用性探测(推荐,跨环境通用)
不依赖 shell 脚本,直接通过 MCP 工具调用验证:
1. 尝试调用 Figma MCP 的任意只读工具(如获取文件信息),传入一个无效 fileKey
2. 如果返回 404/403 等业务错误 → **MCP 服务已连接,API Key 有效** → ✅ 通过
3. 如果返回"工具不存在" / "MCP server not connected" → **MCP 服务未连接** → 走策略 2
#### 策略 2:配置文件检查(fallback)
当策略 1 不可用时,读取 MCP 配置文件验证:
1. 定位配置文件(按以下顺序查找第一个存在的):
- `~/.claude/.mcp.json`(全局,CLI / 桌面版)
- `~/.claude/settings.json` 中的 `mcpServers` 字段
- `.mcp.json`(项目级,当前目录)
- `.claude/settings.json` 中的 `mcpServers` 字段(项目级)
2. 解析 JSON,查找 `mcpServers.figma` 配置
3. 从配置参数中提取 `--figma-api-key=xxx`
4. 若 key 为空或仍为占位符(如 `YOUR_TOKEN`)→ 失败
**提取 Token 的通用逻辑**(不依赖 OS):
- 读取上述配置文件
-`mcpServers.figma` 中查找 `args` 数组
- 找到以 `--figma-api-key=` 开头的参数,取 `=` 后面的值
- 或查找 `env` 对象中的 `FIGMA_API_KEY` 字段
#### 策略 3:Token 有效性验证
拿到 Token 后,调用 Figma API 验证:
```
GET https://api.figma.com/v1/me
Header: X-Figma-Token: <token>
```
- 返回 200 → ✅ `Figma MCP 已就绪,当前用户:{email}`
- 返回 401/403 → ❌ Token 无效
- 网络错误 → ❌ 网络不通或 API 不可达
### 检测结果输出
| 结果 | 输出 |
|------|------|
| ✅ 全部通过 | `✅ Figma MCP 已就绪,当前用户:xxx@xxx.com` |
| ❌ 未配置 | 输出安装指引(见下方) |
| ❌ Token 无效 | `❌ Figma API Key 无效,请重新生成 Personal Access Token` |
| ❌ 网络不通 | `❌ 无法连接 Figma API,请检查网络或代理设置` |
### 检测失败时的安装指引
若检测失败,停止执行并向用户输出:
---
> **⚠️ 未检测到 Figma MCP 服务,请按以下步骤安装:**
>
> #### 第一步:获取 Figma Personal Access Token
>
> 1. 登录 [figma.com](https://figma.com) → 右上角头像 → **Settings**
> 2. 左侧菜单 → **Security** → 滚动到 **Personal access tokens**
> 3. 点击 **Generate new token**,填写名称(如 `claude-code`),勾选 **File content** 读取权限,复制 Token
> 4. **重要**:Token 仅在生成时显示一次,请立即保存
>
> #### 第二步:配置 Figma MCP
>
> **方式 A — CLI 命令**(适用于 `claude` CLI,自动写入全局配置):
> ```bash
> claude mcp add figma -- npx -y @anthropic-ai/figma-mcp --figma-api-key=YOUR_TOKEN
> ```
>
> **方式 B — 项目级 `.mcp.json`**(适用于 VS Code 插件 / 桌面版 / 团队协作):
> 在项目根目录创建 `.mcp.json`:
> ```json
> {
> "mcpServers": {
> "figma": {
> "command": "npx",
> "args": ["-y", "@anthropic-ai/figma-mcp", "--figma-api-key=YOUR_TOKEN"]
> }
> }
> }
> ```
>
> **方式 C — 用户级 MCP 配置**(`~/.claude/settings.json`):
> ```json
> {
> "mcpServers": {
> "figma": {
> "command": "npx",
> "args": ["-y", "@anthropic-ai/figma-mcp", "--figma-api-key=YOUR_TOKEN"]
> }
> }
> }
> ```
>
> #### 常见问题
>
> | 问题 | 解决 |
> |------|------|
> | `npx: command not found` | 确认 Node.js 已安装(`node -v`),或使用全局安装 `npm i -g @anthropic-ai/figma-mcp` |
> | Token 显示 `YOUR_TOKEN` | 替换为你的真实 Token,不要保留占位符 |
> | 添加 MCP 后重启仍不可用 | 确认 `.mcp.json` 在项目根目录(与 `package.json` 同级),重启 Claude Code |
> | 403 Forbidden | Token 缺少 File content 权限,重新生成 Token 并勾选 File content |
> | 网络超时 | 检查代理设置,Figma API 地址为 `api.figma.com` |
>
> #### 第三步:重启后再次运行 `/figma-restore`
---
### MCP 配置缓存机制
**避免同一项目重复检测。**
每次 MCP 检测通过后,在本地记录验证结果:
- 成功检测后,在项目级 `.claude/` 目录写入 `.figma-mcp-verified` 文件,内容为验证日期
- 后续同项目新会话调用 `/figma-restore` 时,先检查此文件:
- 文件存在且验证日期在 7 天内 → **跳过 MCP 检测,直接进入 Step 0**,输出 `✅ Figma MCP 已缓存(最近验证:YYYY-MM-DD)`
- 文件不存在或超过 7 天 → 重新执行完整检测流程
- 检测再次成功后更新缓存日期
此机制保证:同一项目在同一周内只需检测一次,新开会话无需重复配置验证。
---
## ③ 前置配置
- **Figma Token**:从配置文件或 MCP 工具响应中动态获取,勿硬编码
- 获取方式:优先使用策略 1 的工具调用结果;fallback 到策略 2 的配置文件解析
- 提取后在后续所有 Figma API 请求中附加请求头:`X-Figma-Token: <token>`
---
## ④ 技术栈感知(每次执行前必做)
### ④.0 先确定目标包(monorepo 必做)
本仓库是 pnpm monorepo,还原前**必须先确定目标包**,所有后续读写都限定在该包内:
| 包 | 类型 | 路由定义 | 页面目录 | 组件目录 | 静态资源 |
|----|------|---------|---------|---------|---------|
| `packages/admin` | Vue 3 SPA 后台 | `src/router/index.ts` | `src/pages/` | `src/components/` | `public/` |
| `packages/web` | Vue 3 SPA H5/客户端 | `src/router/index.ts` | `src/pages/` | `src/components/` | `public/` |
| `packages/mini` | UniApp 小程序 | `src/pages.json` | `src/pages/` | `src/components/` | `src/static/` |
| `packages/site` | Nuxt 3 官网 | 文件路由 | `pages/` | `components/` | `public/` |
如果用户没说明目标包,**直接问**,不要猜。图片资产下载到目标包对应的静态资源目录下(如 `packages/<pkg>/public/img/...`)。
### ④.1 读取技术栈
还原前先读取目标包的技术栈与代码规范,生成代码必须与项目现有风格一致:
- 读取 `packages/<pkg>/package.json``dependencies``devDependencies`,确认框架、UI 库、CSS 方案
- 读取组件目录列表,了解已有组件
- 读取路由定义,确认路由结构
- 读取根目录 `eslint.config.mjs`,确认代码风格(本仓库:4 空格缩进、双引号、无分号)
根据读取结果确认以下上下文,后续代码生成全部遵循:
| 检查项 | 读取位置 | 影响 |
|--------|---------|------|
| 框架 | `packages/<pkg>/package.json` | Vue3 SPA / Nuxt / UniApp |
| UI 组件库 | `package.json` dependencies | Ant Design Vue / 无 |
| CSS 方案 | `package.json` + `vite.config.*` | SCSS / UnoCSS |
| TypeScript | `tsconfig.json` 是否存在 | props/emits/hooks 类型标注 |
| 全局样式 Token | `src/assets/styles/` | 优先使用已定义变量 |
| 现有组件 | 组件目录列表 | C.O.D.E 评估 |
| 路由结构 | 见上表 | 路由挂载点与 Layout |
| Layout 组件 | `src/components/layout/``App.vue` | 哪些全局元素已由 Layout 处理 |
| 跨包复用 | `common/vue-kit``common/utils` | 通用组件/函数优先复用,不要重复实现 |
---
## ⑤ 四步 UI 还原工作流
### Step 0:类型识别
从 URL 提取 `fileKey``node-id`,读取顶层 Frame 宽度:
调用 Figma API:`GET /v1/files/{fileKey}/nodes?ids={nodeId}&depth=1`
从响应中读取 `$frame.absoluteBoundingBox.width``height`
| Frame 宽度 | 类型 | 后续 |
|-----------|------|------|
| ≤ 430px | H5 移动端 | → Step 1-H5 |
| 431–768px | 平板 | 参考 H5 骨架,适当拓宽 |
| > 768px | Web | → Step 1-Web |
---
### Step 1-H5:骨架模式识别
#### 核心布局原则
- **模式 A**:视口自然滚动,`position: fixed` 仅用于真正浮动的元素
- **模式 B / C**:页面容器 `height: 100vh; overflow: hidden``.page-content` 始终 `flex: 1; overflow-y: auto`(撑满且可滚动)
#### H5 骨架速查
| 模式 | 结构 | 布局方案 | 适用场景 |
|------|------|---------|---------|
| **A** | 视口滚动 + 可选 fixed 叠层 | 根容器自然流 | 落地页、海报页、长营销页 |
| **B** | header(内容可变)+ scrollContent | flex 列,header `flex-shrink: 0`,scrollContent `flex: 1` | 列表页、详情页、表单页;header 可能只有 navbar,也可能是 navbar+tabs-bar / navbar+stats-bar / navbar+hero 等 |
| **C** | header + scrollContent + footer | flex 列,header/footer `flex-shrink: 0`,scrollContent `flex: 1` | 有底部操作栏的页面(TabBar、CTA 栏、底部导航) |
#### 识别决策树
```
整个页面是否需要超出一屏向下滚动(落地页/长页面)?
├─ 是 → 模式 A
└─ 否 → 页面整体锁定在 100vh:
有底部操作区?
├─ 是 → 模式 C
└─ 否 → 模式 B
```
#### 各模式骨架模板与关键样式
**模式 A — 视口滚动页**
```vue
<template>
<div class="page XXX-page">
<!-- 所有滚动内容直接放根容器下:Section、图片、文字 -->
<!-- 可选:fixed 悬浮元素 -->
<div class="page-floating">...</div>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
/* 关键样式 */
.page { width: 100%; } /* 根容器自然流,整个页面即滚动区域 */
.page-floating {
position: fixed; /* 仅真正浮动的元素用 fixed */
bottom: 24px; right: 16px;
z-index: 100;
}
</style>
```
**模式 B — header + scrollContent**
```vue
<template>
<div class="page XXX-page">
<div class="page-header">
<!-- header 内容可变:navbar / navbar+tabs / navbar+stats 等 -->
</div>
<div class="page-content">
<!-- scrollContent:所有可滚动的 Section -->
</div>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
/* 关键样式 */
.page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden; /* 锁定视口 */
}
.page-header { flex-shrink: 0; } /* header 不压缩 */
.page-content {
flex: 1;
overflow-y: auto; /* 撑满剩余空间且可滚动 */
}
</style>
```
**模式 C — header + scrollContent + footer**
```vue
<template>
<div class="page XXX-page">
<div class="page-header">
<!-- header 内容 -->
</div>
<div class="page-content">
<!-- scrollContent:所有可滚动的 Section -->
</div>
<div class="page-footer">
<!-- 底部固定栏 -->
</div>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
/* 关键样式 */
.page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden; /* 锁定视口 */
}
.page-header { flex-shrink: 0; } /* 顶部不压缩 */
.page-content {
flex: 1;
overflow-y: auto; /* 中间撑满且可滚动 */
}
.page-footer {
flex-shrink: 0; /* 底部不压缩 */
padding-bottom: env(safe-area-inset-bottom, 0px);
}
</style>
```
#### 复杂页面判定
骨架模式确定后,对每个 Section 进行复杂度评分(满分 10 分):
| 维度 | 评分标准 | 权重 |
|------|---------|------|
| **布局复杂度** | 嵌套 flex/grid ≥ 3 层 / 多列卡片 / 不规则网格 | 3 分 |
| **交互元素** | 含 Tab 切换 / 轮播 / 折叠面板 / 弹窗触发 | 2 分 |
| **自定义样式** | 渐变背景 / 复杂圆角 / 自定义阴影 / 变形 | 2 分 |
| **媒体密度** | 含 ≥ 2 张图片 / 视频 / SVG 插图 | 2 分 |
| **文字密度** | 含 ≥ 5 个独立文本节点(标题 + 多行说明) | 1 分 |
**决策规则**
```
Σ Section 复杂度评分:
≥ 15 分 或 存在单节 ≥ 7 分 → 进入"复杂页面模式"(快速骨架 + 分节还原)
< 15 分 且 无单节 ≥ 7 分 → 正常流程,按骨架模式直接填充内容,继续 Step 2
```
> 注:3 节但每节都含多列卡片 + 轮播 + 自定义渐变 → 总分 18 → 复杂模式
> 6 节但每节只是"标题 + 一段文字 + 一个按钮"→ 总分 12 → 正常模式
#### H5 复杂页面模式 — 快速骨架 + 分节还原
**第一阶段:快速搭建 H5 骨架(立即执行,不等待)**
骨架要求:
- 颗粒度到按钮级别:所有可见的标题、段落、按钮、Tab、输入框都用真实文字 + 真实尺寸占位
- 图片用带背景色的 `<div class="img-ph">` 占位,内部写图片描述文字
- 使用 Figma 数据中的真实颜色、间距、字号;Section 宽度固定 375px
骨架模板示例(H5 模式 A,展示结构、嵌套和关键样式):
```vue
<template>
<div class="page landing-page">
<section class="section hero-section">
<div class="image-ph image-ph--hero"><span>Hero 主视觉</span></div>
</section>
<section class="section steps-section">
<div class="section__title">3步轻松开启</div>
<div class="section__body">
<div class="step-item"><span class="step-item__num">STEP 1</span></div>
</div>
</section>
<section class="section commission-section">
<div class="section__body">
<div class="comm-card"><span class="comm-card__num">30%</span></div>
</div>
</section>
<div class="page-footer"><button class="btn btn--cta">立即加入</button></div>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
/* ─── Page ─── */
.page {
width: 375px; margin: 0 auto; background: #f5f7fb;
padding-bottom: 100px; /* 为 fixed CTA 留空间 */
}
/* ─── Section 通用 ─── */
.section { margin: 0 12px 16px; border-radius: 10px; overflow: hidden; }
.section__title {
height: 62px; display: flex; align-items: center; justify-content: center;
font-size: 20px; font-weight: 700; color: #fff;
background: linear-gradient(155deg, #0078ff 15%, #5db6ff 87%);
}
.section__body { background: #fff; padding: 16px; }
/* ─── Section 特有 ─── */
.hero-section { position: relative; }
.steps-section { .section__title { /* 可覆盖 */ } }
/* ─── 子组件 BEM ─── */
.step-item {
display: flex; align-items: center; gap: 8px;
&__num { font-size: 12px; font-weight: 700; color: #2f54eb; }
}
.comm-card {
display: flex; flex-direction: column; align-items: center;
padding: 16px; background: #f7f9fc; border-radius: 8px;
&__num { font-size: 24px; font-weight: 800; color: #2f54eb; }
}
/* ─── Footer ─── */
.page-footer {
position: fixed; bottom: 0; left: 50%; transform: translateX(-50%);
width: 375px; background: #fff; padding: 10px 15px 34px;
}
.btn--cta {
width: 100%; height: 54px; border: none; border-radius: 27px;
background: linear-gradient(157deg, #2f54eb 15%, #5879ff 87%);
color: #fff; font-size: 20px; font-weight: 800;
&:active { opacity: 0.85; }
}
/* ─── 图片占位 ─── */
.image-ph {
background: #e8edf5; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
span { font-size: 13px; color: #8b9099; }
&--hero { width: 375px; height: 300px; border-radius: 0; }
}
</style>
```
**第二阶段:分节收集 Figma 链接**
骨架完成后,**使用单次 AskUserQuestion 收集所有 Section 链接**(不再每个 Section 一个 question):
```
⚡ 骨架已完成。请提供各区块的 Figma 链接以精确还原:
(已从页面主链接识别到以下区块,如有遗漏请在"其他"处补充)
请按以下格式一次性回复(每行一个 Section):
1. Hero 头部: https://figma.com/file/xxx?node-id=123:456
2. 3步开启: https://figma.com/file/xxx?node-id=789:012
3. 收益说明: 跳过
4. CTA 按钮: 自动截取
"跳过"=保持骨架不变,"自动截取"=从主设计稿导出 PNG 作为背景图。
```
收到回复后,解析每一行的意图:
- 包含 `https://` → 提取 `fileKey` + `nodeId`,精确还原该节
- 包含 `跳过` → 保持骨架,不处理
- 包含 `自动截取` → 从主设计稿 Frame 导出该区域 PNG 作为背景图
逐节精确还原,每节完成后告知进度。
---
### Step 1-Web:还原区域识别
Web 设计稿通常包含全局 Layout(顶部导航、侧边栏),路由组件只负责主内容区,**不能把整张设计稿还原进路由组件**
#### 1. 定位路由挂载点
读取目标包的路由定义(`src/router/index.ts`;小程序为 `src/pages.json`;Nuxt 为文件路由)、`src/App.vue``src/components/layout/`(如存在),确认路由挂载方式与 Layout 结构。
#### 2. 复杂页面判定与骨架搭建
使用与 H5 相同的复杂度评分体系(见 Step 1-H5 复杂页面判定)。对还原区域内各 Section 评分后:
```
Σ Section 复杂度评分:
≥ 15 分 或 存在单节 ≥ 7 分 → 进入"复杂页面模式"(快速骨架 + 分节还原)
< 15 分 且 无单节 ≥ 7 分 → 正常流程,继续 Step 2
```
#### 复杂页面模式 — 快速骨架 + 分节还原
**第一阶段:快速搭建骨架(立即执行,不等待)**
骨架要求:
- 颗粒度到按钮级别:所有可见的标题、段落、按钮、Tab、输入框都用真实文字 + 真实尺寸占位
- 图片用带背景色的 `<div class="img-ph">` 占位,内部写图片描述文字(如 `<span>Hero 主视觉图</span>`
- 使用 Figma 数据中的真实颜色、间距、字号
骨架模板示例(Web,展示结构、嵌套和关键样式):
```vue
<template>
<div class="page home-page">
<section class="section hero-section">
<div class="page-content">
<p class="section__eyebrow">副标题</p>
<h1 class="section__title">主标题</h1>
<div class="section__actions">
<button class="btn btn--primary">主按钮</button>
</div>
<div class="image-ph image-ph--hero"><span>Hero 主视觉</span></div>
</div>
</section>
<section class="section service-section">
<div class="page-content">
<div class="card-row">
<div class="card card--placeholder"><span>服务项</span></div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts"></script>
<style lang="scss" scoped>
/* ─── Page ─── */
.page-content { max-width: 1272px; margin: 0 auto; padding: 0 24px; }
/* ─── Section: Hero ─── */
.hero-section {
padding: 80px 0;
.section__eyebrow { font-size: 14px; color: #666; margin-bottom: 8px; }
.section__title { font-size: 40px; font-weight: 800; line-height: 1.2; margin-bottom: 24px; }
.section__actions { display: flex; gap: 16px; margin-bottom: 40px; }
}
/* ─── Section: 服务 ─── */
.service-section {
padding: 80px 0;
.section__title { font-size: 32px; font-weight: 700; margin-bottom: 8px; }
}
/* ─── 子组件 BEM ─── */
.card {
flex: 1; height: 200px; background: #f0f4ff; border-radius: 12px;
display: flex; align-items: center; justify-content: center;
&--placeholder span { font-size: 14px; color: #2f54eb; }
}
.card-row { display: flex; gap: 24px; margin-bottom: 40px; }
/* ─── 图片占位 ─── */
.image-ph {
background: #e8edf5; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
span { font-size: 13px; color: #8b9099; }
&--hero { width: 100%; height: 540px; }
}
</style>
```
**第二阶段:分节收集 Figma 链接**
骨架完成后,**使用单次 AskUserQuestion 收集所有 Section 链接**(不再每个 Section 一个 question):
```
⚡ 骨架已完成。请提供各区块的 Figma 链接以精确还原:
(已从页面主链接识别到以下区块,如有遗漏请在"其他"处补充)
请按以下格式一次性回复(每行一个 Section):
1. Hero Banner: https://figma.com/file/xxx?node-id=123:456
2. 人事服务: https://figma.com/file/xxx?node-id=789:012
3. 客户案例: 跳过
4. 底部 CTA: 自动截取
"跳过"=保持骨架不变,"自动截取"=从主设计稿导出 PNG 作为背景图。
```
收到回复后,解析每一行的意图:
- 包含 `https://` → 提取 `fileKey` + `nodeId`,精确还原该节
- 包含 `跳过` → 保持骨架,不处理
- 包含 `自动截取` → 从主设计稿 Frame 导出该区域 PNG 作为背景图
逐节精确还原,每节完成后告知进度。
---
### Step 2:组件复用评估(C.O.D.E)
| 优先级 | 类型 | 检查项 |
|--------|------|--------|
| **C** | Component | 目标包 `components/` 与跨包 `common/vue-kit` 是否已有可复用组件 |
| **O** | Open Source | Ant Design Vue(admin/site)等已装 UI 库是否有现成组件 |
| **D** | Design System | 项目全局样式(`src/assets/styles/`)是否已定义颜色/间距 token |
| **E** | External | 图标 SVG 是否已下载到图标目录 |
复用 > 改写现有 > 新建。仅在 C/O/D/E 均无合适选项时才新建组件。
---
### Step 2.1:BEM 类名规范
所有生成的样式类名统一采用 **BEM(Block__Element--Modifier)** 命名规范。
#### 双类名策略
**页面容器和章节均采用"通用类 + 语义类"双类名**,便于写公共样式和单独定制:
| 元素 | 通用类 | 语义类 | 模板示例 |
|------|--------|--------|---------|
| 页面根容器 | `.page` | `.{XXX-page}` | `class="page landing-page"` |
| 页面头部 | `.page-header` | — | `class="page-header"` |
| 页面内容区 | `.page-content` | — | `class="page-content"` |
| 页面底部 | `.page-footer` | — | `class="page-footer"` |
| 章节根节点 | `.section` | `.{XXX-section}` | `class="section hero-section"` |
| 章节标题 | `.section__title` | — | `class="section__title"` |
| 章节内容 | `.section__body` | — | `class="section__body"` |
**为什么用双类名**
- `.page` / `.section` 写通用重置样式(margin/padding/box-sizing 等),所有页面复用
- `.landing-page` / `.hero-section` 写页面/章节特有样式(颜色/尺寸/背景等)
- 模板中 `class="page landing-page"` 两者并存,不冲突
#### 基础约定
| BEM 部分 | 语法 | 示例 | 说明 |
|---------|------|------|------|
| Block | `.block-name` | `.page-header` `.section` `.btn` `.card` | 独立组件/区块,kebab-case |
| Element | `.block__element` | `.section__title` `.stat-item__label` | Block 的子元素,双下划线 `__` |
| Modifier | `.block--modifier` | `.btn--primary` `.card--highlight` | Block/Element 的状态或变体,双连字符 `--` |
#### 页面骨架结构
```html
<div class="page landing-page"> ← 页面根容器(.page + {语义}-page)
<div class="page-header"> ← 页面头部 Block
<div class="page-header__navbar">...</div>
</div>
<div class="page-content"> ← 页面主内容区 Block
<section class="section hero-section"> ← 章节(.section + {语义}-section)
<h2 class="section__title">...</h2>
<div class="section__body">
<div class="stat-item">
<span class="stat-item__num">21年</span>
<span class="stat-item__label">行业经验</span>
</div>
</div>
</section>
<section class="section steps-section">...</section>
<section class="section cards-section">...</section>
</div>
<div class="page-footer"> ← 页面底部 Block
<button class="btn btn--cta">立即加入</button>
</div>
</div>
```
#### 样式书写规则
**预处理器严格嵌套**(SCSS/LESS/Stylus):使用 `&` 嵌套,禁止超出当前选择器作用域。
```scss
// ✅ 正确:严格嵌套
.section {
margin: 0 12px 16px;
&__title {
font-size: 20px;
font-weight: 700;
&--gradient {
background: linear-gradient(135deg, #0078ff, #5db6ff);
}
}
&__body {
padding: 16px;
}
&--hero {
margin: 0;
border-radius: 0;
}
}
```
**样式顺序 = 视觉顺序**`<style>` 块中的样式声明必须按页面中元素从上到下的视觉排列顺序书写。
```
.page → .page-header → .page-content → .section → .section__title → .section__body → ... → .page-footer
```
- 不可将底部样式写在顶部之前
- 不可将 Section 2 的样式穿插到 Section 1 的子元素中
- 注释分隔符标记区块边界:`/* ─── Section: Hero ─── */`
---
### Step 2.2:背景策略
**渐变叠加优先于背景图片**。能实现视觉效果的,优先用 CSS 渐变/叠加实现,无法替代时才使用图片背景。
#### 优先级
1. **纯色**`background: #color`
2. **渐变**`background: linear-gradient(...)` / `radial-gradient(...)`
3. **渐变 + 纯色叠加**`background: linear-gradient(...) , #color`
4. **背景图片**`background: url(...)`(仅当 Figma 设计稿使用真实照片/复杂插图时)
5. **渐变 + 图片叠加**`background: linear-gradient(...) , url(...)`(图片上加遮罩层)
#### 常见场景
| Figma 设计 | CSS 实现 |
|-----------|---------|
| 蓝色渐变 Banner | `background: linear-gradient(135deg, #0078ff 0%, #5db6ff 100%)` |
| 半透明渐变卡片 | `background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(255,255,255,0.8))` |
| 照片背景 + 深色遮罩 | `background: linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.6)), url(...)` |
| 多色装饰性背景 | `background: radial-gradient(circle at 20% 50%, #e8f0fe 0%, #f5f7fb 70%)` |
| 复杂纹理/照片 | `background: url(...)` + `background-size: cover; background-position: center` |
---
### Step 2.3:图标与特殊文字
**图标和特殊文字默认下载为 SVG**,不使用内联 `<svg>` 或 Unicode 字符。
#### 图标规范
- **禁止**内联 `<svg>…</svg>`(除非是 1-2 个极简通用图标且用户明确要求)
- **禁止**用 Unicode 字符(`›` `✕` `⊙` `→` 等)充当图标
- **正确做法**:从 Figma 导出 `.svg` 到图标目录,以 `<img>` 引用
```vue
<img src="/img/icon-arrow.svg" class="icon icon--arrow" alt="" />
```
#### 特殊文字/装饰性文字
Figma 中的装饰性文字(如大背景文字、图标化数字、装饰性符号):
- 若为纯文字装饰效果(描边、特殊字体)→ 导出 SVG 后以 `<img>` 引用
- 若为普通可读文字 → 直接用 `<p>` / `<span>` 实现
---
### Step 2.4:原子化 CSS 处理
**仅在用户明确指定或技术栈扫描时发现原子化 CSS 框架时才启用。**
#### 检测时机
在 ④ 技术栈感知阶段,扫描到以下任一依赖时,主动向用户确认:
| 框架 | 检测方式 | 询问模板 |
|------|---------|---------|
| Tailwind CSS | `package.json``tailwindcss` | `检测到 Tailwind CSS,是否在本页面使用原子化类名?(BEM 类名仍将保留)` |
| UnoCSS | `package.json``unocss` | 同上 |
| Windi CSS | `package.json``windicss` | 同上 |
| 自定义原子类 | 项目存在 `@apply` 或自定义 utility 文件 | `检测到自定义原子化类,是否使用?请提供类名对照。` |
#### 使用规则
**即使启用原子化 CSS,仍必须保留 BEM 类名。**
```vue
<!-- ✅ 正确:BEM + 原子化并存 -->
<button class="btn btn--primary px-6 py-3 rounded-full text-white bg-blue-600 hover:bg-blue-700">
立即加入
</button>
<!-- ❌ 错误:仅原子化类名 -->
<button class="px-6 py-3 rounded-full text-white bg-blue-600 hover:bg-blue-700">
立即加入
</button>
```
- BEM 类名用于组件级样式管理和主题定制
- 原子化类名用于快速调整间距、颜色等细节
- 两者共存,互不冲突
- 用户未明确指定时,默认**不使用**原子化 CSS
---
### Step 2.5:Figma Auto Layout → CSS 映射指南
从 Figma 节点数据中提取 `layoutMode``primaryAxisAlignItems``counterAxisAlignItems``itemSpacing``paddingTop/Right/Bottom/Left` 等属性,按以下映射表生成 CSS:
#### Figma Auto Layout → CSS 速查表
| Figma 属性 | 值 | CSS 映射 |
|-----------|------|---------|
| `layoutMode` | `HORIZONTAL` | `display: flex; flex-direction: row` |
| `layoutMode` | `VERTICAL` | `display: flex; flex-direction: column` |
| `primaryAxisAlignItems` | `MIN` | `justify-content: flex-start` |
| `primaryAxisAlignItems` | `CENTER` | `justify-content: center` |
| `primaryAxisAlignItems` | `MAX` | `justify-content: flex-end` |
| `primaryAxisAlignItems` | `SPACE_BETWEEN` | `justify-content: space-between` |
| `counterAxisAlignItems` | `MIN` | `align-items: flex-start` |
| `counterAxisAlignItems` | `CENTER` | `align-items: center` |
| `counterAxisAlignItems` | `MAX` | `align-items: flex-end` |
| `counterAxisAlignItems` | `BASELINE` | `align-items: baseline` |
| `itemSpacing` | `N` | `gap: Npx` |
| `paddingTop/Right/Bottom/Left` | `T R B L` | `padding: Tpx Rpx Bpx Lpx` |
| `layoutWrap` | `NO_WRAP` | `flex-wrap: nowrap` |
| `layoutWrap` | `WRAP` | `flex-wrap: wrap` |
#### 子元素尺寸映射
| Figma 子元素 sizing | CSS 映射 |
|-------------------|---------|
| `layoutSizingHorizontal: FIXED`,宽 `W` | `width: Wpx` |
| `layoutSizingHorizontal: FILL` | `flex: 1; width: 100%` |
| `layoutSizingHorizontal: HUG` | `width: fit-content` 或 unset |
| `layoutSizingVertical: FIXED`,高 `H` | `height: Hpx` |
| `layoutSizingVertical: FILL` | `flex: 1; height: 100%` |
| `layoutSizingVertical: HUG` | `height: fit-content` 或 unset |
#### 绝对定位子元素
当 Figma 节点内存在 `position: ABSOLUTE` 的子元素:
- 父元素加 `position: relative`
- 子元素使用 `position: absolute`,通过 `top/right/bottom/left` 定位(值来自 `constraints` + 父容器尺寸计算)
#### 提取方法
从 Step 4 解析的节点数据中,对每个 `layoutMode` 存在的节点:
1. 读取节点的 `layoutMode``primaryAxisAlignItems``counterAxisAlignItems``itemSpacing``padding*``layoutWrap`
2. 读取每个子元素的 `layoutSizingHorizontal``layoutSizingVertical``absoluteBoundingBox`
3. 按上表生成对应 CSS 规则
4.`position: ABSOLUTE` 的子元素,单独生成绝对定位样式
---
### Step 2.6:交互态处理
Figma 设计稿中的交互态(hover、active、focus、disabled)通常通过 **Component Variants****Prototype 交互** 表达,需从节点数据中提取并生成对应 CSS。
#### Variant 交互态提取
当 Figma 节点为 COMPONENT_SET(Variant 集合)时:
1. 读取 `componentPropertyDefinitions` 中的交互相关属性(如 `State``Hover``Interaction`
2. 读取每个 Variant 的 `name` 和属性值
3. 识别常见状态名:`Hover``Pressed``Active``Focused``Disabled``Default`
4.`Default` Variant 为基准,生成 CSS 伪类样式
**映射关系**
| Figma Variant 状态名 | CSS 伪类 |
|---------------------|---------|
| Hover / Mouseover | `:hover` |
| Pressed / Active / Click | `:active` |
| Focus / Focused / Selected | `:focus` |
| Disabled / Inactive | `:disabled``.is-disabled` |
| Default / Normal | 基础样式(无伪类) |
#### 交互态生成规则
1. **按钮交互态**(必须):至少生成 `:hover` + `:active` + `:disabled`
```scss
.btn {
// 基础样式(Default Variant)
background: #2f54eb;
color: #fff;
transition: all 0.2s ease;
&:hover {
background: #1d39c4; // 从 Hover Variant 提取
}
&:active {
background: #10239e; // 从 Pressed Variant 提取
}
&:disabled {
opacity: 0.4; // 从 Disabled Variant 提取
cursor: not-allowed;
}
}
```
2. **卡片/可点击区域**(可选):生成 `:hover`(轻微背景变化或阴影)
3. **表单元素**(必须):生成 `:focus`(边框高亮/阴影)+ `:disabled`
4. **Tab 切换**:生成 `.active` 类(选中态)+ `:hover`
#### Variant 时的降级策略
如果 Figma 节点没有 Variant 数据,但检测到交互元素(按钮、Tab、链接等):
- `:hover` 将基础色加深 10-15%(或降低亮度)
- `:active` 将基础色再加深 10-15%
- `:focus` 添加 `outline: 2px solid <primary-color>` `box-shadow: 0 0 0 3px <primary-color>20`
- `:disabled` `opacity: 0.4; cursor: not-allowed`
---
### Step 2.7:响应式断点处理
**⚠️ 仅在用户显式说明需要响应式时才启用此步骤。**
当用户明确提到"响应式""适配多端""mobile/tablet/desktop"等关键词时:
1. 先向用户确认目标断点范围
2. 使用以下标准断点生成媒体查询:
| 断点名称 | 范围 | 设备类型 |
|---------|------|---------|
| `xs` | `< 375px` | 小屏手机 |
| `sm` | `375px 768px` | 手机 / 小平板 |
| `md` | `768px 1024px` | 平板 / 小笔记本 |
| `lg` | `1024px 1440px` | 桌面 |
| `xl` | `> 1440px` | 大屏 |
#### 响应式生成策略
| 页面类型 | 响应式策略 |
|---------|-----------|
| H5Frame 430px | 使用 `max-width: 100%` + `vw`/`rem` 单位,内容在 `min(375px, 100vw)` 内居中显示 |
| WebFrame > 768px | `.inner { max-width: <design-width>px; margin: 0 auto; }` + 媒体查询适配布局变化 |
| 平板(431-768px | 参考 H5 骨架,增加两侧留白,适当放大触控区域 |
**默认行为(用户未提响应式时)**
- 不生成任何媒体查询
- H5 页面固定 375px 居中
- Web 页面使用设计稿原始尺寸
---
### Step 2.8:页面状态管理
骨架阶段用 mock 数据填充页面,实际开发中页面通常有 **loading / error / empty** 三种状态,需提前预留。
#### 状态定义
| 状态 | 触发条件 | UI 表现 |
|------|---------|---------|
| **loading** | 数据请求中 | 骨架屏 / 全局 loading 提示 |
| **error** | 接口失败 | 错误提示 + 重试按钮 |
| **empty** | 接口返回成功但无数据 | 空状态插图 + 引导文案 |
| **success** | 数据加载完成 | 正常页面内容 |
#### 骨架阶段处理
- 骨架模板中直接使用 mock 数据,不写状态判断逻辑
- 精确还原阶段,如果用户提到接口对接,在 `<script setup>` 中补充状态管理:
```ts
const pageState = ref<'loading' | 'error' | 'empty' | 'success'>('loading')
const pageData = ref<Record<string, any> | null>(null)
async function fetchData() {
pageState.value = 'loading'
try {
pageData.value = await api.getPageData()
pageState.value = pageData.value ? 'success' : 'empty'
} catch {
pageState.value = 'error'
}
}
```
#### 模板中状态判断(精确还原阶段)
```vue
<template>
<div class="page XXX-page">
<div v-if="pageState === 'loading'" class="page-loading">加载中...</div>
<div v-else-if="pageState === 'error'" class="page-error">
<p>加载失败</p>
<button @click="fetchData">重试</button>
</div>
<div v-else-if="pageState === 'empty'" class="page-empty">
<div class="image-ph image-ph--empty"><span>暂无数据</span></div>
<p>这里空空如也</p>
</div>
<template v-else>
<!-- 正常页面内容 -->
</template>
</div>
</template>
```
---
### Step 2.9:图片性能规范
骨架阶段使用占位图,精确还原阶段下载真实图片后需遵循以下性能规范。
#### 图片格式优先级
| 格式 | 使用场景 | 优先级 |
|------|---------|--------|
| **WebP** | 照片、复杂插图 | 最高(体积小 25-35%) |
| **SVG** | 图标、装饰图形、Logo | 高(无损缩放) |
| **PNG** | 需透明度的非矢量图 | 中 |
| **JPEG** | 照片类(fallback) | 低 |
#### 加载策略
| 场景 | 策略 | 实现 |
|------|------|------|
| 首屏可见图片 | 预加载 | `<link rel="preload" as="image" href="...">` |
| 首屏以下图片 | 懒加载 | `<img loading="lazy" ...>` |
| 背景图 | CSS `background-image` 或 `<picture>` | 按需加载 |
#### 响应式图片
Web 项目当设计稿为多端适配时,使用 `<picture>` + `srcset`:
```html
<picture>
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="Hero 主视觉" loading="lazy" />
</picture>
```
H5 移动端当用户明确要求响应式时,才生成多尺寸 `srcset`。
#### 下载后检测
图片下载后按 Step 3 的大小检测规则汇报,> 300KB 提示压缩,> 1MB 必须压缩。
---
### Step 2.10:无障碍 (a11y) 规范
**⚠️ 默认不启用。仅在用户明确提到"无障碍"、"a11y"、"accessibility"、"语义化"等关键词时启用。**
启用后遵循以下规则:
#### 基础规则
| 规则 | 要求 |
|------|------|
| 图片 `alt` | 所有 `<img>` 必须有 `alt` 属性(装饰性图片用 `alt=""`) |
| 按钮 `aria-label` | 图标按钮/无文字按钮必须加 `aria-label` |
| 焦点管理 | 可交互元素必须有可见的 focus 状态 |
| 颜色对比度 | 文字与背景对比度 ≥ 4.5:1(WCAG AA) |
| 语义标签 | 使用 `<nav>`、`<main>`、`<section>`、`<article>` 等语义标签替代 `<div>` |
| 键盘导航 | 所有交互支持 Tab/Enter/Escape 键盘操作 |
#### 语义化模板示例
```vue
<template>
<div class="page XXX-page">
<header class="page-header">
<nav class="page-header__navbar" aria-label="主导航">...</nav>
</header>
<main class="page-content">
<section class="section hero-section" aria-labelledby="hero-title">
<h1 id="hero-title" class="section__title">页面标题</h1>
</section>
</main>
<footer class="page-footer">
<button class="btn btn--cta" aria-label="立即加入">立即加入</button>
</footer>
</div>
</template>
```
---
### Step 3:资产规格约束(违反即暂停报告)
#### 图标规范
- **禁止** 模板中写内联 `<svg>…</svg>`
- **禁止** 用 Unicode 字符(`›` `✕` `⊙` 等)充当图标
- **正确做法**:从 Figma 导出 `.svg` 到图标目录,以 `<img>` 或框架图片组件引用
```vue
<img :src="iconArrow" class="icon" />
<script setup lang="ts">
const iconArrow = getImgSrc("home/bg2.png")
</script>
```
- **导出 SVG**:
调用 Figma API:`GET /v1/images/{fileKey}?ids={iconIds}&format=svg`
从响应中拿到每个节点的 SVG URL,逐个下载保存到目标目录。
- **若导出失败**,立即报告:`⚠️ 图标 <name> 导出失败,请手动从 Figma 导出后放置到 packages\***\public\img\<name>.svg`
#### 图片规范与大小检测
- 尺寸来自 Figma `absoluteBoundingBox`,不估算
- PNG @ 2x scale,放页面专属资产目录
- IMAGE fill 节点导出透明图时改用方式 B(见 Step 4)
**下载后必须检测文件大小**,还原完成后统一汇报:
- 读取下载后文件的大小
- < 300 KB → ✅ 正常
- 300 KB–1 MB → 🟡 偏大,建议用 squoosh / tinypng 压缩
- > 1 MB → 🔴 过大,必须压缩
| 文件大小 | 级别 | 建议 |
|---------|------|------|
| < 300 KB | ✅ 正常 | 无需处理 |
| 300 KB–1 MB | 🟡 偏大 | 建议用 squoosh / tinypng 压缩 |
| > 1 MB | 🔴 过大 | 必须压缩 |
---
### Step 4:实现与验证
#### 解析节点树
调用 Figma API 获取节点结构:
- `GET /v1/files/{fileKey}/nodes?ids={nodeId}&depth=4` — 快速预览结构
- `GET /v1/files/{fileKey}/nodes?ids={nodeId}&depth=8` — 完整数据
- 将 depth=8 的响应保存为 JSON 文件供后续分析
从响应中遍历 `document.children`,提取每个节点的 `type`、`name`、`absoluteBoundingBox`。
#### 递归提取文本和颜色
遍历节点树,提取:
- **文本**:所有 `type === "TEXT"` 节点的 `characters`、`style.fontSize`、`style.fontWeight`
- **颜色**:所有节点的 `fills` 中 `color` 值(跳过 `alpha < 0.1` 的透明填充)
- 颜色转换为 RGB/HEX 格式:`R = round(color.r * 255)`,同理 G、B
#### 导出图片(方式 A:节点截图)
调用 Figma API:`GET /v1/images/{fileKey}?ids={nodeId}&format=png&scale=2`
从响应中拿到图片 URL,下载保存到目标目录。
#### 导出图片(方式 B:IMAGE fill)
遍历节点树,找到所有 `fills` 中 `type === "IMAGE"` 的节点,记录其 `imageRef`。
调用 `GET /v1/files/{fileKey}/images` 获取 imageRef 对应的实际 URL,下载保存。
#### 添加路由
**Vue 3 SPA(`packages/admin`、`packages/web`)** — `src/router/index.ts` 的 routes 数组末尾追加:
```typescript
{
path: '/<page>',
name: '<PageName>',
component: () => import('@/pages/<page>/index.vue'),
meta: { title: '<页面标题>', requiresAuth: true },
},
```
**UniApp 小程序(`packages/mini`)** — 在 `src/pages.json` 的 `pages` 数组中追加:
```json
{
"path": "pages/<page>/index",
"style": { "navigationBarTitleText": "<页面标题>" }
}
```
**Nuxt 官网(`packages/site`)** — 文件路由,只需新建 `pages/<page>.vue` 或 `pages/<page>/index.vue`,无需注册。
#### 验证
- **类型检查**:`pnpm -F <pkg> type-check`
- **代码风格**:`pnpm lint:fix`(`@antfu/eslint-config`,4 空格缩进 / 双引号 / 无分号)
- **页面截图验证**(确认 dev server 已运行):使用浏览器截图工具或 Playwright 访问页面 URL 截图
---
## ⑥ 维护与迭代
根据用户意图选择路径,**不同场景走不同分支,不混用**:
---
### 路径 A — 用户说"这里还原度低 / 不对 / 图挂了"
1. **定位区块**:从用户描述或截图坐标推断是哪个 Section 组件
2. **主动索取 Figma 链接**:若当前会话中没有该节点链接,用 `AskUserQuestion` 询问:
```json
{
"questions": [
{
"question": "请提供「<Section 名称>」区块的 Figma 节点链接,我来精确对齐",
"header": "Figma 链接",
"options": [
{ "label": "提供节点链接", "description": "粘贴 Figma 节点 URL 到下方输入框" },
{ "label": "提供页面主链接", "description": "我会自动定位到该区块" },
{ "label": "截图上传", "description": "上传目标效果截图,我用图片还原" }
],
"multiSelect": false
}
]
}
```
3. **精确重写**:提取 `fileKey` + `nodeId`,直接进入 Step 4 重新读取节点数据并重写组件,**不重走全页面流程**
4. **截图验证**:用 playwright 截取对应区域,确认修复效果
---
### 路径 B — 用户说"改一下文案 / 数据 / 颜色"
纯代码修改,**不需要 Figma**
1. 打开对应组件文件
2. 文案/数据:编辑 `<script setup>` 里的 mock 数组或常量
3. 颜色/间距:修改 `<style>` 块中对应的 CSS 值
4. 直接保存,无需截图验证(改动可见)
---
### 路径 C — 用户说"Figma 设计稿更新了,重新对齐"
设计稿主动改版,已有历史链接,**无需用户再提供**
重新调用 Figma API 获取最新节点数据:`GET /v1/files/{fileKey}/nodes?ids={nodeId}&depth=8`
拿到新数据后,**diff 变动点**(文字、颜色、布局),仅重写有差异的部分,保留未变动代码。
---
### 路径 D — 用户说"新增一张图 / 换一张图"
确保资产目录存在,用方式 A(节点截图)或 B(IMAGE fill)下载图片,下载后检测文件大小,> 300KB 提示压缩建议。
---
name: req-diff
description: 将一份原型/需求描述与项目现有代码做差异对比,逐项编号列出需要新建/修改的页面、组件、字段、状态、跳转逻辑,等待用户逐项裁决,只有用户明确说“开工”后才允许写代码。适用于用户给出一个原型文件路径(如某项目 design-export 目录下的 .dc.html)或一段需求描述,希望先梳理改动范围再动手实现的场景。
---
# 需求/原型 Diff 分析
## 这个 skill 是做什么的
把一份「原型 / 需求描述」和「项目现状(代码)」进行差异比对,产出一份**编号化的差异清单**,然后进入等待裁决的对话循环,直到用户显式说出“开工”才允许写代码。
核心分为两个严格分离的阶段:
1. **分析阶段(Diff & Discuss)**:读原型、读代码、列差异、追问细节、等待用户逐项回复。这个阶段可以无限往复,追问多具体、多少轮都不算“开工”。
2. **执行阶段(Build)**:只有用户明确说出“开工”这个词之后才能进入。此前,绝不能调用 Write / Edit / NotebookEdit / Bash(写入类)等任何会改变文件系统状态的工具。
## 触发方式(重要:不能静默触发)
即使你从用户的提示词中识别出“这是一个原型/需求 diff 对比场景,适用这个 skill”,也**不能自动直接跑这套流程**。你必须先明确询问用户:“看起来可以用 req-diff 这个 skill 来梳理,要用吗?”得到肯定答复后才进入分析阶段。
不要因为用户提供了一个 `.html` 路径或一段需求描述就默认认为对方想用这个 skill——路径和描述本身只是触发候选信号,确认意图永远是第一步。
## 输入形态是通用的
不要假设输入一定是“HTML 原型 vs 代码”这种形式。可能出现的输入包括但不限于:
- 一个原型文件的相对路径(本仓库约定放在 `packages/<pkg>/design-export/*.dc.html`,但**不要把目录名硬编码进判断逻辑**——不同项目目录名可能完全不同,甚至没有目录,只有一段文字描述)
- 一段纯文字需求描述,没有任何附件
- 一张图片 + 一句话
无论输入形态如何,都走同一套流程:理解输入 → 定位对应的实现代码位置 → 列差异 → 编号 → 等裁决。
## 关键澄清:原型 ≠ 视觉设计稿
如果输入是类似 `design-export` 目录下的 `.dc.html` 这种文件,要认清它的性质:**这是交互原型,不是最终视觉设计稿**。它表达的是:
- 有哪些页面
- 页面里有哪些字段/数据
- 有哪些状态和状态之间的流转
- 页面之间怎么跳转
- (通常文件末尾会有“关键逻辑注释” / `logic-card` 这类模块,用大白话说明每个页面的业务规则——这是最重要的信息源,一定要读完)
**因此列差异时,必须排除纯视觉层面的差异**(颜色、间距、字号、圆角、渐变等)。后续用户会拿到正式 UI 图后单独发起一轮 UI 适配(见 figma-restore skill),那是另一个独立环节,不归这次 diff 分析管。
只关注这些维度的差异:
- 页面/路由是否存在(原型里有的页面,代码里有没有对应实现)
- 组件是否存在,承担的职责是否覆盖
- 字段/数据模型是否覆盖(尤其是原型里出现但代码里没有的状态字段)
- 状态与状态流转逻辑是否已实现(比如两态切换、审核流程、权限判定)
- 跳转关系是否正确(页面 A 点击后应该去页面 B,代码里有没有打通)
## 本仓库的代码定位约定
这是一个 pnpm monorepo,包结构如下:
| 包 | 类型 | 路由定义位置 | 页面目录 |
|----|------|-------------|---------|
| `packages/admin` | Vue 3 SPA 后台 | `src/router/index.ts` | `src/pages/` |
| `packages/web` | Vue 3 SPA H5/客户端 | `src/router/index.ts` | `src/pages/` |
| `packages/mini` | UniApp 小程序 | `src/pages.json` | `src/pages/` |
| `packages/site` | Nuxt 3 官网 | 文件路由(无独立配置) | `pages/` |
跨包复用代码在 `common/utils``common/vue-kit``common/sdk`,对应包名 `@common/*`
## 分析阶段的操作步骤
1. **定位对应的代码目录**。如果输入路径包含明显的 package 结构(如 `packages/<pkg>/...`),默认对比同一 package 下的 `src` 目录(Nuxt 项目为包根目录)。如果无法从路径判断,或者项目结构不明显,直接问用户要对比哪个包,不要凭猜测继续。
2. **完整读完原型/需求输入**。如果是 `.dc.html` 类文件,注意文件可能很长,需要分页读完,尤其不能漏掉末尾的“关键逻辑注释”部分——那里通常包含身份判定、状态流转等纯代码里看不出来的业务规则。
3. **系统性读现状代码**:路由配置(见上表)、相关页面文件、相关组件、共享的状态/computed(如 `services/``stores/` 里的公共判断逻辑)。可以用 Explore 类 agent 辅助大范围搜索,但要自己核对结论。
4. **逐项列出差异**,格式不固定,按内容多少自行组织,但必须编号(1、2、3...),方便用户直接用编号回复“1不需要 2需要”。每一项应该说明:
- 差异是什么(原型里有什么,代码里现状如何)
- 涉及哪些文件(用可点击的相对路径引用)
- 如果有多种可能的实现方式,可以简要提及,但不要展开讲代码怎么写
5. **等待用户逐项回复**。用户可能会对某几项做出裁决,同时对另几项提出新的问题——这些追问也要在分析阶段内解决,可以用 `AskUserQuestion` 或直接文字提问,但都不能触发写入类工具。
6. **每轮追问结束后,如果没有更多问题了,主动告知用户“准备好开工了”**,但不要自己开始写代码,继续等待用户明确说“开工”。
## 执行阶段的硬规则
- 只有用户输入中明确出现“开工”这个词(或用户主动要求跳过讨论直接实现),才能进入执行阶段。
- 进入执行阶段后,严格按照分析阶段里已经和用户对齐的清单执行,不要顺带做清单之外的“顺手优化”。
- 遵守仓库架构约定(见 `ARCHITECTURE.md`):接口调用写在 `api/`,业务编排写在 `services/`,页面只负责组装;跨项目复用的纯函数下沉到 `@common/utils`
- 执行过程中如果发现新的歧义(比如某个按钮放哪、某个字段叫什么),要停下来问用户,不能自己猜测决定,问完再继续写。
- 执行完成后用简洁的总结汇报改了什么文件、做了什么,不需要罗列过程细节。
## 反面例子(禁止的行为)
- 用户给了一个 html 路径,没有其他话 → 直接读完就开始建页面、注册路由。**错误**:跳过了触发确认和差异列举,也没有等裁决。
- 用户回复了“1不要 2要 3要”,模型顺势直接开始写代码。**错误**:用户没有说“开工”,即使已经逐项裁决完毕,仍然要等这个词。
- 分析阶段中,模型主动去读代码文件是允许的(只读不算写入),但如果模型顺手用 Edit 修了一个错别字或格式问题,即使很小,也是**错误**的——分析阶段不允许任何写入类工具调用。
- 列差异时写了“这个按钮的颜色和原型不一致”。**错误**:这是视觉层面的差异,不在这次 diff 范围内。
---
name: update-ui
description: 为多人协同开发 .vue 文件提供隔离方案——新建一个独立的 ui-{组件名}.ts 空文件供 UI/交互同事编写协作代码,避免和负责接口对接的开发者在同一个 <script setup> 里改动冲突;联调结束后再把 ui-*.ts 合并回 .vue 文件。适用于两种场景:`/update-ui <vue文件路径>` 搭建协作脚手架,`/update-ui <组件名或"全部"> 合并` 合并回收。
---
# UI 层隔离 Skill(update-ui)
## 背景 / 解决的问题
多人协同改同一批 `.vue` 文件时,一方负责接口对接(`<script setup>` 里的 ref/接口调用/watch),另一方负责 UI(`<template>`/`<style>` 结构和样式,可能新增交互逻辑)。风险点:
- 双方都可能 touch `<script setup>`(比如同事需要给模板加一个纯展示用的 computed 或交互函数),导致同一区块冲突。
- 字段名是双方的隐式契约,模板重写时容易和接口逻辑的变量名对不上。
解决思路:新建一个独立的 `ui-{组件名}.ts` **空文件**,作为 UI/交互同事的专属协作区——具体写什么内容完全由同事自己决定,这个 skill 不代写、不迁移、不猜测。`.vue``<script setup>` 里用 `import * as ui from "./ui-{组件名}.ts"` 引入整个模块,`<template>` 里通过 `ui.xxx` 引用同事导出的任何变量/函数。接口开发者的 `<script setup>` 保持完全不动;同事只改 `ui-*.ts`,两边物理隔离在不同文件,Git 合并时天然不会冲突。
**重要边界**`/update-ui <vue文件路径>` 不做任何代码迁移——不把 `.vue` 里现有的逻辑搬到新文件,不推测同事想写什么内容。只做三件事:建空文件、加一行 import、(如需要)确认引用方式,仅此而已。
## 命令形式
- `/update-ui <vue文件路径>`**搭建协作脚手架**:在同目录新建空的 `ui-{组件名}.ts``.vue``<script setup>` 里加一行 `import * as ui from "./ui-{组件名}.ts"`
- `/update-ui <组件名>``/update-ui <组件名> 合并`**合并回收**:把对应的 `ui-{组件名}.ts` 拼回 `.vue``<script setup>`,删除 `ui-*.ts` 文件和 import 那一行。
- `/update-ui 全部合并` — 扫描整个仓库里所有 `ui-*.ts` 文件(含 `packages/*/src/``packages/site/`),按命名约定 `ui-{组件名}.ts``{组件名}.vue` 逐一找到配对的 `.vue` 文件,全部合并。
## 关键技术约束(硬性,不可违反)
- **`<script setup>` 不允许包含任何 `export` 语句**——这是 Vue SFC 编译器的硬性限制(`<script setup> cannot contain ES module exports`),合并回收时如果 `ui-*.ts` 里的内容含 `export`,必须先剥离 `export` 关键字再拼入 `.vue`
- **模板访问同事导出的内容,必须通过 `import * as ui from "..."` 这种命名空间导入**,不能用具名 `import { a, b } from "..."`。原因:具名导入要求每次同事新增变量,接口开发者都要跟着改这一行 import 列表,产生新的冲突点;命名空间导入只需要写一次,之后模板里用 `ui.新变量名` 即可访问,`.vue` 侧完全不用再碰。
- `<script setup>` 顶层的 `import` 绑定会自动暴露给 `<template>`,不需要额外 `export`——这是 `<script setup>` 的标准行为。
## 命名约定
- 文件名固定为 `ui-{vue文件的 basename(不含扩展名)}.ts`,与 `.vue` 文件放在同一目录。
- 例:`profile-view.vue``ui-profile-view.ts`
- 模板里引用同事写的内容一律带 `ui.` 前缀(如 `ui.someComputed``ui.onSomeClick`),因为是通过命名空间导入的。
- 合并回收后(`ui.` 前缀自然消失,变回直接引用),如果发现导出名和 `<script setup>` 里已有变量重名,见下方合并流程的冲突处理。
## 第一步:搭建协作脚手架(`/update-ui <vue文件路径>`)
1. 读取目标 `.vue` 文件全文,确认 `<script setup>` 的现状(不需要分析要挪什么,只是确认起始位置和已有 import 列表)。
2. 如果同目录已存在同名 `ui-{组件名}.ts`:询问用户是要覆盖重建(清空)还是终止,不擅自覆盖。
3. 创建 `ui-{组件名}.ts`:只写一段简短的文件头注释说明用途和命名约定(见下方模板),**不写任何具体业务逻辑**,也不迁移 `.vue` 里的任何现有代码。
```ts
/**
* UI 层协作文件 —— 由 UI/前端交互同事维护
* 通过 {组件名}.vue 里的 `import * as ui from "./ui-{组件名}.ts"` 暴露给 <template> 使用
* 模板里通过 ui.xxx 引用这里导出的内容
*/
```
4. 修改原 `.vue` 文件:在 `<script setup>` 的 import 区域末尾加一行:
```ts
import * as ui from "./ui-{组件名}.ts"
```
不改动 `<script setup>` 的其余任何内容,也不改动 `<template>`
5. 完成后告知用户:脚手架已就绪,同事可以直接在 `ui-{组件名}.ts` 里写任意 UI 逻辑并 `export`,然后在 `<template>` 里用 `ui.xxx` 引用。
## 第二步:合并回收(`/update-ui <组件名> 合并` 或 `/update-ui 全部合并`)
1. 定位配对文件:
- 指定组件名:拼出 `ui-{组件名}.ts``{组件名}.vue`,确认两者都存在。
- `全部合并`:用 Glob 找仓库里所有 `ui-*.ts`,按文件名反推 `{组件名}.vue`,逐一配对;找不到配对 `.vue` 的,跳过并在最后报告。
2. 读取 `ui-{组件名}.ts``{组件名}.vue` 全文。
3. 检查命名冲突:把 `ui-*.ts` 顶层 `export` 的变量/函数名,和 `.vue``<script setup>` 里已有的顶层变量名(含 import 别名)做比对。
- 有重名:**停止合并该文件**,把冲突的变量名列出来报告给用户,不做任何修改,等用户决定怎么处理(不做静默重命名)。
- 无重名:继续。
4. 执行合并:
- 删除 `.vue` 里的 `import * as ui from "./ui-{组件名}.ts"` 那一行。
-`ui-{组件名}.ts` 的内容拼接进 `<script setup>`(位置放在原有内容之后),**去掉每个 `export` 关键字**`<script setup>` 不允许 export),其余代码原样保留。
-`<template>` 里所有 `ui.xxx` 的引用,去掉 `ui.` 前缀,改为直接引用 `xxx`
- 删除 `ui-{组件名}.ts` 文件。
5. `全部合并` 执行完后,汇总报告:成功合并了哪些文件、因命名冲突跳过了哪些文件(附冲突变量名清单)。
6. 合并完成后跑一次 `pnpm lint:fix`,确保 import 顺序与格式符合 `@antfu/eslint-config` 规范。
## 注意事项
- 拆分(搭脚手架)阶段绝不臆测或代写同事的 UI 逻辑,也不搬运 `.vue` 里的现有代码——这是同事自己的工作内容。
- 合并阶段是纯搬运+去 export 前缀操作,不允许顺带重构、改逻辑、加功能。
- 合并是有损操作(涉及文件删除),执行前按常规安全原则处理:如果 `ui-*.ts` 里有明显的 TODO 或未完成的函数体,先跟用户确认是否真的要合并,而不是默默执行。
......@@ -20,6 +20,12 @@ build/
*.swo
*~
# Claude Code 本地个人配置(团队共享配置在 .claude/settings.json)
.claude/settings.local.json
# UniApp 小程序打包产物
unpackage/
# OS
.DS_Store
Thumbs.db
......
# 项目架构与开发规范
> 本仓库是**前端 Monorepo 模板工程**。派生新项目后请先完成「零、模板初始化」。
## 零、模板初始化
派生仓库后按顺序完成:
1. **替换占位符**:全仓搜索 `TODO:`,逐项替换为项目实际值。主要集中在:
- `packages/*/src/config/index.ts``appName``rootEntrance``domainService.subProjectName` / `serviceName``shareConfig`
- `packages/mini/src/manifest.json``name``appid``mp-weixin.appid`
- `packages/site/config/index.ts``SITE_NAME`
- `packages/admin/index.html``packages/web/index.html``<title>`
2. **填写环境变量**:各包 `.env.development` / `.env.test` / `.env.staging` / `.env.production` 中的值在模板里全部为空,至少需要填 `VITE_APP_UNIPLAT``VITE_APP_CLIENT_ID``site` 包为 `NUXT_PUBLIC_API_BASE_URL`)。
3. **裁剪包**:删除不需要的 `packages/*`,或用 `pnpm new:project <name> [nuxt|vue]` 新增。删包后重新 `pnpm install` 以刷新 commitlint scope 白名单。
4. **更新文档**:改写 `CLAUDE.md` 与本文件中的项目描述。
> ⚠️ 不要把业务域名、appid、client secret 提交回模板仓库。所有环境相关值一律走 `.env.*`,代码里只从 `config` 读取。
---
## 一、项目结构
```
common-web/
frontend-hub/
├── common/ # 公共代码库(跨项目复用)
│ ├── utils/ # 纯工具函数、常量、类型定义
│ ├── vue-kit/ # Vue 相关代码(组件、组合式函数、指令
│ └── sdk/ # 统一业务平台 SDK 封装(uniplat-sdk + HTTP 适配
│ ├── utils/ # 纯工具函数、常量、类型定义(@common/utils)
│ ├── vue-kit/ # Vue 相关代码(组合式函数、组件、指令)(@common/vue-kit
│ └── sdk/ # 统一业务平台 SDK 封装(@common/sdk
├── packages/ # 具体项目
│ ├── official-site-web/ # 官网项目(Nuxt 3 SSR)
│ ├── partner-mp/ # 合伙人小程序(UniApp)
│ └── qqqf-mp/ # 亲亲企服小程序(UniApp,理想架构模板)
│ ├── admin/ # 管理后台(Vue 3 + Vite SPA)
│ ├── web/ # H5 / 客户端(Vue 3 + Vite SPA)
│ ├── mini/ # 小程序(UniApp)
│ └── site/ # 官网(Nuxt 3 SSR)
├── scripts/ # 构建脚本(不参与 lint/format)
├── .changeset/ # Changesets 版本管理配置
├── .claude/ # Claude Code 配置
│ ├── settings.json # 团队共享配置(权限白名单、MCP)
│ └── skills/ # 项目 skills(req-diff / api-diff-sync / figma-restore / update-ui)
├── .husky/ # Git 钩子
├── .vscode/ # VS Code 配置
│ └── settings.json # 编辑器格式化配置
├── eslint.config.mjs # 根 ESLint 配置(@antfu/eslint-config)
├── .lintstagedrc # lint-staged 配置
├── commitlint.config.mjs # commitlint 配置
├── commitlint.config.mjs # commitlint 配置(scope 白名单自动生成)
├── CLAUDE.md # Claude Code 速查索引
└── package.json # 根依赖与脚本
```
......@@ -30,20 +54,20 @@ common-web/
### `packages/` — 具体项目
存放**独立可运行** Web 项目。每个项目有自己的 `package.json``.env` 文件、构建配置。
存放**独立可运行**的项目。每个项目有自己的 `package.json``.env` 文件、构建配置。
### `scripts/` — 辅助脚本
存放项目辅助脚本,不参与代码检查(已在 `eslint.config.mjs` 中忽略)。
**脚本列表**
| 脚本 | 说明 |
|------|------|
| `dev-project.ts` | 启动指定项目的开发服务器 |
| `dev-project.ts` | 启动指定项目的开发服务器(自动识别小程序项目并追加平台参数) |
| `build-project.ts` | 构建指定项目 |
| `git-workflow.ts` | Git 工作流自动化脚本 |
| `build-changed.ts` | 只构建自上次发布以来有变更的包 |
| `git-workflow.ts` | Git 工作流自动化 |
| `new-project.ts` | 一键创建新项目 |
| `generate-commitlint-config.ts` | 根据 workspace 包名生成 commitlint scope 白名单(postinstall 自动执行) |
**使用方式**
......@@ -54,24 +78,23 @@ pnpm dev:project
# 交互式构建(推荐)
pnpm build:project
# 会引导选择项目、环境、平台
# 参数化调用
pnpm dev:project --project=official-site-web --env=test
pnpm build:project --project=partner-mp --env=production --platform=mp-weixin
pnpm dev:project --project=admin --env=test
pnpm build:project --project=mini --env=production --platform=mp-weixin
# 参数说明
# --project=<name> 项目名称
# --project=<name> 项目名称(packages/ 下的目录名)
# --env=<env> 环境:test/staging/production
# --platform=<plat> 平台(仅小程序需要):mp-weixin/h5/mp-alipay 等
# -p <plat> 平台简写参数
# 创建新项目
pnpm new:project admin-web vue
pnpm new:project mobile vue
# Git 工作流
pnpm git:dev-start official-site-web # 创建/切换到项目开发分支
pnpm git:dev-done official-site-web # 合并项目分支到 dev
pnpm git:dev-start admin # 创建/切换到项目开发分支
pnpm git:dev-done admin # 合并项目分支到 dev
pnpm git:deploy-staging # 部署到预发环境
pnpm git:deploy-release v1.0.0 # 部署到生产环境
pnpm git:hotfix-start # 创建/切换到 hotfix 分支
......@@ -91,16 +114,17 @@ pnpm git:refactor-done # 完成 refactor
- 纯函数(无副作用、无框架依赖)
- 通用常量
- 通用类型定义
- 数据格式化工具、加密工具、正则工具等
**示例**
**目录结构**
```
common/utils/src/
├── format.ts # 日期/数字格式化
├── crypto.ts # 加密/解密
├── regex.ts # 正则表达式
├── constants.ts # 通用常量
├── common.ts # 类型判断、数组/对象操作、防抖节流、深拷贝等
├── date-time.ts # 日期时间格式化与计算
├── mask.ts # 脱敏(手机号、身份证、银行卡、姓名等)
├── money.ts # 金额格式化与解析
├── regex.ts # 正则表达式与校验函数(isPhone、isIdCard 等)
├── constants.ts # 通用常量(时间单位、SMS_COUNTDOWN、APP_ENV 等)
└── index.ts # 统一导出
```
......@@ -109,11 +133,9 @@ common/utils/src/
- ✅ 可以放:不依赖 Vue/React 等框架的纯 JS/TS 代码
- ❌ 不能放:Vue 组件、hooks、指令、响应式逻辑
- ❌ 不能放:业务相关代码(如登录逻辑、API 调用)
- ❌ 不能放:依赖 `window` / `uni` 等平台全局对象的代码
**为什么**
- 纯工具函数可以在任何项目(Web、Node.js)中复用
- 与框架解耦,降低依赖,提升可测试性
**为什么**:纯工具函数可以在任何项目(Web、小程序、Node.js)中复用,与框架解耦,降低依赖,提升可测试性。
---
......@@ -121,36 +143,31 @@ common/utils/src/
**存放内容**
- Vue 组件(通用 UI 组件)
- Vue 组合式函数(Composables)
- Vue 组件(通用 UI 组件)
- Vue 自定义指令
**目录结构**
```
common/vue-kit/src/
├── components/ # 通用组件(如 Button、Modal、Loading)
├── composables/
│ ├── use-loading.ts # 加载状态管理(平台无关)
│ ├── use-window-width.ts # 窗口宽度(仅 Web)
│ └── index.ts
├── composables/ # 组合式函数(如 useLogin、useAuth)
│ ├── index.ts
│ └── login-controller.ts
└── directives/ # 自定义指令(如 v-permission、v-lazy)
└── index.ts
└── index.ts
```
**边界条件**
- ✅ 可以放:跨项目复用的 Vue 组件、hooks、指令
- ✅ 可以放:依赖 Vue 响应式系统的逻辑(`ref``computed``watch`
- ✅ 可以放:依赖 Vue 响应式系统的逻辑(`ref``computed``watch`
- ❌ 不能放:业务强相关的组件(如特定页面的表单)
- ❌ 不能放:纯工具函数(应放 `@common/utils`
- ❌ 不能放:平台特定代码(如浏览器特定 API 封装)
**为什么**
> 平台特定的 composable(如依赖 `window` 或 `uni.*`)请在文件注释里标注适用平台,跨端项目引入前确认。
- Vue 相关代码需要 Vue 作为 peer dependency,与纯工具函数分离
- 统一升级 Vue 版本时只需更新一个包
- 按需引入:`import { useLogin } from '@common/vue-kit/composables'`
**按需引入**`import { useLoading } from "@common/vue-kit/composables"`
---
......@@ -159,444 +176,304 @@ common/vue-kit/src/
**存放内容**
- uniplat-sdk 的封装与扩展
- 小程序 HTTP 适配器(uni.request → axios 接口)
- Token 管理、图片处理等通用 SDK 能力
- HTTP 适配器(`uniAdapter` 小程序 / `webAdapter` 浏览器)
- Token 管理、图片 URL 构建、监控上报
- 通用登录/验证码服务工厂
**目录结构**
```
common/sdk/src/
├── index.ts # 统一导出
├── sdk-core.ts # SDK 核心类(SdkCore)
├── sdk-types.ts # SDK 类型定义
├── http/
│ └── adapter.ts # uni.request 适配器(适配 axios 接口)
├── auth/
│ ├── auth-handlers.ts # 认证处理器
│ └── token-manager.ts # Token 管理
└── utils/
└── image-builder.ts # 图片 URL 构建工具
├── index.ts # 统一导出(不含小程序模块)
├── adapters/
│ ├── uni-adapter.ts # uni.request 适配器(小程序)
│ ├── web-adapter.ts # XMLHttpRequest 适配器(浏览器)
│ └── index.ts
├── core/
│ ├── sdk.ts # SDK 核心类(Sdk / SdkCore)、SdkConfig、Environment
│ ├── token-manager.ts # Token 管理(存储、校验、刷新)
│ ├── image-builder.ts # 图片/文件 URL 构建
│ ├── web-monitor.ts # 错误监控上报
│ └── index.ts
├── mini-program/ # 小程序专属(微信登录、更新管理、文件操作等)
└── services/
├── login-service.ts # createLoginService(core, clientId)
└── verify-service.ts # createVerifyService(core, clientId)
```
**核心导出**
| 导出项 | 说明 |
|--------|------|
| `SdkCore` | SDK 核心类,提供 domainService 请求、Token 解码等 |
| `uniAdapter` | uni.request 适配器,用于小程序环境 |
| `PassportTokenController` | Token 控制器 |
| `decodeToken` | JWT Token 解码工具 |
| `Sdk` / `SdkCore` | SDK 核心类,提供 `domainService` 请求、Token 解码等 |
| `SdkConfig` | SDK 配置类型(`uniplatApi``rootEntrance``clientId``domainService``passportUrl` 等) |
| `Environment` | 环境枚举(`dev` / `test` / `stage` / `prod`),与 `VITE_APP_ENV` 一致 |
| `ClientId` | 客户端标识类型(`string`,由环境变量注入,SDK 不内置具体取值) |
| `uniAdapter` / `webAdapter` | HTTP 适配器 |
| `TokenManager` | Token 管理(`hasToken` / `saveToken2Storage` / `clearToken` / `setupStorage`) |
| `createLoginService` / `createVerifyService` | 登录与验证码服务工厂 |
| `buildImage` / `buildFilePath` | 图片/文件 URL 构建 |
| `webMonitor` | 监控上报(需先 `setupEndpoint` 配置地址,未配置则不上报) |
**SDK 调用方式**
**导入约定**(重要)
```typescript
// 业务 API 文件中直接使用
import { sdk } from "./http"
export async function getAccountInfo() {
return await sdk.core
.domainService("welfare_bean", "reward_account_api", "account_info")
.request("get", {})
.then((r: any) => r as AccountInfo)
.catch((err: any) => {
console.log(`err: ${err}`)
return {} as AccountInfo
})
}
// ✅ Web 项目(admin / web / site)
import { Sdk, TokenManager, webAdapter } from "@common/sdk"
// ✅ 小程序专属能力单独从子路径引入,避免 Web 项目引入 uni.* 类型
import { chooseImage } from "@common/sdk/mini-program"
```
**边界条件**
- ✅ 可以放:uniplat-sdk 封装、HTTP 适配、Token 管理
- ❌ 不能放:业务强相关的逻辑(如登录流程、页面跳转)
- ✅ 可以放:uniplat-sdk 封装、HTTP 适配、Token 管理、通用服务工厂
- ❌ 不能放:业务强相关逻辑(如具体登录流程、页面跳转)
- ❌ 不能放:Vue 相关代码(应放 `@common/vue-kit`
-**不能写死任何域名、appid、client id、密钥**——需要环境相关值就加到 `SdkConfig` 上,由各项目 `config` 注入
- ⚠️ `core/` 下的代码要跨端安全:访问 `window` 必须先 `typeof window !== "undefined"` 判断
---
## 四、packages 项目规范
### 4.1 Nuxt 项目目录结构(SSR 项目,如官网
### 4.1 Vue 3 SPA 项目(`admin` / `web`
```
packages/official-site-web/
├── pages/ # 页面(Nuxt 文件路由)
├── components/ # 组件(Nuxt 自动导入)
│ ├── business/ # 业务组件
│ └── layout/ # 布局组件
├── composables/ # 组合式函数(Nuxt 自动导入)
├── api/ # API 层(HTTP 调用)
│ ├── request.ts # axios 实例封装
│ └── xxx.ts # 按模块划分的接口
├── services/ # 服务层(业务逻辑编排)
├── stores/ # 状态管理(Pinia)
├── utils/ # 工具函数(Nuxt 自动导入)
├── config/ # 项目配置(常量、环境配置)
├── types/ # 类型定义
├── assets/ # 静态资源(Nuxt 约定)
├── middleware/ # Nuxt 中间件(Nuxt 约定)
├── plugins/ # Nuxt 插件(Nuxt 约定)
├── nuxt.config.ts # Nuxt 配置
packages/admin/
├── src/
│ ├── pages/ # 页面(路由入口)
│ │ ├── index.vue # 首页
│ │ └── login/
│ │ └── index.vue # 登录页
│ ├── components/ # 组件
│ │ ├── business/ # 业务组件
│ │ └── layout/ # 布局组件
│ ├── composables/ # 组合式函数(转出 @common/vue-kit 的能力)
│ ├── api/ # API 层
│ │ ├── request.ts # SDK 初始化(webAdapter),导出 sdk / loginService / verifyService
│ │ ├── auth.ts # 登录相关接口
│ │ └── index.ts # 统一导出
│ ├── services/ # 服务层
│ │ ├── auth-service.ts # 登录/登出编排(handleXxx)
│ │ └── index.ts
│ ├── stores/ # 状态管理(Pinia)
│ │ ├── auth.ts
│ │ └── index.ts
│ ├── utils/ # 项目私有工具
│ │ ├── toast.ts # Web Toast
│ │ └── index.ts # 同时转出 @common/utils
│ ├── config/ # 配置中心(唯一读取 import.meta.env 的地方)
│ │ └── index.ts
│ ├── types/ # 类型定义
│ ├── router/ # 路由配置(Vue Router)
│ ├── assets/styles/ # 全局样式(web 包)
│ ├── App.vue
│ └── main.ts
├── .env.development / .env.test / .env.staging / .env.production
├── index.html
├── env.d.ts # 环境变量类型声明
├── vite.config.ts # @ 别名指向 ./src
├── package.json
└── tsconfig.json
```
### 4.2 纯 Vue3 项目目录结构(SPA 项目,如后台管理、客户端应用)
```plaintext
packages/admin-web/
├── pages/ # 页面(路由入口)
├── components/ # 组件
│ ├── business/ # 业务组件
│ └── layout/ # 布局组件
├── composables/ # 组合式函数
├── api/ # API 层(HTTP 调用)
│ ├── request.ts # axios 实例封装
│ └── xxx.ts # 按模块划分的接口
├── services/ # 服务层(业务逻辑编排)
├── stores/ # 状态管理(Pinia)
├── utils/ # 工具函数
├── config/ # 项目配置(常量、环境配置)
├── types/ # 类型定义
├── assets/ # 静态资源
├── router/ # 路由配置(Vue Router)
├── App.vue
├── main.ts
├── vite.config.ts
├── package.json
└── tsconfig.json
```
`admin``web` 结构一致,差异只在 UI 方案:`admin` 用 Ant Design Vue,`web` 用 UnoCSS + SCSS。
### 4.3 UniApp 小程序项目目录结构(理想架构,如 qqqf-mp
### 4.2 UniApp 小程序项目(`mini`
```
packages/qqqf-mp/
packages/mini/
├── src/
│ ├── pages/ # 页面(路由入口
│ └── home/
│ │ └── index.vue # 首页
├── components/ # 组件(公共 UI 组件
│ │ └── index.ts # 统一导出
├── composables/ # 组合式函数(复用逻辑)
│ └── index.ts # 统一导出
│ ├── api/ # API 层(SDK 初始化 + HTTP 调用)
│ │ ├── http.ts # SDK 初始化,导出 sdk 实例
│ │ └── home.ts # 业务接口(使用 sdk.core.domainService().request())
├── services/ # 服务层(业务逻辑编排)
│ └── index.ts # 统一导出
│ ├── stores/ # 状态管理(Pinia)
├── utils/ # 工具函数(项目私有)
│ └── index.ts # 统一导出
├── config/ # 项目配置(纯配置文件
│ │ └── index.ts # 环境配置、常量、白名单、CoreEnvir
│ ├── App.vue # 根组件
│ ├── main.ts # 入口文件
│ ├── manifest.json # UniApp 应用配置
└── pages.json # 页面路由配置
├── .env.development # 开发环境
├── .env.test # 测试环境
├── .env.staging # 预发环境
├── .env.production # 生产环境
│ ├── pages/ # 页面(home / login / profile / settings / verify / password
├── components/
│ │ ├── common/
│ │ └── page-layout.vue # 通用页面布局(auto / flex 两种模式
│ │ ├── business/
│ └── index.ts
├── composables/ # useToast(uni.showToast)、转出 useLoading
│ ├── api/ # API 层
│ │ ├── auth.ts # 登录相关接口
│ │ ├── home.ts # 首页数据
│ └── index.ts
├── services/ # 服务层(登录/登出编排)
│ ├── stores/ # Pinia
│ └── useUserInfo.ts
├── utils/ # 项目私有工具
│ ├── sdk.ts # SDK 懒加载单例(initSdk / getSdk / getLoginService / getVerifyService
│ │ └── index.ts # 同时转出 @common/utils
│ ├── config/ # 配置中心(MpConfig、CoreEnvir、noNeedAuthPages)
│ ├── assets/styles/ # base.scss / common.scss / style.scss
│ ├── App.vue # onLaunch 时恢复 Token 登录态
├── main.ts # createSSRApp + Pinia + initSdk
│ ├── manifest.json # UniApp 应用配置(name / appid)
│ ├── pages.json # 页面路由配置
│ └── env.d.ts
├── .env.development / .env.test / .env.staging / .env.production
├── vite.config.ts
├── package.json
└── tsconfig.json
```
**关键架构说明**
| 目录 | 职责 | 说明 |
|------|------|------|
| `config/index.ts` | 纯配置文件 | 环境配置、常量、白名单、CoreEnvir 工具类 |
| `api/http.ts` | SDK 初始化 | 继承 SdkCore,传入 config,导出 sdk 实例 |
| `api/xxx.ts` | 业务接口 | 使用 `sdk.core.domainService().request()` 调用 |
| `services/` | 业务逻辑编排 | 组合多个 API,操作 store,处理复杂流程 |
| `composables/` | 组合式函数 | 复用逻辑(如 useAuth、useLoading) |
| `components/` | 公共组件 | 跨页面复用的 UI 组件 |
| `utils/` | 工具函数 | 项目私有工具(格式化、校验等) |
| `stores/` | 状态管理 | Pinia store,按业务模块拆分 |
**SDK 调用标准模式**
**SDK 初始化流程**
```typescript
// api/http.ts - SDK 初始化
import { SdkCore } from "@common/sdk"
import { config } from "@/config"
class Sdk extends SdkCore {
constructor() {
super(config)
}
}
const sdk = new Sdk()
export { sdk }
```
```typescript
// api/home.ts - 业务接口
import { sdk } from "./http"
export async function fetchHomeData() {
return await sdk.core
.domainService("qqqf", "api", "getHomeData")
.request("get", {})
.then((r: any) => r as HomeData)
.catch((err: any) => {
console.log(`err: ${err}`)
return {} as HomeData
})
}
main.ts → createApp() → initSdk()(设置 UniStorage + 创建 Sdk/服务实例)
App.vue → onLaunch() → TokenManager.hasToken() → sdk.core.loginByToken()
```
**开发脚本**
```bash
# 开发(默认微信小程序)
pnpm dev:project --project=qqqf-mp
# 构建(默认微信小程序)
pnpm build:project --project=qqqf-mp
**新增页面**
# 指定环境
pnpm build:project --project=qqqf-mp --env=production
```
1.`src/pages/<feature>/index.vue` 创建页面
2.`src/pages.json``pages` 数组中追加路由配置
3. 无需注册 Vue Router(UniApp 使用基于 `pages.json` 的约定式路由)
**平台支持**
**平台与环境**
| 脚本 | 说明 |
|------|------|
| `pnpm dev` | 开发微信小程序(默认) |
| `pnpm dev` / `pnpm dev:mp-weixin` | 开发微信小程序 |
| `pnpm dev:mp-alipay` | 开发支付宝小程序 |
| `pnpm dev:h5` | 开发 H5 |
| `pnpm build` | 构建微信小程序(默认) |
| `pnpm build:mp-alipay` | 构建支付宝小程序 |
| `pnpm build:h5` | 构建 H5 |
**环境模式**
| 模式 | 对应文件 | 说明 |
|------|---------|------|
| 默认 | `.env.development` | 开发环境 |
| `--mode test` | `.env.test` | 测试环境 |
| `--mode staging` | `.env.staging` | 预发环境 |
| `--mode production` | `.env.production` | 生产环境 |
### 4.4 UniApp 小程序项目目录结构(partner-mp,历史架构)
| `pnpm build` / `pnpm build:mp-weixin` | 构建微信小程序 |
| 追加 `:test` / `:staging` / `:production` | 切换环境 |
> ⚠️ partner-mp 为历史架构,新项目建议参照 qqqf-mp 理想架构
### 4.3 Nuxt 3 项目(`site`)
```
packages/partner-mp/
├── src/
│ ├── pages/ # 页面(路由入口)
│ ├── components/ # 组件
│ │ ├── business/ # 业务组件
│ │ └── common/ # 通用组件
│ ├── composables/ # 组合式函数
│ ├── api/ # API 层(HTTP 调用)
│ ├── services/ # 服务层(业务逻辑编排)
│ ├── stores/ # 状态管理(Pinia)
│ ├── utils/ # 工具函数
│ ├── config/ # 项目配置
│ ├── App.vue
│ ├── main.ts
│ ├── manifest.json # UniApp 应用配置
│ └── pages.json # 页面路由配置
├── public/ # 静态资源
├── .env.development # 开发环境
├── .env.test # 测试环境
├── .env.staging # 预发环境
├── .env.production # 生产环境
├── vite.config.ts
packages/site/
├── pages/ # 页面(Nuxt 文件路由)
├── components/ # 组件(Nuxt 自动导入)
├── composables/ # 组合式函数(Nuxt 自动导入)
├── api/ # API 层
│ ├── request.ts # axios 实例(baseURL 取自 runtimeConfig)
│ └── index.ts
├── services/ # 服务层
├── stores/ # Pinia
├── utils/ # 工具函数(Nuxt 自动导入)
├── config/ # 项目配置(SITE_NAME、TOKEN_STORAGE_KEY 等)
├── types/ # 类型定义
├── nuxt.config.ts # 含 runtimeConfig.public(NUXT_PUBLIC_* 覆盖)
├── app.vue
├── package.json
└── tsconfig.json
```
**开发脚本**
```bash
# 开发(默认微信小程序)
pnpm dev:project --project=partner-mp
# 构建(默认微信小程序)
pnpm build:project --project=partner-mp
```
**平台支持**
| 脚本 | 说明 |
|------|------|
| `pnpm dev` | 开发微信小程序(默认) |
| `pnpm dev:mp-alipay` | 开发支付宝小程序 |
| `pnpm dev:h5` | 开发 H5 |
| `pnpm build` | 构建微信小程序(默认) |
| `pnpm build:mp-alipay` | 构建支付宝小程序 |
| `pnpm build:h5` | 构建 H5 |
**环境模式**
| 模式 | 对应文件 | 说明 |
|------|---------|------|
| 默认 | `.env.development` | 开发环境 |
| `--mode test` | `.env.test` | 测试环境 |
| `--mode staging` | `.env.staging` | 预发环境 |
| `--mode production` | `.env.production` | 生产环境 |
Nuxt 项目通过 `runtimeConfig` 而非 `import.meta.env` 读取环境变量,`.env.*` 中用 `NUXT_PUBLIC_` 前缀。别名用 Nuxt 内置的 `~/`
### 4.5 项目内各层职责
### 4.4 项目内各层职责
| 目录 | 职责 | 示例 |
| -------------- | ------------------------ | --------------------------------- |
| `pages/` | 页面入口,路由组装组件 | index.vue、about.vue |
| `components/` | 页面内复用的 UI 组件 | Header、Footer、Card |
| `composables/` | 组合式函数,复用逻辑 | useFetch、useAuth |
| `api/` | 封装统一请求方法,按模块导出接口 | request.ts、user.ts |
| `services/` | 业务逻辑编排,组合多个 API | auth.ts(登录重定向)、upload.ts |
| `stores/` | 全局状态管理 | pinia store |
| `utils/` | 项目私有工具函数 | 格式化工具 |
| `config/` | 项目配置 | 环境配置、常量 |
| `pages/` | 页面入口,组装组件与交互 | index.vue、login/index.vue |
| `components/` | 页面内复用的 UI 组件 | page-layout、Header、Card |
| `composables/` | 组合式函数,复用响应式逻辑 | useLoading、useToast |
| `api/` | 封装统一请求方法,按模块导出接口 | request.ts / sdk.ts、auth.ts |
| `services/` | 业务逻辑编排,组合多个 API | auth-service.ts |
| `stores/` | 全局状态管理 | Pinia store |
| `utils/` | 项目私有工具函数 | toast.ts |
| `config/` | 项目配置,唯一读取环境变量的地方 | config、noNeedAuthPages |
| `types/` | 类型定义 | 接口响应类型、业务类型 |
| `router/` | 路由配置(纯 Vue3 项目) | 路由守卫、路由配置 |
| `middleware/` | Nuxt 中间件 | 鉴权中间件 |
| `plugins/` | Nuxt 插件 | 全局插件注册 |
| `router/` | 路由配置(SPA 项目) | 路由守卫、路由配置 |
### 4.6 `api/` 与 `services/` 职责划分
### 4.5 `api/` 与 `services/` 职责划分
**`api/` — 统一请求方法封装层**
**`api/` — 统一请求方法封装层**
核心职责是**封装统一的 HTTP 请求方法**,按模块导出接口
核心职责是**封装统一的请求方法**,按模块导出接口函数。不包含业务逻辑,不操作 store,不跳转路由
**UniApp 小程序项目(SDK 模式)**
- `http.ts`:继承 SdkCore,传入 config,导出 sdk 实例
- `xxx.ts`:只导出业务接口函数,调用 `sdk.core.domainService().request()` 方法
- 不包含业务逻辑,不操作 store,不跳转路由
SDK 项目(`admin` / `web` / `mini`):
```typescript
// api/http.ts — SDK 初始化(UniApp 小程序)
import { SdkCore } from "@common/sdk"
// api/request.ts(Web)— SDK 初始化
import { Sdk, TokenManager, webAdapter } from "@common/sdk"
import { createLoginService } from "@common/sdk/services/login-service"
import { config } from "@/config"
class Sdk extends SdkCore {
constructor() {
super(config)
}
}
const sdk = new Sdk()
export { sdk }
export const sdk = new Sdk(config, webAdapter)
export const loginService = createLoginService(sdk.core, sdk.clientId)
```
```typescript
// api/user.ts — 业务接口函数(UniApp 小程序)
import { sdk } from "./http"
export function getUserInfo() {
return sdk.core
.domainService("welfare_v2", "smart_app_api", "user_info")
.request("get", {})
.then((r: any) => r as UserInfo)
.catch((err: any) => {
console.log(`err: ${err}`)
return {} as UserInfo
})
}
// utils/sdk.ts(小程序)— 懒加载单例,需先注入 UniStorage
export function initSdk() { /* TokenManager.setupStorage(new UniStorage()) + new Sdk() */ }
export function getSdk() { /* ... */ }
```
**Web 项目(axios 模式)**
- `request.ts`:创建 axios 实例 + 拦截器 + 导出统一请求方法(get、post、put、delete)
- `xxx.ts`:只导出业务接口函数,调用 `request` 方法
- 不包含业务逻辑,不操作 store,不跳转路由
```typescript
// api/request.ts — 统一请求方法封装(Web 项目)
import axios from 'axios'
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
// 请求拦截器:注入 token
request.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:统一错误处理
request.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// token 过期,跳转登录
}
return Promise.reject(error)
},
)
export { request }
// api/auth.ts — 业务接口函数
import { config } from "@/config"
import { getSdk } from "@/utils/sdk"
export function sendSmsCode(mobile: string, imgCode: string): Promise<void> {
return getSdk().domainServicePost<any, any, void>(
"send_verifycode",
{ data: { mobile, img_code: imgCode, client_id: config.clientId } },
true, // 匿名接口
"uniplat_base",
"anonymous/system.user",
config,
)
}
```
```typescript
// api/user.ts — 业务接口函数(Web 项目)
import { request } from './request'
export function getUserInfo() {
return request.get<UserInfo>('/api/user/info')
}
axios 项目(`site`):
export function updateUser(data: UserInfo) {
return request.put('/api/user', data)
}
```typescript
// api/request.ts — axios 实例 + 拦截器
// api/xxx.ts — 只导出接口函数,调用 request
```
**`services/` — 服务层(业务逻辑编排)**
**`services/` — 服务层(业务逻辑编排)**
- 组合多个 API 调用 + 业务逻辑
- 可以操作 store、跳转路由
- 处理复杂业务流程(如登录重定向、token 刷新)
- 可以操作 store、跳转路由、弹提示
- 函数命名 `handleXxx`
```typescript
// services/auth.ts
import { getUserInfo } from '@/api/user'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
export async function handleLoginRedirect() {
const userStore = useUserStore()
const router = useRouter()
await userStore.fetchUserInfo()
router.push(userStore.redirectUrl || '/')
// services/auth-service.ts
export async function handleSmsLogin(mobile: string, code: string): Promise<boolean> {
try {
const result = await smsLogin(mobile, code)
saveLoginResult(result) // 持久化 Token + 同步 SDK 登录态 + 写 store
success("登录成功")
return true
} catch {
error("登录失败")
return false
}
}
```
### 4.7 代码放置决策树
**登录态必须做全三件事**(已封装在各包 `services/auth-service.ts`,不要绕过):
1. `TokenManager.saveToken2Storage(jwt)` — 持久化
2. `sdk.core.loginByToken({ token: jwt })` — 同步 SDK 登录态,否则后续请求不带 Token
3. 写入 store — 供 UI 读取
### 4.6 代码放置决策树
```
新代码应该放哪里?
├─ 是否跨项目复用?
│ ├─ 是 → 是否依赖 Vue?
│ ├─ 是 → 是否是 SDK/HTTP/Token 相关?
│ │ ├─ 是 → @common/sdk/
│ │ └─ 否 → 是否依赖 Vue?
│ │ ├─ 是 → @common/vue-kit/
│ │ └─ 否 → @common/utils/
│ │
│ ├─ 是 → 是否是 SDK/HTTP 相关?
│ │ └─ 是 → @common/sdk/
│ │
│ └─ 否 → 放在当前项目 packages/xxx/
│ │
│ ├─ 是页面? → pages/
│ ├─ 是 UI 组件? → components/
│ ├─ 是复用逻辑? → composables/
│ ├─ 是 HTTP 调用? → api/
│ │ ├─ UniApp 项目 → http.ts (SDK 初始化) + xxx.ts (业务接口)
│ │ └─ Web 项目 → request.ts (axios 封装) + xxx.ts (业务接口)
│ ├─ 是接口调用? → api/
│ │ ├─ SDK 项目 → request.ts/sdk.ts(初始化)+ xxx.ts(接口)
│ │ └─ axios 项目 → request.ts(封装)+ xxx.ts(接口)
│ ├─ 是业务逻辑编排? → services/
│ ├─ 是状态管理? → stores/
│ ├─ 是路由配置? → router/(纯 Vue3 项目)
│ ├─ 是路由配置? → router/(SPA 项目)
│ ├─ 是工具函数? → utils/
│ ├─ 是项目配置? → config/
│ └─ 是类型定义? → types/
......@@ -608,96 +485,96 @@ export async function handleLoginRedirect() {
### 5.1 核心技术
| 技术 | 用途 | 版本 |
| 技术 | 用途 | 使用包 |
| ------------ | -------------- | ------- |
| Nuxt 3 | 框架 | ^3.x |
| Vue 3 | UI 框架 | ^3.4.x |
| TypeScript | 类型系统 | ^5.x |
| UnoCSS | 原子化 CSS | ^0.58.x |
| Ant Design Vue | UI 组件库 | ^4.x |
| Pinia | 状态管理 | ^2.x |
| Vue 3 | UI 框架 | 全部 |
| TypeScript | 类型系统 | 全部 |
| Vite | 构建工具 | admin / web / mini |
| Nuxt 3 | SSR 框架 | site |
| UniApp | 跨端小程序框架 | mini |
| Pinia | 状态管理 | 全部 |
| Vue Router | 路由 | admin / web |
| Ant Design Vue | UI 组件库 | admin / site |
| UnoCSS | 原子化 CSS | web / site |
| SCSS | 样式预处理 | web / mini |
| uniplat-sdk | 业务平台 SDK | @common/sdk |
### 5.2 启动与构建
**根目录统一入口**(推荐):
```bash
pnpm dev:project --project official-site-web
pnpm build:project --project official-site-web
pnpm dev:project --project=admin
pnpm build:project --project=admin
```
**子包独立执行**
```bash
cd packages/official-site-web
pnpm run dev
pnpm run build
pnpm -F admin dev
pnpm -F admin build
pnpm -F admin type-check
```
### 5.3 环境变量
**环境命名**
| 环境 | 说明 | 对应分支 | 部署线 |
| 环境 | 说明 | 对应分支 | `VITE_APP_ENV` |
|------|------|---------|--------|
| `dev` | 开发环境,本地调试 | `dev` | - |
| `test` | 测试环境,QA 测试 | `dev` | test |
| `staging` | 预发环境,镜像验证 | `staging` | staging |
| `production` | 生产环境 | `release` | production |
| `dev` | 开发环境,本地调试 | `feature/*` | `dev` |
| `test` | 测试环境,QA 测试 | `dev` | `test` |
| `staging` | 预发环境,镜像验证 | `staging` | `stage` |
| `production` | 生产环境 | `release` | `prod` |
**环境文件**
**环境文件**位于各子包根目录,`.env.development``.env.test``.env.staging``.env.production`
位于各子包根目录:`.env.development``.env.test``.env.staging``.env.production`
> ⚠️ **不要在 `.env.*` 里设置 `NODE_ENV`**。Vite / Nuxt 会根据执行的命令自动设置(dev → `development`,build → `production`);手写 `NODE_ENV=production` 会导致 `pnpm dev:test` 之类的 dev server 报 “NODE_ENV=production is not supported in the .env file” 警告。
**环境变量示例**
**变量清单**(模板中值为空,需按项目填写)
```bash
# .env.development
APP_ENV=dev
NODE_ENV=development
# .env.test
APP_ENV=test
NODE_ENV=production
# .env.staging
APP_ENV=staging
NODE_ENV=production
# .env.production
APP_ENV=production
NODE_ENV=production
```
**主要差异**:各环境 API 地址不同,通过 `VITE_API_BASE_URL` 区分
| 变量 | 说明 | 必填 |
|------|------|------|
| `VITE_APP_ENV` | 运行环境标识(dev/test/stage/prod) | ✅ |
| `VITE_APP_LOG_ENV` | 日志环境(0=dev 1=prod 2=test 3=stage) | |
| `VITE_APP_UNIPLAT` | 统一业务平台 API 根地址 | ✅ |
| `VITE_APP_UNIPLAT_WEBSOCKET_URI` | WebSocket 地址 | |
| `VITE_APP_CLIENT_ID` | 客户端标识(后端分配) | ✅ |
| `VITE_APP_CLIENT_SECRET` | 客户端密钥 | |
| `VITE_APP_WWW_WORK_APP_URL` | 通行证站点地址 | |
| `VITE_APP_API_WORK_APP_URL` | 通行证 API 地址 | |
| `VITE_APP_API_WORK_ORG_URL` | 组织 API 地址 | |
| `VITE_APP_USER_CENTER` | 用户中心地址 | |
| `VITE_APP_PAY_URL` | 支付服务地址 | |
| `VITE_APP_H5_URL` / `VITE_APP_LAND_PAGE` | 配套 H5 / 静态资源根地址 | |
| `VITE_MP_APPID` / `VITE_MP_ACCOUNT_APPID` | 小程序 / 公众号 appid(mini) | |
| `VITE_MAP_KEY` | 地图 key(mini) | |
| `NUXT_PUBLIC_API_BASE_URL` | API 根地址(site 专用) | ✅ |
**约定**:业务代码不直接使用 `import.meta.env`,统一通过各包 `src/config/index.ts` 读取。新增变量时同步更新 `env.d.ts` 的类型声明
### 5.4 代码检查与格式化
本项目使用 `@antfu/eslint-config` 统一处理代码检查和格式化,无需 Prettier。
**代码风格配置**
- 4 空格缩进
- 双引号
- 不使用分号
- 支持 JSX
**代码风格**:4 空格缩进、双引号、不使用分号、支持 JSX。Vue 组件块顺序 `template → script → style`
```bash
pnpm lint # ESLint 检查
pnpm lint:fix # ESLint 检查并自动修复(包含格式化)
pnpm lint:fix # 检查并自动修复(包含格式化)
```
**VS Code 配置**
项目已提供 `.vscode/settings.json`,配置了保存时自动使用 ESLint 格式化。安装 VS Code ESLint 插件后即可生效。
**VS Code**:项目已提供 `.vscode/settings.json`,配置了保存时用 ESLint 格式化。安装 ESLint 插件后即可生效。
提交时自动执行 `lint-staged`(仅检查暂存文件)和 `commitlint`(检查 commit 信息格式)。
### 5.5 依赖管理
- **根目录**:ESLint(@antfu/eslint-config)、Husky、TypeScript 等通用工具
- **子包**:项目特定依赖(Nuxt、Vue、Ant Design Vue 等)
- **common 子包**:只声明 `peerDependencies`,不安装具体版本
- **根目录**:ESLint、Husky、Changesets、TypeScript 等通用工具 → `pnpm add -wD <pkg>`
- **子包**:项目特定依赖`pnpm -F <pkg> add <dep>`
- **common 子包**:只声明必要的 `dependencies`,Vue 类依赖用 `peerDependencies`
安装根目录依赖使用 `pnpm add -wD <package>`
`package.json``pnpm.overrides``axios` 锁定为 `^0.27.2`,这是 `uniplat-sdk` 内部适配器(`axios/lib/*` 深层引用)的要求,**不要升级**
### 5.6 按需引入
......@@ -705,8 +582,10 @@ common 子包通过 `package.json` 的 `exports` 字段实现按需引入:
```typescript
// ✅ 按需引入(推荐)
import { useLogin } from "@common/vue-kit/composables"
import { formatDate } from "@common/utils"
import { useLoading } from "@common/vue-kit/composables"
import { isPhone } from "@common/utils/regex"
import { SMS_COUNTDOWN } from "@common/utils/constants"
import { chooseImage } from "@common/sdk/mini-program"
// ❌ 全量引入(不推荐)
import * as VueKit from "@common/vue-kit"
......@@ -723,10 +602,12 @@ release ← 生产环境,受保护分支
staging ← 预发环境,测试通过后合并
dev ← 总开发分支(release 环境)
dev ← 总开发分支
feature/official-site-web ← 官网项目开发分支
feature/admin-web ← 管理后台开发分支
feature/admin ← 后台项目开发分支
feature/web ← H5 项目开发分支
feature/mini ← 小程序项目开发分支
feature/site ← 官网项目开发分支
hotfix ← 紧急修复分支(唯一)
refactor ← 重构分支(唯一)
```
......@@ -737,29 +618,27 @@ refactor ← 重构分支(唯一)
|------|------|---------|---------|
| `release` | 生产代码,只接受来自 `staging` 的合并 | 禁止直接提交,需 PR | production |
| `staging` | 预发验证,只接受来自 `dev` 的合并 | 禁止直接提交 | staging |
| `dev` | 总开发分支,所有项目合并入口 | 允许合并 feature/项目名 | release |
| `dev` | 总开发分支,所有项目合并入口 | 允许合并 `feature/*` | test |
| `feature/项目名` | 项目开发分支,日常开发 | 允许直接提交 | - |
| `hotfix` | 紧急修复(唯一分支,所有 hotfix 在此开发) | 无 | - |
| `refactor` | 重构(唯一分支,所有重构在此开发) | 无 | - |
| `hotfix` | 紧急修复(唯一分支) | 无 | - |
| `refactor` | 重构(唯一分支) | 无 | - |
### 6.3 分支命名规范
| 类型 | 命名格式 | 示例 |
|------|---------|------|
| 项目开发分支 | `feature/项目名` | `feature/official-site-web``feature/admin-web` |
| 项目开发分支 | `feature/项目名` | `feature/admin``feature/mini` |
| 紧急修复 | `hotfix`(固定名称) | 所有紧急修复都在此分支 |
| 重构 | `refactor`(固定名称) | 所有重构都在此分支 |
### 6.4 工作流脚本
项目提供 `scripts/git-workflow.ts` 脚本,可通过 npm scripts 执行:
```bash
# 创建/切换到项目开发分支
pnpm git:dev-start official-site-web
pnpm git:dev-start admin
# 合并项目分支到 dev
pnpm git:dev-done official-site-web
pnpm git:dev-done admin
# 发布预发(dev → staging)
pnpm git:deploy-staging
......@@ -767,17 +646,13 @@ pnpm git:deploy-staging
# 发布生产(staging → release)
pnpm git:deploy-release v1.0.0
# 创建/切换到 hotfix 分支
# hotfix
pnpm git:hotfix-start
pnpm git:hotfix-done # 合并到 release 和 dev
# 完成 hotfix,合并到 release 和 dev
pnpm git:hotfix-done
# 创建/切换到 refactor 分支
# refactor
pnpm git:refactor-start
# 完成 refactor,合并到 dev
pnpm git:refactor-done
pnpm git:refactor-done # 合并到 dev
```
### 6.5 工作流说明
......@@ -785,167 +660,81 @@ pnpm git:refactor-done
**1. 新功能开发**
```bash
pnpm git:dev-start official-site-web
# 日常开发直接在 feature/official-site-web 提交
# 小 bug 也直接在此分支修复
pnpm git:dev-done official-site-web # 合并回 dev
pnpm git:dev-start admin
# 日常开发直接在 feature/admin 提交,小 bug 也在此分支修复
pnpm git:dev-done admin # 合并回 dev
```
**2. 发布预发**
```bash
pnpm git:deploy-staging # dev → staging
```
**2. 发布预发**`pnpm git:deploy-staging`(dev → staging)
**3. 发布生产**(Changesets 独立版本):
```bash
# 日常开发时记录变更
pnpm changeset
# 选择变更的包和版本类型(major/minor/patch)
# 填写变更说明
# 发布到生产(自动 bump 版本 + 创建独立 tag)
pnpm git:deploy-release
# 或分步执行:
pnpm release # = pnpm changeset:version && pnpm changeset:publish
```
发布后会自动为每个包创建独立 tag,例如:
- `official-site-web@1.2.0`
- `@common/utils@1.0.1`
- `@common/vue-kit@2.0.0`
**4. 紧急修复**
```bash
pnpm git:hotfix-start # 创建/切换到 hotfix 分支
# 修复...
pnpm git:hotfix-done # 合并到 release 和 dev
pnpm changeset # 日常开发时记录变更
pnpm git:deploy-release # 自动 bump 版本 + 创建独立 tag
# 或分步:pnpm release = pnpm changeset:version && pnpm changeset:publish
```
**5. 重构**
发布后会自动为每个包创建独立 tag,例如 `admin@1.2.0``@common/utils@1.0.1`
```bash
pnpm git:refactor-start # 创建/切换到 refactor 分支
# 重构...
pnpm git:refactor-done # 合并到 dev
```
### 6.7 Changesets 独立版本管理
#### 6.7.1 核心概念
### 6.6 Changesets 独立版本管理
本项目采用 **Changesets** 实现 Monorepo 下子包的独立版本管理
**核心概念**
- **独立版本号**:每个子包独立 bump 版本,互不影响
- **独立 Git Tag**:每个包发布时创建独立 tag(如 `official-site-web@1.2.0`
- **独立 Git Tag**:每个包发布时创建独立 tag
- **按需构建**:CI/CD 只构建有变更的子包
- **独立回滚**:可单独回滚某个包到指定版本,不影响其他包
- **独立回滚**:可单独回滚某个包,不影响其他包
#### 6.7.2 日常开发流程
**1. 开发时记录变更**
**日常流程**
```bash
pnpm changeset
pnpm changeset # 交互式选择包 + 版本类型(major/minor/patch)+ 填写说明
pnpm build:changed # 只构建自上次发布以来有变更的包
pnpm git:deploy-release
```
交互式选择:
- 选择哪些包有变更
- 版本类型:`major`(主版本)、`minor`(次版本)、`patch`(补丁)
- 填写变更说明(会生成 CHANGELOG)
`pnpm changeset` 会生成 `.changeset/xxx.md`
这会生成 `.changeset/xxx.md` 文件,例如:
```markdown
---
"official-site-web": minor
"admin": minor
"@common/utils": patch
---
feat: 添加首页 banner 功能
```
**2. 只构建变更的包**
`pnpm git:deploy-release` 执行流程
```bash
pnpm build:changed
```
自动检测自上次发布以来有变更的包,并只构建它们。
**3. 发布到生产**
```bash
pnpm git:deploy-release
```
执行流程:
1. `changeset version` - 自动 bump 版本号,生成 CHANGELOG
1. `changeset version` — bump 版本号,生成 CHANGELOG
2. 提交版本变更到 staging 分支
3. 合并 staging 到 release
4. `changeset publish` - 发布包并创建独立 tag
4. `changeset publish` 发布包并创建独立 tag
5. 推送 release 分支和所有 tag
#### 6.7.3 版本回滚
**场景**:某个子包发布后发现问题,需要回滚到之前的版本,且不影响其他包。
**方式一:创建回滚分支(推荐,适用于需要重新开发)**
**版本回滚**
```bash
# 1. 从指定 tag 创建回滚分支
git checkout -b rollback/official-site-web-1.0.0 official-site-web@1.0.0
# 2. 恢复其他包到最新版本
git checkout dev -- common/ packages/other-web/
# 3. 提交回滚
git commit -m "rollback: official-site-web to v1.0.0"
# 4. 合并回 dev 分支
pnpm git:dev-done official-site-web
```
**方式二:Revert 指定提交(适用于快速修复)**
```bash
# 1. 查看官网项目的提交历史
git log --oneline -- packages/official-site-web/
# 2. Revert 需要回滚的提交(可指定多个 commit)
# 方式一:从 tag 创建回滚分支(适用于需要重新开发)
git checkout -b rollback/admin-1.0.0 admin@1.0.0
git checkout dev -- common/ packages/web/ packages/mini/ packages/site/
git commit -m "rollback: admin to v1.0.0"
pnpm git:dev-done admin
# 方式二:Revert 指定提交(适用于快速修复)
git log --oneline -- packages/admin/
git revert <commit-hash>
# 3. 推送到远程
git push origin dev
# 方式三:CI/CD 部署时指定 tag(适用于紧急回滚部署)
git clone --branch admin@1.0.0 --depth=1 <repo-url>
```
**方式三:CI/CD 部署时指定版本(适用于紧急回滚部署)**
```bash
# 部署系统直接拉取指定 tag 的代码
git clone --branch official-site-web@1.0.0 --depth=1 <repo-url>
# 只部署 packages/official-site-web/ 目录
```
回滚只影响目标包,其他包保持最新版本;回滚后建议重新 `pnpm changeset` 记录。
**注意事项**
- 回滚只影响目标包,其他包保持最新版本
- 回滚后建议重新执行 `pnpm changeset` 记录回滚变更
- 如果回滚涉及依赖变更,需要同步更新 `package.json`
#### 6.7.4 Tag 命名规范
| 包名 | Tag 格式 | 示例 |
|------|---------|------|
| `official-site-web` | `official-site-web@<version>` | `official-site-web@1.2.0` |
| `@common/utils` | `@common/utils@<version>` | `@common/utils@1.0.1` |
| `@common/vue-kit` | `@common/vue-kit@<version>` | `@common/vue-kit@2.0.0` |
#### 6.7.5 CI/CD 集成示例
**CI/CD 集成示例**
```yaml
# GitHub Actions 示例
jobs:
build:
steps:
......@@ -963,7 +752,7 @@ jobs:
git push --follow-tags
```
### 6.8 提交规范
### 6.7 提交规范
Commit 信息格式:`type(scope): description`
......@@ -972,12 +761,11 @@ Commit 信息格式:`type(scope): description`
| 场景 | scope 写法 | 示例 |
|------|-----------|------|
| 单个 common 子包改动 | `@common/包名` | `feat(@common/utils): 添加日期格式化` |
| 单个业务项目改动 | `项目名` | `feat(official-site-web): 添加首页 banner` |
| 单个业务项目改动 | `项目名` | `feat(admin): 添加首页 banner` |
| 根目录配置改动 | `root` | `chore(root): 更新 ESLint 配置` |
| 多个子包改动 | `*` | `feat(*): 同步工具函数调用` |
**scope 白名单**
scope 白名单由 `scripts/generate-commitlint-config.ts` 动态生成,每次 `pnpm install` 时自动更新。新增项目后无需手动修改 commitlint 配置。
scope 白名单由 `scripts/generate-commitlint-config.ts` 动态生成,每次 `pnpm install` 时自动更新。新增/删除项目后无需手动修改 commitlint 配置。
| type | 说明 |
| ---------- | --------- |
......@@ -989,6 +777,19 @@ scope 白名单由 `scripts/generate-commitlint-config.ts` 动态生成,每次
| `chore` | 构建/工具 |
| `test` | 测试 |
示例:`feat(official-site-web): 添加首页 banner 功能`
示例:`chore(root): 更新 ESLint 配置`
示例:`feat(*): 同步 utils 跨包调用`
---
## 七、Claude Code 配置
`.claude/settings.json`(团队共享,已提交)配置了只读命令白名单与 Figma MCP;`.claude/settings.local.json` 为个人本地配置,已 gitignore。
`.claude/skills/` 提供 4 个项目 skill:
| skill | 用途 |
|-------|------|
| `req-diff` | 原型/需求 vs 代码差异对比,编号列清单,等用户说"开工"再动手 |
| `api-diff-sync` | 接口改动文档 → 定位调用点 → 改代码 → 追加联调记录 |
| `figma-restore` | 从 Figma 设计稿还原页面并注册路由 |
| `update-ui` | 多人协同的 `.vue` UI 层隔离(拆出 `ui-*.ts` / 合并回收) |
`api-diff-sync` 依赖「接口文档根目录」,模板默认约定 `docs/api/`,按项目实际情况调整(详见该 skill 文档)。
# frontend-hub — 前端 Monorepo 模板工程
> pnpm workspace + Vue 3 + TypeScript,一套仓库同时承载后台、H5、小程序、官网四类项目
详细架构与规范见 [ARCHITECTURE.md](ARCHITECTURE.md),本文件是给 Claude 的速查索引。
## 这是一个模板工程
从本仓库派生新项目后,**先做这几件事**
1. 全仓搜索 `TODO:` 与占位符,逐项替换为项目实际值(应用名、`rootEntrance``domainService`、分享标题等)
2. 填写各包的 `.env.*`(模板里所有值都是空的,`VITE_APP_UNIPLAT` / `VITE_APP_CLIENT_ID` 必填)
3. 删除不需要的包,或用 `pnpm new:project` 新增包
4. 更新本文件与 `ARCHITECTURE.md` 里的项目描述
**不要把业务域名、appid、client secret 写回模板**——所有环境相关值一律走 `.env.*`,代码里只读 `config`
## 快速开始
| 操作 | 命令 |
|------|------|
| 安装依赖 | `pnpm install` |
| 交互式启动 | `pnpm dev:project`(引导选择项目 / 环境 / 平台) |
| 指定项目启动 | `pnpm dev:project --project=mini --env=test` |
| 交互式构建 | `pnpm build:project` |
| 只构建有变更的包 | `pnpm build:changed` |
| 代码检查 | `pnpm lint` / `pnpm lint:fix` |
| 单包类型检查 | `pnpm -F <pkg> type-check` |
| 新建项目 | `pnpm new:project <name> [nuxt\|vue]` |
| 记录变更(发版用) | `pnpm changeset` |
包管理器:**pnpm**(不要用 npm/yarn)。安装根依赖用 `pnpm add -wD <pkg>`,安装子包依赖用 `pnpm -F <pkg> add <dep>`
## 仓库结构
```
frontend-hub/
├── common/ # 跨项目复用代码(包名 @common/*)
│ ├── utils/ # 纯函数:common / date-time / mask / money / regex / constants
│ ├── vue-kit/ # Vue 相关:composables(useLoading、useWindowWidth)
│ └── sdk/ # 统一业务平台 SDK 封装(uniplat-sdk + HTTP 适配 + Token)
├── packages/ # 具体项目
│ ├── admin/ # 管理后台(Vue 3 + Vite SPA + Ant Design Vue)
│ ├── web/ # H5 / 客户端(Vue 3 + Vite SPA + UnoCSS)
│ ├── mini/ # 小程序(UniApp,微信/支付宝/H5)
│ └── site/ # 官网(Nuxt 3 SSR)
├── scripts/ # 构建与 Git 工作流脚本(不参与 lint)
├── .claude/ # Claude Code 配置与 skills
└── ARCHITECTURE.md # 架构与开发规范(权威文档)
```
## 各包速查
| 包 | 技术栈 | 路由 | 页面目录 | SDK 初始化 |
|----|--------|------|---------|-----------|
| `admin` | Vue3 + Vite + Pinia + Vue Router + Ant Design Vue | `src/router/index.ts` | `src/pages/` | `src/api/request.ts`(webAdapter) |
| `web` | Vue3 + Vite + Pinia + Vue Router + UnoCSS + SCSS | `src/router/index.ts` | `src/pages/` | `src/api/request.ts`(webAdapter) |
| `mini` | UniApp + Vue3 + Pinia + SCSS | `src/pages.json` | `src/pages/` | `src/utils/sdk.ts`(uniAdapter,懒加载单例) |
| `site` | Nuxt 3 + Pinia + UnoCSS + axios | 文件路由 | `pages/` | 无 SDK,用 `api/request.ts`(axios + runtimeConfig) |
`admin` / `web` / `mini``@` 别名指向各自的 `src/``site` 用 Nuxt 的 `~/`
## 分层约定(硬规则)
```
pages/ 页面,只做组装与交互,不写接口调用细节
components/ UI 组件(business/ 业务组件、layout/ 布局组件)
composables/ 可复用的响应式逻辑(useXxx)
api/ 接口层:request.ts / http.ts 做 SDK/axios 初始化,xxx.ts 只导出接口函数
services/ 服务层:编排多个 api + 操作 store + 提示,函数名 handleXxx
stores/ Pinia,按业务模块拆分
utils/ 项目私有工具(通用的下沉到 @common/utils)
config/ 配置中心,唯一读取 import.meta.env 的地方
types/ 类型定义
```
**代码放哪里**:跨项目复用 → 依赖 Vue 就进 `@common/vue-kit`,纯函数进 `@common/utils`,SDK/HTTP 相关进 `@common/sdk`;否则留在当前包。
**几条容易踩的线**
- 业务代码不要直接用 `import.meta.env`,一律从 `config`
- `api/` 里不操作 store、不跳路由,那是 `services/` 的事
- 登录成功必须同时做三件事:持久化 Token(`TokenManager.saveToken2Storage`)、同步 SDK 登录态(`sdk.core.loginByToken`)、写 store。已封装在各包 `services/auth-service.ts` 里,不要绕过
- `@common/sdk` 根入口不导出小程序模块,小程序专属能力从 `@common/sdk/mini-program` 引入(避免 Web 包引入 `uni.*` 类型)
- `@common/sdk` 里不要写死任何域名/appid/client id,需要环境相关值就加到 `SdkConfig`
## 代码风格
`@antfu/eslint-config`,无 Prettier。**4 空格缩进、双引号、不加分号**。Vue 组件用 `<script setup lang="ts">`,块顺序 `template → script → style`
提交前自动跑 `lint-staged` + `commitlint`。Commit 格式 `type(scope): description`,scope 白名单由 `scripts/generate-commitlint-config.ts``pnpm install` 时自动生成(包名、`root``*`)。
## 环境
| 模式 | 文件 | `VITE_APP_ENV` |
|------|------|----------------|
| 默认 | `.env.development` | `dev` |
| `--mode test` | `.env.test` | `test` |
| `--mode staging` | `.env.staging` | `stage` |
| `--mode production` | `.env.production` | `prod` |
取值与 `@common/sdk``Environment` 枚举一致(`dev` / `test` / `stage` / `prod`)。
⚠️ **不要在 `.env.*` 里写 `NODE_ENV`**——Vite/Nuxt 会按命令自动设置,手写 `NODE_ENV=production` 会让 `dev:test` 之类的 dev server 报警告。
## 可用 skills
| skill | 用途 |
|-------|------|
| `req-diff` | 原型/需求 vs 代码差异对比,编号列清单,等用户说"开工"再动手 |
| `api-diff-sync` | 接口改动文档 → 定位调用点 → 改代码 → 追加联调记录 |
| `figma-restore` | 从 Figma 设计稿还原页面并注册路由 |
| `update-ui` | 多人协同的 `.vue` UI 层隔离(拆出 `ui-*.ts` / 合并回收) |
export default {
extends: ['@commitlint/config-conventional'],
rules: {
'scope-enum': [2, 'always', ["root", "*", "@common/sdk", "@common/utils", "@common/vue-kit", "official-site-web", "qqqf-admin", "qqqf-mp"]],
'scope-empty': [2, 'never'],
},
'scope-enum': [2, 'always', ["root","*","@common/sdk","@common/utils","@common/vue-kit","admin","mini","site","web"]],
'scope-empty': [2, 'never']
}
}
......@@ -2,8 +2,11 @@
"name": "@common/sdk",
"version": "1.0.0",
"private": true,
"description": "统一业务平台 SDK 封装(uniplat-sdk + HTTP 适配 + Token 管理)",
"exports": {
".": "./src/index.ts",
"./adapters": "./src/adapters/index.ts",
"./core": "./src/core/index.ts",
"./mini-program": "./src/mini-program/index.ts",
"./services/login-service": "./src/services/login-service.ts",
"./services/verify-service": "./src/services/verify-service.ts"
......@@ -11,10 +14,6 @@
"main": "src/index.ts",
"dependencies": {
"axios": "^0.27.2",
"lodash": "^4.17.21",
"uniplat-sdk": "^0.1.734-private"
},
"devDependencies": {
"@types/lodash": "^4.17.24"
}
}
......@@ -6,7 +6,16 @@ import buildFullPath from "axios/lib/core/buildFullPath"
import settle from "axios/lib/core/settle"
// @ts-ignore
import buildURL from "axios/lib/helpers/buildURL"
import { forEach } from "lodash"
function forEach(
obj: Record<string, any> | undefined,
fn: (val: any, key: string) => void,
) {
if (!obj) return
for (const [key, val] of Object.entries(obj)) {
fn(val, key)
}
}
function isJSONstr(str: string | any[]) {
try {
......@@ -88,12 +97,12 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise {
const uniHeader: Record<string, string> = {}
forEach(config.headers, (val: any, key: string) => {
const _header = key.toLowerCase()
if (
// 无请求体时不发 content-type;referer 由小程序运行时接管,不能透传
const skip =
(typeof requestData === "undefined" &&
_header === "content-type") ||
_header === "referer"
) {
} else {
if (!skip) {
uniHeader[key] = String(val)
}
})
......
......@@ -3,7 +3,7 @@ export { buildFilePath, buildImage } from "./image-builder"
// SDK 核心
export {
ClientId,
CLIENT_ID_NONE,
Environment,
getAuthHandlers,
PASSPORT_ANONYMOUS_SERVICE,
......@@ -11,11 +11,11 @@ export {
PASSPORT_SUB_PROJECT,
registerAuthHandlers,
Sdk,
wxDistributerUrlConfig,
} from "./sdk"
export type {
AuthHandlers,
ClientId,
SdkConfig,
SdkInitOptions,
} from "./sdk"
......@@ -24,6 +24,6 @@ export type {
export { decodeToken, TokenManager } from "./token-manager"
// 监控
export { Product, webMonitor } from "./web-monitor"
export { PRODUCT_DEFAULT, webMonitor } from "./web-monitor"
export type { MonitorOption } from "./web-monitor"
export type { MonitorEndpoint, MonitorOption, Product } from "./web-monitor"
......@@ -15,14 +15,23 @@ import { buildFilePath, buildImage } from "./image-builder"
// ==================== 类型定义 ====================
export interface SdkConfig {
/** 统一业务平台 API 根地址(VITE_APP_UNIPLAT) */
uniplatApi: string
/** 平台入口标识,由后端分配 */
rootEntrance: string
logEnv: string
clientId: string
/** 当前环境标识 */
logEnv?: Environment
/** 客户端标识,由后端分配(VITE_APP_CLIENT_ID) */
clientId: ClientId
/** 默认的领域服务定位(子项目名 + 服务名) */
domainService: {
subProjectName: string
serviceName: string
}
/** 通行证(登录/注册/忘记密码)站点地址,模板需按项目填写 */
passportUrl?: string
/** 微信分销站点地址,按需填写 */
wxDistributerUrl?: string
}
export interface SdkInitOptions {
......@@ -60,52 +69,24 @@ export const PASSPORT_SERVICE = {
// ==================== 环境/客户端枚举 ====================
/** 运行环境,取值与各项目 `.env.*` 里的 `VITE_APP_ENV` 一致 */
export enum Environment {
Dev = "dev",
Test = "test",
Pro = "pro",
Stage = "stage",
Pro = "prod",
}
export enum ClientId {
None = "",
QqxbApp = "qqxb-hrm-app-ios",
QqtxzMp = "qqxb_mini_app",
FlbMp = "qqxb_flb_mini_app",
WebLogin = "teamix-personal-center-web-pc",
RenCaiKuaiDiMp = "welfare_bean_wechat_mini_app",
YuZhiPinMp = "chongqing_job_mini_app",
RenCaiKuaiDiH5 = "welfare_bean_qqxb_h5",
RenCaiKuaiDiPc = "welfare_bean_agent_pc",
Hrs100 = "hrs100-front-web-pc",
QqxbOfficial = "qqxb-official-web",
FastServiceWeb = "teamix-fast-service-web",
FastServiceMerchant = "teamix-fast-service-merchant",
Hrs100WechatMiniApp = "hrs100_wechat_mini_app",
Donghu = "donghu-web-pc",
}
/**
* 客户端标识
*
* 由后端为每个端(小程序 / H5 / 后台)分配,各项目通过
* `VITE_APP_CLIENT_ID` 注入,SDK 不内置任何具体取值。
*/
export type ClientId = string
// ==================== URL 配置 ====================
const qqxbPassportConfig = new Map<Environment, string>([
[
Environment.Dev,
"http://191env1.report.qinqinxiaobao-passport.qqxb.jinsehuaqin.com:8800",
],
[
Environment.Test,
"http://191env1.report.qinqinxiaobao-passport.qqxb.jinsehuaqin.com:8800",
],
[Environment.Pro, "https://passport.qinqinxiaobao.com"],
[Environment.Stage, "https://pre-passport.qinqinxiaobao.com"],
])
export const wxDistributerUrlConfig = new Map<Environment, string>([
[Environment.Dev, "http://wechat.test.teammix.qqxb.cn"],
[Environment.Test, "https://pre-wx-distributer.teammix.com"],
[Environment.Pro, "https://wx-distributer.teammix.com"],
[Environment.Stage, "https://pre-wx-distributer.teammix.com"],
])
/** 空客户端标识 */
export const CLIENT_ID_NONE: ClientId = ""
// ==================== AuthHandlers 管理 ====================
......@@ -127,7 +108,7 @@ export class Sdk {
private token!: string
public orgId!: number
private client = ClientId.None
private client: ClientId = CLIENT_ID_NONE
private passportUrl = ""
private wxDistributerUrl = ""
......@@ -152,15 +133,9 @@ export class Sdk {
this.uniplatSdk.getAxios().defaults.timeout = 10e3
this.uniplatSdk.global.rootEntrance = config.rootEntrance
this.client = (config.clientId as ClientId) || ClientId.None
const envir = (config.logEnv as Environment) || Environment.Dev
this.passportUrl =
qqxbPassportConfig.get(envir) ||
qqxbPassportConfig.get(Environment.Dev)!
this.wxDistributerUrl =
wxDistributerUrlConfig.get(envir) ||
wxDistributerUrlConfig.get(Environment.Dev)!
this.client = config.clientId || CLIENT_ID_NONE
this.passportUrl = config.passportUrl || ""
this.wxDistributerUrl = config.wxDistributerUrl || ""
}
public setupEventHandlers(handlers: {
......@@ -204,9 +179,18 @@ export class Sdk {
return `${this.passportUrl}/forget`
}
public register() {
/**
* 注册页地址
*
* @param redirectUrl 注册完成后的回跳地址。省略时在浏览器环境取
* `window.location.href`;小程序环境需显式传入当前页面路径。
*/
public register(redirectUrl?: string) {
const currentUrl =
redirectUrl ??
(typeof window !== "undefined" ? window.location.href : "")
return `${this.passportUrl}/register?redirectUrl=${encodeURIComponent(
window.location.href,
currentUrl,
)}`
}
......
......@@ -98,7 +98,7 @@ export class TokenManager {
public static hasToken() {
const cache =
this.storage.getItem(this.key) ||
(UniplatSdk.prototype && (window as any).jwtToken)
(typeof window !== "undefined" && (window as any).jwtToken)
if (cache) {
if (this.isTokenValid(cache)) {
return cache
......@@ -111,7 +111,8 @@ export class TokenManager {
public static checkTokenExit() {
const cache =
this.storage.getItem(this.key) || ((window as any).jwtToken)
this.storage.getItem(this.key) ||
(typeof window !== "undefined" && (window as any).jwtToken)
return cache
}
......
import type { AxiosAdapter, AxiosResponse } from "axios"
import type { AxiosAdapter, AxiosRequestConfig, AxiosResponse } from "axios"
import type { UniplatSdk } from "uniplat-sdk"
import Axios from "axios"
/**
* 产品枚举
* 产品标识
*
* 监控平台用于区分上报来源,由各项目自行约定取值。
*/
export enum Product {
Default = "default",
QqxbWeixin = "qqxb-weixin",
QqxbApp = "qqxb-app",
Fulibao = "fulibao",
HrManager = "hr-manager",
Hrs100 = "hrs100",
HrsApp = "hrs-app",
BiJie = "bi-jie",
Cashier = "cashier",
Uniplat = "uniplat",
DeShengJiuYeBao = "de-sheng-jiu-ye-bao",
QqxbPassport = "qqxb-passport",
RenCaiKuaiDi = "ren-cai-kuai-di",
SiJiLianDong = "si-ji-lian-dong",
YiZhiPin = "yi-zhi-pin",
FastServiceMerchant = "fast-service-merchant",
}
export type Product = string
/** 默认产品标识 */
export const PRODUCT_DEFAULT: Product = "default"
enum ProductTable {
Default = "",
......@@ -38,6 +26,13 @@ export interface MonitorOption {
monitorMsgHandler?: (msg: string[], r: AxiosResponse) => void
}
export interface MonitorEndpoint {
/** 监控上报地址 */
url: string
/** 上报接口的 authorization 请求头 */
authorization?: string
}
/**
* Web 监控类
* 负责错误监控、日志上报
......@@ -45,8 +40,9 @@ export interface MonitorOption {
class WebMonitor {
private key = ""
private envir = "dev"
private product = Product.Default
private readonly url = "https://pre-hrs-monitor.hrs100.com"
private product = PRODUCT_DEFAULT
private url = ""
private authorization = ""
private adapter: AxiosAdapter | undefined
public updateKey(key: string) {
......@@ -54,15 +50,30 @@ class WebMonitor {
return this
}
private buildHeaders() {
/**
* 配置监控上报地址与凭证
*
* 未配置 `url` 时监控整体关闭,避免模板工程向错误的地址上报。
*/
public setupEndpoint(endpoint: MonitorEndpoint) {
this.url = endpoint.url
this.authorization = endpoint.authorization || ""
return this
}
private buildHeaders(): AxiosRequestConfig {
const headers: Record<string, string> = {}
if (this.authorization) {
headers.authorization = this.authorization
}
return {
headers: { authorization: "cdd0a34e-f537-4e5b-808e-2ba06af21845" },
headers,
adapter: this.adapter,
}
}
private enable() {
return this.envir !== "dev"
return !!this.url && this.envir !== "dev"
}
private envirString() {
......@@ -76,6 +87,9 @@ class WebMonitor {
}
public log(msg: any) {
if (!this.url) {
return Promise.resolve()
}
return Axios.post(
this.url,
{
......@@ -132,6 +146,7 @@ class WebMonitor {
options &&
options.userAgent &&
typeof window !== "undefined" &&
msg.push(`UserAgent: ${window.navigator.userAgent}`)
r.config &&
r.config.params &&
......@@ -151,7 +166,7 @@ class WebMonitor {
)
}
}
if (window !== undefined) {
if (typeof window !== "undefined") {
msg.push(`Location: ${window.location.href}`)
}
if (r.data) {
......
......@@ -8,7 +8,7 @@ export { buildFilePath, buildImage } from "./core/image-builder"
// SDK 核心(合并了类型、常量、AuthHandlers)
export {
ClientId,
CLIENT_ID_NONE,
Environment,
getAuthHandlers,
PASSPORT_ANONYMOUS_SERVICE,
......@@ -16,11 +16,11 @@ export {
PASSPORT_SUB_PROJECT,
registerAuthHandlers,
Sdk,
wxDistributerUrlConfig,
} from "./core/sdk"
export type {
AuthHandlers,
ClientId,
SdkConfig,
SdkInitOptions,
} from "./core/sdk"
......@@ -34,12 +34,9 @@ export { decodeToken, TokenManager } from "./core/token-manager"
export { TokenManager as PassportTokenController } from "./core/token-manager"
// 监控
export { Product, webMonitor } from "./core/web-monitor"
export { PRODUCT_DEFAULT, webMonitor } from "./core/web-monitor"
export type { MonitorOption } from "./core/web-monitor"
// 小程序模块
export * from "./mini-program"
export type { MonitorEndpoint, MonitorOption, Product } from "./core/web-monitor"
// 服务工厂函数
export { createLoginService, LoginApiName } from "./services/login-service"
......@@ -49,3 +46,6 @@ export type { LoginRegisterResult, RegisterChannel } from "./services/login-serv
export { createVerifyService, VerifyApiName } from "./services/verify-service"
export type { VerifyImageResult } from "./services/verify-service"
// 小程序专属能力请从子路径引入,避免 Web 项目被动引入 uni.* 类型依赖:
// import { chooseImage } from "@common/sdk/mini-program"
import { isArray } from "lodash"
export interface UploadData {
url: string
fullUrl: string
......@@ -30,7 +28,7 @@ export function chooseImage(
| UniApp.ChooseImageSuccessCallbackResultFile
| File
)[] = []
if (!isArray(data.tempFiles)) {
if (!Array.isArray(data.tempFiles)) {
files = [data.tempFiles]
} else {
files = data.tempFiles
......
......@@ -3,8 +3,14 @@
"type": "module",
"version": "1.0.0",
"private": true,
"description": "跨项目复用的纯工具函数(无框架依赖)",
"exports": {
".": "./src/index.ts",
"./constants": "./src/constants.ts"
"./common": "./src/common.ts",
"./constants": "./src/constants.ts",
"./date-time": "./src/date-time.ts",
"./mask": "./src/mask.ts",
"./money": "./src/money.ts",
"./regex": "./src/regex.ts"
}
}
export function isEmpty(value: any): boolean {
if (value === null || value === undefined) return true
if (typeof value === "string") return value.trim() === ""
if (Array.isArray(value)) return value.length === 0
if (typeof value === "object") return Object.keys(value).length === 0
return false
}
export function isNotEmpty(value: any): boolean {
return !isEmpty(value)
}
export function isNull(value: any): boolean {
return value === null
}
export function isUndefined(value: any): boolean {
return value === undefined
}
export function isNotNull(value: any): boolean {
return value !== null
}
export function isNotUndefined(value: any): boolean {
return value !== undefined
}
export function isNil(value: any): boolean {
return value === null || value === undefined
}
export function isNotNil(value: any): boolean {
return value !== null && value !== undefined
}
export function isArray(value: any): boolean {
return Array.isArray(value)
}
export function isObject(value: any): boolean {
return value !== null && typeof value === "object" && !Array.isArray(value)
}
export function isString(value: any): boolean {
return typeof value === "string"
}
export function isNumber(value: any): boolean {
return typeof value === "number" && !isNaN(value)
}
export function isBoolean(value: any): boolean {
return typeof value === "boolean"
}
export function isFunction(value: any): boolean {
return typeof value === "function"
}
export function isPromise(value: any): boolean {
return value instanceof Promise
}
export function cloneDeep<T>(value: T): T {
if (value === null || value === undefined) return value
if (typeof value !== "object") return value
if (Array.isArray(value)) return value.map(item => cloneDeep(item)) as unknown as T
const result: Record<string, any> = {}
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = cloneDeep(value[key])
}
}
return result as T
}
export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout> | null = null
return ((...args: any[]) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
export function throttle<T extends (...args: any[]) => any>(fn: T, delay: number): T {
let lastTime = 0
return ((...args: any[]) => {
const now = Date.now()
if (now - lastTime >= delay) {
lastTime = now
fn(...args)
}
}) as T
}
export function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
export function randomFloat(min: number, max: number, decimals: number = 2): number {
const result = Math.random() * (max - min) + min
return parseFloat(result.toFixed(decimals))
}
export function generateUUID(): string {
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === "x" ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
export function generateShortId(length: number = 8): string {
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
let result = ""
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
export function getType(value: any): string {
return Object.prototype.toString.call(value).slice(8, -1).toLowerCase()
}
export function arrayToObject<T>(array: T[], key: keyof T): Record<string, T> {
return array.reduce((acc, item) => {
acc[String(item[key])] = item
return acc
}, {} as Record<string, T>)
}
export function objectToArray<T>(obj: Record<string, T>): T[] {
return Object.values(obj)
}
export function groupBy<T>(array: T[], key: (item: T) => string): Record<string, T[]> {
return array.reduce((acc, item) => {
const groupKey = key(item)
if (!acc[groupKey]) {
acc[groupKey] = []
}
acc[groupKey].push(item)
return acc
}, {} as Record<string, T[]>)
}
export function unique<T>(array: T[]): T[] {
return [...new Set(array)]
}
export function uniqueBy<T>(array: T[], key: (item: T) => any): T[] {
const seen = new Set()
return array.filter((item) => {
const value = key(item)
if (seen.has(value)) return false
seen.add(value)
return true
})
}
export function sortBy<T>(array: T[], key: (item: T) => any, order: "asc" | "desc" = "asc"): T[] {
return [...array].sort((a, b) => {
const valA = key(a)
const valB = key(b)
if (valA < valB) return order === "asc" ? -1 : 1
if (valA > valB) return order === "asc" ? 1 : -1
return 0
})
}
export function intersection<T>(arr1: T[], arr2: T[]): T[] {
const set = new Set(arr2)
return arr1.filter(item => set.has(item))
}
export function difference<T>(arr1: T[], arr2: T[]): T[] {
const set = new Set(arr2)
return arr1.filter(item => !set.has(item))
}
export function union<T>(arr1: T[], arr2: T[]): T[] {
return [...new Set([...arr1, ...arr2])]
}
export function chunk<T>(array: T[], size: number): T[][] {
const result: T[][] = []
for (let i = 0; i < array.length; i += size) {
result.push(array.slice(i, i + size))
}
return result
}
export function flatten<T>(array: T[][]): T[] {
return array.reduce((acc, item) => acc.concat(item), [])
}
export function pick<T extends object, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> {
const result: Partial<T> = {}
for (const key of keys) {
if (key in obj) {
result[key] = obj[key]
}
}
return result as Pick<T, K>
}
export function omit<T, K extends keyof T>(obj: T, keys: K[]): Omit<T, K> {
const result: Partial<T> = { ...obj }
for (const key of keys) {
delete result[key]
}
return result as Omit<T, K>
}
export function merge<T extends object>(target: T, ...sources: Partial<T>[]): T {
const result: Record<string, any> = { ...target }
for (const source of sources) {
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
const value = (source as Record<string, any>)[key]
if (isObject(result[key]) && isObject(value)) {
result[key] = merge(result[key], value)
} else {
result[key] = value
}
}
}
}
return result as T
}
export function wait(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function tryCatch<T>(fn: () => T, fallback: T): T {
try {
return fn()
} catch {
return fallback
}
}
export function safeGet<T>(obj: any, path: string, fallback?: T): T {
const keys = path.split(".")
let result: any = obj
for (const key of keys) {
if (result === null || result === undefined) return fallback as T
result = result[key]
}
return result as T
}
export function safeSet<T>(obj: any, path: string, value: T): void {
const keys = path.split(".")
let result: any = obj
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]
if (result[key] === null || result[key] === undefined) {
result[key] = {}
}
result = result[key]
}
result[keys[keys.length - 1]] = value
}
export const APP_NAME = 'common-web'
export const VERSION = '1.0.0'
/**
* 跨项目通用常量
*
* 项目私有常量请放在各自的 `src/config/` 下,不要写在这里。
*/
/** 一秒的毫秒数 */
export const SECOND = 1000
/** 一分钟的毫秒数 */
export const MINUTE = 60 * SECOND
/** 一小时的毫秒数 */
export const HOUR = 60 * MINUTE
/** 一天的毫秒数 */
export const DAY = 24 * HOUR
/** 短信验证码倒计时秒数 */
export const SMS_COUNTDOWN = 60
/** 默认分页大小 */
export const DEFAULT_PAGE_SIZE = 20
/** 环境标识,与各项目 `.env.*` 中的 `VITE_APP_ENV` 保持一致 */
export const APP_ENV = {
Dev: "dev",
Test: "test",
Stage: "stage",
Prod: "prod",
} as const
export type AppEnv = (typeof APP_ENV)[keyof typeof APP_ENV]
export function formatTime(date: Date | string | number, format: string = "YYYY-MM-DD HH:mm:ss"): string {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
if (isNaN(d.getTime())) return ""
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, "0")
const day = String(d.getDate()).padStart(2, "0")
const hours = String(d.getHours()).padStart(2, "0")
const minutes = String(d.getMinutes()).padStart(2, "0")
const seconds = String(d.getSeconds()).padStart(2, "0")
const milliseconds = String(d.getMilliseconds()).padStart(3, "0")
return format
.replace("YYYY", String(year))
.replace("MM", month)
.replace("DD", day)
.replace("HH", hours)
.replace("mm", minutes)
.replace("ss", seconds)
.replace("SSS", milliseconds)
}
export function formatDate(date: Date | string | number, format: string = "YYYY-MM-DD"): string {
return formatTime(date, format)
}
export function formatHour(date: Date | string | number, format: string = "HH:mm"): string {
return formatTime(date, format)
}
export function formatRelative(date: Date | string | number): string {
const now = new Date()
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
if (isNaN(d.getTime())) return ""
const diff = now.getTime() - d.getTime()
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
const week = 7 * day
const month = 30 * day
const year = 365 * day
if (diff < minute) return "刚刚"
if (diff < hour) return `${Math.floor(diff / minute)}分钟前`
if (diff < day) return `${Math.floor(diff / hour)}小时前`
if (diff < week) return `${Math.floor(diff / day)}天前`
if (diff < month) return `${Math.floor(diff / week)}周前`
if (diff < year) return `${Math.floor(diff / month)}个月前`
return `${Math.floor(diff / year)}年前`
}
export function isToday(date: Date | string | number): boolean {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
if (isNaN(d.getTime())) return false
const today = new Date()
return d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate()
}
export function isSameDay(date1: Date | string | number, date2: Date | string | number): boolean {
const d1 = typeof date1 === "string" || typeof date1 === "number" ? new Date(date1) : date1
const d2 = typeof date2 === "string" || typeof date2 === "number" ? new Date(date2) : date2
if (isNaN(d1.getTime()) || isNaN(d2.getTime())) return false
return d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
}
export function getTimestamp(date?: Date | string | number): number {
if (!date) return Math.floor(Date.now() / 1000)
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
if (isNaN(d.getTime())) return 0
return Math.floor(d.getTime() / 1000)
}
export function getTimestampMs(date?: Date | string | number): number {
if (!date) return Date.now()
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
if (isNaN(d.getTime())) return 0
return d.getTime()
}
export function addDays(date: Date | string | number, days: number): Date {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
d.setDate(d.getDate() + days)
return d
}
export function addHours(date: Date | string | number, hours: number): Date {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
d.setHours(d.getHours() + hours)
return d
}
export function getDaysInMonth(date: Date | string | number): number {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
const year = d.getFullYear()
const month = d.getMonth()
return new Date(year, month + 1, 0).getDate()
}
export function getWeekDay(date: Date | string | number): number {
const d = typeof date === "string" || typeof date === "number" ? new Date(date) : date
return d.getDay()
}
export function getWeekDayName(date: Date | string | number): string {
const days = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
return days[getWeekDay(date)]
}
export * from './constants'
export * from "./common"
export * from "./constants"
export * from "./date-time"
export * from "./mask"
export * from "./money"
export * from "./regex"
export function maskPhone(phone: string): string {
if (!phone || phone.length < 7) return phone
return phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2")
}
export function maskEmail(email: string): string {
if (!email) return email
const [name, domain] = email.split("@")
if (!name || !domain) return email
const maskedName = name.length <= 2
? name.charAt(0) + "*".repeat(name.length - 1)
: name.charAt(0) + "*".repeat(name.length - 2) + name.charAt(name.length - 1)
return `${maskedName}@${domain}`
}
export function maskIdCard(idCard: string): string {
if (!idCard || idCard.length < 10) return idCard
return idCard.replace(/(\d{6})\d{8}(\d{4})/, "$1********$2")
}
export function maskBankCard(bankCard: string): string {
if (!bankCard || bankCard.length < 10) return bankCard
return bankCard.replace(/(\d{4})\d{8,11}(\d{4})/, "$1********$2")
}
export function maskName(name: string): string {
if (!name) return name
if (name.length === 1) return name
if (name.length === 2) return name.charAt(0) + "*"
return name.charAt(0) + "*".repeat(name.length - 1)
}
export function maskChineseName(name: string): string {
if (!name) return name
if (name.length === 1) return name
if (name.length === 2) return name.charAt(0) + "*"
if (name.length === 3) return name.charAt(0) + "*" + name.charAt(2)
return name.charAt(0) + "**" + name.charAt(name.length - 1)
}
export function maskAddress(address: string): string {
if (!address) return address
if (address.length <= 6) return address
return address.substring(0, 6) + "****" + address.substring(address.length - 2)
}
export function maskCarPlate(plate: string): string {
if (!plate || plate.length < 7) return plate
return plate.replace(/(.{3}).*(.{3})/, "$1***$2")
}
export function maskPassword(password: string): string {
if (!password) return password
return "*".repeat(password.length)
}
export function maskAccount(account: string): string {
if (!account) return account
if (account.length <= 4) return "*".repeat(account.length)
return account.substring(0, 2) + "*".repeat(account.length - 4) + account.substring(account.length - 2)
}
export function maskValue(value: string, start: number = 0, end: number = 0, maskChar: string = "*"): string {
if (!value) return value
if (start < 0) start = 0
if (end < 0) end = 0
if (start >= value.length) return value
const maskedLength = value.length - start - end
if (maskedLength <= 0) return value
const prefix = value.substring(0, start)
const masked = maskChar.repeat(maskedLength)
const suffix = value.substring(value.length - end)
return prefix + masked + suffix
}
export function formatCurrency(amount: number | string, decimals: number = 2, prefix: string = "", suffix: string = ""): string {
const num = typeof amount === "string" ? parseFloat(amount) : amount
if (isNaN(num)) return ""
const formatted = num.toLocaleString("zh-CN", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
return `${prefix}${formatted}${suffix}`
}
export function formatMoney(amount: number | string, decimals: number = 2): string {
return formatCurrency(amount, decimals, "", "")
}
export function formatMoneyWithSign(amount: number | string, decimals: number = 2): string {
const num = typeof amount === "string" ? parseFloat(amount) : amount
if (isNaN(num)) return ""
const sign = num >= 0 ? "+" : ""
return `${sign}${formatMoney(num, decimals)}`
}
export function formatMoneyWithYuan(amount: number | string, decimals: number = 2): string {
return formatCurrency(amount, decimals, "¥", "")
}
export function formatMoneyWithDollar(amount: number | string, decimals: number = 2): string {
return formatCurrency(amount, decimals, "$", "")
}
export function formatMoneyWithComma(amount: number | string, decimals: number = 2): string {
return formatCurrency(amount, decimals, "", "")
}
export function formatMoneyToThousands(amount: number | string, decimals: number = 2): string {
const num = typeof amount === "string" ? parseFloat(amount) : amount
if (isNaN(num)) return ""
if (num >= 10000) {
return `${(num / 10000).toFixed(decimals)}万`
}
if (num >= 100000000) {
return `${(num / 100000000).toFixed(decimals)}亿`
}
return num.toLocaleString("zh-CN", {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
})
}
export function formatMoneyToChinese(amount: number | string): string {
const num = typeof amount === "string" ? parseFloat(amount) : amount
if (isNaN(num)) return ""
const units = ["", "十", "百", "千", "万", "十万", "百万", "千万", "亿"]
const digits = ["零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"]
const integerPart = Math.floor(num)
const decimalPart = Math.round((num - integerPart) * 100)
let result = ""
const str = String(integerPart)
for (let i = 0; i < str.length; i++) {
const digit = parseInt(str[i])
const unitIndex = str.length - i - 1
const unit = units[unitIndex]
if (digit === 0) {
if (result.length > 0 && result[result.length - 1] !== "零") {
result += "零"
}
} else {
result += digits[digit] + unit
}
}
if (result.length === 0) {
result = "零"
}
if (decimalPart > 0) {
const jiao = Math.floor(decimalPart / 10)
const fen = decimalPart % 10
result += "元"
if (jiao > 0) {
result += digits[jiao] + "角"
}
if (fen > 0) {
result += digits[fen] + "分"
}
} else {
result += "元整"
}
return result
}
export function parseMoney(str: string): number {
const cleaned = str.replace(/[¥$,,]/g, "").trim()
return parseFloat(cleaned) || 0
}
export function roundMoney(amount: number | string, decimals: number = 2): number {
const num = typeof amount === "string" ? parseFloat(amount) : amount
if (isNaN(num)) return 0
return Math.round(num * 10 ** decimals) / 10 ** decimals
}
export function compareMoney(amount1: number | string, amount2: number | string): number {
const num1 = typeof amount1 === "string" ? parseFloat(amount1) : amount1
const num2 = typeof amount2 === "string" ? parseFloat(amount2) : amount2
if (isNaN(num1) || isNaN(num2)) return NaN
return num1 - num2
}
export const REGEX = {
phone: /^1[3-9]\d{9}$/,
phoneWithAreaCode: /^(\+?86)?1[3-9]\d{9}$/,
email: /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/,
idCard: /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/,
passport: /^[A-Za-z][0-9]{8}$/,
url: /^(https?:\/\/)?[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)+(\/[\w.~:/?#[\]@!$&'()*+,;=-]*)?$/,
ipv4: /^((25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(25[0-5]|2[0-4]\d|[01]?\d\d?)$/,
ipv6: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/,
hexColor: /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/,
chinese: /^[\u4e00-\u9fa5]+$/,
english: /^[a-zA-Z]+$/,
number: /^-?\d+$/,
decimal: /^-?\d+\.\d+$/,
integer: /^-?\d+$/,
positiveInteger: /^\d+$/,
negativeInteger: /^-\d+$/,
positiveDecimal: /^\d+\.\d+$/,
negativeDecimal: /^-\d+\.\d+$/,
date: /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
time: /^([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d)?$/,
datetime: /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\s+([01]\d|2[0-3]):([0-5]\d)(:[0-5]\d)?$/,
creditCard: /^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9]{2})[0-9]{12}|3[47][0-9]{13})$/,
bankCard: /^\d{16,19}$/,
zipCode: /^\d{6}$/,
qq: /^[1-9][0-9]{4,10}$/,
wechat: /^[a-zA-Z][\w-]{5,19}$/,
carPlate: /^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领][A-HJ-NP-Z][A-HJ-NP-Z0-9]{4,5}[A-HJ-NP-Z0-9挂学警港澳]$/,
username: /^[\w-]{4,16}$/,
password: /^\w{6,20}$/,
strongPassword: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$/,
}
export function isPhone(value: string): boolean {
return REGEX.phone.test(value)
}
export function isPhoneWithAreaCode(value: string): boolean {
return REGEX.phoneWithAreaCode.test(value)
}
export function isEmail(value: string): boolean {
return REGEX.email.test(value)
}
export function isIdCard(value: string): boolean {
return REGEX.idCard.test(value)
}
export function isPassport(value: string): boolean {
return REGEX.passport.test(value)
}
export function isUrl(value: string): boolean {
return REGEX.url.test(value)
}
export function isIpv4(value: string): boolean {
return REGEX.ipv4.test(value)
}
export function isIpv6(value: string): boolean {
return REGEX.ipv6.test(value)
}
export function isHexColor(value: string): boolean {
return REGEX.hexColor.test(value)
}
export function isChinese(value: string): boolean {
return REGEX.chinese.test(value)
}
export function isEnglish(value: string): boolean {
return REGEX.english.test(value)
}
export function isNumberString(value: string): boolean {
return REGEX.number.test(value)
}
export function isDecimal(value: string): boolean {
return REGEX.decimal.test(value)
}
export function isInteger(value: string): boolean {
return REGEX.integer.test(value)
}
export function isPositiveInteger(value: string): boolean {
return REGEX.positiveInteger.test(value)
}
export function isNegativeInteger(value: string): boolean {
return REGEX.negativeInteger.test(value)
}
export function isPositiveDecimal(value: string): boolean {
return REGEX.positiveDecimal.test(value)
}
export function isNegativeDecimal(value: string): boolean {
return REGEX.negativeDecimal.test(value)
}
export function isDate(value: string): boolean {
return REGEX.date.test(value)
}
export function isTime(value: string): boolean {
return REGEX.time.test(value)
}
export function isDatetime(value: string): boolean {
return REGEX.datetime.test(value)
}
export function isCreditCard(value: string): boolean {
return REGEX.creditCard.test(value)
}
export function isBankCard(value: string): boolean {
return REGEX.bankCard.test(value)
}
export function isZipCode(value: string): boolean {
return REGEX.zipCode.test(value)
}
export function isQQ(value: string): boolean {
return REGEX.qq.test(value)
}
export function isWechat(value: string): boolean {
return REGEX.wechat.test(value)
}
export function isCarPlate(value: string): boolean {
return REGEX.carPlate.test(value)
}
export function isUsername(value: string): boolean {
return REGEX.username.test(value)
}
export function isPassword(value: string): boolean {
return REGEX.password.test(value)
}
export function isStrongPassword(value: string): boolean {
return REGEX.strongPassword.test(value)
}
export function isValidIdCard(value: string): boolean {
if (!REGEX.idCard.test(value)) return false
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ["1", "0", "X", "9", "8", "7", "6", "5", "4", "3", "2"]
let sum = 0
for (let i = 0; i < 17; i++) {
sum += parseInt(value[i]) * weights[i]
}
const checkCode = checkCodes[sum % 11]
return value[17].toUpperCase() === checkCode
}
export { useLoading } from './use-loading'
export { useWindowWidth } from './use-window-width'
import { ref } from 'vue'
/**
* 加载状态管理
*
* 与平台无关,Web / 小程序项目均可使用。
*/
export function useLoading() {
const loading = ref(false)
function start() {
loading.value = true
}
function stop() {
loading.value = false
}
/** 包裹一个异步函数,自动维护 loading 状态 */
function wrap<T>(fn: () => Promise<T>): Promise<T> {
loading.value = true
return fn().finally(() => {
loading.value = false
})
}
return { loading, start, stop, wrap }
}
export * from './composables/use-window-width'
export * from './composables'
......@@ -90,7 +90,21 @@ export default antfu(
'**/package-lock.json',
'**/pnpm-lock.yaml',
'**/yarn.lock',
// 手工维护的中文文档:prettier 的表格对齐对 CJK 宽度处理不佳
'ARCHITECTURE.md',
'CLAUDE.md',
'.claude/**',
// 由 scripts/generate-commitlint-config.ts 自动生成
'commitlint.config.mjs',
],
},
// YAML:4 空格缩进下 yaml/indent 要求序列项内容缩进 3 空格,
// 与 style/no-multi-spaces 相互冲突(会触发 ESLint circular fixes),此处关掉后者
{
files: ['**/*.yaml', '**/*.yml'],
rules: {
'style/no-multi-spaces': 'off',
},
},
)
# ===== 应用元信息 =====
APP_ENV=dev
VITE_APP_ENV=dev
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=0
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=1
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=staging
VITE_APP_ENV=stage
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=3
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=test
VITE_APP_ENV=test
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=2
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
......@@ -2,22 +2,19 @@
interface ImportMetaEnv {
readonly VITE_APP_ENV: string
readonly VITE_APP_ID: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_DEBUG: string
readonly VITE_APP_LOG_ENV: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_UNIPLAT_WEBSOCKET_URI: string
readonly VITE_DEBUG: string
readonly VITE_APP_PAY_URL: string
readonly VITE_APP_WWW_WORK_APP_URL: string
readonly VITE_APP_API_WORK_APP_URL: string
readonly VITE_APP_API_WORK_ORG_URL: string
readonly VITE_APP_USER_CENTER: string
readonly VITE_APP_QQXB: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_APP_PAY_URL: string
readonly VITE_APP_H5_URL: string
readonly VITE_APP_LAND_PAGE: string
readonly VITE_APP_XB_URL: string
}
interface ImportMeta {
......
......@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>亲亲企服管理后台</title>
<title>管理后台</title>
</head>
<body>
<div id="app"></div>
......
{
"name": "qqqf-admin",
"name": "admin",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "管理后台模板(Vue 3 + Vite SPA)",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
"dev:test": "vite --mode test",
"dev:staging": "vite --mode staging",
"dev:production": "vite --mode production",
"build": "vue-tsc --noEmit && vite build",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:staging": "vue-tsc --noEmit && vite build --mode staging",
"build:production": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@common/sdk": "workspace:*",
"@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
......@@ -18,7 +28,6 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.3",
"unocss": "^0.58.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
......
import type { LoginRegisterResult, VerifyImageResult } from "@common/sdk"
import { loginService, verifyService } from '@/src/api/request'
import type { LoginRegisterResult, VerifyImageResult } from '@common/sdk'
import { loginService, verifyService } from '@/api/request'
export type { LoginRegisterResult, VerifyImageResult }
......
export {
getVerifyImage,
passwordLogin,
sendSmsCode,
smsLogin,
} from './auth'
export type { LoginRegisterResult, VerifyImageResult } from './auth'
export { getSdk, initToken, loginService, sdk, verifyService } from './request'
import type { SdkConfig } from '@common/sdk'
import { Sdk, TokenManager, webAdapter } from '@common/sdk'
import { createLoginService } from '@common/sdk/services/login-service'
import { createVerifyService } from '@common/sdk/services/verify-service'
import { config } from '@/config'
/**
* qqqf-admin SDK 配置
* SDK 实例(Web 平台使用 webAdapter)
*/
const sdkConfig: SdkConfig = {
uniplatApi: import.meta.env.VITE_APP_UNIPLAT,
rootEntrance: 'qqqf-admin',
logEnv: import.meta.env.VITE_APP_LOG_ENV,
clientId: import.meta.env.VITE_APP_CLIENT_ID,
domainService: {
subProjectName: 'qqqf',
serviceName: 'api',
},
}
/**
* 创建并初始化 SDK
*/
export const sdk = new Sdk(sdkConfig, webAdapter)
export const sdk = new Sdk(config, webAdapter)
/**
* 创建服务(函数式,无需实例化)
......@@ -29,7 +15,7 @@ export const loginService = createLoginService(sdk.core, sdk.clientId)
export const verifyService = createVerifyService(sdk.core, sdk.clientId)
/**
* 初始化 Token(如果
* 初始化 Token(如果本地已有有效 Token 则恢复登录态
*/
export function initToken() {
const token = TokenManager.hasToken()
......
export { useLoading } from '@common/vue-kit/composables'
import type { ClientId, SdkConfig } from '@common/sdk'
import { Environment } from '@common/sdk'
export interface AdminConfig extends SdkConfig {
/** 应用名称,用于页面标题 */
appName: string
}
/**
* 应用配置中心
*
* 所有环境变量在此集中读取,业务代码不要直接使用 `import.meta.env`。
* 带 TODO 的占位项请按项目实际情况填写。
*/
export const config: AdminConfig = {
appName: '管理后台',
uniplatApi: import.meta.env.VITE_APP_UNIPLAT || '',
// TODO: 替换为后端分配的平台入口标识
rootEntrance: '',
logEnv: (import.meta.env.VITE_APP_ENV || Environment.Dev) as Environment,
clientId: (import.meta.env.VITE_APP_CLIENT_ID || '') as ClientId,
domainService: {
// TODO: 替换为项目实际的子项目名与服务名
subProjectName: '',
serviceName: '',
},
passportUrl: import.meta.env.VITE_APP_WWW_WORK_APP_URL || '',
}
/** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login']
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { initToken } from './api/request'
import App from './App.vue'
import router from './router'
import { initToken } from '@/api/request'
import App from '@/App.vue'
import router from '@/router'
// 初始化 SDK 和 Token(已在 api/request.ts 中封装
// 恢复本地 Token 登录态(SDK 已在 api/request.ts 中初始化
initToken()
const app = createApp(App)
......
<template>
<div class="home-container">
<h1>qqqf-admin</h1>
<h1>{{ config.appName }}</h1>
<button class="test-btn" @click="testApiCall">测试 SDK 请求(携带 Token)</button>
<div v-if="result" class="result">
<h3>请求结果:</h3>
......@@ -11,15 +11,20 @@
<script setup lang="ts">
import { ref } from 'vue'
import { sdk } from '@/src/api/request'
import { sdk } from '@/api/request'
import { config } from '@/config'
const result = ref<any>(null)
async function testApiCall() {
try {
// 调用一个匿名接口测试
// 示例:调用一个匿名接口。请替换为项目实际的子项目名/服务名/接口名
const res = await sdk.core
.domainService('qqqf', 'anonymous/api', 'test')
.domainService(
config.domainService.subProjectName,
`anonymous/${config.domainService.serviceName}`,
'test',
)
.get()
result.value = res
}
......
<template>
<div class="login-container">
<div class="login-card">
<h1 class="login-title">亲亲企服管理后台</h1>
<h1 class="login-title">{{ config.appName }}</h1>
<!-- 登录方式切换 -->
<div class="login-tabs">
......@@ -101,15 +101,21 @@
</template>
<script setup lang="ts">
import { SMS_COUNTDOWN } from '@common/utils/constants'
import { isPhone } from '@common/utils/regex'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getVerifyImage, passwordLogin, sendSmsCode, smsLogin } from '@/src/api/auth'
import { useAuthStore } from '@/src/stores/auth'
import { Toast } from '@/src/utils/toast'
import { getVerifyImage } from '@/api/auth'
import { config } from '@/config'
import {
handlePasswordLogin as loginByPassword,
handleSmsLogin as loginBySms,
handleSendSmsCode as requestSmsCode,
} from '@/services'
import { Toast } from '@/utils/toast'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loginType = ref<'sms' | 'password'>('sms')
......@@ -150,7 +156,7 @@ async function sendSms() {
Toast.warning('请输入手机号')
return
}
if (!/^1\d{10}$/.test(smsForm.value.mobile)) {
if (!isPhone(smsForm.value.mobile)) {
Toast.warning('手机号格式不正确')
return
}
......@@ -159,16 +165,15 @@ async function sendSms() {
return
}
try {
await sendSmsCode(
const ok = await requestSmsCode(
smsForm.value.mobile,
verifyImage.value.seed,
smsForm.value.verifycode,
)
Toast.success('验证码已发送')
if (!ok) return
// 开始倒计时
countdown.value = 60
countdown.value = SMS_COUNTDOWN
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && timer) {
......@@ -176,10 +181,6 @@ async function sendSms() {
timer = null
}
}, 1000)
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
}
}
// 验证码登录
......@@ -195,16 +196,8 @@ async function handleSmsLogin() {
smsLoading.value = true
try {
const res = await smsLogin(smsForm.value.mobile, smsForm.value.smsCode)
const result = res as any
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
const ok = await loginBySms(smsForm.value.mobile, smsForm.value.smsCode)
if (ok) redirectAfterLogin()
}
finally {
smsLoading.value = false
......@@ -224,25 +217,23 @@ async function handlePasswordLogin() {
passwordLoading.value = true
try {
const res = await passwordLogin(
const ok = await loginByPassword(
passwordForm.value.username,
passwordForm.value.password,
)
const result = res as any
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
if (ok) redirectAfterLogin()
}
finally {
passwordLoading.value = false
}
}
// 登录成功后回跳
function redirectAfterLogin() {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
// 初始化图形验证码
refreshVerifyImage()
</script>
......
import type { RouteRecordRaw } from 'vue-router'
import { TokenManager } from '@common/sdk'
import { createRouter, createWebHistory } from 'vue-router'
import { config } from '@/config'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('../pages/login/index.vue'),
component: () => import('@/pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false },
},
{
path: '/',
name: 'Home',
component: () => import('../pages/index.vue'),
component: () => import('@/pages/index.vue'),
meta: { title: '首页', requiresAuth: true },
},
]
......@@ -23,7 +24,7 @@ const router = createRouter({
})
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || '亲亲企服管理后台'
document.title = (to.meta.title as string) || config.appName
const token = TokenManager.hasToken()
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
......
import { passwordLogin, sendSmsCode, smsLogin } from '@/api/auth'
import { sdk } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
import { Toast } from '@/utils/toast'
/** 保存登录结果:持久化 Token 并同步 SDK 登录态 */
function saveToken(jwt: string) {
useAuthStore().setToken(jwt)
sdk.core.loginByToken({ token: jwt })
}
/**
* 发送短信验证码
*
* @param mobile 手机号
* @param seed 图形验证码 seed
* @param imgCode 图形验证码
*/
export async function handleSendSmsCode(
mobile: string,
seed: string,
imgCode: string,
): Promise<boolean> {
try {
await sendSmsCode(mobile, seed, imgCode)
Toast.success('验证码已发送')
return true
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
return false
}
}
/** 短信验证码登录 */
export async function handleSmsLogin(
mobile: string,
verifycode: string,
): Promise<boolean> {
try {
const result = (await smsLogin(mobile, verifycode)) as any
saveToken(result.jwt)
Toast.success('登录成功')
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
return false
}
}
/** 密码登录 */
export async function handlePasswordLogin(
username: string,
password: string,
): Promise<boolean> {
try {
const result = (await passwordLogin(username, password)) as any
saveToken(result.jwt)
Toast.success('登录成功')
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
return false
}
}
export {
handlePasswordLogin,
handleSendSmsCode,
handleSmsLogin,
} from './auth-service'
import type { UserInfo } from '../types/auth'
import type { UserInfo } from '@/types/auth'
import { TokenManager } from '@common/sdk'
import { defineStore } from 'pinia'
import { ref } from 'vue'
......
export { useAuthStore } from './auth'
export type { UserInfo } from './auth'
export { Toast } from './toast'
......@@ -10,7 +10,7 @@
"moduleResolution": "bundler",
"paths": {
"@/*": [
"./*"
"./src/*"
]
},
"resolveJsonModule": true,
......
......@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, '.'),
'@': resolve(__dirname, 'src'),
},
},
build: {
......
# ===== 应用元信息 =====
APP_ENV=dev
VITE_APP_ENV=dev
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=0
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 小程序 / 公众号 appid =====
VITE_MP_APPID=
VITE_MP_ACCOUNT_APPID=
# ===== 第三方 =====
VITE_MAP_KEY=
# ===== 测试账号(仅非生产环境)=====
VITE_APP_TEST_ACCOUNT=
VITE_APP_TEST_PASSPORT=
# ===== 应用元信息 =====
APP_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=1
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 小程序 / 公众号 appid =====
VITE_MP_APPID=
VITE_MP_ACCOUNT_APPID=
# ===== 第三方 =====
VITE_MAP_KEY=
# ===== 应用元信息 =====
APP_ENV=staging
VITE_APP_ENV=stage
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=3
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 小程序 / 公众号 appid =====
VITE_MP_APPID=
VITE_MP_ACCOUNT_APPID=
# ===== 第三方 =====
VITE_MAP_KEY=
# ===== 测试账号(仅非生产环境)=====
VITE_APP_TEST_ACCOUNT=
VITE_APP_TEST_PASSPORT=
# ===== 应用元信息 =====
APP_ENV=test
VITE_APP_ENV=test
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=2
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 小程序 / 公众号 appid =====
VITE_MP_APPID=
VITE_MP_ACCOUNT_APPID=
# ===== 第三方 =====
VITE_MAP_KEY=
# ===== 测试账号(仅非生产环境)=====
VITE_APP_TEST_ACCOUNT=
VITE_APP_TEST_PASSPORT=
{
"name": "qqqf-mp",
"version": "0.0.0",
"name": "mini",
"version": "1.0.0",
"private": true,
"description": "小程序模板(UniApp + Vue 3 + TypeScript)",
"scripts": {
"dev": "uni -p mp-weixin",
"dev:h5": "uni",
......@@ -24,6 +25,7 @@
"dependencies": {
"@common/sdk": "workspace:*",
"@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"@dcloudio/uni-app": "3.0.0-4070520250711001",
"@dcloudio/uni-components": "3.0.0-4070520250711001",
"@dcloudio/uni-h5": "3.0.0-4070520250711001",
......
<script lang="ts" setup>
import { TokenManager } from "@common/sdk"
import { onLaunch } from "@dcloudio/uni-app"
import { sdk } from "@/api/http"
import { getSdk } from "@/utils/sdk"
onLaunch(() => {
console.log("App Launch")
sdkInit()
})
// 恢复本地 Token 登录态
function sdkInit() {
const token = TokenManager.hasToken()
if (token) {
sdk.core.loginByToken({
getSdk().core.loginByToken({
token: token as string,
})
}
......@@ -19,8 +20,9 @@ function sdkInit() {
</script>
<style lang="scss">
@import "@/assets/styles/common.scss";
@import "@/assets/styles/style.scss";
// 全局样式(@use 替代已废弃的 @importDart Sass 3.0 将移除 @import
@use "@/assets/styles/common.scss";
@use "@/assets/styles/style.scss";
view {
background-repeat: no-repeat;
......
/**
* 登录服务
* 提供微信一键登录、短信验证码登录、密码登录等接口调用
*/
import { sdk } from "@/api/http"
import { config } from "@/config"
import { getSdk } from "@/utils/sdk"
export interface LoginResult {
jwt: string
......@@ -36,24 +32,18 @@ export function getWxLoginCode(): Promise<string> {
* 微信小程序绑定手机号登录
*/
export function miniProgramBindPhone(
appid: string,
code: string,
clientId: string,
encryptedData: string,
iv: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return sdk.domainServicePost<
any,
any,
LoginResult
>(
return getSdk().domainServicePost<any, any, LoginResult>(
"bind_phone",
{
data: {
appid,
appid: config.mpAppid,
code,
client_id: clientId,
client_id: config.clientId,
encryptedData,
iv,
...registerChannel,
......@@ -62,21 +52,15 @@ export function miniProgramBindPhone(
true, // 匿名接口
"uniplat_base",
"system.wechat",
config,
)
}
/**
* 发送短信验证码
*/
export function sendSmsCode(
mobile: string,
imgCode: string,
): Promise<void> {
return sdk.domainServicePost<
any,
any,
void
>(
export function sendSmsCode(mobile: string, imgCode: string): Promise<void> {
return getSdk().domainServicePost<any, any, void>(
"send_verifycode",
{
data: {
......@@ -88,6 +72,7 @@ export function sendSmsCode(
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
......@@ -99,11 +84,7 @@ export function smsLogin(
verifycode: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return sdk.domainServicePost<
any,
any,
LoginResult
>(
return getSdk().domainServicePost<any, any, LoginResult>(
"login_with_mobile",
{
data: {
......@@ -116,6 +97,7 @@ export function smsLogin(
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
......@@ -127,11 +109,7 @@ export function passwordLogin(
password: string,
registerChannel: RegisterChannel = {},
): Promise<LoginResult> {
return sdk.domainServicePost<
any,
any,
LoginResult
>(
return getSdk().domainServicePost<any, any, LoginResult>(
"login_with_password",
{
data: {
......@@ -144,6 +122,7 @@ export function passwordLogin(
true,
"uniplat_base",
"anonymous/system.user",
config,
)
}
......@@ -151,14 +130,14 @@ export function passwordLogin(
* 获取图形验证码
*/
export function getImageCode(): Promise<string> {
return sdk.domainServiceGet<
any,
{ img_code: string }
>(
return getSdk()
.domainServiceGet<any, { img_code: string }>(
"get_image_code",
{},
true,
"uniplat_base",
"system.user",
).then(res => res.img_code)
config,
)
.then(res => res.img_code)
}
import { config } from "@/config"
import { getSdk } from "@/utils/sdk"
export interface HomeData {
title: string
description: string
bannerUrl: string
}
/**
* 获取首页数据
*
* 示例接口,请替换为项目实际的接口名。
*/
export async function fetchHomeData(): Promise<HomeData> {
try {
const service = getSdk().core.domainService(
config.domainService.subProjectName,
config.domainService.serviceName,
"getHomeData",
)
return await service.request<any, any, HomeData>("get", {})
} catch (err) {
console.log(`err: ${err}`)
return {
title: "默认标题",
description: "默认描述",
bannerUrl: "",
}
}
}
export {
getImageCode,
getWxLoginCode,
miniProgramBindPhone,
passwordLogin,
sendSmsCode,
smsLogin,
} from "./auth"
export type { LoginResult, RegisterChannel } from "./auth"
export { fetchHomeData } from "./home"
export type { HomeData } from "./home"
<template>
<view v-if="mode === 'auto'" :class="rootClasses" :style="rootStyles">
<slot />
</view>
<view
v-else
:class="rootClasses"
:style="rootStyles"
>
<view
v-if="showHeader"
class="page__header" :class="[safeTop ? 'page--safe-top' : '', headerClassName]"
:style="headerStyle"
>
<slot name="header" />
</view>
<scroll-view
scroll-y
class="page__scroll" :class="[scrollClassName]"
:style="scrollStyle"
:scroll-top="scrollTop"
:enable-back-to-top="true"
:show-scrollbar="false"
@scroll="onScroll"
@scrolltoupper="$emit('scrolltoupper', $event)"
@scrolltolower="$emit('scrolltolower', $event)"
>
<slot />
</scroll-view>
<view
v-if="showFooter"
class="page__footer" :class="[safeBottom ? 'page--safe-bottom' : '', footerClassName]"
:style="footerStyle"
>
<slot name="footer" />
</view>
</view>
</template>
<script lang="ts" setup>
import type { CSSProperties } from "vue"
import { computed, ref } from "vue"
export type PageMode = "auto" | "flex"
const props = withDefaults(
defineProps<{
mode?: PageMode
header?: boolean
safeTop?: boolean
footer?: boolean
safeBottom?: boolean
rootClassName?: string
rootStyle?: CSSProperties | string
headerClassName?: string
headerStyle?: CSSProperties | string
scrollClassName?: string
scrollStyle?: CSSProperties | string
footerClassName?: string
footerStyle?: CSSProperties | string
}>(),
{
mode: "auto",
header: false,
safeTop: false,
footer: false,
safeBottom: false,
rootClassName: "",
rootStyle: "",
headerClassName: "",
headerStyle: "",
scrollClassName: "",
scrollStyle: "",
footerClassName: "",
footerStyle: "",
},
)
const emit = defineEmits<{
scroll: [e: any]
scrolltoupper: [e: any]
scrolltolower: [e: any]
}>()
const scrollTop = ref(0)
const showHeader = computed(() => props.header)
const showFooter = computed(() => props.footer)
const rootClasses = computed(() => [
props.mode === "auto" ? "page--auto" : "page--flex",
{
"page--has-footer": showFooter.value,
"page--has-header": showHeader.value,
},
props.rootClassName,
])
const rootStyles = computed(() => {
if (typeof props.rootStyle === "string") return props.rootStyle
return props.rootStyle
})
function onScroll(e: any) {
emit("scroll", e)
}
function scrollToTop() {
scrollTop.value = Math.random() > 0.5 ? -1 : 1
setTimeout(() => {
scrollTop.value = 0
}, 100)
}
defineExpose({ scrollToTop })
</script>
<style lang="scss" scoped>
.page--auto {
width: 100%;
min-height: 100vh;
}
.page--flex {
display: flex;
flex-direction: column;
height: 100vh;
width: 100%;
box-sizing: border-box;
}
.page--flex .page__header {
flex-shrink: 0;
&.page--safe-top {
padding-top: env(safe-area-inset-top);
}
}
.page--flex .page__scroll {
flex: 1;
overflow-y: auto;
width: 100%;
box-sizing: border-box;
}
.page--flex .page__footer {
flex-shrink: 0;
&.page--safe-bottom {
padding-bottom: env(safe-area-inset-bottom);
}
}
</style>
export { default as PageLayout } from "./common/page-layout.vue"
export { useToast } from "./useToast"
export { useLoading } from "@common/vue-kit/composables"
type ToastIcon = "success" | "error" | "loading" | "none"
interface ToastOptions {
title: string
icon?: ToastIcon
duration?: number
mask?: boolean
}
/**
* Toast 提示(基于 uni.showToast)
*/
export function useToast() {
function show(options: ToastOptions) {
const { title, icon = "none", duration = 2000, mask = false } = options
uni.showToast({ title, icon, duration, mask })
}
function success(title: string) {
show({ title, icon: "success" })
}
function error(title: string) {
show({ title, icon: "error" })
}
/** 显示加载中提示,需手动调用 hide 关闭 */
function loading(title: string = "加载中...") {
uni.showLoading({ title, mask: true })
}
function hide() {
uni.hideLoading()
uni.hideToast()
}
return { show, success, error, loading, hide }
}
import type { ClientId, Product, SdkConfig } from "@common/sdk"
import type { App } from "@vue/runtime-core"
import { Environment } from "@common/sdk"
import packageConfig from "../../package.json"
export { Environment }
/** 注册渠道信息,随登录/注册接口一起上报 */
export interface RegisterConfig {
app?: string
scene?: string
app_store?: string
[key: string]: any
}
export interface MpConfig extends SdkConfig {
/** 应用名称 */
appName: string
/** 公众号名称 */
officialAccountName?: string
/** 支付服务地址 */
payUrl?: string
/** 通行证 API 地址 */
passportApi?: string
/** 组织相关 API 地址 */
passportOrg?: string
/** 用户中心地址 */
userCenter?: string
/** 配套 H5 地址 */
h5Url?: string
/** 图片资源根地址 */
imgBaseUrl: string
/** WebSocket 地址 */
uniplatSocketUrl?: string
/** 客户端密钥 */
clientSecret?: string
/** 版本号,取自 package.json */
version: string
/** 微信小程序 appid */
mpAppid?: string
/** 微信公众号 appid */
mpAccountAppid?: string
/** 地图 key */
mapKey?: string
/** 分享配置 */
shareConfig: {
title: string
img?: string
path: string
forceUse?: boolean
hideShareBy?: boolean
}
/** 监控产品标识 */
monitorProductKey?: Product
/** 城市编码长度 */
cityCodeLength: number
/** 审核中的版本号(用于隐藏审核期功能) */
reviewVersion?: number
/** 注册渠道 */
registerConfig?: RegisterConfig
/** 测试账号(仅非生产环境使用) */
testAccount?: string
/** 测试密码(仅非生产环境使用) */
testPassport?: string
}
/**
* 应用配置中心
*
* 所有环境变量在此集中读取,业务代码不要直接使用 `import.meta.env`。
* 带 TODO 的占位项请按项目实际情况填写。
*/
export const config: MpConfig = {
// TODO: 替换为项目名称
appName: "小程序模板",
payUrl: import.meta.env.VITE_APP_PAY_URL || "",
passportUrl: import.meta.env.VITE_APP_WWW_WORK_APP_URL || "",
passportApi: import.meta.env.VITE_APP_API_WORK_APP_URL || "",
passportOrg: import.meta.env.VITE_APP_API_WORK_ORG_URL || "",
userCenter: import.meta.env.VITE_APP_USER_CENTER || "",
uniplatApi: import.meta.env.VITE_APP_UNIPLAT || "",
uniplatSocketUrl: import.meta.env.VITE_APP_UNIPLAT_WEBSOCKET_URI || "",
h5Url: import.meta.env.VITE_APP_H5_URL || "",
imgBaseUrl: `${import.meta.env.VITE_APP_LAND_PAGE || ""}/img/`,
// TODO: 替换为后端分配的平台入口标识
rootEntrance: "",
domainService: {
// TODO: 替换为项目实际的子项目名与服务名
subProjectName: "",
serviceName: "",
},
clientId: (import.meta.env.VITE_APP_CLIENT_ID || "") as ClientId,
clientSecret: import.meta.env.VITE_APP_CLIENT_SECRET || "",
logEnv: (import.meta.env.VITE_APP_ENV || Environment.Dev) as Environment,
version: packageConfig.version,
mpAppid: import.meta.env.VITE_MP_APPID || "",
mpAccountAppid: import.meta.env.VITE_MP_ACCOUNT_APPID || "",
mapKey: import.meta.env.VITE_MAP_KEY || "",
testAccount: import.meta.env.VITE_APP_TEST_ACCOUNT || "",
testPassport: import.meta.env.VITE_APP_TEST_PASSPORT || "",
shareConfig: {
// TODO: 替换为项目分享标题与分享图
title: "",
img: "",
path: "/pages/login/index",
},
cityCodeLength: 6,
registerConfig: {},
}
/** 免登录页面白名单(pages.json 中的 path) */
export const noNeedAuthPages = [
"pages/home/index",
]
export function initCommon(app: App): App {
// Vue3 不再需要 filters 和 mixins
return app
}
/**
* 当前运行环境
*/
export class CoreEnvir {
private static currentEnv: Environment | null = null
static getCurrent(): Environment {
if (this.currentEnv) return this.currentEnv
const envMode = import.meta.env.VITE_APP_ENV
if (
envMode &&
Object.values(Environment).includes(envMode as Environment)
) {
this.currentEnv = envMode as Environment
return this.currentEnv
}
return import.meta.env.MODE === "development"
? Environment.Dev
: Environment.Pro
}
static switch(env: Environment) {
this.currentEnv = env
}
static isDev() {
return this.getCurrent() === Environment.Dev
}
static isPro() {
return this.getCurrent() === Environment.Pro
}
}
/// <reference types="@dcloudio/types" />
declare module "*.vue" {
import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
/** Vite 构建模式(development / test / staging / production) */
readonly MODE: string
readonly VITE_APP_ENV: string
readonly VITE_DEBUG: string
readonly VITE_APP_LOG_ENV: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_UNIPLAT_WEBSOCKET_URI: string
readonly VITE_APP_WWW_WORK_APP_URL: string
readonly VITE_APP_API_WORK_APP_URL: string
readonly VITE_APP_API_WORK_ORG_URL: string
readonly VITE_APP_USER_CENTER: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_APP_PAY_URL: string
readonly VITE_APP_H5_URL: string
readonly VITE_APP_LAND_PAGE: string
readonly VITE_MP_APPID: string
readonly VITE_MP_ACCOUNT_APPID: string
readonly VITE_MAP_KEY: string
readonly VITE_APP_TEST_ACCOUNT: string
readonly VITE_APP_TEST_PASSPORT: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
import { createPinia } from "pinia"
import { createSSRApp } from "vue"
import App from "@/App.vue"
console.log("env", import.meta.env)
import { initSdk } from "@/utils/sdk"
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
initSdk()
return { app }
}
{
"name": "qqqf-mp",
"name": "小程序模板",
"appid": "",
"description": "",
"versionName": "1.0.0",
......
<template>
<view class="home-page">
<view class="header">
<text class="title">欢迎来到 QQQF</text>
<text class="title">欢迎使用 {{ config.appName }}</text>
</view>
<view class="content">
......@@ -25,8 +25,8 @@
<view class="card">
<text class="card-title">环境信息</text>
<text class="info-text">API 地址: {{ apiBaseUrl }}</text>
<text class="info-text">环境: {{ env }}</text>
<text class="info-text">API 地址: {{ config.uniplatApi || "未配置" }}</text>
<text class="info-text">环境: {{ currentEnv }}</text>
</view>
</view>
</view>
......@@ -34,14 +34,14 @@
<script setup lang="ts">
import { ref } from "vue"
import { fetchHomeData, type HomeData } from "@/api/home"
import { fetchHomeData, type HomeData } from "@/api"
import { config, CoreEnvir } from "@/config"
const homeData = ref<HomeData | null>(null)
const loading = ref(false)
const errorMsg = ref("")
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || "未配置"
const env = import.meta.env.VITE_ENV || "development"
const currentEnv = CoreEnvir.getCurrent()
async function handleFetchData() {
loading.value = true
......
......@@ -43,17 +43,15 @@
</template>
<script lang="ts" setup>
import { TokenManager } from "@common/sdk"
import { onLoad } from "@dcloudio/uni-app"
import { ref } from "vue"
import { miniProgramBindPhone } from "@/api/login-service"
import { config } from "@/config"
import { handleWxLogin } from "@/services"
const agree = ref(false)
onLoad((e: { backUrl?: string }) => {
if (e.backUrl) {
uni.setStorageSync("loginRedirect", e.backUrl)
onLoad((query?: AnyObject) => {
if (query?.backUrl) {
uni.setStorageSync("loginRedirect", query.backUrl)
}
})
......@@ -88,33 +86,15 @@ async function doWxLogin(e: any) {
uni.showLoading({ title: "登录中...", mask: true })
try {
// 获取微信登录 code
const code = await new Promise<string>((resolve, reject) => {
uni.login({
provider: "weixin",
success: res => resolve(res.code),
fail: err => reject(err),
})
})
// 调用绑定手机号登录接口
const result = await miniProgramBindPhone(
config.mpAppid || "",
code,
config.clientId,
e.detail.encryptedData,
e.detail.iv,
)
// 保存 token
TokenManager.saveToken2Storage(result.jwt)
uni.hideLoading()
uni.showToast({ title: "登录成功", icon: "success" })
const ok = await handleWxLogin(e.detail.encryptedData, e.detail.iv)
if (!ok) return
setTimeout(() => {
const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index"
uni.reLaunch({ url: redirect })
}, 1500)
} catch (err: any) {
} finally {
uni.hideLoading()
uni.showToast({ title: err?.msg || err?.message || "登录失败,请重试", icon: "none" })
}
}
......
......@@ -141,10 +141,15 @@
<script lang="ts" setup>
import type { VerifyImageResult } from "@common/sdk"
import { TokenManager } from "@common/sdk"
import { SMS_COUNTDOWN } from "@common/utils/constants"
import { isPhone } from "@common/utils/regex"
import { onLoad } from "@dcloudio/uni-app"
import { computed, reactive, ref } from "vue"
import { loginService, verifyService } from "@/api/http"
import { useToast } from "@/composables"
import { handlePasswordLogin, handleSmsLogin } from "@/services"
import { getVerifyService } from "@/utils/sdk"
const { error: toastError, success: toastSuccess } = useToast()
const activeTab = ref<"sms" | "password">("sms")
......@@ -177,12 +182,12 @@ let codeTimer: ReturnType<typeof setInterval> | null = null
const imageCodeModel = ref<VerifyImageResult | null>(null)
function getVerifyCodeImg() {
imageCodeModel.value = verifyService.generateImage()
imageCodeModel.value = getVerifyService().generateImage()
}
function startCodeCountdown() {
codeCounting.value = true
codeCountdown.value = 60
codeCountdown.value = SMS_COUNTDOWN
codeTips.value = `重新获取(${codeCountdown.value}s)`
codeTimer = setInterval(() => {
codeCountdown.value--
......@@ -203,68 +208,60 @@ function stopCodeCountdown() {
codeTips.value = "获取验证码"
}
function isPhone(val: string) {
return /^1[3-9]\d{9}$/.test(val)
}
async function getCode() {
if (!smsModel.mobile) {
uni.showToast({ title: "请输入手机号", icon: "none" })
toastError("请输入手机号")
return
}
if (!isPhone(smsModel.mobile)) {
uni.showToast({ title: "手机号码不正确", icon: "none" })
toastError("手机号码不正确")
return
}
if (!smsModel.imgCode) {
uni.showToast({ title: "请输入图形验证码", icon: "none" })
toastError("请输入图形验证码")
return
}
if (codeCounting.value) {
uni.showToast({ title: "倒计时结束后再发送", icon: "none" })
toastError("倒计时结束后再发送")
return
}
uni.showLoading({ title: "正在获取验证码", mask: true })
try {
await verifyService.sendVerifyCode(
await getVerifyService().sendVerifyCode(
smsModel.mobile,
imageCodeModel.value?.seed || "",
smsModel.imgCode,
)
uni.hideLoading()
uni.showToast({ title: "验证码已发送", icon: "success" })
toastSuccess("验证码已发送")
startCodeCountdown()
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err?.msg || err?.message || "发送失败", icon: "none" })
toastError(err?.msg || err?.message || "发送失败")
}
}
async function smsLogin() {
if (!smsAgree.value) {
uni.showToast({ title: "需同意《服务协议》《隐私政策》", icon: "none" })
toastError("需同意《服务协议》《隐私政策》")
return
}
if (!smsModel.mobile) {
return uni.showToast({ title: "请输入手机号", icon: "none" })
toastError("请输入手机号")
return
}
if (!isPhone(smsModel.mobile)) {
return uni.showToast({ title: "手机号码不正确", icon: "none" })
toastError("手机号码不正确")
return
}
if (!smsModel.code) {
return uni.showToast({ title: "请输入验证码", icon: "none" })
toastError("请输入验证码")
return
}
smsLoading.value = true
try {
const result = await loginService.verifyCodeLogin(smsModel.mobile, smsModel.code)
TokenManager.saveToken2Storage(result.jwt)
uni.showToast({ title: "登录成功", icon: "success" })
setTimeout(() => {
const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index"
uni.reLaunch({ url: redirect })
}, 1500)
} catch (err: any) {
uni.showToast({ title: err?.msg || err?.message || "登录失败", icon: "none" })
const ok = await handleSmsLogin(smsModel.mobile, smsModel.code)
if (ok) redirectAfterLogin()
} finally {
smsLoading.value = false
}
......@@ -272,29 +269,35 @@ async function smsLogin() {
async function passwordLogin() {
if (!pwdAgree.value) {
uni.showToast({ title: "需同意《服务协议》《隐私政策》", icon: "none" })
toastError("需同意《服务协议》《隐私政策》")
return
}
if (!passwordModel.username) {
return uni.showToast({ title: "请输入用户名或手机号", icon: "none" })
toastError("请输入用户名或手机号")
return
}
if (!passwordModel.password) {
return uni.showToast({ title: "请输入密码", icon: "none" })
toastError("请输入密码")
return
}
pwdLoading.value = true
try {
const result = await loginService.login(passwordModel.username, passwordModel.password)
TokenManager.saveToken2Storage(result.jwt)
uni.showToast({ title: "登录成功", icon: "success" })
const ok = await handlePasswordLogin(
passwordModel.username,
passwordModel.password,
)
if (ok) redirectAfterLogin()
} finally {
pwdLoading.value = false
}
}
// 登录成功后回跳
function redirectAfterLogin() {
setTimeout(() => {
const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index"
uni.reLaunch({ url: redirect })
}, 1500)
} catch (err: any) {
uni.showToast({ title: err?.msg || err?.message || "登录失败", icon: "none" })
} finally {
pwdLoading.value = false
}
}
function goForget() {
......
......@@ -141,11 +141,15 @@
</template>
<script lang="ts" setup>
import { SMS_COUNTDOWN } from "@common/utils/constants"
import { maskPhone } from "@common/utils/mask"
import { isPhone } from "@common/utils/regex"
import { onLoad } from "@dcloudio/uni-app"
import { computed, ref } from "vue"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟数据
const userInfo = ref({ mobile: "13800138000" })
const userInfoStore = useUserInfo()
const userInfo = computed(() => userInfoStore.userInfo || { mobile: "" })
const sdkLogin = ref(true)
const phone = ref("")
......@@ -200,7 +204,7 @@ function getCode() {
function startCodeCountdown() {
codeCounting.value = true
codeCountdown.value = 60
codeCountdown.value = SMS_COUNTDOWN
sended.value = true
codeTimer = setInterval(() => {
codeCountdown.value--
......@@ -233,14 +237,9 @@ const isFormValid = computed(() => {
return phoneValid && imgVerifyCode.value && verifyCode.value && pwd.value.pwd1.value && pwd.value.pwd2.value
})
function isPhone(val: string) {
return /^1[3-9]\d{9}$/.test(val)
}
function desensitization(str: string) {
if (!str) return ""
if (str.length <= 6) return str
return str.slice(0, 3) + "****" + str.slice(-4)
// 手机号脱敏展示
function desensitization(str?: string) {
return maskPhone(str || "")
}
function confirm() {
......
<template>
<view class="c-page">
<view v-if="userInfo" class="content">
<view v-if="userInfoStore.userInfo" class="content">
<view class="c-form u-p-b-24">
<view
v-for="item in form"
......@@ -41,18 +41,12 @@
</template>
<script setup lang="ts">
import { maskIdCard, maskPhone } from "@common/utils/mask"
import { onLoad } from "@dcloudio/uni-app"
import { get } from "lodash"
import { ref } from "vue"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟用户信息(实际应从 store 获取)
const userInfo = ref<any>({
realname: "张三",
mobile: "13800138000",
user_info: {
idCard: "110101199001011234",
},
})
const userInfoStore = useUserInfo()
const form = ref([
{
......@@ -91,13 +85,13 @@ const idType = ref([
])
function getInfo(prop: string) {
return get(userInfo.value, prop) || ""
return userInfoStore.get(prop)
}
// 手机号/身份证号脱敏展示
function desensitization(str: string) {
if (!str) return ""
if (str.length <= 6) return str
return str.slice(0, 3) + "****" + str.slice(-4)
return str.length === 11 ? maskPhone(str) : maskIdCard(str)
}
const curIdType = ref<number | null>(null)
......@@ -108,7 +102,7 @@ function confirm() {
}
onLoad(() => {
console.log("userInfo", userInfo.value)
console.log("userInfo", userInfoStore.userInfo)
})
</script>
......
......@@ -25,6 +25,7 @@
<script lang="ts" setup>
import { computed } from "vue"
import { handleLogout } from "@/services"
const form = computed(() => [
{
......@@ -37,11 +38,12 @@ const form = computed(() => [
prop: "password",
route: "/pages/password/index",
},
{
label: "加盟协议",
prop: "agreement",
route: "/pages/agreement/index",
},
// TODO: 实现协议页面后取消注释
// {
// label: "用户协议",
// prop: "agreement",
// route: "/pages/agreement/index",
// },
])
function toPage(route: string) {
......@@ -50,7 +52,7 @@ function toPage(route: string) {
function exit() {
uni.showLoading({ title: "退出中..." })
// TODO: 调用登出逻辑
handleLogout()
uni.hideLoading()
uni.reLaunch({ url: "/pages/home/index" })
}
......
......@@ -5,14 +5,14 @@
<view class="verified-content">
<image
class="bg"
src="https://static.qinqinxiaobao.com/flb-mp/img/auth/auth-detail-bg.png"
:src="`${config.imgBaseUrl}auth/auth-detail-bg.png`"
></image>
<view class="u-flex text u-row-between">
<view class="">您已实名认证</view>
<view class="">
<image
class="icon"
src="https://static.qinqinxiaobao.com/flb-mp/img/auth/auth-status-logo.png"
:src="`${config.imgBaseUrl}auth/auth-status-logo.png`"
></image>
</view>
</view>
......@@ -24,7 +24,7 @@
</view>
<view class="info u-flex">
<view>身份证号</view>
<view>{{ desensitization(userInfo.user_info.idCard) }}</view>
<view>{{ desensitization(userInfo.user_info?.idCard) }}</view>
</view>
<view class="info u-flex">
<view>认证方式</view>
......@@ -161,13 +161,18 @@
</template>
<script lang="ts" setup>
import { SMS_COUNTDOWN } from "@common/utils/constants"
import { maskIdCard, maskPhone } from "@common/utils/mask"
import { isIdCard, isPhone } from "@common/utils/regex"
import { onLoad } from "@dcloudio/uni-app"
import { computed, ref } from "vue"
import { config } from "@/config"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟数据(实际应从 SDK/API 获取)
const userInfo = ref({
realname: "张三",
mobile: "13800138000",
const userInfoStore = useUserInfo()
const userInfo = computed(() => userInfoStore.userInfo || {
realname: "",
mobile: "",
user_info: { idCard: "" },
})
const isVerified = ref(false)
......@@ -221,7 +226,7 @@ function getCode() {
function startCodeCountdown() {
codeCounting.value = true
codeCountdown.value = 60
codeCountdown.value = SMS_COUNTDOWN
sended.value = true
codeTimer = setInterval(() => {
codeCountdown.value--
......@@ -254,18 +259,10 @@ const isFormValid = computed(() => {
return name.value && phoneValid && id_card.value && imgVerifyCode.value && verifyCode.value
})
function isPhone(val: string) {
return /^1[3-9]\d{9}$/.test(val)
}
function isIdentityNumber(val: string) {
return /(^\d{15}$)|(^\d{18}$)|(^\d{17}([\dXx])$)/.test(val)
}
function desensitization(str: string) {
// 手机号/身份证号脱敏展示
function desensitization(str?: string) {
if (!str) return ""
if (str.length <= 6) return str
return str.slice(0, 3) + "****" + str.slice(-4)
return str.length === 11 ? maskPhone(str) : maskIdCard(str)
}
function confirm() {
......@@ -280,7 +277,7 @@ function confirm() {
return
}
warn.value.phone = false
if (!isIdentityNumber(id_card.value)) {
if (!isIdCard(id_card.value)) {
warn.value.id_card = true
return
}
......@@ -315,7 +312,7 @@ function confirm() {
const existPhone = ref("")
function blurIdCard() {
if (!isIdentityNumber(id_card.value)) return
if (!isIdCard(id_card.value)) return
warn.value.id_card = false
idCardLoading.value = true
// TODO: 调用身份证唯一性检查接口
......
import { TokenManager } from "@common/sdk"
import {
getWxLoginCode,
type LoginResult,
miniProgramBindPhone,
passwordLogin,
type RegisterChannel,
smsLogin,
} from "@/api"
import { useToast } from "@/composables"
import { config } from "@/config"
import { useUserInfo } from "@/stores/useUserInfo"
import { getSdk } from "@/utils/sdk"
const { success, error } = useToast()
/** 短信验证码登录 */
export async function handleSmsLogin(
mobile: string,
verifycode: string,
registerChannel: RegisterChannel = config.registerConfig || {},
): Promise<boolean> {
try {
const result = await smsLogin(mobile, verifycode, registerChannel)
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("登录失败,请检查手机号与验证码")
return false
}
}
/** 密码登录 */
export async function handlePasswordLogin(
username: string,
password: string,
registerChannel: RegisterChannel = config.registerConfig || {},
): Promise<boolean> {
try {
const result = await passwordLogin(username, password, registerChannel)
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("登录失败,请检查账号密码")
return false
}
}
/** 微信一键登录(获取手机号后绑定) */
export async function handleWxLogin(
encryptedData: string,
iv: string,
registerChannel: RegisterChannel = config.registerConfig || {},
): Promise<boolean> {
try {
const code = await getWxLoginCode()
const result = await miniProgramBindPhone(
code,
encryptedData,
iv,
registerChannel,
)
saveLoginResult(result)
success("登录成功")
return true
} catch {
error("微信登录失败")
return false
}
}
/** 退出登录:清空 Token 与用户信息 */
export function handleLogout() {
TokenManager.clearToken()
useUserInfo().clearUserInfo()
}
function saveLoginResult(result: LoginResult) {
// 持久化 Token,并同步到 SDK 的登录态
TokenManager.saveToken2Storage(result.jwt)
getSdk().core.loginByToken({ token: result.jwt })
const userStore = useUserInfo()
userStore.setUserInfo({
userId: result.user_id,
mobile: result.mobile,
jwt: result.jwt,
})
}
export {
handleLogout,
handlePasswordLogin,
handleSmsLogin,
handleWxLogin,
} from "./auth-service"
import { defineStore } from "pinia"
import { computed, ref } from "vue"
export interface UserInfo {
userId?: string
realname?: string
mobile?: string
avatar?: string
user_info?: Record<string, any>
[key: string]: any
}
export const useUserInfo = defineStore("userInfo", () => {
const userInfo = ref<UserInfo | null>(null)
const isLoggedIn = computed(() => !!userInfo.value)
function setUserInfo(info: UserInfo) {
userInfo.value = info
}
function clearUserInfo() {
userInfo.value = null
}
function get(prop: string): string {
if (!userInfo.value) return ""
const keys = prop.split(".")
let result: any = userInfo.value
for (const key of keys) {
if (result === null || result === undefined) return ""
result = result[key]
}
return result ?? ""
}
return { userInfo, isLoggedIn, setUserInfo, clearUserInfo, get }
})
export {
getLoginService,
getSdk,
getVerifyService,
initSdk,
} from "./sdk"
// 通用纯工具函数统一从 @common/utils 引入,项目私有工具放在本目录下
export * from "@common/utils"
......@@ -3,15 +3,9 @@ import { createLoginService } from "@common/sdk/services/login-service"
import { createVerifyService } from "@common/sdk/services/verify-service"
import { config } from "@/config"
class Sdk extends SdkBase {
constructor() {
super(config, uniAdapter)
}
}
const sdk = new Sdk()
// 小程序 Storage 适配器(替代 localStorage)
/**
* 小程序 Storage 适配器(替代 localStorage)
*/
class UniStorage implements Storage {
get length() {
const info = uni.getStorageInfoSync()
......@@ -30,9 +24,9 @@ class UniStorage implements Storage {
}
}
key(_index: number) {
key(index: number) {
const info = uni.getStorageInfoSync()
return info.keys[_index] || null
return info.keys[index] || null
}
removeItem(key: string) {
......@@ -44,11 +38,45 @@ class UniStorage implements Storage {
}
}
// 设置小程序存储实例(替代 localStorage)
TokenManager.setupStorage(new UniStorage())
class Sdk extends SdkBase {
constructor() {
super(config, uniAdapter)
}
}
// 创建服务(函数式,无需实例化)
export const loginService = createLoginService(sdk.core, sdk.clientId)
export const verifyService = createVerifyService(sdk.core, sdk.clientId)
let sdkInstance: Sdk | null = null
let loginServiceInstance: ReturnType<typeof createLoginService> | null = null
let verifyServiceInstance: ReturnType<typeof createVerifyService> | null = null
export { sdk }
/**
* 初始化 SDK(在 main.ts 的 createApp 中调用,幂等)
*/
export function initSdk() {
if (sdkInstance) return
TokenManager.setupStorage(new UniStorage())
sdkInstance = new Sdk()
loginServiceInstance = createLoginService(sdkInstance.core, sdkInstance.clientId)
verifyServiceInstance = createVerifyService(sdkInstance.core, sdkInstance.clientId)
}
export function getSdk() {
if (!sdkInstance) {
initSdk()
}
return sdkInstance!
}
export function getLoginService() {
if (!loginServiceInstance) {
initSdk()
}
return loginServiceInstance!
}
export function getVerifyService() {
if (!verifyServiceInstance) {
initSdk()
}
return verifyServiceInstance!
}
APP_ENV=production
NODE_ENV=production
APP_ENV=staging
NODE_ENV=production
APP_ENV=test
NODE_ENV=production
APP_ENV=dev
NODE_ENV=development
VITE_APP_ENV=dev
VITE_APP_ID=dev_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL=https://payment-api.teammix.com
VITE_APP_WWW_WORK_APP_URL=http://106.120.107.150:5000
VITE_APP_API_WORK_APP_URL=http://106.120.107.150:8090
VITE_APP_API_WORK_ORG_URL=http://106.120.107.150:7771
VITE_APP_USER_CENTER=http://106.120.107.150:8080
VITE_APP_UNIPLAT=http://hro.test-api.qqxb.jinsehuaqin.com:8800
VITE_APP_QQXB=http://test-qqxb-h5.hrs100.com
VITE_APP_H5_URL=https://static.qinqinxiaobao.com/flb-mp
VITE_APP_LAND_PAGE=https://static.qinqinxiaobao.com/flb-mp
VITE_APP_BJRSY_COLLECTION=https://bjcjtest.e-tecsun.com
VITE_APP_XB_URL=http://test-qqxb-h5.hrs100.com
VITE_APP_CLIENT_ID=qqqf-admin-web
VITE_APP_UNIPLAT_WEBSOCKET_URI=ws://hro.channel.jinsehuaqin.com:8080/ws
VITE_APP_CLIENT_SECRET=123456
VITE_APP_LOG_ENV=0
APP_ENV=production
NODE_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://userapi.teammix.com"
VITE_APP_USER_CENTER = "http://tmxlogin.teammix.com"
VITE_APP_UNIPLAT = "https://api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://channel.qinqinxiaobao.com/ws"
VITE_APP_XB_URL="https://qqxb-h5.qinqinxiaobao.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 1
APP_ENV=staging
NODE_ENV=production
VITE_APP_ENV=pre
VITE_APP_ID=pre_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://pre-passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://pre-userapi.teammix.com"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "https://pre-user.teammix.com"
VITE_APP_UNIPLAT = "https://pre-api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_XB_URL="https://pre-qqxb-h5.hrs100.com"
VITE_APP_FLB_URL = "http://pre-flb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://pre-channel.qinqinxiaobao.com/ws"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 3
APP_ENV=test
NODE_ENV=production
VITE_APP_ENV=test
VITE_APP_ID=test_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "http://106.120.107.150:5000"
VITE_APP_API_WORK_APP_URL = "http://106.120.107.150:8090"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "http://106.120.107.150:8080"
VITE_APP_UNIPLAT = "http://hro.test-api.qqxb.jinsehuaqin.com:8800"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_BJRSY_COLLECTION = "https://bjcjtest.e-tecsun.com"
VITE_APP_XB_URL="http://test-qqxb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "qqqf-admin-web"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "ws://hro.channel.jinsehuaqin.com:8080/ws"
VITE_APP_CLIENT_SECRET = "123456"
VITE_APP_LOG_ENV = 0
APP_ENV=dev
NODE_ENV=development
VITE_APP_ENV=dev
VITE_APP_ID=dev_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "http://106.120.107.150:5000"
VITE_APP_API_WORK_APP_URL = "http://106.120.107.150:8090"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "http://106.120.107.150:8080"
VITE_APP_UNIPLAT = "http://hro.test-api.qqxb.jinsehuaqin.com:8800"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_BJRSY_COLLECTION = "https://bjcjtest.e-tecsun.com"
VITE_APP_XB_URL="http://test-qqxb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "hrs100_wechat_mini_app"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "ws://hro.channel.jinsehuaqin.com:8080/ws"
VITE_APP_CLIENT_SECRET = "123456"
VITE_APP_LOG_ENV = 0
APP_ENV=production
NODE_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://userapi.teammix.com"
VITE_APP_USER_CENTER = "http://tmxlogin.teammix.com"
VITE_APP_UNIPLAT = "https://api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://channel.qinqinxiaobao.com/ws"
VITE_APP_XB_URL="https://qqxb-h5.qinqinxiaobao.com"
VITE_APP_CLIENT_ID = "hrs100_wechat_mini_app"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 1
APP_ENV=staging
NODE_ENV=production
VITE_APP_ENV=pre
VITE_APP_ID=pre_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "https://pre-passport.teammix.com"
VITE_APP_API_WORK_APP_URL = "https://pre-userapi.teammix.com"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "https://pre-user.teammix.com"
VITE_APP_UNIPLAT = "https://pre-api-hro.qinqinxiaobao.com"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_XB_URL="https://pre-qqxb-h5.hrs100.com"
VITE_APP_FLB_URL = "http://pre-flb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "qqxb_mini_app"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "wss://pre-channel.qinqinxiaobao.com/ws"
VITE_APP_CLIENT_SECRET = "qqxb#teammix#2019"
VITE_APP_LOG_ENV = 3
APP_ENV=test
NODE_ENV=production
VITE_APP_ENV=test
VITE_APP_ID=test_app_id
VITE_DEBUG=true
VITE_APP_PAY_URL="https://payment-api.teammix.com"
VITE_APP_WWW_WORK_APP_URL = "http://106.120.107.150:5000"
VITE_APP_API_WORK_APP_URL = "http://106.120.107.150:8090"
VITE_APP_API_WORK_ORG_URL = "http://106.120.107.150:7771"
VITE_APP_USER_CENTER = "http://106.120.107.150:8080"
VITE_APP_UNIPLAT = "http://hro.test-api.qqxb.jinsehuaqin.com:8800"
VITE_APP_QQXB = "http://test-qqxb-h5.hrs100.com"
VITE_APP_H5_URL = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_LAND_PAGE = "https://static.qinqinxiaobao.com/flb-mp"
VITE_APP_BJRSY_COLLECTION = "https://bjcjtest.e-tecsun.com"
VITE_APP_XB_URL="http://test-qqxb-h5.hrs100.com"
VITE_APP_CLIENT_ID = "hrs100_wechat_mini_app"
VITE_APP_UNIPLAT_WEBSOCKET_URI = "ws://hro.channel.jinsehuaqin.com:8080/ws"
VITE_APP_CLIENT_SECRET = "123456"
VITE_APP_LOG_ENV = 0
import { sdk } from "./http"
export interface HomeData {
title: string
description: string
bannerUrl: string
}
/** 获取首页数据 */
export async function fetchHomeData() {
return await sdk.core
.domainService("qqqf", "api", "getHomeData")
.request("get", {})
.then((r: any) => {
return r as HomeData
})
.catch((err: any) => {
console.log(`err: ${err}`)
return {
title: "默认标题",
description: "默认描述",
bannerUrl: "",
} as HomeData
})
}
import type { ClientId, Product, SdkConfig } from "@common/sdk"
import type { App } from "@vue/runtime-core"
import packageConfig from "../../package.json"
export enum Environment {
Dev = "dev",
Pro = "pro",
Test = "test",
Stage = "stage",
}
export interface MpConfig extends SdkConfig {
appName?: string
appSourceId?: string
officialAccountName?: string
payUrl?: string
passportUrl?: string
passportApi?: string
passportOrg?: string
userCenter?: string
uniplatApi: string
qqxbH5Url?: string
xbUrl?: string
h5Url?: string
collectionUrl?: string
selfH5?: string
selfJobH5?: string
imgBaseUrl: string
clientId: ClientId
uniplatSocketUrl: string
clientSecret: string
logEnv?: Environment
version: string
mpAccountAppid?: string
mpAppid?: string
officialAccountMpH5?: string
mapKey?: string
customFn?: { chooseImage: typeof uni.chooseImage }
shareConfig: {
title: string
img?: string
path: string
forceUse?: boolean
hideshareBy?: boolean
}
shareFixed?: boolean
monitorProductKey?: Product
rootEntrance: string
domainService: {
subProjectName: string
serviceName: string
}
domainService4CitySelector?: {
subProjectName?: string
serviceName?: string
get_lbs?: string
}
cityCodeLength: number | 6
reviewVersion?: number
registerConfig?: any
fastCity: any
testAccount?: string
testPassport?: string
sharePcRefConfig?: {
path: string
id: string
}[]
hideshareBy?: boolean
applicationKey?: any
[key: string]: any
}
export const config: MpConfig = {
appName: "亲亲企服",
payUrl:
import.meta.env.VITE_APP_PAY_URL || "https://payment-api.qqqf.com",
passportUrl:
import.meta.env.VITE_APP_WWW_WORK_APP_URL ||
"https://passport.qqqf.com",
passportApi:
import.meta.env.VITE_APP_API_WORK_APP_URL ||
"https://userapi.qqqf.com",
passportOrg:
import.meta.env.VITE_APP_API_WORK_ORG_URL ||
"https://userapi.qqqf.com",
userCenter:
import.meta.env.VITE_APP_USER_CENTER || "http://tmxlogin.qqqf.com",
uniplatApi:
import.meta.env.VITE_APP_UNIPLAT || "https://api-hro.qqqf.com",
qqxbH5Url:
import.meta.env.VITE_APP_QQXB || "http://test-qqxb-h5.qqqf.com",
rootEntrance: "统一业务平台",
domainService: {
subProjectName: "welfare_v2",
serviceName: "smart_app_api",
},
xbUrl:
import.meta.env.VITE_APP_XB_URL || "https://qqxb-h5.qqqf.com",
h5Url:
import.meta.env.VITE_APP_H5_URL ||
"https://static.qqqf.com/flb-mp",
imgBaseUrl: `${import.meta.env.VITE_APP_LAND_PAGE || "https://static.qqqf.com/flb-mp"}/img/`,
clientId: import.meta.env.VITE_APP_CLIENT_ID || "hrs100_wechat_mini_app",
uniplatSocketUrl:
import.meta.env.VITE_APP_UNIPLAT_WEBSOCKET_URI ||
"wss://channel.qqqf.com/ws",
clientSecret: import.meta.env.VITE_APP_CLIENT_SECRET || "qqxb#teammix#2019",
logEnv: +(import.meta.env.VITE_APP_LOG_ENV || "1") as Environment,
version: packageConfig.version,
mpAccountAppid: "wx67ad4269eb98b531",
mpAppid: "wx71116f4df50d7f93",
officialAccountMpH5:
"https://mp.weixin.qq.com/s?__biz=Mzg3NjcyMDkwNw==&mid=2247483658&idx=1&sn=11c01272402f190d4ddc07e5f66c21e5",
testAccount: import.meta.env.VITE_APP_TEST_ACCOUNT || "",
testPassport: import.meta.env.VITE_APP_TEST_PASSPORT || "",
mapKey: "CQLBZ-RHTCO-3VKWA-S5NTZ-V4ALV-6OBRZ",
shareConfig: {
title: "亲亲企服",
img: `${import.meta.env.VITE_APP_LAND_PAGE || "https://static.qqqf.com/flb-mp"}/img/qqqf-share.png`,
path: "/pages/login/index",
},
selfJobH5: import.meta.env.VITE_APP_SELF_JOB_URL || "",
selfH5: "",
cityCodeLength: 6,
reviewVersion: 126,
registerConfig: {
app: "Reg_MiniPro",
scene: "wxapp_scene_Myqqxb",
app_store: "qq_hehuoren_minreg",
} as any,
}
export const noNeedAuthPages = [
"pages/home/index",
]
export function initCommon(app: App): App {
// Vue3 不再需要 filters 和 mixins
return app
}
export class CoreEnvir {
private static currentEnv: Environment | null = null
static getCurrent(): Environment {
if (this.currentEnv) return this.currentEnv
const envMode = import.meta.env.VITE_ENV
if (
envMode &&
Object.values(Environment).includes(envMode as Environment)
) {
this.currentEnv = envMode as Environment
return this.currentEnv
}
return import.meta.env.MODE === "development"
? Environment.Dev
: Environment.Pro
}
static switch(env: Environment) {
this.currentEnv = env
}
static isDev() {
return this.getCurrent() === Environment.Dev
}
static isPro() {
return !this.isDev() && !import.meta.env.VITE_ENV
}
}
/// <reference types="@dcloudio/types" />
declare module "*.vue" {
import type { DefineComponent } from "vue"
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
readonly VITE_ENV: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
# ===== 应用元信息 =====
APP_ENV=dev
VITE_APP_ENV=dev
# ===== API 基础地址(必填)=====
NUXT_PUBLIC_API_BASE_URL=
# ===== 应用元信息 =====
APP_ENV=production
VITE_APP_ENV=prod
# ===== API 基础地址(必填)=====
NUXT_PUBLIC_API_BASE_URL=
# ===== 应用元信息 =====
APP_ENV=staging
VITE_APP_ENV=stage
# ===== API 基础地址(必填)=====
NUXT_PUBLIC_API_BASE_URL=
# ===== 应用元信息 =====
APP_ENV=test
VITE_APP_ENV=test
# ===== API 基础地址(必填)=====
NUXT_PUBLIC_API_BASE_URL=
export { request } from './request'
import axios from 'axios'
import { TOKEN_STORAGE_KEY } from '~/config'
/**
* axios 实例
*
* baseURL 取自 Nuxt runtimeConfig(由 NUXT_PUBLIC_API_BASE_URL 注入)。
*/
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
})
// 请求拦截器:注入 baseURL 与 token
request.interceptors.request.use(
(config) =>
{
(config) => {
const runtime = useRuntimeConfig()
config.baseURL = config.baseURL || runtime.public.apiBaseUrl
// SSR 环境没有 localStorage,只在客户端注入 token
if (import.meta.client) {
const token = localStorage.getItem(TOKEN_STORAGE_KEY)
if (token) {
config.headers = {
...config.headers,
Authorization: `Bearer ${token}`,
}
}
}
return config
},
(error) =>
{
(error) => {
return Promise.reject(error)
},
)
// 响应拦截器:统一剥离 data
request.interceptors.response.use(
(response) =>
{
(response) => {
return response.data
},
(error) =>
{
(error) => {
return Promise.reject(error)
},
)
......
/** 本地存储的 Token key */
export const TOKEN_STORAGE_KEY = 'JWT_TOKEN'
/** 站点名称,用于页面标题与 SEO */
export const SITE_NAME = '官网'
/** 免登录路由白名单 */
export const noNeedAuthPages = ['/']
......@@ -10,4 +10,12 @@ export default defineNuxtConfig({
css: [
'ant-design-vue/dist/reset.css',
],
// 运行时配置:public 下的值会通过 NUXT_PUBLIC_* 环境变量覆盖
runtimeConfig: {
public: {
apiBaseUrl: '',
appEnv: 'dev',
},
},
})
{
"name": "official-site-web",
"name": "site",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "官网模板(Nuxt 3 SSR)",
"scripts": {
"dev": "nuxt dev",
"dev:test": "nuxt dev --dotenv .env.test",
"dev:staging": "nuxt dev --dotenv .env.staging",
"dev:production": "nuxt dev --dotenv .env.production",
"build": "nuxt build",
"build:test": "nuxt build --dotenv .env.test",
"build:staging": "nuxt build --dotenv .env.staging",
"build:production": "nuxt build --dotenv .env.production",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"type-check": "nuxt typecheck"
},
"dependencies": {
"@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"ant-design-vue": "^4.2.6",
"axios": "^0.27.2",
"nuxt": "^3.14.1",
"pinia": "^2.2.6",
"vue": "^3.5.13"
},
"devDependencies": {
"@pinia/nuxt": "^0.7.0",
"@unocss/nuxt": "^0.64.1",
"@unocss/nuxt": "^66.7.5",
"typescript": "^5.6.3",
"unocss": "^0.64.1"
"unocss": "^66.7.5"
}
}
<template>
<div class="p-4">
<h1 class="text-2xl font-bold">{{ SITE_NAME }}</h1>
</div>
</template>
<script setup lang="ts">
import { SITE_NAME } from '~/config'
useHead({ title: SITE_NAME })
</script>
# ===== 应用元信息 =====
APP_ENV=dev
VITE_APP_ENV=dev
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=0
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=production
VITE_APP_ENV=prod
VITE_DEBUG=false
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=1
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=staging
VITE_APP_ENV=stage
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=3
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
# ===== 应用元信息 =====
APP_ENV=test
VITE_APP_ENV=test
VITE_DEBUG=true
# ===== 日志环境(0=dev 1=prod 2=test 3=stage)=====
VITE_APP_LOG_ENV=2
# ===== 统一业务平台(必填)=====
VITE_APP_UNIPLAT=
VITE_APP_UNIPLAT_WEBSOCKET_URI=
# ===== 账号中心 / 通行证 =====
VITE_APP_WWW_WORK_APP_URL=
VITE_APP_API_WORK_APP_URL=
VITE_APP_API_WORK_ORG_URL=
VITE_APP_USER_CENTER=
# ===== 客户端凭证(必填,向后端申请)=====
VITE_APP_CLIENT_ID=
VITE_APP_CLIENT_SECRET=
# ===== 支付 =====
VITE_APP_PAY_URL=
# ===== 静态资源 / H5 =====
VITE_APP_H5_URL=
VITE_APP_LAND_PAGE=
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_ENV: string
readonly VITE_DEBUG: string
readonly VITE_APP_LOG_ENV: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_UNIPLAT_WEBSOCKET_URI: string
readonly VITE_APP_WWW_WORK_APP_URL: string
readonly VITE_APP_API_WORK_APP_URL: string
readonly VITE_APP_API_WORK_ORG_URL: string
readonly VITE_APP_USER_CENTER: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_APP_PAY_URL: string
readonly VITE_APP_H5_URL: string
readonly VITE_APP_LAND_PAGE: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Web 应用</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
{
"name": "web",
"type": "module",
"version": "1.0.0",
"private": true,
"description": "H5 / 客户端模板(Vue 3 + Vite SPA)",
"scripts": {
"dev": "vite",
"dev:test": "vite --mode test",
"dev:staging": "vite --mode staging",
"dev:production": "vite --mode production",
"build": "vue-tsc --noEmit && vite build",
"build:test": "vue-tsc --noEmit && vite build --mode test",
"build:staging": "vue-tsc --noEmit && vite build --mode staging",
"build:production": "vue-tsc --noEmit && vite build --mode production",
"preview": "vite preview",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"@common/sdk": "workspace:*",
"@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"pinia": "^2.1.7",
"vue": "^3.4.0",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.0",
"sass": "^1.85.1",
"typescript": "^5.6.3",
"unocss": "^66.7.5",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
<template>
<div class="p-4">
<h1 class="text-2xl font-bold">Official Site</h1>
<div id="app">
<RouterView />
</div>
</template>
import type { LoginRegisterResult, VerifyImageResult } from '@common/sdk'
import { loginService, verifyService } from '@/api/request'
export type { LoginRegisterResult, VerifyImageResult }
/**
* 获取图形验证码
*/
export function getVerifyImage(): VerifyImageResult {
return verifyService.generateImage()
}
/**
* 发送短信验证码
*/
export function sendSmsCode(mobile: string, seed: string, verifycode: string) {
return verifyService.sendVerifyCode(mobile, seed, verifycode)
}
/**
* 验证码登录
*/
export function smsLogin(mobile: string, verifycode: string) {
return loginService.verifyCodeLogin(mobile, verifycode)
}
/**
* 密码登录
*/
export function passwordLogin(username: string, password: string) {
return loginService.login(username, password)
}
export {
getVerifyImage,
passwordLogin,
sendSmsCode,
smsLogin,
} from './auth'
export type { LoginRegisterResult, VerifyImageResult } from './auth'
export { getSdk, initToken, loginService, sdk, verifyService } from './request'
import { Sdk, TokenManager, webAdapter } from '@common/sdk'
import { createLoginService } from '@common/sdk/services/login-service'
import { createVerifyService } from '@common/sdk/services/verify-service'
import { config } from '@/config'
/**
* SDK 实例(Web 平台使用 webAdapter)
*/
export const sdk = new Sdk(config, webAdapter)
/**
* 创建服务(函数式,无需实例化)
*/
export const loginService = createLoginService(sdk.core, sdk.clientId)
export const verifyService = createVerifyService(sdk.core, sdk.clientId)
/**
* 初始化 Token(如果本地已有有效 Token 则恢复登录态)
*/
export function initToken() {
const token = TokenManager.hasToken()
if (token) {
sdk.core.loginByToken({
token: token as string,
})
}
return token
}
/**
* 获取 SDK 实例
*/
export function getSdk() {
return sdk
}
// 全局基础样式
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
font-size: 14px;
color: #333;
background-color: #f5f7fa;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
export { useLoading } from '@common/vue-kit/composables'
import type { ClientId, SdkConfig } from '@common/sdk'
import { Environment } from '@common/sdk'
export interface WebConfig extends SdkConfig {
/** 应用名称,用于页面标题 */
appName: string
/** 图片资源根地址 */
imgBaseUrl: string
}
/**
* 应用配置中心
*
* 所有环境变量在此集中读取,业务代码不要直接使用 `import.meta.env`。
* 带 TODO 的占位项请按项目实际情况填写。
*/
export const config: WebConfig = {
appName: 'Web 应用',
uniplatApi: import.meta.env.VITE_APP_UNIPLAT || '',
// TODO: 替换为后端分配的平台入口标识
rootEntrance: '',
logEnv: (import.meta.env.VITE_APP_ENV || Environment.Dev) as Environment,
clientId: (import.meta.env.VITE_APP_CLIENT_ID || '') as ClientId,
domainService: {
// TODO: 替换为项目实际的子项目名与服务名
subProjectName: '',
serviceName: '',
},
passportUrl: import.meta.env.VITE_APP_WWW_WORK_APP_URL || '',
imgBaseUrl: `${import.meta.env.VITE_APP_LAND_PAGE || ''}/img/`,
}
/** 免登录白名单(路由 path) */
export const noNeedAuthPages = ['/login']
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import { initToken } from '@/api/request'
import App from '@/App.vue'
import router from '@/router'
import '@/assets/styles/index.scss'
import 'virtual:uno.css'
// 恢复本地 Token 登录态(SDK 已在 api/request.ts 中初始化)
initToken()
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
<template>
<div class="home">
<h1 class="home__title">{{ config.appName }}</h1>
<p class="home__desc">Vue 3 + Vite + Pinia + Vue Router 模板骨架</p>
<button class="home__btn" @click="handleLogout">退出登录</button>
</div>
</template>
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { config } from '@/config'
import { handleLogout as logout } from '@/services'
const router = useRouter()
function handleLogout() {
logout()
router.push('/login')
}
</script>
<style scoped lang="scss">
.home {
padding: 24px;
&__title {
margin: 0 0 8px;
font-size: 24px;
font-weight: 600;
}
&__desc {
margin: 0 0 24px;
color: #666;
}
&__btn {
height: 40px;
padding: 0 24px;
border: none;
border-radius: 4px;
background: #1890ff;
color: #fff;
font-size: 14px;
cursor: pointer;
}
}
</style>
<template>
<div class="login">
<div class="login__card">
<h1 class="login__title">{{ config.appName }}</h1>
<div class="login__tabs">
<div
class="login__tab"
:class="{ 'login__tab--active': loginType === 'sms' }"
@click="loginType = 'sms'"
>
验证码登录
</div>
<div
class="login__tab"
:class="{ 'login__tab--active': loginType === 'password' }"
@click="loginType = 'password'"
>
密码登录
</div>
</div>
<!-- 验证码登录 -->
<form v-if="loginType === 'sms'" class="login__form" @submit.prevent="onSmsLogin">
<input
v-model="smsForm.mobile"
class="login__input"
type="tel"
placeholder="请输入手机号"
maxlength="11"
/>
<div class="login__row">
<input
v-model="smsForm.imgCode"
class="login__input"
type="text"
placeholder="请输入图形验证码"
/>
<img
v-if="verifyImage.img"
:src="verifyImage.img"
class="login__verify-img"
alt="验证码"
@click="refreshVerifyImage"
/>
</div>
<div class="login__row">
<input
v-model="smsForm.smsCode"
class="login__input"
type="text"
placeholder="请输入短信验证码"
maxlength="6"
/>
<button
class="login__sms-btn"
type="button"
:disabled="countdown > 0 || !smsForm.mobile"
@click="onSendSms"
>
{{ countdown > 0 ? `${countdown}s` : '获取验证码' }}
</button>
</div>
<button class="login__submit" type="submit" :disabled="smsLoading">
{{ smsLoading ? '登录中...' : '登录' }}
</button>
</form>
<!-- 密码登录 -->
<form v-else class="login__form" @submit.prevent="onPasswordLogin">
<input
v-model="passwordForm.username"
class="login__input"
type="text"
placeholder="请输入用户名/手机号"
/>
<input
v-model="passwordForm.password"
class="login__input"
type="password"
placeholder="请输入密码"
/>
<button class="login__submit" type="submit" :disabled="pwdLoading">
{{ pwdLoading ? '登录中...' : '登录' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { SMS_COUNTDOWN } from '@common/utils/constants'
import { isPhone } from '@common/utils/regex'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getVerifyImage } from '@/api/auth'
import { config } from '@/config'
import {
handlePasswordLogin as loginByPassword,
handleSmsLogin as loginBySms,
handleSendSmsCode as requestSmsCode,
} from '@/services'
import { Toast } from '@/utils/toast'
const router = useRouter()
const route = useRoute()
const loginType = ref<'sms' | 'password'>('sms')
const smsForm = ref({ mobile: '', imgCode: '', smsCode: '' })
const passwordForm = ref({ username: '', password: '' })
const smsLoading = ref(false)
const pwdLoading = ref(false)
const countdown = ref(0)
const verifyImage = ref({ img: '', seed: '' })
let timer: ReturnType<typeof setInterval> | null = null
// 获取图形验证码
function refreshVerifyImage() {
try {
verifyImage.value = getVerifyImage() as any
}
catch {
Toast.error('获取图形验证码失败')
}
}
async function onSendSms() {
if (!isPhone(smsForm.value.mobile)) {
Toast.warning('手机号格式不正确')
return
}
if (!smsForm.value.imgCode) {
Toast.warning('请输入图形验证码')
return
}
const ok = await requestSmsCode(
smsForm.value.mobile,
verifyImage.value.seed,
smsForm.value.imgCode,
)
if (!ok) return
countdown.value = SMS_COUNTDOWN
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0 && timer) {
clearInterval(timer)
timer = null
}
}, 1000)
}
async function onSmsLogin() {
if (!smsForm.value.mobile || !smsForm.value.smsCode) {
Toast.warning('请填写手机号与验证码')
return
}
smsLoading.value = true
try {
const ok = await loginBySms(smsForm.value.mobile, smsForm.value.smsCode)
if (ok) redirectAfterLogin()
}
finally {
smsLoading.value = false
}
}
async function onPasswordLogin() {
if (!passwordForm.value.username || !passwordForm.value.password) {
Toast.warning('请填写用户名与密码')
return
}
pwdLoading.value = true
try {
const ok = await loginByPassword(
passwordForm.value.username,
passwordForm.value.password,
)
if (ok) redirectAfterLogin()
}
finally {
pwdLoading.value = false
}
}
// 登录成功后回跳
function redirectAfterLogin() {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
refreshVerifyImage()
</script>
<style scoped lang="scss">
.login {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
&__card {
width: 100%;
max-width: 400px;
padding: 32px 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgb(0 0 0 / 10%);
}
&__title {
margin: 0 0 24px;
font-size: 22px;
font-weight: 600;
text-align: center;
}
&__tabs {
display: flex;
margin-bottom: 24px;
border-bottom: 1px solid #eee;
}
&__tab {
flex: 1;
padding: 12px 0;
color: #666;
text-align: center;
cursor: pointer;
&--active {
color: #1890ff;
border-bottom: 2px solid #1890ff;
}
}
&__form {
display: flex;
flex-direction: column;
gap: 16px;
}
&__row {
display: flex;
gap: 12px;
align-items: center;
}
&__input {
width: 100%;
height: 44px;
padding: 0 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
outline: none;
&:focus {
border-color: #1890ff;
}
}
&__verify-img {
width: 120px;
height: 44px;
border-radius: 4px;
cursor: pointer;
}
&__sms-btn {
flex: none;
width: 110px;
height: 44px;
border: 1px solid #1890ff;
border-radius: 4px;
background: #fff;
color: #1890ff;
cursor: pointer;
&:disabled {
border-color: #ccc;
color: #ccc;
cursor: not-allowed;
}
}
&__submit {
height: 44px;
border: none;
border-radius: 4px;
background: #1890ff;
color: #fff;
font-size: 16px;
cursor: pointer;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
</style>
import type { RouteRecordRaw } from 'vue-router'
import { TokenManager } from '@common/sdk'
import { createRouter, createWebHistory } from 'vue-router'
import { config } from '@/config'
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'Login',
component: () => import('@/pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false },
},
{
path: '/',
name: 'Home',
component: () => import('@/pages/index.vue'),
meta: { title: '首页', requiresAuth: true },
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || config.appName
const token = TokenManager.hasToken()
if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } })
}
else {
next()
}
})
export default router
import { passwordLogin, sendSmsCode, smsLogin } from '@/api/auth'
import { sdk } from '@/api/request'
import { useAuthStore } from '@/stores/auth'
import { Toast } from '@/utils/toast'
/** 保存登录结果:持久化 Token 并同步 SDK 登录态 */
function saveToken(jwt: string) {
useAuthStore().setToken(jwt)
sdk.core.loginByToken({ token: jwt })
}
/** 发送短信验证码 */
export async function handleSendSmsCode(
mobile: string,
seed: string,
imgCode: string,
): Promise<boolean> {
try {
await sendSmsCode(mobile, seed, imgCode)
Toast.success('验证码已发送')
return true
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
return false
}
}
/** 短信验证码登录 */
export async function handleSmsLogin(
mobile: string,
verifycode: string,
): Promise<boolean> {
try {
const result = (await smsLogin(mobile, verifycode)) as any
saveToken(result.jwt)
Toast.success('登录成功')
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
return false
}
}
/** 密码登录 */
export async function handlePasswordLogin(
username: string,
password: string,
): Promise<boolean> {
try {
const result = (await passwordLogin(username, password)) as any
saveToken(result.jwt)
Toast.success('登录成功')
return true
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
return false
}
}
/** 退出登录 */
export function handleLogout() {
useAuthStore().logout()
}
export {
handleLogout,
handlePasswordLogin,
handleSendSmsCode,
handleSmsLogin,
} from './auth-service'
import type { UserInfo } from '@/types/auth'
import { TokenManager } from '@common/sdk'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(TokenManager.hasToken() || '')
const userInfo = ref<UserInfo | null>(null)
function setToken(newToken: string) {
token.value = newToken
TokenManager.saveToken2Storage(newToken)
}
function setUserInfo(info: UserInfo) {
userInfo.value = info
}
function logout() {
token.value = ''
userInfo.value = null
TokenManager.clearToken()
}
return { token, userInfo, setToken, setUserInfo, logout }
})
export { useAuthStore } from './auth'
export interface UserInfo {
id: string
username: string
mobile?: string
avatar?: string
[key: string]: any
}
export type { UserInfo } from './auth'
export { Toast } from './toast'
// 通用纯工具函数统一从 @common/utils 引入,项目私有工具放在本目录下
export * from '@common/utils'
/**
* Toast 提示工具
* 提供轻量的 Web Toast 提示,避免使用 alert 弹窗
*/
interface ToastOptions {
message: string
type?: 'success' | 'error' | 'warning' | 'info'
duration?: number
}
let toastContainer: HTMLElement | null = null
let toastTimer: ReturnType<typeof setTimeout> | null = null
function getContainer() {
if (!toastContainer) {
toastContainer = document.createElement('div')
toastContainer.className = 'toast-container'
document.body.appendChild(toastContainer)
}
return toastContainer
}
function showToast(options: ToastOptions) {
const { message, type = 'info', duration = 2000 } = options
const container = getContainer()
// 清除之前的 toast
if (toastTimer) {
clearTimeout(toastTimer)
}
container.innerHTML = ''
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.textContent = message
container.appendChild(toast)
// 触发显示动画
requestAnimationFrame(() => {
toast.classList.add('toast-show')
})
// 自动隐藏
toastTimer = setTimeout(() => {
toast.classList.remove('toast-show')
setTimeout(() => {
container?.removeChild(toast)
}, 300)
}, duration)
}
export const Toast = {
success(message: string, duration?: number) {
showToast({ message, type: 'success', duration })
},
error(message: string, duration?: number) {
showToast({ message, type: 'error', duration })
},
warning(message: string, duration?: number) {
showToast({ message, type: 'warning', duration })
},
info(message: string, duration?: number) {
showToast({ message, type: 'info', duration })
},
}
// 注入全局样式
if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) {
const style = document.createElement('style')
style.id = 'toast-styles'
style.textContent = `
.toast-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
}
.toast {
padding: 12px 24px;
border-radius: 8px;
font-size: 14px;
color: #fff;
background: rgba(0, 0, 0, 0.8);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
opacity: 0;
transform: translateY(-20px);
transition: all 0.3s ease;
white-space: nowrap;
max-width: 80vw;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-show {
opacity: 1;
transform: translateY(0);
}
.toast-success {
background: #52c41a;
}
.toast-error {
background: #ff4d4f;
}
.toast-warning {
background: #faad14;
}
.toast-info {
background: #1890ff;
}
`
document.head.appendChild(style)
}
{
"compilerOptions": {
"target": "ESNext",
"jsx": "preserve",
"lib": [
"ESNext",
"DOM"
],
"module": "ESNext",
"moduleResolution": "bundler",
"paths": {
"@/*": [
"./src/*"
]
},
"resolveJsonModule": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": [
"src/**/*.ts",
"src/**/*.vue",
"src/**/*.tsx",
"env.d.ts"
],
"exclude": [
"node_modules",
"dist",
"**/*.js"
]
}
import { defineConfig, presetAttributify, presetUno } from 'unocss'
export default defineConfig({
presets: [presetUno(), presetAttributify()],
})
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import UnoCSS from 'unocss/vite'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vue(), UnoCSS()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist',
},
})
This diff could not be displayed because it is too large.
packages:
- 'common/*'
- 'packages/*'
packages:
- "common/*"
- "packages/*"
......@@ -60,8 +60,8 @@ function main()
}
catch (error: any)
{
// 清理临时文件
try { unlinkSync(tmpFile) } catch {}
// process.exit 会跳过 finally,所以退出前先清理
cleanupTmpFile(tmpFile)
if (error.message?.includes('No unreleased changesets found'))
{
......@@ -75,8 +75,20 @@ function main()
}
finally
{
// 清理临时文件
try { unlinkSync(tmpFile) } catch {}
cleanupTmpFile(tmpFile)
}
}
/** 清理临时文件,文件可能已不存在,忽略错误 */
function cleanupTmpFile(tmpFile: string)
{
try
{
unlinkSync(tmpFile)
}
catch
{
// 文件不存在或已被删除,无需处理
}
}
......
......@@ -99,11 +99,16 @@ function executeCommand(projectName: string, envName: string, platform: string)
process.exit(1)
}
console.log(`\nBuilding project ${projectName} in ${envName} environment on ${platform}...`)
const isMiniProgram = isMiniProgramProject(projectPath)
const useScript = platform.includes(":") || platform === "custom"
// 平台参数只对小程序项目有意义,Web/Nuxt 项目不打印
console.log(
isMiniProgram
? `\nBuilding project ${projectName} in ${envName} environment on ${platform}...`
: `\nBuilding project ${projectName} in ${envName} environment...`,
)
// 切换到项目目录
process.chdir(projectPath)
......@@ -139,9 +144,19 @@ function executeCommand(projectName: string, envName: string, platform: string)
execSync(cmd, { stdio: "inherit" })
}
else {
// Vue/Nuxt 项目直接使用 pnpm build
console.log(`Executing: pnpm build`)
execSync("pnpm build", { stdio: "inherit" })
// Vue/Nuxt 项目:优先用 build:<env> 脚本以带上环境,缺失时回退到 pnpm build
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"))
const envScript = `build:${envName}`
const scriptName = pkg.scripts?.[envScript] ? envScript : "build"
if (scriptName === "build") {
console.warn(
`Script ${envScript} not found in project ${projectName}, falling back to pnpm build`,
)
}
console.log(`Executing: pnpm run ${scriptName}`)
execSync(`pnpm run ${scriptName}`, { stdio: "inherit" })
}
}
catch (error: any) {
......
......@@ -99,11 +99,16 @@ function executeCommand(projectName: string, envName: string, platform: string)
process.exit(1)
}
console.log(`\nStarting dev for project ${projectName} in ${envName} environment on ${platform}...`)
const isMiniProgram = isMiniProgramProject(projectPath)
const useScript = platform.includes(":") || platform === "custom"
// 平台参数只对小程序项目有意义,Web/Nuxt 项目不打印
console.log(
isMiniProgram
? `\nStarting dev for project ${projectName} in ${envName} environment on ${platform}...`
: `\nStarting dev for project ${projectName} in ${envName} environment...`,
)
// 切换到项目目录
process.chdir(projectPath)
......@@ -139,9 +144,19 @@ function executeCommand(projectName: string, envName: string, platform: string)
execSync(cmd, { stdio: "inherit" })
}
else {
// Vue/Nuxt 项目直接使用 pnpm dev
console.log(`Executing: pnpm dev`)
execSync("pnpm dev", { stdio: "inherit" })
// Vue/Nuxt 项目:优先用 dev:<env> 脚本以带上环境,缺失时回退到 pnpm dev
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"))
const envScript = `dev:${envName}`
const scriptName = pkg.scripts?.[envScript] ? envScript : "dev"
if (scriptName === "dev") {
console.warn(
`Script ${envScript} not found in project ${projectName}, falling back to pnpm dev`,
)
}
console.log(`Executing: pnpm run ${scriptName}`)
execSync(`pnpm run ${scriptName}`, { stdio: "inherit" })
}
}
catch (error: any) {
......
......@@ -53,7 +53,7 @@ const commands = {
if (!projectName)
{
console.error('Usage: pnpm git:dev-start <project-name>')
console.error('Example: pnpm git:dev-start official-site-web')
console.error('Example: pnpm git:dev-start admin')
process.exit(1)
}
const branchName = `feature/${projectName}`
......@@ -86,7 +86,7 @@ const commands = {
if (!projectName)
{
console.error('Usage: pnpm git:dev-done <project-name>')
console.error('Example: pnpm git:dev-done official-site-web')
console.error('Example: pnpm git:dev-done admin')
process.exit(1)
}
const branchName = `feature/${projectName}`
......
......@@ -10,7 +10,7 @@ const projectType = process.argv[3] || 'nuxt'
if (!projectName)
{
console.error('Usage: pnpm new:project <project-name> [nuxt|vue]')
console.error('Example: pnpm new:project admin-web vue')
console.error('Example: pnpm new:project mobile vue')
process.exit(1)
}
......@@ -144,12 +144,28 @@ writeFileSync(
/**
* 步骤 4:创建环境文件
* 包含 dev、test、staging、production 四种环境
*
* 注意:不要在 .env 中设置 NODE_ENV —— Vite/Nuxt 会根据命令自动设置,
* 手动写 NODE_ENV=production 会让 dev server 报警告。
*/
function envTemplate(appEnv: string, viteAppEnv: string)
{
return [
'# ===== 应用元信息 =====',
`APP_ENV=${appEnv}`,
`VITE_APP_ENV=${viteAppEnv}`,
'',
'# ===== API 基础地址(必填)=====',
'VITE_API_BASE_URL=',
'',
].join('\n')
}
const envFiles = {
'.env.development': 'APP_ENV=dev\nNODE_ENV=development\n',
'.env.test': 'APP_ENV=test\nNODE_ENV=production\n',
'.env.staging': 'APP_ENV=staging\nNODE_ENV=production\n',
'.env.production': 'APP_ENV=production\nNODE_ENV=production\n',
'.env.development': envTemplate('dev', 'dev'),
'.env.test': envTemplate('test', 'test'),
'.env.staging': envTemplate('staging', 'stage'),
'.env.production': envTemplate('production', 'prod'),
}
for (const [file, content] of Object.entries(envFiles))
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment