91 Commits
Author SHA1 Message Date
Sakurasan aa0d87f132 fix(proxy): guard nil SupportModels before dereference in setModelCache 2026-08-30 03:16:50 +08:00
Sakurasan 963da99fd6 ci: add multi-arch dev image build script
- deploy/docker/build-dev.sh + make image-dev: linux/amd64+arm64 build & push,
  dev/dev-<sha>/dev-<date> tags, registry inline cache, auto create buildx builder
- PUSH=0 falls back to host-arch build with --load for local verification
- verified end-to-end (PUSH=0): container frontend build, CGO-free cross compile,
  upx pack 31.7MB->11.8MB, image smoke test 200
2026-08-30 02:11:11 +08:00
Sakurasan 33f5e7b71a rewrite: toast stacking with daisyui toast container
- module-level reactive toast list in composables/toast (setToast signature unchanged, 13 call sites untouched)
- multiple toasts stack simultaneously in daisyUI toast container, each auto-dismisses (3s) with close button
- TransitionGroup enter/leave animation (transform/opacity, honors reduced-motion)
- drop provide/inject queue that blocked consecutive toasts
2026-08-30 01:54:57 +08:00
Sakurasan 1e07c5903f redesign: site-wide UI/UX overhaul per web-interface-guidelines
- unified design system: focus-visible rings, reduced-motion, color-scheme,
  theme persistence, tightened emerald radius tokens (checkbox circle bug)
- redesigned all pages: Home/Login/Signup/404, dashboard shell, Overview hero
  (time-based gradient), list pages with empty states, form modals, detail pages
- role-scoped navigation: console menu vs admin console with cross-switch
  entries, Chinese route titles and breadcrumbs
