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)
This commit is contained in:
Sakurasan
2026-08-29 23:27:31 +08:00
parent 4a68ff6162
commit ac0a6808ec
55 changed files with 2497 additions and 2318 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
+8 -1
View File
@@ -3,4 +3,11 @@ test/
demo/ demo/
*.log *.log
*.db *.db
.env .env
# 构建产物(make web 生成,由 go:embed 打进二进制);保留 .gitkeep 占位使未构建前也能编译
cmd/openteam/dist/*
!cmd/openteam/dist/.gitkeep
# 误生成的目录(仅含 dist/node_modules)
web/
+230
View File
@@ -0,0 +1,230 @@
# 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 由前端阶段提供)。
View File
+3 -1
View File
@@ -14,7 +14,9 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
//go:embed dist/* // all:dist 使 dist 只含 .gitkeep 占位(尚未构建前端)时也能编译通过,
// 本地 go run ./cmd/openteam 无需先跑 pnpm build
//go:embed all:dist
var web embed.FS var web embed.FS
func main() { func main() {
+23 -16
View File
@@ -1,30 +1,37 @@
FROM node:20-alpine AS frontend # syntax=docker/dockerfile:1
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
# 只编译一次,不再被 QEMU 模拟执行两遍
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /frontend-build WORKDIR /frontend-build
COPY ./frontend . COPY ./frontend ./
RUN npm install -g pnpm@10.25.0 \
&& pnpm install --frozen-lockfile \
&& pnpm build
RUN npm install -g pnpm && pnpm i && pnpm build # 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建
FROM golang:1.23-alpine AS backend FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend
LABEL anther="github.com/Sakurasan" LABEL author="github.com/Sakurasan"
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories ARG TARGETOS
RUN apk --no-cache add make cmake upx ARG TARGETARCH
RUN apk --no-cache add make upx
WORKDIR /build WORKDIR /build
COPY . . COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
ENV GO111MODULE=on ENV GO111MODULE=on \
# ENV GOPROXY=https://goproxy.cn,direct CGO_ENABLED=0 \
CMD [ "go mod tidy","go mod download" ] GOOS=$TARGETOS \
GOARCH=$TARGETARCH
RUN make build RUN make build
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
# RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories RUN apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
RUN apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && echo "Asia/Shanghai" > /etc/timezone
&& echo "Asia/Shanghai" > /etc/timezone
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
WORKDIR /app WORKDIR /app
COPY --from=backend /build/bin/openteam /app/openteam COPY --from=backend /build/bin/openteam /app/openteam
ENV GIN_MODE=release ENV GIN_MODE=release
ENV PATH=$PATH:/app ENV PATH=$PATH:/app
EXPOSE 80 443 EXPOSE 80 443
ENTRYPOINT ["/app/openteam"] ENTRYPOINT ["/app/openteam"]
+28 -16
View File
@@ -1,30 +1,42 @@
FROM node:20-alpine AS frontend # syntax=docker/dockerfile:1
# 国内镜像加速版:npm/apk/go proxy 均走国内源
# 前端阶段固定在构建机原生平台编译:多架构构建(linux/amd64,linux/arm64)时
# 只编译一次,不再被 QEMU 模拟执行两遍
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend
WORKDIR /frontend-build WORKDIR /frontend-build
COPY ./frontend . 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
RUN npm config set registry https://registry.npmmirror.com && npm install -g pnpm --registry=https://registry.npmmirror.com && pnpm i && pnpm build # 后端:go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO 直接交叉编译到目标架构,
# 因此同样固定在原生平台构建
FROM golang:1.23-alpine AS backend FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend
LABEL anther="github.com/Sakurasan" LABEL author="github.com/Sakurasan"
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories ARG TARGETOS
RUN apk --no-cache add make cmake upx 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 WORKDIR /build
COPY . . COPY . .
COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist COPY --from=frontend /frontend-build/dist /build/cmd/openteam/dist
ENV GO111MODULE=on ENV GO111MODULE=on \
ENV GOPROXY=https://goproxy.cn,direct GOPROXY=https://goproxy.cn,direct \
CMD [ "go mod tidy","go mod download" ] CGO_ENABLED=0 \
GOOS=$TARGETOS \
GOARCH=$TARGETARCH
RUN make build RUN make build
FROM alpine:latest AS runner FROM alpine:latest AS runner
# 设置alpine 时间为上海时间 # 设置alpine 时间为上海时间
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
RUN apk update && apk --no-cache add tzdata ffmpeg && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ && apk update && apk --no-cache add tzdata ffmpeg ca-certificates && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
&& echo "Asia/Shanghai" > /etc/timezone && echo "Asia/Shanghai" > /etc/timezone
# RUN apk update && apk --no-cache add openssl libgcc libstdc++ binutils
WORKDIR /app WORKDIR /app
COPY --from=backend /build/bin/openteam /app/openteam COPY --from=backend /build/bin/openteam /app/openteam
ENV GIN_MODE=release ENV GIN_MODE=release
ENV PATH=$PATH:/app ENV PATH=$PATH:/app
EXPOSE 80 443 EXPOSE 80 443
ENTRYPOINT ["/app/openteam"] ENTRYPOINT ["/app/openteam"]
+35 -1
View File
@@ -1,3 +1,37 @@
# OpenTeam Frontend # 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 # 类型检查 + 生产构建
```
+1 -1
View File
@@ -8,6 +8,6 @@
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+31 -22
View File
@@ -1,33 +1,42 @@
{ {
"name": "my-project", "name": "opencatd-open-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview" "preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@iconify/vue": "^5.0.1",
"@vitejs/plugin-basic-ssl": "^2.0.0", "@lucide/vue": "^1.37.0",
"axios": "^1.8.4", "@simplewebauthn/browser": "^13.3.0",
"element-plus": "^2.9.7", "axios": "^1.20.0",
"lucide-vue-next": "^0.479.0", "pinia": "^4.0.3",
"qrcode.vue": "^3.6.0", "qrcode.vue": "^3.10.0",
"vue": "^3.5.13", "vue": "^3.5.42",
"vue-router": "^4.5.0" "vue-router": "^5.3.0"
}, },
"devDependencies": { "devDependencies": {
"@iconify-json/mingcute": "^1.2.3", "@iconify-json/mingcute": "^1.2.8",
"@iconify-json/simple-icons": "^1.2.32", "@iconify-json/simple-icons": "^1.2.94",
"@iconify/vue": "^4.3.0", "@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-vue": "^5.2.3", "@types/node": "^26.4.0",
"autoprefixer": "^10.4.21", "@vitejs/plugin-basic-ssl": "^2.3.0",
"daisyui": "^4.12.24", "@vitejs/plugin-vue": "^6.0.8",
"pinia": "^2.3.1", "daisyui": "^5.7.22",
"postcss": "^8.5.3", "tailwindcss": "^4.3.3",
"tailwindcss": "^3.4.17", "typescript": "^6.0.3",
"vite": "^6.3.0" "vite": "^8.2.2",
} "vue-tsc": "^3.3.11"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"vue-demi"
]
},
"packageManager": "pnpm@10.25.0"
} }
+1181 -1578
View File
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+7 -6
View File
@@ -5,14 +5,15 @@
</template> </template>
<script setup> <script setup lang="ts">
import { onMounted, ref, provide } from 'vue'; import { onMounted, ref, provide } from 'vue';
import Toast from './components/Toast.vue'; import Toast from '@/components/common/Toast.vue';
import { ToastKey } from './composables/toast';
import type { ToastMessage, ToastType } from './composables/toast';
const toastQueue = ref<ToastMessage[]>([]);
const toastQueue = ref([]); const setToast = (message: string, type: ToastType = 'info', duration?: number) => {
const setToast = (message, type = 'info', duration) => {
toastQueue.value.push({ message, type, duration }); toastQueue.value.push({ message, type, duration });
}; };
@@ -20,5 +21,5 @@ onMounted(() => {
// authStore.checkLoginStatus(); // authStore.checkLoginStatus();
}); });
provide('toast', { setToast }); provide(ToastKey, { setToast });
</script> </script>
@@ -1,8 +1,9 @@
// src/utils/request.js // src/api/client.ts
import axios from 'axios'; import axios from 'axios'
import { useAuthStore } from '@/stores/auth'; import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '@/stores/auth'
const baseURL = import.meta.env.VITE_API_BASE_URL|| '/api' const baseURL = import.meta.env.VITE_API_BASE_URL || '/api'
if (import.meta.env.DEV) { // Vite 的方式判断开发环境 if (import.meta.env.DEV) { // Vite 的方式判断开发环境
console.log(`[Request] API Base URL: ${baseURL}`); console.log(`[Request] API Base URL: ${baseURL}`);
} else if (process.env.NODE_ENV === 'development') { // Vue CLI 的方式判断开发环境 } else if (process.env.NODE_ENV === 'development') { // Vue CLI 的方式判断开发环境
@@ -10,8 +11,8 @@ if (import.meta.env.DEV) { // Vite 的方式判断开发环境
} }
const service = axios.create({ const service = axios.create({
baseURL: baseURL, baseURL: baseURL,
timeout: 6000, timeout: 6000,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
@@ -19,29 +20,29 @@ const service = axios.create({
// 请求拦截器 // 请求拦截器
service.interceptors.request.use( service.interceptors.request.use(
config => { (config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore(); const authStore = useAuthStore();
if (!authStore.token) { if (!authStore.token) {
authStore.loadTokenFromStorage(); authStore.loadTokenFromStorage();
} }
if (authStore.token) { if (authStore.token) {
config.headers.Authorization = `Bearer ${authStore.token}`; config.headers.Authorization = `Bearer ${authStore.token}`;
} }
return config; return config;
}, },
error => { (error: AxiosError) => {
console.error('Request error:', error); console.error('Request error:', error);
return Promise.reject(error); return Promise.reject(error);
} }
); );
// 响应拦截器 // 响应拦截器
service.interceptors.response.use( service.interceptors.response.use(
response => { (response) => {
return response; // 只返回响应数据,便于后续使用 return response; // 只返回响应数据,便于后续使用
}, },
error => { (error: AxiosError) => {
// 可以在这里处理响应错误的情况,例如统一处理错误信息, 提示用户等 // 可以在这里处理响应错误的情况,例如统一处理错误信息, 提示用户等
console.error('Response error:', error); console.error('Response error:', error);
// 这里可以做一些统一的错误处理,例如根据状态码判断是否 token 失效,并跳转到登录页面 // 这里可以做一些统一的错误处理,例如根据状态码判断是否 token 失效,并跳转到登录页面
@@ -54,4 +55,4 @@ service.interceptors.response.use(
} }
); );
export default service; export default service;
@@ -7,7 +7,7 @@
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20"> <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')"> <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"> <img src="@/assets/logo.svg" alt="Center Logo" class="rounded-full object-cover">
</div> </div>
</div> </div>
@@ -15,7 +15,7 @@
<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"> 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 }" <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"> 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"> <img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else <div v-else
@@ -28,7 +28,7 @@
<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"> 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 }" <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"> 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"> <img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
<div v-else <div v-else
@@ -90,16 +90,19 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick, reactive, computed } from 'vue'; // 引入 computed 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([ const leftIcons = ref<FlowIcon[]>([
{ id: 'web', name: 'Web', img: 'https://img.icons8.com/?size=100&id=38536&format=png&color=000000', color: '#DB4437' }, { 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: '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' }, { id: 'mac', name: 'Mac', img: 'https://img.icons8.com/?size=100&id=RHxDgbKmJhUD&format=png&color=000000', color: '#1DB954' },
]); ]);
const rightIcons = ref([ const rightIcons = ref<FlowIcon[]>([
{ id: 'openai', name: 'OpenAI', img: 'https://img.icons8.com/?size=100&id=FBO05Dys9QCg&format=png&color=000000', color: '#E4405F' }, { 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: '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: 'gemini', name: 'Gemini', img: 'https://img.icons8.com/?size=100&id=eoxMN35Z6JKg&format=png&color=000000', color: '#FF6600' },
@@ -117,14 +120,14 @@ const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线
// --- 结束 Dash 动画参数 --- // --- 结束 Dash 动画参数 ---
const svgCanvas = ref(null); const svgCanvas = ref<SVGSVGElement | null>(null);
const centerElement = ref(null); const centerElement = ref<HTMLElement | null>(null);
const iconRefs = reactive({}); const iconRefs = reactive<Record<string, Element | null>>({});
const centerCoords = ref(null); const centerCoords = ref<Coords | null>(null);
const iconCoords = reactive({}); const iconCoords = reactive<Record<string, Coords | null>>({});
// (getElementCenterCoords 和 updateCoordinates 函数保持不变) // (getElementCenterCoords 和 updateCoordinates 函数保持不变)
const getElementCenterCoords = (element) => { const getElementCenterCoords = (element: Element | null): Coords | null => {
if (!element || !svgCanvas.value) return null; if (!element || !svgCanvas.value) return null;
const svgRect = svgCanvas.value.getBoundingClientRect(); const svgRect = svgCanvas.value.getBoundingClientRect();
const elemRect = element.getBoundingClientRect(); const elemRect = element.getBoundingClientRect();
@@ -157,12 +160,12 @@ const updateCoordinates = () => {
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation) // (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
/** /**
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心) * 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
* @param {object} iconCoord 图标坐标 {x, y} * @param iconCoord 图标坐标 {x, y}
* @param {object} centerCoord 中心坐标 {x, y} * @param centerCoord 中心坐标 {x, y}
* @param {'left' | 'right'} side 图标在哪一侧 * @param side 图标在哪一侧
* @returns {string} SVG path 'd' 属性字符串 * @returns SVG path 'd' 属性字符串
*/ */
const calculatePathForVisual = (iconCoord, centerCoord, side) => { const calculatePathForVisual = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right'): string => {
if (!iconCoord || !centerCoord) return ''; if (!iconCoord || !centerCoord) return '';
const { x: startX, y: startY } = iconCoord; const { x: startX, y: startY } = iconCoord;
const { x: endX, y: endY } = centerCoord; const { x: endX, y: endY } = centerCoord;
@@ -175,17 +178,17 @@ const calculatePathForVisual = (iconCoord, centerCoord, side) => {
/** /**
* 计算动画运动的 SVG 路径 * 计算动画运动的 SVG 路径
* @param {object} iconCoord 图标坐标 {x, y} * @param iconCoord 图标坐标 {x, y}
* @param {object} centerCoord 中心坐标 {x, y} * @param centerCoord 中心坐标 {x, y}
* @param {'left' | 'right'} side 图标在哪一侧 * @param side 图标在哪一侧
* @param {'toCenter' | 'fromCenter'} direction 动画方向 * @param direction 动画方向
* @returns {string} SVG path 'd' 属性字符串 * @returns SVG path 'd' 属性字符串
*/ */
const calculatePathForAnimation = (iconCoord, centerCoord, side, direction) => { const calculatePathForAnimation = (iconCoord: Coords | null | undefined, centerCoord: Coords | null, side: 'left' | 'right', direction: 'toCenter' | 'fromCenter' = 'toCenter'): string => {
if (!iconCoord || !centerCoord) return ''; if (!iconCoord || !centerCoord) return '';
let startX, startY, endX, endY; let startX: number, startY: number, endX: number, endY: number;
let controlX, controlY; let controlX: number, controlY: number;
if (direction === 'fromCenter') { if (direction === 'fromCenter') {
// --- 动画从中心开始 --- // --- 动画从中心开始 ---
@@ -219,7 +222,7 @@ const calculatePathForAnimation = (iconCoord, centerCoord, side, direction) => {
// --- 生命周期钩子 (保持不变) --- // --- 生命周期钩子 (保持不变) ---
let resizeObserver; let resizeObserver: ResizeObserver | undefined;
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
@@ -71,33 +71,25 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { computed, ref ,watch} from 'vue'; import { computed, ref, watch } from 'vue';
const props = defineProps({ const props = withDefaults(defineProps<{
currentPage: { currentPage: number;
type: Number, totalItems: number;
required: true, pageSize?: number;
}, pageSizeOptions?: number[];
totalItems: { showSelectPageSize?: boolean;
type: Number, }>(), {
required: true, pageSize: 10,
}, pageSizeOptions: () => [10, 25, 50, 100],
pageSize: { showSelectPageSize: true,
type: Number,
default: 10,
},
pageSizeOptions: {
type: Array,
default: () => [10, 25, 50, 100],
},
showSelectPageSize: {
type: Boolean,
default: true,
},
}); });
const emit = defineEmits(['changePage', 'changePageSize']); const emit = defineEmits<{
(e: 'changePage', page: number, pageSize: number): void;
(e: 'changePageSize', pageSize: number): void;
}>();
const totalPages = computed(() => { const totalPages = computed(() => {
return Math.max(1, Math.ceil(props.totalItems / props.pageSize)); return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
@@ -116,7 +108,7 @@ watch(() => props.pageSize, (newPageSize) => {
localPageSize.value = newPageSize; localPageSize.value = newPageSize;
}); });
const emitChangePage = (page, pageSize) => { // 添加了 pageSize 参数 const emitChangePage = (page: number, pageSize: number) => { // 添加了 pageSize 参数
const validPage = Math.max(1, Math.min(page, totalPages.value)); const validPage = Math.max(1, Math.min(page, totalPages.value));
if (validPage !== localCurrentPage.value) { if (validPage !== localCurrentPage.value) {
localCurrentPage.value = validPage; localCurrentPage.value = validPage;
@@ -124,8 +116,8 @@ const emitChangePage = (page, pageSize) => { // 添加了 pageSize 参数
} }
}; };
const emitChangePageSize = (event) => { const emitChangePageSize = (event: Event) => {
const newPageSize = parseInt(event.target.value, 10); const newPageSize = parseInt((event.target as HTMLSelectElement).value, 10);
localPageSize.value = newPageSize; localPageSize.value = newPageSize;
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
}; };
@@ -40,20 +40,16 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, reactive, watch } from 'vue'; import { ref, reactive, watch } from 'vue';
import QrcodeVue from 'qrcode.vue'; import QrcodeVue from 'qrcode.vue';
// 定义组件接收的 props // 组件接收的 props
const props = defineProps({ const props = withDefaults(defineProps<{
value: { // 二维码的原始值 value: string; // 二维码的原始值
type: String, size?: number; // 二维码的尺寸 (像素)
required: true }>(), {
}, size: 160, // 默认大小
size: { // 二维码的尺寸 (像素)
type: Number,
default: 160 // 默认大小
}
}); });
// 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值 // 使用 ref 创建一个响应式变量,用于存储当前显示的二维码值
@@ -76,21 +72,21 @@ const copyValue = async () => {
setTimeout(() => { setTimeout(() => {
showCopied.value = false; showCopied.value = false;
}, 1500); // 1.5 秒后恢复 }, 1500); // 1.5 秒后恢复
} catch (err) { } catch (err: any) {
console.error('Failed to copy: ', err); console.error('Failed to copy: ', err);
// 可以在这里添加错误提示 // 可以在这里添加错误提示
} }
}; };
const applist = reactive([ const applist = reactive<{ name: string; url: string }[]>([
// { name: 'openteam', url: '/assets/logo.svg' }, // { name: 'openteam', url: '/assets/logo.svg' },
{ name: 'botgem', url: 'https://botgem.com/favicon.ico' }, { name: 'botgem', url: 'https://botgem.com/favicon.ico' },
{ name: 'opencat', url: 'https://opencat.app/favicon.ico' }, { name: 'opencat', url: 'https://opencat.app/favicon.ico' },
]) ])
const applyPrefix = (name) => { const applyPrefix = (name: string) => {
let origin = window.location.origin; let origin = window.location.origin;
switch (name) { switch (name) {
case 'botgem': case 'botgem':
currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`; currentValue.value = `ama://set-api-key?server=${origin}&key=${props.value}`;
+104
View File
@@ -0,0 +1,104 @@
<template>
<div
class="input input-sm input-bordered w-full h-auto min-h-9 flex flex-wrap items-center gap-1 py-1 px-2"
:class="{ 'input-disabled': 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>
@@ -14,19 +14,17 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onUnmounted, watch, onMounted } from 'vue'; import { ref, onUnmounted, watch } from 'vue';
import type { ToastMessage } from '@/composables/toast';
const props = defineProps({ const props = defineProps<{
queue: { queue: ToastMessage[];
type: Array, }>();
required: true,
},
});
const show = ref(false); const show = ref(false);
const currentMessage = ref(null); const currentMessage = ref<ToastMessage | null>(null);
let timer = null; let timer: ReturnType<typeof setTimeout> | null = null;
const processQueue = () => { const processQueue = () => {
if (props.queue.length === 0) { if (props.queue.length === 0) {
@@ -34,14 +32,14 @@ const processQueue = () => {
currentMessage.value = null; currentMessage.value = null;
return; return;
} }
currentMessage.value = props.queue.shift(); currentMessage.value = props.queue.shift() ?? null;
show.value = true; show.value = true;
timer = setTimeout(() => { timer = setTimeout(() => {
processQueue(); processQueue();
}, currentMessage.value.duration || 3000); }, currentMessage.value?.duration || 3000);
}; };
watch(() => props.queue, () => { watch(() => props.queue, () => {
@@ -49,7 +47,7 @@ watch(() => props.queue, () => {
processQueue() processQueue()
} }
}, { deep: true }); }, { deep: true });
onUnmounted(() => { onUnmounted(() => {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
@@ -22,42 +22,39 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
const props = defineProps({ const props = withDefaults(defineProps<{
title: { title?: string | null; // 默认为null,将从路由中获取
type: String,
default: null // 默认为null,将从路由中获取
},
// Optional custom breadcrumb items // Optional custom breadcrumb items
customBreadcrumbs: { customBreadcrumbs?: { name: string; path: string }[];
type: Array, }>(), {
default: () => [] title: null,
} customBreadcrumbs: () => [],
}); });
const route = useRoute(); const route = useRoute();
// Generate breadcrumb items based on current route // Generate breadcrumb items based on current route
// 生成面包屑项 // 生成面包屑项
const breadcrumbItems = computed(() => { const breadcrumbItems = computed<{ name: string; path: string }[]>(() => {
if (props.customBreadcrumbs.length > 0) { if (props.customBreadcrumbs.length > 0) {
return props.customBreadcrumbs; return props.customBreadcrumbs;
} }
// 获取当前路径并分割成段 // 获取当前路径并分割成段
const pathSegments = route.path.split('/').filter(segment => segment); const pathSegments = route.path.split('/').filter(segment => segment);
return pathSegments.map((segment, index) => { return pathSegments.map((segment, index) => {
const name = segment.charAt(0).toUpperCase() + segment.slice(1); const name = segment.charAt(0).toUpperCase() + segment.slice(1);
// 对于最后一段,不设置链接 // 对于最后一段,不设置链接
if (index === pathSegments.length - 1) { if (index === pathSegments.length - 1) {
return { name, path: '' }; return { name, path: '' };
} }
// 创建到此段的路径 // 创建到此段的路径
const path = '/' + pathSegments.slice(0, index + 1).join('/'); const path = '/' + pathSegments.slice(0, index + 1).join('/');
return { name, path }; return { name, path };
@@ -69,13 +66,13 @@ const displayTitle = computed(() => {
if (props.title) { if (props.title) {
return props.title; return props.title;
} }
const pathSegments = route.path.split('/').filter(segment => segment); const pathSegments = route.path.split('/').filter(segment => segment);
if (pathSegments.length > 0) { if (pathSegments.length > 0) {
const lastSegment = pathSegments[pathSegments.length - 1]; const lastSegment = pathSegments[pathSegments.length - 1];
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1); return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
} }
return 'Dashboard'; return 'Dashboard';
}); });
</script> </script>
+8 -38
View File
@@ -30,7 +30,7 @@
<div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div> <div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div>
</summary> </summary>
<ul> <ul>
<li v-for="subItem in item.children" :key="subItem.label"> <li v-for="subItem in (item.children as MenuLink[])" :key="subItem.label">
<router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }"> <router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }">
<component :is="subItem.icon" class="w-4" /> <component :is="subItem.icon" class="w-4" />
{{ subItem.label }} {{ subItem.label }}
@@ -44,22 +44,11 @@
</aside> </aside>
</template> </template>
<script setup> <script setup lang="ts">
import { import { computed } from 'vue';
LayoutDashboardIcon, import { useRoute } from 'vue-router';
ShieldPlus, import { routes, generateMenuItemsFromRoutes, type MenuItem, type MenuLink } from '@/utils/router_menu'
UsersRoundIcon, import { useAuthStore } from '@/stores/auth';
KeyRoundIcon,
MessageSquareIcon,
SettingsIcon,
UserIcon,
CommandIcon,
BracesIcon,
} from 'lucide-vue-next'
import { ref, reactive, onMounted ,computed} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {routes,generateMenuItemsFromRoutes}from '@/utils/router_menu.js'
import { useAuthStore } from '@/stores/auth.js';
const authStore = useAuthStore(); const authStore = useAuthStore();
const userrole = computed(() => { const userrole = computed(() => {
@@ -68,31 +57,12 @@ const userrole = computed(() => {
}); });
const route = useRoute(); const route = useRoute();
const router = useRouter();
// 判断当前路由是否激活菜单项 // 判断当前路由是否激活菜单项
const isActive = (path) => { const isActive = (path: string) => {
return route.path === path; return route.path === path;
// return router.currentRoute.value.fullPath.startsWith(path); // return router.currentRoute.value.fullPath.startsWith(path);
}; };
let menuItems = reactive([ const menuItems = computed<MenuItem[]>(() => generateMenuItemsFromRoutes(routes, userrole.value));
{ type: 'link', label: 'Overview', to: '/dashboard/overview', icon: LayoutDashboardIcon },
{ type: 'title', label: 'Apps' },
{ type: 'link', label: 'Tokens', to: '/dashboard/tokens', icon: BracesIcon },
{
type: 'submenu', label: 'Manager', icon: CommandIcon, open: true, badge: 'Admin',
children: [
{ label: 'Users', to: '/dashboard/manager/users', icon: UsersRoundIcon },
{ label: 'ApiKeys', to: '/dashboard/manager/keys', icon: KeyRoundIcon },
],
},
{
type: 'submenu', label: 'Settings', icon: SettingsIcon,open: false,
children: [
{ label: 'Profile', to: '/dashboard/settings/profile', icon: UserIcon },
]
},
]);
menuItems = computed(() => generateMenuItemsFromRoutes(routes, userrole.value));
</script> </script>
+24
View File
@@ -0,0 +1,24 @@
import { inject } from 'vue'
import type { InjectionKey } from 'vue'
export type ToastType = 'info' | 'success' | 'error'
export type ToastMessage = {
message: string
type?: ToastType
duration?: number
}
export type ToastContext = {
setToast: (message: string, type?: ToastType, duration?: number) => void
}
export const ToastKey: InjectionKey<ToastContext> = Symbol('toast')
export function useToast(): ToastContext {
const ctx = inject(ToastKey)
if (!ctx) {
throw new Error('ToastContext 未提供:请在 App 根组件 provide(ToastKey, ...)')
}
return ctx
}
@@ -42,7 +42,7 @@
<div v-if="userInfo.avatar" class="avatar"> <div v-if="userInfo.avatar" class="avatar">
<div class="mask mask-squircle w-8 h-8"> <div class="mask mask-squircle w-8 h-8">
<img :src="userInfo.avatar" :alt="userInfo.name"> <img :src="userInfo.avatar" :alt="userInfo.name">
<!-- <img src='../assets/logo.svg' :alt="userInfo.name"> --> <!-- <img src='@/assets/logo.svg' :alt="userInfo.name"> -->
</div> </div>
</div> </div>
<!-- 没有头像时显示首字母 --> <!-- 没有头像时显示首字母 -->
@@ -87,25 +87,34 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { reactive, ref, computed, onMounted } from 'vue' import { reactive, ref, computed, onMounted } from 'vue'
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, Settings, LogOut } from 'lucide-vue-next' import type { Component } from 'vue'
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, LogOut } from '@lucide/vue'
import Sidebar from '@/components/dashboard/Sidebar.vue' import Sidebar from '@/components/dashboard/Sidebar.vue'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { UserInfo } from '@/types'
type NavItem = {
name?: string;
icon?: Component;
type?: 'divider';
class?: string;
}
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const isDark = ref(false) const isDark = ref(false)
const isLargeSidebarOpen = ref(true) const isLargeSidebarOpen = ref(true)
const userInfo = computed(() => { const userInfo = computed<Partial<UserInfo>>(() => {
return authStore.user || {} return authStore.user || {}
}) })
onMounted(async () => { onMounted(async () => {
if (!userInfo) { if (!userInfo.value) {
await authStore.getProfile() await authStore.getProfile()
} }
@@ -135,13 +144,13 @@ const userInitials = computed(() => {
return 'U'; return 'U';
}); });
const userNavigation = reactive([ const userNavigation = reactive<NavItem[]>([
{ name: 'Profile', icon: User }, { name: 'Profile', icon: User },
{ type: 'divider' }, { type: 'divider' },
{ name: 'Logout', icon: LogOut, class: 'text-error' } { name: 'Logout', icon: LogOut, class: 'text-error' }
]); ]);
const handleNavigation = (item) => { const handleNavigation = (item: NavItem) => {
if (item.name === 'Profile') { if (item.name === 'Profile') {
router.push('/dashboard/settings/profile') router.push('/dashboard/settings/profile')
} else if (item.name === 'Logout'){ } else if (item.name === 'Logout'){
@@ -1,18 +1,15 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import './style.css' import './styles/main.css'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import request from '@/utils/request' import request from '@/api/client'
const pinia = createPinia() const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
app.provide('request', request) app.provide('request', request)
app.use(ElementPlus)
app.use(router) app.use(router)
app.use(pinia) app.use(pinia)
app.mount('#app') app.mount('#app')
-53
View File
@@ -1,53 +0,0 @@
import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import { routes } from '@/utils/router_menu.js'
let defaultroutes = [
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
{ path: '/404', name: '404', component: () => import('@/views/404.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue') }, // Catch all 404
{
path: '/dashboard', name: 'Dashboard', component: () => import('@/views/DashBoard.vue'), meta: { requiresAuth: true }, redirect: '/dashboard/overview', children: [
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: 'Overview' } },
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'Tokens' } },
{
path: 'manager', name: 'Manager', meta: { title: 'Manager' }, redirect: '/dashboard/manager/users', children: [
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: 'Users' } },
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: 'UserNew' } },
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: 'UserView' } },
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: 'Keys' } },
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: 'KeyView' } },
]
},
{
path: 'settings', name: 'Settings', meta: { title: 'Settings' }, redirect: '/dashboard/settings/profile', children: [
{ path: 'profile', name: 'Profile', component: () => import('@/views/dashboard/Profile.vue'), meta: { title: 'Profile' } },
]
},
]
},
]
const router = createRouter({
history: createWebHistory(),
routes,
})
// const router = createRouter({
// history: createWebHistory(process.env.BASE_URL),
// routes
// })
router.beforeEach((to, from, next) => {
const isAuthenticated = localStorage.getItem('token')
if (to.meta.requiresAuth && !isAuthenticated) {
next('/login')
} else {
next()
}
})
export default router
+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
@@ -1,20 +1,21 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import request from '@/utils/request' import type { AxiosResponse } from 'axios';
import request from '@/api/client'
import type { UserInfo, AuthCredentials, TokenPayload, TokenInfo } from '@/types';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
// import { jwtDecode } from 'jwt-decode';
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const router = useRouter(); const router = useRouter();
const user = ref(null); const user = ref<UserInfo | null>(null);
const role = computed(() => { const role = computed<number>(() => {
if (!user.value) return 0; if (!user.value) return 0;
return user.value.role; return user.value.role;
}) })
const token = ref(localStorage.getItem('token') || ''); const token = ref<string>(localStorage.getItem('token') || '');
const isAdmin = computed(() => { const isAdmin = computed(() => {
if (!user.value || user.value.role === 0) return false; if (!user.value || user.value.role === 0) return false;
@@ -23,34 +24,34 @@ export const useAuthStore = defineStore('auth', () => {
const isLoggedIn = computed(() => !!token.value); const isLoggedIn = computed(() => !!token.value);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref<string | null>(null);
const setToken = (newToken) => { const setToken = (newToken: string) => {
token.value = newToken; token.value = newToken;
localStorage.setItem('token', newToken); localStorage.setItem('token', newToken);
} }
const loadTokenFromStorage=()=> { const loadTokenFromStorage = () => {
const storedToken = localStorage.getItem('token'); const storedToken = localStorage.getItem('token');
if (storedToken) { if (storedToken) {
token.value = storedToken; token.value = storedToken;
} }
} }
const register = async (userInfo) => { const register = async (userInfo: AuthCredentials) => {
error.value = null; error.value = null;
try { try {
const res = await request.post('/auth/register', userInfo) const res = await request.post('/auth/register', userInfo)
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '注册失败'; error.value = err.response?.data?.error || '注册失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息 throw error // 或者您可以在这里处理错误,例如显示错误消息
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const login = async (userInfo) => { const login = async (userInfo: AuthCredentials) => {
error.value = null; error.value = null;
try { try {
const res = await request.post('/auth/login', userInfo) const res = await request.post('/auth/login', userInfo)
@@ -59,10 +60,10 @@ export const useAuthStore = defineStore('auth', () => {
} }
await getProfile() // 登录成功后获取用户信息 await getProfile() // 登录成功后获取用户信息
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '登录失败'; error.value = err.response?.data?.error || '登录失败';
throw error // 或者您可以在这里处理错误,例如显示错误消息 throw error // 或者您可以在这里处理错误,例如显示错误消息
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
@@ -78,11 +79,11 @@ export const useAuthStore = defineStore('auth', () => {
user.value = res.data.data user.value = res.data.data
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败'; error.value = err.response?.data?.error || '获取用户信息失败';
throw error throw error
}finally { } finally {
loading.value = false; loading.value = false;
} }
} }
@@ -96,99 +97,99 @@ export const useAuthStore = defineStore('auth', () => {
user.value = res.data.data user.value = res.data.data
} }
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败'; error.value = err.response?.data?.error || '获取用户信息失败';
throw error throw error
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const updateProfile = async (userInfo) => { const updateProfile = async (userInfo: Partial<UserInfo>) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const res = await request.post('/profile/update', userInfo) const res = await request.post('/profile/update', userInfo)
console.log('auth.js updateProfile', res.data); console.log('auth updateProfile', res.data);
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '更新用户信息失败'; error.value = err.response?.data?.error || '更新用户信息失败';
throw error throw error
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const updatePassword = async (payload) => { const updatePassword = async (payload: { password: string; newpassword: string }) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const res = await request.post('/profile/update/password', payload) const res = await request.post('/profile/update/password', payload)
console.log('auth.js updatePassword', res.data); console.log('auth updatePassword', res.data);
return res return res
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '更新密码失败'; error.value = err.response?.data?.error || '更新密码失败';
throw error throw error
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const createToken = async (newToken) => { const createToken = async (newToken: TokenPayload) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post('/tokens', newToken) const response: AxiosResponse = await request.post('/tokens', newToken)
console.log('createToken', response.data); console.log('createToken', response.data);
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '创建token失败'; error.value = err.response?.data?.error || '创建token失败';
throw error throw error
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const resetToken = async (id) => { const resetToken = async (id: number | string) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post(`/tokens/reset/${id}`) const response: AxiosResponse = await request.post(`/tokens/reset/${id}`)
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '重置token失败'; error.value = err.response?.data?.error || '重置token失败';
throw error throw error
}finally{ } finally {
loading.value = false; loading.value = false;
} }
} }
const updateToken = async (token) => { const updateToken = async (tokenInfo: Partial<TokenInfo>) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.put(`/tokens/${token.id}`, token) const response: AxiosResponse = await request.put(`/tokens/${tokenInfo.id}`, tokenInfo)
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '更新token失败'; error.value = err.response?.data?.error || '更新token失败';
throw error throw error
}finally { } finally {
loading.value = false; loading.value = false;
} }
} }
const deleteToken = async (id) => { const deleteToken = async (id: number | string) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.delete(`/tokens/${id}`) const response: AxiosResponse = await request.delete(`/tokens/${id}`)
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '删除token失败'; error.value = err.response?.data?.error || '删除token失败';
throw err throw err
}finally { } finally {
loading.value = false; loading.value = false;
} }
} }
@@ -206,14 +207,14 @@ export const useAuthStore = defineStore('auth', () => {
} }
return { return {
loading,error, loading, error,
user,role,token, user, role, token,
isLoggedIn, isLoggedIn, isAdmin,
setToken,loadTokenFromStorage, setToken, loadTokenFromStorage,
login,register, login, register,
getProfile,updateProfile,updatePassword,refreshProfile, getProfile, updateProfile, updatePassword, refreshProfile,
createToken,deleteToken,resetToken,updateToken, createToken, deleteToken, resetToken, updateToken,
clear, clear,
logout logout
} }
}); });
@@ -1,16 +1,18 @@
// src/stores/key.js // src/stores/key.ts
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import request from '@/utils/request'; import type { AxiosResponse } from 'axios';
import request from '@/api/client';
import type { ApiKey, NewApiKeyPayload } from '@/types';
export const useKeyStore = defineStore('key', () => { export const useKeyStore = defineStore('key', () => {
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref<string | null>(null);
const totalKeys = ref(0); const totalKeys = ref(0);
const keys = ref([]); const keys = ref<ApiKey[]>([]);
const key = ref(null); const key = ref<ApiKey | null>(null);
const fetchKeys = async (pageSize = 20, page = 1, active) => { const fetchKeys = async (pageSize = 20, page = 1, active?: boolean | boolean[]) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@@ -21,10 +23,10 @@ export const useKeyStore = defineStore('key', () => {
active, active,
}, },
}); });
keys.value = response.data.data?.keys; keys.value = response.data.data?.keys ?? [];
totalKeys.value = response.data.data?.total; totalKeys.value = response.data.data?.total ?? 0;
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKeys失败'; error.value = err.response?.data?.error || '获取ApiKeys失败';
throw error throw error
} finally { } finally {
@@ -32,7 +34,7 @@ export const useKeyStore = defineStore('key', () => {
} }
}; };
const fetchKey = async (id) => { const fetchKey = async (id: number | string) => {
if (keys.value.length > 0) { if (keys.value.length > 0) {
const findkey = keys.value.find(item => item.id === id) const findkey = keys.value.find(item => item.id === id)
if (findkey) { if (findkey) {
@@ -42,19 +44,17 @@ export const useKeyStore = defineStore('key', () => {
} }
loading.value = true; loading.value = true;
error.value = null; error.value = null;
// const findkey = keys.find(item=>item.id === id)
// console.log('findkey',findkey)
try { try {
const response = await request.get(`/keys/${id}`); const response = await request.get(`/keys/${id}`);
key.value = response.data.data; key.value = response.data.data;
if (key.value.support_models.length < 3) { 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) : '' key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
} }
if (!key.value.support_models_array) { if (key.value && !key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : [] key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
} }
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKey失败'; error.value = err.response?.data?.error || '获取ApiKey失败';
throw error throw error
} finally { } finally {
@@ -62,19 +62,19 @@ export const useKeyStore = defineStore('key', () => {
} }
}; };
const refreshKey = async (id) => { const refreshKey = async (id: number | string) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.get(`/keys/${id}`); const response = await request.get(`/keys/${id}`);
key.value = response.data.data; key.value = response.data.data;
if (key.value.support_models.length < 3) { 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) : '' key.value.support_models = key.value.support_models_array ? JSON.stringify(key.value.support_models) : ''
} }
if (!key.value.support_models_array) { if (key.value && !key.value.support_models_array) {
key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : [] key.value.support_models_array = key.value.support_models ? JSON.parse(key.value.support_models) : []
} }
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取ApiKey失败'; error.value = err.response?.data?.error || '获取ApiKey失败';
throw error throw error
} finally { } finally {
@@ -82,13 +82,13 @@ export const useKeyStore = defineStore('key', () => {
} }
} }
const createKey = async (data) => { const createKey = async (data: NewApiKeyPayload) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post('/keys', data); const response: AxiosResponse = await request.post('/keys', data);
return response; return response;
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '创建ApiKey失败'; error.value = err.response?.data?.error || '创建ApiKey失败';
throw error throw error
} finally { } finally {
@@ -96,13 +96,13 @@ export const useKeyStore = defineStore('key', () => {
} }
} }
const updateKey = async (key) => { const updateKey = async (keyInfo: ApiKey) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.put(`/keys/${key.id}`, key); const response: AxiosResponse = await request.put(`/keys/${keyInfo.id}`, keyInfo);
return response; return response;
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '更新ApiKey失败'; error.value = err.response?.data?.error || '更新ApiKey失败';
throw error throw error
} finally { } finally {
@@ -110,14 +110,14 @@ export const useKeyStore = defineStore('key', () => {
} }
} }
const keyOption = async (option, ids) => { const keyOption = async (option: string, ids: (number | string)[]) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post(`/keys/batch/${option}`, { ids }); const response: AxiosResponse = await request.post(`/keys/batch/${option}`, { ids });
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '操作失败'; error.value = err.response?.data?.error || '操作失败';
throw err; throw err;
} finally { } finally {
@@ -137,4 +137,3 @@ export const useKeyStore = defineStore('key', () => {
keyOption, keyOption,
} }
}); });
@@ -1,30 +1,32 @@
// src/stores/user.js // src/stores/user.ts
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import request from '@/utils/request'; import type { AxiosResponse } from 'axios';
import request from '@/api/client';
import type { UserInfo, NewUserPayload } from '@/types';
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const users = ref([]); const users = ref<UserInfo[]>([]);
const totalUsers = ref(0); const totalUsers = ref(0);
const user = ref(null); const user = ref<UserInfo | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref<string | null>(null);
async function createUser(userData) { async function createUser(userData: NewUserPayload) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post('/users', userData); const response: AxiosResponse = await request.post('/users', userData);
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '创建用户失败' error.value = err.response?.data?.error || '创建用户失败'
throw err; throw err;
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function listUser(pageSize = 20, page = 1, active) { async function listUser(pageSize = 20, page = 1, active?: boolean | boolean[]) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
@@ -35,9 +37,9 @@ export const useUserStore = defineStore('user', () => {
active, active,
}, },
}); });
users.value = response.data.data?.users; users.value = response.data.data?.users ?? [];
totalUsers.value = response.data.data?.total; totalUsers.value = response.data.data?.total ?? 0;
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户列表失败'; error.value = err.response?.data?.error || '获取用户列表失败';
throw err; throw err;
} finally { } finally {
@@ -45,15 +47,15 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function getUser(id) { async function getUser(id: number | string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.get(`/users/${id}`); const response = await request.get(`/users/${id}`);
console.log('getUser response',response); console.log('getUser response', response);
user.value = response.data.data; user.value = response.data.data;
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败'; error.value = err.response?.data?.error || '获取用户信息失败';
throw err; throw err;
} finally { } finally {
@@ -61,15 +63,15 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function refreshUser(id) { async function refreshUser(id: number | string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.get(`/users/${id}`); const response = await request.get(`/users/${id}`);
console.log('getUser response',response); console.log('getUser response', response);
user.value = response.data.data; user.value = response.data.data;
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取用户信息失败'; error.value = err.response?.data?.error || '获取用户信息失败';
throw err; throw err;
} finally { } finally {
@@ -77,14 +79,14 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function editUser(id, userData) { async function editUser(id: number | string, userData: Partial<UserInfo>) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response= await request.put(`/users/${id}`, userData); const response: AxiosResponse = await request.put(`/users/${id}`, userData);
console.log('editUser',response); console.log('editUser', response);
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '编辑用户失败'; error.value = err.response?.data?.error || '编辑用户失败';
throw err; throw err;
} finally { } finally {
@@ -92,13 +94,13 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function deleteUser(id) { async function deleteUser(id: number | string) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.delete(`/users/${id}`); const response: AxiosResponse = await request.delete(`/users/${id}`);
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '删除用户失败'; error.value = err.response?.data?.error || '删除用户失败';
throw err; throw err;
} finally { } finally {
@@ -106,14 +108,14 @@ export const useUserStore = defineStore('user', () => {
} }
} }
async function userOption(option, ids) { async function userOption(option: string, ids: (number | string)[]) {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.post(`/users/batch/${option}`, { ids }); const response: AxiosResponse = await request.post(`/users/batch/${option}`, { ids });
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '操作失败'; error.value = err.response?.data?.error || '操作失败';
throw err; throw err;
} finally { } finally {
@@ -135,4 +137,4 @@ export const useUserStore = defineStore('user', () => {
deleteUser, deleteUser,
userOption, userOption,
}; };
}); });
@@ -1,18 +1,20 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref } from "vue"; import { ref } from "vue";
import request from "@/utils/request"; import type { AxiosResponse } from "axios";
import request from "@/api/client";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; import { startRegistration, startAuthentication } from "@simplewebauthn/browser";
import { useAuthStore } from "./auth"; import { useAuthStore } from "./auth";
import type { PasskeyInfo } from "@/types";
export const useWebAuthStore = defineStore("webauth", () => { export const useWebAuthStore = defineStore("webauth", () => {
const router = useRouter(); const router = useRouter();
// const token = ref(localStorage.getItem("token") || ""); // const token = ref(localStorage.getItem("token") || "");
const passkeys = ref(null); const passkeys = ref<PasskeyInfo[] | null>(null);
const loading = ref(false); const loading = ref(false);
const error = ref(null); const error = ref<string | null>(null);
const addPasskey = async () => { const addPasskey = async () => {
error.value = ""; error.value = "";
@@ -29,10 +31,10 @@ export const useWebAuthStore = defineStore("webauth", () => {
let attestation; let attestation;
try { try {
// Pass 'undefined' as the second argument if you are not using an AbortSignal // Pass 'undefined' as the second argument if you are not using an AbortSignal
attestation = await startRegistration({optionsJSON: options}); attestation = await startRegistration({ optionsJSON: options });
// console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation)); // console.log("WebAuthn 注册结果 (Attestation):", JSON.stringify(attestation));
error.value = null; error.value = null;
} catch (regError) { } catch (regError: any) {
// console.log("WebAuthn 注册失败或取消:", regError); // console.log("WebAuthn 注册失败或取消:", regError);
if (regError.name === "NotAllowedError") { if (regError.name === "NotAllowedError") {
error.value = "Passkey 操作被取消或不允许。"; error.value = "Passkey 操作被取消或不允许。";
@@ -43,11 +45,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
} }
// 3. 将注册结果 (Attestation) 发送到后端进行验证和保存 // 3. 将注册结果 (Attestation) 发送到后端进行验证和保存
const res2 = await request.post("/profile/passkey", attestation); const res2: AxiosResponse = await request.post("/profile/passkey", attestation);
// console.log("end:", res2); // console.log("end:", res2);
return res2; return res2;
} catch (err) { } catch (err: any) {
error.value =err.response?.data?.error || "添加 Passkey 失败,请稍后重试。"; error.value = err.response?.data?.error || "添加 Passkey 失败,请稍后重试。";
throw error throw error
} finally { } finally {
loading.value = false; loading.value = false;
@@ -62,13 +64,13 @@ export const useWebAuthStore = defineStore("webauth", () => {
const res = await request.get("/auth/passkey/begin"); const res = await request.get("/auth/passkey/begin");
// console.log("login begin:", res.data); // console.log("login begin:", res.data);
const options = res.data.data.publicKey; const options = res.data.data.publicKey;
// 2. 调用 Web Authentication API 进行认证 // 2. 调用 Web Authentication API 进行认证
let assertion; let assertion;
try { try {
assertion = await startAuthentication({ optionsJSON: options }); assertion = await startAuthentication({ optionsJSON: options });
// console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion)); // console.log("WebAuthn 认证结果 (Assertion):", JSON.stringify(assertion));
} catch (loginError) { } catch (loginError: any) {
if (loginError.name === "NotAllowedError") { if (loginError.name === "NotAllowedError") {
error.value = "Passkey 登录被取消或不允许。"; error.value = "Passkey 登录被取消或不允许。";
} else { } else {
@@ -76,11 +78,11 @@ export const useWebAuthStore = defineStore("webauth", () => {
} }
throw error; throw error;
} }
// 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token // 3. 将认证结果 (Assertion) 发送到后端进行验证并获取 Token
const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge const challenge = options.challenge; // 从 begin 接口返回的 options 中获取 challenge
const res2 = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion); const res2: AxiosResponse = await request.post(`/auth/passkey/finish?challenge=${challenge}`, assertion);
// 4. 处理登录成功的响应,通常包含 Token // 4. 处理登录成功的响应,通常包含 Token
if (res2.status === 200 && !!res2.data.data?.token) { if (res2.status === 200 && !!res2.data.data?.token) {
const token = res2.data.data.token const token = res2.data.data.token
@@ -89,7 +91,7 @@ export const useWebAuthStore = defineStore("webauth", () => {
await authStore.getProfile() await authStore.getProfile()
return res2.data return res2.data
} }
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || err.value || "Passkey 登录失败,请稍后重试。"; error.value = err.response?.data?.error || err.value || "Passkey 登录失败,请稍后重试。";
throw error; throw error;
} finally { } finally {
@@ -104,24 +106,24 @@ export const useWebAuthStore = defineStore("webauth", () => {
const response = await request.get('/profile/passkeys') const response = await request.get('/profile/passkeys')
// console.log('getPasskeys',response.data.data) // console.log('getPasskeys',response.data.data)
passkeys.value = response.data.data passkeys.value = response.data.data
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || '获取token列表失败'; error.value = err.response?.data?.error || '获取token列表失败';
throw error throw error
}finally { } finally {
loading.value = false; loading.value = false;
} }
} }
const deletePasskey = async (id) => { const deletePasskey = async (id: string | number) => {
loading.value = true; loading.value = true;
error.value = null; error.value = null;
try { try {
const response = await request.delete(`/profile/passkeys/${id}`) const response: AxiosResponse = await request.delete(`/profile/passkeys/${id}`)
return response return response
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || `删除passkey ${id} 失败`; error.value = err.response?.data?.error || `删除passkey ${id} 失败`;
throw error throw error
}finally { } finally {
loading.value = false; loading.value = false;
} }
} }
@@ -135,4 +137,4 @@ export const useWebAuthStore = defineStore("webauth", () => {
getPasskeys, getPasskeys,
deletePasskey, deletePasskey,
}; };
}); });
-11
View File
@@ -1,11 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
padding: 0;
}
+10
View File
@@ -0,0 +1,10 @@
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark, cupcake, emerald, pastel;
}
body {
margin: 0;
padding: 0;
}
+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
}
@@ -1,11 +1,11 @@
// src/utils/format-date.js // src/utils/format-date.ts
export function dateToUnix(dateString) { export function dateToUnix(dateString: string): number {
const date = new Date(dateString); const date = new Date(dateString);
return Math.floor(date.getTime() / 1000); return Math.floor(date.getTime() / 1000);
} }
export function unixToDate(timestamp) { export function unixToDate(timestamp: number): string {
const date = new Date(timestamp * 1000); const date = new Date(timestamp * 1000);
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -13,7 +13,7 @@ export function unixToDate(timestamp) {
return `${year}-${month}-${day}`; return `${year}-${month}-${day}`;
} }
export function formatDateTime(unixTimestamp) { export function formatDateTime(unixTimestamp: number | undefined | null): string {
// 如果时间戳不存在或为0,返回'未知' // 如果时间戳不存在或为0,返回'未知'
if (!unixTimestamp) return "未知"; if (!unixTimestamp) return "未知";
@@ -1,24 +1,43 @@
import type { Component } from 'vue'
import type { RouteRecordRaw } from 'vue-router'
import { import {
LayoutDashboardIcon, LayoutDashboardIcon,
ShieldPlus,
UsersRoundIcon, UsersRoundIcon,
KeyRoundIcon, KeyRoundIcon,
MessageSquareIcon,
SettingsIcon, SettingsIcon,
UserIcon, UserIcon,
CommandIcon, CommandIcon,
BracesIcon, BracesIcon,
} from 'lucide-vue-next' } from '@lucide/vue'
export const routes = [ export type MenuLink = { type: 'link'; label: string; to: string; icon?: Component }
export type MenuItem =
| MenuLink
| { type: 'title'; label: string }
| { type: 'submenu'; label: string; to?: string; icon?: Component; open?: boolean; badge?: string; children?: MenuItem[] }
// 路由 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: '/', name: 'Home',component: () => import('@/views/Home.vue') },
{ path: '/404', name: '404',component: () => import('@/views/404.vue') }, { path: '/404', name: '404',component: () => import('@/views/error/NotFound.vue') },
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, { path: '/login', name: 'Login', component: () => import('@/views/auth/Login.vue') },
{ path: '/signup', name: 'Signup', component: () => import('@/views/Signup.vue') }, { path: '/signup', name: 'Signup', component: () => import('@/views/auth/Signup.vue') },
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/404.vue')}, // Catch all 404 { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/NotFound.vue')}, // Catch all 404
{ path: '/dashboard', name: 'Dashboard', component: ()=>import('@/views/DashBoard.vue'), meta: { requiresAuth: true, title: 'Dashboard', showInSidebar: false },redirect: '/dashboard/overview', children:[ { path: '/dashboard', name: 'Dashboard', component: ()=>import('@/layouts/DashboardLayout.vue'), meta: { requiresAuth: true, title: 'Dashboard', showInSidebar: false },redirect: '/dashboard/overview', children:[
{ path: 'overview', name: 'Overview', component: ()=>import('@/views/dashboard/Overview.vue'),meta: { title: 'Overview', icon: LayoutDashboardIcon, showInSidebar: true } }, { path: 'overview', name: 'Overview', component: ()=>import('@/views/dashboard/Overview.vue'),meta: { title: 'Overview', icon: LayoutDashboardIcon, showInSidebar: true } },
{ path: 'tokens', name: 'Tokens', component: ()=>import('@/views/dashboard/Tokens.vue'),meta: { title: 'Tokens', icon: BracesIcon, showInSidebar: true } }, { path: 'tokens', name: 'Tokens', component: ()=>import('@/views/dashboard/Tokens.vue'),meta: { title: 'Tokens', icon: BracesIcon, showInSidebar: true } },
{ path: 'manager', name: 'Manager',meta: { title: 'Manager', icon: CommandIcon, showInSidebar: true, open: true, badge: 'Admin' }, redirect: '/dashboard/manager/users',children:[ { path: 'manager', name: 'Manager',meta: { title: 'Manager', icon: CommandIcon, showInSidebar: true, open: true, badge: 'Admin' }, redirect: '/dashboard/manager/users',children:[
@@ -34,42 +53,55 @@ export const routes = [
]}, ]},
]; ];
export function generateMenuItemsFromRoutes(routes, userRole, parentPath = '') { export function generateMenuItemsFromRoutes(routes: RouteRecordRaw[], userRole: number, parentPath = ''): MenuItem[] {
const menuItems = []; const menuItems: MenuItem[] = [];
for (const route of routes) { for (const route of routes) {
if (route.meta && route.meta.title && route.meta.showInSidebar) { if (route.meta && route.meta.title && route.meta.showInSidebar) {
const fullPath = parentPath + '/' + route.path.replace(/^\//, ''); const fullPath = parentPath + '/' + route.path.replace(/^\//, '');
const menuItem = {
label: route.meta.title,
to: fullPath,
icon: route.meta.icon,
};
if (route.children && route.children.length > 0) { if (route.children && route.children.length > 0) {
if (route.name === 'Manager' && userRole < 10) { if (route.name === 'Manager' && userRole < 10) {
continue; continue;
} }
menuItem.type = 'submenu'; const menuItem: MenuItem = {
menuItem.open = route.meta.open !== undefined ? route.meta.open : false; type: 'submenu',
menuItem.badge = route.meta.badge; label: route.meta.title,
menuItem.children = generateMenuItemsFromRoutes(route.children, userRole, fullPath); to: fullPath,
} else { icon: route.meta.icon,
menuItem.type = 'link'; open: route.meta.open !== undefined ? route.meta.open : false,
} badge: route.meta.badge,
children: generateMenuItemsFromRoutes(route.children, userRole, fullPath),
};
if (route.name === 'Overview') {
menuItems.push(menuItem);
menuItems.push({ type: 'title', label: 'Apps' });
continue
}
if (route.name === 'Overview') {
menuItems.push(menuItem); menuItems.push(menuItem);
menuItems.push({ type: 'title', label: 'Apps' }); } else {
continue const menuItem: MenuItem = {
} type: 'link',
label: route.meta.title,
to: fullPath,
icon: route.meta.icon,
};
menuItems.push(menuItem); if (route.name === 'Overview') {
menuItems.push(menuItem);
menuItems.push({ type: 'title', label: 'Apps' });
continue
}
menuItems.push(menuItem);
}
} else if (route.path === '/dashboard' && route.children) { } else if (route.path === '/dashboard' && route.children) {
menuItems.push(...generateMenuItemsFromRoutes(route.children, userRole, '/dashboard')); menuItems.push(...generateMenuItemsFromRoutes(route.children, userRole, '/dashboard'));
} }
} }
return menuItems; return menuItems;
} }
+11 -10
View File
@@ -3,7 +3,7 @@
<div class="navbar fixed w-full top-0 z-50 backdrop-blur-sm bg-base-100/50"> <div class="navbar fixed w-full top-0 z-50 backdrop-blur-sm bg-base-100/50">
<div class="container mx-auto flex justify-between items-center p-1 rounded-box"> <div class="container mx-auto flex justify-between items-center p-1 rounded-box">
<div class="flex items-center h-12 w-12 rounded-full text-l"> <div class="flex items-center h-12 w-12 rounded-full text-l">
<img src="../assets/logo.svg" alt="Logo" class="select-none"> <img src="@/assets/logo.svg" alt="Logo" class="select-none">
<span class="hidden sm:flex text-xl font-bold"> <span class="hidden sm:flex text-xl font-bold">
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a> <a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
</span> </span>
@@ -25,7 +25,7 @@
<main class="flex-grow flex flex-col justify-center items-center pt-16"> <main class="flex-grow flex flex-col justify-center items-center pt-16">
<div class="text-center"> <div class="text-center">
<div class="flex items-center justify-center my-4 outline-none select-none"> <div class="flex items-center justify-center my-4 outline-none select-none">
<img src="../assets/openteam.png" alt="Project Logo" class="h-40"> <img src="@/assets/openteam.png" alt="Project Logo" class="h-40">
</div> </div>
<h1 class="text-4xl font-bold mb-4"> <h1 class="text-4xl font-bold mb-4">
<a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a> <a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a>
@@ -65,7 +65,7 @@
<p class="mb-2">欢迎加入我们的Telegram频道,获取最新动态和帮助</p> <p class="mb-2">欢迎加入我们的Telegram频道,获取最新动态和帮助</p>
<div class="flex justify-center mb-4"> <div class="flex justify-center mb-4">
<a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel"> <a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel">
<img src="../assets/openteam_channel.jpg" alt="Telegram Group QR Code" <img src="@/assets/openteam_channel.jpg" alt="Telegram Group QR Code"
class="w-40 fill-current backdrop-blur-0 select-none"> class="w-40 fill-current backdrop-blur-0 select-none">
</a> </a>
@@ -95,27 +95,28 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted, inject } from 'vue'; import { ref, onMounted } from 'vue';
import LineSegmentFlow from '@/components/LineSegmentFlow.vue'; import LineSegmentFlow from '@/components/common/LineSegmentFlow.vue';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { useToast } from '@/composables/toast';
const currentYear = ref(''); const currentYear = ref('');
const url = ref(''); const url = ref('');
const { setToast } = inject('toast'); const { setToast } = useToast();
const copyUrl = async () => { const copyUrl = async () => {
try { try {
await navigator.clipboard.writeText(url.value); await navigator.clipboard.writeText(url.value);
setToast('复制成功!', 'info'); setToast('复制成功!', 'info');
} catch (err) { } catch (err: any) {
setToast('复制失败,请手动复制。', 'error'); setToast('复制失败,请手动复制。', 'error');
} }
}; };
const star = ref(0); const star = ref(0);
const getGithubStars = async () => { const getGithubStars = async (): Promise<number> => {
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam', { next: { revalidate: 3600 } }); const res = await fetch('https://ungh.cc/repos/mirrors2/openteam', { next: { revalidate: 3600 } } as RequestInit);
const data = await res.json(); const data = await res.json();
return data.repo.stars; return data.repo.stars;
}; };
@@ -2,7 +2,7 @@
<div class="min-h-screen flex items-center justify-center p-4"> <div class="min-h-screen flex items-center justify-center p-4">
<div class="card w-full max-w-md bg-base-100 shadow-xl"> <div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<img src="../assets/openteam.webp" alt="Company Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer" <img src="@/assets/openteam.webp" alt="Company Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer"
@click="$router.push('/')" /> @click="$router.push('/')" />
<h2 class="card-title text-md sm:text-xl mb-6 justify-center flex"> <h2 class="card-title text-md sm:text-xl mb-6 justify-center flex">
@@ -103,19 +103,20 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, reactive, inject, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '@/stores/webauth'; import { useWebAuthStore } from '@/stores/webauth';
// import request from '@/utils/request'; import { useToast } from '@/composables/toast';
// import request from '@/api/client';
const router = useRouter() const router = useRouter()
const authStore = useAuthStore(); const authStore = useAuthStore();
const webauthStore = useWebAuthStore(); const webauthStore = useWebAuthStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
const error = ref(null) const error = ref<string | null>(null)
const user = reactive({ const user = reactive({
username: localStorage.getItem('account') || '', username: localStorage.getItem('account') || '',
password: localStorage.getItem('password') || '', password: localStorage.getItem('password') || '',
@@ -130,6 +131,13 @@ onMounted(() => {
supportWebAuth.value = !!window.PublicKeyCredential; 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 () => { const handleLogin = async () => {
error.value = null; error.value = null;
@@ -139,7 +147,7 @@ const handleLogin = async () => {
if (user.rember) { if (user.rember) {
localStorage.setItem('account', user.username); localStorage.setItem('account', user.username);
localStorage.setItem('password', user.password); localStorage.setItem('password', user.password);
localStorage.setItem('rember', user.rember); localStorage.setItem('rember', String(user.rember));
} else { } else {
localStorage.removeItem('account'); localStorage.removeItem('account');
localStorage.removeItem('password'); localStorage.removeItem('password');
@@ -148,9 +156,9 @@ const handleLogin = async () => {
setToast('登录成功', 'success'); setToast('登录成功', 'success');
router.push('/dashboard'); router.push('/dashboard');
} }
} catch (err) { } catch (err: any) {
console.error('Login error:', err); console.error('Login error:', err);
error.value = err error.value = errMsg(err)
} }
} }
@@ -158,13 +166,13 @@ const handlePasskeyLogin = async () => {
error.value = null; error.value = null;
try { try {
const res = await webauthStore.loginPasskey(); const res = await webauthStore.loginPasskey();
if (!!res.code && res.code === 200) { if (!!res?.code && res.code === 200) {
setToast('登录成功', 'success'); setToast('登录成功', 'success');
router.push('/dashboard'); router.push('/dashboard');
} }
} catch (err) { } catch (err: any) {
console.error('Passkey login error:', err); console.error('Passkey login error:', err);
error.value = err error.value = errMsg(err)
} }
}; };
@@ -2,7 +2,7 @@
<div class="min-h-screen flex items-center justify-center p-4"> <div class="min-h-screen flex items-center justify-center p-4">
<div class="card w-full max-w-md bg-base-100 shadow-xl"> <div class="card w-full max-w-md bg-base-100 shadow-xl">
<div class="card-body p-4 sm:p-6"> <div class="card-body p-4 sm:p-6">
<img src="../assets/openteam.webp" alt="Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer" @click="$router.push('/')"/> <img src="@/assets/openteam.webp" alt="Logo" class="h-32 w-auto mx-auto mb-0 pb-0 select-none hover:cursor-pointer" @click="$router.push('/')"/>
<h2 class="card-title text-md sm:text-xl mb-2 justify-center flex"> <h2 class="card-title text-md sm:text-xl mb-2 justify-center flex">
Create Your Account Create Your Account
@@ -53,14 +53,15 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, inject } from 'vue' import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from '@/composables/toast'
const router = useRouter() const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { setToast } = inject('toast'); const { setToast } = useToast();
const error = ref(''); const error = ref('');
@@ -72,7 +73,7 @@ const handleRegister = async () => {
if (password.value !== confirmPassword.value) { if (password.value !== confirmPassword.value) {
alert("密码不一致"); alert("密码不一致");
return return
} }
try { try {
let res = await authStore.register({ let res = await authStore.register({
username: username.value, username: username.value,
+19 -16
View File
@@ -46,7 +46,7 @@
<option value="github">Github</option> <option value="github">Github</option>
<option value="openai-compatible">OpenAI Compatible</option> <option value="openai-compatible">OpenAI Compatible</option>
</select> </select>
<button type="button" @click="togglePasswordVisibility" tabindex="-1" <button type="button" tabindex="-1"
class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md" class="absolute inset-y-0 left-0 px-3 flex items-center text-base-content/60 hover:text-base-content/80 focus:outline-none focus:ring-0 rounded-r-md"
id="password-visibility-toggle"> id="password-visibility-toggle">
<img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt=""> <img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt="">
@@ -135,7 +135,7 @@
</label> </label>
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text" <!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> --> placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable <TagInput v-model="newApiKey.support_models_array" clearable
placeholder="Please input" @change="onchange_supportmodel" /> placeholder="Please input" @change="onchange_supportmodel" />
</div> </div>
@@ -180,22 +180,23 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, inject } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useKeyStore } from '@/stores/key'; import { useKeyStore } from '@/stores/key';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import TagInput from '@/components/common/TagInput.vue';
import { useToast } from '@/composables/toast';
import type { NewApiKeyPayload } from '@/types';
const router = useRouter()
const keyStore = useKeyStore() const keyStore = useKeyStore()
const { setToast } = inject('toast') const { setToast } = useToast()
const error = ref(null) const error = ref<string | null>(null)
// Control advanced options visibility // Control advanced options visibility
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(false)
// Initialize API key object // Initialize API key object
const newApiKey = ref({ const newApiKey = ref<NewApiKeyPayload>({
name: '', name: '',
type: '', type: '',
apikey: '', apikey: '',
@@ -245,7 +246,7 @@ const cancel = () => {
emit('closeModal', true) emit('closeModal', true)
} }
const apiKeyImageMap = { const apiKeyImageMap: Record<string, string> = {
'openai': '/assets/openai.svg', 'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg', 'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg', 'gemini': '/assets/gemini.svg',
@@ -254,7 +255,7 @@ const apiKeyImageMap = {
}; };
const apiKeyImageUrl = (keytype) => { const apiKeyImageUrl = (keytype: string) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg'; return apiKeyImageMap[keytype] || '/assets/logo.svg';
}; };
@@ -277,7 +278,7 @@ const createApiKey = async () => {
// Attempt to parse parameters JSON // Attempt to parse parameters JSON
try { try {
JSON.parse(newApiKey.value.parameters); JSON.parse(newApiKey.value.parameters || '{}');
} catch (e) { } catch (e) {
setToast('Invalid JSON format for Parameters.', 'error'); setToast('Invalid JSON format for Parameters.', 'error');
return; return;
@@ -291,17 +292,19 @@ const createApiKey = async () => {
// Optionally navigate or reset form // Optionally navigate or reset form
emit('closeModal', true) emit('closeModal', true)
} else { } else {
setToast(res.error || res.data?.message || 'Failed to create API Key', 'error') setToast(res.data?.error || res.data?.message || 'Failed to create API Key', 'error')
} }
} catch (err) { } catch (err: any) {
console.log('createApiKey error:', err) console.log('createApiKey error:', err)
error.value = err || 'Failed to create API Key' error.value = err?.message || String(err) || 'Failed to create API Key'
// setToast(error.response?.data?.error || 'Failed to create API Key', 'error') // setToast(error.response?.data?.error || 'Failed to create API Key', 'error')
} }
} }
const emit = defineEmits(['closeModal']) const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script> </script>
+15 -17
View File
@@ -125,8 +125,8 @@
</label> </label>
<!-- <textarea id="support_models" v-model="key.support_models_text" <!-- <textarea id="support_models" v-model="key.support_models_text"
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> --> placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
<el-input-tag v-model="key.support_models_array" :trigger="'Enter'" clearable <TagInput v-model="key.support_models_array" clearable
placeholder="Please input" @change="onchange_supportmodel"/> placeholder="Please input" @change="onchange_supportmodel" />
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -157,31 +157,28 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, inject, reactive } from 'vue'; import { computed, onMounted, reactive } from 'vue';
import { useRoute,useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; import { useKeyStore } from '@/stores/key';
import { useKeyStore } from '../../stores/key';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import TagInput from '@/components/common/TagInput.vue';
import { useToast } from '@/composables/toast';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const keyStore = useKeyStore(); const keyStore = useKeyStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
const keyId = computed(() => route.query.id); const keyId = computed(() => route.query.id);
onMounted(async () => {
});
const key = computed(() => keyStore.key); const key = computed(() => keyStore.key);
const loading = computed(() => keyStore.loading); const loading = computed(() => keyStore.loading);
onMounted(async () => { onMounted(async () => {
console.log('keyId', keyId.value) console.log('keyId', keyId.value)
if (keyId.value) { if (keyId.value) {
await keyStore.fetchKey(keyId.value); await keyStore.fetchKey(keyId.value as string);
} }
}); });
@@ -194,7 +191,7 @@ const keyOption = reactive([
{name: 'openai-compatible', label: 'OpenAI Compatible'} {name: 'openai-compatible', label: 'OpenAI Compatible'}
]) ])
const apiKeyImageMap = { const apiKeyImageMap: Record<string, string> = {
'openai': '/assets/openai.svg', 'openai': '/assets/openai.svg',
'claude': '/assets/claude.svg', 'claude': '/assets/claude.svg',
'gemini': '/assets/gemini.svg', 'gemini': '/assets/gemini.svg',
@@ -202,16 +199,17 @@ const apiKeyImageMap = {
'github': '/assets/github.svg' 'github': '/assets/github.svg'
}; };
const apiKeyImageUrl = (keytype) => { const apiKeyImageUrl = (keytype: string) => {
return apiKeyImageMap[keytype] || '/assets/logo.svg'; return apiKeyImageMap[keytype] || '/assets/logo.svg';
}; };
const onchange_supportmodel = () => { const onchange_supportmodel = () => {
if (!key.value) return;
key.value.support_models = JSON.stringify(key.value.support_models_array) key.value.support_models = JSON.stringify(key.value.support_models_array)
} }
const updateKey = async () => { const updateKey = async () => {
if (!key.value) return;
try { try {
const res = await keyStore.updateKey(key.value); const res = await keyStore.updateKey(key.value);
console.log('updateKey', res) console.log('updateKey', res)
@@ -219,7 +217,7 @@ const updateKey = async () => {
setToast(`Key ${key.value.name} updated`, 'success'); setToast(`Key ${key.value.name} updated`, 'success');
} }
await keyStore.refreshKey(key.value.id); await keyStore.refreshKey(key.value.id);
} catch (err) { } catch (err: any) {
console.error('Error updating key:', err); console.error('Error updating key:', err);
} }
}; };
+27 -32
View File
@@ -148,27 +148,29 @@
<!-- Pagination --> <!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize" <Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" /> :pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, reactive, onMounted, inject, computed } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/Pagination.vue'; import Pagination from '@/components/common/Pagination.vue';
import KeyNew from '@/views/dashboard/KeyNew.vue'; import KeyNew from '@/views/dashboard/KeyNew.vue';
import { useKeyStore } from '@/stores/key'; import { useKeyStore } from '@/stores/key';
import { useToast } from '@/composables/toast';
import type { ApiKey } from '@/types';
import { import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon, BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, Infinity TrashIcon, Infinity
} from 'lucide-vue-next'; } from '@lucide/vue';
const router = useRouter(); const router = useRouter();
const keyStore = useKeyStore(); const keyStore = useKeyStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
onMounted(async () => { onMounted(async () => {
await keyStore.fetchKeys(); await keyStore.fetchKeys();
@@ -184,19 +186,14 @@ const totalItems = computed(() => keyStore.totalKeys);
// 封装公共的用户列表获取方法 // 封装公共的用户列表获取方法
const fetchKeys = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => { const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value; currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value)); // console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await keyStore.fetchKeys(size, page, active); await keyStore.fetchKeys(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
}; };
// 组件挂载时加载用户数据
// onMounted(async () => {
// await fetchKeys();
// });
// 分页与页面大小变化 // 分页与页面大小变化
const changePage = async (page, size) => { const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) { if (page == currentPage.value && size == pageSize.value) {
return return
} }
@@ -205,11 +202,9 @@ const changePage = async (page, size) => {
await fetchKeys(); await fetchKeys();
}; };
const changePageSize = changePage;
// 复选框选择状态 // 复选框选择状态
const selectAll = ref(false) const selectAll = ref(false)
const selectedKeys = ref([]) const selectedKeys = ref<ApiKey[]>([])
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (keys.value.length === 0) { if (keys.value.length === 0) {
@@ -219,7 +214,7 @@ const toggleSelectAll = () => {
if (selectAll.value) { if (selectAll.value) {
// Select all on the current page // Select all on the current page
selectedKeys.value = users.value.map(user => user) selectedKeys.value = keys.value.map(key => key)
} else { } else {
// Clear all selections // Clear all selections
selectedKeys.value = [] selectedKeys.value = []
@@ -227,7 +222,7 @@ const toggleSelectAll = () => {
} }
const toggleUserSelection = (key) => { const toggleUserSelection = (key: ApiKey) => {
if (selectedKeys.value.includes(key)) { if (selectedKeys.value.includes(key)) {
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key); selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
} else { } else {
@@ -238,9 +233,9 @@ const toggleUserSelection = (key) => {
// 状态筛选 // 状态筛选
const statusOptions = ['Active', 'Inactive']; const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive([]); const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
const toggleStatusFilter = async (status) => { const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active'; const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status); const index = selectedStatuses.findIndex(item => item.status === status);
@@ -254,12 +249,12 @@ const toggleStatusFilter = async (status) => {
}; };
// 处理批量操作 // 处理批量操作
const handleBatchAction = async (action) => { const handleBatchAction = async (action: string) => {
if (selectedKeys.value.length === 0) { if (selectedKeys.value.length === 0) {
return setToast('请选择数据', 'error'); return setToast('请选择数据', 'error');
} }
if (!['enable', 'disable', 'delete'].includes(action)) { if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast('无效的操作 ${action}', 'error'); return setToast(`无效的操作 ${action}`, 'error');
} }
try { try {
@@ -273,14 +268,14 @@ const handleBatchAction = async (action) => {
selectAll.value = false; selectAll.value = false;
await fetchKeys(); await fetchKeys();
} catch (error) { } catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error); console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error'); setToast('批量操作失败', 'error');
} }
}; };
// 更新用户状态 // 更新用户状态
const updateStatus = async (key) => { const updateStatus = async (key: ApiKey) => {
try { try {
const action = key.active ? 'enable' : 'disable'; const action = key.active ? 'enable' : 'disable';
const res = await keyStore.keyOption(action, [key.id]); const res = await keyStore.keyOption(action, [key.id]);
@@ -289,24 +284,24 @@ const updateStatus = async (key) => {
setToast(`Key ${key.name} has been ${action}`, 'success'); setToast(`Key ${key.name} has been ${action}`, 'success');
} }
await fetchKeys(); await fetchKeys();
} catch (error) { } catch (error: any) {
console.error('状态更新失败:', error); console.error('状态更新失败:', error);
setToast('状态更新失败', 'error'); setToast('状态更新失败', 'error');
} }
}; };
const viewKey = (key) => { const viewKey = (key: ApiKey) => {
router.push({ name: 'ApiKeyView', query: { id: key.id } }); router.push({ name: 'ApiKeyView', query: { id: key.id } });
} }
// 删除用户 // 删除用户
const confirmDeleteKey = async (key) => { const confirmDeleteKey = async (key: ApiKey) => {
if (confirm(`确认删除 ${key.name}?`)) { if (confirm(`确认删除 ${key.name}?`)) {
await deleteKey(key); await deleteKey(key);
} }
}; };
const deleteKey = async (key) => { const deleteKey = async (key: ApiKey) => {
try { try {
const res = await keyStore.keyOption('delete', [key.id]); const res = await keyStore.keyOption('delete', [key.id]);
if (res.data?.code === 200) { if (res.data?.code === 200) {
@@ -314,13 +309,13 @@ const deleteKey = async (key) => {
} }
await fetchKeys(); await fetchKeys();
} catch (error) { } catch (error: any) {
console.error('删除失败:', error); console.error('删除失败:', error);
setToast('删除失败', 'error'); setToast('删除失败', 'error');
} }
}; };
const displayIcon = (apitype) => { const displayIcon = (apitype: string) => {
switch (apitype) { switch (apitype) {
case 'openai': case 'openai':
return '/assets/openai.svg'; return '/assets/openai.svg';
@@ -339,7 +334,7 @@ const displayIcon = (apitype) => {
} }
// 关闭模态框 // 关闭模态框
const modalRef = ref(null); const modalRef = ref<HTMLDialogElement | null>(null);
const closeModal = async () => { const closeModal = async () => {
if (modalRef.value) { if (modalRef.value) {
modalRef.value.close(); modalRef.value.close();
+12 -12
View File
@@ -55,7 +55,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-semibold text-base-content/70 w-20">角色</span> <span class="font-semibold text-base-content/70 w-20">角色</span>
<span class="badge" :class="user?.role > 0 ? 'badge-warning' : 'badge-ghost'">{{ <span class="badge" :class="(user?.role || 0) > 0 ? 'badge-warning' : 'badge-ghost'">{{
getRoleName(user?.role || 0) }}</span> getRoleName(user?.role || 0) }}</span>
</div> </div>
</div> </div>
@@ -149,8 +149,8 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { onMounted, computed } from 'vue'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -167,18 +167,18 @@ onMounted(async () => {
}); });
const getTimeOfDay = () => { const getTimeOfDay = (): string => {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour < 12) return '早上好'; if (hour < 12) return '早上好';
if (hour < 18) return '下午好'; if (hour < 18) return '下午好';
return '晚上好'; return '晚上好';
}; };
const formatQuota = (used, total) => { const formatQuota = (used: number, total: number): string => {
if (total === 0) return '无限制'; if (total === 0) return '无限制';
// 格式化金额 // 格式化金额
const formatCurrency = (amount) => { const formatCurrency = (amount: number): string => {
if (amount === 0) return '$0'; if (amount === 0) return '$0';
return `$${amount.toFixed(2)}`; return `$${amount.toFixed(2)}`;
}; };
@@ -188,7 +188,7 @@ const formatQuota = (used, total) => {
}; };
const getRoleName = (role) => { const getRoleName = (role: number): string => {
switch (role) { switch (role) {
case 20: return 'Root'; case 20: return 'Root';
case 10: return 'Admin'; case 10: return 'Admin';
@@ -197,7 +197,7 @@ const getRoleName = (role) => {
}; };
// 格式化日期时间 // 格式化日期时间
function formatDateTime(unixTimestamp) { function formatDateTime(unixTimestamp?: number): string {
// 如果时间戳不存在或为0,返回'未知' // 如果时间戳不存在或为0,返回'未知'
if (!unixTimestamp) return '未知'; if (!unixTimestamp) return '未知';
@@ -216,7 +216,7 @@ function formatDateTime(unixTimestamp) {
} }
// 获取背景渐变类 // 获取背景渐变类
const getGradientClass = () => { const getGradientClass = (): string => {
const hour = new Date().getHours(); const hour = new Date().getHours();
if (hour < 6) return 'bg-gradient-to-r from-[#e0f2f1] to-[#1a1a1a] bg-opacity-50 backdrop-blur-lg'; // 深夜到黎明:柔和的薄荷绿渐变到微黑 if (hour < 6) return 'bg-gradient-to-r from-[#e0f2f1] to-[#1a1a1a] bg-opacity-50 backdrop-blur-lg'; // 深夜到黎明:柔和的薄荷绿渐变到微黑
if (hour < 12) return 'bg-gradient-to-r from-[#8cc7f1] to-[#cf6f26] bg-opacity-50 backdrop-blur-lg'; // 早晨:温暖的杏仁色渐变到深灰 if (hour < 12) return 'bg-gradient-to-r from-[#8cc7f1] to-[#cf6f26] bg-opacity-50 backdrop-blur-lg'; // 早晨:温暖的杏仁色渐变到深灰
@@ -229,7 +229,7 @@ const getGradientClass = () => {
// 计算配额百分比 // 计算配额百分比
const quotaPercentage = computed(() => { const quotaPercentage = computed(() => {
if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0; if (!user.value || user.value.unlimited_quota || !user.value.quota) return 0;
return (user.value.used_quota / user.value.quota) * 100; return (user.value.used_quota ?? 0) / user.value.quota * 100;
}); });
// 获取配额颜色 // 获取配额颜色
@@ -249,12 +249,12 @@ const getQuotaColorClass = computed(() => {
}); });
// 用户最后活动时间 // 用户最后活动时间
const getLastActive = () => { const getLastActive = (): string => {
if (!user.value || !user.value?.updated_at) return '未知'; if (!user.value || !user.value?.updated_at) return '未知';
const lastActive = new Date(user.value.updated_at * 1000); const lastActive = new Date(user.value.updated_at * 1000);
const now = new Date(); const now = new Date();
const diff = now - lastActive; const diff = now.getTime() - lastActive.getTime();
// 转换为天/小时/分钟 // 转换为天/小时/分钟
const days = Math.floor(diff / (1000 * 60 * 60 * 24)); const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+27 -28
View File
@@ -251,7 +251,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="border rounded-md p-4 flex items-center justify-between"> <div class="border rounded-md p-4 flex items-center justify-between">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Github class="w-6 h-6 text-base-content/80" /> <img src="/assets/github.svg" alt="GitHub" class="w-6 h-6 text-base-content/80" />
<span>GitHub</span> <span>GitHub</span>
</div> </div>
<button class="btn btn-sm" :class="isGithubConnected ? 'btn-success' : 'btn-outline'"> <button class="btn btn-sm" :class="isGithubConnected ? 'btn-success' : 'btn-outline'">
@@ -274,26 +274,25 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, inject } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Bookmark, Infinity, Info } from '@lucide/vue';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Bookmark, Infinity, Github, Info } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useWebAuthStore } from '../../stores/webauth'; import { useWebAuthStore } from '@/stores/webauth';
import { formatDateTime} from '@/utils/format-date'; import { formatDateTime } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { UserInfo, PasskeyInfo } from '@/types';
const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const webAuthStore = useWebAuthStore(); const webAuthStore = useWebAuthStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
const loading = computed(() => authStore.loading); const loading = computed(() => authStore.loading);
const user = computed(() => authStore.user); const user = computed(() => authStore.user);
const basicinfo_error = ref(null); const basicinfo_error = ref<string | null>(null);
const password_error = ref(null); const password_error = ref<string | null>(null);
const basicinfo = ref({ const basicinfo = ref({
name: user.value?.name || '', name: user.value?.name || '',
@@ -345,9 +344,9 @@ const updateBasicInfo = async () => {
await authStore.refreshProfile(); // Refresh user data await authStore.refreshProfile(); // Refresh user data
basicinfo_error.value = null; // Clear error basicinfo_error.value = null; // Clear error
} catch (err) { } catch (err: any) {
console.log('Error updating basic info:', err); console.log('Error updating basic info:', err);
basicinfo_error.value = err || '更新失败'; basicinfo_error.value = err?.message || String(err) || '更新失败';
setToast('Failed to update basic information', 'error'); setToast('Failed to update basic information', 'error');
} }
}; };
@@ -372,18 +371,18 @@ const updatePassword = async () => {
passwordData.value.newPassword = ''; passwordData.value.newPassword = '';
passwordData.value.confirmPassword = ''; passwordData.value.confirmPassword = '';
password_error.value = null; // Clear error password_error.value = null; // Clear error
} catch (err) { } catch (err: any) {
console.error('Error updating password:', err); console.error('Error updating password:', err);
password_error.value = err || '更新失败'; password_error.value = err?.message || String(err) || '更新失败';
} }
}; };
// 格式化角色 // 格式化角色
const formatRole = (role) => { const formatRole = (role?: number): string => {
switch (true) { switch (true) {
case role > 10: case (role ?? 0) > 10:
return 'Root'; return 'Root';
case role > 0: case (role ?? 0) > 0:
return 'Admin'; return 'Admin';
default: default:
return 'User'; return 'User';
@@ -422,38 +421,38 @@ const toggleTelegramConnection = () => {
const newpasskey = async () => { const newpasskey = async () => {
try { try {
let res = await webAuthStore.addPasskey(); let res = await webAuthStore.addPasskey();
if (res.data?.code == 200) { if (res?.data?.code == 200) {
await getPasskeys(); await getPasskeys();
} }
} catch (err) { } catch (err: any) {
console.log('err', err); console.log('err', err);
} }
}; };
const passkeys = computed(() => webAuthStore.passkeys); const passkeys = computed<PasskeyInfo[] | null>(() => webAuthStore.passkeys);
const getPasskeys = async () => { const getPasskeys = async () => {
try { try {
await webAuthStore.getPasskeys(); await webAuthStore.getPasskeys();
} catch (err) { } catch (err: any) {
console.log('err', err); console.log('err', err);
} }
} }
const confirmRmovePasskey = async (passkey) => { const confirmRmovePasskey = async (passkey: PasskeyInfo) => {
if(confirm(`确认删除 ${passkey.name}?`)) { if(confirm(`确认删除 ${passkey.name}?`)) {
await removePasskey(passkey.id) await removePasskey(passkey.id)
} }
} }
const removePasskey = async (id) => { const removePasskey = async (id: PasskeyInfo['id']) => {
try { try {
const res = await webAuthStore.deletePasskey(id); const res = await webAuthStore.deletePasskey(id);
if (res.data?.code == 200) { if (res?.data?.code == 200) {
setToast('Passkey removed successfully', 'success'); setToast('Passkey removed successfully', 'success');
} }
await getPasskeys(); await getPasskeys();
} catch (err) { } catch (err: any) {
console.log('err', err); console.log('err', err);
} }
} }
+17 -16
View File
@@ -189,28 +189,29 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, inject } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig } from '@lucide/vue';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed import { useAuthStore } from '@/stores/auth';
import { useAuthStore } from '../../stores/auth';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast';
import type { UserInfo } from '@/types';
const route = useRoute();
const authStore = useAuthStore(); const authStore = useAuthStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
const loading = computed(() => authStore.loading); const loading = computed(() => authStore.loading);
const user = computed(() => authStore.user); const user = computed(() => authStore.user);
onMounted(async () => { onMounted(async () => {
await authStore.refreshProfile() await authStore.refreshProfile()
}); });
// 原实现误引用了未定义的 userStore/userId,这里改为更新当前登录用户资料
const updateUser = async () => { const updateUser = async () => {
if (!user.value) return; if (!user.value) return;
try { try {
const payload = { const payload: Partial<UserInfo> = {
name: user.value.name, name: user.value.name,
username: user.value.username, username: user.value.username,
email: user.value.email, email: user.value.email,
@@ -219,13 +220,13 @@ const updateUser = async () => {
if (user.value.password) { if (user.value.password) {
payload.password = user.value.password; payload.password = user.value.password;
} }
const res = await userStore.editUser(userId.value, payload); const res = await authStore.updateProfile(payload);
console.log('updateUser', res) console.log('updateUser', res)
if (res.data?.code == 200) { if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success'); setToast(`User ${user.value.username} updated`, 'success');
} }
await userStore.refreshUser(userId.value); await authStore.refreshProfile();
} catch (err) { } catch (err: any) {
console.error('Error updating user:', err.response?.data?.data?.error); console.error('Error updating user:', err.response?.data?.data?.error);
} }
}; };
@@ -239,11 +240,11 @@ const togglePasswordVisibility = () => {
}; };
// 格式化角色 // 格式化角色
const formatRole = (role) => { const formatRole = (role?: number): string => {
switch (true) { switch (true) {
case role > 10: case (role ?? 0) > 10:
return 'Root'; return 'Root';
case role > 0: case (role ?? 0) > 0:
return 'Admin'; return 'Admin';
default: default:
return 'U'; return 'U';
+14 -16
View File
@@ -133,27 +133,27 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, inject, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { Eye, EyeOff } from 'lucide-vue-next' import { Eye, EyeOff } from '@lucide/vue'
import { dateToUnix } from '@/utils/format-date.js' import { dateToUnix } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { TokenPayload } from '@/types';
const router = useRouter()
const authStore = useAuthStore() const authStore = useAuthStore()
const { setToast } = inject('toast') const { setToast } = useToast()
const error = ref(null) const error = ref<string | null>(null)
const user = computed(() => authStore.user); const user = computed(() => authStore.user);
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(false)
const newToken = ref({ const newToken = ref<TokenPayload>({
name: '', name: '',
key: '', key: '',
user_id: user.user_id, user_id: user.value?.user_id as number | undefined,
active: true, active: true,
quota: 0, quota: 0,
unlimited_quota: true, unlimited_quota: true,
@@ -216,7 +216,7 @@ const createToken = async () => {
console.log(res) console.log(res)
error.value = res.data?.error || 'Failed to create token' error.value = res.data?.error || 'Failed to create token'
} }
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create token' error.value = err.response?.data?.error || 'Failed to create token'
} }
@@ -227,10 +227,6 @@ const cancel = () => {
emit('closeModal', false) emit('closeModal', false)
} }
const deleteToken = async (id) => {
console.log(id)
}
// 显示密码 // 显示密码
const isTokenVisible = ref(false); const isTokenVisible = ref(false);
@@ -238,7 +234,9 @@ function toggleTokenVisibility() {
isTokenVisible.value = !isTokenVisible.value; isTokenVisible.value = !isTokenVisible.value;
} }
const emit = defineEmits(['closeModal']) const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script> </script>
+21 -21
View File
@@ -48,7 +48,7 @@
@change="updateStatus(token)" /> @change="updateStatus(token)" />
</td> </td>
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> --> <!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td> <td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
<td class="px-2 py-3"> <td class="px-2 py-3">
<template v-if="token.unlimited_quota"> <template v-if="token.unlimited_quota">
<Infinity /> <Infinity />
@@ -103,54 +103,54 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted, inject, computed, watch } from 'vue'; import { ref, onMounted, watch, computed } from 'vue';
import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import QRCodeCard from '@/components/QRCodeCard.vue'; import QRCodeCard from '@/components/common/QRCodeCard.vue';
import TokenNew from '@/views/dashboard/TokenNew.vue'; import TokenNew from '@/views/dashboard/TokenNew.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { import {
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser
} from 'lucide-vue-next'; } from '@lucide/vue';
import { unixToDate } from '@/utils/format-date'; import { unixToDate } from '@/utils/format-date';
import { useToast } from '@/composables/toast';
import type { TokenInfo } from '@/types';
const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore();
const user = computed(() => authStore.user); const user = computed(() => authStore.user);
const { setToast } = inject('toast'); const { setToast } = useToast();
onMounted(async () => { onMounted(async () => {
await authStore.refreshProfile(); await authStore.refreshProfile();
}) })
watch(() => authStore.user, async (newUser) => { watch(() => authStore.user, (newUser) => {
if (newUser.expired_at > 0) { if (newUser && newUser.expired_at && newUser.expired_at > 0) {
newUser.format_expired_at = unixToDate(newUser.expired_at); newUser.format_expired_at = unixToDate(newUser.expired_at);
} }
}) })
const updateStatus = async (token) => { const updateStatus = async (token: TokenInfo) => {
console.log(token); console.log(token);
try { try {
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active }); const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
if (res.data?.code == 200) { if (res.data?.code == 200) {
setToast(`Token ${token.name} updated`, 'success'); setToast(`Token ${token.name} updated`, 'success');
} }
} catch (error) { } catch (error: any) {
token.active = !token.active token.active = !token.active
console.log(error.response.data.error); console.log(error.response.data.error);
setToast(error.response.data.error, 'error'); setToast(error.response.data.error, 'error');
} }
} }
const confirmRevokeToken = async (token) => { const confirmRevokeToken = async (token: TokenInfo) => {
if (confirm(`确认删除 ${token.name}?`)) { if (confirm(`确认删除 ${token.name}?`)) {
await revokeToken(token); await revokeToken(token);
} }
} }
const revokeToken = async (token) => { const revokeToken = async (token: TokenInfo) => {
try { try {
const res = await authStore.deleteToken(token.id); const res = await authStore.deleteToken(token.id);
if (res.data?.code == 200) { if (res.data?.code == 200) {
@@ -158,12 +158,12 @@ const revokeToken = async (token) => {
} }
await authStore.refreshProfile(); await authStore.refreshProfile();
} catch (error) { } catch (error: any) {
setToast(error.response.data.error, 'error'); setToast(error.response.data.error, 'error');
} }
} }
const cleanUsedToken = async (token) => { const cleanUsedToken = async (token: TokenInfo) => {
if (token.used_quota == 0 || token.used_quota == null) { if (token.used_quota == 0 || token.used_quota == null) {
return; return;
@@ -175,19 +175,19 @@ const cleanUsedToken = async (token) => {
setToast(`Token ${token.name} used quota reset`, 'success'); setToast(`Token ${token.name} used quota reset`, 'success');
} }
await authStore.refreshProfile(); await authStore.refreshProfile();
} catch (error) { } catch (error: any) {
console.log(error); console.log(error);
setToast(error, 'error'); setToast(error, 'error');
} }
} }
const showTokenModel = ref(false); const showTokenModel = ref(false);
const tokenRef = ref(null); const tokenRef = ref<HTMLDialogElement | null>(null);
const viewToken = (token) => { const viewToken = (token: TokenInfo) => {
const dialog = tokenRef.value; const dialog = tokenRef.value;
if (dialog) { if (dialog) {
if (!dialog.hasAttribute('open')) { if (!dialog.hasAttribute('open')) {
qrCodeValue.value = token.key; qrCodeValue.value = token.key || '';
dialog.showModal(); dialog.showModal();
} else { } else {
if (dialog.hasAttribute('open')) { if (dialog.hasAttribute('open')) {
@@ -202,7 +202,7 @@ const qrCodeValue = ref('');
// 关闭模态框 // 关闭模态框
const modalRef = ref(null); const modalRef = ref<HTMLDialogElement | null>(null);
const closeModal = async () => { const closeModal = async () => {
if (modalRef.value) { if (modalRef.value) {
modalRef.value.close(); modalRef.value.close();
+28 -28
View File
@@ -144,27 +144,29 @@
<!-- Pagination --> <!-- Pagination -->
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize" <Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" /> :pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, reactive, onMounted, inject, computed } from 'vue'; import { ref, reactive, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import Pagination from '@/components/Pagination.vue'; import Pagination from '@/components/common/Pagination.vue';
import UserNew from '@/views/dashboard/UserNew.vue'; import UserNew from '@/views/dashboard/UserNew.vue';
import { useUserStore } from '@/stores/user'; import { useUserStore } from '@/stores/user';
import { useToast } from '@/composables/toast';
import type { UserInfo } from '@/types';
import { import {
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon, BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
TrashIcon, Infinity TrashIcon, Infinity
} from 'lucide-vue-next'; } from '@lucide/vue';
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const users = computed(() => userStore.users); const users = computed(() => userStore.users);
const { setToast } = inject('toast'); const { setToast } = useToast();
// 用户数据 // 用户数据
const currentPage = ref(1); const currentPage = ref(1);
@@ -172,10 +174,10 @@ const pageSize = ref(10);
const totalItems = computed(() => userStore.totalUsers); const totalItems = computed(() => userStore.totalUsers);
// 封装公共的用户列表获取方法 // 封装公共的用户列表获取方法
const listUsers = async (size = pageSize.value, page = currentPage.value, active = selectedStatuses.map(status => status.value)) => { const listUsers = async (size?: number, page?: number, active?: boolean[] | boolean) => {
currentPage.value = page || currentPage.value; currentPage.value = page || currentPage.value;
// console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value)); // console.log('pagesize', pageSize.value, 'page', currentPage.value, 'active', selectedStatuses.map(status => status.value));
await userStore.listUser(size, page, active); await userStore.listUser(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
}; };
// 组件挂载时加载用户数据 // 组件挂载时加载用户数据
@@ -184,7 +186,7 @@ onMounted(() => {
}); });
// 分页与页面大小变化 // 分页与页面大小变化
const changePage = async (page, size) => { const changePage = async (page: number, size: number) => {
if (page == currentPage.value && size == pageSize.value) { if (page == currentPage.value && size == pageSize.value) {
return return
} }
@@ -193,11 +195,9 @@ const changePage = async (page, size) => {
await listUsers(); await listUsers();
}; };
const changePageSize = changePage;
// 复选框选择状态 // 复选框选择状态
const selectAll = ref(false) const selectAll = ref(false)
const selectedUsers = ref([]) const selectedUsers = ref<UserInfo[]>([])
const toggleSelectAll = () => { const toggleSelectAll = () => {
users.value.forEach(key => key.selected = selectAll.value) users.value.forEach(key => key.selected = selectAll.value)
@@ -212,7 +212,7 @@ const toggleSelectAll = () => {
} }
const toggleUserSelection = (user) => { const toggleUserSelection = (user: UserInfo) => {
if (selectedUsers.value.includes(user)) { if (selectedUsers.value.includes(user)) {
selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user); selectedUsers.value = selectedUsers.value.filter(selectedUser => selectedUser !== user);
} else { } else {
@@ -223,9 +223,9 @@ const toggleUserSelection = (user) => {
// 状态筛选 // 状态筛选
const statusOptions = ['Active', 'Inactive']; const statusOptions = ['Active', 'Inactive'];
const selectedStatuses = reactive([]); const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
const toggleStatusFilter = async (status) => { const toggleStatusFilter = async (status: string) => {
const statusValue = status === 'Active'; const statusValue = status === 'Active';
const index = selectedStatuses.findIndex(item => item.status === status); const index = selectedStatuses.findIndex(item => item.status === status);
@@ -239,12 +239,12 @@ const toggleStatusFilter = async (status) => {
}; };
// 处理批量操作 // 处理批量操作
const handleBatchAction = async (action) => { const handleBatchAction = async (action: string) => {
if (selectedUsers.value.length === 0) { if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error'); return setToast('请选择用户', 'error');
} }
if (!['enable', 'disable', 'delete'].includes(action)) { if (!['enable', 'disable', 'delete'].includes(action)) {
return setToast('无效的操作 ${action}', 'error'); return setToast(`无效的操作 ${action}`, 'error');
} }
if (selectedUsers.value.length === 0) { if (selectedUsers.value.length === 0) {
return setToast('请选择用户', 'error'); return setToast('请选择用户', 'error');
@@ -255,20 +255,20 @@ const handleBatchAction = async (action) => {
if (res.data?.code === 200) { if (res.data?.code === 200) {
setToast(`Users ${action} Success`, 'success'); setToast(`Users ${action} Success`, 'success');
} else { } else {
setToast(res.error || `${action} Failed`, 'error'); setToast(res.data?.error || `${action} Failed`, 'error');
} }
selectedUsers.value = []; selectedUsers.value = [];
selectAll.value = false; selectAll.value = false;
await listUsers(); await listUsers();
} catch (error) { } catch (error: any) {
console.error(`批量操作 ${action} 失败:`, error); console.error(`批量操作 ${action} 失败:`, error);
setToast('批量操作失败', 'error'); setToast('批量操作失败', 'error');
} }
}; };
// 更新用户状态 // 更新用户状态
const updateStatus = async (user) => { const updateStatus = async (user: UserInfo) => {
try { try {
const action = user.active ? 'enable' : 'disable'; const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]); const res = await userStore.userOption(action, [user.id]);
@@ -276,45 +276,45 @@ const updateStatus = async (user) => {
if (res.data?.code === 200) { if (res.data?.code === 200) {
setToast(`User ${user.name} has been ${action}`, 'success'); setToast(`User ${user.name} has been ${action}`, 'success');
} else { } else {
setToast(res.error || `用户 ${user.id} ${action} 失败`, 'error'); setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
} }
await listUsers(); await listUsers();
} catch (error) { } catch (error: any) {
console.error('状态更新失败:', error); console.error('状态更新失败:', error);
setToast('状态更新失败', 'error'); setToast('状态更新失败', 'error');
} }
}; };
const viewUser = (user) => { const viewUser = (user: UserInfo) => {
router.push({ name: 'UserView', query: { id: user.id } }); router.push({ name: 'UserView', query: { id: user.id } });
} }
const confirmDeleteUser = (user) => { const confirmDeleteUser = (user: UserInfo) => {
if (confirm(`确认删除 ${user.username}?`)) { if (confirm(`确认删除 ${user.username}?`)) {
deleteUser(user); deleteUser(user);
} }
}; };
// 删除用户 // 删除用户
const deleteUser = async (user) => { const deleteUser = async (user: UserInfo) => {
try { try {
const res = await userStore.userOption('delete', [user.id]); const res = await userStore.userOption('delete', [user.id]);
if (res.data?.code === 200) { if (res.data?.code === 200) {
setToast('用户删除成功', 'success'); setToast('用户删除成功', 'success');
} else { } else {
setToast(res.error || '删除失败', 'error'); setToast(res.data?.error || '删除失败', 'error');
} }
await listUsers(); await listUsers();
} catch (error) { } catch (error: any) {
console.error('删除失败:', error); console.error('删除失败:', error);
setToast('删除失败', 'error'); setToast('删除失败', 'error');
} }
}; };
// 关闭模态框 // 关闭模态框
const modalRef = ref(null); const modalRef = ref<HTMLDialogElement | null>(null);
const closeModal = async () => { const closeModal = async () => {
if (modalRef.value) { if (modalRef.value) {
modalRef.value.close(); modalRef.value.close();
+14 -12
View File
@@ -151,23 +151,23 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, inject } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user' import { useUserStore } from '@/stores/user'
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { Eye, EyeOff } from 'lucide-vue-next' import { Eye, EyeOff } from '@lucide/vue'
import { useToast } from '@/composables/toast';
import type { NewUserPayload } from '@/types';
const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { setToast } = inject('toast') const { setToast } = useToast()
const error = ref(null) const error = ref<string | null>(null)
// Control advanced options visibility // Control advanced options visibility
const showAdvancedOptions = ref(false) const showAdvancedOptions = ref(false)
// Initialize user object // Initialize user object
const newUser = ref({ const newUser = ref<NewUserPayload>({
name: '', name: '',
username: '', username: '',
email: '', email: '',
@@ -188,7 +188,7 @@ const resetNewUser = () => {
role: 0, // Default to Regular User role: 0, // Default to Regular User
active: true, // Default to Active active: true, // Default to Active
quota: 0, // Default quota value (relevant if not unlimited) quota: 0, // Default quota value (relevant if not unlimited)
unlimitedQuota: true, // Default to unlimited unlimited_quota: true, // Default to unlimited
language: 'en', // Default language language: 'en', // Default language
} }
} }
@@ -226,9 +226,9 @@ const createUser = async () => {
// Optionally navigate or reset form // Optionally navigate or reset form
emit('closeModal', true) emit('closeModal', true)
} else { } else {
setToast(res.error || res.data?.message || 'Failed to create user', 'error') setToast(res.data?.error || res.data?.message || 'Failed to create user', 'error')
} }
} catch (err) { } catch (err: any) {
error.value = err.response?.data?.error || 'Failed to create user' error.value = err.response?.data?.error || 'Failed to create user'
// setToast(error.response?.data?.error || 'Failed to create user', 'error') // setToast(error.response?.data?.error || 'Failed to create user', 'error')
} }
@@ -241,7 +241,9 @@ function togglePasswordVisibility() {
isPasswordVisible.value = !isPasswordVisible.value; isPasswordVisible.value = !isPasswordVisible.value;
} }
const emit = defineEmits(['closeModal']) const emit = defineEmits<{
(e: 'closeModal', value: boolean): void
}>()
</script> </script>
+19 -17
View File
@@ -214,22 +214,24 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, computed, onMounted, inject } from 'vue'; import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from 'lucide-vue-next'; // Ensure lucide-vue-next is installed import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from '@lucide/vue';
import { useUserStore } from '../../stores/user'; import { useUserStore } from '@/stores/user';
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue'; import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
import { useToast } from '@/composables/toast';
import type { UserInfo, TokenInfo } from '@/types';
const route = useRoute(); const route = useRoute();
const userStore = useUserStore(); const userStore = useUserStore();
const { setToast } = inject('toast'); const { setToast } = useToast();
const userId = computed(() => route.query.id); const userId = computed(() => route.query.id);
onMounted(async () => { onMounted(async () => {
if (userId.value) { if (userId.value) {
await userStore.getUser(userId.value); await userStore.getUser(userId.value as string);
} }
}); });
@@ -237,7 +239,7 @@ const user = computed(() => userStore.user);
const loading = computed(() => userStore.loading); // Access loading state const loading = computed(() => userStore.loading); // Access loading state
// 更新状态 // 更新状态
const updateStatus = async (user) => { const updateStatus = async (user: UserInfo) => {
try { try {
const action = user.active ? 'enable' : 'disable'; const action = user.active ? 'enable' : 'disable';
const res = await userStore.userOption(action, [user.id]); const res = await userStore.userOption(action, [user.id]);
@@ -247,7 +249,7 @@ const updateStatus = async (user) => {
setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error'); setToast(res.data?.error || `用户 ${user.id} ${action} 失败`, 'error');
} }
await userStore.refreshUser(user.id); await userStore.refreshUser(user.id);
} catch (error) { } catch (error: any) {
user.active = !user.active; user.active = !user.active;
console.error('状态更新失败:', error); console.error('状态更新失败:', error);
// setToast(error.response.data?.error || '状态更新失败', 'error'); // setToast(error.response.data?.error || '状态更新失败', 'error');
@@ -257,7 +259,7 @@ const updateStatus = async (user) => {
const updateUser = async () => { const updateUser = async () => {
if (!user.value) return; if (!user.value) return;
try { try {
const payload = { const payload: Partial<UserInfo> = {
name: user.value.name, name: user.value.name,
username: user.value.username, username: user.value.username,
email: user.value.email, email: user.value.email,
@@ -270,13 +272,13 @@ const updateUser = async () => {
if (user.value.password) { if (user.value.password) {
payload.password = user.value.password; payload.password = user.value.password;
} }
const res = await userStore.editUser(userId.value, payload); const res = await userStore.editUser(userId.value as string, payload);
console.log('updateUser', res) console.log('updateUser', res)
if (res.data?.code == 200) { if (res.data?.code == 200) {
setToast(`User ${userId.value} updated`, 'success'); setToast(`User ${userId.value} updated`, 'success');
} }
await userStore.refreshUser(userId.value); await userStore.refreshUser(userId.value as string);
} catch (err) { } catch (err: any) {
console.error('Error updating user:', err.response?.data?.data?.error); console.error('Error updating user:', err.response?.data?.data?.error);
} }
}; };
@@ -290,11 +292,11 @@ const togglePasswordVisibility = () => {
}; };
// 格式化角色 // 格式化角色
const formatRole = (role) => { const formatRole = (role?: number): string => {
switch (true) { switch (true) {
case role > 10: case (role ?? 0) > 10:
return 'Root'; return 'Root';
case role > 0: case (role ?? 0) > 0:
return 'Admin'; return 'Admin';
default: default:
return 'U'; return 'U';
@@ -316,7 +318,7 @@ const formatRole = (role) => {
// } // }
// 格式化日期 // 格式化日期
const formatDate = (dateString) => { const formatDate = (dateString?: number): string | null => {
if (!dateString) return null; if (!dateString) return null;
try { try {
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(dateString * 1000)); // Multiply by 1000 for JavaScript Date return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(dateString * 1000)); // Multiply by 1000 for JavaScript Date
@@ -327,7 +329,7 @@ const formatDate = (dateString) => {
}; };
// 删除token // 删除token
const revokeToken = (tokenId) => { const revokeToken = (tokenId: TokenInfo['id']) => {
console.log('Revoking token:', tokenId); console.log('Revoking token:', tokenId);
}; };
@@ -3,7 +3,7 @@
<header class="fixed w-full top-0 z-50 backdrop-blur-md bg-base-100/50"> <header class="fixed w-full top-0 z-50 backdrop-blur-md bg-base-100/50">
<div class="container mx-auto flex justify-between items-center p-4"> <div class="container mx-auto flex justify-between items-center p-4">
<div class="flex items-center h-12 w-12 rounded-full text-l"> <div class="flex items-center h-12 w-12 rounded-full text-l">
<img src="../assets/logo.svg" alt="Logo" class="select-none"> <img src="@/assets/logo.svg" alt="Logo" class="select-none">
<span class="hidden sm:flex text-xl font-bold"> <span class="hidden sm:flex text-xl font-bold">
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a> <a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
</span> </span>
@@ -16,7 +16,7 @@
<main class="flex-grow flex flex-col justify-center items-center pt-16"> <main class="flex-grow flex flex-col justify-center items-center pt-16">
<div class="text-center"> <div class="text-center">
<div class="flex items-center justify-center my-8 outline-none select-none"> <div class="flex items-center justify-center my-8 outline-none select-none">
<!-- <img src="../assets/404.svg" alt="404 Not Found" class="h-48"> --> <!-- <img src="@/assets/404.svg" alt="404 Not Found" class="h-48"> -->
</div> </div>
<h1 class="text-5xl font-bold mb-4 text-rose-300"> <h1 class="text-5xl font-bold mb-4 text-rose-300">
404 404
@@ -50,7 +50,7 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
const currentYear = ref(''); const currentYear = ref('');
+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
}
-16
View File
@@ -1,16 +0,0 @@
/** @type {import('tailwindcss').Config} */
import daisyui from 'daisyui';
export default{
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [daisyui],
daisyui: {
themes: ["light", "dark","cupcake","emerald","pastel"], // 可以根据需要添加或修改主题
},
}
+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"]
}
@@ -1,19 +1,34 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import basicSsl from '@vitejs/plugin-basic-ssl'; // 推荐使用这个插件简化自签名证书管理 import basicSsl from '@vitejs/plugin-basic-ssl'; // 推荐使用这个插件简化自签名证书管理
import path from 'path' 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/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue(),basicSsl()], plugins: [vue(), tailwindcss(), ...(useHttps ? [basicSsl()] : [])],
server: { server: {
https: true, // 启用 HTTPS https: useHttps ? {} : undefined,
host: 'localhost', // 确保 host 是 localhost host: 'localhost', // 确保 host 是 localhost
port: 5173, port: 5173,
proxy: {
// 前端 axios baseURL 为 /api,开发时代理到本地 Go 后端,免去跨域与重建
'/api': {
target: apiTarget,
changeOrigin: true,
},
},
}, },
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(import.meta.dirname, 'src'),
}, },
}, },
build: { build: {
+25 -6
View File
@@ -16,17 +16,36 @@ LDFlags=" \
-X 'main.BuildGoVersion=$(BuildGoVersion)'" -X 'main.BuildGoVersion=$(BuildGoVersion)'"
.PHONY: web .PHONY: web
# web # 构建前端并放入 Go embed 目录 (cmd/openteam/dist)
web: web:
cd frontend && npm install -g pnpm && pnpm build && mv dist ../cmd/openteam/ cd frontend && (command -v pnpm >/dev/null 2>&1 || npm install -g pnpm) && pnpm install --frozen-lockfile && pnpm build
rm -rf cmd/openteam/dist
mkdir -p cmd/openteam
mv frontend/dist cmd/openteam/dist
touch cmd/openteam/dist/.gitkeep
.PHONY: dev-backend
# 本地启动后端 (端口 8080, 数据库 ./db/openteam.db)
dev-backend:
PORT=8080 go run ./cmd/openteam
.PHONY: dev-frontend
# 本地启动前端 dev server (端口 5173, /api 代理到后端)
dev-frontend:
cd frontend && pnpm dev
.PHONY: dev
# 并行启动前后端本地开发环境 (Ctrl+C 一起退出)
dev:
$(MAKE) -j2 dev-backend dev-frontend
.PHONY: build .PHONY: build
# build # build
build: build:
# mkdir -p bin/ && go build -ldflags $(LDFlags) -o ./bin/ ./... # mkdir -p bin/ && go build -ldflags $(LDFlags) -o ./bin/ ./...
rm -rf bin rm -rf bin
mkdir -p bin/ && go build -ldflags "-s -w" -o ./bin/openteam ./cmd/openteam/ mkdir -p bin/ && CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/openteam ./cmd/openteam/
upx -9 bin/openteam command -v upx >/dev/null 2>&1 && upx -9 bin/openteam || echo "upx 未安装,跳过二进制压缩"
.PHONY:image .PHONY:image
# build docker images # build docker images