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 2584 additions and 429 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: 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/ ...@@ -20,6 +20,12 @@ build/
*.swo *.swo
*~ *~
# Claude Code 本地个人配置(团队共享配置在 .claude/settings.json)
.claude/settings.local.json
# UniApp 小程序打包产物
unpackage/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
......
# 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 { export default {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
rules: { rules: {
'scope-enum': [2, 'always', ["root", "*", "@common/sdk", "@common/utils", "@common/vue-kit", "official-site-web", "qqqf-admin", "qqqf-mp"]], 'scope-enum': [2, 'always', ["root","*","@common/sdk","@common/utils","@common/vue-kit","admin","mini","site","web"]],
'scope-empty': [2, 'never'], 'scope-empty': [2, 'never']
}, }
} }
...@@ -2,8 +2,11 @@ ...@@ -2,8 +2,11 @@
"name": "@common/sdk", "name": "@common/sdk",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "统一业务平台 SDK 封装(uniplat-sdk + HTTP 适配 + Token 管理)",
"exports": { "exports": {
".": "./src/index.ts", ".": "./src/index.ts",
"./adapters": "./src/adapters/index.ts",
"./core": "./src/core/index.ts",
"./mini-program": "./src/mini-program/index.ts", "./mini-program": "./src/mini-program/index.ts",
"./services/login-service": "./src/services/login-service.ts", "./services/login-service": "./src/services/login-service.ts",
"./services/verify-service": "./src/services/verify-service.ts" "./services/verify-service": "./src/services/verify-service.ts"
...@@ -11,10 +14,6 @@ ...@@ -11,10 +14,6 @@
"main": "src/index.ts", "main": "src/index.ts",
"dependencies": { "dependencies": {
"axios": "^0.27.2", "axios": "^0.27.2",
"lodash": "^4.17.21",
"uniplat-sdk": "^0.1.734-private" "uniplat-sdk": "^0.1.734-private"
},
"devDependencies": {
"@types/lodash": "^4.17.24"
} }
} }
...@@ -6,7 +6,16 @@ import buildFullPath from "axios/lib/core/buildFullPath" ...@@ -6,7 +6,16 @@ import buildFullPath from "axios/lib/core/buildFullPath"
import settle from "axios/lib/core/settle" import settle from "axios/lib/core/settle"
// @ts-ignore // @ts-ignore
import buildURL from "axios/lib/helpers/buildURL" 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[]) { function isJSONstr(str: string | any[]) {
try { try {
...@@ -88,12 +97,12 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise { ...@@ -88,12 +97,12 @@ export function uniAdapter(config: AxiosRequestConfig): AxiosPromise {
const uniHeader: Record<string, string> = {} const uniHeader: Record<string, string> = {}
forEach(config.headers, (val: any, key: string) => { forEach(config.headers, (val: any, key: string) => {
const _header = key.toLowerCase() const _header = key.toLowerCase()
if ( // 无请求体时不发 content-type;referer 由小程序运行时接管,不能透传
const skip =
(typeof requestData === "undefined" && (typeof requestData === "undefined" &&
_header === "content-type") || _header === "content-type") ||
_header === "referer" _header === "referer"
) { if (!skip) {
} else {
uniHeader[key] = String(val) uniHeader[key] = String(val)
} }
}) })
......
...@@ -3,7 +3,7 @@ export { buildFilePath, buildImage } from "./image-builder" ...@@ -3,7 +3,7 @@ export { buildFilePath, buildImage } from "./image-builder"
// SDK 核心 // SDK 核心
export { export {
ClientId, CLIENT_ID_NONE,
Environment, Environment,
getAuthHandlers, getAuthHandlers,
PASSPORT_ANONYMOUS_SERVICE, PASSPORT_ANONYMOUS_SERVICE,
...@@ -11,11 +11,11 @@ export { ...@@ -11,11 +11,11 @@ export {
PASSPORT_SUB_PROJECT, PASSPORT_SUB_PROJECT,
registerAuthHandlers, registerAuthHandlers,
Sdk, Sdk,
wxDistributerUrlConfig,
} from "./sdk" } from "./sdk"
export type { export type {
AuthHandlers, AuthHandlers,
ClientId,
SdkConfig, SdkConfig,
SdkInitOptions, SdkInitOptions,
} from "./sdk" } from "./sdk"
...@@ -24,6 +24,6 @@ export type { ...@@ -24,6 +24,6 @@ export type {
export { decodeToken, TokenManager } from "./token-manager" 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" ...@@ -15,14 +15,23 @@ import { buildFilePath, buildImage } from "./image-builder"
// ==================== 类型定义 ==================== // ==================== 类型定义 ====================
export interface SdkConfig { export interface SdkConfig {
/** 统一业务平台 API 根地址(VITE_APP_UNIPLAT) */
uniplatApi: string uniplatApi: string
/** 平台入口标识,由后端分配 */
rootEntrance: string rootEntrance: string
logEnv: string /** 当前环境标识 */
clientId: string logEnv?: Environment
/** 客户端标识,由后端分配(VITE_APP_CLIENT_ID) */
clientId: ClientId
/** 默认的领域服务定位(子项目名 + 服务名) */
domainService: { domainService: {
subProjectName: string subProjectName: string
serviceName: string serviceName: string
} }
/** 通行证(登录/注册/忘记密码)站点地址,模板需按项目填写 */
passportUrl?: string
/** 微信分销站点地址,按需填写 */
wxDistributerUrl?: string
} }
export interface SdkInitOptions { export interface SdkInitOptions {
...@@ -60,52 +69,24 @@ export const PASSPORT_SERVICE = { ...@@ -60,52 +69,24 @@ export const PASSPORT_SERVICE = {
// ==================== 环境/客户端枚举 ==================== // ==================== 环境/客户端枚举 ====================
/** 运行环境,取值与各项目 `.env.*` 里的 `VITE_APP_ENV` 一致 */
export enum Environment { export enum Environment {
Dev = "dev", Dev = "dev",
Test = "test", Test = "test",
Pro = "pro",
Stage = "stage", Stage = "stage",
Pro = "prod",
} }
export enum ClientId { /**
None = "", * 客户端标识
QqxbApp = "qqxb-hrm-app-ios", *
QqtxzMp = "qqxb_mini_app", * 由后端为每个端(小程序 / H5 / 后台)分配,各项目通过
FlbMp = "qqxb_flb_mini_app", * `VITE_APP_CLIENT_ID` 注入,SDK 不内置任何具体取值。
WebLogin = "teamix-personal-center-web-pc", */
RenCaiKuaiDiMp = "welfare_bean_wechat_mini_app", export type ClientId = string
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",
}
// ==================== URL 配置 ==================== /** 空客户端标识 */
export const CLIENT_ID_NONE: ClientId = ""
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"],
])
// ==================== AuthHandlers 管理 ==================== // ==================== AuthHandlers 管理 ====================
...@@ -127,7 +108,7 @@ export class Sdk { ...@@ -127,7 +108,7 @@ export class Sdk {
private token!: string private token!: string
public orgId!: number public orgId!: number
private client = ClientId.None private client: ClientId = CLIENT_ID_NONE
private passportUrl = "" private passportUrl = ""
private wxDistributerUrl = "" private wxDistributerUrl = ""
...@@ -152,15 +133,9 @@ export class Sdk { ...@@ -152,15 +133,9 @@ export class Sdk {
this.uniplatSdk.getAxios().defaults.timeout = 10e3 this.uniplatSdk.getAxios().defaults.timeout = 10e3
this.uniplatSdk.global.rootEntrance = config.rootEntrance this.uniplatSdk.global.rootEntrance = config.rootEntrance
this.client = (config.clientId as ClientId) || ClientId.None this.client = config.clientId || CLIENT_ID_NONE
this.passportUrl = config.passportUrl || ""
const envir = (config.logEnv as Environment) || Environment.Dev this.wxDistributerUrl = config.wxDistributerUrl || ""
this.passportUrl =
qqxbPassportConfig.get(envir) ||
qqxbPassportConfig.get(Environment.Dev)!
this.wxDistributerUrl =
wxDistributerUrlConfig.get(envir) ||
wxDistributerUrlConfig.get(Environment.Dev)!
} }
public setupEventHandlers(handlers: { public setupEventHandlers(handlers: {
...@@ -204,9 +179,18 @@ export class Sdk { ...@@ -204,9 +179,18 @@ export class Sdk {
return `${this.passportUrl}/forget` 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( return `${this.passportUrl}/register?redirectUrl=${encodeURIComponent(
window.location.href, currentUrl,
)}` )}`
} }
......
...@@ -98,7 +98,7 @@ export class TokenManager { ...@@ -98,7 +98,7 @@ export class TokenManager {
public static hasToken() { public static hasToken() {
const cache = const cache =
this.storage.getItem(this.key) || this.storage.getItem(this.key) ||
(UniplatSdk.prototype && (window as any).jwtToken) (typeof window !== "undefined" && (window as any).jwtToken)
if (cache) { if (cache) {
if (this.isTokenValid(cache)) { if (this.isTokenValid(cache)) {
return cache return cache
...@@ -111,7 +111,8 @@ export class TokenManager { ...@@ -111,7 +111,8 @@ export class TokenManager {
public static checkTokenExit() { public static checkTokenExit() {
const cache = 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 return cache
} }
......
import type { AxiosAdapter, AxiosResponse } from "axios" import type { AxiosAdapter, AxiosRequestConfig, AxiosResponse } from "axios"
import type { UniplatSdk } from "uniplat-sdk" import type { UniplatSdk } from "uniplat-sdk"
import Axios from "axios" import Axios from "axios"
/** /**
* 产品枚举 * 产品标识
*
* 监控平台用于区分上报来源,由各项目自行约定取值。
*/ */
export enum Product { export type Product = string
Default = "default",
QqxbWeixin = "qqxb-weixin", /** 默认产品标识 */
QqxbApp = "qqxb-app", export const PRODUCT_DEFAULT: Product = "default"
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",
}
enum ProductTable { enum ProductTable {
Default = "", Default = "",
...@@ -38,6 +26,13 @@ export interface MonitorOption { ...@@ -38,6 +26,13 @@ export interface MonitorOption {
monitorMsgHandler?: (msg: string[], r: AxiosResponse) => void monitorMsgHandler?: (msg: string[], r: AxiosResponse) => void
} }
export interface MonitorEndpoint {
/** 监控上报地址 */
url: string
/** 上报接口的 authorization 请求头 */
authorization?: string
}
/** /**
* Web 监控类 * Web 监控类
* 负责错误监控、日志上报 * 负责错误监控、日志上报
...@@ -45,8 +40,9 @@ export interface MonitorOption { ...@@ -45,8 +40,9 @@ export interface MonitorOption {
class WebMonitor { class WebMonitor {
private key = "" private key = ""
private envir = "dev" private envir = "dev"
private product = Product.Default private product = PRODUCT_DEFAULT
private readonly url = "https://pre-hrs-monitor.hrs100.com" private url = ""
private authorization = ""
private adapter: AxiosAdapter | undefined private adapter: AxiosAdapter | undefined
public updateKey(key: string) { public updateKey(key: string) {
...@@ -54,15 +50,30 @@ class WebMonitor { ...@@ -54,15 +50,30 @@ class WebMonitor {
return this 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 { return {
headers: { authorization: "cdd0a34e-f537-4e5b-808e-2ba06af21845" }, headers,
adapter: this.adapter, adapter: this.adapter,
} }
} }
private enable() { private enable() {
return this.envir !== "dev" return !!this.url && this.envir !== "dev"
} }
private envirString() { private envirString() {
...@@ -76,6 +87,9 @@ class WebMonitor { ...@@ -76,6 +87,9 @@ class WebMonitor {
} }
public log(msg: any) { public log(msg: any) {
if (!this.url) {
return Promise.resolve()
}
return Axios.post( return Axios.post(
this.url, this.url,
{ {
...@@ -132,6 +146,7 @@ class WebMonitor { ...@@ -132,6 +146,7 @@ class WebMonitor {
options && options &&
options.userAgent && options.userAgent &&
typeof window !== "undefined" &&
msg.push(`UserAgent: ${window.navigator.userAgent}`) msg.push(`UserAgent: ${window.navigator.userAgent}`)
r.config && r.config &&
r.config.params && r.config.params &&
...@@ -151,7 +166,7 @@ class WebMonitor { ...@@ -151,7 +166,7 @@ class WebMonitor {
) )
} }
} }
if (window !== undefined) { if (typeof window !== "undefined") {
msg.push(`Location: ${window.location.href}`) msg.push(`Location: ${window.location.href}`)
} }
if (r.data) { if (r.data) {
......
...@@ -8,7 +8,7 @@ export { buildFilePath, buildImage } from "./core/image-builder" ...@@ -8,7 +8,7 @@ export { buildFilePath, buildImage } from "./core/image-builder"
// SDK 核心(合并了类型、常量、AuthHandlers) // SDK 核心(合并了类型、常量、AuthHandlers)
export { export {
ClientId, CLIENT_ID_NONE,
Environment, Environment,
getAuthHandlers, getAuthHandlers,
PASSPORT_ANONYMOUS_SERVICE, PASSPORT_ANONYMOUS_SERVICE,
...@@ -16,11 +16,11 @@ export { ...@@ -16,11 +16,11 @@ export {
PASSPORT_SUB_PROJECT, PASSPORT_SUB_PROJECT,
registerAuthHandlers, registerAuthHandlers,
Sdk, Sdk,
wxDistributerUrlConfig,
} from "./core/sdk" } from "./core/sdk"
export type { export type {
AuthHandlers, AuthHandlers,
ClientId,
SdkConfig, SdkConfig,
SdkInitOptions, SdkInitOptions,
} from "./core/sdk" } from "./core/sdk"
...@@ -34,12 +34,9 @@ export { decodeToken, TokenManager } from "./core/token-manager" ...@@ -34,12 +34,9 @@ export { decodeToken, TokenManager } from "./core/token-manager"
export { TokenManager as PassportTokenController } 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 type { MonitorEndpoint, MonitorOption, Product } from "./core/web-monitor"
// 小程序模块
export * from "./mini-program"
// 服务工厂函数 // 服务工厂函数
export { createLoginService, LoginApiName } from "./services/login-service" export { createLoginService, LoginApiName } from "./services/login-service"
...@@ -49,3 +46,6 @@ export type { LoginRegisterResult, RegisterChannel } from "./services/login-serv ...@@ -49,3 +46,6 @@ export type { LoginRegisterResult, RegisterChannel } from "./services/login-serv
export { createVerifyService, VerifyApiName } from "./services/verify-service" export { createVerifyService, VerifyApiName } from "./services/verify-service"
export type { VerifyImageResult } 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 { export interface UploadData {
url: string url: string
fullUrl: string fullUrl: string
...@@ -30,7 +28,7 @@ export function chooseImage( ...@@ -30,7 +28,7 @@ export function chooseImage(
| UniApp.ChooseImageSuccessCallbackResultFile | UniApp.ChooseImageSuccessCallbackResultFile
| File | File
)[] = [] )[] = []
if (!isArray(data.tempFiles)) { if (!Array.isArray(data.tempFiles)) {
files = [data.tempFiles] files = [data.tempFiles]
} else { } else {
files = data.tempFiles files = data.tempFiles
......
...@@ -3,8 +3,14 @@ ...@@ -3,8 +3,14 @@
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "跨项目复用的纯工具函数(无框架依赖)",
"exports": { "exports": {
".": "./src/index.ts", ".": "./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' 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( ...@@ -90,7 +90,21 @@ export default antfu(
'**/package-lock.json', '**/package-lock.json',
'**/pnpm-lock.yaml', '**/pnpm-lock.yaml',
'**/yarn.lock', '**/yarn.lock',
// 手工维护的中文文档:prettier 的表格对齐对 CJK 宽度处理不佳
'ARCHITECTURE.md', '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 @@ ...@@ -2,22 +2,19 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_APP_ENV: string readonly VITE_APP_ENV: string
readonly VITE_APP_ID: string readonly VITE_DEBUG: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_CLIENT_ID: string
readonly VITE_APP_CLIENT_SECRET: string
readonly VITE_APP_LOG_ENV: string readonly VITE_APP_LOG_ENV: string
readonly VITE_APP_UNIPLAT: string
readonly VITE_APP_UNIPLAT_WEBSOCKET_URI: 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_WWW_WORK_APP_URL: string
readonly VITE_APP_API_WORK_APP_URL: string readonly VITE_APP_API_WORK_APP_URL: string
readonly VITE_APP_API_WORK_ORG_URL: string readonly VITE_APP_API_WORK_ORG_URL: string
readonly VITE_APP_USER_CENTER: 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_H5_URL: string
readonly VITE_APP_LAND_PAGE: string readonly VITE_APP_LAND_PAGE: string
readonly VITE_APP_XB_URL: string
} }
interface ImportMeta { interface ImportMeta {
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>亲亲企服管理后台</title> <title>管理后台</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
......
{ {
"name": "qqqf-admin", "name": "admin",
"type": "module", "type": "module",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "管理后台模板(Vue 3 + Vite SPA)",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc && vite build", "dev:test": "vite --mode test",
"preview": "vite preview" "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": { "dependencies": {
"@common/sdk": "workspace:*",
"@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"ant-design-vue": "^4.2.6", "ant-design-vue": "^4.2.6",
"axios": "^1.7.9",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.3.0" "vue-router": "^4.3.0"
...@@ -18,7 +28,6 @@ ...@@ -18,7 +28,6 @@
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"typescript": "^5.6.3", "typescript": "^5.6.3",
"unocss": "^0.58.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
......
import type { LoginRegisterResult, VerifyImageResult } from "@common/sdk" import type { LoginRegisterResult, VerifyImageResult } from '@common/sdk'
import { loginService, verifyService } from '@/src/api/request' import { loginService, verifyService } from '@/api/request'
export type { LoginRegisterResult, VerifyImageResult } 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 { Sdk, TokenManager, webAdapter } from '@common/sdk'
import { createLoginService } from '@common/sdk/services/login-service' import { createLoginService } from '@common/sdk/services/login-service'
import { createVerifyService } from '@common/sdk/services/verify-service' import { createVerifyService } from '@common/sdk/services/verify-service'
import { config } from '@/config'
/** /**
* qqqf-admin SDK 配置 * SDK 实例(Web 平台使用 webAdapter)
*/ */
const sdkConfig: SdkConfig = { export const sdk = new Sdk(config, webAdapter)
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)
/** /**
* 创建服务(函数式,无需实例化) * 创建服务(函数式,无需实例化)
...@@ -29,7 +15,7 @@ export const loginService = createLoginService(sdk.core, sdk.clientId) ...@@ -29,7 +15,7 @@ export const loginService = createLoginService(sdk.core, sdk.clientId)
export const verifyService = createVerifyService(sdk.core, sdk.clientId) export const verifyService = createVerifyService(sdk.core, sdk.clientId)
/** /**
* 初始化 Token(如果 * 初始化 Token(如果本地已有有效 Token 则恢复登录态
*/ */
export function initToken() { export function initToken() {
const token = TokenManager.hasToken() 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 { createPinia } from 'pinia'
import { createApp } from 'vue' import { createApp } from 'vue'
import { initToken } from './api/request' import { initToken } from '@/api/request'
import App from './App.vue' import App from '@/App.vue'
import router from './router' import router from '@/router'
// 初始化 SDK 和 Token(已在 api/request.ts 中封装 // 恢复本地 Token 登录态(SDK 已在 api/request.ts 中初始化
initToken() initToken()
const app = createApp(App) const app = createApp(App)
......
<template> <template>
<div class="home-container"> <div class="home-container">
<h1>qqqf-admin</h1> <h1>{{ config.appName }}</h1>
<button class="test-btn" @click="testApiCall">测试 SDK 请求(携带 Token)</button> <button class="test-btn" @click="testApiCall">测试 SDK 请求(携带 Token)</button>
<div v-if="result" class="result"> <div v-if="result" class="result">
<h3>请求结果:</h3> <h3>请求结果:</h3>
...@@ -11,15 +11,20 @@ ...@@ -11,15 +11,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { sdk } from '@/src/api/request' import { sdk } from '@/api/request'
import { config } from '@/config'
const result = ref<any>(null) const result = ref<any>(null)
async function testApiCall() { async function testApiCall() {
try { try {
// 调用一个匿名接口测试 // 示例:调用一个匿名接口。请替换为项目实际的子项目名/服务名/接口名
const res = await sdk.core const res = await sdk.core
.domainService('qqqf', 'anonymous/api', 'test') .domainService(
config.domainService.subProjectName,
`anonymous/${config.domainService.serviceName}`,
'test',
)
.get() .get()
result.value = res result.value = res
} }
......
<template> <template>
<div class="login-container"> <div class="login-container">
<div class="login-card"> <div class="login-card">
<h1 class="login-title">亲亲企服管理后台</h1> <h1 class="login-title">{{ config.appName }}</h1>
<!-- 登录方式切换 --> <!-- 登录方式切换 -->
<div class="login-tabs"> <div class="login-tabs">
...@@ -101,15 +101,21 @@ ...@@ -101,15 +101,21 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { SMS_COUNTDOWN } from '@common/utils/constants'
import { isPhone } from '@common/utils/regex'
import { ref } from 'vue' import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { getVerifyImage, passwordLogin, sendSmsCode, smsLogin } from '@/src/api/auth' import { getVerifyImage } from '@/api/auth'
import { useAuthStore } from '@/src/stores/auth' import { config } from '@/config'
import { Toast } from '@/src/utils/toast' import {
handlePasswordLogin as loginByPassword,
handleSmsLogin as loginBySms,
handleSendSmsCode as requestSmsCode,
} from '@/services'
import { Toast } from '@/utils/toast'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const authStore = useAuthStore()
const loginType = ref<'sms' | 'password'>('sms') const loginType = ref<'sms' | 'password'>('sms')
...@@ -150,7 +156,7 @@ async function sendSms() { ...@@ -150,7 +156,7 @@ async function sendSms() {
Toast.warning('请输入手机号') Toast.warning('请输入手机号')
return return
} }
if (!/^1\d{10}$/.test(smsForm.value.mobile)) { if (!isPhone(smsForm.value.mobile)) {
Toast.warning('手机号格式不正确') Toast.warning('手机号格式不正确')
return return
} }
...@@ -159,16 +165,15 @@ async function sendSms() { ...@@ -159,16 +165,15 @@ async function sendSms() {
return return
} }
try { const ok = await requestSmsCode(
await sendSmsCode(
smsForm.value.mobile, smsForm.value.mobile,
verifyImage.value.seed, verifyImage.value.seed,
smsForm.value.verifycode, smsForm.value.verifycode,
) )
Toast.success('验证码已发送') if (!ok) return
// 开始倒计时 // 开始倒计时
countdown.value = 60 countdown.value = SMS_COUNTDOWN
timer = setInterval(() => { timer = setInterval(() => {
countdown.value-- countdown.value--
if (countdown.value <= 0 && timer) { if (countdown.value <= 0 && timer) {
...@@ -176,10 +181,6 @@ async function sendSms() { ...@@ -176,10 +181,6 @@ async function sendSms() {
timer = null timer = null
} }
}, 1000) }, 1000)
}
catch (e: any) {
Toast.error(e?.message || '发送失败')
}
} }
// 验证码登录 // 验证码登录
...@@ -195,16 +196,8 @@ async function handleSmsLogin() { ...@@ -195,16 +196,8 @@ async function handleSmsLogin() {
smsLoading.value = true smsLoading.value = true
try { try {
const res = await smsLogin(smsForm.value.mobile, smsForm.value.smsCode) const ok = await loginBySms(smsForm.value.mobile, smsForm.value.smsCode)
const result = res as any if (ok) redirectAfterLogin()
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
} }
finally { finally {
smsLoading.value = false smsLoading.value = false
...@@ -224,25 +217,23 @@ async function handlePasswordLogin() { ...@@ -224,25 +217,23 @@ async function handlePasswordLogin() {
passwordLoading.value = true passwordLoading.value = true
try { try {
const res = await passwordLogin( const ok = await loginByPassword(
passwordForm.value.username, passwordForm.value.username,
passwordForm.value.password, passwordForm.value.password,
) )
const result = res as any if (ok) redirectAfterLogin()
authStore.setToken(result.jwt)
Toast.success('登录成功')
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
catch (e: any) {
Toast.error(e?.message || '登录失败')
} }
finally { finally {
passwordLoading.value = false passwordLoading.value = false
} }
} }
// 登录成功后回跳
function redirectAfterLogin() {
const redirect = (route.query.redirect as string) || '/'
router.push(redirect)
}
// 初始化图形验证码 // 初始化图形验证码
refreshVerifyImage() refreshVerifyImage()
</script> </script>
......
import type { RouteRecordRaw } from 'vue-router' import type { RouteRecordRaw } from 'vue-router'
import { TokenManager } from '@common/sdk' import { TokenManager } from '@common/sdk'
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { config } from '@/config'
const routes: RouteRecordRaw[] = [ const routes: RouteRecordRaw[] = [
{ {
path: '/login', path: '/login',
name: 'Login', name: 'Login',
component: () => import('../pages/login/index.vue'), component: () => import('@/pages/login/index.vue'),
meta: { title: '登录', requiresAuth: false }, meta: { title: '登录', requiresAuth: false },
}, },
{ {
path: '/', path: '/',
name: 'Home', name: 'Home',
component: () => import('../pages/index.vue'), component: () => import('@/pages/index.vue'),
meta: { title: '首页', requiresAuth: true }, meta: { title: '首页', requiresAuth: true },
}, },
] ]
...@@ -23,7 +24,7 @@ const router = createRouter({ ...@@ -23,7 +24,7 @@ const router = createRouter({
}) })
router.beforeEach((to, _from, next) => { 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() const token = TokenManager.hasToken()
if (to.meta.requiresAuth && !token) { if (to.meta.requiresAuth && !token) {
next({ path: '/login', query: { redirect: to.fullPath } }) 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 { TokenManager } from '@common/sdk'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
......
export { useAuthStore } from './auth'
export type { UserInfo } from './auth'
export { Toast } from './toast'
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"paths": { "paths": {
"@/*": [ "@/*": [
"./*" "./src/*"
] ]
}, },
"resolveJsonModule": true, "resolveJsonModule": true,
......
...@@ -6,7 +6,7 @@ export default defineConfig({ ...@@ -6,7 +6,7 @@ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, '.'), '@': resolve(__dirname, 'src'),
}, },
}, },
build: { 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", "name": "mini",
"version": "0.0.0", "version": "1.0.0",
"private": true, "private": true,
"description": "小程序模板(UniApp + Vue 3 + TypeScript)",
"scripts": { "scripts": {
"dev": "uni -p mp-weixin", "dev": "uni -p mp-weixin",
"dev:h5": "uni", "dev:h5": "uni",
...@@ -24,6 +25,7 @@ ...@@ -24,6 +25,7 @@
"dependencies": { "dependencies": {
"@common/sdk": "workspace:*", "@common/sdk": "workspace:*",
"@common/utils": "workspace:*", "@common/utils": "workspace:*",
"@common/vue-kit": "workspace:*",
"@dcloudio/uni-app": "3.0.0-4070520250711001", "@dcloudio/uni-app": "3.0.0-4070520250711001",
"@dcloudio/uni-components": "3.0.0-4070520250711001", "@dcloudio/uni-components": "3.0.0-4070520250711001",
"@dcloudio/uni-h5": "3.0.0-4070520250711001", "@dcloudio/uni-h5": "3.0.0-4070520250711001",
......
<script lang="ts" setup> <script lang="ts" setup>
import { TokenManager } from "@common/sdk" import { TokenManager } from "@common/sdk"
import { onLaunch } from "@dcloudio/uni-app" import { onLaunch } from "@dcloudio/uni-app"
import { sdk } from "@/api/http" import { getSdk } from "@/utils/sdk"
onLaunch(() => { onLaunch(() => {
console.log("App Launch") console.log("App Launch")
sdkInit() sdkInit()
}) })
// 恢复本地 Token 登录态
function sdkInit() { function sdkInit() {
const token = TokenManager.hasToken() const token = TokenManager.hasToken()
if (token) { if (token) {
sdk.core.loginByToken({ getSdk().core.loginByToken({
token: token as string, token: token as string,
}) })
} }
...@@ -19,8 +20,9 @@ function sdkInit() { ...@@ -19,8 +20,9 @@ function sdkInit() {
</script> </script>
<style lang="scss"> <style lang="scss">
@import "@/assets/styles/common.scss"; // 全局样式(@use 替代已废弃的 @importDart Sass 3.0 将移除 @import
@import "@/assets/styles/style.scss"; @use "@/assets/styles/common.scss";
@use "@/assets/styles/style.scss";
view { view {
background-repeat: no-repeat; background-repeat: no-repeat;
......
/**
* 登录服务
* 提供微信一键登录、短信验证码登录、密码登录等接口调用
*/
import { sdk } from "@/api/http"
import { config } from "@/config" import { config } from "@/config"
import { getSdk } from "@/utils/sdk"
export interface LoginResult { export interface LoginResult {
jwt: string jwt: string
...@@ -36,24 +32,18 @@ export function getWxLoginCode(): Promise<string> { ...@@ -36,24 +32,18 @@ export function getWxLoginCode(): Promise<string> {
* 微信小程序绑定手机号登录 * 微信小程序绑定手机号登录
*/ */
export function miniProgramBindPhone( export function miniProgramBindPhone(
appid: string,
code: string, code: string,
clientId: string,
encryptedData: string, encryptedData: string,
iv: string, iv: string,
registerChannel: RegisterChannel = {}, registerChannel: RegisterChannel = {},
): Promise<LoginResult> { ): Promise<LoginResult> {
return sdk.domainServicePost< return getSdk().domainServicePost<any, any, LoginResult>(
any,
any,
LoginResult
>(
"bind_phone", "bind_phone",
{ {
data: { data: {
appid, appid: config.mpAppid,
code, code,
client_id: clientId, client_id: config.clientId,
encryptedData, encryptedData,
iv, iv,
...registerChannel, ...registerChannel,
...@@ -62,21 +52,15 @@ export function miniProgramBindPhone( ...@@ -62,21 +52,15 @@ export function miniProgramBindPhone(
true, // 匿名接口 true, // 匿名接口
"uniplat_base", "uniplat_base",
"system.wechat", "system.wechat",
config,
) )
} }
/** /**
* 发送短信验证码 * 发送短信验证码
*/ */
export function sendSmsCode( export function sendSmsCode(mobile: string, imgCode: string): Promise<void> {
mobile: string, return getSdk().domainServicePost<any, any, void>(
imgCode: string,
): Promise<void> {
return sdk.domainServicePost<
any,
any,
void
>(
"send_verifycode", "send_verifycode",
{ {
data: { data: {
...@@ -88,6 +72,7 @@ export function sendSmsCode( ...@@ -88,6 +72,7 @@ export function sendSmsCode(
true, true,
"uniplat_base", "uniplat_base",
"anonymous/system.user", "anonymous/system.user",
config,
) )
} }
...@@ -99,11 +84,7 @@ export function smsLogin( ...@@ -99,11 +84,7 @@ export function smsLogin(
verifycode: string, verifycode: string,
registerChannel: RegisterChannel = {}, registerChannel: RegisterChannel = {},
): Promise<LoginResult> { ): Promise<LoginResult> {
return sdk.domainServicePost< return getSdk().domainServicePost<any, any, LoginResult>(
any,
any,
LoginResult
>(
"login_with_mobile", "login_with_mobile",
{ {
data: { data: {
...@@ -116,6 +97,7 @@ export function smsLogin( ...@@ -116,6 +97,7 @@ export function smsLogin(
true, true,
"uniplat_base", "uniplat_base",
"anonymous/system.user", "anonymous/system.user",
config,
) )
} }
...@@ -127,11 +109,7 @@ export function passwordLogin( ...@@ -127,11 +109,7 @@ export function passwordLogin(
password: string, password: string,
registerChannel: RegisterChannel = {}, registerChannel: RegisterChannel = {},
): Promise<LoginResult> { ): Promise<LoginResult> {
return sdk.domainServicePost< return getSdk().domainServicePost<any, any, LoginResult>(
any,
any,
LoginResult
>(
"login_with_password", "login_with_password",
{ {
data: { data: {
...@@ -144,6 +122,7 @@ export function passwordLogin( ...@@ -144,6 +122,7 @@ export function passwordLogin(
true, true,
"uniplat_base", "uniplat_base",
"anonymous/system.user", "anonymous/system.user",
config,
) )
} }
...@@ -151,14 +130,14 @@ export function passwordLogin( ...@@ -151,14 +130,14 @@ export function passwordLogin(
* 获取图形验证码 * 获取图形验证码
*/ */
export function getImageCode(): Promise<string> { export function getImageCode(): Promise<string> {
return sdk.domainServiceGet< return getSdk()
any, .domainServiceGet<any, { img_code: string }>(
{ img_code: string }
>(
"get_image_code", "get_image_code",
{}, {},
true, true,
"uniplat_base", "uniplat_base",
"system.user", "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 { createPinia } from "pinia"
import { createSSRApp } from "vue" import { createSSRApp } from "vue"
import App from "@/App.vue" import App from "@/App.vue"
import { initSdk } from "@/utils/sdk"
console.log("env", import.meta.env)
export function createApp() { export function createApp() {
const app = createSSRApp(App) const app = createSSRApp(App)
const pinia = createPinia() const pinia = createPinia()
app.use(pinia) app.use(pinia)
initSdk()
return { app } return { app }
} }
{ {
"name": "qqqf-mp", "name": "小程序模板",
"appid": "", "appid": "",
"description": "", "description": "",
"versionName": "1.0.0", "versionName": "1.0.0",
......
<template> <template>
<view class="home-page"> <view class="home-page">
<view class="header"> <view class="header">
<text class="title">欢迎来到 QQQF</text> <text class="title">欢迎使用 {{ config.appName }}</text>
</view> </view>
<view class="content"> <view class="content">
...@@ -25,8 +25,8 @@ ...@@ -25,8 +25,8 @@
<view class="card"> <view class="card">
<text class="card-title">环境信息</text> <text class="card-title">环境信息</text>
<text class="info-text">API 地址: {{ apiBaseUrl }}</text> <text class="info-text">API 地址: {{ config.uniplatApi || "未配置" }}</text>
<text class="info-text">环境: {{ env }}</text> <text class="info-text">环境: {{ currentEnv }}</text>
</view> </view>
</view> </view>
</view> </view>
...@@ -34,14 +34,14 @@ ...@@ -34,14 +34,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue" 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 homeData = ref<HomeData | null>(null)
const loading = ref(false) const loading = ref(false)
const errorMsg = ref("") const errorMsg = ref("")
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || "未配置" const currentEnv = CoreEnvir.getCurrent()
const env = import.meta.env.VITE_ENV || "development"
async function handleFetchData() { async function handleFetchData() {
loading.value = true loading.value = true
......
...@@ -43,17 +43,15 @@ ...@@ -43,17 +43,15 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { TokenManager } from "@common/sdk"
import { onLoad } from "@dcloudio/uni-app" import { onLoad } from "@dcloudio/uni-app"
import { ref } from "vue" import { ref } from "vue"
import { miniProgramBindPhone } from "@/api/login-service" import { handleWxLogin } from "@/services"
import { config } from "@/config"
const agree = ref(false) const agree = ref(false)
onLoad((e: { backUrl?: string }) => { onLoad((query?: AnyObject) => {
if (e.backUrl) { if (query?.backUrl) {
uni.setStorageSync("loginRedirect", e.backUrl) uni.setStorageSync("loginRedirect", query.backUrl)
} }
}) })
...@@ -88,33 +86,15 @@ async function doWxLogin(e: any) { ...@@ -88,33 +86,15 @@ async function doWxLogin(e: any) {
uni.showLoading({ title: "登录中...", mask: true }) uni.showLoading({ title: "登录中...", mask: true })
try { try {
// 获取微信登录 code const ok = await handleWxLogin(e.detail.encryptedData, e.detail.iv)
const code = await new Promise<string>((resolve, reject) => { if (!ok) return
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" })
setTimeout(() => { setTimeout(() => {
const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index" const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index"
uni.reLaunch({ url: redirect }) uni.reLaunch({ url: redirect })
}, 1500) }, 1500)
} catch (err: any) { } finally {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: err?.msg || err?.message || "登录失败,请重试", icon: "none" })
} }
} }
......
...@@ -141,10 +141,15 @@ ...@@ -141,10 +141,15 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VerifyImageResult } from "@common/sdk" 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 { onLoad } from "@dcloudio/uni-app"
import { computed, reactive, ref } from "vue" 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") const activeTab = ref<"sms" | "password">("sms")
...@@ -177,12 +182,12 @@ let codeTimer: ReturnType<typeof setInterval> | null = null ...@@ -177,12 +182,12 @@ let codeTimer: ReturnType<typeof setInterval> | null = null
const imageCodeModel = ref<VerifyImageResult | null>(null) const imageCodeModel = ref<VerifyImageResult | null>(null)
function getVerifyCodeImg() { function getVerifyCodeImg() {
imageCodeModel.value = verifyService.generateImage() imageCodeModel.value = getVerifyService().generateImage()
} }
function startCodeCountdown() { function startCodeCountdown() {
codeCounting.value = true codeCounting.value = true
codeCountdown.value = 60 codeCountdown.value = SMS_COUNTDOWN
codeTips.value = `重新获取(${codeCountdown.value}s)` codeTips.value = `重新获取(${codeCountdown.value}s)`
codeTimer = setInterval(() => { codeTimer = setInterval(() => {
codeCountdown.value-- codeCountdown.value--
...@@ -203,68 +208,60 @@ function stopCodeCountdown() { ...@@ -203,68 +208,60 @@ function stopCodeCountdown() {
codeTips.value = "获取验证码" codeTips.value = "获取验证码"
} }
function isPhone(val: string) {
return /^1[3-9]\d{9}$/.test(val)
}
async function getCode() { async function getCode() {
if (!smsModel.mobile) { if (!smsModel.mobile) {
uni.showToast({ title: "请输入手机号", icon: "none" }) toastError("请输入手机号")
return return
} }
if (!isPhone(smsModel.mobile)) { if (!isPhone(smsModel.mobile)) {
uni.showToast({ title: "手机号码不正确", icon: "none" }) toastError("手机号码不正确")
return return
} }
if (!smsModel.imgCode) { if (!smsModel.imgCode) {
uni.showToast({ title: "请输入图形验证码", icon: "none" }) toastError("请输入图形验证码")
return return
} }
if (codeCounting.value) { if (codeCounting.value) {
uni.showToast({ title: "倒计时结束后再发送", icon: "none" }) toastError("倒计时结束后再发送")
return return
} }
uni.showLoading({ title: "正在获取验证码", mask: true }) uni.showLoading({ title: "正在获取验证码", mask: true })
try { try {
await verifyService.sendVerifyCode( await getVerifyService().sendVerifyCode(
smsModel.mobile, smsModel.mobile,
imageCodeModel.value?.seed || "", imageCodeModel.value?.seed || "",
smsModel.imgCode, smsModel.imgCode,
) )
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: "验证码已发送", icon: "success" }) toastSuccess("验证码已发送")
startCodeCountdown() startCodeCountdown()
} catch (err: any) { } catch (err: any) {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: err?.msg || err?.message || "发送失败", icon: "none" }) toastError(err?.msg || err?.message || "发送失败")
} }
} }
async function smsLogin() { async function smsLogin() {
if (!smsAgree.value) { if (!smsAgree.value) {
uni.showToast({ title: "需同意《服务协议》《隐私政策》", icon: "none" }) toastError("需同意《服务协议》《隐私政策》")
return return
} }
if (!smsModel.mobile) { if (!smsModel.mobile) {
return uni.showToast({ title: "请输入手机号", icon: "none" }) toastError("请输入手机号")
return
} }
if (!isPhone(smsModel.mobile)) { if (!isPhone(smsModel.mobile)) {
return uni.showToast({ title: "手机号码不正确", icon: "none" }) toastError("手机号码不正确")
return
} }
if (!smsModel.code) { if (!smsModel.code) {
return uni.showToast({ title: "请输入验证码", icon: "none" }) toastError("请输入验证码")
return
} }
smsLoading.value = true smsLoading.value = true
try { try {
const result = await loginService.verifyCodeLogin(smsModel.mobile, smsModel.code) const ok = await handleSmsLogin(smsModel.mobile, smsModel.code)
TokenManager.saveToken2Storage(result.jwt) if (ok) redirectAfterLogin()
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" })
} finally { } finally {
smsLoading.value = false smsLoading.value = false
} }
...@@ -272,29 +269,35 @@ async function smsLogin() { ...@@ -272,29 +269,35 @@ async function smsLogin() {
async function passwordLogin() { async function passwordLogin() {
if (!pwdAgree.value) { if (!pwdAgree.value) {
uni.showToast({ title: "需同意《服务协议》《隐私政策》", icon: "none" }) toastError("需同意《服务协议》《隐私政策》")
return return
} }
if (!passwordModel.username) { if (!passwordModel.username) {
return uni.showToast({ title: "请输入用户名或手机号", icon: "none" }) toastError("请输入用户名或手机号")
return
} }
if (!passwordModel.password) { if (!passwordModel.password) {
return uni.showToast({ title: "请输入密码", icon: "none" }) toastError("请输入密码")
return
} }
pwdLoading.value = true pwdLoading.value = true
try { try {
const result = await loginService.login(passwordModel.username, passwordModel.password) const ok = await handlePasswordLogin(
TokenManager.saveToken2Storage(result.jwt) passwordModel.username,
uni.showToast({ title: "登录成功", icon: "success" }) passwordModel.password,
)
if (ok) redirectAfterLogin()
} finally {
pwdLoading.value = false
}
}
// 登录成功后回跳
function redirectAfterLogin() {
setTimeout(() => { setTimeout(() => {
const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index" const redirect = uni.getStorageSync("loginRedirect") || "/pages/home/index"
uni.reLaunch({ url: redirect }) uni.reLaunch({ url: redirect })
}, 1500) }, 1500)
} catch (err: any) {
uni.showToast({ title: err?.msg || err?.message || "登录失败", icon: "none" })
} finally {
pwdLoading.value = false
}
} }
function goForget() { function goForget() {
......
...@@ -141,11 +141,15 @@ ...@@ -141,11 +141,15 @@
</template> </template>
<script lang="ts" setup> <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 { onLoad } from "@dcloudio/uni-app"
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟数据 const userInfoStore = useUserInfo()
const userInfo = ref({ mobile: "13800138000" }) const userInfo = computed(() => userInfoStore.userInfo || { mobile: "" })
const sdkLogin = ref(true) const sdkLogin = ref(true)
const phone = ref("") const phone = ref("")
...@@ -200,7 +204,7 @@ function getCode() { ...@@ -200,7 +204,7 @@ function getCode() {
function startCodeCountdown() { function startCodeCountdown() {
codeCounting.value = true codeCounting.value = true
codeCountdown.value = 60 codeCountdown.value = SMS_COUNTDOWN
sended.value = true sended.value = true
codeTimer = setInterval(() => { codeTimer = setInterval(() => {
codeCountdown.value-- codeCountdown.value--
...@@ -233,14 +237,9 @@ const isFormValid = computed(() => { ...@@ -233,14 +237,9 @@ const isFormValid = computed(() => {
return phoneValid && imgVerifyCode.value && verifyCode.value && pwd.value.pwd1.value && pwd.value.pwd2.value 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) {
} return maskPhone(str || "")
function desensitization(str: string) {
if (!str) return ""
if (str.length <= 6) return str
return str.slice(0, 3) + "****" + str.slice(-4)
} }
function confirm() { function confirm() {
......
<template> <template>
<view class="c-page"> <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 class="c-form u-p-b-24">
<view <view
v-for="item in form" v-for="item in form"
...@@ -41,18 +41,12 @@ ...@@ -41,18 +41,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { maskIdCard, maskPhone } from "@common/utils/mask"
import { onLoad } from "@dcloudio/uni-app" import { onLoad } from "@dcloudio/uni-app"
import { get } from "lodash"
import { ref } from "vue" import { ref } from "vue"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟用户信息(实际应从 store 获取) const userInfoStore = useUserInfo()
const userInfo = ref<any>({
realname: "张三",
mobile: "13800138000",
user_info: {
idCard: "110101199001011234",
},
})
const form = ref([ const form = ref([
{ {
...@@ -91,13 +85,13 @@ const idType = ref([ ...@@ -91,13 +85,13 @@ const idType = ref([
]) ])
function getInfo(prop: string) { function getInfo(prop: string) {
return get(userInfo.value, prop) || "" return userInfoStore.get(prop)
} }
// 手机号/身份证号脱敏展示
function desensitization(str: string) { function desensitization(str: string) {
if (!str) return "" if (!str) return ""
if (str.length <= 6) return str return str.length === 11 ? maskPhone(str) : maskIdCard(str)
return str.slice(0, 3) + "****" + str.slice(-4)
} }
const curIdType = ref<number | null>(null) const curIdType = ref<number | null>(null)
...@@ -108,7 +102,7 @@ function confirm() { ...@@ -108,7 +102,7 @@ function confirm() {
} }
onLoad(() => { onLoad(() => {
console.log("userInfo", userInfo.value) console.log("userInfo", userInfoStore.userInfo)
}) })
</script> </script>
......
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed } from "vue" import { computed } from "vue"
import { handleLogout } from "@/services"
const form = computed(() => [ const form = computed(() => [
{ {
...@@ -37,11 +38,12 @@ const form = computed(() => [ ...@@ -37,11 +38,12 @@ const form = computed(() => [
prop: "password", prop: "password",
route: "/pages/password/index", route: "/pages/password/index",
}, },
{ // TODO: 实现协议页面后取消注释
label: "加盟协议", // {
prop: "agreement", // label: "用户协议",
route: "/pages/agreement/index", // prop: "agreement",
}, // route: "/pages/agreement/index",
// },
]) ])
function toPage(route: string) { function toPage(route: string) {
...@@ -50,7 +52,7 @@ function toPage(route: string) { ...@@ -50,7 +52,7 @@ function toPage(route: string) {
function exit() { function exit() {
uni.showLoading({ title: "退出中..." }) uni.showLoading({ title: "退出中..." })
// TODO: 调用登出逻辑 handleLogout()
uni.hideLoading() uni.hideLoading()
uni.reLaunch({ url: "/pages/home/index" }) uni.reLaunch({ url: "/pages/home/index" })
} }
......
...@@ -5,14 +5,14 @@ ...@@ -5,14 +5,14 @@
<view class="verified-content"> <view class="verified-content">
<image <image
class="bg" class="bg"
src="https://static.qinqinxiaobao.com/flb-mp/img/auth/auth-detail-bg.png" :src="`${config.imgBaseUrl}auth/auth-detail-bg.png`"
></image> ></image>
<view class="u-flex text u-row-between"> <view class="u-flex text u-row-between">
<view class="">您已实名认证</view> <view class="">您已实名认证</view>
<view class=""> <view class="">
<image <image
class="icon" class="icon"
src="https://static.qinqinxiaobao.com/flb-mp/img/auth/auth-status-logo.png" :src="`${config.imgBaseUrl}auth/auth-status-logo.png`"
></image> ></image>
</view> </view>
</view> </view>
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
</view> </view>
<view class="info u-flex"> <view class="info u-flex">
<view>身份证号</view> <view>身份证号</view>
<view>{{ desensitization(userInfo.user_info.idCard) }}</view> <view>{{ desensitization(userInfo.user_info?.idCard) }}</view>
</view> </view>
<view class="info u-flex"> <view class="info u-flex">
<view>认证方式</view> <view>认证方式</view>
...@@ -161,13 +161,18 @@ ...@@ -161,13 +161,18 @@
</template> </template>
<script lang="ts" setup> <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 { onLoad } from "@dcloudio/uni-app"
import { computed, ref } from "vue" import { computed, ref } from "vue"
import { config } from "@/config"
import { useUserInfo } from "@/stores/useUserInfo"
// 模拟数据(实际应从 SDK/API 获取) const userInfoStore = useUserInfo()
const userInfo = ref({ const userInfo = computed(() => userInfoStore.userInfo || {
realname: "张三", realname: "",
mobile: "13800138000", mobile: "",
user_info: { idCard: "" }, user_info: { idCard: "" },
}) })
const isVerified = ref(false) const isVerified = ref(false)
...@@ -221,7 +226,7 @@ function getCode() { ...@@ -221,7 +226,7 @@ function getCode() {
function startCodeCountdown() { function startCodeCountdown() {
codeCounting.value = true codeCounting.value = true
codeCountdown.value = 60 codeCountdown.value = SMS_COUNTDOWN
sended.value = true sended.value = true
codeTimer = setInterval(() => { codeTimer = setInterval(() => {
codeCountdown.value-- codeCountdown.value--
...@@ -254,18 +259,10 @@ const isFormValid = computed(() => { ...@@ -254,18 +259,10 @@ const isFormValid = computed(() => {
return name.value && phoneValid && id_card.value && imgVerifyCode.value && verifyCode.value return name.value && phoneValid && id_card.value && imgVerifyCode.value && verifyCode.value
}) })
function isPhone(val: string) { // 手机号/身份证号脱敏展示
return /^1[3-9]\d{9}$/.test(val) function desensitization(str?: string) {
}
function isIdentityNumber(val: string) {
return /(^\d{15}$)|(^\d{18}$)|(^\d{17}([\dXx])$)/.test(val)
}
function desensitization(str: string) {
if (!str) return "" if (!str) return ""
if (str.length <= 6) return str return str.length === 11 ? maskPhone(str) : maskIdCard(str)
return str.slice(0, 3) + "****" + str.slice(-4)
} }
function confirm() { function confirm() {
...@@ -280,7 +277,7 @@ function confirm() { ...@@ -280,7 +277,7 @@ function confirm() {
return return
} }
warn.value.phone = false warn.value.phone = false
if (!isIdentityNumber(id_card.value)) { if (!isIdCard(id_card.value)) {
warn.value.id_card = true warn.value.id_card = true
return return
} }
...@@ -315,7 +312,7 @@ function confirm() { ...@@ -315,7 +312,7 @@ function confirm() {
const existPhone = ref("") const existPhone = ref("")
function blurIdCard() { function blurIdCard() {
if (!isIdentityNumber(id_card.value)) return if (!isIdCard(id_card.value)) return
warn.value.id_card = false warn.value.id_card = false
idCardLoading.value = true idCardLoading.value = true
// TODO: 调用身份证唯一性检查接口 // 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" ...@@ -3,15 +3,9 @@ import { createLoginService } from "@common/sdk/services/login-service"
import { createVerifyService } from "@common/sdk/services/verify-service" import { createVerifyService } from "@common/sdk/services/verify-service"
import { config } from "@/config" import { config } from "@/config"
class Sdk extends SdkBase { /**
constructor() { * 小程序 Storage 适配器(替代 localStorage)
super(config, uniAdapter) */
}
}
const sdk = new Sdk()
// 小程序 Storage 适配器(替代 localStorage)
class UniStorage implements Storage { class UniStorage implements Storage {
get length() { get length() {
const info = uni.getStorageInfoSync() const info = uni.getStorageInfoSync()
...@@ -30,9 +24,9 @@ class UniStorage implements Storage { ...@@ -30,9 +24,9 @@ class UniStorage implements Storage {
} }
} }
key(_index: number) { key(index: number) {
const info = uni.getStorageInfoSync() const info = uni.getStorageInfoSync()
return info.keys[_index] || null return info.keys[index] || null
} }
removeItem(key: string) { removeItem(key: string) {
...@@ -44,11 +38,45 @@ class UniStorage implements Storage { ...@@ -44,11 +38,45 @@ class UniStorage implements Storage {
} }
} }
// 设置小程序存储实例(替代 localStorage) class Sdk extends SdkBase {
TokenManager.setupStorage(new UniStorage()) constructor() {
super(config, uniAdapter)
}
}
// 创建服务(函数式,无需实例化) let sdkInstance: Sdk | null = null
export const loginService = createLoginService(sdk.core, sdk.clientId) let loginServiceInstance: ReturnType<typeof createLoginService> | null = null
export const verifyService = createVerifyService(sdk.core, sdk.clientId) 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
This diff could not be displayed because it is too large.
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