- theme switcher (light/dark/auto) via composables/theme, home + dashboard
- state-driven dropdowns (touch-safe logout), menu divider pointer-events fix
- btn-primary unified to black-on-white / white-on-black per theme
- update REFACTOR_PLAN.md (increments 2-6)
2026-08-30 01:35:10 +08:00
Sakurasan ac0a6808ec refactor: frontend TS migration + Tailwind4/daisyUI5 unify + BUILDPLATFORM docker build
- migrate frontend to TypeScript (vue-tsc strict in build), upgrade all deps to latest (Vite 8, Tailwind 4, daisyUI 5, Pinia 4, vue-router 5)
- restructure frontend dirs (api/components/common/layouts/styles/types/views)
- drop Element Plus, add daisyUI TagInput; main CSS 490KB->163KB, entry JS 618KB->1.3KB
- rewrite Dockerfile(.cn): frontend/backend stages pinned to $BUILDPLATFORM, CGO_ENABLED=0 cross-compile, no QEMU in multi-arch builds; add .dockerignore
- local dev: Vite /api proxy + make dev targets; go:embed all:dist with .gitkeep so backend runs without prior frontend build
- fix latent bugs: Keys.vue users ref, Settings.vue undefined userStore, Login.vue Ref-as-error display, res.error misuse
- add REFACTOR_PLAN.md (phased refactor log)
2026-08-29 23:27:31 +08:00
Sakurasan 4a68ff6162 add claude proxy 2025-07-30 11:18:47 +08:00
Sakurasan 6b2d78fe56 fix:maxTokens 2025-05-04 02:52:04 +08:00
Sakurasan 9c604460b1 fix empty models 2025-04-22 02:52:56 +08:00
Sakurasan 8d34f8d6fe up 2025-04-22 02:16:28 +08:00
Sakurasan 6d1d0f3b6b up 2025-04-22 02:08:32 +08:00
Sakurasan 24529189d9 fix daily usage 2025-04-22 01:50:50 +08:00
Sakurasan 000162b1b1 fix usage 2025-04-22 01:06:03 +08:00
Sakurasan 6662ea5e04 fix record usage 2025-04-22 00:48:24 +08:00
Sakurasan 5789d50e9e update record usage 2025-04-21 23:59:30 +08:00
Sakurasan ca3d89751d fix stream usage 2025-04-21 22:48:28 +08:00
Sakurasan 2bc857cf88 add log 2025-04-21 21:59:13 +08:00
Sakurasan a9ff7e1c94 add log 2025-04-21 21:50:29 +08:00
Sakurasan 51d4651c6c up 2025-04-21 20:19:48 +08:00
Sakurasan e112f3af12 collect usage 2025-04-21 19:10:27 +08:00
Sakurasan 73e53c2333 add models task 2025-04-21 01:40:06 +08:00
Sakurasan 470e49b850 support fetch models 2025-04-21 01:30:17 +08:00
Sakurasan d426781e47 UP 2025-04-20 19:21:51 +08:00
Sakurasan b80f0759a5 fix select key 2025-04-20 18:33:59 +08:00
Sakurasan b83c6d9786 fix active key,suffix 2025-04-20 16:22:23 +08:00
Sakurasan fe0f2a7e88 key icon 2025-04-20 02:59:08 +08:00
Sakurasan ed42f3ded7 fix UI & token copy 2025-04-20 02:03:13 +08:00
Sakurasan 54246c542a up 2025-04-19 01:35:18 +08:00
Sakurasan ca305f4199 fetch model & add apitype 2025-04-19 01:21:28 +08:00
Sakurasan f8e539c9b4 add cli load 2025-04-18 19:05:23 +08:00
Sakurasan e0b531c578 fix passkey 2025-04-18 02:47:10 +08:00
Sakurasan afb51b0a94 remove 2025-04-17 23:56:28 +08:00
Sakurasan 3c4619b98c fix UI 2025-04-17 23:50:02 +08:00
Sakurasan efd38034ac push home 2025-04-17 23:35:03 +08:00
Sakurasan d4bf227cd8 fix UI 2025-04-17 23:15:43 +08:00
Sakurasan fca67cae40 fix UI 2025-04-17 23:09:57 +08:00
Sakurasan 20c3cda4a7 fix bugs 2025-04-17 21:02:00 +08:00
Sakurasan 9045130778 fix findkey 2025-04-17 20:42:08 +08:00
Sakurasan 5403ed0cba fix store bug 2025-04-17 20:15:41 +08:00
Sakurasan 87778326ea fix auth bug 2025-04-17 19:58:46 +08:00
Sakurasan a32a9543e2 fix bug 2025-04-17 18:57:19 +08:00
Sakurasan 9e0c33ad93 fix filepath 2025-04-17 18:38:34 +08:00
Sakurasan c8e565c6a4 fix db path 2025-04-17 02:37:41 +08:00
Sakurasan 9ed162ff0c up 2025-04-17 02:27:46 +08:00
Sakurasan b0d68ba338 fix bugs 2025-04-17 02:17:19 +08:00
Sakurasan d4cbc27a77 fix bug 2025-04-16 23:39:56 +08:00
Sakurasan 2360e7d2bf update baseurl 2025-04-16 19:04:22 +08:00
Sakurasan 81845e3f30 new logo 2025-04-16 18:19:26 +08:00
Sakurasan 15f17f4e8d frontend 2025-04-16 18:14:53 +08:00
Sakurasan ffb4496fd8 remove old frontend 2025-04-16 18:04:53 +08:00
Sakurasan 7bb8f8d012 Merge branch 'dev' into team 2025-04-16 18:02:58 +08:00
Sakurasan e7ffc9e8b9 reface to openteam 2025-04-16 18:01:27 +08:00
Sakurasan 1e00905dcb o3 2025-02-02 00:31:46 +08:00
Sakurasan bc223d6530 team api 2025-02-01 23:52:55 +08:00
Sakurasan 8b5c6feb5e add azure o1 2025-01-01 23:12:05 +08:00
Sakurasan 65d6d12972 refact:openteam 2025-01-01 23:10:01 +08:00
Sakurasan 1f5e1c221c fix: classify chatgpt-4o-latest 2024-12-20 03:49:30 +08:00
Sakurasan 24bac8e38d update: openai struct 2024-12-20 03:25:56 +08:00
Sakurasan fb5b1a55ae update openai struct 2024-12-20 02:42:49 +08:00
Sakurasan 07bcabc5d4 fix: model prefix 2024-12-18 15:58:43 +08:00
Sakurasan 8f6f3313f5 fix: custom_endpoint 2024-12-18 01:08:23 +08:00
Sakurasan f8b364a052 fix: custom_endpoint 2024-12-18 01:00:45 +08:00
Sakurasan 000fc2f616 new claude model 2024-12-12 16:25:35 +08:00
Sakurasan 97887035da update gemini model 2024-12-12 16:13:56 +08:00
Sakurasan fde0e46cbd update gemini model 2024-12-03 16:28:21 +08:00
Sakurasan 5181ba4552 up 2024-11-18 03:52:34 +08:00
Sakurasan 39a81688a9 up 2024-11-18 03:27:54 +08:00
Sakurasan 59063b40e1 up 2024-11-18 03:12:04 +08:00
Sakurasan b7190c6eb5 up 2024-11-18 02:56:48 +08:00
Sakurasan d5d87a9bb0 up 2024-11-18 02:37:15 +08:00
Sakurasan 653b68c644 up 2024-11-18 00:13:54 +08:00
Sakurasan 78e3be4575 update: allow cors 2024-11-17 22:32:14 +08:00
Sakurasan 4b60e17fec remove: action cache 2024-11-17 22:13:29 +08:00
Sakurasan 017bcfcb32 fix: bug 2024-11-17 21:39:13 +08:00
Sakurasan 7519846e20 fix:bug 2024-11-17 18:17:21 +08:00
Sakurasan c34e977da7 update 2024-11-01 03:15:31 +08:00
Sakurasan af4f8db0f1 update: claude version 2024-10-28 21:08:59 +08:00
Sakurasan ae3b72c0ff update: package version 2024-10-21 16:24:58 +08:00
Sakurasan 0c99913e91 update action 2024-10-21 15:47:59 +08:00
Sakurasan 955816dd04 up 2024-10-17 02:10:27 +08:00
Sakurasan c06dbde3da update realtime 2024-10-08 02:49:08 +08:00
Sakurasan 1afaf3b385 up 2024-10-06 02:08:27 +08:00
Sakurasan 2a0c18e51b up 2024-10-06 01:26:02 +08:00
Sakurasan 05c16df6d2 update go version 2024-10-06 00:56:27 +08:00
Sakurasan 236dffa256 add realtime proxy 2024-10-06 00:44:48 +08:00
Sakurasan eef24913e0 fix bug 2024-09-14 00:31:07 +08:00
Sakurasan 7979102880 fix bug 2024-09-13 23:13:31 +08:00
Sakurasan 7fd82b43f4 refact & update new model 2024-09-13 21:32:10 +08:00
Sakurasan c11824f5aa support gpt-4o-mini 2024-07-19 12:10:49 +08:00
Sakurasan 1284ad9dec up 2024-06-12 00:50:01 +08:00
Sakurasan b101da7677 log bing err 2024-06-04 01:04:06 +08:00
Sakurasan afbc8e008b bing search 2024-06-04 00:57:37 +08:00
207 changed files with 18837 additions and 4055 deletions
+21
View File
@@ -0,0 +1,21 @@
# 版本管理
.git
.gitignore
# 前端依赖与本地构建产物(镜像内由 pnpm 重新安装、重新构建)
**/node_modules
frontend/dist
web
# Go 构建产物
bin
cmd/openteam/dist
# 文档与 CI
doc
.envci.yaml
tag.yaml
# 日志与数据库
*.log
*.db
+6 -13
View File
@@ -1,28 +1,25 @@
name: Docker Image CI
#触发器设置
on:
push:
branches:
- main
- dev
#项目任务,任务之间可以并行调度
jobs:
build:
#选择云端运行的环境
runs-on: ubuntu-latest
steps:
#uses代表使用一个模块,此处使用的是checkout模块,将github项目文件导入到当前环境中
- uses: actions/checkout@v3
#使用with跟在后面来为前面的模块输入参数
with:
submodules: 'true'
- name: Get current date
id: date
run: echo "::set-output name=today::$(date +'%Y%m%d')"
run: echo "today=$(date +'%Y%m%d')" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
@@ -31,24 +28,20 @@ jobs:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
#这里用到了github的secrets功能,避免账户和密码随仓库泄露
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
#导入这个模块来完成自动编译和推送
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile
push: true
#在这里通过加入需要编译的平台和前面配好的QEMU,buildx来达到多平台编译
platforms: linux/amd64,linux/arm64
#指定用户/仓库名
tags: |
${{ github.repository }}:${{ steps.date.outputs.today }}
${{ github.repository }}:${{ contains(github.ref,'main') && 'latest' || github.ref_name }}
- name: Docker Hub Description
#这里是通过md文件自动生成dockerhub描述的模块,也可以不需要
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
+8 -15
View File
@@ -1,27 +1,24 @@
name: Docker Image CI
#触发器设置
name: Docker Image CI Tag
on:
push:
tags:
- 'v*'
#项目任务,任务之间可以并行调度
jobs:
build:
#选择云端运行的环境
runs-on: ubuntu-latest
steps:
#uses代表使用一个模块,此处使用的是checkout模块,将github项目文件导入到当前环境中
- uses: actions/checkout@v3
#使用with跟在后面来为前面的模块输入参数
with:
submodules: 'true'
- name: Get version # 获取 Tag Version
- name: Get version
id: vars
run: echo ::set-output name=tag::${GITHUB_REF#refs/*/}
run: echo "tag=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
@@ -30,24 +27,20 @@ jobs:
- name: Login to DockerHub
uses: docker/login-action@v2
with:
#这里用到了github的secrets功能,避免账户和密码随仓库泄露
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
#导入这个模块来完成自动编译和推送
uses: docker/build-push-action@v3
uses: docker/build-push-action@v4
with:
context: .
file: ./docker/Dockerfile
push: true
#在这里通过加入需要编译的平台和前面配好的QEMU,buildx来达到多平台编译
platforms: linux/amd64,linux/arm64
#指定用户/仓库名
tags: |
${{ github.repository }}:${{ steps.vars.outputs.tag }}
${{ github.repository }}:latest
- name: Docker Hub Description
#这里是通过md文件自动生成dockerhub描述的模块,也可以不需要
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
+9 -1
View File
@@ -1,5 +1,13 @@
bin/
test/
demo/
*.log
*.db
demo/
.env
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
cmd/openteam/dist/*
!cmd/openteam/dist/.gitkeep
# 误生成的目录(仅含 dist/node_modules)
web/
+27 -10
View File
@@ -1,8 +1,13 @@
# opencatd-open
# ~~opencatd-open~~ [OpenTeam](https://github.com/mirrors2/opencatd-open)
本项目即将更名,后续请关注 👉🏻 https://github.com/mirrors2/openteam
<a title="Docker Image CI" target="_blank" href="https://github.com/mirrors2/opencatd-open/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/mirrors2/opencatd-open/ci.yaml?label=Actions&logo=github&style=flat-square"></a>
<a title="Docker Pulls" target="_blank" href="https://hub.docker.com/r/mirrors2/opencatd-open"><img src="https://img.shields.io/docker/pulls/mirrors2/opencatd-open.svg?logo=docker&label=docker&style=flat-square"></a>
[![Telegram group](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamChat&query=count&color=2CA5E0&label=Telegram%20Group&logo=telegram&cacheSeconds=3600&style=flat-square)](https://t.me/OpenTeamChat) [![Telegram channel](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.swo.moe%2Fstats%2Ftelegram%2FOpenTeamLLM&query=count&color=2CA5E0&label=Telegram%20Channel&logo=telegram&cacheSeconds=3600&style=flat-square)](https://t.me/OpenTeamLLM)
opencatd-open is an open-source, team-shared service for ChatGPT API that can be safely shared with others for API usage.
---
@@ -14,11 +19,11 @@ OpenCat for Team的开源实现
## Extra Support:
| 任务 | 完成情况 |
| --- | --- |
|[Azure OpenAI](./doc/azure.md) | ✅|
|[Claude](./doc/azure.md) | ✅|
|[Gemini](./doc/gemini.md) | ✅|
| 🎯 | 🚧 |Extra Provider|
| --- | --- | --- |
|[OpenAI](./doc/azure.md) | ✅|Azure, Github Marketplace|
|[Claude](./doc/azure.md) | ✅|VertexAI|
|[Gemini](./doc/gemini.md) | ✅||
| ... | ... |
@@ -72,14 +77,26 @@ wget https://github.com/mirrors2/opencatd-open/raw/main/docker/docker-compose.ym
- [Fly.io](https://fly.io/)
- 或者其他
修改openai的endpoint地址?使用任意上游地址(套娃代理)
- 设置环境变量 openai_endpoint
使用Nginx + Docker部署
- [使用Nginx + Docker部署](./doc/deploy.md)
pandora for team
- [pandora for team](./doc/pandora.md)
如何自定义HOST地址? (仅OpenAI)
- 需修改环境变量,优先级递增(全局配置谨慎修改)
- Cloudflare AI Gateway地址 `AIGateWay_Endpoint=https://gateway.ai.cloudflare.com/v1/123456789/xxxx/openai/chat/completions`
- 自定义的endpoint `OpenAI_Endpoint=https://your.domain/v1/chat/completions`
设置主页跳转地址?
- 修改环境变量 `CUSTOM_REDIRECT=https://your.domain`
## 获取更多信息
[![TG](https://telegram.org/img/favicon.ico)](https://t.me/OpenTeamLLM)
## 赞助
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20A%20Coffee-FFDD55?style=flat-square&logo=buy-me-a-coffee&logoColor=black)](https://www.buymeacoffee.com/littlecjun)
# License
[GNU General Public License v3.0](License)
[![GitHub License](https://img.shields.io/github/license/mirrors2/opencatd-open.svg?logo=github&style=flat-square)](https://github.com/mirrors2/opencatd-open/blob/main/License)
+302
View File
@@ -0,0 +1,302 @@
# opencatd-open 重构计划
> 本文档是重构过程的唯一进度记录,每完成一个阶段立即更新「阶段状态」与「执行记录」。
> 前后端同仓库,本期(第一期)只重构前端 + 前端相关的构建脚本;后端代码不在本期范围。
- 计划创建时间:2026-08-29
- 当前分支:`team`(按约定不提交,所有改动留在工作区,由维护者回来后审查)
- 项目根目录:`/Users/cjun/Code/Go/src/opencatd-open`
- 前端目录:`frontend/`(构建产物 `dist/` 由 Go 通过 `//go:embed dist/*` 嵌入 `cmd/openteam`)
## 一、背景与现状
| 项 | 现状 | 问题 |
| --- | --- | --- |
| 技术栈 | Vue 3.5 + Vite 6 + JavaScript,无 TS | 依赖偏旧;无类型约束 |
| UI 库 | Element Plus 与 daisyUI/Tailwind 3 **两套并存** | 9 个视图使用 `el-*` 组件,风格割裂、包体冗余 |
| 目录结构 | components/views 仅按 dashboard 简单分层 | 组件分类不规范,无 api/layouts/types 分层 |
| 构建脚本 | Dockerfile 三阶段,node 阶段未指定 `$BUILDPLATFORM` | 多架构构建时前端被 QEMU 模拟重复编译,极慢 |
| 依赖声明 | pinia、@iconify/vue 误放 devDependencies | 分类错误 |
| Dockerfile 杂项 | 存在无效的 `CMD ["go mod tidy","go mod download"]`;node:20 基础镜像 | 需清理/升级 |
| 杂项 | 根目录 `web/`(仅 dist + node_modules,未跟踪) | 疑似误构建产物,暂不动,仅记录 |
## 二、已确认的决策(2026-08-29,维护者离开前确认)
1. **迁移到 TypeScript**(全量,含 vue-tsc 类型检查)。
2. **UI 统一到 Tailwind/daisyUI**,移除 Element Plus,`el-*` 组件全部重写;接受外观变化。
3. **不提交**:所有改动留在工作区,按阶段推进,不做 git commit。
其余由执行者自行决定的默认约定:
- 依赖一律升到**当前最新稳定版**(含 Tailwind 4 / daisyUI 5 / Vite 7+ / Pinia 3 等大版本跨越)。
- Element Plus 在被移除前不再投入升级成本(Phase 4 直接删除)。
- 每阶段验收标准:`pnpm build`(后期含 `vue-tsc`)通过 + 页面路由/交互逻辑与重构前等价。
- 计划文档放项目根目录 `REFACTOR_PLAN.md`。
## 三、阶段计划
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| Phase 0 | 创建本计划文档 | ✅ 完成 |
| Phase 1 | 依赖全部升级到最新版(Tailwind 4 / daisyUI 5 迁移、pinia 归位 dependencies) | ✅ 完成 |
| Phase 2 | TypeScript 迁移(tsconfig、vue-tsc、全量 .ts/.vue 改写) | ✅ 完成 |
| Phase 3 | 目录结构规范化(api / components / composables / layouts / types / views 分层) | ✅ 完成 |
| Phase 4 | 移除 Element Plus,统一 Tailwind/daisyUI 重写全部组件 | ✅ 完成 |
| Phase 5 | Docker / makefile 构建脚本更新(前端 `$BUILDPLATFORM` 单次编译) | ✅ 完成 |
| Phase 6 | 最终验证(前端 build + Go embed 编译),收尾文档 | ✅ 完成 |
## 四、各阶段详细方案
### Phase 1 — 依赖升级
- `vite`、`@vitejs/plugin-vue`、`@vitejs/plugin-basic-ssl`、`vue`、`vue-router`、`axios`、`lucide-vue-next`、`qrcode.vue`、`@simplewebauthn/browser`、`@iconify/vue`、`@iconify-json/*` → 最新。
- `pinia` → v3 并移入 dependencies;`@iconify/vue` 移入 dependencies。
- Tailwind 3 → 4:改用 `@tailwindcss/vite` 插件,删除 `postcss.config.js`/`autoprefixer`/`tailwind.config.js`,`style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"` + `@theme` 定义原有 daisyUI 主题集合(light/dark/cupcake/emerald/pastel)。
- `daisyui` → v5。
- element-plus 保持现状(Phase 4 删除)。
- 验收:`pnpm build` 通过。
### Phase 2 — TypeScript 迁移
- 新增 `tsconfig.json`(bundler 解析策略 + `@` 别名路径映射)、`src/vite-env.d.ts`、`env.d.ts`(`import.meta.env` 类型)。
- `vite.config.js` → `vite.config.ts`;`src/**/*.js`(router/stores/utils/main)→ `.ts`。
- 全部 `.vue` 改 `<script setup lang="ts">`,props/emits/响应式数据补类型。
- `build` 脚本加 `vue-tsc --noEmit` 类型检查。
- 验收:`pnpm build`(含 vue-tsc)通过。
### Phase 3 — 目录结构规范化
```
src/
├── api/ # axios 实例 + 各业务接口封装(由 utils/request.js 演进)
├── assets/ # 图片/图标(不变)
├── components/
│ ├── common/ # 通用组件(Toast、Pagination、QRCodeCard、LineSegmentFlow)
│ └── dashboard/ # 仪表盘布局组件(Sidebar、BreadcrumbHeader)
├── composables/ # 组合式函数(useToast 等从 provide/inject 演进)
├── layouts/ # 布局(DashboardLayout 等,如适用)
├── router/ # 路由
├── stores/ # pinia stores
├── styles/ # 全局样式
├── types/ # 共享 TS 类型(API 响应、业务实体)
├── utils/ # 纯工具函数(格式化日期等)
└── views/
├── auth/ # Login、Signup
├── error/ # 404
└── dashboard/ # Overview、Keys、Tokens、Users、Settings、Profile 等
```
- vite.config 的 manualChunks 别名同步更新。
- 验收:`pnpm build` 通过,无悬空 import。
### Phase 4 — UI 统一到 Tailwind/daisyUI
- 移除 `element-plus` 依赖与 `main.ts` 全局注册。
- 重写以下 9 个视图中的 `el-*` 组件(table/dialog/form/select/input/switch/message 等用 daisyUI 组件类 + 自实现交互):
Login、Signup、dashboard/{UserView、Settings、KeyView、Profile、TokenNew、KeyNew、UserNew}。
- 顺带规范既有自研组件(Toast、Pagination 等)使用 daisyUI 类。
- 保留既有业务逻辑、字段、接口调用不变。
- 验收:`pnpm build` 通过;`grep el-`/`element-plus` 无残留。
### Phase 5 — Docker / makefile 构建脚本
- `deploy/docker/Dockerfile`:
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend`(多架构下只原生编译一次)。
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH` 交叉编译(go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO);如遇阻塞则后端阶段回退为按目标平台编译,仅保留前端优化。
- 删除无效 `CMD` 行;runner 阶段瘦身。
- `makefile`:web 目标改用 pnpm(不强制全局安装 pnpm),构建产物位置与 embed 路径核对。
- 同步更新 `Dockerfile.cn`(国内镜像版)保持一致。
- 验收:`docker build` 本地单架构通过(多架构如环境不允许则用 `--platform` 模拟检查语法与目标参数)。
### Phase 6 — 最终验证与收尾
- `frontend: pnpm build`(含 vue-tsc)。
- 把 `frontend/dist` 放入 `cmd/openteam/dist` 后执行 `go build ./cmd/openteam`,确认 embed 成功。
- 更新本文档所有阶段状态与执行记录,列出遗留问题(如 `web/` 目录处置、外观回归点)。
## 五、执行记录(每阶段完成后追加)
### Phase 0 — 计划文档创建(2026-08-29)
- 已确认三项决策:TS 迁移 / 统一 Tailwind-daisyUI / 不提交。
- 摸底结论:前端约 4700 行 Vue;Element Plus 用于 9 个视图;pinia、@iconify/vue 在 devDependencies;`glebarez/sqlite` 为纯 Go 驱动(Docker 交叉编译可行);本地 Node v25.2.1 / pnpm 10.25.0。
- 发现根目录未跟踪的 `web/` 目录仅含 dist 与 node_modules,疑似误产物,本期不动。
### Phase 1 — 依赖升级(2026-08-29)✅
- 升级结果(均为当前最新稳定版):
- dependencies:vue 3.5.42、vue-router **5.3.0**(大版本 4→5)、pinia **4.0.3**(大版本 2→4,并移入 dependencies)、axios 1.20.0、qrcode.vue 3.10.0、@simplewebauthn/browser 13.3.0、@iconify/vue 5.0.1(移入 dependencies)、**@lucide/vue 1.37.0**(替代已废弃的 lucide-vue-next)。
- devDependencies:vite **8.2.2**(大版本 6→8,构建器为 rolldown)、@vitejs/plugin-vue 6.0.8、@vitejs/plugin-basic-ssl 2.3.0、tailwindcss **4.3.3**(大版本 3→4)、@tailwindcss/vite 4.3.3、daisyui **5.7.22**(大版本 4→5)、@iconify-json/* 升级。
- 移除:autoprefixer、postcss、tailwind.config.js、postcss.config.js(Tailwind 4 改为 CSS-first 配置)。
- 迁移要点:
- `style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"`,主题集合保持 light(默认)/dark/cupcake/emerald/pastel。
- `vite.config.js` 加入 `@tailwindcss/vite` 插件;`__dirname` 改为 `import.meta.dirname`(消除 Vite 8 警告)。
- package.json 改名为 `opencatd-open-frontend`;新增 `pnpm.onlyBuiltDependencies: [esbuild, vue-demi]` 放行构建脚本。
- 代码适配:新版 Lucide 移除品牌图标,`Profile.vue` 的 `<Github>` 图标改为项目内已有的 `<img src="/assets/github.svg">`(与 Keys/KeyNew/KeyView 用法一致)。
- 验收:`pnpm build` 通过(4.8s,rolldown 构建)。
- 备注:element-plus 2.9.7 保持旧版未升级(Phase 4 将整体移除);当前 components chunk 406KB 主要来自 Element Plus,Phase 4 后预计大幅缩小。
### Phase 2 — TypeScript 迁移(2026-08-29)✅
- 工具链:typescript **6.0.3** + vue-tsc 3.3.11 + @types/node 26.4.0。
- 注:最初装了 typescript 7.0.2(tsgo 原生版),但 vue-tsc 依赖 `typescript/lib/tsc` 导出而 TS7 已移除,故回落到 6.x(当前最新 JS 版)。
- tsconfig:strict 模式、bundler 解析、`@` 别名(TS6 弃用 baseUrl,改用相对 paths)、types 含 vite/client + node。
- 全量转换:`vite.config.ts`、`src/main.ts`、router/stores/utils 共 9 个 JS→TS;19 个 `.vue` 全部 `<script setup lang="ts">`。
- 类型设计:
- `src/types/index.ts`:UserInfo / TokenInfo / ApiKey / PasskeyInfo 及各请求 Payload 类型(宽松可选字段 + 索引签名兼容后端松散返回)。
- `src/composables/toast.ts`:类型安全的 provide/inject(InjectionKey),替代各视图裸 `inject('toast')`。
- `vue-router` RouteMeta 模块扩展(title/icon/showInSidebar/requiresAuth 等);MenuItem 为可辨识联合。
- package.json:新增 `typecheck` 脚本;`build` 改为 `vue-tsc --noEmit && vite build`。
- 顺带修复的存量 bug(均记录在案):
1. `Keys.vue` toggleSelectAll 引用了不存在的 `users`(应为 `keys`)——运行时全选会抛错。
2. `Settings.vue` updateUser 引用了未定义的 `userStore`/`userId`(复制粘贴残留),提交表单必抛错——改为经 `authStore.updateProfile` 更新当前用户。
3. store 的 catch 中 `throw error` 抛出的是 Ref 对象,`Login.vue` 会把 Ref 显示为 `[object Object]`——加了 `errMsg()` 取值辅助。
4. `KeyNew.vue` 模板绑定了不存在的 `togglePasswordVisibility`(点击报 TypeError)——移除死绑定。
5. `User/Keys/UserNew/KeyNew` 里 `res.error` 恒为 undefined(AxiosResponse 无此字段)——改为 `res.data?.error`。
6. `TokenNew.vue` 初始 `user_id: user.user_id`(ComputedRef 上取值恒 undefined)——改为 `user.value?.user_id`。
7. `Login.vue` rember 记住密码存入布尔被 localStorage 转字符串('true'),统一 `String()` 存储。
- 验收:`pnpm build`(含 vue-tsc 严格检查)通过。
### Phase 3 — 目录结构规范化(2026-08-29)✅
- 最终结构:
- `src/api/client.ts` ← utils/request.ts(axios 实例与拦截器;业务接口调用仍保留在 stores 中,作为轻量 API 层,避免无谓 churn,后续可按需下沉到 api/ 各模块)
- `src/components/common/` ← Toast / Pagination / QRCodeCard / LineSegmentFlow
- `src/components/dashboard/` 保持(Sidebar / BreadcrumbHeader)
- `src/layouts/DashboardLayout.vue` ← views/DashBoard.vue(本质是布局组件,归位 layouts 层)
- `src/styles/main.css` ← src/style.css
- `src/views/auth/` ← Login / Signup;`src/views/error/NotFound.vue` ← views/404.vue
- `src/views/Home.vue`、`src/views/dashboard/*` 保持
- 全部相对路径 import 统一为 `@/` 别名;模板内相对资源路径(`../assets/...`)统一为 `@/assets/...`。
- vite.config.ts 的 manualChunks 分包规则按新目录核对(components / views-dashboard / stores 三组仍有效)。
- 验收:`pnpm build` 通过,无悬空 import。
### Phase 4 — UI 统一到 Tailwind/daisyUI(2026-08-29)✅
- 摸底修正:Element Plus 实际仅在 3 处使用(main.ts 全局注册 + KeyNew/KeyView 的 `el-input-tag`),其余视图本就以 daisyUI 为主。
- 变更:
- `main.ts` 移除 Element Plus 注册与样式;`pnpm remove element-plus`。
- 新增 `src/components/common/TagInput.vue`(daisyUI 风格,Enter 添加/逐个删除/Backspace 删末尾/可清空),替换 KeyNew/KeyView 中的 `el-input-tag`。
- 全库 grep 确认无 `element-plus` / `el-*` 组件残留。
- 收益(构建产物对比):
- 主 CSS:489.6KB → 163.4KB(-67%,主要为 Element Plus 全量样式)
- components JS:405.7KB → 194.7KB(-52%)
- 入口 index JS:617.8KB → 1.3KB(Element Plus 运行时原本在入口包)
- 验收:`pnpm build`(含类型检查)通过。
### Phase 5 — Docker / makefile 构建脚本(2026-08-29)✅
- `deploy/docker/Dockerfile` 重写:
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine`:多架构构建时前端只在构建机原生平台编译**一次**(原先会被 QEMU 模拟在每个目标平台各跑一遍)。
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH` 交叉编译(glebarez/sqlite 纯 Go 驱动,已验证可行),全程零 QEMU。
- 删除了无效且写法错误的 `CMD ["go mod tidy","go mod download"]`;pnpm 固定 `@10.25.0`;`pnpm install --frozen-lockfile`;runner 阶段补充 `ca-certificates`;修正 `LABEL anther→author`;移除不再需要的 `cmake`。
- `Dockerfile.cn`(国内源版)同步更新。
- 新增根目录 `.dockerignore`(原先缺失:node_modules、dist、.git、web/ 等全部会被拷进构建上下文)。
- `makefile`:
- `web` 目标:pnpm 按需安装 + `--frozen-lockfile`,产物干净替换到 `cmd/openteam/dist`(原 `mv dist ../cmd/openteam/` 在目标已存在时会错误嵌套一层)。
- `build` 目标:加 `CGO_ENABLED=0`;`upx` 改为可选(本机未装时跳过,不再中断)。
- package.json 增加 `"packageManager": "pnpm@10.25.0"`:新版 pnpm 默认启用供应链策略(拒装 24h 内发布的包)并不再读取 package.json 的 `pnpm` 字段,钉住版本保证容器内外行为一致、可重现。
- 验证:
- `docker build --target frontend` 通过,dist 产物完整。
- `docker buildx build --platform linux/amd64,linux/arm64`(xbuilder)通过;日志确认 frontend 仅在原生平台执行一次,arm64 后端为交叉编译,无 QEMU。
- 本机 `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build` 产出静态 ELF(含嵌入前端)。
- `make web`、`make build` 通过。
### Phase 6 — 最终验证与收尾(2026-08-29)✅
- `pnpm build`(vue-tsc 严格类型检查 + vite 构建)通过。
- `.gitignore` 补充 `cmd/openteam/dist/`(构建产物)与 `web/`(误生成目录,未跟踪)。
- 遗留事项(供维护者决策,均不影响本期交付):
1. `frontend/src/utils/format-date.ts`、`Overview.vue` 内部仍各自实现了一份 `formatDateTime`,可合并到 utils 统一导入(行为无差异,纯清理)。
2. CI workflow(`.github/workflows/`)引用的 `./docker/Dockerfile` 路径早已失效(现为 `deploy/docker/Dockerfile`),且 checkout 的还是 @v3 旧 action——后端/CI 不在本期范围,未改动。
3. 依赖升级中未逐项验证运行时 UI 细节(如 daisyUI 4→5 的个别类名行为差异、vue-router 4→5),建议维护者回来后 `make web && make build` 或 `pnpm dev` 过一遍登录/令牌/密钥/用户管理页面。
4. `web/` 目录(仅 dist + node_modules)疑似误构建产物,已加入 .gitignore,确认无用后可删除。
## 六、后续增量(2026-08-29,维护者返程前追加)
### 增量 1 — 本地开发体验优化(前后端分离联调)✅
背景:前端构建产物经 `//go:embed` 嵌入 Go 二进制,本地每改一次前端都要 `make web + make build`,且没有热更新。
- `frontend/vite.config.ts`:dev server 增加 `server.proxy`,`/api` 代理到本地 Go 后端(默认 `http://localhost:8080`,`VITE_DEV_API_TARGET` 可覆盖)。开发时前端跑在 Vite 上(HMR),接口走代理到后端,无跨域问题。
- dev server 默认改为 HTTP(localhost 属浏览器安全上下文,clipboard/Passkey 均可用);需要自签名 HTTPS 时设 `VITE_DEV_HTTPS=true` 恢复 basicSsl。
- `cmd/openteam/main.go`:`//go:embed dist/*` → `//go:embed all:dist`,配合 `cmd/openteam/dist/.gitkeep` 占位(已跟踪),dist 没有真实产物时 `go run ./cmd/openteam` 也能编译启动——克隆后可直接起后端联调,不必先构建前端。`make web` 移入产物后会补回 `.gitkeep`。
- `makefile` 新增:
- `make dev-backend`:`PORT=8080 go run ./cmd/openteam`(数据库 `./db/openteam.db`)
- `make dev-frontend`:`cd frontend && pnpm dev`(5173)
- `make dev`:`$(MAKE) -j2` 并行启动两者,Ctrl+C 一起退出
- `frontend/README.md` 补充开发文档(启动方式、环境变量表)。
- 验证:后端 8080 + Vite 5173 同时运行,`curl http://localhost:5173/` 返回 SPA 页面;经 5173 代理的 `POST /api/auth/login` 返回后端真实校验 JSON、`GET /api/auth/passkey/begin` 返回 200,代理链路完整。
- 说明:这是构建基础设施改动,触及 `cmd/openteam/main.go` 一行 embed 指令;后端业务逻辑零改动。生产构建流程不受影响(Docker 内 dist 由前端阶段提供)。
### 增量 2 — 全站 UI/UX 重设计(web-design-guidelines)✅
依据:Vercel Web Interface Guidelines(`~/.agents/skills/web-design-guidelines` 拉取的最新规则集)。先对全部页面截图建立基线,再逐页重写模板;**业务逻辑、接口调用、路由全部未动**。
- 设计系统基础(`styles/main.css` + `index.html`):
- 深浅色 `color-scheme` 跟随主题;`<meta name="theme-color">`;内联脚本恢复上次主题(不再每次刷新回 emerald)。
- 全局 `:focus-visible` 焦点环、`touch-action: manipulation`、`prefers-reduced-motion` 全局降级、`.modal-box` 防滚动穿透。
- 覆写 emerald 主题圆角令牌(默认 selector 圆角 1rem 使小复选框渲染成正圆的 bug)。
- 壳层:侧栏启用 daisyUI 5 `menu-active` 高亮 + sticky;顶栏重排(侧栏开关带 aria-pressed、主题切换持久化 localStorage、用户菜单头像+用户名);主区限宽 max-w-6xl。
- 页面模式统一:`面包屑 + 大标题 → 描述 + 主操作 → 卡片内容`;列表页统一工具栏/表格/空状态/分页(空数据隐藏分页);表单统一分区标题、label/控件绑定、required 标记、示例占位符、提交按钮 spinner。
- 主要变更:
- Overview:深色渐变横幅改为品牌 primary→secondary 渐变;信息卡由 badge 滥用改为 dl 键值布局(仅角色/状态保留 badge);时间 tabular-nums。
- Tokens/Keys/Users 列表:空状态(图标+文案+CTA)、行操作按钮 aria-label、状态筛选改为带计数的下拉、移除两个死的 Filter 输入框、批量操作菜单重排。
- 弹窗表单(TokenNew/KeyNew/UserNew):去掉 min-h-screen 包裹,适配弹窗容器。
- 详情页(KeyView/UserView):头像+徽章头部、分区表单、tokens 表格;加载态明确。
- Profile/Settings:分区卡片(基本信息/密码/Passkeys/关联账号),提交加 spinner。
- Login/Signup:实体主按钮、内联错误 alert(role=alert)、autocomplete(username/current-password/new-password)、死链"Forgot password?"移除、密码不一致内联提示。
- Home:导航栏 Star 徽标 + Open Dashboard 主 CTA,hero 文案重写,endpoint 复制组加 label,图片补尺寸。
- 404:品牌化布局 + 具体文案("doesn't exist or has been moved")。
- Toast:`aria-live="polite"` + toast-top/end 定位;Pagination:join 样式 + Showing x–y of z + tabular-nums。
- 顺带修复:DashboardLayout `if (!userInfo)` 恒真导致刷新后头部用户名丢失(改为 `!authStore.user`)。
- 验证:`pnpm build`(vue-tsc 严格检查)通过;本地起前后端后逐页浏览器截图回归(Users/Overview/Tokens/新建弹窗/Keys 空状态/Profile/404/Home/Login)视觉与交互正常;复选框圆角修复经计算样式确认(16px→8px)。
- 已知取舍:破坏性操作沿用原生 `confirm()`(满足"需确认"要求,后续可换主题化 modal);列表筛选/分页尚未同步到 URL query(指南建议,列为后续项);"Forgot password?" 为死链已移除。
- 补充(维护者反馈):Overview 横幅恢复**随时间段变化的配色**(原版行为),四段式与新版式协调——深夜 slate-900→indigo-950、早晨 sky-600→amber-400、白天 blue-600→cyan-400、夜晚 slate-900→indigo-950;白色文字置于渐变左侧深色端保证对比度。顺带修复原版问候语 bug:0-6 点原显示"早上好",现对齐四段为"夜深了"。
### 增量 3 — 主题切换(浅色 / 深色 / 自动)✅
- 新增 `src/composables/theme.ts`:偏好三态 `light | dark | auto`(浅色映射品牌 emerald 主题),localStorage 持久化(key `theme`),auto 模式监听 `prefers-color-scheme` 实时跟随系统切换;模块级单例状态,主页与仪表盘共享。
- `index.html` 内联脚本同步三态逻辑(含 auto 解析),首屏不闪烁。
- 主页导航栏与仪表盘顶栏均提供主题下拉(太阳/月亮/显示器图标随当前偏好变化,当前项 `menu-active` 高亮);仪表盘原先的二态硬切换按钮升级为同一三选下拉,避免两套主题逻辑互相覆盖。
- 默认偏好为 `auto`(首次访问跟随系统);旧存储值 `emerald` 不再有效,自动回落 auto。
- 验证:`pnpm build` 通过;浏览器实测浅色↔深色↔自动即时生效、`data-theme` 与 localStorage 值正确、刷新后保持、auto 按系统深色解析为 dark;主页与仪表盘主题共享一致。
### 增量 4 — 导航栏按角色分区重设计(参考维护者提供的双栏设计稿)✅
- 信息架构对齐设计稿:导航拆为**控制台**(所有用户:仪表盘 / API 密钥 / 账户设置)与**管理后台**(role ≥ 10:用户管理 / 渠道管理)两个区域,按当前路由区域(`/dashboard/manager/*`)切换显示;底部互切入口——控制台区显示「管理后台 →」(仅管理员),后台区显示「← 返回控制台」。
- `router_menu.ts` 重写:弃用递归菜单生成器,改为显式两套菜单数组;路由 meta 标题中文化(仪表盘 / API 密钥 / 用户管理 / 渠道管理 / 账户设置等)。
- `BreadcrumbHeader` 增加路由路径→中文标题映射,面包屑与页面标题随之中文化(此前按英文路径段拼接)。
- 与设计稿的差异(页面对应关系):API 密钥→个人 Tokens 页;渠道管理→上游 Provider Keys 页;设计稿中的用量明细/用量统计/模型定价/系统配置暂无对应页面,未做死链,留作后续功能。
- 验证:`pnpm build` 通过;浏览器实测 admin 视角(控制台菜单 + 底部管理后台入口 → 后台菜单 + 返回控制台,高亮正确)与普通用户视角(注册 member 账号实测:仅控制台菜单,管理后台入口数量为 0);中文面包屑生效。
- 补充(维护者反馈):「API 密钥」菜单项与页面标题改名为 **API Keys**;面包屑重设计——废弃路径段拼接,改为按路由名显式定义层级:顶级页面(仪表盘/API Keys/账户设置)不显示面包屑仅保留标题,管理后台列表页显示「管理后台」一级,详情页显示「管理后台 / 列表页」两级(末级为当前页标题),标题统一取自定义 title 或路由 meta.title。
- 补充(交互收尾):移动端抽屉在路由切换后自动收起(router.afterEach);顶栏主题/个人下拉为焦点展开型,选择后主动移除焦点收起菜单,个人菜单按钮补 aria-expanded 语义。注:DashboardLayout 模板含维护者手动增强的个人菜单(身份信息头 + 管理后台入口),脚本已按模板对齐(handleMenuAction / isAdminUser)。
### 增量 5 — 个人下拉修复与触屏可用性(维护者反馈)✅
- 问题 1(内容不对):身份信息行的 `email || '@username'` 回退会显示伪社交句柄,且已在管理后台时仍显示「管理后台」入口。修复:第二行仅在存在邮箱时显示邮箱、有显示名时显示 `@用户名`、否则不显示;名称行追加角色徽章(Root/Admin/User);「管理后台」入口在后台区域隐藏(`isAdminUser && !isAdminArea`)。
- 问题 2(手机点退出无反应):daisyUI CSS 下拉依赖焦点展开,触屏点击菜单项时按钮失焦、下拉先于 click 关闭导致点击落空。修复:主题与个人下拉改为**状态驱动**(`dropdown-open` class + `v-if` 遮罩点击关闭 + Escape 关闭 + 路由切换关闭),互斥打开;主页主题下拉同步修复。
- 验证:`pnpm build` 通过;桌面实测下拉内容(root + Root 徽章 + @admin,菜单项完整);移动端 390px 实测点「退出登录」成功跳转 /login 且 token 清除;随后已恢复 admin 会话与桌面视口。
- 补充(维护者反馈):个人下拉内的 `<hr>` 分割线被 daisyUI menu 的通用子元素样式选中(cursor: pointer + hover 背景,可点击),已为其 li 加 `pointer-events-none select-none`,实测恢复默认光标且不可交互。
### 增量 6 — 主按钮统一黑白配色(维护者反馈)✅
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。
- 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。
- 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。
### 增量 7 — Toast 重写:多实例堆叠(维护者反馈)✅
- 背景:原实现为串行队列(processQueue 一次展示一条),连续操作时提示互相阻塞。
- daisyUI 的 `toast` 组件本身只负责定位与堆叠(容器内多个 `alert` 自动纵向排列),队列/自动消失/动画需应用层实现——已按此重写:
- `composables/toast.ts`:模块级响应式 `toasts` 列表,`setToast(message, type?, duration?)` 推入带唯一 id 的条目并定时自动移除(默认 3s);`useToast()` 签名不变,13 个调用视图零改动;移除原 provide/inject 方案。
- `Toast.vue`:daisyUI `toast toast-top toast-end` 容器 + `TransitionGroup` 进出场动画(仅 transform/opacity,配合全局 reduced-motion 降级)、每条带关闭按钮(aria-label)、容器 `aria-live="polite"`。
- 验证:`pnpm build` 通过;浏览器实测连续触发两条 toast 同时堆叠展示、3s 后全部自动消失。
### 增量 8 — dev 镜像构建脚本(多平台)✅
- 新增 `deploy/docker/build-dev.sh` + `make image-dev`:
- 默认多平台 `linux/amd64,linux/arm64` 构建并推送(自动创建缺失的 buildx 容器构建器);`PUSH=0` 退化为本机架构构建并 `--load` 到本地。
- 标签:`dev`、`dev-<短SHA>`、`dev-<YYYYMMDD>`(`REGISTRY_IMAGE` / `PLATFORMS` / `BUILDER` / `DOCKERFILE` 均可用环境变量覆盖)。
- 推送时附带 registry inline cache(`--cache-from/--cache-to type=inline`)加速后续 dev 构建。
- 验证:`PUSH=0` 端到端实测通过(容器内前端构建 + Go 交叉编译 + upx 压缩 31.7MB→11.8MB + 本地加载);镜像冒烟测试 `/:200`、`/api/*:200`。
+3 -1
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"embed"
"fmt"
"io/fs"
"log"
"opencatd-open/internal/cli"
"opencatd-open/internal/consts"
"opencatd-open/pkg/config"
"opencatd-open/pkg/store"
"opencatd-open/router"
"github.com/spf13/cobra"
)
// all:dist 使 dist 只含 .gitkeep 占位(尚未构建前端)时也能编译通过,
// 本地 go run ./cmd/openteam 无需先跑 pnpm build
//go:embed all:dist
var web embed.FS
func main() {
cfg, err := config.LoadConfig()
if err != nil {
panic(err)
}
db, err := store.InitDB(cfg)
if err != nil {
panic(err)
}
rootCmd := &cobra.Command{
Use: "openteam",
Short: "openteam cli",
Long: consts.Logo,
Run: func(cmd *cobra.Command, args []string) {
router.SetRouter(cfg, db, &web)
},
}
rootCmd.AddCommand(cli.LoadCmd)
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
func printFilesAndDirs(fsys fs.FS, prefix string) error {
return fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
fmt.Printf("%s[DIR] %s\n", prefix, p)
} else {
info, err := d.Info()
if err != nil {
return err
}
fmt.Printf("%s[FILE] %s (%d bytes)\n", prefix, p, info.Size())
}
return nil
})
}
+37
View File
@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
# 只编译一次,不再被 QEMU 模拟执行两遍
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /frontend-build
COPY ./frontend ./
RUN npm install -g pnpm@10.25.0 \
&& pnpm install --frozen-lockfile \
&& pnpm build
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend
LABEL author="github.com/Sakurasan"
ARG TARGETOS
ARG TARGETARCH
RUN apk --no-cache add make upx
WORKDIR /build
COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=$TARGETOS \
GOARCH=$TARGETARCH
RUN make build
FROM alpine:latest AS runner
# 设置alpine 时间为上海时间
RUN apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=backend /build/bin/openteam /app/openteam
ENV GIN_MODE=release
ENV PATH=$PATH:/app
EXPOSE 80 443
ENTRYPOINT ["/app/openteam"]
+42
View File
@@ -0,0 +1,42 @@
# syntax=docker/dockerfile:1
# 国内镜像加速版:npm/apk/go proxy 均走国内源
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
# 只编译一次,不再被 QEMU 模拟执行两遍
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /frontend-build
COPY ./frontend ./
RUN npm config set registry https://registry.npmmirror.com \
&& npm install -g pnpm@10.25.0 --registry=https://registry.npmmirror.com \
&& pnpm install --frozen-lockfile \
&& pnpm build
# 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend
LABEL author="github.com/Sakurasan"
ARG TARGETOS
ARG TARGETARCH
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk --no-cache add make upx
WORKDIR /build
COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
ENV GO111MODULE=on \
GOPROXY=https://goproxy.cn,direct \
CGO_ENABLED=0 \
GOOS=$TARGETOS \
GOARCH=$TARGETARCH
RUN make build
FROM alpine:latest AS runner
# 设置alpine 时间为上海时间
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
&& apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
COPY --from=backend /build/bin/openteam /app/openteam
ENV GIN_MODE=release
ENV PATH=$PATH:/app
EXPOSE 80 443
ENTRYPOINT ["/app/openteam"]
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env sh
# 构建 dev 镜像:默认多平台(amd64/arm64)并推送,PUSH=0 时按本机架构构建并加载到本地
#
# 用法:
# sh deploy/docker/build-dev.sh # 多平台构建并推送 dev 标签
# PUSH=0 sh deploy/docker/build-dev.sh # 本机架构构建 --load,仅本地
# REGISTRY_IMAGE=demo/openteam PLATFORMS=linux/amd64 sh deploy/docker/build-dev.sh
set -e
cd "$(dirname "$0")/../.."
REGISTRY_IMAGE=${REGISTRY_IMAGE:-mirrors2/opencatd-open}
PLATFORMS=${PLATFORMS:-linux/amd64,linux/arm64}
BUILDER=${BUILDER:-xbuilder}
PUSH=${PUSH:-1}
DOCKERFILE=${DOCKERFILE:-deploy/docker/Dockerfile}
SHORT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown)
DATE_TAG=$(date +%Y%m%d)
# 确保 buildx 容器构建器存在(默认 docker driver 不支持多平台)
docker buildx inspect "$BUILDER" >/dev/null 2>&1 || docker buildx create --name "$BUILDER" --driver docker-container >/dev/null
TAGS="-t $REGISTRY_IMAGE:dev -t $REGISTRY_IMAGE:dev-$SHORT_SHA -t $REGISTRY_IMAGE:dev-$DATE_TAG"
if [ "$PUSH" = "1" ]; then
echo ">>> 多平台构建并推送: $PLATFORMS"
# shellcheck disable=SC2086
docker buildx build \
--builder "$BUILDER" \
--platform "$PLATFORMS" \
$TAGS \
--cache-from type=registry,ref="$REGISTRY_IMAGE:dev" \
--cache-to type=inline \
-f "$DOCKERFILE" \
. --push
echo ">>> 已推送: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
else
HOST_ARCH=$(docker version -f '{{.Server.Arch}}')
echo ">>> 本机架构构建并加载: linux/$HOST_ARCH"
docker buildx build \
--builder "$BUILDER" \
--platform "linux/$HOST_ARCH" \
$TAGS \
-f "$DOCKERFILE" \
. --load
echo ">>> 已加载本地镜像: $REGISTRY_IMAGE:dev (dev-$SHORT_SHA, dev-$DATE_TAG)"
fi
+8
View File
@@ -0,0 +1,8 @@
version: '3.9'
services:
adminer:
image: adminer
restart: always
ports:
- 8080:8080
+21
View File
@@ -0,0 +1,21 @@
version: '3.9'
services:
mariadb:
image: mariadb
container_name: mysql
restart: unless-stopped
ports:
- "3306:3306"
volumes:
- ${PWD}/mysqldb:/var/lib/mysql
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --skip-character-set-client-handshake
environment:
MYSQL_ROOT_PASSWORD: openteam
MYSQL_DATABASE: openteam
MYSQL_USER: openteam
MYSQL_PASSWORD: openteam
+22
View File
@@ -0,0 +1,22 @@
# CREATE EXTENSION vector;
# SELECT * FROM pg_extension;
# SELECT * FROM pg_available_extensions;
version: '3.9'
services:
pg:
image: pgvector/pgvector:pg17
# image: paradedb/paradedb
container_name: pg
restart: always
# network_mode: host
ports:
- 5432:5432
environment:
POSTGRES_DB: openteam
POSTGRES_USER: openteam
POSTGRES_PASSWORD: openteam
volumes:
- $PWD/pgdata:/var/lib/postgresql/data
+10
View File
@@ -0,0 +1,10 @@
version: '3.7'
services:
sqlite-web:
image: vaalacat/sqlite-web
ports:
- 8800:8080
volumes:
- $PWD/db:/data
environment:
- SQLITE_DATABASE=openteam.db
+24
View File
@@ -0,0 +1,24 @@
version: '3.7'
services:
opencatd:
image: mirrors2/opencatd-open
container_name: opencatd-open
restart: unless-stopped
#network_mode: host
ports:
- 80:80
volumes:
- $PWD/db:/app/db
logging:
# driver: "json-file"
options:
max-size: 10m
max-file: 3
# environment:
# Vertex: |
# {
# "type": "service_account",
# "universe_domain": "googleapis.com"
# }
-27
View File
@@ -1,27 +0,0 @@
FROM node:18.12.1-alpine3.16 AS frontend
WORKDIR /frontend-build
COPY ./web/ .
RUN npm install && npm run build && rm -rf node_modules
FROM golang:1.21-alpine as builder
LABEL anther="github.com/Sakurasan"
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk --no-cache add make cmake upx
WORKDIR /build
COPY --from=frontend /frontend-build/dist /build/dist
COPY . /build
ENV GO111MODULE=on
# ENV GOPROXY=https://goproxy.cn,direct
CMD [ "go mod tidy","go mod download" ]
RUN make build
FROM alpine:latest AS runner
# 设置alpine 时间为上海时间
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories && apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
WORKDIR /app
COPY --from=builder /build/bin/opencatd /app/opencatd
ENV GIN_MODE=release
ENV PATH=$PATH:/app
EXPOSE 80
ENTRYPOINT ["/app/opencatd"]
-10
View File
@@ -1,10 +0,0 @@
version: '3.7'
services:
opencatd:
image: mirrors2/opencatd-open
container_name: opencatd-open
restart: unless-stopped
ports:
- 80:80
volumes:
- /etc/opencatd:/app/db
+37
View File
@@ -0,0 +1,37 @@
# opencatd-open frontend
Vue 3 + TypeScript + Vite + Tailwind CSS 4 / daisyUI 5。
## 本地开发
前端通过 Vite dev server 开发(支持热更新),`/api` 请求代理到本地 Go 后端,无需每次重新构建嵌入:
```bash
# 方式一:一条命令并行启动前后端(后端 8080,前端 5173)
make dev
# 方式二:分开跑
make dev-backend # go run ./cmd/openteam,PORT=8080
make dev-frontend # cd frontend && pnpm dev
```
浏览器访问 http://localhost:5173 (localhost 属浏览器安全上下文,clipboard / Passkey 可直接用)。
环境变量:
| 变量 | 说明 | 默认 |
| --- | --- | --- |
| `VITE_DEV_API_TARGET` | dev 代理的后端地址 | `http://localhost:8080` |
| `VITE_DEV_HTTPS` | 设为 `true` 时启用自签名 HTTPS dev server | 关闭 |
## 构建与嵌入
`make web` 构建前端并把产物移入 `cmd/openteam/dist`(Go 通过 `//go:embed all:dist` 嵌入);`make build` 编译二进制。`dist` 目录仅含 `.gitkeep` 占位时后端也能正常编译,因此克隆后可直接 `make dev-backend` 起后端联调。
## 其他常用命令
```bash
pnpm dev # 仅前端 dev server
pnpm typecheck # vue-tsc 类型检查
pnpm build # 类型检查 + 生产构建
```
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en" data-theme="emerald">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="OpenTeam — an open-source, team-shared service compatible with the OpenAI API." />
<meta name="theme-color" content="#ffffff" />
<title>OpenTeam</title>
<script>
// 恢复主题偏好(light/dark/auto,auto 跟随系统),避免闪烁
try {
var __p = localStorage.getItem('theme') || 'auto'
var __dark = __p === 'dark' || (__p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.setAttribute('data-theme', __dark ? 'dark' : 'emerald')
} catch (e) {
document.documentElement.setAttribute('data-theme', 'emerald')
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+42
View File
@@ -0,0 +1,42 @@
{
"name": "opencatd-open-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@iconify/vue": "^5.0.1",
"@lucide/vue": "^1.37.0",
"@simplewebauthn/browser": "^13.3.0",
"axios": "^1.20.0",
"pinia": "^4.0.3",
"qrcode.vue": "^3.10.0",
"vue": "^3.5.42",
"vue-router": "^5.3.0"
},
"devDependencies": {
"@iconify-json/mingcute": "^1.2.8",
"@iconify-json/simple-icons": "^1.2.94",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.4.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-vue": "^6.0.8",
"daisyui": "^5.7.22",
"tailwindcss": "^4.3.3",
"typescript": "^6.0.3",
"vite": "^8.2.2",
"vue-tsc": "^3.3.11"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"vue-demi"
]
},
"packageManager": "pnpm@10.25.0"
}
+1904
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

