Compare commits

...

37 Commits

Author SHA1 Message Date
xiaojunnuo b083b3cc41 style(trade crud): adjust order and style for order operation buttons
add order prop for syncStatus, cancel and updatePaid buttons, set cancel button to danger style for both trade pages, unify button display order logic
2026-05-29 01:00:14 +08:00
xiaojunnuo 6624769032 perf(trade): 优化商品购买页面的规格展示和折扣计算,支持订单取消 2026-05-29 00:56:30 +08:00
xiaojunnuo c7a9363422 chore: 钱包重构 2026-05-27 01:49:43 +08:00
xiaojunnuo 969b6e3288 chore: 钱包邀请页面美化 2026-05-26 23:45:44 +08:00
xiaojunnuo 41254d10b7 fix: 修复阿里云证书订单orderid 选择出错的问题 2026-05-26 11:13:29 +08:00
xiaojunnuo ed97f41884 chore: 阿里云订单优化只获取一个订单id 2026-05-26 09:10:01 +08:00
xiaojunnuo 02b83ce0ad Merge branch 'v2-invite' of https://github.com/certd/certd into v2-invite 2026-05-26 09:02:53 +08:00
xiaojunnuo f1d2a1033a feat: 商业版支持邀请推广功能 2026-05-26 01:08:17 +08:00
xiaojunnuo ba1fe54ef8 chore: 优化私有图片上传和查看逻辑 2026-05-25 23:05:23 +08:00
xiaojunnuo deac92faf8 chore: 1 2026-05-25 22:39:32 +08:00
xiaojunnuo 1c36a79162 Merge branch 'v2-dev' into v2-invite 2026-05-25 22:36:40 +08:00
xiaojunnuo b6f7042adc chore: 1 2026-05-25 11:42:35 +08:00
xiaojunnuo f721cefb4a build: release 2026-05-25 00:32:36 +08:00
xiaojunnuo fc2c947afe build: publish 2026-05-25 00:23:24 +08:00
xiaojunnuo d0272095cc build: trigger build image 2026-05-25 00:23:12 +08:00
xiaojunnuo 4a09cf289d v1.40.4 2026-05-25 00:22:01 +08:00
xiaojunnuo 89c23fef35 build: prepare to build 2026-05-25 00:18:32 +08:00
xiaojunnuo 5e59651d45 fix(pipeline-service): 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug 2026-05-25 00:15:23 +08:00
xiaojunnuo 5e72f75395 perf: 商业版套餐只支持设置为可叠加 2026-05-24 23:53:55 +08:00
xiaojunnuo 0a77fe0169 chore: 钱包余额明细 2026-05-24 23:12:56 +08:00
xiaojunnuo 961abb0f80 chore: acme账号改为必填项 2026-05-24 19:58:58 +08:00
xiaojunnuo 4efe12d2d3 Merge branch 'codex/v2-persist-01' into v2-invite 2026-05-24 19:48:24 +08:00
xiaojunnuo 67b05e2d75 feat: 支持dns-persist-01持久化验证方式申请证书,优化Acme账号的存储方式 2026-05-24 05:42:51 +08:00
xiaojunnuo 8edb6f8727 perf: 新增阿里云直播证书部署插件 2026-05-22 19:06:55 +08:00
xiaojunnuo b30f02a1fb chore: 1 2026-05-22 00:06:55 +08:00
xiaojunnuo 7e2333a63a chore: 1 2026-05-22 00:06:18 +08:00
xiaojunnuo 0be66cccbc build: release 2026-05-21 23:48:39 +08:00
xiaojunnuo ed26ed196d build: publish 2026-05-21 23:26:57 +08:00
xiaojunnuo a204f270dd build: trigger build image 2026-05-21 23:26:51 +08:00
xiaojunnuo 7585d7bbd0 build: publish 2026-05-21 23:25:32 +08:00
xiaojunnuo 2981f086c8 build: trigger build image 2026-05-21 23:25:21 +08:00
xiaojunnuo 784ef8a6a4 chore: 1 2026-05-21 23:23:20 +08:00
xiaojunnuo f4bb459b5e chore: 佣金等级功能 2026-05-20 00:17:29 +08:00
xiaojunnuo 83a5a21f95 perf: 商业版提现增加收款二维码上传 2026-05-19 23:37:03 +08:00
xiaojunnuo 85c633fddf Merge branch 'v2-dev' into v2-invite 2026-05-19 22:59:18 +08:00
xiaojunnuo f9a310b6c3 feat: 商业版支持邀请返佣功能 2026-05-18 13:25:35 +08:00
xiaojunnuo 1bdcfe646f chore: tsconfig.build 支持sourcemap控制 2026-05-18 13:25:01 +08:00
173 changed files with 7641 additions and 471 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
# cache: 'npm'
# working-directory: ./packages/ui/certd-client
- run: |
npm install -g pnpm
npm install -g pnpm@10.33.4
pnpm install
npm run build
working-directory: ./packages/ui/certd-client
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
console.log("certd_version:",pkg.version);
return pkg.version
- run: |
npm install -g pnpm
npm install -g pnpm@10.33.4
pnpm install
npm run build
working-directory: ./packages/ui/certd-client
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
run: |
export GITEE_TOKEN=${{ secrets.GITEE_TOKEN }}
rm -rf ./pnpm*.yaml
npm install -g pnpm
npm install -g pnpm@10.33.4
pnpm install
npm run publish_to_gitee
working-directory: ./
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
run: |
export GITHUB_TOKEN=${{ secrets.GH_TOKEN }}
rm -rf ./pnpm*.yaml
npm install -g pnpm
npm install -g pnpm@10.33.4
pnpm install
npm run publish_to_github
working-directory: ./
+1 -1
View File
@@ -50,7 +50,7 @@ jobs:
# cache: 'npm'
# working-directory: ./packages/ui/certd-client
- run: |
npm install -g pnpm
npm install -g pnpm@10.33.4
pnpm install
npm run build
working-directory: ./packages/ui/certd-client
+3 -1
View File
@@ -32,4 +32,6 @@ test/**/*.js
test.js
.history
/logs
.pnpm-lock.yaml
.pnpm-lock.yaml
pnpm-lock.yaml
.studio/
+2 -1
View File
@@ -20,5 +20,6 @@
"scm.repositories.visible": 9,
"scm.repositories.explorer": false,
"scm.repositories.selectionMode": "multiple",
"scm.repositories.sortOrder": "discovery time"
"scm.repositories.sortOrder": "discovery time",
"git.ignoreLimitWarning": true
}
+16 -1
View File
@@ -1,4 +1,4 @@
# Certd 开发 Agent 上下文
# Certd 开发 Agent 上下文
这个文件是给在本仓库工作的开发 agent 看的常驻项目说明。后续会话进入仓库后,应先读取它,再按任务需要查看具体代码,避免每次都重新全量扫描项目。
@@ -35,6 +35,8 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `packages/ui/certd-server/`:后端服务
- `packages/ui/certd-client/`:前端 Web 管理台
`packages/pro/` 是独立 Git 工作区,使用 `packages/pro/.git` 管理。根仓库的 `git status` / `git diff` 默认看不到这里的实际改动;修改商业版代码后,要在 `packages/pro` 目录内单独执行 `git status` / `git diff` 检查。
## 后端
主要后端包:`packages/ui/certd-server`
@@ -108,6 +110,14 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 不要运行前端 `pnpm tsc` / `vue-tsc`:当前依赖组合中 `vue-tsc@1.8.27` 会直接抛内部错误 `Search string not found: "/supportedTSExtensions = .*(?=;)/"`,不是有效的项目类型检查结果。
- 前端暂不跑单元测试;当前 `test:unit` 只是占位脚本
前端列表管理页面约定:
- 列表管理、后台管理、记录查询、CRUD 表格类页面,默认优先使用 Fast Crud(`@fast-crud/fast-crud``fs-crud``useFs``createCrudOptions`)实现。
- 只有轻量只读展示、强交互自定义界面或已有页面模式明确不适合 Fast Crud 时,才手写 `a-table` / 自定义列表,并在回复中说明原因。
- 开发或重构这类页面前,先读取 `.trae/skills/fast-crud-page-dev/SKILL.md`,按仓库内 Fast Crud 页面拆分与验证方式实现。
- 前端对话框里只做纯确认时可以使用 `Modal.confirm`;只要对话框里有字段输入、表单校验或提交字段,统一使用 `useFormDialog` / `openFormDialog`,不要在 `Modal.confirm``content` 里手写输入框。
- 页面内嵌 Fast Crud 表格时,要显式给外层容器稳定高度或 `flex: 1; min-height: 0` 的撑满链路;Fast Crud 依赖外部元素高度,不能只依赖表格默认高度。
## 流水线与插件模型
项目最关键的架构概念是证书流水线。
@@ -167,7 +177,9 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- 优先沿用现有模块、插件、服务模式,再考虑新增抽象。
- `packages/ui/certd-server/data/``logs/`、生成的 metadata/dist 等通常视为运行时或构建产物,除非任务明确要求处理它们。
- 注意本地数据和配置里可能包含凭据、证书材料等敏感信息。
- 使用 `/basic/file/upload` 上传文件后,接口返回的是临时缓存 key。业务保存表单或设置时,后端必须调用 `FileService.saveFile(userId, key, "public" | "private")` 转成永久文件 key 后再入库/入设置;不要直接保存 `tmpfile_key_...`,否则后续回显或下载会失效。
- 本仓库代码注释优先使用中文,尤其是解释业务规则、兼容逻辑、协议细节和隐藏风险时;除非文件已有明确英文注释风格或引用外部英文术语,否则不要新增英文说明性注释。
- 代码可读性优先于短写法。遇到包含业务分支的复杂三元表达式、内联对象、链式调用或条件组合时,优先拆成命名清晰的中间变量、独立分支或小函数,让读代码的人能一眼看出业务意图;不要为了少写几行把逻辑压成难读的一坨。
## 插件开发技能
@@ -182,6 +194,7 @@ Certd 是一个支持私有化部署的 SSL/TLS 证书自动化管理平台。
- `access-plugin-dev`:开发 Access 授权插件
- `dns-provider-dev`:开发 DNS Provider 插件
- `fast-crud-page-dev`:开发或重构前端 Fast Crud 列表管理页面
- `task-plugin-dev`:开发 Task 部署任务插件
- `plugin-converter`:将插件转换为 YAML 配置
@@ -218,3 +231,5 @@ Get-ChildItem packages\ui\certd-client\src\views\certd
- 单个 monorepo 包运行单元测试时,优先使用 `corepack pnpm --dir <包目录> test:unit`,例如 `corepack pnpm --dir packages\ui\certd-server test:unit``corepack pnpm --dir packages\core\basic test:unit``corepack pnpm --dir packages\plugins\plugin-lib test:unit`;也可以用包名过滤,例如 `corepack pnpm --filter @certd/ui-server test:unit`。前端 `packages\ui\certd-client` 暂时不跑单元测试。
- 前端 TS/Vue/locale 等文件改动后,优先只对本次改动文件运行项目现有自动格式化/修复;Windows/PowerShell 下 Prettier 已验证可用命令为 `packages\ui\certd-client\node_modules\.bin\prettier.cmd --write <files>`ESLint 可用命令为 `packages\ui\certd-client\node_modules\.bin\eslint.cmd --fix <files>`;不要运行 `vue-tsc` / `pnpm tsc`;不要为了格式化无关文件而扩大 diff。项目保留了 `tslint` 依赖,但当前主要使用 ESLint + Prettier。
- 优先对改动包运行聚焦的测试;后端可按包运行单元测试,前端优先使用 Prettier/ESLint 做改动文件验证。只有跨包影响明显时再考虑全 monorepo 构建。
- 不要主动运行 `pnpm install` 安装依赖:用户会事先准备好 `node_modules`。如果 `pnpm install``test:unit` 因缺少依赖、TTY 或网络问题失败,立即停止尝试,告知用户解决环境问题。
+11
View File
@@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Bug Fixes
* **pipeline-service:** 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug ([5e59651](https://github.com/certd/certd/commit/5e59651d45bc91919629e35995ff1b3cff6b87ea))
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes
+21
View File
@@ -3,6 +3,27 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Bug Fixes
* **pipeline-service:** 修复流水线运行时超过套餐部署次数仍然能够正常运行的bug ([5e59651](https://github.com/certd/certd/commit/5e59651d45bc91919629e35995ff1b3cff6b87ea))
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes
* 修复暗黑模式下注册页面验证码看不清的问题 ([5ba33be](https://github.com/certd/certd/commit/5ba33be30f765f06cafbfcc04f5e25320db01449))
### Performance Improvements
* 修复商业版套餐添加和修改时的字段显示 ([fb5b00d](https://github.com/certd/certd/commit/fb5b00d73f925036a65ce5003c57c1199578c34d))
## [1.40.2](https://github.com/certd/certd/compare/v1.40.1...v1.40.2) (2026-05-19)
### Bug Fixes
+8
View File
@@ -39,13 +39,21 @@ pnpm install
pnpm init
```
### lib包编译:
将packages下面依赖的包都编译一遍,并监听改动。
```shell
pnpm dev
```
### 启动 server:
启动server
```shell
cd packages/ui/certd-server
pnpm dev
```
### 启动 client:
启动前端
```shell
cd packages/ui/certd-client
pnpm dev
+9 -8
View File
@@ -1,5 +1,5 @@
# 任务插件
`131` 款任务插件
`132` 款任务插件
## 1. 证书申请
| 序号 | 名称 | 说明 |
@@ -93,13 +93,14 @@
| 9.| **阿里云-部署至ESA** | 部署证书到阿里云ESA(边缘安全加速),自动删除过期证书 |
| 10.| **阿里云-部署至阿里云FC(3.0)** | 部署证书到阿里云函数计算(FC3.0) |
| 11.| **阿里云-部署至GA** | 部署证书到阿里云GA(全球加速),支持更新默认证书和扩展证书 |
| 12.| **阿里云-部署至NLB(网络负载均衡)** | NLB,网络负载均衡,更新监听器的默认证书 |
| 13.| **阿里云-部署证书至OSS** | 部署域名证书至阿里云OSS自定义域名,不是上传到阿里云oss |
| 14.| **阿里云-部署至CLB(传统负载均衡)** | 部署证书到阿里云CLB(传统负载均衡) |
| 15.| **阿里云-部署至VOD** | 部署证书到阿里云视频点播(vod |
| 16.| **阿里云-部署至阿里云WAF(云产品接入)** | 部署证书到阿里云WAF(云产品接入),CNAME方式接入的请选择另外一个waf插件 |
| 17.| **阿里云-部署至阿里云WAF(cname接入)** | 部署证书到阿里云WAF(cname接入),云资源的请选择另外一个waf插件 |
| 18.| **阿里云-上传证书到CAS** | 上传证书到阿里云证书管理服务(CAS),如果不想在阿里云上同一份证书上传多次,可以把此任务作为前置任务,其他阿里云任务证书那一项选择此任务的输出 |
| 12.| **阿里云-部署至直播(Live** | 部署证书到阿里云视频直播(Live)域名 |
| 13.| **阿里云-部署至NLB(网络负载均衡)** | NLB,网络负载均衡,更新监听器的默认证书 |
| 14.| **阿里云-部署证书至OSS** | 部署域名证书至阿里云OSS自定义域名,不是上传到阿里云oss |
| 15.| **阿里云-部署至CLB(传统负载均衡)** | 部署证书到阿里云CLB(传统负载均衡) |
| 16.| **阿里云-部署至VOD** | 部署证书到阿里云视频点播(vod |
| 17.| **阿里云-部署至阿里云WAF(云产品接入)** | 部署证书到阿里云WAF(云产品接入)CNAME方式接入的请选择另外一个waf插件 |
| 18.| **阿里云-部署至阿里云WAF(cname接入)** | 部署证书到阿里云WAF(cname接入),云资源的请选择另外一个waf插件 |
| 19.| **阿里云-上传证书到CAS** | 上传证书到阿里云证书管理服务(CAS),如果不想在阿里云上同一份证书上传多次,可以把此任务作为前置任务,其他阿里云任务证书那一项选择此任务的输出 |
## 6. 华为云
| 序号 | 名称 | 说明 |
+1 -1
View File
@@ -9,5 +9,5 @@
}
},
"npmClient": "pnpm",
"version": "1.40.3"
"version": "1.40.4"
}
+2 -1
View File
@@ -20,6 +20,7 @@
"devb": "lerna run dev-build",
"i-all": "lerna link && lerna exec npm install ",
"publish": "pnpm run prepublishOnly2 && lerna publish --force-publish=pro/plus-core --conventional-commits && pnpm run afterpublishOnly ",
"publish2":" npm run pub_all && pnpm run afterpublishOnly",
"afterpublishOnly": "pnpm run copylogs && time /t >trigger/build.trigger && git add ./trigger/build.trigger && git commit -m \"build: trigger build image\" && TIMEOUT /T 10 && pnpm run commitAll",
"transform-sql": "cd ./packages/ui/certd-server/db/ && node --experimental-json-modules transform.js",
"plugin-doc-gen": "cd ./packages/ui/certd-server/ && pnpm run export-metadata",
@@ -39,7 +40,7 @@
"test:unit": "cross-env NODE_ENV=unittest pnpm -r --workspace-concurrency=1 run test:unit",
"pub": "echo 1",
"dev": "pnpm run -r --parallel compile ",
"pub_all":"pnpm run -r --parallel pub ",
"pub_all": "node ./scripts/pub-all.js",
"release": "time /t >trigger/release.trigger && git add trigger/release.trigger && git commit -m \"build: release\" && git push",
"publish_to_atomgit": "node --experimental-json-modules ./scripts/publish-atomgit.js",
"publish_to_gitee": "node --experimental-json-modules ./scripts/publish-gitee.js",
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/publishlab/node-acme-client/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/acme-client
## [1.40.3](https://github.com/publishlab/node-acme-client/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/acme-client
+3 -3
View File
@@ -3,7 +3,7 @@
"description": "Simple and unopinionated ACME client",
"private": false,
"author": "nmorsman",
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"module": "./dist/index.js",
"main": "./dist/index.js",
@@ -18,7 +18,7 @@
"types"
],
"dependencies": {
"@certd/basic": "^1.40.3",
"@certd/basic": "workspace:^1.40.4",
"@peculiar/x509": "^1.11.0",
"asn1js": "^3.0.5",
"axios": "^1.9.0",
@@ -76,5 +76,5 @@
"bugs": {
"url": "https://github.com/publishlab/node-acme-client/issues"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+4
View File
@@ -467,6 +467,10 @@ class AcmeClient {
return createHash('sha256').update(result).digest('base64url');
}
if (challenge.type === 'dns-persist-01') {
return '';
}
/* https://datatracker.ietf.org/doc/html/rfc8737 */
if (challenge.type === 'tls-alpn-01') {
return result;
+5 -1
View File
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
token: string;
}
export type Challenge = HttpChallenge | DnsChallenge;
export interface DnsPersistChallenge extends ChallengeAbstract {
type: "dns-persist-01";
}
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
/**
* Certificate
+20 -1
View File
@@ -170,7 +170,7 @@ export function createChallengeFn(opts = {}) {
if (txtRecords.length === 0) {
throw new Error(`没有找到TXT解析记录(${recordName}`);
throw new Error(`没有找到TXT解析记录(${recordName},请稍后重试`);
}
return txtRecords;
}
@@ -203,6 +203,24 @@ export function createChallengeFn(opts = {}) {
return true;
}
async function verifyDnsPersistChallenge(authz, challenge, keyAuthorization, prefix = '_validation-persist.') {
const recordName = `${prefix}${authz.identifier.value.replace(/^\*\./, '')}`;
log(`本地校验DNS持久验证TXT记录: ${recordName}`);
let recordValues = await walkTxtRecord(recordName, 0, walkFromAuthoritative);
recordValues = [...new Set(recordValues)];
const expected = challenge.expectedRecordValue;
if (!expected) {
log(`未提供dns-persist-01本地校验期望值,跳过精确匹配,仅确认TXT记录存在`);
return true;
}
log(`DNS查询成功, 找到 ${recordValues.length} 条TXT记录:${recordValues}`);
if (!recordValues.length || !recordValues.includes(expected)) {
throw new Error(`没有找到需要的DNS持久验证TXT记录: ${recordName},请稍后重试,期望:${expected},结果:${recordValues}`);
}
log(`DNS持久验证记录匹配成功(${challenge.type}/${recordName}:${expected}`);
return true;
}
/**
* Verify ACME TLS ALPN challenge
*
@@ -234,6 +252,7 @@ export function createChallengeFn(opts = {}) {
challenges: {
'http-01': verifyHttpChallenge,
'dns-01': verifyDnsChallenge,
'dns-persist-01': verifyDnsPersistChallenge,
'tls-alpn-01': verifyTlsAlpnChallenge,
},
walkTxtRecord,
+1 -1
View File
@@ -57,7 +57,7 @@ export interface ClientExternalAccountBindingOptions {
export interface ClientAutoOptions {
csr: CsrBuffer | CsrString;
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string}>;
challengeCreateFn: (authz: Authorization, keyAuthorization: (challenge:rfc8555.Challenge)=>Promise<string>) => Promise<{recordReq?:any,recordRes?:any,dnsProvider?:any,challenge: rfc8555.Challenge,keyAuthorization:string,httpUploader?:any}>;
challengeRemoveFn: (authz: Authorization, challenge: rfc8555.Challenge, keyAuthorization: string,recordReq:any, recordRes:any,dnsProvider:any,httpUploader:any) => Promise<any>;
email?: string;
termsOfServiceAgreed?: boolean;
+5 -1
View File
@@ -97,7 +97,11 @@ export interface DnsChallenge extends ChallengeAbstract {
token: string;
}
export type Challenge = HttpChallenge | DnsChallenge;
export interface DnsPersistChallenge extends ChallengeAbstract {
type: 'dns-persist-01';
}
export type Challenge = HttpChallenge | DnsChallenge | DnsPersistChallenge;
/**
* Certificate
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/basic
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/basic
+1 -1
View File
@@ -1 +1 @@
22:57
00:18
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/basic",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -52,5 +52,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/pipeline
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/pipeline
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/pipeline",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -19,8 +19,8 @@
"compile": "tsc --skipLibCheck --watch"
},
"dependencies": {
"@certd/basic": "^1.40.3",
"@certd/plus-core": "^1.40.3",
"@certd/basic": "workspace:^1.40.4",
"@certd/plus-core": "workspace:^1.40.4",
"dayjs": "^1.11.7",
"lodash-es": "^4.17.21",
"reflect-metadata": "^0.1.13"
@@ -49,5 +49,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+1
View File
@@ -19,6 +19,7 @@ export type AccessInputDefine = FormItemProps & {
};
export type AccessDefine = Registrable & {
icon?: string;
subtype?: string;
input?: {
[key: string]: AccessInputDefine;
};
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-huawei
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-huawei
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-huawei",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
"types": "./dist/d/index.d.ts",
@@ -27,5 +27,5 @@
"prettier": "^2.8.8",
"tslib": "^2.8.1"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-iframe
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-iframe
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-iframe",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -34,5 +34,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/jdcloud
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/jdcloud
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/jdcloud",
"version": "1.40.3",
"version": "1.40.4",
"description": "jdcloud openApi sdk",
"main": "./dist/bundle.js",
"module": "./dist/bundle.js",
@@ -59,5 +59,5 @@
"fetch"
]
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/lib-k8s
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-k8s
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/lib-k8s",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -19,7 +19,7 @@
"compile": "tsc --skipLibCheck --watch"
},
"dependencies": {
"@certd/basic": "^1.40.3",
"@certd/basic": "^1.40.4",
"@kubernetes/client-node": "0.21.0"
},
"devDependencies": {
@@ -36,5 +36,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/lib-server
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/lib-server",
"version": "1.40.3",
"version": "1.40.4",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -29,11 +29,11 @@
],
"license": "AGPL",
"dependencies": {
"@certd/acme-client": "^1.40.3",
"@certd/basic": "^1.40.3",
"@certd/pipeline": "^1.40.3",
"@certd/plugin-lib": "^1.40.3",
"@certd/plus-core": "^1.40.3",
"@certd/acme-client": "workspace:^1.40.4",
"@certd/basic": "workspace:^1.40.4",
"@certd/pipeline": "workspace:^1.40.4",
"@certd/plugin-lib": "workspace:^1.40.4",
"@certd/plus-core": "workspace:^1.40.4",
"@midwayjs/cache": "3.14.0",
"@midwayjs/core": "3.20.11",
"@midwayjs/i18n": "3.20.13",
@@ -69,5 +69,5 @@
"typeorm": "^0.3.11",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
@@ -8,6 +8,8 @@ export const Constants = {
guest: '_guest_',
//无需登录
anonymous: '_guest_',
//无需登录,有 token 时解析当前用户
guestOptionalAuth: '_guestOptionalAuth_',
//仅需要登录
authOnly: '_authOnly_',
//仅需要登录
@@ -0,0 +1,43 @@
/// <reference types="mocha" />
/// <reference types="node" />
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { FileService } from "./file-service.js";
function createUploadFile(key: string) {
const uploadRootDir = "./data/upload";
const filePath = path.join(uploadRootDir, key);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, "test");
return filePath;
}
describe("FileService.getFile", () => {
let cwd: string;
let oldCwd: string;
beforeEach(() => {
oldCwd = process.cwd();
cwd = fs.mkdtempSync(path.join(os.tmpdir(), "certd-file-service-"));
process.chdir(cwd);
});
afterEach(() => {
process.chdir(oldCwd);
fs.rmSync(cwd, { recursive: true, force: true });
});
it("allows admin to read another user's private file", () => {
const service = new FileService();
const userIdMd5 = Buffer.from(Buffer.from("2").toString("base64")).toString("hex");
const key = `/private/${userIdMd5}/2026_05_25/qr.png`;
const expectedPath = createUploadFile(key);
const filePath = service.getFile(key, 1, true);
assert.equal(filePath, expectedPath);
});
});
@@ -56,7 +56,7 @@ export class FileService {
return key;
}
getFile(key: string, userId?: number) {
getFile(key: string, userId?: number, allowAnyPrivateUser = false) {
if (!key) {
throw new ParamException('参数错误');
}
@@ -70,7 +70,7 @@ export class FileService {
const keyArr = key.split('/');
const permission = keyArr[1];
const userIdMd5 = keyArr[2];
if (permission !== 'public') {
if (permission !== 'public' && !allowAnyPrivateUser) {
//非公开文件需要验证用户
const userIdStr = Buffer.from(Buffer.from(userIdMd5, 'hex').toString('base64')).toString();
const userIdInt: number = parseInt(userIdStr, 10);
@@ -245,6 +245,8 @@ export class SysSuiteSetting extends BaseSettings {
enabled: boolean = false;
allowSuiteStack: boolean = false;
registerGift?: {
productId: number;
duration: number;
@@ -19,6 +19,9 @@ export class AccessEntity {
@Column({ comment: '类型', length: 100 })
type: string;
@Column({ name: 'subtype', comment: '子类型', length: 100, nullable: true })
subtype: string;
@Column({ name: 'setting', comment: '设置', length: 10240, nullable: true })
setting: string;
@@ -1,4 +1,4 @@
import { IAccessService } from '@certd/pipeline';
import { IAccessService } from "@certd/pipeline";
export class AccessGetter implements IAccessService {
userId: number;
@@ -15,6 +15,6 @@ export class AccessGetter implements IAccessService {
}
async getCommonById<T = any>(id: any) {
return await this.getter<T>(id, 0,null);
return await this.getter<T>(id, 0, null);
}
}
@@ -1,14 +1,16 @@
import assert from "assert";
import esmock from "esmock";
import { AccessService } from "./access-service.js";
describe("AccessService", () => {
it("does not write id into access setting when updating selected fields", async () => {
let updateParam: any;
const service = new AccessService();
service.info = async () => ({
id: 12,
type: "eab",
} as any);
service.info = async () =>
({
id: 12,
type: "eab",
}) as any;
service.decryptAccessEntity = () => ({
kid: "kid-1",
});
@@ -27,4 +29,82 @@ describe("AccessService", () => {
accountKey: "account-key",
});
});
it("writes subtype from access define field", async () => {
const { AccessService: MockedAccessService } = await esmock("./access-service.js", {
"@certd/pipeline": {
accessRegistry: {
getDefine(type: string) {
assert.equal(type, "acmeAccount");
return {
name: "acmeAccount",
subtype: "caType",
input: {
caType: {},
account: {
encrypt: true,
},
},
};
},
},
},
});
const service = new MockedAccessService();
service.encryptService = {
encrypt(value: string) {
return `encrypted:${value}`;
},
};
const param: any = {
type: "acmeAccount",
setting: JSON.stringify({
caType: "letsencrypt",
account: JSON.stringify({
accountKey: "key",
accountUri: "https://example.com/acct/1",
caType: "letsencrypt",
}),
}),
};
service.encryptSetting(param);
assert.equal(param.subtype, "letsencrypt");
});
it("allows acme account access to be saved before account generation", async () => {
const { AccessService: MockedAccessService } = await esmock("./access-service.js", {
"@certd/pipeline": {
accessRegistry: {
getDefine() {
return {
name: "acmeAccount",
subtype: "caType",
input: {
caType: {},
account: {
encrypt: true,
},
},
};
},
},
},
});
const service = new MockedAccessService();
const param: any = {
type: "acmeAccount",
setting: JSON.stringify({
caType: "letsencrypt",
}),
};
service.encryptSetting(param);
assert.equal(param.subtype, "letsencrypt");
assert.deepEqual(JSON.parse(param.setting), {
caType: "letsencrypt",
});
});
});
@@ -1,17 +1,17 @@
import {Inject, Provide, Scope, ScopeEnum} from '@midwayjs/core';
import {InjectEntityModel} from '@midwayjs/typeorm';
import { Inject, Provide, Scope, ScopeEnum } from "@midwayjs/core";
import { InjectEntityModel } from "@midwayjs/typeorm";
import { In, Repository } from "typeorm";
import {AccessGetter, BaseService, PageReq, PermissionException, ValidateException} from '../../../index.js';
import {AccessEntity} from '../entity/access.js';
import {AccessDefine, accessRegistry, newAccess} from '@certd/pipeline';
import {EncryptService} from './encrypt-service.js';
import { logger, utils } from '@certd/basic';
import { AccessGetter, BaseService, PageReq, PermissionException, ValidateException } from "../../../index.js";
import { AccessEntity } from "../entity/access.js";
import { AccessDefine, accessRegistry, newAccess } from "@certd/pipeline";
import { EncryptService } from "./encrypt-service.js";
import { logger, utils } from "@certd/basic";
/**
*
*/
@Provide()
@Scope(ScopeEnum.Request, {allowDowngrade: true})
@Scope(ScopeEnum.Request, { allowDowngrade: true })
export class AccessService extends BaseService<AccessEntity> {
@InjectEntityModel(AccessEntity)
repository: Repository<AccessEntity>;
@@ -36,16 +36,16 @@ export class AccessService extends BaseService<AccessEntity> {
async add(param) {
let oldEntity = null;
if (param._copyFrom){
if (param._copyFrom) {
oldEntity = await this.info(param._copyFrom);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
if (oldEntity.userId !== param.userId) {
throw new ValidateException('您无权查看该授权配置');
if (oldEntity.userId !== param.userId) {
throw new ValidateException("您无权查看该授权配置");
}
}
delete param._copyFrom
delete param._copyFrom;
this.encryptSetting(param, oldEntity);
param.keyId = "ac_" + utils.id.simpleNanoId();
return await super.add(param);
@@ -62,17 +62,20 @@ export class AccessService extends BaseService<AccessEntity> {
return;
}
const json = JSON.parse(setting);
if (accessDefine.subtype) {
param.subtype = json[accessDefine.subtype] || null;
}
let oldSetting = {};
let encryptSetting = {};
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === '{}';
const firstEncrypt = !oldSettingEntity || !oldSettingEntity.encryptSetting || oldSettingEntity.encryptSetting === "{}";
if (oldSettingEntity) {
oldSetting = JSON.parse(oldSettingEntity.setting || '{}');
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || '{}');
oldSetting = JSON.parse(oldSettingEntity.setting || "{}");
encryptSetting = JSON.parse(oldSettingEntity.encryptSetting || "{}");
}
for (const key in json) {
//加密
let value = json[key];
if (value && typeof value === 'string') {
if (value && typeof value === "string") {
//去除前后空格
value = value.trim();
json[key] = value;
@@ -81,7 +84,7 @@ export class AccessService extends BaseService<AccessEntity> {
if (!accessInputDefine) {
continue;
}
if (!accessInputDefine.encrypt || !value || typeof value !== 'string') {
if (!accessInputDefine.encrypt || !value || typeof value !== "string") {
//定义无需加密、value为空、不是字符串 这些不需要加密
encryptSetting[key] = {
value: value,
@@ -96,7 +99,7 @@ export class AccessService extends BaseService<AccessEntity> {
const subIndex = Math.min(2, length);
let starLength = length - subIndex * 2;
starLength = Math.max(2, starLength);
const starString = '*'.repeat(starLength);
const starString = "*".repeat(starLength);
json[key] = value.substring(0, subIndex) + starString + value.substring(value.length - subIndex);
encryptSetting[key] = {
value: this.encryptService.encrypt(value),
@@ -116,21 +119,21 @@ export class AccessService extends BaseService<AccessEntity> {
async update(param) {
const oldEntity = await this.info(param.id);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
this.encryptSetting(param, oldEntity);
delete param.keyId
delete param.keyId;
return await super.update(param);
}
async updateAccess(access: any) {
const oldEntity = await this.info(access.id);
if (oldEntity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
const setting = this.decryptAccessEntity(oldEntity);
for (const key of Object.keys(access)) {
if (key === 'id') {
if (key === "id") {
continue;
}
setting[key] = access[key];
@@ -145,11 +148,13 @@ export class AccessService extends BaseService<AccessEntity> {
async getSimpleInfo(id: number) {
const entity = await this.info(id);
if (entity == null) {
throw new ValidateException('该授权配置不存在,请确认是否已被删除');
throw new ValidateException("该授权配置不存在,请确认是否已被删除");
}
return {
id: entity.id,
name: entity.name,
type: entity.type,
subtype: entity.subtype,
userId: entity.userId,
projectId: entity.projectId,
};
@@ -162,14 +167,14 @@ export class AccessService extends BaseService<AccessEntity> {
}
if (checkUserId) {
if (userId == null) {
throw new ValidateException('userId不能为空');
throw new ValidateException("userId不能为空");
}
if (userId !== entity.userId) {
throw new PermissionException('您对该Access授权无访问权限');
throw new PermissionException("您对该Access授权无访问权限");
}
}
if (projectId != null && projectId !== entity.projectId) {
throw new PermissionException('您对该Access授权无访问权限');
throw new PermissionException("您对该Access授权无访问权限");
}
// const access = accessRegistry.get(entity.type);
@@ -178,8 +183,8 @@ export class AccessService extends BaseService<AccessEntity> {
id: entity.id,
...setting,
};
const accessGetter = new AccessGetter(userId,projectId, this.getById.bind(this));
return await newAccess(entity.type, input,accessGetter);
const accessGetter = new AccessGetter(userId, projectId, this.getById.bind(this));
return await newAccess(entity.type, input, accessGetter);
}
async getById(id: any, userId: number, projectId?: number): Promise<any> {
@@ -188,7 +193,7 @@ export class AccessService extends BaseService<AccessEntity> {
decryptAccessEntity(entity: AccessEntity): any {
let setting = {};
if (entity.encryptSetting && entity.encryptSetting !== '{}') {
if (entity.encryptSetting && entity.encryptSetting !== "{}") {
setting = JSON.parse(entity.encryptSetting);
for (const key in setting) {
//解密
@@ -213,12 +218,11 @@ export class AccessService extends BaseService<AccessEntity> {
return accessRegistry.getDefine(type);
}
async getSimpleByIds(ids: number[], userId: any, projectId?: number) {
if (ids.length === 0) {
return [];
}
if (userId==null) {
if (userId == null) {
return [];
}
return await this.repository.find({
@@ -231,24 +235,24 @@ export class AccessService extends BaseService<AccessEntity> {
id: true,
name: true,
type: true,
userId:true,
projectId:true,
subtype: true,
userId: true,
projectId: true,
},
});
}
/**
*
* @param accessId
* @param projectId
* @param accessId
* @param projectId
*/
async copyTo(accessId: number,projectId?: number) {
async copyTo(accessId: number, projectId?: number) {
const access = await this.info(accessId);
if (access == null) {
throw new Error(`该授权配置不存在,请确认是否已被删除:id=${accessId}`);
}
const keyId = access.keyId;
//检查目标项目里是否已经有相同keyId的配置
const existAccess = await this.repository.findOne({
@@ -263,10 +267,10 @@ export class AccessService extends BaseService<AccessEntity> {
}
const newAccess = {
...access,
userId:-1,
userId: -1,
id: undefined,
projectId,
}
};
await this.repository.save(newAccess);
return newAccess.id;
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/midway-flyway-js
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/midway-flyway-js
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/midway-flyway-js",
"version": "1.40.3",
"version": "1.40.4",
"description": "midway with flyway, sql upgrade way ",
"private": false,
"type": "module",
@@ -49,5 +49,5 @@
"typeorm": "^0.3.11",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
**Note:** Version bump only for package @certd/plugin-cert
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/plugin-cert
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-cert",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -18,10 +18,10 @@
"compile": "tsc --skipLibCheck --watch"
},
"dependencies": {
"@certd/acme-client": "^1.40.3",
"@certd/basic": "^1.40.3",
"@certd/pipeline": "^1.40.3",
"@certd/plugin-lib": "^1.40.3",
"@certd/acme-client": "workspace:^1.40.4",
"@certd/basic": "workspace:^1.40.4",
"@certd/pipeline": "workspace:^1.40.4",
"@certd/plugin-lib": "workspace:^1.40.4",
"psl": "^1.9.0",
"punycode.js": "^2.3.1"
},
@@ -41,5 +41,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 新增阿里云直播证书部署插件 ([8edb6f8](https://github.com/certd/certd/commit/8edb6f8727bd148f106801bef25567880fd35e9e))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
**Note:** Version bump only for package @certd/plugin-lib
+6 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@certd/plugin-lib",
"private": false,
"version": "1.40.3",
"version": "1.40.4",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
@@ -23,10 +23,10 @@
"@alicloud/pop-core": "^1.7.10",
"@alicloud/tea-util": "^1.4.11",
"@aws-sdk/client-s3": "^3.964.0",
"@certd/acme-client": "^1.40.3",
"@certd/basic": "^1.40.3",
"@certd/pipeline": "^1.40.3",
"@certd/plus-core": "^1.40.3",
"@certd/acme-client": "workspace:^1.40.4",
"@certd/basic": "workspace:^1.40.4",
"@certd/pipeline": "workspace:^1.40.4",
"@certd/plus-core": "workspace:^1.40.4",
"@kubernetes/client-node": "0.21.0",
"ali-oss": "^6.22.0",
"basic-ftp": "^5.0.5",
@@ -61,5 +61,5 @@
"tslib": "^2.8.1",
"typescript": "^5.4.2"
},
"gitHead": "678b70cee8510a2b5217788c5db9469f49cbd439"
"gitHead": "4a09cf289d3f360c830f1a203fa11baa6af20f1c"
}
@@ -13,7 +13,7 @@ export interface ICertInfoGetter {
export type CertInfo = {
crt: string; //fullchain证书
key: string; //私钥
csr: string; //csr
csr?: string; //csr
oc?: string; //仅证书,非fullchain证书
ic?: string; //中间证书
pfx?: string;
@@ -35,7 +35,7 @@ export function createRemoteSelectInputDefine(opts?: {
helper?: string;
formItem?: any;
mode?: string;
multi?: boolean;
single?: boolean;
required?: boolean;
rules?: any;
mergeScript?: string;
@@ -59,11 +59,8 @@ export function createRemoteSelectInputDefine(opts?: {
const helper = opts?.helper || "请选择";
const search = opts?.search ?? false;
const pager = opts?.pager ?? false;
let mode = "default";
const multi = opts?.multi ?? true;
if (multi) {
mode = "tags";
}
const mode = "tags";
const single = opts?.single ?? false;
const item = {
title,
component: {
@@ -75,7 +72,7 @@ export function createRemoteSelectInputDefine(opts?: {
action,
search,
pager,
multi,
single,
pageSize: opts?.pageSize,
watches: [certDomainsInputKey, accessIdInputKey, ...watches],
uploadCert: opts?.uploadCert,
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.40.4](https://github.com/certd/certd/compare/v1.40.3...v1.40.4) (2026-05-24)
### Performance Improvements
* 商业版套餐只支持设置为可叠加 ([5e72f75](https://github.com/certd/certd/commit/5e72f75395fb632a30e80c07d35d8ba40ef631fa))
## [1.40.3](https://github.com/certd/certd/compare/v1.40.2...v1.40.3) (2026-05-21)
### Bug Fixes
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@certd/ui-client",
"version": "1.40.3",
"version": "1.40.4",
"private": true,
"scripts": {
"dev": "vite --open",
@@ -106,8 +106,8 @@
"zod-defaults": "^0.1.3"
},
"devDependencies": {
"@certd/lib-iframe": "^1.40.3",
"@certd/pipeline": "^1.40.3",
"@certd/lib-iframe": "^1.40.4",
"@certd/pipeline": "^1.40.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
"@types/chai": "^4.3.12",
@@ -11,14 +11,16 @@
<td class="record-value" :title="cnameRecord.recordValue">
<fs-copyable v-model="cnameRecord.recordValue"></fs-copyable>
</td>
<td class="status center flex-center">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
<td class="status center">
<span class="status-content">
<fs-values-format v-model="cnameRecord.status" :dict="statusDict" />
<a-tooltip v-if="cnameRecord.error" :title="cnameRecord.error">
<fs-icon class="ml-5 color-red" icon="ion:warning-outline"></fs-icon>
</a-tooltip>
<a-tooltip v-if="cnameRecord.status === 'valid'" :title="t('certd.verifyPlan.resetStatusTooltip')">
<fs-icon class="ml-2 color-yellow text-md pointer" icon="solar:undo-left-square-bold" @click="resetStatus"></fs-icon>
</a-tooltip>
</span>
</td>
<td class="center">
<template v-if="cnameRecord.status !== 'valid'">
@@ -142,5 +144,10 @@ async function resetStatus() {
.fs-copyable {
width: 100%;
}
.status-content {
display: inline-flex;
align-items: center;
justify-content: center;
}
}
</style>
@@ -2,11 +2,11 @@
<table class="cname-verify-plan">
<thead>
<tr>
<td style="width: 160px">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td style="width: 100px; text-align: center">{{ t("certd.verifyPlan.recordType") }}</td>
<td style="width: 250px">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td style="width: 120px" class="center">{{ t("certd.status") }}</td>
<td style="width: 90px" class="center">{{ t("certd.verifyPlan.operation") }}</td>
<td class="col-host">{{ t("certd.verifyPlan.hostRecord") }}</td>
<td class="col-type center">{{ t("certd.verifyPlan.recordType") }}</td>
<td class="col-value">{{ t("certd.verifyPlan.setCnameRecord") }}</td>
<td class="col-status center">{{ t("certd.status") }}</td>
<td class="col-action center">{{ t("certd.verifyPlan.operation") }}</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
@@ -49,6 +49,21 @@ function onRecordChange(domain: string, record: CnameRecord) {
.cname-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
@@ -0,0 +1,144 @@
<template>
<tbody class="dns-persist-record-info">
<tr v-if="dnsPersistRecord">
<td class="host-record" :title="dnsPersistRecord.hostRecord">
<fs-copyable v-model="dnsPersistRecord.hostRecord"></fs-copyable>
</td>
<td style="text-align: center">TXT</td>
<td class="record-value" :title="dnsPersistRecord.recordValue">
<fs-copyable v-model="dnsPersistRecord.recordValue"></fs-copyable>
</td>
<td class="status center">
<fs-values-format v-model="dnsPersistRecord.status" :dict="statusDict" />
</td>
<td class="center">
<template v-if="dnsPersistRecord.status !== 'valid'">
<a-space>
<a-button type="primary" size="small" @click="openSettingDialog">设置TXT</a-button>
<a-button type="primary" size="small" :loading="loading" @click="doVerify">校验</a-button>
</a-space>
</template>
<div v-else class="helper">请勿删除TXT记录</div>
</td>
</tr>
<tr v-else>
<td colspan="5" class="color-red">{{ errorMessage || "请先选择ACME账号" }}</td>
</tr>
</tbody>
</template>
<script lang="ts" setup>
import { dict } from "@fast-crud/fast-crud";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { GetByDomain, Verify } from "/@/views/certd/cert/dns-persist/api";
import { useDnsPersistSettingDialog } from "/@/views/certd/cert/dns-persist/use-setting-dialog";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistRecordInfo",
});
const props = defineProps<{
domain: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
wildcard?: boolean;
persistUntil?: number;
}>();
const emit = defineEmits<{
change: [DnsPersistRecord];
}>();
const statusDict = dict({
data: [
{ value: "pending", label: "待设置", color: "warning" },
{ value: "validating", label: "校验中", color: "blue" },
{ value: "valid", label: "有效", color: "green" },
{ value: "failed", label: "请重试", color: "red" },
],
});
const dnsPersistRecord = ref<DnsPersistRecord | null>(null);
const loading = ref(false);
const errorMessage = ref("");
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
function onRecordChange() {
if (dnsPersistRecord.value) {
emit("change", dnsPersistRecord.value);
} else {
emit("change", {
domain: props.domain,
status: null,
} as any);
}
}
async function loadRecord() {
errorMessage.value = "";
dnsPersistRecord.value = null;
if (!props.domain || (!props.acmeAccountAccessId && !props.commonAcmeAccountAccessId)) {
onRecordChange();
return;
}
try {
dnsPersistRecord.value = await GetByDomain({
domain: props.domain,
caType: props.caType,
acmeAccountAccessId: props.acmeAccountAccessId,
commonAcmeAccountAccessId: props.commonAcmeAccountAccessId,
wildcard: props.wildcard,
persistUntil: props.persistUntil,
createOnNotFound: true,
});
onRecordChange();
} catch (e: any) {
errorMessage.value = e.message;
}
}
watch(
() => [props.domain, props.caType, props.acmeAccountAccessId, props.commonAcmeAccountAccessId, props.wildcard, props.persistUntil],
async () => {
await loadRecord();
},
{
immediate: true,
}
);
async function doVerify() {
if (!dnsPersistRecord.value?.id) {
return;
}
loading.value = true;
try {
const ok = await Verify(dnsPersistRecord.value.id);
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
await loadRecord();
} finally {
loading.value = false;
}
}
function openSettingDialog() {
if (!dnsPersistRecord.value) {
return;
}
openDnsPersistSettingDialog({
record: dnsPersistRecord.value,
onDone: loadRecord,
});
}
</script>
<style lang="less">
.dns-persist-record-info {
.fs-copyable {
width: 100%;
}
}
</style>
@@ -0,0 +1,94 @@
<template>
<table class="dns-persist-verify-plan">
<thead>
<tr>
<td class="col-host">TXT主机名</td>
<td class="col-type center">记录类型</td>
<td class="col-value">请设置TXT记录验证成功以后不要删除</td>
<td class="col-status center">状态</td>
<td class="col-action center">操作</td>
</tr>
</thead>
<template v-for="key in domains" :key="key">
<dns-persist-record-info
:domain="key"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
:wildcard="modelValue[key]?.wildcard"
:persist-until="modelValue[key]?.persistUntil"
@change="onRecordChange(key, $event)"
/>
</template>
</table>
</template>
<script lang="ts" setup>
import { computed } from "vue";
import DnsPersistRecordInfo from "./dns-persist-record-info.vue";
import { DnsPersistRecord } from "./type";
defineOptions({
name: "DnsPersistVerifyPlan",
});
const emit = defineEmits(["update:modelValue", "change"]);
const props = defineProps<{
modelValue: Record<string, DnsPersistRecord>;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const domains = computed(() => {
return Object.keys(props.modelValue || {});
});
function onRecordChange(domain: string, record: DnsPersistRecord) {
const value = { ...props.modelValue };
value[domain] = {
...value[domain],
...record,
};
emit("update:modelValue", value);
emit("change", value);
}
</script>
<style lang="less">
.dns-persist-verify-plan {
width: 100%;
table-layout: fixed;
.col-host {
width: 220px;
}
.col-type {
width: 100px;
}
.col-value {
width: 360px;
}
.col-status {
width: 120px;
}
.col-action {
width: 150px;
}
tbody tr td {
border-top: 1px solid #e8e8e8 !important;
}
tr {
td {
border: 0 !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.center {
text-align: center;
}
}
}
}
</style>
@@ -46,13 +46,28 @@
<div class="form-item">
<span class="label">{{ t("certd.verifyPlan.dnsAccess") }}:</span>
<span class="input">
<access-selector v-model="item.dnsProviderAccessId" size="small" :type="item.dnsProviderAccessType || item.dnsProviderType" :placeholder="t('certd.verifyPlan.pleaseSelect')" @change="onPlanChanged"></access-selector>
<access-selector
v-model="item.dnsProviderAccessId"
size="small"
:type="item.dnsProviderAccessType || item.dnsProviderType"
:placeholder="t('certd.verifyPlan.pleaseSelect')"
@change="onPlanChanged"
></access-selector>
</span>
</div>
</div>
<div v-if="item.type === 'cname'" class="plan-cname">
<cname-verify-plan v-model="item.cnameVerifyPlan" @change="onPlanChanged" />
</div>
<div v-if="item.type === 'dns-persist'" class="plan-dns-persist">
<dns-persist-verify-plan
v-model="item.dnsPersistVerifyPlan"
:ca-type="caType"
:acme-account-access-id="acmeAccountAccessId"
:common-acme-account-access-id="commonAcmeAccountAccessId"
@change="onPlanChanged"
/>
</div>
<div v-if="item.type === 'http'" class="plan-http">
<http-verify-plan v-model="item.httpVerifyPlan" @change="onPlanChanged" />
<div class="helper">{{ t("certd.verifyPlan.httpHelper") }}</div>
@@ -76,6 +91,7 @@ import { useI18n } from "vue-i18n";
import { dict, FsDictSelect } from "@fast-crud/fast-crud";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import CnameVerifyPlan from "./cname-verify-plan.vue";
import DnsPersistVerifyPlan from "./dns-persist-verify-plan.vue";
import HttpVerifyPlan from "./http-verify-plan.vue";
import { Form } from "ant-design-vue";
import { DomainsVerifyPlanInput } from "./type";
@@ -92,6 +108,10 @@ const challengeTypeOptions = ref<any[]>([
label: t("certd.verifyPlan.dnsChallenge"),
value: "dns",
},
{
label: "DNS持久验证",
value: "dns-persist",
},
{
label: t("certd.verifyPlan.cnameChallenge"),
value: "cname",
@@ -106,6 +126,9 @@ const props = defineProps<{
modelValue?: DomainsVerifyPlanInput;
domains?: string[];
defaultType?: string;
caType?: string;
acmeAccountAccessId?: number;
commonAcmeAccountAccessId?: number;
}>();
const emit = defineEmits<{
@@ -189,11 +212,15 @@ async function onDomainsChanged(domains: string[]) {
const cnameOrigin = planItem.cnameVerifyPlan;
const httpOrigin = planItem.httpVerifyPlan;
const dnsPersistOrigin = planItem.dnsPersistVerifyPlan;
planItem.cnameVerifyPlan = {};
planItem.httpVerifyPlan = {};
planItem.dnsPersistVerifyPlan = {};
const cnamePlan = planItem.cnameVerifyPlan;
const httpPlan = planItem.httpVerifyPlan;
const dnsPersistPlan = planItem.dnsPersistVerifyPlan;
for (const subDomain of domainGroupItem.keySubDomains) {
const wildcard = true;
if (!cnameOrigin[subDomain]) {
//@ts-ignore
planItem.cnameVerifyPlan[subDomain] = {
@@ -225,6 +252,19 @@ async function onDomainsChanged(domains: string[]) {
domain: subDomain,
};
}
if (!dnsPersistOrigin?.[subDomain]) {
//@ts-ignore
dnsPersistPlan[subDomain] = {
domain: subDomain,
wildcard,
};
} else {
dnsPersistPlan[subDomain] = {
...dnsPersistOrigin[subDomain],
wildcard,
};
}
}
for (const subDomain of Object.keys(cnamePlan)) {
@@ -238,6 +278,12 @@ async function onDomainsChanged(domains: string[]) {
delete httpPlan[subDomain];
}
}
for (const subDomain of Object.keys(dnsPersistPlan)) {
if (!domainGroupItem.keySubDomains.includes(subDomain)) {
delete dnsPersistPlan[subDomain];
}
}
}
for (const domain of Object.keys(planRef.value)) {
const mainDomains = Object.keys(domainGroups);
@@ -268,6 +314,7 @@ watch(
overflow-x: auto;
.fullscreen-modal {
display: none;
background-color: rgba(0, 0, 0, 0.42);
}
&.fullscreen {
@@ -7,15 +7,32 @@ export type HttpRecord = {
httpUploadRootDir: string;
};
export type DnsPersistRecord = {
id?: number;
domain: string;
mainDomain?: string;
status?: string;
hostRecord?: string;
recordValue?: string;
caType?: string;
acmeAccountAccessId?: number;
accountUri?: string;
wildcard?: boolean;
persistUntil?: number;
dnsProviderType?: string;
dnsProviderAccess?: number;
};
export type DomainVerifyPlanInput = {
domain: string;
domains: string[];
type: "cname" | "dns" | "http";
type: "cname" | "dns" | "http" | "dns-persist";
dnsProviderType?: string;
dnsProviderAccessType?: string;
dnsProviderAccessId?: number;
cnameVerifyPlan?: Record<string, CnameRecord>;
httpVerifyPlan?: Record<string, HttpRecord>;
dnsPersistVerifyPlan?: Record<string, DnsPersistRecord>;
};
export type DomainsVerifyPlanInput = {
[key: string]: DomainVerifyPlanInput;
@@ -46,6 +46,14 @@ function checkDomainVerifyPlan(rule: any, value: DomainsVerifyPlanInput) {
if (!value[domain].dnsProviderType || !value[domain].dnsProviderAccessId) {
throw new Error($t("certd.verifyPlan.errors.dnsProviderRequired", { domain }));
}
} else if (type === "dns-persist") {
const subDomains = Object.keys(value[domain].dnsPersistVerifyPlan || {});
for (const subDomain of subDomains) {
const plan = value[domain].dnsPersistVerifyPlan[subDomain];
if (plan.status !== "valid") {
throw new Error(`DNS持久验证记录(${subDomain})还未校验成功`);
}
}
}
}
return true;
@@ -1,8 +1,8 @@
<template>
<div class="refresh-input">
<div class="refresh-input-line">
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" allow-clear @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
<a-input class="refresh-input-control" :value="value" :placeholder="placeholder" :allow-clear="!disabled" :disabled="disabled" @update:value="emit('update:value', $event)"></a-input>
<fs-button :loading="loading" :disabled="disabled" type="primary" :text="buttonText" :icon="icon" @click="doRefresh"></fs-button>
</div>
<div class="helper" :class="{ error: hasError }">
{{ message }}
@@ -25,6 +25,7 @@ type RefreshInputProps = ComponentPropsType & {
icon?: string;
placeholder?: string;
successMessage?: string;
disabled?: boolean;
};
const fromType: any = inject("getFromType");
@@ -49,6 +50,9 @@ const placeholder = computed(() => props.placeholder || "");
const successMessage = computed(() => props.successMessage || "刷新成功,请保存配置");
const doRefresh = async () => {
if (props.disabled) {
return;
}
if (loading.value) {
return;
}
@@ -69,7 +69,7 @@ const props = defineProps<
watches?: string[];
search?: boolean;
pager?: boolean;
multi?: boolean;
single?: boolean;
pageSize?: number;
uploadCert?: UploadCertProps;
} & ComponentPropsType
@@ -79,15 +79,15 @@ const emit = defineEmits<{
}>();
function updateValue(value: any) {
// if (props.multi !== false) {
// emit("update:value", value);
// } else {
// const last = value?.[value.length - 1];
// emit("update:value", last);
// selectRef.value.blur();
// }
if (props.single === true) {
const last = value?.[value.length - 1];
emit("update:value", last);
selectRef.value.blur();
} else {
emit("update:value", value);
}
emit("update:value", value);
// emit("update:value", value);
}
const attrs = useAttrs();
@@ -5,6 +5,7 @@ function createChallengeTypeDict() {
return dict({
data: [
{ value: "dns", label: $t("certd.verifyPlan.dnsChallenge"), color: "green" },
{ value: "dns-persist", label: "DNS持久验证", color: "cyan" },
{ value: "cname", label: $t("certd.verifyPlan.cnameProxyChallenge"), color: "blue" },
{ value: "http", label: $t("certd.verifyPlan.httpChallenge"), color: "yellow" },
],
@@ -39,7 +40,12 @@ export const Dicts = {
sslProviderDict: dict({
data: [
{ value: "letsencrypt", label: "Let's Encrypt" },
{ value: "letsencrypt_staging", label: "Let's Encrypt测试环境" },
{ value: "google", label: "Google" },
{ value: "zerossl", label: "ZeroSSL" },
{ value: "sslcom", label: "SSL.com" },
{ value: "litessl", label: "litessl" },
{ value: "custom", label: "自定义ACME" },
],
}),
get challengeTypeDict() {
@@ -11,6 +11,7 @@ export default {
siteMonitor: "Site Certificate Monitor",
settings: "Settings",
accessManager: "Access Management",
dnsPersistRecord: "DNS Persist Records",
subDomain: "Subdomain Delegation Settings",
pipelineGroup: "Pipeline Group Management",
openKey: "Open API Key",
@@ -22,6 +23,7 @@ export default {
mySuite: "My Suite",
suiteBuy: "Suite Purchase",
myTrade: "My Orders",
inviteCommission: "Incentive Plan",
paymentReturn: "Payment Return",
source: "Source Code",
github: "GitHub",
@@ -46,6 +48,10 @@ export default {
suiteSetting: "Suite Settings",
orderManager: "Order Management",
userSuites: "User Suites",
inviteCommissionSetting: "Incentive Plan Settings",
inviteLevel: "Promotion Levels",
inviteUserLevel: "User Promotion Levels",
inviteWithdraw: "Withdrawal Requests",
netTest: "Network Test",
enterpriseSetting: "Enterprise Settings",
@@ -11,6 +11,7 @@ export default {
siteMonitor: "站点证书监控",
settings: "设置",
accessManager: "授权管理",
dnsPersistRecord: "DNS持久验证记录",
subDomain: "子域名托管设置",
pipelineGroup: "流水线分组管理",
openKey: "开放接口密钥",
@@ -22,6 +23,8 @@ export default {
mySuite: "我的套餐",
suiteBuy: "套餐购买",
myTrade: "我的订单",
myWallet: "我的钱包",
inviteCommission: "激励计划",
paymentReturn: "支付返回",
source: "源码",
github: "github",
@@ -46,6 +49,10 @@ export default {
suiteSetting: "套餐设置",
orderManager: "订单管理",
userSuites: "用户套餐",
inviteCommissionSetting: "激励计划设置",
inviteLevel: "推广等级",
inviteUserLevel: "用户推广等级",
inviteWithdraw: "提现申请记录",
netTest: "网络测试",
enterpriseManager: "企业管理设置",
projectManager: "项目管理",
+2
View File
@@ -12,9 +12,11 @@ import plugin from "./plugin/";
import { setupVben } from "./vben";
import { util } from "/@/utils";
import { initPreferences } from "/@/vben/preferences";
import { inviteUtils } from "/@/utils/util.invite";
// import "./components/code-editor/import-works";
// @ts-ignore
async function bootstrap() {
inviteUtils.captureFromLocation();
const app = createApp(App);
// app.use(Antd);
app.use(Antd);
@@ -186,6 +186,17 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.dnsPersistRecord",
name: "DnsPersistRecord",
path: "/certd/cert/dns-persist",
component: "/certd/cert/dns-persist/index.vue",
meta: {
icon: "ion:shield-half-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.subDomain",
name: "SubDomain",
@@ -324,7 +335,7 @@ export const certdResources = [
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
return settingStore.isInviteCommissionEnabled;
},
icon: "ion:gift-outline",
auth: true,
@@ -359,6 +370,36 @@ export const certdResources = [
keepAlive: true,
},
},
{
title: "certd.inviteCommission",
name: "InviteCommission",
path: "/certd/invite",
component: "/certd/invite/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.myWallet",
name: "MyWallet",
path: "/certd/wallet",
component: "/certd/wallet/index.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:wallet-outline",
auth: true,
keepAlive: true,
},
},
{
title: "certd.paymentReturn",
name: "PaymentReturn",
@@ -286,6 +286,70 @@ export const sysResources = [
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteCommissionSetting",
name: "SysInviteCommissionSetting",
path: "/sys/suite/invite/setting",
component: "/sys/suite/invite/setting.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:gift-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteLevel",
name: "SysInviteLevel",
path: "/sys/suite/invite/level",
component: "/sys/suite/invite/level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:ribbon-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteUserLevel",
name: "SysInviteUserLevel",
path: "/sys/suite/invite/user-level",
component: "/sys/suite/invite/user-level.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:people-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
{
title: "certd.sysResources.inviteWithdraw",
name: "SysInviteWithdraw",
path: "/sys/suite/invite/withdraw",
component: "/sys/suite/invite/withdraw.vue",
meta: {
show: () => {
const settingStore = useSettingStore();
return settingStore.isComm;
},
icon: "ion:cash-outline",
permission: "sys:settings:edit",
auth: true,
keepAlive: true,
},
},
],
},
{
@@ -97,6 +97,9 @@ export type SysPublicSetting = {
export type SuiteSetting = {
enabled?: boolean;
};
export type InviteSetting = {
enabled?: boolean;
};
export type SysPrivateSetting = {
httpProxy?: string;
httpsProxy?: string;
@@ -142,6 +145,7 @@ export type AllSettings = {
siteEnv: SiteEnv;
headerMenus: HeaderMenus;
suiteSetting: SuiteSetting;
inviteSetting: InviteSetting;
app: AppInfo;
};
@@ -30,6 +30,9 @@ export interface SettingState {
headerMenus?: HeaderMenus;
inited?: boolean;
suiteSetting?: SuiteSetting;
inviteSetting?: {
enabled?: boolean;
};
app: {
version?: string;
time?: number;
@@ -102,6 +105,7 @@ export const useSettingStore = defineStore({
menus: [],
},
suiteSetting: { enabled: false },
inviteSetting: { enabled: false },
inited: false,
app: {
version: "",
@@ -196,6 +200,9 @@ export const useSettingStore = defineStore({
// @ts-ignore
return this.suiteSetting?.enabled === true;
},
isInviteCommissionEnabled(): boolean {
return this.isComm && this.inviteSetting?.enabled === true;
},
},
actions: {
checkPlus() {
@@ -215,6 +222,7 @@ export const useSettingStore = defineStore({
merge(this.plusInfo, allSettings.plusInfo || {});
merge(this.headerMenus, allSettings.headerMenus || {});
merge(this.suiteSetting, allSettings.suiteSetting || {});
merge(this.inviteSetting, allSettings.inviteSetting || {});
//@ts-ignore
this.initSiteInfo(allSettings.siteInfo || {});
this.initAppInfo(allSettings.app || {});
@@ -4,6 +4,7 @@ export interface RegisterReq {
username: string;
password: string;
confirmPassword: string;
inviteCode?: string;
}
/**
* @description: Login interface parameters
@@ -18,6 +19,7 @@ export interface SmsLoginReq {
phoneCode: string;
smsCode: string;
randomStr: string;
inviteCode?: string;
}
export interface ForgotPasswordReq {
@@ -0,0 +1,54 @@
const INVITE_STORAGE_KEY = "certd_invite_code";
const INVITE_TTL = 3 * 24 * 60 * 60 * 1000;
export type InviteCache = {
code: string;
expiresAt: number;
};
function normalizeInviteCode(code?: string | null) {
return code?.trim().toUpperCase();
}
export const inviteUtils = {
save(code?: string | null) {
const normalized = normalizeInviteCode(code);
if (!normalized) {
return;
}
const cache: InviteCache = {
code: normalized,
expiresAt: Date.now() + INVITE_TTL,
};
localStorage.setItem(INVITE_STORAGE_KEY, JSON.stringify(cache));
},
get() {
const text = localStorage.getItem(INVITE_STORAGE_KEY);
if (!text) {
return "";
}
try {
const cache = JSON.parse(text) as InviteCache;
if (!cache.code || !cache.expiresAt || cache.expiresAt < Date.now()) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
return cache.code;
} catch (e) {
localStorage.removeItem(INVITE_STORAGE_KEY);
return "";
}
},
captureFromLocation() {
const hashQuery = window.location.hash?.split("?")[1] || "";
const search = window.location.search?.replace(/^\?/, "") || "";
const hashParams = new URLSearchParams(hashQuery);
const searchParams = new URLSearchParams(search);
const code = hashParams.get("inviteCode") || searchParams.get("inviteCode");
if (code) {
this.save(code);
}
},
};
@@ -12,6 +12,12 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const { props, ctx, api } = context;
const lastResRef = ref();
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
query.query = query.query || {};
if (props.subtype) {
query.query.subtype = props.subtype;
} else {
delete query.query.subtype;
}
return await context.api.GetList(query);
};
const editRequest = async (req: EditReq) => {
@@ -47,7 +53,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const { myProjectDict } = useDicts();
const typeRef = ref("aliyun");
context.typeRef = typeRef;
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api);
const commonColumnsDefine = getCommonColumnDefine(crudExpose, typeRef, api, props.subtype);
commonColumnsDefine.type.form.component.disabled = true;
const projectStore = useProjectStore();
return {
@@ -21,6 +21,10 @@ export default defineComponent({
type: String, //user | sys
default: "user",
},
subtype: {
type: String,
default: "",
},
modelValue: {},
},
emits: ["update:modelValue"],
@@ -30,10 +34,17 @@ export default defineComponent({
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
// crud
function refreshSearch() {
const form: any = { type: props.type };
if (props.subtype) {
form.subtype = props.subtype;
}
crudExpose.setSearchFormData({ form, mergeForm: true });
crudExpose.doRefresh();
}
function onTypeChanged(value: any) {
context.typeRef.value = value;
crudExpose.setSearchFormData({ form: { type: value }, mergeForm: true });
crudExpose.doRefresh();
refreshSearch();
}
watch(
() => {
@@ -44,6 +55,14 @@ export default defineComponent({
onTypeChanged(value);
}
);
watch(
() => {
return props.subtype;
},
() => {
refreshSearch();
}
);
//
onMounted(() => {
onTypeChanged(props.type);
@@ -9,7 +9,7 @@
<a-form-item-rest v-if="chooseForm.show">
<a-modal v-model:open="chooseForm.show" title="选择授权提供者" width="900px" @ok="chooseForm.ok">
<div style="height: 400px; position: relative">
<cert-access-modal v-model="selectedId" :type="type" :from="from"></cert-access-modal>
<cert-access-modal v-model="selectedId" :type="type" :subtype="subtype" :from="from"></cert-access-modal>
</div>
</a-modal>
</a-form-item-rest>
@@ -35,6 +35,10 @@ export default defineComponent({
type: String,
default: "aliyun",
},
subtype: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "请选择",
@@ -1,11 +1,11 @@
import { ColumnCompositionProps, dict } from "@fast-crud/fast-crud";
import { computed, provide, ref, toRef } from "vue";
import { provide, ref, toRef } from "vue";
import { useReference } from "/@/use/use-refrence";
import { forEach, get, merge, set } from "lodash-es";
import SecretPlainGetter from "/@/views/certd/access/access-selector/access/secret-plain-getter.vue";
import { utils } from "/@/utils";
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any, fixedSubtype?: string) {
provide("getFromType", api.from);
provide("accessApi", api);
provide("get:plugin:type", () => {
@@ -34,6 +34,13 @@ export function getCommonColumnDefine(crudExpose: any, typeRef: any, api: any) {
}
}
console.log('crudBinding.value[mode + "Form"].columns', columnsRef.value);
if (mode === "add" && define.subtype && fixedSubtype) {
form.access = form.access || {};
const subtypeKey = `access.${define.subtype}`;
if (get(form, subtypeKey) == null) {
set(form, subtypeKey, fixedSubtype);
}
}
forEach(define.input, (value: any, mapKey: any) => {
const key = "access." + mapKey;
const field = {
@@ -0,0 +1,83 @@
import { request } from "/src/api/service";
const apiPrefix = "/cert/dns-persist";
export async function GetList(query: any) {
return await request({
url: apiPrefix + "/page",
method: "post",
data: query,
});
}
export async function AddObj(obj: any) {
return await request({
url: apiPrefix + "/add",
method: "post",
data: obj,
});
}
export async function UpdateObj(obj: any) {
return await request({
url: apiPrefix + "/update",
method: "post",
data: obj,
});
}
export async function DelObj(id: any) {
return await request({
url: apiPrefix + "/delete",
method: "post",
params: { id },
});
}
export async function BuildRecord(body: { domain: string; accountUri: string; wildcard?: boolean; persistUntil?: number }) {
return await request({
url: apiPrefix + "/build",
method: "post",
data: body,
});
}
export async function GetByDomain(body: { domain: string; caType?: string; acmeAccountAccessId?: number; commonAcmeAccountAccessId?: number; wildcard?: boolean; persistUntil?: number; createOnNotFound?: boolean }) {
return await request({
url: apiPrefix + "/getByDomain",
method: "post",
data: body,
});
}
export async function CheckRecord(body: { hostRecord: string; recordValue: string }) {
return await request({
url: apiPrefix + "/check",
method: "post",
data: body,
});
}
export async function Verify(id: number) {
return await request({
url: apiPrefix + "/verify",
method: "post",
data: { id },
});
}
export async function TriggerVerify(id: number) {
return await request({
url: apiPrefix + "/triggerVerify",
method: "post",
data: { id },
});
}
export async function CreateTxt(body: { id: number; dnsProviderType?: string; dnsProviderAccess?: number }) {
return await request({
url: apiPrefix + "/createTxt",
method: "post",
data: body,
});
}
@@ -0,0 +1,349 @@
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { message, Modal, notification } from "ant-design-vue";
import * as api from "./api";
import { Dicts } from "/@/components/plugins/lib/dicts";
import { createAccessApi } from "/@/views/certd/access/api";
import { useDnsPersistSettingDialog } from "./use-setting-dialog";
function parseAccount(account: any) {
if (!account) {
return null;
}
if (typeof account === "string") {
return JSON.parse(account);
}
return account;
}
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const accessApi = createAccessApi();
const { openDnsPersistSettingDialog } = useDnsPersistSettingDialog();
const accessDict = dict({
value: "id",
label: "name",
url: "accessDict",
async getNodesByValues(ids: number[]) {
return await accessApi.GetDictByIds(ids);
},
});
const dnsProviderTypeDict = dict({
url: "pi/dnsProvider/dnsProviderTypeDict",
});
const statusDict = dict({
data: [
{ value: "pending", label: "待设置", color: "warning" },
{ value: "created", label: "已创建", color: "blue" },
{ value: "validating", label: "校验中", color: "blue" },
{ value: "valid", label: "有效", color: "green" },
{ value: "failed", label: "请重试", color: "red" },
],
});
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
const editRequest = async ({ form, row }: EditReq) => {
form.id = row.id;
return await api.UpdateObj(form);
};
const delRequest = async ({ row }: DelReq) => {
const res = await api.DelObj(row.id);
if (res?.message) {
notification.warning({
message: "请到供应商删除TXT记录",
description: res.message,
duration: 0,
});
}
return res;
};
const addRequest = async ({ form }: AddReq) => {
return await api.AddObj(form);
};
async function fillRecord(form: any) {
if (!form.domain || !form.acmeAccountAccessId) {
return;
}
const access: any = await accessApi.GetObj(form.acmeAccountAccessId);
const setting = JSON.parse(access.setting || "{}");
const account = parseAccount(setting.account);
if (!account?.accountUri) {
message.error("ACME账号授权缺少accountUri,请重新生成账号");
return;
}
const record = await api.BuildRecord({
domain: form.domain,
accountUri: account.accountUri,
wildcard: true,
persistUntil: form.persistUntil,
});
form.caType = account.caType;
form.accountUri = account.accountUri;
form.hostRecord = record.hostRecord;
form.recordValue = record.recordValue;
form.status = "pending";
}
async function verifyRecord(row: any) {
const ok = await api.Verify(row.id);
message[ok ? "success" : "error"](ok ? "校验成功" : "未找到匹配的TXT记录,请稍后重试");
await crudExpose.doRefresh();
return ok;
}
function showRecordHelp(row: any) {
openDnsPersistSettingDialog({
record: row,
async onDone() {
await crudExpose.doRefresh();
},
});
}
return {
crudOptions: {
request: {
pageRequest,
addRequest,
editRequest,
delRequest,
},
actionbar: {
buttons: {
add: {
icon: "ion:add-circle-outline",
},
},
},
rowHandle: {
minWidth: 120,
fixed: "right",
},
columns: {
id: {
title: "ID",
key: "id",
type: "number",
column: { width: 80, order: -999 },
form: { show: false },
},
domain: {
title: "域名",
type: "text",
search: { show: true },
form: {
required: true,
valueChange({ form }) {
fillRecord(form);
},
},
},
mainDomain: {
title: "主域名",
type: "text",
form: {
show: false,
},
column: {
width: 160,
order: 901,
},
},
wildcard: {
title: "通配符",
type: "dict-switch",
form: {
show: false,
value: true,
},
column: { show: false },
},
acmeAccountAccessId: {
title: "ACME账号授权",
type: "dict-select",
dict: accessDict,
form: {
required: true,
order: -9,
component: {
name: "AccessSelector",
vModel: "modelValue",
type: "acmeAccount",
subtype: compute(({ form }) => {
return form.caType;
}),
},
valueChange({ form }) {
fillRecord(form);
},
},
column: {
width: 180,
},
},
caType: {
title: "颁发机构",
type: "dict-select",
dict: Dicts.sslProviderDict,
form: {
required: true,
value: "letsencrypt",
order: -10,
valueChange({ form }) {
form.acmeAccountAccessId = null;
fillRecord(form);
},
},
column: { width: 120 },
},
persistUntil: {
title: "有效期至",
type: "datetime",
form: {
helper: "可选;为空表示长期有效",
order: 20,
valueChange({ form }) {
fillRecord(form);
},
},
column: { width: 180, order: 900 },
},
hostRecord: {
title: "TXT主机名",
type: "copyable",
form: {
show: false,
},
column: {
width: 220,
cellRender({ value }) {
return (
<a-tooltip title={value}>
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
},
},
},
recordValue: {
title: "请设置TXT记录",
type: "copyable",
form: {
show: false,
},
column: {
width: 380,
cellRender({ value }) {
return (
<a-tooltip title={value}>
<fs-copyable modelValue={value}></fs-copyable>
</a-tooltip>
);
},
},
},
dnsProviderType: {
title: "DNS服务商",
type: "dict-select",
dict: dnsProviderTypeDict,
form: {
show: false,
component: {
name: "DnsProviderSelector",
},
},
column: { show: false },
},
dnsProviderAccess: {
title: "DNS授权",
type: "dict-select",
dict: accessDict,
form: {
show: false,
component: {
name: "AccessSelector",
vModel: "modelValue",
type: compute(({ form }) => {
const type = form.dnsProviderType || "aliyun";
return dnsProviderTypeDict?.dataMap[type]?.accessType || type;
}),
},
},
column: { show: false },
},
status: {
title: "状态",
type: "dict-select",
dict: statusDict,
form: {
show: false,
value: "pending",
},
column: {
width: 120,
cellRender({ value, row }) {
async function resetStatus() {
Modal.confirm({
title: "重新校验",
content: "确认将该记录状态重置为待设置,并重新校验吗?",
onOk: async () => {
await api.UpdateObj({ id: row.id, status: "pending" });
await verifyRecord(row);
},
});
}
return (
<div class={"flex flex-left"}>
<fs-values-format modelValue={value} dict={statusDict}></fs-values-format>
{row.status === "valid" && (
<a-tooltip title="撤销并重新校验">
<fs-icon class={"ml-5 pointer color-yellow"} icon="solar:undo-left-square-bold" onClick={resetStatus}></fs-icon>
</a-tooltip>
)}
</div>
);
},
},
},
triggerValidate: {
title: "校验",
type: "text",
form: {
show: false,
},
column: {
conditionalRenderDisabled: true,
width: 210,
align: "center",
cellRender({ row }) {
return (
<a-space>
{row.status === "valid" ? (
<span class="text-gray-500">TXT记录</span>
) : (
<>
<a-button type="primary" size="small" onClick={() => showRecordHelp(row)}>
TXT
</a-button>
<a-button type="primary" size="small" onClick={() => verifyRecord(row)}>
</a-button>
</>
)}
</a-space>
);
},
},
},
accountUri: {
title: "Account URI",
type: "text",
form: { show: false },
column: { show: false },
},
},
},
};
}
@@ -0,0 +1,33 @@
<template>
<fs-page class="page-cert-dns-persist">
<template #header>
<div class="title">
DNS持久验证记录
<span class="red sub" style="color: red">当前仅 Let's Encrypt 测试环境可以申请 DNS 持久验证证书</span>
</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud>
</fs-page>
</template>
<script lang="ts" setup>
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
defineOptions({
name: "DnsPersistRecord",
});
const context: any = {
permission: { isProjectPermission: true },
};
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions, context });
onMounted(() => {
crudExpose.doRefresh();
});
onActivated(async () => {
await crudExpose.doRefresh();
});
</script>
@@ -0,0 +1,133 @@
import { message } from "ant-design-vue";
import { reactive } from "vue";
import AccessSelector from "/@/views/certd/access/access-selector/index.vue";
import DnsProviderSelector from "/@/components/plugins/cert/dns-provider-selector/index.vue";
import { useFormDialog } from "/@/use/use-dialog";
import { CreateTxt, TriggerVerify } from "./api";
export type DnsPersistSettingRecord = {
id?: number;
mainDomain?: string;
hostRecord?: string;
recordValue?: string;
dnsProviderType?: string;
dnsProviderAccess?: number;
};
export function useDnsPersistSettingDialog() {
const { openFormDialog } = useFormDialog();
function copyableRow(label: string, value?: string) {
return (
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>{label}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<fs-copyable class="w-full" model-value={value || ""}></fs-copyable>
</div>
</div>
);
}
async function openDnsPersistSettingDialog(req: { record: DnsPersistSettingRecord; onDone?: () => Promise<void> | void }) {
const record = req.record;
const form = reactive({
mode: "manual",
dnsProviderType: record.dnsProviderType || "",
dnsProviderAccessType: "",
dnsProviderAccess: record.dnsProviderAccess || null,
});
async function submit() {
if (!record.id) {
return;
}
if (form.mode === "manual") {
await TriggerVerify(record.id);
message.success("已提交校验");
await req.onDone?.();
return;
}
if (!form.dnsProviderType || !form.dnsProviderAccess) {
throw new Error("请选择DNS服务商和授权");
}
await CreateTxt({
id: record.id,
dnsProviderType: form.dnsProviderType,
dnsProviderAccess: form.dnsProviderAccess,
});
message.success("TXT记录已创建");
await req.onDone?.();
}
await openFormDialog({
title: "设置DNS TXT记录",
wrapper: {
width: 680,
buttons: {
reset: {
show: false,
},
ok: {
show: true,
text: "确定",
},
},
},
body: () => (
<div>
<a-radio-group value={form.mode} buttonStyle="solid" class="mb-10" onUpdate:value={(value: string) => (form.mode = value)}>
<a-radio-button value="manual"></a-radio-button>
<a-radio-button value="auto"></a-radio-button>
</a-radio-group>
{form.mode === "manual" ? (
<div>
<a-alert class="mb-10" type="info" show-icon message="请到DNS解析控制台添加以下TXT记录,添加后点击确定会立即校验。" />
{copyableRow("主域名", record.mainDomain)}
{copyableRow("TXT主机名", record.hostRecord)}
{copyableRow("TXT值", record.recordValue)}
</div>
) : (
<div>
<a-alert class="mb-10" type="info" show-icon message="请选择DNS服务商和授权,系统会创建TXT记录,后续校验由后台完成。" />
{copyableRow("主域名", record.mainDomain)}
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>DNS服务商</div>
<div style={{ flex: 1, minWidth: 0 }}>
<DnsProviderSelector
class="w-full"
style={{ width: "100%" }}
modelValue={form.dnsProviderType}
onUpdate:modelValue={(value: string) => {
form.dnsProviderType = value;
form.dnsProviderAccess = null;
}}
onSelectedChange={(option: any) => {
form.dnsProviderAccessType = option?.accessType || form.dnsProviderType;
}}
/>
</div>
</div>
<div class="mb-10 flex items-center">
<div style={{ width: "90px", flexShrink: 0 }}>DNS授权</div>
<div style={{ flex: 1, minWidth: 0 }}>
<AccessSelector
modelValue={form.dnsProviderAccess}
type={form.dnsProviderAccessType || form.dnsProviderType || "aliyun"}
onUpdate:modelValue={(value: number) => {
form.dnsProviderAccess = value;
}}
/>
</div>
</div>
</div>
)}
</div>
),
onSubmit: submit,
});
}
return {
openDnsPersistSettingDialog,
};
}
@@ -92,6 +92,9 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
if (form.challengeType === "cname") {
throw new Error(t("certd.domain.cnameManagedInCnamePage"));
}
if (form.challengeType === "dns-persist") {
throw new Error("DNS持久验证记录请在DNS持久验证记录页面管理");
}
if (form.challengeType === "dns") {
const isSubdomain = await api.IsSubdomain({ domain: form.domain });
if (isSubdomain && !subdomainConfirmed.value) {
@@ -221,6 +224,17 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
crudExpose.getFormWrapperRef().close();
},
});
} else if (value === "dns-persist") {
Modal.confirm({
title: "请前往DNS持久验证记录页面添加记录",
content: "DNS持久验证需要先配置ACME账号和_validation-persist持久TXT记录,续期时不再增删DNS记录;当前仅 Let's Encrypt 测试环境可以申请。",
async onOk() {
router.push({
path: "/certd/cert/dns-persist",
});
crudExpose.getFormWrapperRef().close();
},
});
}
},
},
@@ -0,0 +1,17 @@
import { request } from "/@/api/service";
export async function GetMyInvite() {
return await request({ url: "/invite/my", method: "post" });
}
export async function OpenInvitePlan() {
return await request({ url: "/invite/open", method: "post" });
}
export async function GetInvitees(query: any) {
return await request({ url: "/invite/invitees/page", method: "post", data: query });
}
export async function GetCommissionLogs(query: any) {
return await request({ url: "/invite/commission/page", method: "post", data: query });
}
@@ -0,0 +1,44 @@
import { CreateCrudOptionsProps, CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetInvitees(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: {
title: "邀请时间",
type: "datetime",
column: { width: 180 },
},
simpleUser: {
title: "被邀请用户名",
type: "text",
column: {
minWidth: 180,
cellRender({ row }) {
const simpleUser = row.simpleUser;
if (!simpleUser) {
return row.inviteeUserId ? `用户${row.inviteeUserId} (${row.inviteeUserId})` : "-";
}
return simpleUser.displayName || `${simpleUser.username || "-"} (${simpleUser.id})`;
},
},
},
inviteCode: {
title: "邀请码",
type: "text",
column: { width: 160 },
},
},
},
};
}
@@ -0,0 +1,67 @@
import { CreateCrudOptionsRet, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetCommissionLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: {
title: "时间",
type: "datetime",
column: { width: 180 },
},
amount: {
title: "收益金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
simpleUser: {
title: "被推广用户",
type: "text",
column: {
width: 170,
cellRender({ row }) {
const simpleUser = row.simpleUser;
if (!simpleUser) {
return "-";
}
return (
<div class="leading-5">
<div>
{simpleUser.username || "-"} ({simpleUser.id})
</div>
</div>
);
},
},
},
consumeAmount: {
title: "推广金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
},
},
};
}
@@ -0,0 +1,829 @@
<template>
<fs-page class="page-invite">
<template #header>
<div class="title">
激励计划
<span class="sub"> 邀请好友获取丰厚佣金奖励 </span>
</div>
<div class="more">
<a-button type="primary" @click="openAgreementDialog(false)">推广协议</a-button>
</div>
</template>
<div v-if="loaded && enabled && inviteInfo.enabled" class="invite-body">
<div class="invite-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-card-main">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
<div v-if="item.key === 'totalIncome'" class="withdraw-action">
<div class="withdraw-available">可提现 {{ moneyText(inviteInfo.wallet?.availableAmount) }}</div>
<a-button class="summary-action-button" type="primary" @click="gotoWallet">提现</a-button>
</div>
</div>
</div>
<div class="invite-link-panel">
<div class="invite-info-row invite-highlight-row">
<div class="info-icon">
<fs-icon icon="ion:ticket-outline" />
</div>
<span class="info-label">邀请码</span>
<div class="info-content">
<fs-copyable v-model="inviteInfo.inviteCode" />
</div>
</div>
<div class="invite-info-row invite-highlight-row">
<div class="info-icon">
<fs-icon icon="ion:link-outline" />
</div>
<span class="info-label">邀请链接</span>
<div class="info-content">
<fs-copyable v-model="inviteInfo.inviteLink" />
</div>
</div>
<div class="invite-info-row invite-highlight-row level-highlight-row" @click="levelDialogOpen = true">
<div class="info-icon level-info-icon">
<fs-icon v-if="inviteInfo.currentLevel" :icon="levelIcon(inviteInfo.currentLevel)" />
<fs-icon v-else icon="ion:ribbon-outline" />
</div>
<span class="info-label">我的等级</span>
<div class="info-content level-info-content">
<span class="level-name-text">{{ inviteInfo.currentLevel?.name || "未设置" }}</span>
<span v-if="inviteInfo.currentLevel" class="current-level-rate">
<span class="current-level-rate-label">返佣比例</span>
<span class="current-level-rate-value">{{ inviteInfo.currentLevel.commissionRate }}%</span>
</span>
<span v-if="inviteInfo.currentLevel" class="level-rate-desc">好友付费后按此比例计算佣金</span>
</div>
<fs-icon class="level-open-icon" icon="ion:chevron-forward-outline" />
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="invite-tabs" @change="handleTabChange">
<a-tab-pane key="invitees" tab="邀请成功">
<fs-crud v-if="activeTab === 'invitees'" ref="inviteesCrudRef" class="invite-crud" v-bind="inviteesCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="收益记录">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="invite-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
<div v-else-if="loaded && enabled" class="invite-disabled">
<a-empty description="请先开通激励计划">
<a-button type="primary" @click="openAgreementDialog(true)">开通激励计划</a-button>
</a-empty>
</div>
<a-empty v-else-if="loaded" description="激励计划未开启" />
<a-modal v-model:open="levelDialogOpen" title="推广等级" width="820px" wrap-class-name="invite-level-modal" :footer="null">
<div class="level-modal-subtitle">推广越多等级越高返佣比例越高</div>
<div class="level-progress-box">
<div>
<div class="level-progress-label">当前累计推广金额</div>
<div class="level-progress-value">¥ {{ amountToYuan(inviteInfo.summary.promotionAmount) }}</div>
</div>
<div class="level-progress-desc">
<template v-if="inviteInfo.nextLevel">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} </template>
<template v-else-if="inviteInfo.currentLevel?.levelType === 'exclusive'">当前为专属等级不参与自动升级</template>
<template v-else>已达到当前可自动升级的最高等级</template>
</div>
</div>
<div class="level-card-grid modal-level-grid">
<div v-for="level in visibleLevels" :key="level.id" class="level-card" :class="{ active: level.id === inviteInfo.currentLevel?.id }">
<div class="level-name">
<span class="level-medal">
<fs-icon :icon="levelIcon(level)" />
</span>
{{ level.name }}
<a-tag v-if="level.levelType === 'exclusive'" color="orange">专属</a-tag>
</div>
<div class="level-rate-label">佣金比例</div>
<div class="level-rate">{{ level.commissionRate }}%</div>
<div v-if="level.levelType === 'exclusive'" class="level-threshold exclusive-threshold">平台指定专属等级</div>
<div v-else class="level-threshold">累计推广 {{ amountToYuan(level.minAmount) }} </div>
<a-tag v-if="level.id === inviteInfo.currentLevel?.id" class="current-tag" color="blue">当前等级</a-tag>
<div v-else-if="level.id === inviteInfo.nextLevel?.id" class="next-gap">还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }}</div>
</div>
</div>
<div v-if="inviteInfo.nextLevel" class="next-level">距离下一等级{{ inviteInfo.nextLevel.name }}还差 {{ amountToYuan(inviteInfo.nextLevel.gapAmount) }} 元推广金额</div>
<div v-else class="next-level">已达到当前可自动升级的最高等级</div>
</a-modal>
<a-modal
v-model:open="agreementDialogOpen"
:title="agreementDialogNeedOpen ? '开通激励计划' : '推广协议'"
width="760px"
:mask-closable="!agreementDialogNeedOpen"
:keyboard="!agreementDialogNeedOpen"
:confirm-loading="agreementSubmitting"
@ok="handleAgreementOk"
@cancel="closeAgreementDialog"
>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="invite-agreement-content editor-content-view" v-html="agreementText"></div>
<div v-if="agreementDialogNeedOpen" class="invite-agreement-confirm">
<a-checkbox v-model:checked="agreementAgree">我已阅读并同意推广协议</a-checkbox>
</div>
<template #footer>
<a-button @click="closeAgreementDialog">{{ agreementDialogNeedOpen ? "暂不开通" : "关闭" }}</a-button>
<a-button v-if="agreementDialogNeedOpen" type="primary" :disabled="!agreementAgree" :loading="agreementSubmitting" @click="handleAgreementOk">同意并开通</a-button>
</template>
</a-modal>
</fs-page>
</template>
<script lang="ts" setup>
import { computed, nextTick, onActivated, onMounted, reactive, ref } from "vue";
import { FsIcon, useFs } from "@fast-crud/fast-crud";
import { notification } from "ant-design-vue";
import { useRouter } from "vue-router";
import * as api from "./api";
import createInviteesCrudOptions from "./crud-invitees";
import createLogsCrudOptions from "./crud-logs";
import { useSettingStore } from "/@/store/settings";
import { util } from "/@/utils";
defineOptions({ name: "InviteCommission" });
const router = useRouter();
const settingStore = useSettingStore();
const enabled = ref(false);
const activeTab = ref("invitees");
const loaded = ref(false);
const levelDialogOpen = ref(false);
const agreementDialogOpen = ref(false);
const agreementDialogNeedOpen = ref(false);
const agreementAgree = ref(false);
const agreementSubmitting = ref(false);
const defaultAgreementContent = "<p>请遵守平台推广规则,不得通过虚假注册、刷单、恶意诱导等方式获取收益。平台有权对异常推广行为进行核查,并根据实际情况暂停结算或关闭激励计划资格。</p>";
const inviteInfo = reactive<any>({
enabled: false,
inviteCode: "",
inviteLink: "",
agreementContent: "",
summary: { totalIncomeAmount: 0, monthIncomeAmount: 0, promotionAmount: 0, inviteeCount: 0 },
wallet: { availableAmount: 0 },
currentLevel: null,
nextLevel: null,
levelList: [],
});
const { crudBinding: inviteesCrudBinding, crudExpose: inviteesCrudExpose, crudRef: inviteesCrudRef } = useFs({ createCrudOptions: createInviteesCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
const summaryCards = computed(() => [
{
key: "totalIncome",
title: "累计收益",
value: moneyText(inviteInfo.summary.totalIncomeAmount),
className: "income",
},
{
key: "monthIncome",
title: "本月收益",
value: moneyText(inviteInfo.summary.monthIncomeAmount),
className: "income",
},
{
key: "promotionAmount",
title: "累计推广金额",
value: moneyText(inviteInfo.summary.promotionAmount),
className: "promotion",
},
{
key: "inviteeCount",
title: "已推广人数",
value: `${inviteInfo.summary.inviteeCount || 0}`,
className: "people",
},
]);
const visibleLevels = computed(() => {
return (inviteInfo.levelList || []).filter((level: any) => !level.disabled);
});
const agreementText = computed(() => inviteInfo.agreementContent?.trim() || defaultAgreementContent);
function levelIcon(level: any) {
return level?.icon || "ion:ribbon-outline";
}
function gotoWallet() {
router.push({ path: "/certd/wallet" });
}
function openAgreementDialog(needOpenPlan: boolean) {
agreementDialogNeedOpen.value = needOpenPlan;
agreementAgree.value = false;
agreementDialogOpen.value = true;
}
function closeAgreementDialog() {
agreementDialogOpen.value = false;
}
async function handleAgreementOk() {
if (!agreementDialogNeedOpen.value) {
closeAgreementDialog();
return;
}
if (!agreementAgree.value) {
notification.warning({ message: "请先勾选同意推广协议" });
return;
}
agreementSubmitting.value = true;
try {
await api.OpenInvitePlan();
notification.success({ message: "激励计划已开通" });
closeAgreementDialog();
await refreshInvitePage(false);
} finally {
agreementSubmitting.value = false;
}
}
async function loadMyInvite(autoOpenAgreement = false) {
const res: any = await api.GetMyInvite();
Object.assign(inviteInfo, res || {});
if (autoOpenAgreement && !inviteInfo.enabled) {
await nextTick();
openAgreementDialog(true);
}
}
async function refreshActiveList() {
if (!inviteInfo.enabled) {
return;
}
if (activeTab.value === "invitees") {
await inviteesCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function handleTabChange() {
await nextTick();
await refreshActiveList();
}
async function refreshInvitePage(autoOpenAgreement = true) {
await settingStore.initOnce();
enabled.value = settingStore.isInviteCommissionEnabled;
loaded.value = true;
if (!enabled.value) {
return;
}
await loadMyInvite(autoOpenAgreement);
if (!inviteInfo.enabled) {
return;
}
await nextTick();
await refreshActiveList();
}
onMounted(async () => {
await refreshInvitePage(true);
});
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshInvitePage();
});
</script>
<style lang="less">
.page-invite {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.invite-page-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.invite-page-subtitle {
margin-top: 4px;
color: hsl(var(--muted-foreground));
font-size: 13px;
font-weight: 400;
}
.invite-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.invite-disabled {
display: flex;
flex: 1;
align-items: center;
justify-content: center;
}
.level-subtitle,
.level-modal-subtitle {
color: hsl(var(--muted-foreground));
font-size: 14px;
}
.invite-summary-grid {
display: grid;
gap: 16px;
}
.invite-summary-grid {
flex: none;
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-bottom: 18px;
}
.summary-card,
.invite-link-panel {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.82)), hsl(var(--card));
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.summary-card:hover,
.invite-link-panel:hover {
border-color: rgba(52, 120, 246, 0.34);
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.12);
transform: translateY(-2px);
}
.summary-card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 112px;
overflow: hidden;
padding: 22px;
}
.summary-card-main {
min-width: 0;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.income {
color: #c58a35;
}
.summary-value.people {
color: #3478f6;
}
.summary-value.promotion {
color: #16a085;
}
.summary-action-button {
flex: none;
min-width: 72px;
}
.withdraw-action {
display: flex;
align-items: flex-end;
flex: none;
flex-direction: column;
gap: 8px;
}
.withdraw-available {
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.invite-link-panel {
flex: none;
padding: 16px 18px;
margin-bottom: 18px;
}
.invite-info-row {
display: flex;
align-items: center;
min-height: 34px;
gap: 10px;
}
.invite-highlight-row {
min-height: 48px;
padding: 8px 12px;
border: 1px solid rgba(52, 120, 246, 0.16);
border-radius: 8px;
background: rgba(248, 250, 252, 0.72);
transition:
border-color 0.18s ease,
background-color 0.18s ease,
box-shadow 0.18s ease;
}
.invite-highlight-row:hover {
border-color: rgba(52, 120, 246, 0.34);
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.level-highlight-row {
cursor: pointer;
}
.level-info-icon {
color: #8a5a16;
font-size: 20px;
}
.invite-info-row + .invite-info-row {
margin-top: 8px;
}
.info-icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: 1px solid rgba(52, 120, 246, 0.14);
border-radius: 8px;
background: rgba(52, 120, 246, 0.08);
color: #3478f6;
font-size: 17px;
}
.info-label {
width: 72px;
flex: none;
color: hsl(var(--foreground));
font-size: 13px;
font-weight: 600;
text-align: left;
white-space: nowrap;
}
.info-content {
flex: 1;
min-width: 0;
}
.current-level-rate {
display: inline-flex;
align-items: center;
flex: none;
height: 26px;
margin-left: 6px;
overflow: hidden;
border: 1px solid rgba(197, 138, 53, 0.22);
border-radius: 6px;
background: rgba(197, 138, 53, 0.08);
color: #c58a35;
font-size: 13px;
font-weight: 700;
}
.current-level-rate-label {
height: 100%;
padding: 0 8px;
border-right: 1px solid rgba(197, 138, 53, 0.18);
background: rgba(197, 138, 53, 0.1);
color: #8a5a16;
font-weight: 500;
line-height: 24px;
}
.current-level-rate-value {
padding: 0 8px;
line-height: 24px;
}
.level-info-content {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.level-name-text {
flex: none;
color: hsl(var(--foreground));
font-weight: 600;
}
.level-rate-desc {
min-width: 180px;
color: hsl(var(--muted-foreground));
font-size: 12px;
line-height: 20px;
}
.level-open-icon {
flex: none;
color: hsl(var(--muted-foreground));
font-size: 16px;
transition: transform 0.18s ease;
}
.level-highlight-row:hover .level-open-icon {
transform: translateX(2px);
}
.level-button {
display: inline-flex;
align-items: center;
height: 28px;
padding-left: 0;
gap: 4px;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #8a5a16;
font-size: 20px;
}
.invite-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.invite-crud {
flex: 1;
min-height: 0;
}
.invite-tabs {
.fs-search {
display: none;
}
}
}
.invite-level-modal {
.level-card-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
}
.level-card {
position: relative;
min-height: 132px;
padding: 16px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.8)), linear-gradient(135deg, rgba(197, 138, 53, 0.14), rgba(52, 120, 246, 0.12));
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.2s,
background-color 0.2s;
}
.level-card:hover {
border-color: rgba(52, 120, 246, 0.38);
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.12);
transform: translateY(-2px);
}
.level-card.active {
border-color: #3478f6;
background: linear-gradient(145deg, rgba(236, 244, 255, 0.92), rgba(248, 250, 252, 0.88)), hsl(var(--primary) / 10%);
}
.level-name {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: hsl(var(--foreground));
font-weight: 700;
}
.level-medal {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: #8a5a16;
font-size: 20px;
}
.level-rate-label {
margin-top: 12px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.level-rate {
margin-top: 2px;
color: #c58a35;
font-size: 24px;
font-weight: 700;
line-height: 30px;
text-align: center;
}
.level-threshold,
.next-gap {
margin-top: 6px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.exclusive-threshold {
color: #8a5a16;
}
.current-tag {
display: table;
margin: 10px auto 0;
}
.next-gap {
color: #3478f6;
}
}
.modal-level-grid {
margin-top: 12px;
}
.level-progress-box {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid rgba(52, 120, 246, 0.16);
border-radius: 8px;
background: rgba(248, 250, 252, 0.86);
}
.level-progress-label {
color: hsl(var(--muted-foreground));
font-size: 12px;
}
.level-progress-value {
margin-top: 2px;
color: #16a085;
font-size: 22px;
font-weight: 700;
line-height: 28px;
}
.level-progress-desc {
color: #3478f6;
font-size: 13px;
text-align: right;
}
.invite-agreement-content {
max-height: 360px;
padding: 12px;
overflow: auto;
border: 1px solid #eee;
border-radius: 6px;
background: hsl(var(--card));
line-height: 1.7;
:deep(img) {
max-width: 100%;
height: auto;
}
:deep(p) {
margin-bottom: 10px;
}
}
.invite-agreement-confirm {
margin-top: 14px;
padding: 10px 12px;
border: 1px solid #e6f4ff;
border-radius: 6px;
background: #f5fbff;
}
.level-modal-subtitle {
margin-bottom: 12px;
}
.next-level {
margin-top: 16px;
color: #3478f6;
}
@media (max-width: 900px) {
.page-invite {
.invite-summary-grid,
.level-card-grid {
grid-template-columns: 1fr;
}
.invite-info-row {
align-items: stretch;
flex-direction: column;
}
.info-label {
width: auto;
text-align: left;
}
}
.invite-level-modal {
.level-card-grid {
grid-template-columns: 1fr;
}
.level-progress-box {
align-items: flex-start;
flex-direction: column;
}
.level-progress-desc {
text-align: left;
}
}
}
</style>
@@ -455,8 +455,8 @@ onMounted(async () => {
.card-header {
.header-bg-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
opacity: 0.15;
background: rgba(255, 255, 255, 0.04);
opacity: 1;
}
}
@@ -572,8 +572,9 @@ onMounted(async () => {
.bindings-card,
.passkey-card {
background: #fff;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.22);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
transition: all 0.3s ease;
margin: 5px;
}
@@ -586,13 +587,15 @@ onMounted(async () => {
.profile-card:hover,
.bindings-card:hover,
.passkey-card:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
border-color: rgba(148, 163, 184, 0.34);
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.1);
transform: translateY(-2px);
}
.card-header {
position: relative;
padding: 40px 30px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.9)), hsl(var(--card));
}
.header-bg-gradient {
@@ -601,8 +604,8 @@ onMounted(async () => {
left: 0;
right: 0;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
opacity: 0.08;
background: radial-gradient(circle at 14% 22%, rgba(52, 120, 246, 0.08), transparent 34%), radial-gradient(circle at 86% 18%, rgba(197, 138, 53, 0.08), transparent 32%);
opacity: 1;
}
.header-content {
@@ -631,14 +634,14 @@ onMounted(async () => {
padding: 0;
color: #667eea;
background: #ffffff;
border: 1px solid #e5e7eb;
border: 1px solid rgba(148, 163, 184, 0.34);
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.12);
}
.user-avatar {
border: 4px solid #ffffff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.12);
}
.status-indicator {
@@ -678,7 +681,9 @@ onMounted(async () => {
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 8px;
background: rgba(255, 255, 255, 0.72);
font-size: 13px;
}
@@ -213,6 +213,10 @@ function useStepForm() {
const stepOpen = (step: any, emit: any) => {
callback.value = emit;
currentStep.value = merge({ input: {}, strategy: {} }, step);
// version 1
if (mode.value === "edit" && currentStep.value.type === "CertApply" && currentStep.value.input?.version == null) {
currentStep.value.input.version = 1;
}
if (step.type) {
changeCurrentPlugin(currentStep.value);
}
@@ -46,6 +46,7 @@ export type TradeCreateReq = {
duration: number;
num: number;
payType: string;
useRebateBalance?: boolean;
};
export async function TradeCreate(form: TradeCreateReq) {
@@ -8,7 +8,7 @@
<a-col :span="24">
<a-card>
<div class="suite-intro-box">
<div>说明 同一时间只有最新购买的一个套餐生效 可以购买多个加量包加量包立即生效 套餐和加量包内的数量可以叠加</div>
<div>{{ buyHelperText }}</div>
<div v-if="suiteIntro" v-html="suiteIntro"></div>
</div>
</a-card>
@@ -31,7 +31,7 @@
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import * as api from "./api";
import ProductInfo from "/@/views/certd/suite/product-info.vue";
import OrderModal from "/@/views/certd/suite/order-modal.vue";
@@ -55,9 +55,17 @@ async function doOrder(req: any) {
}
const suiteIntro = ref("");
const allowSuiteStack = ref(false);
const buyHelperText = computed(() => {
if (allowSuiteStack.value) {
return "说明:可以购买多个套餐和加量包,套餐和加量包内的数量可以叠加";
}
return "说明:① 同一时间只有最新购买的一个套餐生效;② 可以购买多个加量包,加量包立即生效;③ 套餐和加量包内的数量可以叠加";
});
async function loadSuiteIntro() {
const res = await api.GetSuiteSetting();
suiteIntro.value = res.intro;
allowSuiteStack.value = !!res.allowSuiteStack;
}
loadSuiteIntro();
</script>
@@ -117,7 +125,7 @@ loadSuiteIntro();
.suite-card-col {
width: 20% !important;
min-width: 360px !important;
min-width: 354px !important;
}
}
}
@@ -4,20 +4,33 @@
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.package") }}</span>{{ product.title }}
</div>
<div class="flex-o mt-5">
<div v-if="product.intro" class="flex-o mt-5">
<span class="label">{{ $t("certd.order.description") }}</span>{{ product.intro }}
</div>
<div class="flex-o mt-5">
<div class="order-spec-row mt-5">
<span class="label">{{ $t("certd.order.specifications") }}</span>
<span class="flex-o flex-wrap">
<span class="flex-o"> {{ $t("certd.order.pipeline") }}<suite-value class="ml-5" :model-value="product.content.maxPipelineCount" :unit="$t('certd.order.unit.pieces')" /> </span>
<span class="flex-o"> {{ $t("certd.order.totalDomain") }}<suite-value class="ml-5" :model-value="product.content.maxDomainCount" :unit="$t('certd.order.unit.count')" /> </span>
<span class="flex-o" style="padding-left: 2em">
- {{ $t("certd.order.includedWildcardDomain") }}<suite-value class="ml-5" :model-value="product.content.maxWildcardDomainCount" :unit="$t('certd.order.unit.count')" />
</span>
<span class="flex-o"> {{ $t("certd.order.deployTimes") }}<suite-value class="ml-5" :model-value="product.content.maxDeployCount" :unit="$t('certd.order.unit.times')" /> </span>
<span class="flex-o"> {{ $t("certd.order.monitorCount") }}<suite-value class="ml-5" :model-value="product.content.maxMonitorCount" :unit="$t('certd.order.unit.times')" /> </span>
</span>
<div class="spec-grid">
<div class="spec-item">
<div class="spec-name">{{ $t("certd.order.totalDomain") }}</div>
<suite-value :model-value="product.content.maxDomainCount" :unit="$t('certd.order.unit.count')" />
</div>
<div class="spec-item">
<div class="spec-name">{{ $t("certd.order.includedWildcardDomain") }}</div>
<suite-value :model-value="product.content.maxWildcardDomainCount" :unit="$t('certd.order.unit.count')" />
</div>
<div class="spec-item">
<div class="spec-name">{{ $t("certd.order.pipeline") }}</div>
<suite-value :model-value="product.content.maxPipelineCount" :unit="$t('certd.order.unit.pieces')" />
</div>
<div class="spec-item">
<div class="spec-name">{{ $t("certd.order.deployTimes") }}</div>
<suite-value :model-value="product.content.maxDeployCount" :unit="$t('certd.order.unit.times')" />
</div>
<div class="spec-item">
<div class="spec-name">{{ $t("certd.order.monitorCount") }}</div>
<suite-value :model-value="product.content.maxMonitorCount" :unit="$t('certd.order.unit.times')" />
</div>
</div>
</div>
<div class="flex-o mt-5">
@@ -26,12 +39,21 @@
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.price") }}</span>
<price-input :edit="false" :model-value="durationSelected.price"></price-input>
<price-input :edit="false" :model-value="durationSelected.price" zero-text="0元"></price-input>
</div>
<div v-if="durationSelected.price > 0 && wallet.availableAmount > 0" class="flex-o mt-5">
<span class="label">余额抵扣</span>
<a-switch v-model:checked="formRef.useRebateBalance" />
<span class="ml-10">可用 {{ amountToYuan(wallet.availableAmount) }} 预计抵扣 {{ amountToYuan(expectedRebateAmount) }} </span>
</div>
<div v-if="durationSelected.price > 0 && formRef.useRebateBalance" class="flex-o mt-5">
<span class="label">还需支付</span>
<span class="color-red">{{ amountToYuan(expectedThirdPartyAmount) }} </span>
</div>
<div class="flex-o mt-5">
<span class="label">{{ $t("certd.order.paymentMethod") }}</span>
<div v-if="durationSelected.price === 0">{{ $t("certd.order.free") }}</div>
<div v-if="durationSelected.price === 0 || expectedThirdPartyAmount === 0">{{ $t("certd.order.free") }}</div>
<fs-dict-select v-else v-model:value="formRef.payType" :dict="paymentsDictRef" style="width: 200px"> </fs-dict-select>
</div>
</div>
@@ -39,7 +61,7 @@
</template>
<script setup lang="tsx">
import { ref } from "vue";
import { computed, ref } from "vue";
import { GetPaymentTypes, OrderModalOpenReq, TradeCreate } from "/@/views/certd/suite/api";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
@@ -49,11 +71,14 @@ import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
import { useRouter } from "vue-router";
import qrcode from "qrcode";
import * as api from "/@/views/certd/suite/api";
import { GetWalletSummary } from "/@/views/certd/wallet/api";
import { util } from "/@/utils";
const openRef = ref(false);
const product = ref<any>(null);
const formRef = ref<any>({});
const durationSelected = ref<any>(null);
const wallet = ref<any>({ availableAmount: 0 });
async function open(opts: OrderModalOpenReq) {
openRef.value = true;
@@ -63,6 +88,12 @@ async function open(opts: OrderModalOpenReq) {
formRef.value.productId = opts.product.id;
formRef.value.duration = opts.duration;
formRef.value.num = opts.num ?? 1;
formRef.value.useRebateBalance = false;
try {
wallet.value = await GetWalletSummary();
} catch (e) {
wallet.value = { availableAmount: 0 };
}
}
const paymentsDictRef = dict({
async getData() {
@@ -77,6 +108,21 @@ const paymentsDictRef = dict({
const router = useRouter();
const expectedRebateAmount = computed(() => {
if (!formRef.value.useRebateBalance) {
return 0;
}
return Math.min(wallet.value.availableAmount || 0, durationSelected.value?.price || 0);
});
const expectedThirdPartyAmount = computed(() => {
return Math.max(0, (durationSelected.value?.price || 0) - expectedRebateAmount.value);
});
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
async function orderCreate() {
if (durationSelected.value.price === 0) {
//0
@@ -93,7 +139,7 @@ async function orderCreate() {
return;
}
if (!formRef.value.payType) {
if (expectedThirdPartyAmount.value > 0 && !formRef.value.payType) {
notification.error({
message: "请选择支付方式",
});
@@ -104,8 +150,17 @@ async function orderCreate() {
duration: formRef.value.duration,
num: formRef.value.num ?? 1,
payType: formRef.value.payType,
useRebateBalance: formRef.value.useRebateBalance,
});
if (paymentReq.paid) {
notification.success({
message: "套餐购买成功",
});
openRef.value = false;
return;
}
async function onPaid() {
openRef.value = false;
router.push({
@@ -218,6 +273,10 @@ defineExpose({
</script>
<style lang="less">
.order-box {
display: flex;
flex-direction: column;
gap: 10px;
.label {
width: 80px;
text-align: right;
@@ -225,6 +284,37 @@ defineExpose({
color: #686868;
flex: none;
}
.order-spec-row {
display: flex;
align-items: flex-start;
}
.spec-grid {
display: grid;
flex: 1;
min-width: 0;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.spec-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 42px;
padding: 8px 10px;
gap: 8px;
border: 1px solid rgba(52, 120, 246, 0.12);
border-radius: 8px;
background: rgba(248, 250, 252, 0.86);
}
.spec-name {
color: hsl(var(--foreground));
font-size: 13px;
line-height: 20px;
}
}
.modal-confirm-center {
@@ -39,7 +39,8 @@
<div class="flex-o duration-label">时长</div>
<div class="duration-list">
<div v-for="dp of product.durationPrices" :key="dp.duration" class="duration-item" :class="{ active: selected.duration === dp.duration }" @click="selected = dp">
{{ durationDict.dataMap[dp.duration]?.label }}
<span class="duration-text">{{ durationDict.dataMap[dp.duration]?.label }}</span>
<span v-if="discountText(dp)" class="duration-discount">{{ discountText(dp) }}</span>
</div>
</div>
</div>
@@ -47,8 +48,8 @@
<div class="price flex-between mt-5">
<div class="flex-o">价格</div>
<div class="flex-o price-text">
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" />
<span class="ml-5" style="font-size: 12px"> / {{ durationDict.dataMap[selected.duration]?.label }}</span>
<price-input style="color: red" :font-size="20" :model-value="selected?.price" :edit="false" zero-text="免费" />
<span class="price-unit">/ {{ durationDict.dataMap[selected.duration]?.label }}</span>
</div>
</div>
@@ -61,7 +62,7 @@
import { durationDict } from "/@/views/certd/suite/api";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import { ref } from "vue";
import { computed, ref } from "vue";
import { dict, FsIcon } from "@fast-crud/fast-crud";
const props = defineProps<{
@@ -69,6 +70,20 @@ const props = defineProps<{
}>();
const selected = ref(props.product.durationPrices[0]);
const originalUnitPrice = computed(() => {
const unitPrices = (props.product.durationPrices || [])
.map((item: any) => {
const duration = Number(item.duration);
const price = Number(item.price);
if (!duration || duration <= 0 || price <= 0) {
return null;
}
return price / duration;
})
.filter((item: number | null): item is number => item != null);
return Math.max(...unitPrices, 0);
});
const productTypeDictRef = dict({
data: [
{ value: "suite", label: "套餐", color: "green" },
@@ -77,6 +92,20 @@ const productTypeDictRef = dict({
});
const emit = defineEmits(["order"]);
function discountText(durationPrice: any) {
const duration = Number(durationPrice.duration);
const price = Number(durationPrice.price);
if (!duration || duration <= 0 || price <= 0 || originalUnitPrice.value <= 0) {
return "";
}
const currentUnitPrice = price / duration;
const discount = Math.round((currentUnitPrice / originalUnitPrice.value) * 100) / 10;
if (discount >= 10) {
return "";
}
return `${discount}`;
}
async function doOrder() {
emit("order", { product: props.product, productId: props.product.id, duration: selected.value.duration, price: selected.value.price });
}
@@ -108,13 +137,21 @@ async function doOrder() {
.duration-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 4px;
.duration-item {
width: 45px;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
min-width: 56px;
height: 32px;
border: 1px solid #cdcdcd;
border-radius: 4px;
text-align: center;
padding: 2px;
margin: 2px;
padding: 3px 6px;
cursor: pointer;
line-height: 16px;
&:hover {
border-color: #1890ff;
@@ -124,6 +161,44 @@ async function doOrder() {
background-color: #c1eafb;
}
}
.duration-text {
display: block;
line-height: 20px;
white-space: nowrap;
}
.duration-discount {
position: absolute;
top: -9px;
right: -7px;
height: 16px;
padding: 0 4px;
border-radius: 8px 8px 8px 2px;
background: #ff4d4f;
color: #f5222d;
color: #fff;
font-size: 11px;
font-weight: 600;
line-height: 16px;
white-space: nowrap;
box-shadow: 0 2px 6px rgba(245, 34, 45, 0.24);
}
}
.price-text {
flex: none;
align-items: baseline;
justify-content: flex-end;
white-space: nowrap;
}
.price-unit {
flex: none;
margin-left: 5px;
color: hsl(var(--muted-foreground));
font-size: 12px;
white-space: nowrap;
}
}
</style>
@@ -26,11 +26,11 @@ export async function UpdateObj(obj: any) {
});
}
export async function DelObj(id: any) {
export async function CancelObj(id: any) {
return await request({
url: apiPrefix + "/delete",
url: apiPrefix + "/cancel",
method: "post",
params: { id },
data: { id },
});
}
@@ -50,14 +50,6 @@ export async function GetDetail(id: any) {
});
}
export async function DeleteBatch(ids: any[]) {
return await request({
url: apiPrefix + "/deleteByIds",
method: "post",
data: { ids },
});
}
export async function SyncStatus(id: any) {
return await request({
url: apiPrefix + "/syncStatus",
@@ -1,18 +1,10 @@
import * as api from "./api";
import { useI18n } from "/src/locales";
import { computed, Ref, ref } from "vue";
import { useRouter } from "vue-router";
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes, utils } from "@fast-crud/fast-crud";
import { useUserStore } from "/@/store/user";
import { useSettingStore } from "/@/store/settings";
import { AddReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet, dict, EditReq, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import { Modal } from "ant-design-vue";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
import SuiteValue from "/@/views/sys/suite/product/suite-value.vue";
import DurationValue from "/@/views/sys/suite/product/duration-value.vue";
export default function ({ crudExpose, context }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const router = useRouter();
const { t } = useI18n();
export default function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetList(query);
};
@@ -21,56 +13,35 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
const res = await api.UpdateObj(form);
return res;
};
const delRequest = async ({ row }: DelReq) => {
return await api.DelObj(row.id);
};
const addRequest = async ({ form }: AddReq) => {
const res = await api.AddObj(form);
return res;
};
const userStore = useUserStore();
const settingStore = useSettingStore();
const selectedRowKeys: Ref<any[]> = ref([]);
context.selectedRowKeys = selectedRowKeys;
return {
crudOptions: {
settings: {
plugins: {
//这里使用行选择插件,生成行选择crudOptions配置,最终会与crudOptions合并
rowSelection: {
enabled: true,
order: -2,
before: true,
// handle: (pluginProps,useCrudProps)=>CrudOptions,
props: {
multiple: true,
crossPage: true,
selectedRowKeys,
},
},
},
},
request: {
pageRequest,
addRequest,
editRequest,
delRequest,
},
rowHandle: {
width: 240,
width: 120,
fixed: "right",
buttons: {
view: { show: false },
edit: { show: false },
copy: { show: false },
remove: { show: false },
syncStatus: {
order: 10,
show: compute(({ row }) => {
return row.status === "wait_pay";
}),
text: "同步订单状态",
title: "同步订单状态",
text: null,
tooltip: { title: "同步订单状态" },
icon: "ant-design:sync-outlined",
type: "link",
click: async ({ row }) => {
Modal.confirm({
@@ -83,6 +54,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
});
},
},
cancel: {
order: 99,
show: compute(({ row }) => {
return row.status === "wait_pay";
}),
title: "取消订单",
text: null,
tooltip: { title: "取消订单" },
icon: "ion:close-circle-outline",
type: "link",
danger: true,
click: async ({ row }) => {
Modal.confirm({
title: "确认取消订单?",
content: "取消后订单会关闭,已冻结的余额抵扣金额将自动退回。",
okText: "确认取消",
cancelText: "再想想",
onOk: async () => {
await api.CancelObj(row.id);
await crudExpose.doRefresh();
},
});
},
},
},
},
actionbar: {
@@ -151,6 +146,30 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
},
},
},
rebateAmount: {
title: "余额抵扣",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
thirdPartyPayAmount: {
title: "实付金额",
type: "number",
column: {
width: 110,
component: {
name: PriceInput,
vModel: "modelValue",
edit: false,
},
},
},
status: {
title: "状态",
search: { show: true },
@@ -177,6 +196,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat
{ label: "支付宝", value: "alipay" },
{ label: "微信", value: "wxpay" },
{ label: "免费", value: "free" },
{ label: "余额抵扣", value: "rebate" },
],
}),
column: {
@@ -3,13 +3,7 @@
<template #header>
<div class="title">我的订单</div>
</template>
<fs-crud ref="crudRef" v-bind="crudBinding">
<template #pagination-left>
<a-tooltip title="批量删除">
<fs-button icon="DeleteOutlined" @click="handleBatchDelete"></fs-button>
</a-tooltip>
</template>
</fs-crud>
<fs-crud ref="crudRef" v-bind="crudBinding" />
</fs-page>
</template>
@@ -17,31 +11,11 @@
import { onActivated, onMounted } from "vue";
import { useFs } from "@fast-crud/fast-crud";
import createCrudOptions from "./crud";
import { message, Modal } from "ant-design-vue";
import { DeleteBatch } from "./api";
defineOptions({
name: "MyTrade",
});
const { crudBinding, crudRef, crudExpose, context } = useFs({ createCrudOptions });
const selectedRowKeys = context.selectedRowKeys;
const handleBatchDelete = () => {
if (selectedRowKeys.value?.length > 0) {
Modal.confirm({
title: "确认",
content: `确定要批量删除这${selectedRowKeys.value.length}条记录吗`,
async onOk() {
await DeleteBatch(selectedRowKeys.value);
message.info("删除成功");
crudExpose.doRefresh();
selectedRowKeys.value = [];
},
});
} else {
message.error("请先勾选记录");
}
};
const { crudBinding, crudRef, crudExpose } = useFs({ createCrudOptions });
//
onMounted(() => {
@@ -0,0 +1,29 @@
import { request } from "/@/api/service";
export async function GetWalletSummary() {
return await request({ url: "/wallet/summary", method: "post" });
}
export async function GetWithdrawSetting() {
return await request({ url: "/wallet/withdraw/setting/get", method: "post" });
}
export async function GetWalletSetting() {
return await request({ url: "/wallet/settings/get", method: "post" });
}
export async function SaveWithdrawSetting(data: any) {
return await request({ url: "/wallet/withdraw/setting/save", method: "post", data });
}
export async function ApplyWithdraw(amount: number) {
return await request({ url: "/wallet/withdraw/apply", method: "post", data: { amount } });
}
export async function GetWithdraws(query: any) {
return await request({ url: "/wallet/withdraw/page", method: "post", data: query });
}
export async function GetWalletLogs(query: any) {
return await request({ url: "/wallet/log/page", method: "post", data: query });
}
@@ -0,0 +1,71 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import { util } from "/@/utils";
function moneyText(amount: number) {
const yuan = util.amount.toYuan(Math.abs(amount || 0));
if (amount < 0) {
return `${yuan}`;
}
return `¥${yuan}`;
}
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWalletLogs(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "时间", type: "datetime", column: { width: 180 } },
type: {
title: "类型",
type: "dict-select",
dict: dict({
data: [
{ label: "收益入账", value: "income", color: "success" },
{ label: "余额抵扣", value: "consume", color: "default" },
{ label: "提现冻结", value: "withdraw_freeze", color: "warning" },
{ label: "提现成功", value: "withdraw", color: "success" },
{ label: "提现成功", value: "withdraw_success", color: "success" },
{ label: "提现退回", value: "withdraw_reject", color: "processing" },
],
}),
column: { width: 120 },
},
amount: {
title: "变动金额",
type: "number",
column: {
width: 120,
cellRender({ value }) {
const amount = Number(value || 0);
return <span class={amount < 0 ? "text-green-500" : "text-red-500"}>{moneyText(amount)}</span>;
},
},
},
balanceAfter: {
title: "变动后余额",
type: "number",
column: {
width: 130,
cellRender({ value }) {
return <span class="text-red-500">{moneyText(Number(value || 0))}</span>;
},
},
},
remark: {
title: "备注",
type: "text",
column: { minWidth: 220 },
},
},
},
};
}
@@ -0,0 +1,54 @@
import { CreateCrudOptionsRet, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud";
import * as api from "./api";
import PriceInput from "/@/views/sys/suite/product/price-input.vue";
export default function (): CreateCrudOptionsRet {
const pageRequest = async (query: UserPageQuery): Promise<UserPageRes> => {
return await api.GetWithdraws(query);
};
return {
crudOptions: {
request: { pageRequest },
search: { show: false },
actionbar: { show: false },
toolbar: { show: false },
rowHandle: { show: false },
columns: {
createTime: { title: "申请时间", type: "datetime", column: { width: 180 } },
amount: {
title: "金额",
type: "number",
column: {
width: 120,
component: { name: PriceInput, vModel: "modelValue", edit: false },
},
},
status: {
title: "状态",
type: "dict-select",
dict: dict({
data: [
{ label: "待审核", value: "pending", color: "warning" },
{ label: "已通过", value: "approved", color: "success" },
{ label: "已拒绝", value: "rejected", color: "error" },
],
}),
column: { width: 110 },
},
channel: {
title: "提现渠道",
type: "dict-select",
dict: dict({
data: [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
],
}),
column: { width: 110 },
},
auditRemark: { title: "审核备注", type: "text", column: { minWidth: 180 } },
},
},
};
}
@@ -0,0 +1,421 @@
<template>
<fs-page class="page-wallet">
<template #header>
<div class="title">我的钱包</div>
</template>
<div class="wallet-body">
<div class="wallet-summary-grid">
<div v-for="item in summaryCards" :key="item.key" class="summary-card">
<div class="summary-card-main">
<div class="summary-title">{{ item.title }}</div>
<div class="summary-value" :class="item.className">{{ item.value }}</div>
</div>
<a-button v-if="item.key === 'availableAmount'" class="summary-action-button" type="primary" @click="openWithdrawDialog">申请提现</a-button>
</div>
</div>
<a-tabs v-model:active-key="activeTab" class="wallet-tabs" @change="refreshActiveList">
<a-tab-pane key="withdraw" tab="提现记录">
<fs-crud v-if="activeTab === 'withdraw'" ref="withdrawCrudRef" class="wallet-crud" v-bind="withdrawCrudBinding" />
</a-tab-pane>
<a-tab-pane key="logs" tab="余额明细">
<fs-crud v-if="activeTab === 'logs'" ref="logsCrudRef" class="wallet-crud" v-bind="logsCrudBinding" />
</a-tab-pane>
</a-tabs>
</div>
</fs-page>
</template>
<script lang="ts" setup>
import { computed, h, onActivated, onMounted, reactive, ref } from "vue";
import { compute, dict, useFs } from "@fast-crud/fast-crud";
import { Button, notification } from "ant-design-vue";
import * as api from "./api";
import createLogsCrudOptions from "./crud-logs";
import createWithdrawCrudOptions from "./crud-withdraw";
import { util } from "/@/utils";
import { useFormDialog } from "/@/use/use-dialog";
import { useUserStore } from "/@/store/user";
defineOptions({ name: "MyWallet" });
const summary = reactive<any>({ availableAmount: 0, frozenAmount: 0, totalIncomeAmount: 0, totalWithdrawAmount: 0 });
const loaded = ref(false);
const activeTab = ref("withdraw");
const { openFormDialog } = useFormDialog();
const userStore = useUserStore();
const { crudBinding: withdrawCrudBinding, crudExpose: withdrawCrudExpose, crudRef: withdrawCrudRef } = useFs({ createCrudOptions: createWithdrawCrudOptions });
const { crudBinding: logsCrudBinding, crudExpose: logsCrudExpose, crudRef: logsCrudRef } = useFs({ createCrudOptions: createLogsCrudOptions });
function amountToYuan(amount: number) {
return util.amount.toYuan(amount || 0);
}
function moneyText(amount: number) {
return `¥ ${amountToYuan(amount)}`;
}
function buildPrivateFileUrl(key: string) {
return `/api/basic/file/download?token=${userStore.getToken}&key=${encodeURIComponent(key)}`;
}
const summaryCards = computed(() => [
{
key: "availableAmount",
title: "可用余额",
value: moneyText(summary.availableAmount),
className: "available",
},
{
key: "frozenAmount",
title: "冻结余额",
value: moneyText(summary.frozenAmount),
className: "frozen",
},
{
key: "totalIncomeAmount",
title: "累计收入",
value: moneyText(summary.totalIncomeAmount),
className: "income",
},
{
key: "totalWithdrawAmount",
title: "累计提现",
value: moneyText(summary.totalWithdrawAmount),
className: "withdraw",
},
]);
async function loadWalletSummary() {
const res: any = await api.GetWalletSummary();
Object.assign(summary, res || {});
}
async function openWithdrawSetting() {
const [setting, walletSetting]: any[] = await Promise.all([api.GetWithdrawSetting(), api.GetWalletSetting()]);
const enabledChannels = walletSetting?.withdrawChannels?.length ? walletSetting.withdrawChannels : ["alipay", "bank"];
const enabledBanks = walletSetting?.withdrawBanks?.length ? walletSetting.withdrawBanks : [];
const channelOptions = [
{ label: "支付宝", value: "alipay" },
{ label: "银行卡", value: "bank" },
].filter(item => enabledChannels.includes(item.value));
const bankOptions = enabledBanks.map((item: string) => ({ label: item, value: item }));
const initialForm = Object.assign({ channel: "alipay", realName: "", account: "", bankName: "" }, setting || {});
if (!enabledChannels.includes(initialForm.channel)) {
initialForm.channel = enabledChannels[0] || "alipay";
}
await openFormDialog({
title: "提现设置",
wrapper: {
width: 560,
},
initialForm,
columns: {
channel: {
title: "提现渠道",
type: "dict-radio",
dict: dict({
data: channelOptions,
}),
form: {
col: { span: 24 },
rules: [{ required: true, message: "请选择提现渠道" }],
},
},
realName: {
title: "真实姓名",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入真实姓名" }],
},
},
account: {
title: "收款账号",
type: "text",
form: {
col: { span: 24 },
rules: [{ required: true, message: "请输入收款账号" }],
},
},
qrCode: {
title: "收款二维码",
type: "avatar-uploader",
form: {
col: { span: 24 },
helper: "上传支付宝收款二维码图片",
show: compute(({ form }) => form.channel !== "bank"),
component: {
valueType: "key",
uploader: {
type: "form",
action: "/basic/file/upload?token=" + userStore.getToken,
name: "file",
headers: {
Authorization: "Bearer " + userStore.getToken,
},
successHandle(res: any) {
return res;
},
},
buildUrl(key: string) {
return buildPrivateFileUrl(key);
},
},
},
},
bankName: {
title: "开户银行",
form: {
col: { span: 24 },
show: compute(({ form }) => form.channel === "bank"),
component: {
name: "a-select",
vModel: "value",
options: bankOptions,
showSearch: true,
placeholder: "请选择开户银行",
},
rules: [{ required: compute(({ form }) => form.channel === "bank"), message: "请输入开户银行" }],
},
},
},
async onSubmit(form: any) {
if (form.channel === "bank") {
form.qrCode = "";
}
await api.SaveWithdrawSetting(form);
notification.success({ message: "保存成功" });
},
});
}
async function openWithdrawDialog() {
await openFormDialog({
title: "申请提现",
wrapper: {
width: 520,
},
initialForm: {
amountYuan: null,
},
body: () =>
h("div", { class: "withdraw-dialog-tip" }, [
h("span", "提现前需要先设置提现账号。"),
h(
Button,
{
size: "small",
type: "link",
onClick: openWithdrawSetting,
},
() => "提现设置"
),
]),
columns: {
amountYuan: {
title: "提现金额",
form: {
col: { span: 24 },
component: {
name: "a-input-number",
vModel: "value",
min: 0,
precision: 2,
addonAfter: "元",
style: { width: "100%" },
},
rules: [{ required: true, message: "请输入提现金额" }],
},
},
},
async onSubmit(form: any) {
await applyWithdraw(form.amountYuan);
},
});
}
async function applyWithdraw(amountYuan: number) {
await api.ApplyWithdraw(util.amount.toCent(amountYuan || 0));
activeTab.value = "withdraw";
await loadWalletSummary();
await Promise.all([withdrawCrudExpose.doRefresh(), logsCrudExpose.doRefresh()]);
notification.success({ message: "提现申请已提交" });
}
async function refreshActiveList() {
if (activeTab.value === "withdraw") {
await withdrawCrudExpose.doRefresh();
} else if (activeTab.value === "logs") {
await logsCrudExpose.doRefresh();
}
}
async function refreshWalletPage() {
await loadWalletSummary();
await refreshActiveList();
loaded.value = true;
}
onMounted(refreshWalletPage);
onActivated(async () => {
if (!loaded.value) {
return;
}
await refreshWalletPage();
});
</script>
<style lang="less">
.page-wallet {
display: flex;
min-height: 0;
.fs-page-content {
display: flex;
min-height: 0;
}
.wallet-body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 20px;
background: hsl(var(--background-deep));
}
.wallet-summary-grid {
flex: none;
}
.wallet-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.summary-card,
.wallet-tabs {
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.82)), hsl(var(--card));
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease;
}
.summary-card {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 112px;
overflow: hidden;
padding: 22px;
}
.summary-card:hover {
border-color: rgba(52, 120, 246, 0.34);
box-shadow: 0 16px 38px rgba(15, 23, 42, 0.12);
transform: translateY(-2px);
}
.summary-card-main {
min-width: 0;
}
.summary-title {
margin-bottom: 10px;
color: hsl(var(--muted-foreground));
font-size: 15px;
}
.summary-value {
font-size: 30px;
font-weight: 700;
line-height: 36px;
}
.summary-value.available,
.summary-value.income {
color: #c58a35;
}
.summary-value.frozen {
color: #8b96a8;
}
.summary-value.withdraw {
color: #3478f6;
}
.summary-action-button {
flex: none;
box-shadow: 0 8px 18px rgba(52, 120, 246, 0.22);
}
.wallet-tabs {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 0 12px 12px;
background: rgba(255, 255, 255, 0.92);
}
.ant-tabs-content-holder,
.ant-tabs-content,
.ant-tabs-tabpane {
display: flex;
flex: 1;
min-height: 0;
}
.ant-tabs-tabpane {
flex-direction: column;
}
.wallet-crud {
flex: 1;
min-height: 0;
}
.wallet-tabs {
.fs-search {
display: none;
}
}
}
@media (max-width: 900px) {
.page-wallet {
.wallet-summary-grid {
grid-template-columns: 1fr;
}
.summary-card {
align-items: flex-start;
flex-direction: column;
}
}
}
.withdraw-dialog-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
margin-bottom: 12px;
border: 1px solid #d9e8ff;
border-radius: 6px;
background: #f5f9ff;
color: #315174;
}
</style>

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