After

Width:  |  Height:  |  Size: 913 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+8
View File
@@ -0,0 +1,8 @@
<template>
<RouterView />
<Toast />
</template>
<script setup lang="ts">
import Toast from '@/components/common/Toast.vue';
</script>
+58
View File
@@ -0,0 +1,58 @@
// src/api/client.ts
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'
const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
if (import.meta.env.DEV) { // Vite 的方式判断开发环境
console.log(`[Request] API Base URL: ${baseURL}`);
} else if (process.env.NODE_ENV === 'development') { // Vue CLI 的方式判断开发环境
console.log(`[Request] API Base URL: ${baseURL}`);
}
const service = axios.create({
baseURL: baseURL,
timeout: 6000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore();
if (!authStore.token) {
authStore.loadTokenFromStorage();
}
if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`;
}
return config;
},
(error: AxiosError) => {
console.error('Request error:', error);
return Promise.reject(error);
}
);
// 响应拦截器
service.interceptors.response.use(
(response) => {
return response; // 只返回响应数据,便于后续使用
},
(error: AxiosError) => {
// 可以在这里处理响应错误的情况,例如统一处理错误信息, 提示用户等
console.error('Response error:', error);
// 这里可以做一些统一的错误处理,例如根据状态码判断是否 token 失效,并跳转到登录页面
if (error.response && error.response.status === 401) {
const authStore = useAuthStore();
authStore.clear();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default service;
+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Anthropic</title><path d="M13.827 3.52h3.603L24 20h-3.603l-6.57-16.48zm-7.258 0h3.767L16.906 20h-3.674l-1.343-3.461H5.017l-1.344 3.46H0L6.57 3.522zm4.132 9.959L8.453 7.687 6.205 13.48H10.7z"></path></svg>

After

Width:  |  Height:  |  Size: 368 B

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Azure</title><path d="M7.242 1.613A1.11 1.11 0 018.295.857h6.977L8.03 22.316a1.11 1.11 0 01-1.052.755h-5.43a1.11 1.11 0 01-1.053-1.466L7.242 1.613z" fill="url(#lobe-icons-azure-fill-0)"></path><path d="M18.397 15.296H7.4a.51.51 0 00-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226l-2.706-7.775z" fill="#0078D4"></path><path d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998L15.272.857z" fill="url(#lobe-icons-azure-fill-1)"></path><path d="M17.193 1.613a1.11 1.11 0 00-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 01-1.052 1.466h-.12 7.895a1.11 1.11 0 001.052-1.466L17.193 1.613z" fill="url(#lobe-icons-azure-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-0" x1="8.247" x2="1.002" y1="1.626" y2="23.03"><stop stop-color="#114A8B"></stop><stop offset="1" stop-color="#0669BC"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-1" x1="14.042" x2="12.324" y1="15.302" y2="15.888"><stop stop-opacity=".3"></stop><stop offset=".071" stop-opacity=".2"></stop><stop offset=".321" stop-opacity=".1"></stop><stop offset=".623" stop-opacity=".05"></stop><stop offset="1" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-azure-fill-2" x1="12.841" x2="20.793" y1="1.626" y2="22.814"><stop stop-color="#3CCBF4"></stop><stop offset="1" stop-color="#2892DF"></stop></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude</title><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><defs><linearGradient id="lobe-icons-gemini-fill" x1="0%" x2="68.73%" y1="100%" y2="30.395%"><stop offset="0%" stop-color="#1C7DFF"></stop><stop offset="52.021%" stop-color="#1C69FF"></stop><stop offset="100%" stop-color="#F0DCD6"></stop></linearGradient></defs><path d="M12 24A14.304 14.304 0 000 12 14.304 14.304 0 0012 0a14.305 14.305 0 0012 12 14.305 14.305 0 00-12 12" fill="url(#lobe-icons-gemini-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 581 B

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="56" viewBox="0 0 24 24" width="56" xmlns="http://www.w3.org/2000/svg" style="flex: 0 0 auto; line-height: 1;"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>

After

Width:  |  Height:  |  Size: 913 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@@ -0,0 +1,280 @@
<template>
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
<div
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] backdrop-blur-0 px-4 py-0 my-0 rounded-lg overflow-hidden ">
<!-- bg-gradient-to-br from-slate-50 to-orange-50 -->
<!-- 中心图标容器 -->
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<!-- 中心图标本身 -->
<div class="w-10 h-10 md:w-16 md:h-16 rounded-full flex items-center justify-center backdrop-blur-md animate-bounce hover:cursor-alias" @click="$router.push('/dashboard')">
<img src="@/assets/logo.svg" alt="Center Logo" class="rounded-full object-cover">
</div>
</div>
<!-- 左侧图标列 -->
<div
class="absolute top-0 left-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
<!-- 遍历左侧图标数据 -->
<div v-for="icon in leftIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el as Element }"
class="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12 flex items-center justify-center">
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
</div>
</div>
</div>
<!-- 右侧图标列 -->
<div
class="absolute top-0 right-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
<!-- 遍历右侧图标数据 -->
<div v-for="icon in rightIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el as Element }"
class="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12 flex items-center justify-center">
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
</div>
</div>
</div>
<!-- SVG 画布,用于绘制线条和动画 -->
<svg class="absolute inset-0 w-full h-full z-0" ref="svgCanvas">
<defs>
<!-- 这里可以定义 SVG 渐变或标记 (marker) -->
</defs>
<!-- 只有当中心点和图标坐标都计算好后才开始绘制 -->
<g v-if="centerCoords && Object.keys(iconCoords).length >= (leftIcons.length + rightIcons.length)">
<!-- 绘制左侧图标的线条和动画 -->
<template v-for="icon in leftIcons" :key="'group-left-' + icon.id">
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
<path :id="'path-visual-left-' + icon.id"
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')" stroke="#E5E7EB"
stroke-width="1" fill="none" />
<!-- 2. 绘制用于动画的、覆盖在背景线上的短线段 -->
<path :id="'path-anim-left-' + icon.id"
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
:stroke="icon.color || '#DB2777'" stroke-width="2.5" fill="none" stroke-linecap="round"
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="largeGap + dashLen">
<!-- 定义动画:改变 stroke-dashoffset 使短线段移动 -->
<animate attributeName="stroke-dashoffset" :from="largeGap + dashLen" :to="0"
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze" />
<!-- keyTimes 和 values 可以更精细控制,但这里 from/to 足够 -->
</path>
</template>
<!-- 绘制右侧图标的线条和动画 -->
<template v-for="icon in rightIcons" :key="'group-right-' + icon.id">
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
<path :id="'path-visual-right-' + icon.id"
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right')" stroke="#E5E7EB"
stroke-width="1" fill="none" />
<!-- 2. 绘制用于动画的、覆盖在背景线上的短线段 -->
<path :id="'path-anim-right-' + icon.id"
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
:stroke="icon.color || '#1D4ED8'" stroke-width="2.5" fill="none" stroke-linecap="round"
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="0">
<!-- 定义动画:改变 stroke-dashoffset 使短线段移动 -->
<!-- 注意:路径本身是从 Icon 到 Center 绘制的。为了让动画看起来是从 Center 到 Icon, -->
<!-- 我们需要让 dashoffset 从 0 (在Icon处开始) 变为 负的pattern长度 (移动到Center处结束) -->
<animate attributeName="stroke-dashoffset" :from="0" :to="-(largeGap + dashLen)"
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze" />
</path>
</template>
</g>
</svg>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
type Coords = { x: number; y: number };
type FlowIcon = { id: string; name: string; img: string; color: string };
// --- 图标数据 (保持不变) ---
const leftIcons = ref<FlowIcon[]>([
{ id: 'web', name: 'Web', img: 'https://img.icons8.com/?size=100&id=38536&format=png&color=000000', color: '#DB4437' },
{ id: 'iphone', name: 'iPhone', img: 'https://img.icons8.com/?size=100&id=ZwGNoFXGbt9n&format=png&color=000000', color: '#eac50c' },
{ id: 'mac', name: 'Mac', img: 'https://img.icons8.com/?size=100&id=RHxDgbKmJhUD&format=png&color=000000', color: '#1DB954' },
]);
const rightIcons = ref<FlowIcon[]>([
{ id: 'openai', name: 'OpenAI', img: 'https://img.icons8.com/?size=100&id=FBO05Dys9QCg&format=png&color=000000', color: '#E4405F' },
{ id: 'claude', name: 'Claude', img: 'https://img.icons8.com/?size=100&id=H5H0mqCCr5AV&format=png&color=000000', color: '#229ED9' },
{ id: 'gemini', name: 'Gemini', img: 'https://img.icons8.com/?size=100&id=eoxMN35Z6JKg&format=png&color=000000', color: '#FF6600' },
{ id: 'azure', name: 'Azure', img: 'https://img.icons8.com/?size=100&id=VLKafOkk3sBX&format=png&color=000000', color: '#007FFF' },
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' },
{ id: 'google', name: 'Google', img: 'https://img.icons8.com/color/48/google-logo.png', color: '#DB4437' },
{ id: 'deepseek', name: 'DeepSeek', img: 'https://img.icons8.com/?size=100&id=YWOidjGxCpFW&format=png&color=000000', color: '#4CAF50' },
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#333' },
]);
// --- 结束图标数据 ---
// --- Dash 动画参数 ---
const dashLen = ref(15); // 移动线段的长度
const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线段可见
// --- 结束 Dash 动画参数 ---
const svgCanvas = ref<SVGSVGElement | null>(null);
const centerElement = ref<HTMLElement | null>(null);
const iconRefs = reactive<Record<string, Element | null>>({});
const centerCoords = ref<Coords | null>(null);
const iconCoords = reactive<Record<string, Coords | null>>({});
// (getElementCenterCoords 和 updateCoordinates 函数保持不变)
const getElementCenterCoords = (element: Element | null): Coords | null => {
if (!element || !svgCanvas.value) return null;
const svgRect = svgCanvas.value.getBoundingClientRect();
const elemRect = element.getBoundingClientRect();
return {
x: elemRect.left + elemRect.width / 2 - svgRect.left,
y: elemRect.top + elemRect.height / 2 - svgRect.top,
};
};
const updateCoordinates = () => {
if (!centerElement.value || !svgCanvas.value) return;
centerCoords.value = getElementCenterCoords(centerElement.value);
const allIcons = [...leftIcons.value, ...rightIcons.value];
let coordsFound = 0;
allIcons.forEach(icon => {
const element = iconRefs[icon.id];
if (element) {
iconCoords[icon.id] = getElementCenterCoords(element);
if (iconCoords[icon.id]) {
coordsFound++;
}
} else {
console.warn(`找不到图标 ${icon.id} 的 DOM 元素引用。`);
}
});
// if (coordsFound < allIcons.length) { // 可选的调试信息
// console.warn("部分图标坐标未能成功计算。");
// }
};
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
/**
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
* @param iconCoord 图标坐标 {x, y}
* @param centerCoord 中心坐标 {x, y}
* @param side 图标在哪一侧
* @returns SVG path 'd' 属性字符串
*/
const calculatePathForVisual = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right'): string => {
if (!iconCoord || !centerCoord) return '';
const { x: startX, y: startY } = iconCoord;
const { x: endX, y: endY } = centerCoord;
const controlX = (side === 'left')
? startX + (endX - startX) * 0.6
: startX - (startX - endX) * 0.6;
const controlY = startY;
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
};
/**
* 计算动画运动的 SVG 路径
* @param iconCoord 图标坐标 {x, y}
* @param centerCoord 中心坐标 {x, y}
* @param side 图标在哪一侧
* @param direction 动画方向
* @returns SVG path 'd' 属性字符串
*/
const calculatePathForAnimation = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right', direction: 'toCenter' | 'fromCenter' = 'toCenter'): string => {
if (!iconCoord || !centerCoord) return '';
let startX: number, startY: number, endX: number, endY: number;
let controlX: number, controlY: number;
if (direction === 'fromCenter') {
// --- 动画从中心开始 ---
startX = centerCoord.x;
startY = centerCoord.y;
endX = iconCoord.x;
endY = iconCoord.y;
// 控制点计算:
// 为了使曲线形状看起来与 'toCenter' 类似但方向相反
// 我们将控制点放在靠近中心(起点)的位置,并使其 Y 坐标与终点(图标)对齐
controlX = startX + (endX - startX) * 0.4; // X 轴方向上,控制点靠近起点 (中心)
controlY = endY; // Y 轴方向上,与终点 (图标) 对齐
} else { // direction === 'toCenter' (默认)
// --- 动画从图标开始 ---
startX = iconCoord.x;
startY = iconCoord.y;
endX = centerCoord.x;
endY = centerCoord.y;
// 控制点计算 (与视觉线一致)
controlX = (side === 'left')
? startX + (endX - startX) * 0.6
: startX - (startX - endX) * 0.6;
controlY = startY; // Y 轴方向上,与起点 (图标) 对齐
}
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
};
// --- 生命周期钩子 (保持不变) ---
let resizeObserver: ResizeObserver | undefined;
onMounted(async () => {
await nextTick();
updateCoordinates();
resizeObserver = new ResizeObserver(updateCoordinates);
if (svgCanvas.value?.parentElement) {
resizeObserver.observe(svgCanvas.value.parentElement);
} else {
console.warn("无法找到用于 ResizeObserver 的父元素。");
}
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
}
});
</script>
<style scoped>
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
/* Prevent text/image selection */
user-select: none;
/* Standard */
-webkit-user-select: none;
/* Safari, Chrome, Opera */
-moz-user-select: none;
/* Firefox */
-ms-user-select: none;
/* IE/Edge */
/* Prevent dragging ghost image (optional but helpful) */
-webkit-user-drag: none;
user-drag: none;
/* Maybe needed for some browsers */
pointer-events: none;
/* Also prevents clicks/hovers directly on the img if needed */
}
.flex-col.justify-around {
justify-content: space-around;
}
/* 可选:给动画路径添加一点模糊效果? */
#path-anim-left,
#path-anim-right {
filter: blur(2px);
background-color: #eac50c;
}
</style>
@@ -0,0 +1,130 @@
<template>
<div class="mt-4 flex flex-col items-center justify-between gap-3 text-sm sm:flex-row">
<span class="text-base-content/60 tabular-nums">
Showing {{ startItem }}–{{ endItem }} of {{ totalItems }}
</span>
<div class="flex flex-wrap items-center justify-center gap-3 sm:justify-end">
<label v-if="showSelectPageSize" class="flex items-center gap-2">
<span class="hidden text-base-content/60 sm:inline">Per page</span>
<select
class="select select-sm w-16 border-base-300 bg-base-100"
:value="pageSize"
@change="emitChangePageSize($event)"
>
<option v-for="option in pageSizeOptions" :key="option" :value="option">
{{ option }}
</option>
</select>
</label>
<span class="text-base-content/60 tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
<div class="join">
<button
class="btn btn-sm join-item border-base-300 bg-base-100"
:disabled="currentPage === 1"
@click="emitChangePage(1, pageSize)"
aria-label="First page"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
<button
class="btn btn-sm join-item border-base-300 bg-base-100"
:disabled="currentPage === 1"
@click="emitChangePage(currentPage - 1, pageSize)"
aria-label="Previous page"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
class="btn btn-sm join-item border-base-300 bg-base-100"
:disabled="currentPage === totalPages"
@click="emitChangePage(currentPage + 1, pageSize)"
aria-label="Next page"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
<button
class="btn btn-sm join-item border-base-300 bg-base-100"
:disabled="currentPage === totalPages"
@click="emitChangePage(totalPages, pageSize)"
aria-label="Last page"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
const props = withDefaults(defineProps<{
currentPage: number;
totalItems: number;
pageSize?: number;
pageSizeOptions?: number[];
showSelectPageSize?: boolean;
}>(), {
pageSize: 10,
pageSizeOptions: () => [10, 25, 50, 100],
showSelectPageSize: true,
});
const emit = defineEmits<{
(e: 'changePage', page: number, pageSize: number): void;
(e: 'changePageSize', pageSize: number): void;
}>();
const totalPages = computed(() => {
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
});
// 使用一个 ref 来存储内部的 currentPage 状态
const localCurrentPage = ref(props.currentPage);
const localPageSize = ref(props.pageSize);
// 监听 props.currentPage 的变化,并更新 localCurrentPage
watch(() => props.currentPage, (newCurrentPage) => {
localCurrentPage.value = newCurrentPage;
});
watch(() => props.pageSize, (newPageSize) => {
localPageSize.value = newPageSize;
});
const emitChangePage = (page: number, pageSize: number) => { // 添加了 pageSize 参数
const validPage = Math.max(1, Math.min(page, totalPages.value));
if (validPage !== localCurrentPage.value) {
localCurrentPage.value = validPage;
emit('changePage', validPage, pageSize); // 将 pageSize 传递给父组件
}
};
const emitChangePageSize = (event: Event) => {
const newPageSize = parseInt((event.target as HTMLSelectElement).value, 10);
localPageSize.value = newPageSize;
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
};
const startItem = computed(() => {
return (localCurrentPage.value - 1) * localPageSize.value + 1;
});
const endItem = computed(() => {
return Math.min(localCurrentPage.value * localPageSize.value, props.totalItems);
});
</script>
@@ -0,0 +1,115 @@
<template>
<div class="flex flex-col items-center space-y-4 p-6 bg-base-100 rounded-xl shadow-lg mt-2 max-w-sm mx-auto backdrop-blur-xl glass">
<div class="p-3 bg-white rounded-lg shadow-inner cursor-pointer" @click="toggleQRCode">
<qrcode-vue :value="currentValue" :size="size" level="H" />
</div>
<div class="relative w-full p-4 ">
<p
class="text-sm break-all whitespace-pre-wrap m-1 p-1 pr-5 text-base-content border border-dashed border-base-content rounded-lg">
{{ currentValue }}
</p>
<button @click="copyValue" class="absolute top-2 right-2 btn btn-xs btn-ghost bg-gray-100">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2M16 8h2a2 2 0 012 2v8a2 2 0 01-2-2h-8a2 2 0 01-2-2v-2" />
</svg>
</button>
<div v-if="showCopied"
class="absolute -top-5 -right-2 bg-neutral text-neutral-content px-2 py-1 rounded text-xs opacity-100 transition-opacity duration-300">
Copied
</div>
</div>
<div class="grid grid-cols-2 gap-2 w-full">
<button v-for="app in applist" :key="app.name"
class="btn btn-sm btn-outline btn-ghost flex items-center justify-center"
@click="applyPrefix(app.name)">
<img :src="app.url" alt="" class="w-5 h-5 mr-1">
<span>{{ app.name }}</span>
</button>
</div>
<div class="w-full">
<button class="btn btn-outline btn-sm w-full" @click="resetValue">
Reset
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import QrcodeVue from 'qrcode.vue';
// 组件接收的 props
const props = withDefaults(defineProps<{
value: string; // 二维码的原始值
size?: number; // 二维码的尺寸 (像素)
}>(), {
size: 160, // 默认大小
});
// 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值
// 初始值是来自 props 的 value
const currentValue = ref(props.value);
// 监听 props.value 的变化,如果外部传入的 value 改变,更新 currentValue
watch(() => props.value, (newValue) => {
currentValue.value = newValue;
});
const showCopied = ref(false);
const copyValue = async () => {
try {
await navigator.clipboard.writeText(currentValue.value);
showCopied.value = true;
setTimeout(() => {
showCopied.value = false;
}, 1500); // 1.5 秒后恢复
} catch (err: any) {
console.error('Failed to copy: ', err);
// 可以在这里添加错误提示
}
};
const applist = reactive<{ name: string; url: string }[]>([
// { name: 'openteam', url: '/assets/logo.svg' },
{ name: 'botgem', url: 'https://botgem.com/favicon.ico' },
{ name: 'opencat', url: 'https://opencat.app/favicon.ico' },
])
const applyPrefix = (name: string) => {
let origin = window.location.origin;
switch (name) {
case 'botgem':
currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`;
break;
case 'opencat':
currentValue.value = `opencat://team/join?domain=${origin}&token=${props.value}`;
break;
default:
currentValue.value = name + props.value;
break;
}
};
const toggleQRCode = () => {
if (currentValue.value.startsWith('sk-')) {
return
} else {
window.open(currentValue.value, '_blank')
}
}
// 清除前缀,恢复到原始值
const resetValue = () => {
currentValue.value = props.value;
};
</script>
+104
View File
@@ -0,0 +1,104 @@
<template>
<div
class="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-field border border-base-300 bg-base-100 px-2 py-1 text-sm transition-colors focus-within:border-primary"
:class="{ 'pointer-events-none opacity-60': disabled }"
@click="focusInput"
>
<span
v-for="(tag, index) in tags"
:key="`${tag}-${index}`"
class="badge badge-sm badge-ghost gap-1 py-2"
>
{{ tag }}
<button
v-if="!disabled"
type="button"
class="hover:text-error"
:aria-label="`Remove ${tag}`"
@click.stop="removeTag(index)"
>
✕
</button>
</span>
<input
ref="inputRef"
v-model="draft"
type="text"
class="grow min-w-20 bg-transparent border-none outline-none focus:outline-none p-0 m-0 h-7"
:placeholder="tags.length ? '' : placeholder"
:disabled="disabled"
@keydown.enter.prevent="commitDraft"
@keydown.backspace="removeLastOnEmpty"
@blur="commitDraft"
/>
<button
v-if="clearable && tags.length && !disabled"
type="button"
class="text-base-content/40 hover:text-error"
aria-label="Clear all"
@click.stop="clearAll"
>
✕
</button>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
// 替代 Element Plus 的 el-input-tag:Enter 添加标签、可逐个删除、可清空
const props = withDefaults(defineProps<{
modelValue: string[] | undefined
placeholder?: string
clearable?: boolean
disabled?: boolean
/** 触发提交的按键,仅支持 Enter(与 el-input-tag 的 trigger 对齐) */
trigger?: string
}>(), {
placeholder: 'Please input',
clearable: false,
disabled: false,
trigger: 'Enter',
})
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
(e: 'change', value: string[]): void
}>()
const tags = computed(() => props.modelValue ?? [])
const inputRef = ref<HTMLInputElement | null>(null)
const draft = ref('')
const commitDraft = () => {
const value = draft.value.trim()
if (!value) return
if (!tags.value.includes(value)) {
const next = [...tags.value, value]
emit('update:modelValue', next)
emit('change', next)
}
draft.value = ''
}
const removeTag = (index: number) => {
const next = tags.value.filter((_, i) => i !== index)
emit('update:modelValue', next)
emit('change', next)
}
const removeLastOnEmpty = () => {
if (draft.value.length === 0 && tags.value.length) {
removeTag(tags.value.length - 1)
}
}
const clearAll = () => {
emit('update:modelValue', [])
emit('change', [])
}
const focusInput = () => {
inputRef.value?.focus()
}
</script>
+50
View File
@@ -0,0 +1,50 @@
<!-- src/components/common/Toast.vue:daisyUI toast 容器堆叠展示全部活动提示 -->
<template>
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16 gap-2">
<TransitionGroup name="toast">
<div
v-for="t in toasts"
:key="t.id"
role="status"
class="alert shadow-lg"
:class="{
'alert-error': t.type === 'error',
'alert-success': t.type === 'success',
'alert-info': t.type === 'info',
}"
>
<span class="min-w-0 flex-1 break-words">{{ t.message }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="关闭提示" @click="dismiss(t.id)">✕</button>
</div>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { useToasts } from '@/composables/toast';
const { toasts, dismiss } = useToasts();
</script>
<style scoped>
/* 只动 transform/opacity;系统开启减弱动态效果时由全局样式禁用 */
.toast-enter-active,
.toast-leave-active {
transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateY(-8px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(16px);
}
.toast-leave-active {
position: absolute;
right: 0;
}
</style>
@@ -0,0 +1,70 @@
<template>
<!-- Breadcrumb and Title:仅在有多级路径(列表/详情)时显示面包屑 -->
<div class="mb-5">
<nav v-if="breadcrumbItems.length > 1" aria-label="Breadcrumb" class="text-xs text-base-content/50">
<ul class="flex flex-wrap items-center gap-1.5">
<li v-for="(item, index) in breadcrumbItems" :key="index" class="flex items-center gap-1.5">
<span v-if="index > 0" aria-hidden="true" class="text-base-content/30">/</span>
<template v-if="item.path && index !== breadcrumbItems.length - 1">
<RouterLink :to="item.path" class="transition-colors hover:text-base-content/80">
{{ item.label }}
</RouterLink>
</template>
<template v-else>
<span aria-current="page" class="text-base-content/80">{{ item.label }}</span>
</template>
</li>
</ul>
</nav>
<h1 class="mt-1 text-xl font-semibold tracking-tight text-balance">{{ displayTitle }}</h1>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
type Crumb = { label: string; path: string };
const props = withDefaults(defineProps<{
title?: string | null; // 默认为null,将从路由 meta 中获取
// Optional custom breadcrumb items
customBreadcrumbs?: Crumb[];
}>(), {
title: null,
customBreadcrumbs: () => [],
});
const route = useRoute();
// 按路由名显式定义多级面包屑:只有存在父级页面的路由才需要路径展示
const crumbsByRouteName: Record<string, Crumb[]> = {
// 管理后台列表页:区域根
User: [{ label: '管理后台', path: '/dashboard/manager/users' }],
ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }],
// 详情页:区域根 / 列表页(末级为当前页标题)
UserView: [
{ label: '管理后台', path: '/dashboard/manager/users' },
{ label: '用户管理', path: '/dashboard/manager/users' },
],
ApiKeyView: [
{ label: '管理后台', path: '/dashboard/manager/users' },
{ label: '渠道管理', path: '/dashboard/manager/keys' },
],
};
const breadcrumbItems = computed<Crumb[]>(() => {
if (props.customBreadcrumbs.length > 0) {
return props.customBreadcrumbs;
}
return crumbsByRouteName[String(route.name)] ?? [];
});
// 显示的标题:自定义标题 → 路由 meta.title
const displayTitle = computed(() => {
if (props.title) {
return props.title;
}
return (route.meta.title as string) || 'Dashboard';
});
</script>
@@ -0,0 +1,77 @@
<template>
<aside class="sticky top-0 flex h-screen w-60 flex-col border-r border-base-300/70 bg-base-100">
<div class="flex select-none items-center gap-2.5 px-4 pb-2 pt-5">
<img src="@/assets/logo.svg" alt="OpenTeam logo" class="h-9 w-9" @click="$router.push('/')" />
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
</div>
<nav class="flex-1 overflow-y-auto px-2 pt-1" aria-label="主导航">
<ul class="menu w-full gap-0.5 text-sm">
<li class="menu-title mt-2 text-xs font-medium text-base-content/40">
{{ isAdminArea ? '管理后台' : '控制台' }}
</li>
<li v-for="item in items" :key="item.to">
<router-link :to="item.to" :class="{ 'menu-active': isActive(item.to) }" class="rounded-lg py-2.5">
<component :is="item.icon" class="h-[18px] w-[18px]" aria-hidden="true" />
{{ item.label }}
</router-link>
</li>
</ul>
</nav>
<!-- 区域互切入口:控制台 → 管理后台(仅管理员可见);管理后台 → 返回控制台 -->
<div v-if="!isAdminArea && isAdminUser" class="border-t border-base-300/70 p-2">
<ul class="menu w-full text-sm">
<li>
<router-link to="/dashboard/manager/users" class="justify-between rounded-lg py-2.5">
<span class="flex min-w-0 items-center gap-2">
<ShieldIcon class="h-[18px] w-[18px]" aria-hidden="true" />
管理后台
</span>
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
</router-link>
</li>
</ul>
</div>
<div v-else-if="isAdminArea" class="border-t border-base-300/70 p-2">
<ul class="menu w-full text-sm">
<li>
<router-link to="/dashboard/overview" class="justify-between rounded-lg py-2.5">
<span class="flex min-w-0 items-center gap-2">
<ArrowLeftIcon class="h-[18px] w-[18px]" aria-hidden="true" />
返回控制台
</span>
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
</router-link>
</li>
</ul>
</div>
</aside>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { ShieldIcon, ChevronRight, ArrowLeftIcon } from '@lucide/vue';
import { consoleMenu, adminMenu, type MenuLink } from '@/utils/router_menu';
import { useAuthStore } from '@/stores/auth';
const authStore = useAuthStore();
const userrole = computed(() => {
const user = authStore.user;
return user ? user.role : 0;
});
const route = useRoute();
// 当前处于管理后台区域(/dashboard/manager/*)还是控制台区域
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'));
// 仅管理员(role >= 10)可见管理后台入口
const isAdminUser = computed(() => userrole.value >= 10);
const items = computed<MenuLink[]>(() => (isAdminArea.value ? adminMenu : consoleMenu));
const isActive = (path: string) => {
return route.path === path;
};
</script>
+60
View File
@@ -0,0 +1,60 @@
import { ref } from 'vue'
// 主题偏好:浅色 / 深色 / 跟随系统;浅色映射为品牌 emerald 主题
export type ThemePreference = 'light' | 'dark' | 'auto'
export type ResolvedTheme = 'light' | 'dark'
const STORAGE_KEY = 'theme'
const MEDIA = '(prefers-color-scheme: dark)'
function readStoredPreference(): ThemePreference {
try {
const v = localStorage.getItem(STORAGE_KEY)
if (v === 'light' || v === 'dark' || v === 'auto') return v
} catch { /* localStorage 不可用 */ }
return 'auto'
}
function resolveTheme(p: ThemePreference): ResolvedTheme {
if (p !== 'auto') return p
try {
return window.matchMedia(MEDIA).matches ? 'dark' : 'light'
} catch {
return 'light'
}
}
function applyTheme() {
resolvedTheme.value = resolveTheme(preference.value)
document.documentElement.setAttribute('data-theme', resolvedTheme.value === 'dark' ? 'dark' : 'emerald')
}
// 系统深浅色切换时,auto 模式实时跟随(模块级监听,避免重复注册)
let mediaListenerBound = false
function ensureMediaListener() {
if (mediaListenerBound || typeof window === 'undefined') return
mediaListenerBound = true
try {
window.matchMedia(MEDIA).addEventListener('change', () => {
if (preference.value === 'auto') applyTheme()
})
} catch { /* 旧浏览器无 addEventListener */ }
}
const preference = ref<ThemePreference>(readStoredPreference())
const resolvedTheme = ref<ResolvedTheme>(resolveTheme(preference.value))
applyTheme()
ensureMediaListener()
function setPreference(p: ThemePreference) {
preference.value = p
try {
localStorage.setItem(STORAGE_KEY, p)
} catch { /* ignore */ }
applyTheme()
}
export function useTheme() {
return { preference, resolvedTheme, setPreference }
}
+35
View File
@@ -0,0 +1,35 @@
import { ref } from 'vue'
export type ToastType = 'info' | 'success' | 'error'
export type ToastItem = {
id: number
message: string
type: ToastType
duration: number
}
// 模块级共享状态:所有活动 toast 堆叠展示,互不阻塞
const toasts = ref<ToastItem[]>([])
let seed = 0
function dismiss(id: number) {
toasts.value = toasts.value.filter(t => t.id !== id)
}
export function setToast(message: string, type: ToastType = 'info', duration = 3000) {
const id = ++seed
toasts.value.push({ id, message, type, duration })
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
}
export function useToast() {
return { setToast }
}
// 供 Toast 组件渲染使用
export function useToasts() {
return { toasts, dismiss }
}
+242
View File
@@ -0,0 +1,242 @@
<!-- src/layouts/DashboardLayout.vue -->
<template>
<div class="min-h-screen bg-base-200">
<div class="drawer" :class="{ 'lg:drawer-open': isLargeSidebarOpen }">
<input id="ot-drawer" type="checkbox" class="drawer-toggle" />
<!-- Main Content -->
<div class="drawer-content flex min-h-screen flex-col">
<!-- Top Navigation -->
<header class="sticky top-0 z-30 flex h-14 w-full items-center gap-1.5 border-b border-base-300/70 bg-base-100/90 px-2 backdrop-blur sm:px-4">
<!-- Mobile menu button -->
<label for="ot-drawer" class="btn btn-ghost btn-square btn-sm lg:hidden" aria-label="Open navigation menu">
<MenuIcon class="h-5 w-5" aria-hidden="true" />
</label>
<!-- Desktop sidebar toggle -->
<button
class="btn btn-ghost btn-square btn-sm hidden lg:inline-flex"
@click="toggleSidebarLarge"
:aria-pressed="isLargeSidebarOpen"
:aria-label="isLargeSidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'"
>
<PanelLeftIcon class="h-5 w-5" aria-hidden="true" />
</button>
<div class="flex-auto"></div>
<!-- 下拉打开时的透明遮罩:点击任意处关闭(触屏可靠) -->
<div v-if="themeMenuOpen || userMenuOpen" class="fixed inset-0 z-30" aria-hidden="true" @click="closeMenus"></div>
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': themeMenuOpen }">
<button class="btn btn-circle btn-ghost btn-sm" aria-label="切换主题" :aria-expanded="themeMenuOpen"
@click="toggleThemeMenu">
<SunIcon v-if="preference === 'light'" class="h-5 w-5" aria-hidden="true" />
<MoonIcon v-else-if="preference === 'dark'" class="h-5 w-5" aria-hidden="true" />
<MonitorIcon v-else class="h-5 w-5" aria-hidden="true" />
</button>
<ul class="menu dropdown-content z-40 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg"
@keydown.escape="closeMenus">
<li>
<button :class="{ 'menu-active': preference === 'light' }" @click="selectTheme('light')"
:aria-pressed="preference === 'light'">
<SunIcon class="h-4 w-4" aria-hidden="true" />浅色
</button>
</li>
<li>
<button :class="{ 'menu-active': preference === 'dark' }" @click="selectTheme('dark')"
:aria-pressed="preference === 'dark'">
<MoonIcon class="h-4 w-4" aria-hidden="true" />深色
</button>
</li>
<li>
<button :class="{ 'menu-active': preference === 'auto' }" @click="selectTheme('auto')"
:aria-pressed="preference === 'auto'">
<MonitorIcon class="h-4 w-4" aria-hidden="true" />自动
</button>
</li>
</ul>
</div>
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': userMenuOpen }">
<button class="btn btn-ghost btn-sm gap-2 px-2" aria-label="打开个人菜单" :aria-expanded="userMenuOpen"
@click="toggleUserMenu">
<!-- 有头像时显示头像 -->
<div v-if="userInfo.avatar" class="avatar">
<div class="mask mask-squircle w-7">
<img :src="userInfo.avatar" :alt="userInfo.username || 'User avatar'" width="28" height="28" />
</div>
</div>
<!-- 没有头像时显示首字母 -->
<div v-else class="placeholder avatar">
<div class="flex w-7 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-xs font-medium">{{ userInitials }}</span>
</div>
</div>
<div class="hidden flex-col items-start sm:flex">
<span class="text-sm leading-none font-medium">{{ userInfo.username }}</span>
</div>
<ChevronDown class="h-3.5 w-3.5 text-base-content/50" aria-hidden="true" />
</button>
<ul class="menu dropdown-content z-40 mt-2 w-56 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg"
@keydown.escape="closeMenus">
<!-- 身份信息 -->
<li class="pointer-events-none">
<div class="flex items-center gap-2.5 px-2 py-2">
<div class="placeholder avatar">
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-sm font-medium">{{ userInitials }}</span>
</div>
</div>
<div class="min-w-0">
<p class="flex items-center gap-1.5 truncate text-sm font-medium">
<span class="truncate">{{ userInfo.name || userInfo.username }}</span>
<span class="badge badge-ghost badge-xs">{{ roleName }}</span>
</p>
<p v-if="userInfo.email" class="truncate text-xs text-base-content/50">{{ userInfo.email }}</p>
<p v-else-if="userInfo.name" class="truncate text-xs text-base-content/50">{{ '@' + (userInfo.username || '') }}</p>
</div>
</div>
</li>
<li aria-hidden="true" class="pointer-events-none select-none"><hr class="-mx-2 my-1 border-base-content/10" /></li>
<li>
<a href="#" @click.prevent="handleMenuAction('profile')">
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
账户设置
</a>
</li>
<li v-if="isAdminUser && !isAdminArea">
<a href="#" @click.prevent="handleMenuAction('admin')">
<ShieldIcon class="h-4 w-4" aria-hidden="true" />
管理后台
</a>
</li>
<li aria-hidden="true" class="pointer-events-none select-none"><hr class="-mx-2 my-1 border-base-content/10" /></li>
<li class="text-error">
<a href="#" @click.prevent="handleMenuAction('logout')">
<LogOut class="h-4 w-4" aria-hidden="true" />
退出登录
</a>
</li>
</ul>
</div>
</header>
<!-- Main Content Area -->
<main class="mx-auto w-full max-w-6xl flex-1 px-3 py-6 sm:px-6 lg:px-8">
<router-view></router-view>
</main>
</div>
<!-- Sidebar -->
<div class="drawer-side z-40">
<label for="ot-drawer" class="drawer-overlay" aria-label="Close navigation menu"></label>
<Sidebar />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { MenuIcon, SunIcon, MoonIcon, MonitorIcon, PanelLeftIcon, ChevronDown, SettingsIcon, ShieldIcon, LogOut } from '@lucide/vue'
import Sidebar from '@/components/dashboard/Sidebar.vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter, useRoute } from 'vue-router'
import { useTheme } from '@/composables/theme'
import type { ThemePreference } from '@/composables/theme'
import type { UserInfo } from '@/types'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const { preference, setPreference } = useTheme()
const isLargeSidebarOpen = ref(true)
// 移动端抽屉:路由切换后自动收起;下拉一并关闭
router.afterEach(() => {
const drawer = document.getElementById('ot-drawer') as HTMLInputElement | null
if (drawer) drawer.checked = false
userMenuOpen.value = false
themeMenuOpen.value = false
})
// 下拉改为状态驱动(dropdown-open),不再依赖焦点——触屏上点菜单项不会因失焦而先收起
const userMenuOpen = ref(false)
const themeMenuOpen = ref(false)
const toggleUserMenu = () => {
userMenuOpen.value = !userMenuOpen.value
themeMenuOpen.value = false
}
const toggleThemeMenu = () => {
themeMenuOpen.value = !themeMenuOpen.value
userMenuOpen.value = false
}
const closeMenus = () => {
userMenuOpen.value = false
themeMenuOpen.value = false
}
const selectTheme = (p: ThemePreference) => {
setPreference(p)
closeMenus()
}
const userInfo = computed<Partial<UserInfo>>(() => authStore.user || {})
onMounted(async () => {
if (!authStore.user) {
await authStore.getProfile()
}
})
const userInitials = computed(() => {
if (userInfo.value.name) {
return userInfo.value.name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2); // Max 2 letters
}
if (userInfo.value.username) {
return userInfo.value.username
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2); // Max 2 letters
}
return 'U';
});
// 仅管理员可见「管理后台」入口(已处于后台区域时不再重复显示)
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'))
const isAdminUser = computed(() => (authStore.user?.role ?? 0) >= 10)
const roleName = computed(() => {
const role = authStore.user?.role ?? 0;
if (role > 10) return 'Root';
if (role > 0) return 'Admin';
return 'User';
});
const handleMenuAction = (action: 'profile' | 'admin' | 'logout') => {
closeMenus()
if (action === 'profile') {
router.push('/dashboard/settings/profile')
} else if (action === 'admin') {
router.push('/dashboard/manager/users')
} else if (action === 'logout') {
authStore.logout()
}
}
const toggleSidebarLarge = () => {
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
}
</script>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import './styles/main.css'
import App from './App.vue'
import router from './router'
import request from '@/api/client'
const pinia = createPinia()
const app = createApp(App)
app.provide('request', request)
app.use(router)
app.use(pinia)
app.mount('#app')
+18
View File
@@ -0,0 +1,18 @@
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from '@/utils/router_menu'
const router = createRouter({
history: createWebHistory(),
routes,
})
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
+220
View File
@@ -0,0 +1,220 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { AxiosResponse } from 'axios';
import request from '@/api/client'
import type { UserInfo, AuthCredentials, TokenPayload, TokenInfo } from '@/types';
import { useRouter } from 'vue-router';
export const useAuthStore = defineStore('auth', () => {
const router = useRouter();
const user = ref<UserInfo | null>(null);
const role = computed<number>(() => {
if (!user.value) return 0;
return user.value.role;
})
const token = ref<string>(localStorage.getItem('token') || '');
const isAdmin = computed(() => {
if (!user.value || user.value.role === 0) return false;
return user.value.role > 0;
});
const isLoggedIn = computed(() => !!token.value);
const loading = ref(false);
const error = ref<string | null>(null);
const setToken = (newToken: string) => {
token.value = newToken;
localStorage.setItem('token', newToken);
}
const loadTokenFromStorage = () => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
token.value = storedToken;
}
}
const register = async (userInfo: AuthCredentials) => {
error.value = null;
try {
const res = await request.post('/auth/register', userInfo)
return res
} catch (err: any) {
error.value = err.response?.data?.error || '注册失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息
} finally {
loading.value = false;
}
}
const login = async (userInfo: AuthCredentials) => {
error.value = null;
try {
const res = await request.post('/auth/login', userInfo)
if (res.status === 200 && !!res.data.data?.token) {
setToken(res.data.data.token)
}
await getProfile() // 登录成功后获取用户信息
return res
} catch (err: any) {
error.value = err.response?.data?.error || '登录失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息
} finally {
loading.value = false;
}
}
const getProfile = async () => {
loading.value = true;
error.value = null;
if (token.value && user.value) {
return user.value
}
try {
const res = await request.get('/profile')
user.value = res.data.data
return res
} catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw error
} finally {
loading.value = false;
}
}
const refreshProfile = async () => {
loading.value = true;
error.value = null;
try {
const res = await request.get('/profile')
if (res.data.code == 200) {
user.value = res.data.data
}
return res
} catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw error
} finally {
loading.value = false;
}
}
const updateProfile = async (userInfo: Partial<UserInfo>) => {
loading.value = true;
error.value = null;
try {
const res = await request.post('/profile/update', userInfo)
console.log('auth updateProfile', res.data);
return res
} catch (err: any) {
error.value = err.response?.data?.error || '更新用户信息失败';
throw error
} finally {
loading.value = false;
}
}
const updatePassword = async (payload: { password: string; newpassword: string }) => {
loading.value = true;
error.value = null;
try {
const res = await request.post('/profile/update/password', payload)
console.log('auth updatePassword', res.data);
return res
} catch (err: any) {
error.value = err.response?.data?.error || '更新密码失败';
throw error
} finally {
loading.value = false;
}
}
const createToken = async (newToken: TokenPayload) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post('/tokens', newToken)
console.log('createToken', response.data);
return response
} catch (err: any) {
error.value = err.response?.data?.error || '创建token失败';
throw error
} finally {
loading.value = false;
}
}
const resetToken = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/tokens/reset/${id}`)
return response
} catch (err: any) {
error.value = err.response?.data?.error || '重置token失败';
throw error
} finally {
loading.value = false;
}
}
const updateToken = async (tokenInfo: Partial<TokenInfo>) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.put(`/tokens/${tokenInfo.id}`, tokenInfo)
return response
} catch (err: any) {
error.value = err.response?.data?.error || '更新token失败';
throw error
} finally {
loading.value = false;
}
}
const deleteToken = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/tokens/${id}`)
return response
} catch (err: any) {
error.value = err.response?.data?.error || '删除token失败';
throw err
} finally {
loading.value = false;
}
}
const clear = () => {
user.value = null
token.value = ''
localStorage.removeItem('token')
}
const logout = () => {
clear()
router.push('/login')
}
return {
loading, error,
user, role, token,
isLoggedIn, isAdmin,
setToken, loadTokenFromStorage,
login, register,
getProfile, updateProfile, updatePassword, refreshProfile,
createToken, deleteToken, resetToken, updateToken,
clear,
logout
}
});
+139
View File
@@ -0,0 +1,139 @@
// src/stores/key.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { AxiosResponse } from 'axios';
import request from '@/api/client';
import type { ApiKey, NewApiKeyPayload } from '@/types';
export const useKeyStore = defineStore('key', () => {
const loading = ref(false);
const error = ref<string | null>(null);
const totalKeys = ref(0);
const keys = ref<ApiKey[]>([]);
const key = ref<ApiKey | null>(null);
const fetchKeys = async (pageSize = 20, page = 1, active?: boolean | boolean[]) => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/keys', {
params: {
pageSize,
page,
active,
},
});
keys.value = response.data.data?.keys ?? [];
totalKeys.value = response.data.data?.total ?? 0;
} catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKeys失败';
throw error
} finally {
loading.value = false;
}
};
const fetchKey = async (id: number | string) => {
if (keys.value.length > 0) {
const findkey = keys.value.find(item => item.id === id)
if (findkey) {
key.value = findkey;
return;
}
}
loading.value = true;
error.value = null;
try {
const response = await request.get(`/keys/${id}`);
key.value = response.data.data;
if (key.value && (key.value.support_models?.length ?? 0) < 3) {
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
}
if (key.value && !key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
}
} catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKey失败';
throw error
} finally {
loading.value = false;
}
};
const refreshKey = async (id: number | string) => {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/keys/${id}`);
key.value = response.data.data;
if (key.value && (key.value.support_models?.length ?? 0) < 3) {
key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
}
if (key.value && !key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
}
} catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const createKey = async (data: NewApiKeyPayload) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post('/keys', data);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || '创建ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const updateKey = async (keyInfo: ApiKey) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.put(`/keys/${keyInfo.id}`, keyInfo);
return response;
} catch (err: any) {
error.value = err.response?.data?.error || '更新ApiKey失败';
throw error
} finally {
loading.value = false;
}
}
const keyOption = async (option: string, ids: (number | string)[]) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/keys/batch/${option}`, { ids });
return response
} catch (err: any) {
error.value = err.response?.data?.error || '操作失败';
throw err;
} finally {
loading.value = false;
}
}
//todo 更新 批量操作
return {
loading, error,
key, keys, totalKeys,
fetchKeys,
fetchKey,
refreshKey,
createKey,
updateKey,
keyOption,
}
});
+140
View File
@@ -0,0 +1,140 @@
// src/stores/user.ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { AxiosResponse } from 'axios';
import request from '@/api/client';
import type { UserInfo, NewUserPayload } from '@/types';
export const useUserStore = defineStore('user', () => {
const users = ref<UserInfo[]>([]);
const totalUsers = ref(0);
const user = ref<UserInfo | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
async function createUser(userData: NewUserPayload) {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post('/users', userData);
return response
} catch (err: any) {
error.value = err.response?.data?.error || '创建用户失败'
throw err;
} finally {
loading.value = false;
}
}
async function listUser(pageSize = 20, page = 1, active?: boolean | boolean[]) {
loading.value = true;
error.value = null;
try {
const response = await request.get('/users', {
params: {
pageSize,
page,
active,
},
});
users.value = response.data.data?.users ?? [];
totalUsers.value = response.data.data?.total ?? 0;
} catch (err: any) {
error.value = err.response?.data?.error || '获取用户列表失败';
throw err;
} finally {
loading.value = false;
}
}
async function getUser(id: number | string) {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/users/${id}`);
console.log('getUser response', response);
user.value = response.data.data;
return response
} catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw err;
} finally {
loading.value = false;
}
}
async function refreshUser(id: number | string) {
loading.value = true;
error.value = null;
try {
const response = await request.get(`/users/${id}`);
console.log('getUser response', response);
user.value = response.data.data;
return response
} catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败';
throw err;
} finally {
loading.value = false;
}
}
async function editUser(id: number | string, userData: Partial<UserInfo>) {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.put(`/users/${id}`, userData);
console.log('editUser', response);
return response
} catch (err: any) {
error.value = err.response?.data?.error || '编辑用户失败';
throw err;
} finally {
loading.value = false;
}
}
async function deleteUser(id: number | string) {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/users/${id}`);
return response
} catch (err: any) {
error.value = err.response?.data?.error || '删除用户失败';
throw err;
} finally {
loading.value = false;
}
}
async function userOption(option: string, ids: (number | string)[]) {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.post(`/users/batch/${option}`, { ids });
return response
} catch (err: any) {
error.value = err.response?.data?.error || '操作失败';
throw err;
} finally {
loading.value = false;
}
}
return {
users,
totalUsers,
user,
loading,
error,
createUser,
listUser,
getUser,
refreshUser,
editUser,
deleteUser,
userOption,
};
});
+140
View File
@@ -0,0 +1,140 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { AxiosResponse } from "axios";
import request from "@/api/client";
import { useRouter } from "vue-router";
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
import { useAuthStore } from "./auth";
import type { PasskeyInfo } from "@/types";
export const useWebAuthStore = defineStore("webauth", () => {
const router = useRouter();
// const token = ref(localStorage.getItem("token") || "");
const passkeys = ref<PasskeyInfo[] | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const addPasskey = async () => {
error.value = "";
loading.value = true;
try {
// 1. 从后端获取注册选项 (Creation Options)
const res = await request.get("/profile/passkey");
// console.log("begin:", res.data.data.publicKey);
const options = res.data.data.publicKey;
// 调用 Web Authentication API 进行注册
// const credential = await navigator.credentials.create(options);
// console.log("credential:", credential);
let attestation;
try {
// Pass 'undefined' as the second argument if you are not using an AbortSignal
attestation = await startRegistration({ optionsJSON: options });
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
error.value = null;
} catch (regError: any) {
// console.log("WebAuthn 注册失败或取消:", regError);
if (regError.name === "NotAllowedError") {
error.value = "Passkey 操作被取消或不允许。";
} else {
error.value = `Passkey 创建出错: ${regError.message}`;
}
return; // 终止流程
}
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2: AxiosResponse = await request.post("/profile/passkey", attestation);
// console.log("end:", res2);
return res2;
} catch (err: any) {
error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
throw error
} finally {
loading.value = false;
}
};
const loginPasskey = async () => {
error.value = null;
loading.value = true;
try {
// 1. 从后端获取登录选项 (Assertion Options)
const res = await request.get("/auth/passkey/begin");
// console.log("login begin:", res.data);
const options = res.data.data.publicKey;
// 2. 调用 Web Authentication API 进行认证
let assertion;
try {
assertion = await startAuthentication({ optionsJSON: options });
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
} catch (loginError: any) {
if (loginError.name === "NotAllowedError") {
error.value = "Passkey 登录被取消或不允许。";
} else {
error.value = `Passkey 登录出错: ${loginError.message}`;
}
throw error;
}
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
// 4. 处理登录成功的响应,通常包含 Token
if (res2.status === 200 && !!res2.data.data?.token) {
const token = res2.data.data.token
const authStore = useAuthStore()
authStore.setToken(token)
await authStore.getProfile()
return res2.data
}
} catch (err: any) {
error.value = err.response?.data?.error || err.value || "Passkey 登录失败,请稍后重试。";
throw error;
} finally {
loading.value = false;
}
};
const getPasskeys = async () => {
loading.value = true;
error.value = null;
try {
const response = await request.get('/profile/passkeys')
// console.log('getPasskeys',response.data.data)
passkeys.value = response.data.data
} catch (err: any) {
error.value = err.response?.data?.error || '获取token列表失败';
throw error
} finally {
loading.value = false;
}
}
const deletePasskey = async (id: string | number) => {
loading.value = true;
error.value = null;
try {
const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`)
return response
} catch (err: any) {
error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
throw error
} finally {
loading.value = false;
}
}
return {
passkeys,
loading,
error,
addPasskey,
loginPasskey,
getPasskeys,
deletePasskey,
};
});
+90
View File
@@ -0,0 +1,90 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark, cupcake, emerald, pastel;
}
/* 收紧 emerald 主题的圆角令牌:默认 selector 圆角过大,小尺寸复选框会渲染成正圆 */
@plugin "daisyui/theme" {
name: "emerald";
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.75rem;
}
@theme {
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", sans-serif;
}
/* 深浅色跟随 data-theme,修复滚动条与原生控件配色 */
html {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
body {
margin: 0;
padding: 0;
background-color: var(--color-base-200);
color: var(--color-base-content);
-webkit-font-smoothing: antialiased;
}
/* 移动端去除双击缩放延迟与点击高亮 */
button,
a,
[role="button"],
input[type="checkbox"],
input[type="radio"],
summary {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* 统一可见键盘焦点(:focus-visible 不干扰鼠标点击) */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* 弹层内禁止滚动穿透 */
.modal-box {
overscroll-behavior: contain;
}
/* 普通主按钮统一黑白:浅色主题黑底白字,深色主题白底黑字(特殊按钮 success/error/warning/outline 不受影响) */
.btn-primary {
--btn-color: #171717;
--btn-fg: #ffffff;
--btn-soft-bg: initial;
}
.btn-primary:hover {
--btn-bg: #000000;
}
html[data-theme="dark"] .btn-primary {
--btn-color: #ffffff;
--btn-fg: #171717;
}
html[data-theme="dark"] .btn-primary:hover {
--btn-bg: #e4e4e4;
}
/* 尊重系统“减弱动态效果”偏好 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
+111
View File
@@ -0,0 +1,111 @@
// 后端 API 数据结构,字段以后端实际返回为准,均为可选宽松定义
export type UserInfo = {
id: number
username: string
name?: string
email?: string
password?: string
avatar_url?: string
avatar?: string
role: number
active: boolean
email_verified?: boolean
timezone?: string
language?: string
unlimited_quota?: boolean
used_quota?: number
quota?: number
created_at?: number
updated_at?: number
expired_at?: number
format_expired_at?: string
tokens?: TokenInfo[]
// 后端返回字段较松散,允许扩展
[key: string]: unknown
}
export type TokenInfo = {
id: number
name: string
key?: string
active: boolean
quota?: number
used_quota?: number
unlimited_quota?: boolean
expired_at?: number
// 部分视图沿用旧字段名
expiredAt?: number
userid?: number
[key: string]: unknown
}
export type ApiKey = {
id: number
type: string
name: string
apikey?: string
active: boolean
endpoint?: string
resource_name?: string
api_secret?: string
model_prefix?: string
model_alias?: string
parameters?: string
support_models?: string
support_models_array?: string[]
selected?: boolean
[key: string]: unknown
}
export type PasskeyInfo = {
id: string | number
name?: string
created_at?: number
sign_count?: number
device_type?: string
[key: string]: unknown
}
export type AuthCredentials = {
username: string
password: string
}
export type TokenPayload = {
name: string
key?: string
user_id?: number | string
active: boolean
quota: number
unlimited_quota: boolean
expired_at: number
format_expired_at?: string
never_expired?: boolean
}
export type NewApiKeyPayload = {
name: string
type: string
apikey: string
active: boolean
endpoint?: string
resource_name?: string
api_secret?: string
model_prefix?: string
model_alias?: string
parameters?: string
support_models?: string
support_models_array?: string[]
}
export type NewUserPayload = {
username: string
password: string
email?: string
name?: string
role?: number
active?: boolean
quota?: number
unlimited_quota?: boolean
language?: string
}
+32
View File
@@ -0,0 +1,32 @@
// src/utils/format-date.ts
export function dateToUnix(dateString: string): number {
const date = new Date(dateString);
return Math.floor(date.getTime() / 1000);
}
export function unixToDate(timestamp: number): string {
const date = new Date(timestamp * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDateTime(unixTimestamp: number | undefined | null): string {
// 如果时间戳不存在或为0,返回'未知'
if (!unixTimestamp) return "未知";
// 将Unix时间戳转换为毫秒
const date = new Date(unixTimestamp * 1000);
// 获取日期和时间的各个部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
// 返回格式化的日期时间字符串
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
+79
View File
@@ -0,0 +1,79 @@
import type { Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import {
GaugeIcon,
UsersRoundIcon,
KeyRoundIcon,
SettingsIcon,
GlobeIcon,
} from '@lucide/vue'
export type MenuLink = { label: string; to: string; icon?: Component }
// 路由 meta 扩展字段
declare module 'vue-router' {
interface RouteMeta {
title?: string
icon?: Component
showInSidebar?: boolean
requiresAuth?: boolean
open?: boolean
badge?: string
}
}
export const routes: RouteRecordRaw[] = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/404', name: '404', component: () => import('@/views/error/NotFound.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/auth/Login.vue') },
{ path: '/signup', name: 'Signup', component: () => import('@/views/auth/Signup.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/NotFound.vue') }, // Catch all 404
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/layouts/DashboardLayout.vue'),
meta: { requiresAuth: true, title: '控制台' },
redirect: '/dashboard/overview',
children: [
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'API Keys' } },
{
path: 'manager',
name: 'Manager',
meta: { title: '管理后台' },
redirect: '/dashboard/manager/users',
children: [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } },
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
],
},
{
path: 'settings',
name: 'Settings',
meta: { title: '账户设置' },
redirect: '/dashboard/settings/profile',
children: [
{ path: 'profile', name: 'Profile', component: () => import('@/views/dashboard/Profile.vue'), meta: { title: '账户设置' } },
],
},
],
},
];
// 控制台菜单(所有登录用户)
export const consoleMenu: MenuLink[] = [
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
{ label: 'API Keys', to: '/dashboard/tokens', icon: KeyRoundIcon },
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
]
// 管理后台菜单(role >= 10)
export const adminMenu: MenuLink[] = [
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon },
]
+177
View File
@@ -0,0 +1,177 @@
<template>
<div class="min-h-screen bg-base-100 text-base-content">
<header class="fixed inset-x-0 top-0 z-50 border-b border-base-300/40 bg-base-100/80 backdrop-blur">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-2.5">
<a href="/" class="flex select-none items-center gap-2">
<img src="@/assets/logo.svg" alt="OpenTeam logo" width="36" height="36" class="h-9 w-9" />
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
</a>
<div class="flex items-center gap-2">
<!-- 下拉打开时的透明遮罩:点击任意处关闭(触屏可靠) -->
<div v-if="themeMenuOpen" class="fixed inset-0 z-40" aria-hidden="true" @click="themeMenuOpen = false"></div>
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': themeMenuOpen }">
<label id="theme-menu-label" class="sr-only">切换主题</label>
<button class="btn btn-ghost btn-sm btn-square h-9 w-9 min-h-9 rounded-full"
aria-labelledby="theme-menu-label" :aria-expanded="themeMenuOpen" @click="themeMenuOpen = !themeMenuOpen">
<SunIcon v-if="preference === 'light'" class="size-4" aria-hidden="true" />
<MoonIcon v-else-if="preference === 'dark'" class="size-4" aria-hidden="true" />
<MonitorIcon v-else class="size-4" aria-hidden="true" />
</button>
<ul class="menu dropdown-content z-50 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
<li>
<button :class="{ 'menu-active': preference === 'light' }" @click="selectTheme('light')"
:aria-pressed="preference === 'light'">
<SunIcon class="size-4" aria-hidden="true" />浅色
</button>
</li>
<li>
<button :class="{ 'menu-active': preference === 'dark' }" @click="selectTheme('dark')"
:aria-pressed="preference === 'dark'">
<MoonIcon class="size-4" aria-hidden="true" />深色
</button>
</li>
<li>
<button :class="{ 'menu-active': preference === 'auto' }" @click="selectTheme('auto')"
:aria-pressed="preference === 'auto'">
<MonitorIcon class="size-4" aria-hidden="true" />自动
</button>
</li>
</ul>
</div>
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
class="btn btn-ghost btn-sm h-9 min-h-9 gap-2 rounded-full">
<Icon icon="simple-icons:github" class="size-4" aria-hidden="true" />
<span class="hidden sm:inline">Star</span>
<span class="inline-flex items-center gap-1 rounded-full bg-base-200 px-2 py-0.5 text-xs font-medium tabular-nums">
<Icon icon="mingcute:star-fill" class="size-3.5 text-warning" aria-hidden="true" />
{{ star }}
</span>
</a>
<RouterLink to="/dashboard" class="btn btn-primary btn-sm h-9 min-h-9 rounded-full px-4">
Open Dashboard
</RouterLink>
</div>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 pb-16 pt-24">
<!-- Hero -->
<section class="flex flex-col items-center text-center">
<img src="@/assets/openteam.png" alt="OpenTeam project logo" width="160" height="160" class="h-28 w-auto select-none" fetchpriority="high" />
<h1 class="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl">OpenTeam</h1>
<p class="mt-4 max-w-2xl text-pretty text-lg text-base-content/70">
An open-source, team-shared service compatible with the OpenAI API — share LLM API usage across your team through a single endpoint.
</p>
<div class="tooltip mt-8 w-full max-w-xl" data-tip="Point your client at the OpenTeam base URL">
<label class="mb-1.5 block text-left text-xs font-medium uppercase tracking-wider text-base-content/50" for="endpoint-url">
Team API Endpoint
</label>
<div class="join w-full">
<input id="endpoint-url" type="text" readonly aria-label="Team API endpoint URL"
class="input input-bordered join-item grow font-mono text-sm" v-model="url" />
<button class="btn btn-primary join-item" @click="copyUrl">Copy</button>
</div>
</div>
<p class="mt-6 text-sm text-base-content/60">
👉 Api-Keys:
<a href="https://platform.openai.com/account/api-keys" class="link link-hover link-primary">platform.openai.com/account/api-keys</a>
</p>
</section>
<!-- How it works -->
<section class="mt-12">
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body items-center px-4 pb-2 pt-6 text-center sm:px-8">
<h2 class="text-xl font-semibold tracking-tight text-pretty">One endpoint, every model</h2>
<p class="max-w-lg text-sm text-base-content/60">
Connect clients on the left with LLM providers on the right — OpenTeam routes and accounts for every request.
</p>
<div class="w-full" aria-hidden="true">
<LineSegmentFlow />
</div>
</div>
</div>
</section>
<!-- Community -->
<section class="mt-8">
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body items-center text-center">
<h2 class="text-base font-semibold">Join our Telegram channel</h2>
<p class="text-sm text-base-content/60">Get the latest updates and help from the community.</p>
<a href="https://t.me/OpenTeamLLM" target="_blank" rel="noopener noreferrer" class="tooltip mt-3" data-tip="Telegram Channel">
<img src="@/assets/openteam_channel.jpg" alt="Telegram channel QR code" width="160" height="160"
loading="lazy" class="w-36 rounded-xl border border-base-300/60 select-none" />
</a>
</div>
</div>
</section>
</main>
<footer class="border-t border-base-300/40 bg-base-100 pb-10 pt-8 text-center text-sm text-base-content/60">
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" aria-label="OpenTeam on GitHub" class="inline-flex p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" class="fill-current" aria-hidden="true">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
<p class="mt-3">
Copyright © {{ currentYear }} Mirrors2 ·
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="link link-hover">opencatd-open</a>
</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { SunIcon, MoonIcon, MonitorIcon } from '@lucide/vue';
import LineSegmentFlow from '@/components/common/LineSegmentFlow.vue';
import { Icon } from '@iconify/vue';
import { useToast } from '@/composables/toast';
import { useTheme } from '@/composables/theme';
import type { ThemePreference } from '@/composables/theme';
const { preference, setPreference } = useTheme();
// 下拉状态驱动(dropdown-open),触屏点选可靠
const themeMenuOpen = ref(false);
// daisyUI 下拉基于焦点展开:选择后移除焦点收起菜单
const selectTheme = (p: ThemePreference) => {
setPreference(p);
themeMenuOpen.value = false;
};
const currentYear = ref('');
const url = ref('');
const { setToast } = useToast();
const copyUrl = async () => {
try {
await navigator.clipboard.writeText(url.value);
setToast('Copied to clipboard.', 'success');
} catch (err: any) {
setToast('Copy failed — select the text and copy manually.', 'error');
}
};
const star = ref(0);
const getGithubStars = async (): Promise<number> => {
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam');
const data = await res.json();
return data.repo.stars;
};
onMounted(() => {
url.value = window.location.origin;
currentYear.value = new Date().getFullYear().toString();
getGithubStars().then((stars) => {
star.value = stars;
}).catch(() => { /* 徽标数字失败可忽略 */ });
});
</script>
+142
View File
@@ -0,0 +1,142 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-base-200 p-4">
<div class="card w-full max-w-md border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-6 sm:p-8">
<RouterLink to="/" class="mx-auto select-none" aria-label="Back to home">
<img src="@/assets/openteam.webp" alt="OpenTeam logo" width="176" height="128" class="h-28 w-auto" />
</RouterLink>
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Log in to Dashboard</h1>
<div v-if="error" role="alert" class="alert alert-error mt-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div>
<form class="mt-4 space-y-4" @submit.prevent="handleLogin">
<div>
<label class="mb-1 block text-sm font-medium" for="username">Account</label>
<input type="text" id="username" name="username" autocomplete="username" spellcheck="false"
placeholder="username or email" class="input input-bordered w-full"
v-model="user.username" required />
</div>
<div>
<label class="mb-1 block text-sm font-medium" for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password"
placeholder="••••••••" class="input input-bordered w-full"
v-model="user.password" minlength="4" required />
</div>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" name="remember" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
Remember me
</label>
<button type="submit" class="btn btn-primary w-full" :disabled="loggingIn">
<span v-if="loggingIn" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Log In
</button>
</form>
<div class="divider my-2 text-xs text-base-content/40">OR</div>
<button type="button" @click="handlePasskeyLogin" class="btn btn-outline w-full" :disabled="!supportWebAuth"
:aria-label="supportWebAuth ? 'Sign in with Passkey' : 'Passkey not supported in this browser'">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
</svg>
Sign in with Passkey
</button>
<p class="mt-5 text-center text-sm text-base-content/60">
Don't have an account?
<RouterLink to="/signup" class="link link-primary link-hover">Sign up</RouterLink>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { CircleAlert } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '@/stores/webauth';
import { useToast } from '@/composables/toast';
const router = useRouter()
const authStore = useAuthStore();
const webauthStore = useWebAuthStore();
const { setToast } = useToast();
const error = ref<string | null>(null)
const loggingIn = ref(false)
const user = reactive({
username: localStorage.getItem('account') || '',
password: localStorage.getItem('password') || '',
rember: localStorage.getItem('rember') || false,
})
const supportWebAuth = ref(false);
onMounted(() => {
if (localStorage.getItem('token')) {
router.push('/dashboard')
}
supportWebAuth.value = !!window.PublicKeyCredential;
})
// store 的 catch 里 throw 的是 error ref,这里统一取出可展示的错误信息
const errMsg = (err: any): string => {
if (typeof err === 'string') return err
if (err?.__v_isRef) return errMsg(err.value)
return err?.response?.data?.error || err?.message || String(err)
}
const handleLogin = async () => {
error.value = null;
loggingIn.value = true;
try {
const response = await authStore.login({ username: user.username, password: user.password });
if (response.status === 200) {
if (user.rember) {
localStorage.setItem('account', user.username);
localStorage.setItem('password', user.password);
localStorage.setItem('rember', String(user.rember));
} else {
localStorage.removeItem('account');
localStorage.removeItem('password');
localStorage.removeItem('rember');
}
setToast('Logged in successfully.', 'success');
router.push('/dashboard');
}
} catch (err: any) {
console.error('Login error:', err);
error.value = errMsg(err)
} finally {
loggingIn.value = false;
}
}
const handlePasskeyLogin = async () => {
error.value = null;
loggingIn.value = true;
try {
const res = await webauthStore.loginPasskey();
if (!!res?.code && res.code === 200) {
setToast('Logged in successfully.', 'success');
router.push('/dashboard');
}
} catch (err: any) {
console.error('Passkey login error:', err);
error.value = errMsg(err)
} finally {
loggingIn.value = false;
}
};
</script>
+99
View File
@@ -0,0 +1,99 @@
<template>
<div class="flex min-h-screen items-center justify-center bg-base-200 p-4">
<div class="card w-full max-w-md border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-6 sm:p-8">
<RouterLink to="/" class="mx-auto select-none" aria-label="Back to home">
<img src="@/assets/openteam.webp" alt="OpenTeam logo" width="176" height="128" class="h-28 w-auto" />
</RouterLink>
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Create Your Account</h1>
<div v-if="error" role="alert" class="alert alert-error mt-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = ''">✕</button>
</div>
<form class="mt-4 space-y-4" @submit.prevent="handleRegister">
<div>
<label class="mb-1 block text-sm font-medium" for="account">Account</label>
<input type="text" id="account" name="username" autocomplete="username" spellcheck="false"
placeholder="username" class="input input-bordered w-full" v-model="username" required />
</div>
<div>
<label class="mb-1 block text-sm font-medium" for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="new-password"
placeholder="At least 4 characters" class="input input-bordered w-full"
v-model="password" required minlength="4" />
</div>
<div>
<label class="mb-1 block text-sm font-medium" for="confirm-password">Confirm Password</label>
<input type="password" id="confirm-password" name="confirm-password" autocomplete="new-password"
placeholder="Retype your password" class="input input-bordered w-full"
v-model="confirmPassword" required />
<p v-if="confirmPassword && password !== confirmPassword" class="mt-1.5 text-xs text-error" role="alert">
Passwords do not match.
</p>
</div>
<button type="submit" class="btn btn-primary w-full" :disabled="registering">
<span v-if="registering" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create Account
</button>
</form>
<p class="mt-5 text-center text-sm text-base-content/60">
Already have an account?
<RouterLink to="/login" class="link link-primary link-hover">Log in</RouterLink>
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
import { CircleAlert } from '@lucide/vue'
import { useToast } from '@/composables/toast'
const router = useRouter()
const authStore = useAuthStore()
const { setToast } = useToast();
const error = ref('');
const registering = ref(false);
const username = ref('');
const password = ref('');
const confirmPassword = ref('');
const handleRegister = async () => {
if (password.value !== confirmPassword.value) {
error.value = 'Passwords do not match.'
return
}
registering.value = true
try {
let res = await authStore.register({
username: username.value,
password: password.value
})
if (res.status === 200) {
setToast(res.data?.msg || 'Account created.', 'success');
setTimeout(() => {
router.push('/login');
}, 100);
error.value = ''
}
} catch (e: any) {
error.value = 'Registration failed. Please try again.'
} finally {
registering.value = false
}
}
</script>
+268
View File
@@ -0,0 +1,268 @@
<template>
<div class="mx-auto w-full max-w-3xl">
<header class="mb-4 pr-8">
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
<p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
</header>
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div>
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createApiKey">
<div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="key-name" class="mb-1 block text-sm font-medium">
Name <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="key-name" name="name" type="text" v-model="newApiKey.name" placeholder="e.g. prod-openai"
autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
</div>
<div>
<label for="key-type" class="mb-1 block text-sm font-medium">
Type <span class="text-error" aria-hidden="true">*</span>
</label>
<div class="relative">
<select id="key-type" name="type" v-model="newApiKey.type" required
class="select select-bordered w-full pl-10">
<option disabled value="">Select provider</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="azure">Azure</option>
<option value="github">GitHub</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<img :src="apiKeyImageUrl(newApiKey.type)" alt="" width="20" height="20"
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
</div>
</div>
<div>
<label for="key-apikey" class="mb-1 block text-sm font-medium">
API Key <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey"
placeholder="sk-…" autocomplete="off" spellcheck="false"
class="input input-bordered w-full font-mono text-sm" required />
</div>
<div>
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint"
placeholder="https://api.example.com/v1" spellcheck="false"
class="input input-bordered w-full font-mono text-sm" />
</div>
</div>
</section>
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
Advanced Options
</div>
<div class="collapse-content px-3">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div>
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name"
placeholder="Azure resource name" autocomplete="off" spellcheck="false"
class="input input-bordered w-full" />
</div>
<div>
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret"
placeholder="Optional secret" autocomplete="off" spellcheck="false"
class="input input-bordered w-full font-mono text-sm" />
</div>
<div>
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
<input id="key-prefix" name="model_prefix" type="text" v-model="newApiKey.model_prefix"
placeholder="e.g. azure-gpt" autocomplete="off" spellcheck="false"
class="input input-bordered w-full" />
</div>
<div>
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
<textarea id="key-alias" name="model_alias" v-model="newApiKey.model_alias" rows="2"
placeholder='{"gpt-4o": "my-gpt4o"}' spellcheck="false"
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
</div>
<div>
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
<textarea id="key-parameters" name="parameters" v-model="newApiKey.parameters" rows="2"
placeholder="{}" spellcheck="false"
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
</div>
<label class="block">
<span class="mb-1 block text-sm font-medium">Support Models</span>
<TagInput v-model="newApiKey.support_models_array" clearable
placeholder="Type a model and press Enter" @change="onchange_supportmodel" />
<span class="mt-1 block text-xs text-base-content/50">Restrict which models this key can serve. Empty allows all.</span>
</label>
<div>
<span class="mb-1 block text-sm font-medium">Status</span>
<div class="flex h-9 items-center gap-3">
<input type="checkbox" name="active" v-model="newApiKey.active" role="switch"
class="toggle toggle-sm" :class="newApiKey.active ? 'toggle-success' : 'toggle-error'"
aria-label="Key active" />
<span class="text-sm text-base-content/70">
{{ newApiKey.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</section>
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create API Key
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useKeyStore } from '@/stores/key';
import { CircleAlert } from '@lucide/vue';
import { useToast } from '@/composables/toast';
import type { NewApiKeyPayload } from '@/types';
const keyStore = useKeyStore()
const { setToast } = useToast()
const error = ref<string | null>(null)
const creating = ref(false)
// Control advanced options visibility
const showAdvancedOptions = ref(false)
// Initialize API key object
const newApiKey = ref<NewApiKeyPayload>({
name: '',
type: '',
apikey: '',
active: true,
endpoint: '',
resource_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '[]',
support_models_array: [],
})
const resetNewApiKey = () => {
newApiKey.value = {
name: '',
type: '',
apikey: '',
active: true,
endpoint: '',
resource_name: '',
// deployment_name: '',
api_secret: '',
model_prefix: '',
model_alias: '',
parameters: '{}',
support_models: '[]',
support_models_array: [],
}
}
const onchange_supportmodel = () => {
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
}
// Form validation
const isFormValid = computed(() => {
return newApiKey.value.name &&
newApiKey.value.type &&
newApiKey.value.apikey
})
const cancel = () => {
resetNewApiKey()
emit('closeModal', true)
}
const apiKeyImageMap: Record<string, string> = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype: string) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const createApiKey = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
return
}
creating.value = true
try {
try {
if (!Array.isArray(newApiKey.value.support_models_array)) {
setToast('Support Models must be a JSON array.', 'error');
return;
}
} catch (e) {
setToast('Invalid JSON format for Support Models.', 'error');
return;
}
// Attempt to parse parameters JSON
try {
JSON.parse(newApiKey.value.parameters || '{}');
} catch (e) {
setToast('Invalid JSON format for Parameters.', 'error');
return;
}
const res = await keyStore.createKey(newApiKey.value);
if (res.data?.code === 200) {
error.value = null;
resetNewApiKey();
setToast('API Key created successfully.', 'success')
emit('closeModal', true)
} else {
setToast(res.data?.error || res.data?.message || 'Failed to create API Key', 'error')
}
} catch (err: any) {
console.log('createApiKey error:', err)
error.value = err?.message || String(err) || 'Failed to create API Key'
} finally {
creating.value = false
}
}
const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script>
+207
View File
@@ -0,0 +1,207 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader title="渠道详情" />
<div v-if="key" class="space-y-5">
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="key-name" class="mb-1 block text-sm font-medium">
Name <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
class="input input-bordered w-full" required />
</div>
<div>
<label for="key-type" class="mb-1 block text-sm font-medium">
Type <span class="text-error" aria-hidden="true">*</span>
</label>
<div class="relative">
<select id="key-type" name="type" v-model="key.type" required
class="select select-bordered w-full pl-10">
<option disabled value="">Select provider</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="azure">Azure</option>
<option value="github">GitHub</option>
<option value="openai-compatible">OpenAI Compatible</option>
</select>
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
</div>
</div>
<div>
<label for="key-apikey" class="mb-1 block text-sm font-medium">
API Key <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
</div>
<div>
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
class="input input-bordered w-full font-mono text-sm" />
</div>
</div>
</section>
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
<input type="checkbox" checked class="min-h-0 py-2" />
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
Advanced Options
</div>
<div class="collapse-content px-3">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div>
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
</div>
<div>
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
</div>
<div>
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
spellcheck="false" class="input input-bordered w-full" />
</div>
<div>
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
</div>
<div>
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
</div>
<label class="block">
<span class="mb-1 block text-sm font-medium">Support Models</span>
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
@change="onchange_supportmodel" />
</label>
<div>
<span class="mb-1 block text-sm font-medium">Status</span>
<div class="flex h-9 items-center gap-3">
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
<span class="text-sm text-base-content/70">
{{ key.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</section>
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Loading state -->
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useKeyStore } from '../../stores/key';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import TagInput from '@/components/common/TagInput.vue';
import { useToast } from '@/composables/toast';
const route = useRoute();
const router = useRouter();
const keyStore = useKeyStore();
const { setToast } = useToast();
const updating = ref(false);
const keyId = computed(() => route.query.id);
const key = computed(() => keyStore.key);
const loading = computed(() => keyStore.loading);
onMounted(async () => {
console.log('keyId', keyId.value)
if (keyId.value) {
await keyStore.fetchKey(keyId.value as string);
}
});
const keyOption = reactive([
{name: 'openai', label: 'OpenAI'},
{name: 'claude', label: 'Claude'},
{name: 'gemini', label: 'Gemini'},
{name: 'azure', label: 'Azure'},
{name: 'github', label: 'Github'},
{name: 'openai-compatible', label: 'OpenAI Compatible'}
])
const apiKeyImageMap: Record<string, string> = {
'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg',
'azure': '/assets/azure.svg',
'github': '/assets/github.svg'
};
const apiKeyImageUrl = (keytype: string) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg';
};
const onchange_supportmodel = () => {
if (!key.value) return;
key.value.support_models = JSON.stringify(key.value.support_models_array)
}
const updateKey = async () => {
if (!key.value) return;
updating.value = true;
try {
const res = await keyStore.updateKey(key.value);
console.log('updateKey', res)
if (res.data?.code == 200) {
setToast(`Key ${key.value.name} updated`, 'success');
}
await keyStore.refreshKey(key.value.id);
} catch (err: any) {
console.error('Error updating key:', err);
} finally {
updating.value = false;
}
};
const cancel = () => {
router.push({ name: 'ApiKey' });
}
</script>
+326
View File
@@ -0,0 +1,326 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-base-content/60">Upstream provider keys used to serve model requests.</p>
<div class="flex items-center gap-2">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
</button>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
<li>
<button class="text-error" @click="handleBatchAction('delete')">
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
</button>
</li>
<li>
<button @click="handleBatchAction('disable')">
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
</button>
</li>
<li>
<button @click="handleBatchAction('enable')">
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
</button>
</div>
</div>
<!-- Status filter -->
<details class="dropdown dropdown-end" v-if="keys.length">
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
Status
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
</summary>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
<li v-for="status in statusOptions" :key="status">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" class="checkbox checkbox-xs" :checked="selectedStatuses.some(item => item.status === status)"
@change="toggleStatusFilter(status)" />
{{ status }}
</label>
</li>
</ul>
</details>
<!-- Table -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="overflow-x-auto" v-if="keys.length">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
aria-label="Select all keys" />
</th>
<th>Type</th>
<th>Name</th>
<th>Active</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" />
</td>
<td>
<div class="flex items-center gap-2">
<img :src="displayIcon(key.type)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
<span class="text-sm capitalize">{{ key.type }}</span>
</div>
</td>
<td class="max-w-40 truncate font-medium">{{ key.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm"
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)"
:aria-label="`Toggle key ${key.name}`" />
</td>
<td class="pr-3">
<div class="flex items-center justify-end gap-1">
<button class="btn btn-ghost btn-xs btn-square" @click="viewKey(key)" aria-label="View key details">
<EyeIcon class="h-4 w-4" aria-hidden="true" />
</button>
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)"
aria-label="Delete key">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="text-sm font-semibold">No API keys yet</h2>
<p class="max-w-xs text-sm text-base-content/60">
Add an OpenAI, Claude, Gemini or compatible provider key to start serving requests.
</p>
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
</button>
</div>
</div>
<!-- Pagination -->
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
<!-- New key modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-3xl px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<KeyNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/common/Pagination.vue';
import KeyNew from '@/views/dashboard/KeyNew.vue';
import { useKeyStore } from '@/stores/key';
import { useToast } from '@/composables/toast';
import type { ApiKey } from '@/types';
import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, KeyRoundIcon, ListFilterIcon
} from '@lucide/vue';
const router = useRouter();
const keyStore = useKeyStore();
const { setToast } = useToast();
onMounted(async () => {
await keyStore.fetchKeys();
})
const keys = computed(() => keyStore.keys);
// 用户数据
const currentPage = ref(1);
const pageSize = ref(10);
const totalItems = computed(() => keyStore.totalKeys);
// 封装公共的用户列表获取方法
const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value;
await keyStore.fetchKeys(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
};
// 分页与页面大小变化
const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) {
return
}
currentPage.value = page;
pageSize.value = size;
await fetchKeys();
};
// 复选框选择状态
const selectAll = ref(false)
const selectedKeys = ref<ApiKey[]>([])
const toggleSelectAll = () => {
if (keys.value.length === 0) {
return
}
keys.value.forEach(key => key.selected = selectAll.value)
if (selectAll.value) {
// Select all on the current page
selectedKeys.value = keys.value.map(key => key)
} else {
// Clear all selections
selectedKeys.value = []
}
}
const toggleUserSelection = (key: ApiKey) => {
if (selectedKeys.value.includes(key)) {
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
} else {
selectedKeys.value.push(key);
}
selectAll.value = selectedKeys.value.length === keys.value.length;
};
// 状态筛选
const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else {
selectedStatuses.push({ status, value: statusValue });
}
await fetchKeys(undefined, 1, undefined);
};
// 处理批量操作
const handleBatchAction = async (action: string) => {
if (selectedKeys.value.length === 0) {
return setToast('请选择数据', 'error');
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast(`无效的操作 ${action}`, 'error');
}
try {
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id));
if (res.data?.code === 200) {
setToast(`Key ${action} Success`, 'success');
} else {
setToast(res.data.error || `${action} Failed`, 'error');
}
selectedKeys.value = [];
selectAll.value = false;
await fetchKeys();
} catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error');
}
};
// 更新用户状态
const updateStatus = async (key: ApiKey) => {
try {
const action = key.active ? 'enable' : 'disable';
const res = await keyStore.keyOption(action, [key.id]);
if (res.data?.code === 200) {
setToast(`Key ${key.name} has been ${action}`, 'success');
}
await fetchKeys();
} catch (error: any) {
console.error('状态更新失败:', error);
setToast('状态更新失败', 'error');
}
};
const viewKey = (key: ApiKey) => {
router.push({ name: 'ApiKeyView', query: { id: key.id } });
}
// 删除用户
const confirmDeleteKey = async (key: ApiKey) => {
if (confirm(`确认删除 ${key.name}?`)) {
await deleteKey(key);
}
};
const deleteKey = async (key: ApiKey) => {
try {
const res = await keyStore.keyOption('delete', [key.id]);
if (res.data?.code === 200) {
setToast('删除成功', 'success');
}
await fetchKeys();
} catch (error: any) {
console.error('删除失败:', error);
setToast('删除失败', 'error');
}
};
const displayIcon = (apitype: string) => {
switch (apitype) {
case 'openai':
return '/assets/openai.svg';
case 'claude':
return '/assets/claude.svg';
case 'gemini':
return '/assets/gemini.svg'
case 'azure':
return '/assets/azure.svg';
case 'github':
return '/assets/github.svg';
default:
return '/assets/logo.svg';
}
}
// 关闭模态框
const modalRef = ref<HTMLDialogElement | null>(null);
const openModal = () => {
modalRef.value?.showModal();
};
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await fetchKeys();
};
</script>
+244
View File
@@ -0,0 +1,244 @@
<template>
<div class="space-y-6">
<BreadcrumbHeader />
<!-- Welcome hero(随时间段切换配色) -->
<section
class="relative overflow-hidden rounded-2xl bg-gradient-to-br text-white shadow-sm"
:class="getTimeTheme()">
<div class="flex flex-col items-center gap-4 p-6 text-center sm:flex-row sm:p-8 sm:text-left">
<div class="placeholder avatar">
<div
class="flex w-16 items-center justify-center rounded-full bg-white/15 ring-2 ring-white/25 sm:w-20">
<span v-if="!user?.avatar_url" class="text-2xl font-bold sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
<img v-else :src="user?.avatar_url" :alt="user?.name || user?.username || 'Avatar'"
class="h-full w-full object-cover" />
</div>
</div>
<div>
<h2 class="text-xl font-bold tracking-tight text-balance sm:text-2xl">
<span class="mr-1.5" aria-hidden="true">👋</span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
</h2>
<p class="mt-1 text-sm text-white/80">欢迎回到您的个人仪表盘</p>
</div>
</div>
</section>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
<!-- 基本信息 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">基本信息</h3>
<dl class="divide-y divide-base-300/40 text-sm">
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">用户名</dt>
<dd class="truncate font-medium">{{ user?.username }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">显示名称</dt>
<dd class="truncate font-medium">{{ user?.name || '—' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">邮箱</dt>
<dd class="truncate font-medium">{{ user?.email || '—' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">角色</dt>
<dd>
<span class="badge badge-sm"
:class="(user?.role || 0) > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">{{
getRoleName(user?.role || 0) }}</span>
</dd>
</div>
</dl>
</div>
</article>
<!-- 账户状态 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">账户状态</h3>
<dl class="divide-y divide-base-300/40 text-sm">
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">状态</dt>
<dd>
<span class="badge badge-sm"
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user?.active ? 'Active' : 'Inactive' }}
</span>
</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">时区</dt>
<dd class="font-medium">{{ user?.timezone || 'UTC' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">语言</dt>
<dd class="font-medium uppercase">{{ user?.language || 'en' }}</dd>
</div>
<div class="flex items-center justify-between gap-3 py-2.5">
<dt class="shrink-0 text-base-content/60">最后活动</dt>
<dd class="font-medium tabular-nums">{{ getLastActive() }}</dd>
</div>
</dl>
</div>
</article>
<!-- 配额信息 -->
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h3 class="card-title pb-1 text-base font-semibold">配额信息</h3>
<div class="flex-1 space-y-4 pt-1">
<div v-if="user?.unlimited_quota" class="flex items-center justify-between text-sm">
<span class="text-base-content/60">使用量</span>
<span class="badge badge-success badge-soft">无限制</span>
</div>
<div v-else class="space-y-2 text-sm">
<div class="flex items-center justify-between gap-3">
<span class="text-base-content/60">使用量</span>
<span class="font-medium tabular-nums" :class="getQuotaColor">
{{ formatQuota(user?.used_quota || 0, user?.quota || 0) }}
</span>
</div>
<progress class="progress w-full" :class="getQuotaColorClass" :value="quotaPercentage"
max="100" :aria-label="`Quota used ${quotaPercentage.toFixed(1)}%`"></progress>
</div>
<div class="grid grid-cols-2 gap-3 border-t border-base-300/40 pt-3">
<div>
<p class="text-xs text-base-content/50">创建时间</p>
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.created_at) }}</p>
</div>
<div>
<p class="text-xs text-base-content/50">更新时间</p>
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.updated_at) }}</p>
</div>
</div>
</div>
</div>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
onMounted(async () => {
if (!authStore.isLoggedIn) {
router.push('/login');
};
await authStore.refreshProfile();
});
const getTimeOfDay = (): string => {
const hour = new Date().getHours();
if (hour < 6) return '夜深了';
if (hour < 12) return '早上好';
if (hour < 18) return '下午好';
return '晚上好';
};
// 横幅随时间段切换配色:白色文字位于渐变左侧深色端,保证对比度
const getTimeTheme = (): string => {
const hour = new Date().getHours();
if (hour < 6) return 'from-slate-900 via-slate-800 to-indigo-950'; // 深夜
if (hour < 12) return 'from-sky-600 via-sky-500 to-amber-400'; // 早晨
if (hour < 18) return 'from-blue-600 via-sky-500 to-cyan-400'; // 白天
return 'from-slate-900 via-indigo-950 to-slate-800'; // 夜晚
};
const formatQuota = (used: number, total: number): string => {
if (total === 0) return '无限制';
// 格式化金额
const formatCurrency = (amount: number): string => {
if (amount === 0) return '$0';
return `$${amount.toFixed(2)}`;
};
const percentage = (used / total) * 100;
return `${formatCurrency(used)} / ${formatCurrency(total)} (${percentage.toFixed(1)}%)`;
};
const getRoleName = (role: number): string => {
switch (role) {
case 20: return 'Root';
case 10: return 'Admin';
default: return 'User';
}
};
// 格式化日期时间
function formatDateTime(unixTimestamp?: number): string {
// 如果时间戳不存在或为0,返回'未知'
if (!unixTimestamp) return '未知';
// 将Unix时间戳转换为毫秒
const date = new Date(unixTimestamp * 1000);
// 获取日期和时间的各个部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
// 返回格式化的日期时间字符串
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
// 计算配额百分比
const quotaPercentage = computed(() => {
if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0;
return (user.value.used_quota ?? 0) / user.value.quota * 100;
});
// 获取配额颜色
const getQuotaColor = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'text-success';
if (percentage < 80) return 'text-warning';
return 'text-error';
});
// 获取配额颜色类
const getQuotaColorClass = computed(() => {
const percentage = quotaPercentage.value;
if (percentage < 50) return 'progress-success';
if (percentage < 80) return 'progress-warning';
return 'progress-error';
});
// 用户最后活动时间
const getLastActive = (): string => {
if (!user.value || !user.value?.updated_at) return '未知';
const lastActive = new Date(user.value.updated_at * 1000);
const now = new Date();
const diff = now.getTime() - lastActive.getTime();
// 转换为天/小时/分钟
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days > 0) return `${days}天前`;
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours > 0) return `${hours}小时前`;
const minutes = Math.floor(diff / (1000 * 60));
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
</script>
+430
View File
@@ -0,0 +1,430 @@
<template>
<div class="mx-auto max-w-3xl space-y-5">
<BreadcrumbHeader title="账户设置" />
<div v-if="user" class="space-y-5">
<!-- Basic Information -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<div class="flex items-center gap-4">
<div class="placeholder avatar" v-if="!user?.avatar_url">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
</div>
</div>
<div class="min-w-0">
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name }}</h2>
<p class="truncate text-sm text-base-content/60">{{ user?.username }}</p>
</div>
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
<span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }}
</span>
<span class="flex items-center gap-1 text-base-content/60">
Quota:
<template v-if="user.unlimited_quota">
<Infinity class="inline h-4 w-4" aria-label="Unlimited" />
<span class="tabular-nums">({{ user.used_quota }} used)</span>
</template>
<span v-else class="tabular-nums">{{ user.used_quota }} / {{ user.quota }}</span>
</span>
</div>
</div>
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 border-t border-base-300/40 pt-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div v-if="basicinfo_error" role="alert" class="alert alert-error text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ basicinfo_error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="basicinfo_error = null">✕</button>
</div>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="pf-name" class="mb-1 block text-sm font-medium">Name</label>
<input id="pf-name" name="name" type="text" v-model="basicinfo.name" placeholder="Full name"
autocomplete="off" class="input input-bordered w-full" />
</div>
<div>
<label for="pf-username" class="mb-1 block text-sm font-medium">
Username <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="pf-username" name="username" type="text" v-model="basicinfo.username"
spellcheck="false" autocomplete="off" class="input input-bordered w-full" required />
</div>
<div>
<label for="pf-email" class="mb-1 block text-sm font-medium">Email Address</label>
<div class="relative">
<input id="pf-email" name="email" type="email" v-model="basicinfo.email"
placeholder="name@example.com" spellcheck="false"
class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleEmailVerify" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
aria-label="Email verification status">
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
<span v-else class="tooltip tooltip-left" data-tip="Send verification email">
<Send class="h-4 w-4" aria-hidden="true" />
</span>
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="savingBasic">
<span v-if="savingBasic" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Update Information
</button>
</div>
</form>
</section>
</div>
</div>
<!-- Password -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-5 p-4 sm:p-6">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Password</h3>
<div v-if="password_error" role="alert" class="alert alert-error text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">{{ password_error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="password_error = null">✕</button>
</div>
<form @submit.prevent="updatePassword" class="space-y-4">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-3">
<div>
<label for="pf-old-password" class="mb-1 block text-sm font-medium">Old Password</label>
<input id="pf-old-password" name="old_password" type="password" autocomplete="current-password"
v-model="passwordData.oldPassword" placeholder="Current password"
class="input input-bordered w-full" />
</div>
<div>
<label for="pf-new-password" class="mb-1 block text-sm font-medium">New Password</label>
<div class="relative">
<input id="pf-new-password" name="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
v-model="passwordData.newPassword" placeholder="At least 4 characters" autocomplete="new-password"
class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isNewPasswordVisible ? 'Hide new password' : 'Show new password'">
<EyeOff v-if="!isNewPasswordVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div>
<label for="pf-confirm-password" class="mb-1 block text-sm font-medium">Confirm New Password</label>
<div class="relative">
<input id="pf-confirm-password" name="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
v-model="passwordData.confirmPassword" placeholder="Retype new password" autocomplete="new-password"
class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isConfirmPasswordVisible ? 'Hide confirmed password' : 'Show confirmed password'">
<EyeOff v-if="!isConfirmPasswordVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="btn btn-outline btn-warning btn-sm px-5" :disabled="changingPassword">
<span v-if="changingPassword" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Change Password
</button>
</div>
</form>
</div>
</div>
<!-- Passkeys -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-4 p-4 sm:p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Passkeys</h3>
<p class="mt-1 text-sm text-base-content/60">Sign in securely without a password.</p>
</div>
<button class="btn btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth"
:aria-label="supportWebAuth ? 'Add passkey' : 'WebAuthn not supported in this browser'">
<Fingerprint class="h-4 w-4" aria-hidden="true" />Add Passkey
</button>
</div>
<div v-if="passkeys && passkeys.length" class="-mx-4 overflow-x-auto sm:-mx-6">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">Name</th>
<th>Create Time</th>
<th>Sign Count</th>
<th>Device</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4 font-medium">{{ passkey.name }}</td>
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</td>
<td class="tabular-nums">{{ passkey.sign_count }}</td>
<td class="text-base-content/70">{{ passkey.device_type }}</td>
<td class="pr-4 text-right">
<button class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="rounded-lg border border-dashed border-base-300 py-8 text-center">
<Fingerprint class="mx-auto h-8 w-8 text-base-content/20" aria-hidden="true" />
<p class="mt-2 text-sm text-base-content/50">No passkeys registered yet.</p>
</div>
</div>
</div>
<!-- Linked accounts (todo) -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body gap-4 p-4 sm:p-6">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Linked Accounts</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
<div class="flex items-center gap-2 text-sm">
<img src="/assets/github.svg" alt="GitHub" width="24" height="24" class="h-6 w-6" />
<span>GitHub</span>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="toggleGithubConnection">
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
</button>
</div>
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
<div class="flex items-center gap-2 text-sm">
<Send class="h-5 w-5 fill-current text-base-content/50" aria-hidden="true" />
<span>Telegram</span>
</div>
<button type="button" class="btn btn-outline btn-xs" @click="toggleTelegramConnection">
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Loading state -->
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading profile">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Eye, EyeOff, BadgeCheck, Send, TrashIcon, Infinity, CircleAlert, Fingerprint } from '@lucide/vue';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '../../stores/auth';
import { useWebAuthStore } from '../../stores/webauth';
import { formatDateTime } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { UserInfo, PasskeyInfo } from '@/types';
const authStore = useAuthStore();
const webAuthStore = useWebAuthStore();
const { setToast } = useToast();
const user = computed(() => authStore.user);
const basicinfo_error = ref<string | null>(null);
const password_error = ref<string | null>(null);
const savingBasic = ref(false);
const changingPassword = ref(false);
const basicinfo = ref({
name: user.value?.name || '',
username: user.value?.username || '',
email: user.value?.email || '',
});
const passwordData = ref({
oldPassword: '',
newPassword: '',
confirmPassword: '',
});
const isNewPasswordVisible = ref(false);
const isConfirmPasswordVisible = ref(false);
const isGithubConnected = ref(false); // Replace with actual status
const isTelegramConnected = ref(false); // Replace with actual status
const supportWebAuth = ref(false);
onMounted(async () => {
await authStore.refreshProfile();
basicinfo.value = {
name: user.value?.name || '',
username: user.value?.username || '',
email: user.value?.email || '',
};
await webAuthStore.getPasskeys();
if (window.PublicKeyCredential) {
console.log('WebAuthn is supported');
} else {
console.log('WebAuthn is not supported');
}
supportWebAuth.value = !!window.PublicKeyCredential;
});
const confirmUpdateBasicInfo = () => {
updateBasicInfo();
};
const updateBasicInfo = async () => {
if (!basicinfo.value) return;
savingBasic.value = true;
try {
console.log('updateBasicInfo', basicinfo.value);
const res = await authStore.updateProfile(basicinfo.value);
if (res.data?.code == 200) {
setToast('Basic information updated successfully.', 'success');
}
await authStore.refreshProfile(); // Refresh user data
basicinfo_error.value = null; // Clear error
} catch (err: any) {
console.log('Error updating basic info:', err);
basicinfo_error.value = err?.message || String(err) || '更新失败';
setToast('Failed to update basic information', 'error');
} finally {
savingBasic.value = false;
}
};
const updatePassword = async () => {
if (!user.value) return;
if (passwordData.value.newPassword !== passwordData.value.confirmPassword) {
setToast('新密码和确认密码不匹配', 'error');
return;
}
changingPassword.value = true;
try {
const payload = {
password: passwordData.value.oldPassword,
newpassword: passwordData.value.newPassword,
};
console.log('payload', payload)
const res = await authStore.updatePassword(payload);
if (res.data?.code == 200) {
setToast('Password updated successfully.', 'success');
}
passwordData.value.oldPassword = '';
passwordData.value.newPassword = '';
passwordData.value.confirmPassword = '';
password_error.value = null; // Clear error
} catch (err: any) {
console.error('Error updating password:', err);
password_error.value = err?.message || String(err) || '更新失败';
} finally {
changingPassword.value = false;
}
};
// 格式化角色
const formatRole = (role?: number): string => {
switch (true) {
case (role ?? 0) > 10:
return 'Root';
case (role ?? 0) > 0:
return 'Admin';
default:
return 'User';
}
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo: Implement logic to send verification email
setToast('todo,Sending verification email...', 'info');
return;
}
}
const toggleNewPasswordVisibility = () => {
isNewPasswordVisible.value = !isNewPasswordVisible.value;
};
const toggleConfirmPasswordVisibility = () => {
isConfirmPasswordVisible.value = !isConfirmPasswordVisible.value;
};
const toggleGithubConnection = () => {
isGithubConnected.value = !isGithubConnected.value;
setToast(`GitHub ${isGithubConnected.value ? 'connected' : 'disconnected'}`, 'info');
// Implement actual connection/disconnection logic here
};
const toggleTelegramConnection = () => {
isTelegramConnected.value = !isTelegramConnected.value;
setToast(`Telegram ${isTelegramConnected.value ? 'connected' : 'disconnected'}`, 'info');
// Implement actual connection/disconnection logic here
};
const newpasskey = async () => {
try {
let res = await webAuthStore.addPasskey();
if (res?.data?.code == 200) {
await getPasskeys();
}
} catch (err: any) {
console.log('err', err);
}
};
const passkeys = computed<PasskeyInfo[] | null>(() => webAuthStore.passkeys);
const getPasskeys = async () => {
try {
await webAuthStore.getPasskeys();
} catch (err: any) {
console.log('err', err);
}
}
const confirmRmovePasskey = async (passkey: PasskeyInfo) => {
if(confirm(`确认删除 ${passkey.name}?`)) {
await removePasskey(passkey.id)
}
}
const removePasskey = async (id: PasskeyInfo['id']) => {
try {
const res = await webAuthStore.deletePasskey(id);
if (res?.data?.code == 200) {
setToast('Passkey removed successfully.', 'success');
}
await getPasskeys();
} catch (err: any) {
console.log('err', err);
}
}
</script>
+193
View File
@@ -0,0 +1,193 @@
<template>
<div class="mx-auto max-w-3xl space-y-5">
<BreadcrumbHeader title="账户设置" />
<div v-if="user" class="space-y-5">
<!-- Profile header -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
<div class="placeholder avatar" v-if="!user?.avatar_url">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
</div>
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }}
</span>
<span class="badge badge-sm"
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
{{ formatRole(user?.role) }}
</span>
</div>
</div>
</div>
</div>
<!-- Edit form -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="st-name" class="mb-1 block text-sm font-medium">Name</label>
<input id="st-name" name="name" type="text" v-model="user.name" placeholder="Full name"
autocomplete="off" class="input input-bordered w-full" />
</div>
<div>
<label for="st-username" class="mb-1 block text-sm font-medium">
Username <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="st-username" name="username" type="text" v-model="user.username" spellcheck="false"
autocomplete="off" class="input input-bordered w-full" required />
</div>
<div>
<label for="st-email" class="mb-1 block text-sm font-medium">Email Address</label>
<div class="relative">
<input id="st-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
spellcheck="false" class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleEmailVerify" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
aria-label="Email verification status">
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
<Send class="h-4 w-4" aria-hidden="true" />
</span>
</button>
</div>
</div>
<div>
<label for="st-password" class="mb-1 block text-sm font-medium">
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
</label>
<div class="relative">
<input id="st-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
class="input input-bordered w-full pr-10" />
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div>
<label for="st-language" class="mb-1 block text-sm font-medium">Language</label>
<select id="st-language" name="language" v-model="user.language" class="select select-bordered w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
</div>
</section>
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Loading state -->
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading settings">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { Eye, EyeOff, BadgeCheck, Send } from '@lucide/vue';
import { useAuthStore } from '../../stores/auth';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast';
import type { UserInfo } from '@/types';
const authStore = useAuthStore();
const { setToast } = useToast();
const saving = ref(false);
const user = computed(() => authStore.user);
onMounted(async () => {
await authStore.refreshProfile()
});
// 原实现误引用了未定义的 userStore/userId,这里改为更新当前登录用户资料
const updateUser = async () => {
if (!user.value) return;
saving.value = true;
try {
const payload: Partial<UserInfo> = {
name: user.value.name,
username: user.value.username,
email: user.value.email,
language: user.value.language,
};
if (user.value.password) {
payload.password = user.value.password;
}
const res = await authStore.updateProfile(payload);
console.log('updateUser', res)
if (res.data?.code == 200) {
setToast('Settings saved successfully.', 'success');
}
await authStore.refreshProfile();
} catch (err: any) {
console.error('Error updating user:', err.response?.data?.data?.error);
} finally {
saving.value = false;
}
};
//显示密码
const isPasswordVisible = ref(false);
const togglePasswordVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value;
};
// 格式化角色
const formatRole = (role?: number): string => {
switch (true) {
case (role ?? 0) > 10:
return 'Root';
case (role ?? 0) > 0:
return 'Admin';
default:
return 'U';
}
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo
return
}
}
</script>
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="mx-auto w-full max-w-3xl">
<header class="mb-4 pr-8">
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2>
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
</header>
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">Error: {{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div>
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createToken">
<div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="token-name" class="mb-1 block text-sm font-medium">
Name <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="token-name" name="name" type="text" v-model="newToken.name"
placeholder="e.g. my-laptop" autocomplete="off"
class="input input-bordered w-full" required />
</div>
</div>
</section>
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
Advanced Options
</div>
<div class="collapse-content px-3">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div>
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label>
<div class="relative">
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate"
class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleTokenVisibility"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isTokenVisible ? 'Hide token key' : 'Show token key'"
id="token-visibility-toggle">
<EyeOff v-if="!isTokenVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div>
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label>
<div class="flex items-center gap-3">
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at"
class="input input-bordered w-full" :disabled="newToken.never_expired" />
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
<input type="checkbox" name="never_expired" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
Never
</label>
</div>
</div>
<div>
<label for="token-quota" class="mb-1 block text-sm font-medium">Quota</label>
<div class="flex items-center gap-3">
<input id="token-quota" name="quota" type="number" inputmode="numeric" v-model="newToken.quota"
placeholder="e.g. 10" autocomplete="off"
class="input input-bordered w-full flex-grow" :disabled="newToken.unlimited_quota" />
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
<input type="checkbox" name="unlimited_quota" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
Unlimited
</label>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium">Status</span>
<div class="flex h-9 items-center gap-3">
<input type="checkbox" name="active" v-model="newToken.active" role="switch"
class="toggle toggle-sm" :class="newToken.active ? 'toggle-success' : 'toggle-error'"
aria-label="Token active" />
<span class="text-sm text-base-content/70">
{{ newToken.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</section>
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
<button type="button" class="btn btn-ghost btn-sm" @click="cancel">
Cancel
</button>
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create Token
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
import { dateToUnix } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { TokenPayload } from '@/types';
const authStore = useAuthStore()
const { setToast } = useToast()
const error = ref<string | null>(null)
const creating = ref(false)
const user = computed(() => authStore.user);
const showAdvancedOptions = ref(false)
const newToken = ref<TokenPayload>({
name: '',
key: '',
user_id: user.value?.user_id as number | undefined,
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired: true,
})
const resetnewToken = () => {
newToken.value = {
name: '',
key: '',
user_id: '',
active: true,
quota: 0,
unlimited_quota: true,
expired_at: 0,
format_expired_at: '',
never_expired: true,
}
}
watch(
() => newToken.value.never_expired,
(newNeverExpiredValue) => {
if (newNeverExpiredValue) {
newToken.value.expired_at = 0;
}
}
);
watch(
() => newToken.value.format_expired_at,
(format_expired_at) => {
if (!newToken.value.never_expired && format_expired_at) {
newToken.value.expired_at = dateToUnix(format_expired_at);
} else {
newToken.value.expired_at = 0;
}
}
)
const isFormValid = computed(() => {
return newToken.value.name
})
const createToken = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields Name.', 'error')
return
}
creating.value = true
try {
const res = await authStore.createToken(newToken.value)
if (res.data?.code === 200) {
error.value = null;
resetnewToken();
setToast('Token created successfully.', 'success')
emit('closeModal', true)
} else {
console.log(res)
error.value = res.data?.error || 'Failed to create token'
}
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create token'
} finally {
creating.value = false
}
}
const cancel = () => {
resetnewToken()
emit('closeModal', false)
}
// 显示密码
const isTokenVisible = ref(false);
function toggleTokenVisibility() {
isTokenVisible.value = !isTokenVisible.value;
}
const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script>
+213
View File
@@ -0,0 +1,213 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
</button>
</div>
<!-- Table -->
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">Token</th>
<th>Status</th>
<th>Expired</th>
<th class="text-right">Quota</th>
<th class="text-right">Used</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4 font-medium">{{ token.name }}</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm"
:class="!token.active && 'toggle-error'" v-model="token.active"
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
</td>
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
<td class="text-right tabular-nums">
<template v-if="token.unlimited_quota">
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
<span class="sr-only">Unlimited</span>
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
<td class="pr-3 text-right">
<div class="flex items-center justify-end gap-1">
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
<EyeIcon class="h-4 w-4" aria-hidden="true" />
</button>
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
@click="cleanUsedToken(token)" aria-label="Reset used quota">
<Eraser class="h-4 w-4" aria-hidden="true" />
</button>
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmRevokeToken(token)" aria-label="Revoke token">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
<Braces class="h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="text-sm font-semibold">No tokens yet</h2>
<p class="max-w-xs text-sm text-base-content/60">
Create a token to connect OpenCat, BotGem and other OpenAI-compatible clients.
</p>
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Token
</button>
</div>
</div>
<!-- New token modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-3xl px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<TokenNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
<!-- Token QR modal -->
<dialog ref="tokenRef" class="modal">
<div class="modal-box max-w-sm px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<QRCodeCard :value="qrCodeValue" :size="120" />
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import QRCodeCard from '@/components/common/QRCodeCard.vue';
import TokenNew from '@/views/dashboard/TokenNew.vue';
import { useAuthStore } from '@/stores/auth';
import {
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser, Braces
} from '@lucide/vue';
import { unixToDate } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { TokenInfo } from '@/types';
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const { setToast } = useToast();
onMounted(async () => {
await authStore.refreshProfile();
})
watch(() => authStore.user, (newUser) => {
if (newUser && newUser.expired_at && newUser.expired_at > 0) {
newUser.format_expired_at = unixToDate(newUser.expired_at);
}
})
const updateStatus = async (token: TokenInfo) => {
console.log(token);
try {
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
if (res.data?.code == 200) {
setToast(`Token ${token.name} updated`, 'success');
}
} catch (error: any) {
token.active = !token.active
console.log(error.response.data.error);
setToast(error.response.data.error, 'error');
}
}
const confirmRevokeToken = async (token: TokenInfo) => {
if (confirm(`确认删除 ${token.name}?`)) {
await revokeToken(token);
}
}
const revokeToken = async (token: TokenInfo) => {
try {
const res = await authStore.deleteToken(token.id);
if (res.data?.code == 200) {
setToast(`Token ${token.name} revoked`, 'success');
}
await authStore.refreshProfile();
} catch (error: any) {
setToast(error.response.data.error, 'error');
}
}
const cleanUsedToken = async (token: TokenInfo) => {
if (token.used_quota == 0 || token.used_quota == null) {
return;
}
try {
const res = await authStore.resetToken(token.id);
console.log('cleanUsedToken', res);
if (res.data?.code == 200) {
setToast(`Token ${token.name} used quota reset`, 'success');
}
await authStore.refreshProfile();
} catch (error: any) {
console.log(error);
setToast(error, 'error');
}
}
const showTokenModel = ref(false);
const tokenRef = ref<HTMLDialogElement | null>(null);
const viewToken = (token: TokenInfo) => {
const dialog = tokenRef.value;
if (dialog) {
if (!dialog.hasAttribute('open')) {
qrCodeValue.value = token.key || '';
dialog.showModal();
} else {
if (dialog.hasAttribute('open')) {
dialog.close();
}
}
}
showTokenModel.value = !showTokenModel.value
}
const qrCodeValue = ref('');
// 关闭模态框
const modalRef = ref<HTMLDialogElement | null>(null);
const openModal = () => {
modalRef.value?.showModal();
};
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await authStore.refreshProfile();
};
</script>
+322
View File
@@ -0,0 +1,322 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader />
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-base-content/60">Team members and their access.</p>
<div class="flex items-center gap-2">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
</button>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
<li>
<button class="text-error" @click="handleBatchAction('delete')">
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
</button>
</li>
<li>
<button @click="handleBatchAction('disable')">
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
</button>
</li>
<li>
<button @click="handleBatchAction('enable')">
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
</button>
</li>
</ul>
</div>
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new user">
<PlusIcon class="h-4 w-4" aria-hidden="true" />New User
</button>
</div>
</div>
<!-- Status filter -->
<details class="dropdown dropdown-end" v-if="users.length">
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
Status
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
</summary>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
<li v-for="status in statusOptions" :key="status">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" class="checkbox checkbox-xs"
:checked="selectedStatuses.some(item => item.status === status)"
@change="toggleStatusFilter(status)" />
{{ status }}
</label>
</li>
</ul>
</details>
<!-- Table -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="overflow-x-auto" v-if="users.length">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
aria-label="Select all users" />
</th>
<th>ID</th>
<th>Name</th>
<th>Active</th>
<th class="text-right">Quota</th>
<th class="text-right">Used</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4">
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
@change="toggleUserSelection(user)" :aria-label="`Select user ${user.username}`" />
</td>
<td class="tabular-nums text-base-content/60">{{ user.id }}</td>
<td class="max-w-40 truncate font-medium">
<div class="flex items-center gap-2">
<div class="placeholder avatar">
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-[10px] font-medium">{{ user.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
{{ user.username }}
</div>
</td>
<td>
<input type="checkbox" class="toggle toggle-success toggle-sm"
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
:aria-label="`Toggle user ${user.username}`" />
</td>
<td class="text-right tabular-nums">
<template v-if="user.unlimited_quota">
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
<span class="sr-only">Unlimited</span>
</template>
<template v-else>{{ user.quota }}</template>
</td>
<td class="text-right tabular-nums">{{ user.used_quota }}</td>
<td class="pr-3">
<div class="flex items-center justify-end gap-1">
<button class="btn btn-ghost btn-xs btn-square" @click="viewUser(user)" aria-label="View user details">
<EyeIcon class="h-4 w-4" aria-hidden="true" />
</button>
<button v-if="user.role < 20" class="btn btn-ghost btn-xs btn-square text-error"
@click="confirmDeleteUser(user)" aria-label="Delete user">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Empty state -->
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
<UsersRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
<h2 class="text-sm font-semibold">No users yet</h2>
<p class="max-w-xs text-sm text-base-content/60">
Invite teammates by creating accounts with their own quotas.
</p>
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create User
</button>
</div>
</div>
<!-- Pagination -->
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
<!-- New user modal -->
<dialog ref="modalRef" class="modal">
<div class="modal-box max-w-3xl px-0 sm:px-6">
<form method="dialog">
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
</form>
<UserNew @closeModal="closeModal" />
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="Close dialog">close</button>
</form>
</dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/common/Pagination.vue';
import UserNew from '@/views/dashboard/UserNew.vue';
import { useUserStore } from '@/stores/user';
import { useToast } from '@/composables/toast';
import type { UserInfo } from '@/types';
import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, UsersRoundIcon, Infinity, ListFilterIcon
} from '@lucide/vue';
const router = useRouter();
const userStore = useUserStore();
const users = computed(() => userStore.users);
const { setToast } = useToast();
// 用户数据
const currentPage = ref(1);
const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法
const listUsers = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value;
await userStore.listUser(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
};
// 组件挂载时加载用户数据
onMounted(() => {
listUsers();
});
// 分页与页面大小变化
const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) {
return
}
currentPage.value = page;
pageSize.value = size;
await listUsers();
};
// 复选框选择状态
const selectAll = ref(false)
const selectedUsers = ref<UserInfo[]>([])
const toggleSelectAll = () => {
users.value.forEach(key => key.selected = selectAll.value)
if (selectAll.value) {
// Select all users on the current page
selectedUsers.value = users.value.map(user => user)
} else {
// Clear all selections
selectedUsers.value = []
}
}
const toggleUserSelection = (user: UserInfo) => {
if (selectedUsers.value.includes(user)) {
selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user);
} else {
selectedUsers.value.push(user);
}
selectAll.value = selectedUsers.value.length === users.value.length;
};
// 状态筛选
const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status);
if (index > -1) {
selectedStatuses.splice(index, 1);
} else {
selectedStatuses.push({ status, value: statusValue });
}
await listUsers(undefined, 1, undefined);
};
// 处理批量操作
const handleBatchAction = async (action: string) => {
if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error');
}
if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast(`无效的操作 ${action}`, 'error');
}
try {
const res = await userStore.userOption(action, selectedUsers.value.map(user => user.id));
if (res.data?.code === 200) {
setToast(`Users ${action} Success`, 'success');
} else {
setToast(res.data?.error || `${action} Failed`, 'error');
}
selectedUsers.value = [];
selectAll.value = false;
await listUsers();
} catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error');
}
};
// 更新用户状态
const updateStatus = async (user: UserInfo) => {
try {
const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) {
setToast(`User ${user.name} has been ${action}`, 'success');
} else {
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
}
await listUsers();
} catch (error: any) {
console.error('状态更新失败:', error);
setToast('状态更新失败', 'error');
}
};
const viewUser = (user: UserInfo) => {
router.push({ name: 'UserView', query: { id: user.id } });
}
const confirmDeleteUser = (user: UserInfo) => {
if (confirm(`确认删除 ${user.username}?`)) {
deleteUser(user);
}
};
// 删除用户
const deleteUser = async (user: UserInfo) => {
try {
const res = await userStore.userOption('delete', [user.id]);
if (res.data?.code === 200) {
setToast('用户删除成功', 'success');
} else {
setToast(res.data?.error || '删除失败', 'error');
}
await listUsers();
} catch (error: any) {
console.error('删除失败:', error);
setToast('删除失败', 'error');
}
};
// 关闭模态框
const modalRef = ref<HTMLDialogElement | null>(null);
const openModal = () => {
modalRef.value?.showModal();
};
const closeModal = async () => {
if (modalRef.value) {
modalRef.value.close();
}
await listUsers();
};
</script>
+216
View File
@@ -0,0 +1,216 @@
<template>
<div class="mx-auto w-full max-w-3xl">
<header class="mb-4 pr-8">
<h2 class="text-lg font-semibold tracking-tight">Create New User</h2>
<p class="mt-0.5 text-sm text-base-content/60">Invite a teammate with their own account and quota.</p>
</header>
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
<span class="flex-1">Error: {{ error }}</span>
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
</div>
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createUser">
<div class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="user-username" class="mb-1 block text-sm font-medium">
Username <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="user-username" name="username" type="text" v-model="newUser.username"
placeholder="e.g. alice" autocomplete="off" spellcheck="false"
class="input input-bordered w-full" required />
</div>
<div>
<label for="user-password" class="mb-1 block text-sm font-medium">
Password <span class="text-error" aria-hidden="true">*</span>
</label>
<div class="relative">
<input id="user-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
v-model="newUser.password" autocomplete="new-password" placeholder="At least 4 characters"
class="input input-bordered w-full pr-10" required />
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
<div>
<label for="user-name" class="mb-1 block text-sm font-medium">Name</label>
<input id="user-name" name="name" type="text" v-model="newUser.name" placeholder="Full name"
autocomplete="off" class="input input-bordered w-full" />
</div>
<div>
<label for="user-email" class="mb-1 block text-sm font-medium">Email Address</label>
<input id="user-email" name="email" type="email" v-model="newUser.email"
placeholder="name@example.com" spellcheck="false" class="input input-bordered w-full" />
</div>
</div>
</section>
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
Advanced Options
</div>
<div class="collapse-content px-3">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div>
<label for="user-role" class="mb-1 block text-sm font-medium">Role</label>
<select id="user-role" name="role" v-model="newUser.role" class="select select-bordered w-full">
<option :value="0">User</option>
<option :value="10">Admin</option>
</select>
</div>
<div>
<label for="user-language" class="mb-1 block text-sm font-medium">Language</label>
<select id="user-language" name="language" v-model="newUser.language" class="select select-bordered w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div>
<label for="user-quota" class="mb-1 block text-sm font-medium">Quota</label>
<div class="flex items-center gap-3">
<input id="user-quota" name="quota" type="number" inputmode="numeric" v-model="newUser.quota"
placeholder="e.g. 10" autocomplete="off"
class="input input-bordered w-full flex-grow" :disabled="newUser.unlimited_quota" />
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
<input type="checkbox" name="unlimited_quota" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
Unlimited
</label>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium">Status</span>
<div class="flex h-9 items-center gap-3">
<input type="checkbox" name="active" v-model="newUser.active" role="switch"
class="toggle toggle-sm" :class="newUser.active ? 'toggle-success' : 'toggle-error'"
aria-label="User active" />
<span class="text-sm text-base-content/70">
{{ newUser.active ? 'Active' : 'Inactive' }}
</span>
</div>
</div>
</div>
</div>
</section>
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Create User
</button>
</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useUserStore } from '@/stores/user'
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
import { useToast } from '@/composables/toast';
import type { NewUserPayload } from '@/types';
const userStore = useUserStore()
const { setToast } = useToast()
const error = ref<string | null>(null)
const creating = ref(false)
// Control advanced options visibility
const showAdvancedOptions = ref(false)
// Initialize user object
const newUser = ref<NewUserPayload>({
name: '',
username: '',
email: '',
password: '',
role: 0, // Default to Regular User
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimited_quota: true, // Default to unlimited
language: 'en', // Default language
})
const resetNewUser = () => {
newUser.value = {
name: '',
username: '',
email: '',
password: '',
role: 0, // Default to Regular User
active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited)
unlimited_quota: true, // Default to unlimited
language: 'en', // Default language
}
}
// Form validation
const isFormValid = computed(() => {
return newUser.value.username &&
newUser.value.password // Password is required for creation
})
// Create user method
const createUser = async () => {
if (!isFormValid.value) {
setToast('Please fill in all required fields (Username, Password).', 'error')
return
}
creating.value = true
try {
const res = await userStore.createUser({
username: newUser.value.username,
password: newUser.value.password,
email: newUser.value.email,
name: newUser.value.name || newUser.value.username, // Use username if name is empty
role: newUser.value.role,
active: newUser.value.active,
quota: newUser.value.quota,
unlimited_quota: newUser.value.unlimited_quota,
language: newUser.value.language
});
if (res.data?.code === 200) {
error.value = null;
resetNewUser();
setToast('User created successfully.', 'success')
emit('closeModal', true)
} else {
setToast(res.data?.error || res.data?.message || 'Failed to create user', 'error')
}
} catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create user'
} finally {
creating.value = false
}
}
// 显示密码
const isPasswordVisible = ref(false);
function togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value;
}
const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script>
+316
View File
@@ -0,0 +1,316 @@
<template>
<div class="space-y-5">
<BreadcrumbHeader title="用户详情" />
<div v-if="user" class="space-y-5">
<!-- Profile header -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
<div class="placeholder avatar" v-if="!user?.avatar_url">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
</div>
</div>
<div class="avatar" v-else>
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
</div>
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="badge badge-sm"
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
{{ user.active ? 'Active' : 'Inactive' }}
</span>
<span class="badge badge-sm"
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
{{ formatRole(user?.role) }}
</span>
</div>
</div>
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
</div>
</div>
<!-- Edit form -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
<section class="space-y-4">
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
<div>
<label for="uv-name" class="mb-1 block text-sm font-medium">Name</label>
<input id="uv-name" name="name" type="text" v-model="user.name" placeholder="Full name"
autocomplete="off" class="input input-bordered w-full" />
</div>
<div>
<label for="uv-username" class="mb-1 block text-sm font-medium">
Username <span class="text-error" aria-hidden="true">*</span>
</label>
<input id="uv-username" name="username" type="text" v-model="user.username" spellcheck="false"
autocomplete="off" class="input input-bordered w-full" required />
</div>
<div>
<label for="uv-email" class="mb-1 block text-sm font-medium">Email Address</label>
<div class="relative">
<input id="uv-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
spellcheck="false" class="input input-bordered w-full pr-10" />
<button type="button" @click="toggleEmailVerify" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
aria-label="Email verification status">
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
<Send class="h-4 w-4" aria-hidden="true" />
</span>
</button>
</div>
</div>
<div>
<label for="uv-password" class="mb-1 block text-sm font-medium">
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
</label>
<div class="relative">
<input id="uv-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
class="input input-bordered w-full pr-10" />
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
<Eye v-else class="h-4 w-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
</section>
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
<input type="checkbox" class="min-h-0 py-2" checked />
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
Advanced Options
</div>
<div class="collapse-content px-3">
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
<div>
<label for="uv-role" class="mb-1 block text-sm font-medium">Role</label>
<select id="uv-role" name="role" v-model="user.role" class="select select-bordered w-full">
<option :value="0">User</option>
<option :value="10">Admin</option>
<option v-if="user.role > 10" :value="20">Root</option>
</select>
</div>
<div>
<label for="uv-language" class="mb-1 block text-sm font-medium">Language</label>
<select id="uv-language" name="language" v-model="user.language" class="select select-bordered w-full">
<option value="en">English</option>
<option value="zh">中文</option>
</select>
</div>
<div>
<label for="uv-quota" class="mb-1 block text-sm font-medium">Quota</label>
<div class="flex items-center gap-3">
<input id="uv-quota" name="quota" type="number" inputmode="numeric" v-model="user.quota"
placeholder="e.g. 10" autocomplete="off"
class="input input-bordered w-full flex-grow" :disabled="user.unlimited_quota" />
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
<input type="checkbox" name="unlimited_quota" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
Unlimited
</label>
</div>
</div>
</div>
</div>
</section>
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
Save Changes
</button>
</div>
</form>
</div>
<!-- Tokens -->
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body p-4 sm:p-5">
<h3 class="card-title pb-1 text-base font-semibold">Tokens</h3>
<div v-if="user.tokens && user.tokens.length" class="-mx-4 overflow-x-auto sm:-mx-5">
<table class="table table-sm">
<thead>
<tr class="text-xs uppercase tracking-wider text-base-content/50">
<th class="pl-4">Token Name</th>
<th>Key</th>
<th>Expired At</th>
<th class="text-right">Quota</th>
<th class="text-right">Used</th>
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
<td class="pl-4 font-medium">{{ token.name }}</td>
<td class="max-w-32 truncate font-mono text-xs text-base-content/60">{{ token.key }}</td>
<td class="tabular-nums text-base-content/70">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
<td class="text-right tabular-nums">
<template v-if="token.unlimited_quota">
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
<span class="sr-only">Unlimited</span>
</template>
<template v-else>{{ token.quota }}</template>
</td>
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
<td class="pr-4 text-right">
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
@click="revokeToken(token.id)" aria-label="Revoke token">
<TrashIcon class="h-4 w-4" aria-hidden="true" />
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="py-8 text-center">
<p class="text-sm text-base-content/50">This user has no tokens yet.</p>
</div>
</div>
</div>
</div>
<!-- Loading state -->
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading user details">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, TrashIcon, Infinity } from '@lucide/vue';
import { useUserStore } from '../../stores/user';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast';
import type { UserInfo, TokenInfo } from '@/types';
const route = useRoute();
const userStore = useUserStore();
const { setToast } = useToast();
const saving = ref(false);
const userId = computed(() => route.query.id);
onMounted(async () => {
if (userId.value) {
await userStore.getUser(userId.value as string);
}
});
const user = computed(() => userStore.user);
const loading = computed(() => userStore.loading); // Access loading state
// 更新状态
const updateStatus = async (user: UserInfo) => {
try {
const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]);
if (res.data?.code === 200) {
setToast(`User ${user.id} ${action} Success`, 'success');
} else {
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
}
await userStore.refreshUser(user.id);
} catch (error: any) {
user.active = !user.active;
console.error('状态更新失败:', error);
}
};
const updateUser = async () => {
if (!user.value) return;
saving.value = true;
try {
const payload: Partial<UserInfo> = {
name: user.value.name,
username: user.value.username,
email: user.value.email,
role: user.value.role,
language: user.value.language,
quota: user.value.quota,
unlimited_quota: user.value.unlimited_quota,
};
// Only include password if it's not empty
if (user.value.password) {
payload.password = user.value.password;
}
const res = await userStore.editUser(userId.value as string, payload);
console.log('updateUser', res)
if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success');
}
await userStore.refreshUser(userId.value as string);
} catch (err: any) {
console.error('Error updating user:', err.response?.data?.data?.error);
} finally {
saving.value = false;
}
};
//显示密码
const isPasswordVisible = ref(false);
const togglePasswordVisibility = () => {
isPasswordVisible.value = !isPasswordVisible.value;
};
// 格式化角色
const formatRole = (role?: number): string => {
switch (true) {
case (role ?? 0) > 10:
return 'Root';
case (role ?? 0) > 0:
return 'Admin';
default:
return 'U';
}
};
// 格式化日期
const formatDate = (dateString?: number): string | null => {
if (!dateString) return null;
try {
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(dateString * 1000)); // Multiply by 1000 for JavaScript Date
} catch (e) {
console.error("Error formatting date:", e);
return 'Invalid Date';
}
};
// 删除token
const revokeToken = (tokenId: TokenInfo['id']) => {
console.log('Revoking token:', tokenId);
};
const toggleEmailVerify = () => {
if (user.value && !user.value.email_verified) {
// todo
return
}
}
</script>
+41
View File
@@ -0,0 +1,41 @@
<template>
<div class="flex min-h-screen flex-col bg-base-100 text-base-content">
<header class="border-b border-base-300/40">
<div class="mx-auto flex max-w-5xl items-center gap-2 px-4 py-3">
<img src="@/assets/logo.svg" alt="OpenTeam logo" width="36" height="36" class="h-9 w-9 select-none" />
<a href="/" class="text-lg font-bold tracking-tight">OpenTeam</a>
</div>
</header>
<main class="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
<p class="text-7xl font-black tabular-nums tracking-tight text-primary/80 sm:text-8xl">404</p>
<h1 class="mt-4 text-2xl font-semibold tracking-tight">Page not found</h1>
<p class="mt-2 max-w-md text-pretty text-base-content/60">
The page you are looking for doesn't exist or has been moved.
</p>
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="/" class="btn btn-primary">Go Back Home</a>
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="btn btn-outline">
Visit GitHub Repo
</a>
</div>
</main>
<footer class="pb-8 text-center text-sm text-base-content/50">
<p>
Copyright © {{ currentYear }} Mirrors2 ·
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="link link-hover">opencatd-open</a>
</p>
</footer>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
const currentYear = ref('');
onMounted(() => {
currentYear.value = new Date().getFullYear().toString();
});
</script>
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** 后端 API 基础路径,默认 /api */
readonly VITE_API_BASE_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"useDefineForClassFields": true,
"paths": {
"@/*": ["./src/*"]
},
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}
+55
View File
@@ -0,0 +1,55 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import basicSsl from '@vitejs/plugin-basic-ssl'; // 推荐使用这个插件简化自签名证书管理
import path from 'path'
// 本地开发默认走 HTTP(localhost 属于安全上下文,clipboard/passkey 均可用);
// 需要自签名 HTTPS 时设置 VITE_DEV_HTTPS=true
const useHttps = process.env.VITE_DEV_HTTPS === 'true'
// 后端地址:默认 make dev-backend 启动的 8080,可用 VITE_DEV_API_TARGET 覆盖
const apiTarget = process.env.VITE_DEV_API_TARGET || 'http://localhost:8080'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue(), tailwindcss(), ...(useHttps ? [basicSsl()] : [])],
server: {
https: useHttps ? {} : undefined,
host: 'localhost', // 确保 host 是 localhost
port: 5173,
proxy: {
// 前端 axios baseURL 为 /api,开发时代理到本地 Go 后端,免去跨域与重建
'/api': {
target: apiTarget,
changeOrigin: true,
},
},
},
resolve: {
alias: {
'@': path.resolve(import.meta.dirname, 'src'),
},
},
build: {
// sourcemap: true,
chunkSizeWarningLimit: 1000,
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('src/components')) {
return 'components';
}
if (id.includes('src/views/dashboard')) {
return 'views-dashboard'
}
if (id.includes('src/stores')) {
return 'stores';
}
}
}
}
},
});
+86 -64
View File
@@ -1,102 +1,124 @@
module opencatd-open
go 1.21
toolchain go1.21.9
go 1.23.2
require (
cloud.google.com/go/vertexai v0.7.1
cloud.google.com/go/vertexai v0.13.1
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d
github.com/duke-git/lancet/v2 v2.3.0
github.com/bluele/gcache v0.0.2
github.com/coder/websocket v1.8.12
github.com/duke-git/lancet/v2 v2.3.3
github.com/faiface/beep v1.1.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/glebarez/sqlite v1.11.0
github.com/go-ozzo/ozzo-validation/v4 v4.3.0
github.com/go-webauthn/webauthn v0.12.3
github.com/golang-jwt/jwt v3.2.2+incompatible
github.com/google/generative-ai-go v0.12.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/generative-ai-go v0.18.0
github.com/google/uuid v1.6.0
github.com/google/wire v0.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/liushuangls/go-anthropic/v2 v2.15.0
github.com/mileusna/useragent v1.3.5
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pkoukk/tiktoken-go v0.1.6
github.com/robfig/cron/v3 v3.0.1
github.com/sashabaranov/go-openai v1.23.1
github.com/tidwall/gjson v1.17.1
golang.org/x/oauth2 v0.20.0
google.golang.org/api v0.180.0
gopkg.in/vansante/go-ffprobe.v2 v2.1.1
gorm.io/gorm v1.25.10
github.com/pkoukk/tiktoken-go v0.1.7
github.com/sashabaranov/go-openai v1.32.2
github.com/spf13/cobra v1.9.1
github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.37.0
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c
golang.org/x/sync v0.13.0
golang.org/x/time v0.10.0
google.golang.org/api v0.224.0
google.golang.org/genai v1.0.0
gopkg.in/vansante/go-ffprobe.v2 v2.2.0
gorm.io/driver/mysql v1.5.7
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
cloud.google.com/go v0.113.0 // indirect
cloud.google.com/go/ai v0.5.0 // indirect
cloud.google.com/go/aiplatform v1.66.0 // indirect
cloud.google.com/go/auth v0.4.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.2 // indirect
cloud.google.com/go/compute v1.26.0 // indirect
cloud.google.com/go/compute/metadata v0.3.0 // indirect
cloud.google.com/go/iam v1.1.7 // indirect
cloud.google.com/go/longrunning v0.5.7 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/ai v0.8.2 // indirect
cloud.google.com/go/aiplatform v1.74.0 // indirect
cloud.google.com/go/auth v0.15.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.4.0 // indirect
cloud.google.com/go/longrunning v0.6.4 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/glebarez/go-sqlite v1.22.0 // indirect
github.com/go-logr/logr v1.4.1 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.7 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
github.com/googleapis/gax-go/v2 v2.12.4 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/go-webauthn/x v0.1.20 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hajimehoshi/go-mp3 v0.3.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.26.0 // indirect
go.opentelemetry.io/otel/metric v1.26.0 // indirect
go.opentelemetry.io/otel/trace v1.26.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/text v0.15.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 // indirect
google.golang.org/grpc v1.64.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a // indirect
google.golang.org/grpc v1.71.1 // indirect
google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.50.5 // indirect
modernc.org/libc v1.61.0 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/sqlite v1.29.9 // indirect
modernc.org/sqlite v1.33.1 // indirect
)
+252 -304
View File
@@ -1,177 +1,127 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
cloud.google.com/go v0.112.1/go.mod h1:+Vbu+Y1UU+I1rjmzeMOb/8RfkKJK2Gyxi1X6jJCZLo4=
cloud.google.com/go v0.112.2 h1:ZaGT6LiG7dBzi6zNOvVZwacaXlmf3lRqnC4DQzqyRQw=
cloud.google.com/go v0.112.2/go.mod h1:iEqjp//KquGIJV/m+Pk3xecgKNhV+ry+vVTsy4TbDms=
cloud.google.com/go v0.113.0 h1:g3C70mn3lWfckKBiCVsAshabrDg01pQ0pnX1MNtnMkA=
cloud.google.com/go v0.113.0/go.mod h1:glEqlogERKYeePz6ZdkcLJ28Q2I6aERgDDErBg9GzO8=
cloud.google.com/go/ai v0.3.5-0.20240409161017-ce55ad694f21 h1:kSJt55RNa+qATWnX2xjyq9S2YGDxxBwpmUVZNuFLOi0=
cloud.google.com/go/ai v0.3.5-0.20240409161017-ce55ad694f21/go.mod h1:iX72tmUodGXVDxRDCGUZEPiB9HaMeERXkOdgCkUi8sA=
cloud.google.com/go/ai v0.4.1 h1:Rk+AZuehuKoA5E0FccwhU6JB3CPpsC9VUbsOMj8KxDk=
cloud.google.com/go/ai v0.4.1/go.mod h1:vJ7QwLSfisbbZAHFxiaPX8+g9Kuxt1DjXmGrEujThwU=
cloud.google.com/go/ai v0.5.0 h1:x8s4rDn5t9OVZvBCgtr5bZTH5X0O7JdE6zYo+O+MpRw=
cloud.google.com/go/ai v0.5.0/go.mod h1:96VBphk70e0zdXZrbtgPuKYRZsQ3UktSUXhuojwiKA8=
cloud.google.com/go/aiplatform v1.60.0 h1:0cSrii1ZeLr16MbBoocyy5KVnrSdiQ3KN/vtrTe7RqE=
cloud.google.com/go/aiplatform v1.60.0/go.mod h1:eTlGuHOahHprZw3Hio5VKmtThIOak5/qy6pzdsqcQnM=
cloud.google.com/go/aiplatform v1.66.0 h1:bbFYY4JInclG10czRFUYj2rjD+obhh3Gi9zVlyoMgEc=
cloud.google.com/go/aiplatform v1.66.0/go.mod h1:bPQS0UjaXaTAq57UgP3XWDCtYFOIbXXpkMsl6uP4JAc=
cloud.google.com/go/auth v0.4.0 h1:vcJWEguhY8KuiHoSs/udg1JtIRYm3YAWPBE1moF1m3U=
cloud.google.com/go/auth v0.4.0/go.mod h1:tO/chJN3obc5AbRYFQDsuFbL4wW5y8LfbPtDCfgwOVE=
cloud.google.com/go/auth v0.4.1 h1:Z7YNIhlWRtrnKlZke7z3GMqzvuYzdc2z98F9D1NV5Hg=
cloud.google.com/go/auth v0.4.1/go.mod h1:QVBuVEKpCn4Zp58hzRGvL0tjRGU0YqdRTdCHM1IHnro=
cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg=
cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40=
cloud.google.com/go/compute v1.26.0 h1:uHf0NN2nvxl1Gh4QO83yRCOdMK4zivtMS5gv0dEX0hg=
cloud.google.com/go/compute v1.26.0/go.mod h1:T9RIRap4pVHCGUkVFRJ9hygT3KCXjip41X1GgWtBBII=
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
cloud.google.com/go/iam v1.1.7 h1:z4VHOhwKLF/+UYXAJDFwGtNF0b6gjsW1Pk9Ml0U/IoM=
cloud.google.com/go/iam v1.1.7/go.mod h1:J4PMPg8TtyurAUvSmPj8FF3EDgY1SPRZxcUGrn7WXGA=
cloud.google.com/go/longrunning v0.5.6 h1:xAe8+0YaWoCKr9t1+aWe+OeQgN/iJK1fEgZSXmjuEaE=
cloud.google.com/go/longrunning v0.5.6/go.mod h1:vUaDrWYOMKRuhiv6JBnn49YxCPz2Ayn9GqyjaBT8/mA=
cloud.google.com/go/longrunning v0.5.7 h1:WLbHekDbjK1fVFD3ibpFFVoyizlLRl73I7YKuAKilhU=
cloud.google.com/go/longrunning v0.5.7/go.mod h1:8GClkudohy1Fxm3owmBGid8W0pSgodEMwEAztp38Xng=
cloud.google.com/go/vertexai v0.7.1 h1:CSdqsEwjklLIlI1e5SrsnkwG/I+CeJekkBbMTzeYhVg=
cloud.google.com/go/vertexai v0.7.1/go.mod h1:HfnfYR9aPS+qF2436S6Hzuw0Fp+PORjzK3ggqymdzSU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/ai v0.8.2 h1:LEaQwqBv+k2ybrcdTtCTc9OPZXoEdcQaGrfvDYS6Bnk=
cloud.google.com/go/ai v0.8.2/go.mod h1:Wb3EUUGWwB6yHBaUf/+oxUq/6XbCaU1yh0GrwUS8lr4=
cloud.google.com/go/aiplatform v1.74.0 h1:rE2P5H7FOAFISAZilmdkapbk4CVgwfVs6FDWlhGfuy0=
cloud.google.com/go/aiplatform v1.74.0/go.mod h1:hVEw30CetNut5FrblYd1AJUWRVSIjoyIvp0EVUh51HA=
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/iam v1.4.0 h1:ZNfy/TYfn2uh/ukvhp783WhnbVluqf/tzOaqVUPlIPA=
cloud.google.com/go/iam v1.4.0/go.mod h1:gMBgqPaERlriaOV0CUl//XUzDhSfXevn4OEUbg6VRs4=
cloud.google.com/go/longrunning v0.6.4 h1:3tyw9rO3E2XVXzSApn1gyEEnH2K9SynNQjMlBi3uHLg=
cloud.google.com/go/longrunning v0.6.4/go.mod h1:ttZpLCe6e7EXvn9OxpBRx7kZEB0efv8yBO6YnVMfhJs=
cloud.google.com/go/vertexai v0.13.1 h1:E6I+eA6vNQxz7/rb0wdILdKg4hFmMNWZLp+dSy9DnEo=
cloud.google.com/go/vertexai v0.13.1/go.mod h1:25DzKFzP9JByYxcNjJefu/px2dRjcRpCDSdULYL2avI=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d h1:3v1QFdgk450QH+7C+lw1k+olbjK4fKGsrEfnEG/HLkY=
github.com/Sakurasan/to v0.0.0-20180919163141-e72657dd7c7d/go.mod h1:2sp0vsMyh5sqmKl5N+ps/cSspqLkoXUlesSzsufIGRU=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0=
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.10.0 h1:+/GIL799phkJqYW+3YbOd8LCcbHzT0Pbo8zl70MHsq0=
github.com/dlclark/regexp2 v1.10.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/duke-git/lancet/v2 v2.2.7 h1:u9zr6HR+MDUvZEtTlAFtSTIgZfEFsN7cKi27n5weZsw=
github.com/duke-git/lancet/v2 v2.2.7/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/duke-git/lancet/v2 v2.3.0 h1:Ztie0qOnC4QgGYYqmpmQxbxkPcm54kqFXj1bwhiV8zg=
github.com/duke-git/lancet/v2 v2.3.0/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/duke-git/lancet/v2 v2.3.3 h1:OhqzNzkbJBS9ZlWLo/C7g+WSAOAAyNj7p9CAiEHurUc=
github.com/duke-git/lancet/v2 v2.3.3/go.mod h1:zGa2R4xswg6EG9I6WnyubDbFO/+A/RROxIbXcwryTsc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.10.0 h1:u4gt8y7OND/cCei/NMHmfbLxF6xP2wgKcT/BJf2pYkc=
github.com/glebarez/sqlite v1.10.0/go.mod h1:IJ+lfSOmiekhQsFTJRx/lHtGYmCdtAiTaf5wI9u5uHA=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.16.0 h1:x+plE831WK4vaKHO/jpgUGsvLKIqRRkz6M78GuJAfGE=
github.com/go-playground/validator/v10 v10.16.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-webauthn/webauthn v0.12.3 h1:hHQl1xkUuabUU9uS+ISNCMLs9z50p9mDUZI/FmkayNE=
github.com/go-webauthn/webauthn v0.12.3/go.mod h1:4JRe8Z3W7HIw8NGEWn2fnUwecoDzkkeach/NnvhkqGY=
github.com/go-webauthn/x v0.1.20 h1:brEBDqfiPtNNCdS/peu8gARtq8fIPsHz0VzpPjGvgiw=
github.com/go-webauthn/x v0.1.20/go.mod h1:n/gAc8ssZJGATM0qThE+W+vfgXiMedsWi3wf/C4lld0=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/generative-ai-go v0.11.0 h1:+wL9xu5jVIgJKC6NmZOxZsBYWDtIap7DGUZ1diQSSnk=
github.com/google/generative-ai-go v0.11.0/go.mod h1:RauvbBjc+AzW0b1LV0VSlxHI5n2i3dz8oJfjboOSiWQ=
github.com/google/generative-ai-go v0.11.2 h1:T/Liv2wfg+l3pYVuL9lOx3tsvVXzby/jwiUCxncRyag=
github.com/google/generative-ai-go v0.11.2/go.mod h1:gk9K/raHyN6r8vdbaNDn9X6GXzeE78iMjcrWm6a5x/Q=
github.com/google/generative-ai-go v0.12.0 h1:ocoAhazDpxDYgjTZdQ2aeVG+Sz4lvmhzfAlRRQF+mxU=
github.com/google/generative-ai-go v0.12.0/go.mod h1:ZTE7C93HuLGT6oJ1IJGt8dfo7HCHqBv3dVUGUCns0yE=
github.com/google/generative-ai-go v0.18.0 h1:6ybg9vOCLcI/UpBBYXOTVgvKmcUKFRNj+2Cj3GnebSo=
github.com/google/generative-ai-go v0.18.0/go.mod h1:JYolL13VG7j79kM5BtHz4qwONHkeJQzOCkKXnpqtS/E=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5 h1:5iH8iuqE5apketRbSFBy+X1V0o+l+8NF1avt4HWl7cA=
github.com/google/pprof v0.0.0-20240827171923-fa2c70bbbfe5/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
github.com/googleapis/gax-go/v2 v2.12.4 h1:9gWcmF85Wvq4ryPFvGFaOgPIs1AQX0d0bcbGw4Z96qg=
github.com/googleapis/gax-go/v2 v2.12.4/go.mod h1:KYEYLorsnIGDi/rPC8b5TdlB9kbKoFubselGIoBMCwI=
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
@@ -180,30 +130,50 @@ github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6Ee
github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/liushuangls/go-anthropic/v2 v2.15.0 h1:zpplg7BRV/9FlMmeMPI0eDwhViB0l9SkNrF8ErYlRoQ=
github.com/liushuangls/go-anthropic/v2 v2.15.0/go.mod h1:kq2yW3JVy1/rph8u5KzX7F3q95CEpCT2RXp/2nfCmb4=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -213,223 +183,201 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkoukk/tiktoken-go v0.1.6 h1:JF0TlJzhTbrI30wCvFuiw6FzP2+/bR+FIxUdgEAcUsw=
github.com/pkoukk/tiktoken-go v0.1.6/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw=
github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/sashabaranov/go-openai v1.17.9 h1:QEoBiGKWW68W79YIfXWEFZ7l5cEgZBV4/Ow3uy+5hNY=
github.com/sashabaranov/go-openai v1.17.9/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.23.1 h1:b2IsEG9+BdJ3f6G3gGu9Lon2Mw/C0aYqME3YzwBHcls=
github.com/sashabaranov/go-openai v1.23.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sashabaranov/go-openai v1.32.2 h1:8z9PfYaLPbRzmJIYpwcWu6z3XU8F+RwVMF1QRSeSF2M=
github.com/sashabaranov/go-openai v1.32.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0 h1:A3SayB3rNyt+1S6qpI9mHPkeHTZbD7XILEqWnYZb2l0=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.51.0/go.mod h1:27iA5uvhuRNmalO+iEUdVn5ZMj2qy10Mm+XRIpRmyuU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs=
go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4=
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30=
go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4=
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA=
go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.6.0 h1:S0JTfE48HbRj80+4tbvZDYsJ3tGv6BUU3XxyZ7CirAc=
golang.org/x/arch v0.6.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No=
golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c h1:7dEasQXItcW1xKJ2+gg5VOiBnqWrJc+rq0DPKyvvdbY=
golang.org/x/exp v0.0.0-20241009180824-f66d83c29e7c/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8=
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.173.0 h1:fz6B7GWYWLS/HfruiTsRYVKQQApJ6vasTYWAK6+Qo8g=
google.golang.org/api v0.173.0/go.mod h1:ins7pTzjeBPQ3SdC/plzki6d/dQWwAWy8qVZ4Vgkzl8=
google.golang.org/api v0.178.0 h1:yoW/QMI4bRVCHF+NWOTa4cL8MoWL3Jnuc7FlcFF91Ok=
google.golang.org/api v0.178.0/go.mod h1:84/k2v8DFpDRebpGcooklv/lais3MEfqpaBLA12gl2U=
google.golang.org/api v0.180.0 h1:M2D87Yo0rGBPWpo1orwfCLehUUL6E7/TYe5gvMQWDh4=
google.golang.org/api v0.180.0/go.mod h1:51AiyoEg1MJPSZ9zvklA8VnRILPXxn1iVen9v25XHAE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y=
google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s=
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda h1:wu/KJm9KJwpfHWhkkZGohVC6KRrc1oJNr4jwtQMOQXw=
google.golang.org/genproto v0.0.0-20240401170217-c3f982113cda/go.mod h1:g2LLCvCeCSir/JJSWosk19BR4NVxGqHUC6rxIRsd7Aw=
google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda h1:b6F6WIV4xHHD0FA4oIyzU6mHWg2WI2X1RBehwa5QN38=
google.golang.org/genproto/googleapis/api v0.0.0-20240401170217-c3f982113cda/go.mod h1:AHcE/gZH76Bk/ROZhQphlRoWo5xKDEtz3eVEO1LfA8c=
google.golang.org/genproto/googleapis/api v0.0.0-20240506185236-b8a5c65736ae h1:AH34z6WAGVNkllnKs5raNq3yRq93VnjBG6rpfub/jYk=
google.golang.org/genproto/googleapis/api v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:FfiGhwUm6CJviekPrc0oJ+7h29e+DmWU6UtjX0ZvI7Y=
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8 h1:W5Xj/70xIA4x60O/IFyXivR5MGqblAb8R3w26pnD6No=
google.golang.org/genproto/googleapis/api v0.0.0-20240513163218-0867130af1f8/go.mod h1:vPrPUTsDCYxXWjP7clS81mZ6/803D8K4iM9Ma27VKas=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa h1:RBgMaUMP+6soRkik4VoN8ojR2nex2TqZwjSSogic+eo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240325203815-454cdb8f5daa/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae h1:c55+MER4zkBS14uJhSZMGGmya0yJx5iHV4x/fpOSNRk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240506185236-b8a5c65736ae/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8 h1:mxSlqyb8ZAHsYDCfiXN1EDdNTdvjUJSLY+OnAUtYNYA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240513163218-0867130af1f8/go.mod h1:I7Y+G38R2bu5j1aLzfFmQfTcU/WnFuqDwLZAbvKTKpM=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.62.1 h1:B4n+nfKzOICUXMgyrNd19h/I9oH0L1pizfk1d4zSgTk=
google.golang.org/grpc v1.62.1/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM=
google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY=
google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.224.0 h1:Ir4UPtDsNiwIOHdExr3fAj4xZ42QjK7uQte3lORLJwU=
google.golang.org/api v0.224.0/go.mod h1:3V39my2xAGkodXy0vEqcEtkqgw2GtrFL5WuBZlCTCOQ=
google.golang.org/genai v1.0.0 h1:9IIZimT9bJm0wiF55VAoGCL8MfOAZcwqRRlxZZ/KSoc=
google.golang.org/genai v1.0.0/go.mod h1:TyfOKRz/QyCaj6f/ZDt505x+YreXnY40l2I6k8TvgqY=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb h1:ITgPrl429bc6+2ZraNSzMDk3I95nmQln2fuPstKwFDE=
google.golang.org/genproto v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:sAo5UzpjUwgFBCzupwhcLcxHVDK7vG5IqI30YnwX2eE=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a h1:GIqLhp/cYUkuGuiT+vJk8vhOP86L4+SP5j8yXgeVpvI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250409194420-de1ac958c67a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.1 h1:ffsFWr7ygTUscGPI0KKK6TLrGz0476KUvvsbqWK0rPI=
google.golang.org/grpc v1.71.1/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/vansante/go-ffprobe.v2 v2.1.1 h1:DIh5fMn+tlBvG7pXyUZdemVmLdERnf2xX6XOFF+0BBU=
gopkg.in/vansante/go-ffprobe.v2 v2.1.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/vansante/go-ffprobe.v2 v2.2.0 h1:iuOqTsbfYuqIz4tAU9NWh22CmBGxlGHdgj4iqP+NUmY=
gopkg.in/vansante/go-ffprobe.v2 v2.2.0/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/libc v1.35.0 h1:EQ4szx6Q/QLZuysmAnI4dfRnKbAbNlENp23ruvTJ2nE=
modernc.org/libc v1.35.0/go.mod h1:YAXkAZ8ktnkCKaN9sw/UDeUVkGYJ/YquGO4FTi5nmHE=
modernc.org/libc v1.50.5 h1:ZzeUd0dIc/sUtoPTCYIrgypkuzoGzNu6kbEWj2VuEmk=
modernc.org/libc v1.50.5/go.mod h1:rhzrUx5oePTSTIzBgM0mTftwWHK8tiT9aNFUt1mldl0=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.21.0 h1:kKPI3dF7RIag8YcToh5ZwDcVMIv6VGa0ED5cvh0LMW4=
modernc.org/ccgo/v4 v4.21.0/go.mod h1:h6kt6H/A2+ew/3MW/p6KEoQmrq/i3pr0J/SiwiaF/g0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.5.0 h1:bJ9ChznK1L1mUtAQtxi0wi5AtAs5jQuw4PrPHO5pb6M=
modernc.org/gc/v2 v2.5.0/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.61.0 h1:eGFcvWpqlnoGwzZeZe3PWJkkKbM/3SUGyk1DVZQ0TpE=
modernc.org/libc v1.61.0/go.mod h1:DvxVX89wtGTu+r72MLGhygpfi3aUGgZRdAYGCAVVud0=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/sqlite v1.27.0 h1:MpKAHoyYB7xqcwnUwkuD+npwEa0fojF0B5QRbN+auJ8=
modernc.org/sqlite v1.27.0/go.mod h1:Qxpazz0zH8Z1xCFyi5GSL3FzbtZ3fvbjmywNogldEW0=
modernc.org/sqlite v1.29.9 h1:9RhNMklxJs+1596GNuAX+O/6040bvOwacTxuFcRuQow=
modernc.org/sqlite v1.29.9/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+77
View File
@@ -0,0 +1,77 @@
package auth
import (
"errors"
"opencatd-open/internal/model"
"time"
"github.com/golang-jwt/jwt/v5"
)
type Claims struct {
UserID int64 `json:"user_id"`
Name string `json:"name"`
Type string `json:"type"`
jwt.RegisteredClaims
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token,omitempty"`
}
func GenerateTokenPair(user *model.User, secret string, accessExpire, refreshExpire time.Duration) (*TokenPair, error) {
// Generate access token
accessToken, err := generateToken(user, "access", secret, accessExpire)
if err != nil {
return nil, err
}
// Generate refresh token
refreshToken, err := generateToken(user, "refresh", secret, refreshExpire)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessToken,
RefreshToken: refreshToken,
}, nil
}
func generateToken(user *model.User, tokenType, secret string, expire time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: user.ID,
Name: user.Username,
Type: tokenType,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(expire)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}
func ValidateToken(tokenString, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrInvalidKey
}
+91
View File
@@ -0,0 +1,91 @@
package cli
import (
"encoding/json"
"fmt"
"log"
"opencatd-open/internal/model"
"opencatd-open/pkg/store"
"os"
"strings"
"github.com/duke-git/lancet/v2/fileutil"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
var LoadCmd = &cobra.Command{
Use: "load",
Short: "import user.json -> db",
Long: "\nimport user.json -> db",
Run: func(cmd *cobra.Command, args []string) {
db := store.GetDB()
var cont int64
if err := db.Model(model.User{}).Count(&cont).Error; err != nil {
fmt.Println(err)
return
}
if cont == 0 {
fmt.Println("创建管理员之后再操作")
}
if !fileutil.IsExist("./db/user.json") {
log.Fatalln("404! user.json is not found.")
return
}
file, err := os.Open("./db/user.json")
if err != nil {
fmt.Println("Error opening file:", err)
return
}
defer file.Close()
var usermap []map[string]string
if err := json.NewDecoder(file).Decode(&usermap); err != nil {
fmt.Println("解析文件失败:", err)
return
}
for _, um := range usermap {
var name string
if um["username"] != "" {
name = um["name"]
} else if um["name"] == "" {
name = um["username"]
} else {
fmt.Println("获取不到数据")
continue
}
var user = model.User{
Username: name,
Name: name,
Tokens: []model.Token{
{
Name: "default",
Key: "sk-team-" + strings.ReplaceAll(uuid.New().String(), "-", ""),
},
{
Name: name,
Key: um["token"],
},
},
}
if err := db.Create(&user).Error; err != nil {
fmt.Printf("\nCreate User %s Error:%s", user.Username, err)
}
}
},
}
var SaveCmd = &cobra.Command{
Use: "save",
Short: "backup user info -> user.json",
Run: func(cmd *cobra.Command, args []string) {
},
}
func init() {
// SaveCmd.Flags().StringP("user", "u", "", "Save User")
}
+62
View File
@@ -0,0 +1,62 @@
package consts
import "gorm.io/gorm"
const Logo = `
____ _____
/ __ \ |_ _|
| | | |_ __ ___ _ __ | | ___ __ _ _ __ ___
| | | | '_ \ / _ \ '_ \ | | / _ \/ _' | '_ ' _ \
| |__| | |_) | __/ | | | | || __/ (_| | | | | | |
\____/| .__/ \___|_| |_| \_/ \___|\__,_|_| |_| |_|
| |
|_|
https://github.com/mirrors2/openteam
---------------------------------------------------
`
const SecretKey = "openteam"
const Day = 24 * 60 * 60 // day := 86400
type UserRole int
const (
RoleUser UserRole = iota * 10
RoleAdmin
RoleRoot
)
const (
StatusDisabled = iota
StatusEnabled
StatusExpired // 过期
StatusExhausted // 耗尽
StatusDeleted = -1
)
const (
Limited = iota
Unlimited
UnlimitedQuota = 999999
)
var (
ErrUserNotFound = gorm.ErrRecordNotFound
)
func OpenOrClose(status bool) int {
if status {
return StatusEnabled
}
return StatusDisabled
}
// type DBType int
// const (
// DBTypeMySQL DBType = iota
// DBTypePostgreSQL
// DBTypeSQLite
// )
+177
View File
@@ -0,0 +1,177 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"opencatd-open/internal/consts"
"opencatd-open/internal/dto"
"opencatd-open/internal/model"
"opencatd-open/internal/utils"
"strconv"
"strings"
"github.com/duke-git/lancet/v2/slice"
"github.com/gin-gonic/gin"
)
func (a Api) CreateApiKey(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
newkey := new(model.ApiKey)
err := c.ShouldBind(newkey)
if err != nil {
dto.Fail(c, 400, err.Error())
}
if slice.Contain([]string{"openai", "azure", "claude"}, *newkey.ApiType) {
sma, err := utils.FetchKeyModel(a.db, newkey)
if err == nil && len(sma) > 0 {
newkey.SupportModelsArray = sma
var buf = new(bytes.Buffer)
json.NewEncoder(buf).Encode(sma) //nolint:errcheck
newkey.SupportModels = utils.ToPtr(buf.String())
}
}
err = a.keyService.CreateApiKey(c, newkey)
if err != nil {
dto.Fail(c, 400, err.Error())
} else {
dto.Success(c, nil)
}
}
func (a Api) GetApiKey(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
dto.Fail(c, 400, err.Error())
return
}
key, err := a.keyService.GetApiKey(c, id)
if err != nil {
dto.Fail(c, 400, err.Error())
} else {
dto.Success(c, key)
}
}
func (a Api) ListApiKey(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
limit, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
offset := (page - 1) * limit
active := c.QueryArray("active[]")
if !slice.ContainSubSlice([]string{"true", "false"}, active) {
dto.Fail(c, http.StatusBadRequest, "active must be true or false")
return
}
keys, total, err := a.keyService.ListApiKey(c, limit, offset, active)
if err != nil {
dto.Fail(c, 500, err.Error())
} else {
for _, key := range keys {
str := *key.ApiKey
slen := len(str)
if slen > 20 {
slen = 20
}
str = str[:slen]
key.ApiKey = &str
var sma []string
json.NewDecoder(strings.NewReader(*key.SupportModels)).Decode(&sma) //nolint:errcheck
key.SupportModelsArray = sma
}
dto.Success(c, gin.H{
"total": total,
"keys": keys,
})
}
}
func (a Api) DeleteApiKey(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
var batchid dto.BatchIDRequest
err := c.ShouldBind(&batchid)
if err != nil {
dto.Fail(c, 400, err.Error())
return
}
err = a.keyService.DeleteApiKey(c, batchid.IDs)
if err != nil {
dto.Fail(c, 500, err.Error())
} else {
dto.Success(c, nil)
}
}
func (a Api) UpdateApiKey(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
var req model.ApiKey
err := c.ShouldBind(&req)
if err != nil {
dto.Fail(c, 400, err.Error())
return
}
err = a.keyService.UpdateApiKey(c, &req)
if err != nil {
dto.Fail(c, 500, err.Error())
} else {
dto.Success(c, nil)
}
}
func (a Api) ApiKeyOption(c *gin.Context) {
role := c.MustGet("user_role").(*consts.UserRole)
if *role < consts.RoleAdmin {
dto.Fail(c, 403, "Permission denied")
return
}
option := strings.ToLower(c.Param("option"))
var batchid dto.BatchIDRequest
err := c.ShouldBind(&batchid)
if err != nil {
dto.Fail(c, 400, err.Error())
return
}
switch option {
case "enable":
err = a.keyService.EnableApiKey(c, batchid.IDs)
case "disable":
err = a.keyService.DisableApiKey(c, batchid.IDs)
case "delete":
err = a.keyService.DeleteApiKey(c, batchid.IDs)
default:
dto.Fail(c, 400, "invalid option, only support enable, disable, delete")
return
}
if err != nil {
dto.Fail(c, 400, err.Error())
return
}
dto.Success(c, nil)
}

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