Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e07c5903f | ||
|
|
ac0a6808ec |
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
# opencatd-open 重构计划
|
||||||
|
|
||||||
|
> 本文档是重构过程的唯一进度记录,每完成一个阶段立即更新「阶段状态」与「执行记录」。
|
||||||
|
> 前后端同仓库,本期(第一期)只重构前端 + 前端相关的构建脚本;后端代码不在本期范围。
|
||||||
|
|
||||||
|
- 计划创建时间:2026-08-29
|
||||||
|
- 当前分支:`team`(按约定不提交,所有改动留在工作区,由维护者回来后审查)
|
||||||
|
- 项目根目录:`/Users/cjun/Code/Go/src/opencatd-open`
|
||||||
|
- 前端目录:`frontend/`(构建产物 `dist/` 由 Go 通过 `//go:embed dist/*` 嵌入 `cmd/openteam`)
|
||||||
|
|
||||||
|
## 一、背景与现状
|
||||||
|
|
||||||
|
| 项 | 现状 | 问题 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 技术栈 | Vue 3.5 + Vite 6 + JavaScript,无 TS | 依赖偏旧;无类型约束 |
|
||||||
|
| UI 库 | Element Plus 与 daisyUI/Tailwind 3 **两套并存** | 9 个视图使用 `el-*` 组件,风格割裂、包体冗余 |
|
||||||
|
| 目录结构 | components/views 仅按 dashboard 简单分层 | 组件分类不规范,无 api/layouts/types 分层 |
|
||||||
|
| 构建脚本 | Dockerfile 三阶段,node 阶段未指定 `$BUILDPLATFORM` | 多架构构建时前端被 QEMU 模拟重复编译,极慢 |
|
||||||
|
| 依赖声明 | pinia、@iconify/vue 误放 devDependencies | 分类错误 |
|
||||||
|
| Dockerfile 杂项 | 存在无效的 `CMD ["go mod tidy","go mod download"]`;node:20 基础镜像 | 需清理/升级 |
|
||||||
|
| 杂项 | 根目录 `web/`(仅 dist + node_modules,未跟踪) | 疑似误构建产物,暂不动,仅记录 |
|
||||||
|
|
||||||
|
## 二、已确认的决策(2026-08-29,维护者离开前确认)
|
||||||
|
|
||||||
|
1. **迁移到 TypeScript**(全量,含 vue-tsc 类型检查)。
|
||||||
|
2. **UI 统一到 Tailwind/daisyUI**,移除 Element Plus,`el-*` 组件全部重写;接受外观变化。
|
||||||
|
3. **不提交**:所有改动留在工作区,按阶段推进,不做 git commit。
|
||||||
|
|
||||||
|
其余由执行者自行决定的默认约定:
|
||||||
|
|
||||||
|
- 依赖一律升到**当前最新稳定版**(含 Tailwind 4 / daisyUI 5 / Vite 7+ / Pinia 3 等大版本跨越)。
|
||||||
|
- Element Plus 在被移除前不再投入升级成本(Phase 4 直接删除)。
|
||||||
|
- 每阶段验收标准:`pnpm build`(后期含 `vue-tsc`)通过 + 页面路由/交互逻辑与重构前等价。
|
||||||
|
- 计划文档放项目根目录 `REFACTOR_PLAN.md`。
|
||||||
|
|
||||||
|
## 三、阶段计划
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Phase 0 | 创建本计划文档 | ✅ 完成 |
|
||||||
|
| Phase 1 | 依赖全部升级到最新版(Tailwind 4 / daisyUI 5 迁移、pinia 归位 dependencies) | ✅ 完成 |
|
||||||
|
| Phase 2 | TypeScript 迁移(tsconfig、vue-tsc、全量 .ts/.vue 改写) | ✅ 完成 |
|
||||||
|
| Phase 3 | 目录结构规范化(api / components / composables / layouts / types / views 分层) | ✅ 完成 |
|
||||||
|
| Phase 4 | 移除 Element Plus,统一 Tailwind/daisyUI 重写全部组件 | ✅ 完成 |
|
||||||
|
| Phase 5 | Docker / makefile 构建脚本更新(前端 `$BUILDPLATFORM` 单次编译) | ✅ 完成 |
|
||||||
|
| Phase 6 | 最终验证(前端 build + Go embed 编译),收尾文档 | ✅ 完成 |
|
||||||
|
|
||||||
|
## 四、各阶段详细方案
|
||||||
|
|
||||||
|
### Phase 1 — 依赖升级
|
||||||
|
|
||||||
|
- `vite`、`@vitejs/plugin-vue`、`@vitejs/plugin-basic-ssl`、`vue`、`vue-router`、`axios`、`lucide-vue-next`、`qrcode.vue`、`@simplewebauthn/browser`、`@iconify/vue`、`@iconify-json/*` → 最新。
|
||||||
|
- `pinia` → v3 并移入 dependencies;`@iconify/vue` 移入 dependencies。
|
||||||
|
- Tailwind 3 → 4:改用 `@tailwindcss/vite` 插件,删除 `postcss.config.js`/`autoprefixer`/`tailwind.config.js`,`style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"` + `@theme` 定义原有 daisyUI 主题集合(light/dark/cupcake/emerald/pastel)。
|
||||||
|
- `daisyui` → v5。
|
||||||
|
- element-plus 保持现状(Phase 4 删除)。
|
||||||
|
- 验收:`pnpm build` 通过。
|
||||||
|
|
||||||
|
### Phase 2 — TypeScript 迁移
|
||||||
|
|
||||||
|
- 新增 `tsconfig.json`(bundler 解析策略 + `@` 别名路径映射)、`src/vite-env.d.ts`、`env.d.ts`(`import.meta.env` 类型)。
|
||||||
|
- `vite.config.js` → `vite.config.ts`;`src/**/*.js`(router/stores/utils/main)→ `.ts`。
|
||||||
|
- 全部 `.vue` 改 `<script setup lang="ts">`,props/emits/响应式数据补类型。
|
||||||
|
- `build` 脚本加 `vue-tsc --noEmit` 类型检查。
|
||||||
|
- 验收:`pnpm build`(含 vue-tsc)通过。
|
||||||
|
|
||||||
|
### Phase 3 — 目录结构规范化
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # axios 实例 + 各业务接口封装(由 utils/request.js 演进)
|
||||||
|
├── assets/ # 图片/图标(不变)
|
||||||
|
├── components/
|
||||||
|
│ ├── common/ # 通用组件(Toast、Pagination、QRCodeCard、LineSegmentFlow)
|
||||||
|
│ └── dashboard/ # 仪表盘布局组件(Sidebar、BreadcrumbHeader)
|
||||||
|
├── composables/ # 组合式函数(useToast 等从 provide/inject 演进)
|
||||||
|
├── layouts/ # 布局(DashboardLayout 等,如适用)
|
||||||
|
├── router/ # 路由
|
||||||
|
├── stores/ # pinia stores
|
||||||
|
├── styles/ # 全局样式
|
||||||
|
├── types/ # 共享 TS 类型(API 响应、业务实体)
|
||||||
|
├── utils/ # 纯工具函数(格式化日期等)
|
||||||
|
└── views/
|
||||||
|
├── auth/ # Login、Signup
|
||||||
|
├── error/ # 404
|
||||||
|
└── dashboard/ # Overview、Keys、Tokens、Users、Settings、Profile 等
|
||||||
|
```
|
||||||
|
|
||||||
|
- vite.config 的 manualChunks 别名同步更新。
|
||||||
|
- 验收:`pnpm build` 通过,无悬空 import。
|
||||||
|
|
||||||
|
### Phase 4 — UI 统一到 Tailwind/daisyUI
|
||||||
|
|
||||||
|
- 移除 `element-plus` 依赖与 `main.ts` 全局注册。
|
||||||
|
- 重写以下 9 个视图中的 `el-*` 组件(table/dialog/form/select/input/switch/message 等用 daisyUI 组件类 + 自实现交互):
|
||||||
|
Login、Signup、dashboard/{UserView、Settings、KeyView、Profile、TokenNew、KeyNew、UserNew}。
|
||||||
|
- 顺带规范既有自研组件(Toast、Pagination 等)使用 daisyUI 类。
|
||||||
|
- 保留既有业务逻辑、字段、接口调用不变。
|
||||||
|
- 验收:`pnpm build` 通过;`grep el-`/`element-plus` 无残留。
|
||||||
|
|
||||||
|
### Phase 5 — Docker / makefile 构建脚本
|
||||||
|
|
||||||
|
- `deploy/docker/Dockerfile`:
|
||||||
|
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend`(多架构下只原生编译一次)。
|
||||||
|
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH` 交叉编译(go.mod 使用纯 Go 的 glebarez/sqlite,可关闭 CGO);如遇阻塞则后端阶段回退为按目标平台编译,仅保留前端优化。
|
||||||
|
- 删除无效 `CMD` 行;runner 阶段瘦身。
|
||||||
|
- `makefile`:web 目标改用 pnpm(不强制全局安装 pnpm),构建产物位置与 embed 路径核对。
|
||||||
|
- 同步更新 `Dockerfile.cn`(国内镜像版)保持一致。
|
||||||
|
- 验收:`docker build` 本地单架构通过(多架构如环境不允许则用 `--platform` 模拟检查语法与目标参数)。
|
||||||
|
|
||||||
|
### Phase 6 — 最终验证与收尾
|
||||||
|
|
||||||
|
- `frontend: pnpm build`(含 vue-tsc)。
|
||||||
|
- 把 `frontend/dist` 放入 `cmd/openteam/dist` 后执行 `go build ./cmd/openteam`,确认 embed 成功。
|
||||||
|
- 更新本文档所有阶段状态与执行记录,列出遗留问题(如 `web/` 目录处置、外观回归点)。
|
||||||
|
|
||||||
|
## 五、执行记录(每阶段完成后追加)
|
||||||
|
|
||||||
|
### Phase 0 — 计划文档创建(2026-08-29)
|
||||||
|
|
||||||
|
- 已确认三项决策:TS 迁移 / 统一 Tailwind-daisyUI / 不提交。
|
||||||
|
- 摸底结论:前端约 4700 行 Vue;Element Plus 用于 9 个视图;pinia、@iconify/vue 在 devDependencies;`glebarez/sqlite` 为纯 Go 驱动(Docker 交叉编译可行);本地 Node v25.2.1 / pnpm 10.25.0。
|
||||||
|
- 发现根目录未跟踪的 `web/` 目录仅含 dist 与 node_modules,疑似误产物,本期不动。
|
||||||
|
|
||||||
|
### Phase 1 — 依赖升级(2026-08-29)✅
|
||||||
|
|
||||||
|
- 升级结果(均为当前最新稳定版):
|
||||||
|
- dependencies:vue 3.5.42、vue-router **5.3.0**(大版本 4→5)、pinia **4.0.3**(大版本 2→4,并移入 dependencies)、axios 1.20.0、qrcode.vue 3.10.0、@simplewebauthn/browser 13.3.0、@iconify/vue 5.0.1(移入 dependencies)、**@lucide/vue 1.37.0**(替代已废弃的 lucide-vue-next)。
|
||||||
|
- devDependencies:vite **8.2.2**(大版本 6→8,构建器为 rolldown)、@vitejs/plugin-vue 6.0.8、@vitejs/plugin-basic-ssl 2.3.0、tailwindcss **4.3.3**(大版本 3→4)、@tailwindcss/vite 4.3.3、daisyui **5.7.22**(大版本 4→5)、@iconify-json/* 升级。
|
||||||
|
- 移除:autoprefixer、postcss、tailwind.config.js、postcss.config.js(Tailwind 4 改为 CSS-first 配置)。
|
||||||
|
- 迁移要点:
|
||||||
|
- `style.css` 改为 `@import "tailwindcss"` + `@plugin "daisyui"`,主题集合保持 light(默认)/dark/cupcake/emerald/pastel。
|
||||||
|
- `vite.config.js` 加入 `@tailwindcss/vite` 插件;`__dirname` 改为 `import.meta.dirname`(消除 Vite 8 警告)。
|
||||||
|
- package.json 改名为 `opencatd-open-frontend`;新增 `pnpm.onlyBuiltDependencies: [esbuild, vue-demi]` 放行构建脚本。
|
||||||
|
- 代码适配:新版 Lucide 移除品牌图标,`Profile.vue` 的 `<Github>` 图标改为项目内已有的 `<img src="/assets/github.svg">`(与 Keys/KeyNew/KeyView 用法一致)。
|
||||||
|
- 验收:`pnpm build` 通过(4.8s,rolldown 构建)。
|
||||||
|
- 备注:element-plus 2.9.7 保持旧版未升级(Phase 4 将整体移除);当前 components chunk 406KB 主要来自 Element Plus,Phase 4 后预计大幅缩小。
|
||||||
|
|
||||||
|
### Phase 2 — TypeScript 迁移(2026-08-29)✅
|
||||||
|
|
||||||
|
- 工具链:typescript **6.0.3** + vue-tsc 3.3.11 + @types/node 26.4.0。
|
||||||
|
- 注:最初装了 typescript 7.0.2(tsgo 原生版),但 vue-tsc 依赖 `typescript/lib/tsc` 导出而 TS7 已移除,故回落到 6.x(当前最新 JS 版)。
|
||||||
|
- tsconfig:strict 模式、bundler 解析、`@` 别名(TS6 弃用 baseUrl,改用相对 paths)、types 含 vite/client + node。
|
||||||
|
- 全量转换:`vite.config.ts`、`src/main.ts`、router/stores/utils 共 9 个 JS→TS;19 个 `.vue` 全部 `<script setup lang="ts">`。
|
||||||
|
- 类型设计:
|
||||||
|
- `src/types/index.ts`:UserInfo / TokenInfo / ApiKey / PasskeyInfo 及各请求 Payload 类型(宽松可选字段 + 索引签名兼容后端松散返回)。
|
||||||
|
- `src/composables/toast.ts`:类型安全的 provide/inject(InjectionKey),替代各视图裸 `inject('toast')`。
|
||||||
|
- `vue-router` RouteMeta 模块扩展(title/icon/showInSidebar/requiresAuth 等);MenuItem 为可辨识联合。
|
||||||
|
- package.json:新增 `typecheck` 脚本;`build` 改为 `vue-tsc --noEmit && vite build`。
|
||||||
|
- 顺带修复的存量 bug(均记录在案):
|
||||||
|
1. `Keys.vue` toggleSelectAll 引用了不存在的 `users`(应为 `keys`)——运行时全选会抛错。
|
||||||
|
2. `Settings.vue` updateUser 引用了未定义的 `userStore`/`userId`(复制粘贴残留),提交表单必抛错——改为经 `authStore.updateProfile` 更新当前用户。
|
||||||
|
3. store 的 catch 中 `throw error` 抛出的是 Ref 对象,`Login.vue` 会把 Ref 显示为 `[object Object]`——加了 `errMsg()` 取值辅助。
|
||||||
|
4. `KeyNew.vue` 模板绑定了不存在的 `togglePasswordVisibility`(点击报 TypeError)——移除死绑定。
|
||||||
|
5. `User/Keys/UserNew/KeyNew` 里 `res.error` 恒为 undefined(AxiosResponse 无此字段)——改为 `res.data?.error`。
|
||||||
|
6. `TokenNew.vue` 初始 `user_id: user.user_id`(ComputedRef 上取值恒 undefined)——改为 `user.value?.user_id`。
|
||||||
|
7. `Login.vue` rember 记住密码存入布尔被 localStorage 转字符串('true'),统一 `String()` 存储。
|
||||||
|
- 验收:`pnpm build`(含 vue-tsc 严格检查)通过。
|
||||||
|
|
||||||
|
### Phase 3 — 目录结构规范化(2026-08-29)✅
|
||||||
|
|
||||||
|
- 最终结构:
|
||||||
|
- `src/api/client.ts` ← utils/request.ts(axios 实例与拦截器;业务接口调用仍保留在 stores 中,作为轻量 API 层,避免无谓 churn,后续可按需下沉到 api/ 各模块)
|
||||||
|
- `src/components/common/` ← Toast / Pagination / QRCodeCard / LineSegmentFlow
|
||||||
|
- `src/components/dashboard/` 保持(Sidebar / BreadcrumbHeader)
|
||||||
|
- `src/layouts/DashboardLayout.vue` ← views/DashBoard.vue(本质是布局组件,归位 layouts 层)
|
||||||
|
- `src/styles/main.css` ← src/style.css
|
||||||
|
- `src/views/auth/` ← Login / Signup;`src/views/error/NotFound.vue` ← views/404.vue
|
||||||
|
- `src/views/Home.vue`、`src/views/dashboard/*` 保持
|
||||||
|
- 全部相对路径 import 统一为 `@/` 别名;模板内相对资源路径(`../assets/...`)统一为 `@/assets/...`。
|
||||||
|
- vite.config.ts 的 manualChunks 分包规则按新目录核对(components / views-dashboard / stores 三组仍有效)。
|
||||||
|
- 验收:`pnpm build` 通过,无悬空 import。
|
||||||
|
|
||||||
|
### Phase 4 — UI 统一到 Tailwind/daisyUI(2026-08-29)✅
|
||||||
|
|
||||||
|
- 摸底修正:Element Plus 实际仅在 3 处使用(main.ts 全局注册 + KeyNew/KeyView 的 `el-input-tag`),其余视图本就以 daisyUI 为主。
|
||||||
|
- 变更:
|
||||||
|
- `main.ts` 移除 Element Plus 注册与样式;`pnpm remove element-plus`。
|
||||||
|
- 新增 `src/components/common/TagInput.vue`(daisyUI 风格,Enter 添加/逐个删除/Backspace 删末尾/可清空),替换 KeyNew/KeyView 中的 `el-input-tag`。
|
||||||
|
- 全库 grep 确认无 `element-plus` / `el-*` 组件残留。
|
||||||
|
- 收益(构建产物对比):
|
||||||
|
- 主 CSS:489.6KB → 163.4KB(-67%,主要为 Element Plus 全量样式)
|
||||||
|
- components JS:405.7KB → 194.7KB(-52%)
|
||||||
|
- 入口 index JS:617.8KB → 1.3KB(Element Plus 运行时原本在入口包)
|
||||||
|
- 验收:`pnpm build`(含类型检查)通过。
|
||||||
|
|
||||||
|
### Phase 5 — Docker / makefile 构建脚本(2026-08-29)✅
|
||||||
|
|
||||||
|
- `deploy/docker/Dockerfile` 重写:
|
||||||
|
- 前端阶段 `FROM --platform=$BUILDPLATFORM node:22-alpine`:多架构构建时前端只在构建机原生平台编译**一次**(原先会被 QEMU 模拟在每个目标平台各跑一遍)。
|
||||||
|
- 后端阶段同样 `$BUILDPLATFORM` + `CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH` 交叉编译(glebarez/sqlite 纯 Go 驱动,已验证可行),全程零 QEMU。
|
||||||
|
- 删除了无效且写法错误的 `CMD ["go mod tidy","go mod download"]`;pnpm 固定 `@10.25.0`;`pnpm install --frozen-lockfile`;runner 阶段补充 `ca-certificates`;修正 `LABEL anther→author`;移除不再需要的 `cmake`。
|
||||||
|
- `Dockerfile.cn`(国内源版)同步更新。
|
||||||
|
- 新增根目录 `.dockerignore`(原先缺失:node_modules、dist、.git、web/ 等全部会被拷进构建上下文)。
|
||||||
|
- `makefile`:
|
||||||
|
- `web` 目标:pnpm 按需安装 + `--frozen-lockfile`,产物干净替换到 `cmd/openteam/dist`(原 `mv dist ../cmd/openteam/` 在目标已存在时会错误嵌套一层)。
|
||||||
|
- `build` 目标:加 `CGO_ENABLED=0`;`upx` 改为可选(本机未装时跳过,不再中断)。
|
||||||
|
- package.json 增加 `"packageManager": "pnpm@10.25.0"`:新版 pnpm 默认启用供应链策略(拒装 24h 内发布的包)并不再读取 package.json 的 `pnpm` 字段,钉住版本保证容器内外行为一致、可重现。
|
||||||
|
- 验证:
|
||||||
|
- `docker build --target frontend` 通过,dist 产物完整。
|
||||||
|
- `docker buildx build --platform linux/amd64,linux/arm64`(xbuilder)通过;日志确认 frontend 仅在原生平台执行一次,arm64 后端为交叉编译,无 QEMU。
|
||||||
|
- 本机 `CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build` 产出静态 ELF(含嵌入前端)。
|
||||||
|
- `make web`、`make build` 通过。
|
||||||
|
|
||||||
|
### Phase 6 — 最终验证与收尾(2026-08-29)✅
|
||||||
|
|
||||||
|
- `pnpm build`(vue-tsc 严格类型检查 + vite 构建)通过。
|
||||||
|
- `.gitignore` 补充 `cmd/openteam/dist/`(构建产物)与 `web/`(误生成目录,未跟踪)。
|
||||||
|
- 遗留事项(供维护者决策,均不影响本期交付):
|
||||||
|
1. `frontend/src/utils/format-date.ts`、`Overview.vue` 内部仍各自实现了一份 `formatDateTime`,可合并到 utils 统一导入(行为无差异,纯清理)。
|
||||||
|
2. CI workflow(`.github/workflows/`)引用的 `./docker/Dockerfile` 路径早已失效(现为 `deploy/docker/Dockerfile`),且 checkout 的还是 @v3 旧 action——后端/CI 不在本期范围,未改动。
|
||||||
|
3. 依赖升级中未逐项验证运行时 UI 细节(如 daisyUI 4→5 的个别类名行为差异、vue-router 4→5),建议维护者回来后 `make web && make build` 或 `pnpm dev` 过一遍登录/令牌/密钥/用户管理页面。
|
||||||
|
4. `web/` 目录(仅 dist + node_modules)疑似误构建产物,已加入 .gitignore,确认无用后可删除。
|
||||||
|
|
||||||
|
## 六、后续增量(2026-08-29,维护者返程前追加)
|
||||||
|
|
||||||
|
### 增量 1 — 本地开发体验优化(前后端分离联调)✅
|
||||||
|
|
||||||
|
背景:前端构建产物经 `//go:embed` 嵌入 Go 二进制,本地每改一次前端都要 `make web + make build`,且没有热更新。
|
||||||
|
|
||||||
|
- `frontend/vite.config.ts`:dev server 增加 `server.proxy`,`/api` 代理到本地 Go 后端(默认 `http://localhost:8080`,`VITE_DEV_API_TARGET` 可覆盖)。开发时前端跑在 Vite 上(HMR),接口走代理到后端,无跨域问题。
|
||||||
|
- dev server 默认改为 HTTP(localhost 属浏览器安全上下文,clipboard/Passkey 均可用);需要自签名 HTTPS 时设 `VITE_DEV_HTTPS=true` 恢复 basicSsl。
|
||||||
|
- `cmd/openteam/main.go`:`//go:embed dist/*` → `//go:embed all:dist`,配合 `cmd/openteam/dist/.gitkeep` 占位(已跟踪),dist 没有真实产物时 `go run ./cmd/openteam` 也能编译启动——克隆后可直接起后端联调,不必先构建前端。`make web` 移入产物后会补回 `.gitkeep`。
|
||||||
|
- `makefile` 新增:
|
||||||
|
- `make dev-backend`:`PORT=8080 go run ./cmd/openteam`(数据库 `./db/openteam.db`)
|
||||||
|
- `make dev-frontend`:`cd frontend && pnpm dev`(5173)
|
||||||
|
- `make dev`:`$(MAKE) -j2` 并行启动两者,Ctrl+C 一起退出
|
||||||
|
- `frontend/README.md` 补充开发文档(启动方式、环境变量表)。
|
||||||
|
- 验证:后端 8080 + Vite 5173 同时运行,`curl http://localhost:5173/` 返回 SPA 页面;经 5173 代理的 `POST /api/auth/login` 返回后端真实校验 JSON、`GET /api/auth/passkey/begin` 返回 200,代理链路完整。
|
||||||
|
- 说明:这是构建基础设施改动,触及 `cmd/openteam/main.go` 一行 embed 指令;后端业务逻辑零改动。生产构建流程不受影响(Docker 内 dist 由前端阶段提供)。
|
||||||
|
|
||||||
|
### 增量 2 — 全站 UI/UX 重设计(web-design-guidelines)✅
|
||||||
|
|
||||||
|
依据:Vercel Web Interface Guidelines(`~/.agents/skills/web-design-guidelines` 拉取的最新规则集)。先对全部页面截图建立基线,再逐页重写模板;**业务逻辑、接口调用、路由全部未动**。
|
||||||
|
|
||||||
|
- 设计系统基础(`styles/main.css` + `index.html`):
|
||||||
|
- 深浅色 `color-scheme` 跟随主题;`<meta name="theme-color">`;内联脚本恢复上次主题(不再每次刷新回 emerald)。
|
||||||
|
- 全局 `:focus-visible` 焦点环、`touch-action: manipulation`、`prefers-reduced-motion` 全局降级、`.modal-box` 防滚动穿透。
|
||||||
|
- 覆写 emerald 主题圆角令牌(默认 selector 圆角 1rem 使小复选框渲染成正圆的 bug)。
|
||||||
|
- 壳层:侧栏启用 daisyUI 5 `menu-active` 高亮 + sticky;顶栏重排(侧栏开关带 aria-pressed、主题切换持久化 localStorage、用户菜单头像+用户名);主区限宽 max-w-6xl。
|
||||||
|
- 页面模式统一:`面包屑 + 大标题 → 描述 + 主操作 → 卡片内容`;列表页统一工具栏/表格/空状态/分页(空数据隐藏分页);表单统一分区标题、label/控件绑定、required 标记、示例占位符、提交按钮 spinner。
|
||||||
|
- 主要变更:
|
||||||
|
- Overview:深色渐变横幅改为品牌 primary→secondary 渐变;信息卡由 badge 滥用改为 dl 键值布局(仅角色/状态保留 badge);时间 tabular-nums。
|
||||||
|
- Tokens/Keys/Users 列表:空状态(图标+文案+CTA)、行操作按钮 aria-label、状态筛选改为带计数的下拉、移除两个死的 Filter 输入框、批量操作菜单重排。
|
||||||
|
- 弹窗表单(TokenNew/KeyNew/UserNew):去掉 min-h-screen 包裹,适配弹窗容器。
|
||||||
|
- 详情页(KeyView/UserView):头像+徽章头部、分区表单、tokens 表格;加载态明确。
|
||||||
|
- Profile/Settings:分区卡片(基本信息/密码/Passkeys/关联账号),提交加 spinner。
|
||||||
|
- Login/Signup:实体主按钮、内联错误 alert(role=alert)、autocomplete(username/current-password/new-password)、死链"Forgot password?"移除、密码不一致内联提示。
|
||||||
|
- Home:导航栏 Star 徽标 + Open Dashboard 主 CTA,hero 文案重写,endpoint 复制组加 label,图片补尺寸。
|
||||||
|
- 404:品牌化布局 + 具体文案("doesn't exist or has been moved")。
|
||||||
|
- Toast:`aria-live="polite"` + toast-top/end 定位;Pagination:join 样式 + Showing x–y of z + tabular-nums。
|
||||||
|
- 顺带修复:DashboardLayout `if (!userInfo)` 恒真导致刷新后头部用户名丢失(改为 `!authStore.user`)。
|
||||||
|
- 验证:`pnpm build`(vue-tsc 严格检查)通过;本地起前后端后逐页浏览器截图回归(Users/Overview/Tokens/新建弹窗/Keys 空状态/Profile/404/Home/Login)视觉与交互正常;复选框圆角修复经计算样式确认(16px→8px)。
|
||||||
|
- 已知取舍:破坏性操作沿用原生 `confirm()`(满足"需确认"要求,后续可换主题化 modal);列表筛选/分页尚未同步到 URL query(指南建议,列为后续项);"Forgot password?" 为死链已移除。
|
||||||
|
- 补充(维护者反馈):Overview 横幅恢复**随时间段变化的配色**(原版行为),四段式与新版式协调——深夜 slate-900→indigo-950、早晨 sky-600→amber-400、白天 blue-600→cyan-400、夜晚 slate-900→indigo-950;白色文字置于渐变左侧深色端保证对比度。顺带修复原版问候语 bug:0-6 点原显示"早上好",现对齐四段为"夜深了"。
|
||||||
|
|
||||||
|
### 增量 3 — 主题切换(浅色 / 深色 / 自动)✅
|
||||||
|
|
||||||
|
- 新增 `src/composables/theme.ts`:偏好三态 `light | dark | auto`(浅色映射品牌 emerald 主题),localStorage 持久化(key `theme`),auto 模式监听 `prefers-color-scheme` 实时跟随系统切换;模块级单例状态,主页与仪表盘共享。
|
||||||
|
- `index.html` 内联脚本同步三态逻辑(含 auto 解析),首屏不闪烁。
|
||||||
|
- 主页导航栏与仪表盘顶栏均提供主题下拉(太阳/月亮/显示器图标随当前偏好变化,当前项 `menu-active` 高亮);仪表盘原先的二态硬切换按钮升级为同一三选下拉,避免两套主题逻辑互相覆盖。
|
||||||
|
- 默认偏好为 `auto`(首次访问跟随系统);旧存储值 `emerald` 不再有效,自动回落 auto。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测浅色↔深色↔自动即时生效、`data-theme` 与 localStorage 值正确、刷新后保持、auto 按系统深色解析为 dark;主页与仪表盘主题共享一致。
|
||||||
|
|
||||||
|
### 增量 4 — 导航栏按角色分区重设计(参考维护者提供的双栏设计稿)✅
|
||||||
|
|
||||||
|
- 信息架构对齐设计稿:导航拆为**控制台**(所有用户:仪表盘 / API 密钥 / 账户设置)与**管理后台**(role ≥ 10:用户管理 / 渠道管理)两个区域,按当前路由区域(`/dashboard/manager/*`)切换显示;底部互切入口——控制台区显示「管理后台 →」(仅管理员),后台区显示「← 返回控制台」。
|
||||||
|
- `router_menu.ts` 重写:弃用递归菜单生成器,改为显式两套菜单数组;路由 meta 标题中文化(仪表盘 / API 密钥 / 用户管理 / 渠道管理 / 账户设置等)。
|
||||||
|
- `BreadcrumbHeader` 增加路由路径→中文标题映射,面包屑与页面标题随之中文化(此前按英文路径段拼接)。
|
||||||
|
- 与设计稿的差异(页面对应关系):API 密钥→个人 Tokens 页;渠道管理→上游 Provider Keys 页;设计稿中的用量明细/用量统计/模型定价/系统配置暂无对应页面,未做死链,留作后续功能。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测 admin 视角(控制台菜单 + 底部管理后台入口 → 后台菜单 + 返回控制台,高亮正确)与普通用户视角(注册 member 账号实测:仅控制台菜单,管理后台入口数量为 0);中文面包屑生效。
|
||||||
|
- 补充(维护者反馈):「API 密钥」菜单项与页面标题改名为 **API Keys**;面包屑重设计——废弃路径段拼接,改为按路由名显式定义层级:顶级页面(仪表盘/API Keys/账户设置)不显示面包屑仅保留标题,管理后台列表页显示「管理后台」一级,详情页显示「管理后台 / 列表页」两级(末级为当前页标题),标题统一取自定义 title 或路由 meta.title。
|
||||||
|
- 补充(交互收尾):移动端抽屉在路由切换后自动收起(router.afterEach);顶栏主题/个人下拉为焦点展开型,选择后主动移除焦点收起菜单,个人菜单按钮补 aria-expanded 语义。注:DashboardLayout 模板含维护者手动增强的个人菜单(身份信息头 + 管理后台入口),脚本已按模板对齐(handleMenuAction / isAdminUser)。
|
||||||
|
|
||||||
|
### 增量 5 — 个人下拉修复与触屏可用性(维护者反馈)✅
|
||||||
|
|
||||||
|
- 问题 1(内容不对):身份信息行的 `email || '@username'` 回退会显示伪社交句柄,且已在管理后台时仍显示「管理后台」入口。修复:第二行仅在存在邮箱时显示邮箱、有显示名时显示 `@用户名`、否则不显示;名称行追加角色徽章(Root/Admin/User);「管理后台」入口在后台区域隐藏(`isAdminUser && !isAdminArea`)。
|
||||||
|
- 问题 2(手机点退出无反应):daisyUI CSS 下拉依赖焦点展开,触屏点击菜单项时按钮失焦、下拉先于 click 关闭导致点击落空。修复:主题与个人下拉改为**状态驱动**(`dropdown-open` class + `v-if` 遮罩点击关闭 + Escape 关闭 + 路由切换关闭),互斥打开;主页主题下拉同步修复。
|
||||||
|
- 验证:`pnpm build` 通过;桌面实测下拉内容(root + Root 徽章 + @admin,菜单项完整);移动端 390px 实测点「退出登录」成功跳转 /login 且 token 清除;随后已恢复 admin 会话与桌面视口。
|
||||||
|
- 补充(维护者反馈):个人下拉内的 `<hr>` 分割线被 daisyUI menu 的通用子元素样式选中(cursor: pointer + hover 背景,可点击),已为其 li 加 `pointer-events-none select-none`,实测恢复默认光标且不可交互。
|
||||||
|
|
||||||
|
### 增量 6 — 主按钮统一黑白配色(维护者反馈)✅
|
||||||
|
|
||||||
|
- 背景:daisyUI dark 主题的 primary 为紫色,普通主按钮(btn-primary)在深色模式下显示为紫底。
|
||||||
|
- 方案:`main.css` 覆写 `.btn-primary` 的 daisyUI 颜色变量(`--btn-color` / `--btn-fg`)——浅色主题黑底白字(#171717/#fff,hover 纯黑)、深色主题白底黑字(#fff/#171717,hover 浅灰)。仅影响 btn-primary;success/error/warning/outline/ghost 等特殊按钮与链接、开关、焦点环均保持原样。
|
||||||
|
- 验证:`pnpm build` 通过;浏览器实测深色(白底黑字 New Token)与浅色(黑底白字 Log In)两种主题,特殊按钮未受影响;已恢复维护者的 auto 主题偏好与 admin 会话。
|
||||||
Vendored
@@ -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
@@ -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
@@ -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
@@ -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 # 类型检查 + 生产构建
|
||||||
|
```
|
||||||
|
|||||||
+13
-1
@@ -4,10 +4,22 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="OpenTeam — an open-source, team-shared service compatible with the OpenAI API." />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
<title>OpenTeam</title>
|
<title>OpenTeam</title>
|
||||||
|
<script>
|
||||||
|
// 恢复主题偏好(light/dark/auto,auto 跟随系统),避免闪烁
|
||||||
|
try {
|
||||||
|
var __p = localStorage.getItem('theme') || 'auto'
|
||||||
|
var __dark = __p === 'dark' || (__p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.setAttribute('data-theme', __dark ? 'dark' : 'emerald')
|
||||||
|
} catch (e) {
|
||||||
|
document.documentElement.setAttribute('data-theme', 'emerald')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1181
-1578
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col md:flex-row items-center justify-between gap-3 mt-4 text-sm">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
显示 {{ startItem }} 到 {{ endItem }},共 {{ totalItems }} 项
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2 md:gap-4 justify-center md:justify-end">
|
|
||||||
<div v-if="showSelectPageSize" class="flex items-center gap-2">
|
|
||||||
<span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">每页显示</span>
|
|
||||||
<select
|
|
||||||
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-16 dark:text-white dark:border-neutral-700 dark:bg-neutral-900"
|
|
||||||
:value="pageSize"
|
|
||||||
@change="emitChangePageSize($event)"
|
|
||||||
>
|
|
||||||
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
|
||||||
{{ option }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span class="px-2 text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{{ currentPage }}/{{ totalPages }}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div class="flex items-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
|
||||||
:disabled="currentPage === 1"
|
|
||||||
@click="emitChangePage(1, pageSize)"
|
|
||||||
aria-label="第一页"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
|
||||||
:disabled="currentPage === 1"
|
|
||||||
@click="emitChangePage(currentPage - 1, pageSize)"
|
|
||||||
aria-label="上一页"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
|
||||||
:disabled="currentPage === totalPages"
|
|
||||||
@click="emitChangePage(currentPage + 1, pageSize)"
|
|
||||||
aria-label="下一页"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-icon btn-sm h-9 w-9 rounded-md border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-900 dark:text-white dark:hover:bg-neutral-800"
|
|
||||||
:disabled="currentPage === totalPages"
|
|
||||||
@click="emitChangePage(totalPages, pageSize)"
|
|
||||||
aria-label="最后一页"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed, ref ,watch} from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
currentPage: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
totalItems: {
|
|
||||||
type: Number,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
pageSize: {
|
|
||||||
type: Number,
|
|
||||||
default: 10,
|
|
||||||
},
|
|
||||||
pageSizeOptions: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [10, 25, 50, 100],
|
|
||||||
},
|
|
||||||
showSelectPageSize: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits(['changePage', 'changePageSize']);
|
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
|
||||||
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 使用一个 ref 来存储内部的 currentPage 状态
|
|
||||||
const localCurrentPage = ref(props.currentPage);
|
|
||||||
const localPageSize = ref(props.pageSize);
|
|
||||||
|
|
||||||
// 监听 props.currentPage 的变化,并更新 localCurrentPage
|
|
||||||
watch(() => props.currentPage, (newCurrentPage) => {
|
|
||||||
localCurrentPage.value = newCurrentPage;
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(() => props.pageSize, (newPageSize) => {
|
|
||||||
localPageSize.value = newPageSize;
|
|
||||||
});
|
|
||||||
|
|
||||||
const emitChangePage = (page, pageSize) => { // 添加了 pageSize 参数
|
|
||||||
const validPage = Math.max(1, Math.min(page, totalPages.value));
|
|
||||||
if (validPage !== localCurrentPage.value) {
|
|
||||||
localCurrentPage.value = validPage;
|
|
||||||
emit('changePage', validPage, pageSize); // 将 pageSize 传递给父组件
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const emitChangePageSize = (event) => {
|
|
||||||
const newPageSize = parseInt(event.target.value, 10);
|
|
||||||
localPageSize.value = newPageSize;
|
|
||||||
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
|
|
||||||
};
|
|
||||||
|
|
||||||
const startItem = computed(() => {
|
|
||||||
return (localCurrentPage.value - 1) * localPageSize.value + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const endItem = computed(() => {
|
|
||||||
return Math.min(localCurrentPage.value * localPageSize.value, props.totalItems);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<!-- src/components/Toast.vue -->
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
v-if="show"
|
|
||||||
class="mt-20 toast "
|
|
||||||
:class="{ 'toast-error': currentMessage?.type === 'error', 'toast-success': currentMessage?.type === 'success' }"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="alert"
|
|
||||||
:class="{ 'alert-error': currentMessage?.type === 'error', 'alert-success': currentMessage?.type === 'success' }"
|
|
||||||
>
|
|
||||||
<span>{{ currentMessage?.message }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onUnmounted, watch, onMounted } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
queue: {
|
|
||||||
type: Array,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const show = ref(false);
|
|
||||||
const currentMessage = ref(null);
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
const processQueue = () => {
|
|
||||||
if (props.queue.length === 0) {
|
|
||||||
show.value = false;
|
|
||||||
currentMessage.value = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMessage.value = props.queue.shift();
|
|
||||||
show.value = true;
|
|
||||||
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
processQueue();
|
|
||||||
}, currentMessage.value.duration || 3000);
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(() => props.queue, () => {
|
|
||||||
if(!show.value) {
|
|
||||||
processQueue()
|
|
||||||
}
|
|
||||||
}, { deep: true });
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
+30
-27
@@ -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();
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mt-4 flex flex-col items-center justify-between gap-3 text-sm sm:flex-row">
|
||||||
|
<span class="text-base-content/60 tabular-nums">
|
||||||
|
Showing {{ startItem }}–{{ endItem }} of {{ totalItems }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-center gap-3 sm:justify-end">
|
||||||
|
<label v-if="showSelectPageSize" class="flex items-center gap-2">
|
||||||
|
<span class="hidden text-base-content/60 sm:inline">Per page</span>
|
||||||
|
<select
|
||||||
|
class="select select-sm w-16 border-base-300 bg-base-100"
|
||||||
|
:value="pageSize"
|
||||||
|
@change="emitChangePageSize($event)"
|
||||||
|
>
|
||||||
|
<option v-for="option in pageSizeOptions" :key="option" :value="option">
|
||||||
|
{{ option }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="text-base-content/60 tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
|
||||||
|
|
||||||
|
<div class="join">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="emitChangePage(1, pageSize)"
|
||||||
|
aria-label="First page"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
@click="emitChangePage(currentPage - 1, pageSize)"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="emitChangePage(currentPage + 1, pageSize)"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
@click="emitChangePage(totalPages, pageSize)"
|
||||||
|
aria-label="Last page"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
currentPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
pageSize?: number;
|
||||||
|
pageSizeOptions?: number[];
|
||||||
|
showSelectPageSize?: boolean;
|
||||||
|
}>(), {
|
||||||
|
pageSize: 10,
|
||||||
|
pageSizeOptions: () => [10, 25, 50, 100],
|
||||||
|
showSelectPageSize: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'changePage', page: number, pageSize: number): void;
|
||||||
|
(e: 'changePageSize', pageSize: number): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const totalPages = computed(() => {
|
||||||
|
return Math.max(1, Math.ceil(props.totalItems / props.pageSize));
|
||||||
|
});
|
||||||
|
|
||||||
|
// 使用一个 ref 来存储内部的 currentPage 状态
|
||||||
|
const localCurrentPage = ref(props.currentPage);
|
||||||
|
const localPageSize = ref(props.pageSize);
|
||||||
|
|
||||||
|
// 监听 props.currentPage 的变化,并更新 localCurrentPage
|
||||||
|
watch(() => props.currentPage, (newCurrentPage) => {
|
||||||
|
localCurrentPage.value = newCurrentPage;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.pageSize, (newPageSize) => {
|
||||||
|
localPageSize.value = newPageSize;
|
||||||
|
});
|
||||||
|
|
||||||
|
const emitChangePage = (page: number, pageSize: number) => { // 添加了 pageSize 参数
|
||||||
|
const validPage = Math.max(1, Math.min(page, totalPages.value));
|
||||||
|
if (validPage !== localCurrentPage.value) {
|
||||||
|
localCurrentPage.value = validPage;
|
||||||
|
emit('changePage', validPage, pageSize); // 将 pageSize 传递给父组件
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emitChangePageSize = (event: Event) => {
|
||||||
|
const newPageSize = parseInt((event.target as HTMLSelectElement).value, 10);
|
||||||
|
localPageSize.value = newPageSize;
|
||||||
|
emit('changePage', 1, newPageSize); // 确保同时传递 page 和 pageSize
|
||||||
|
};
|
||||||
|
|
||||||
|
const startItem = computed(() => {
|
||||||
|
return (localCurrentPage.value - 1) * localPageSize.value + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const endItem = computed(() => {
|
||||||
|
return Math.min(localCurrentPage.value * localPageSize.value, props.totalItems);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
+11
-15
@@ -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}`;
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex min-h-9 w-full flex-wrap items-center gap-1 rounded-field border border-base-300 bg-base-100 px-2 py-1 text-sm transition-colors focus-within:border-primary"
|
||||||
|
:class="{ 'pointer-events-none opacity-60': disabled }"
|
||||||
|
@click="focusInput"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-for="(tag, index) in tags"
|
||||||
|
:key="`${tag}-${index}`"
|
||||||
|
class="badge badge-sm badge-ghost gap-1 py-2"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
<button
|
||||||
|
v-if="!disabled"
|
||||||
|
type="button"
|
||||||
|
class="hover:text-error"
|
||||||
|
:aria-label="`Remove ${tag}`"
|
||||||
|
@click.stop="removeTag(index)"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="draft"
|
||||||
|
type="text"
|
||||||
|
class="grow min-w-20 bg-transparent border-none outline-none focus:outline-none p-0 m-0 h-7"
|
||||||
|
:placeholder="tags.length ? '' : placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
@keydown.enter.prevent="commitDraft"
|
||||||
|
@keydown.backspace="removeLastOnEmpty"
|
||||||
|
@blur="commitDraft"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="clearable && tags.length && !disabled"
|
||||||
|
type="button"
|
||||||
|
class="text-base-content/40 hover:text-error"
|
||||||
|
aria-label="Clear all"
|
||||||
|
@click.stop="clearAll"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
// 替代 Element Plus 的 el-input-tag:Enter 添加标签、可逐个删除、可清空
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
modelValue: string[] | undefined
|
||||||
|
placeholder?: string
|
||||||
|
clearable?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
/** 触发提交的按键,仅支持 Enter(与 el-input-tag 的 trigger 对齐) */
|
||||||
|
trigger?: string
|
||||||
|
}>(), {
|
||||||
|
placeholder: 'Please input',
|
||||||
|
clearable: false,
|
||||||
|
disabled: false,
|
||||||
|
trigger: 'Enter',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: string[]): void
|
||||||
|
(e: 'change', value: string[]): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tags = computed(() => props.modelValue ?? [])
|
||||||
|
const inputRef = ref<HTMLInputElement | null>(null)
|
||||||
|
const draft = ref('')
|
||||||
|
|
||||||
|
const commitDraft = () => {
|
||||||
|
const value = draft.value.trim()
|
||||||
|
if (!value) return
|
||||||
|
if (!tags.value.includes(value)) {
|
||||||
|
const next = [...tags.value, value]
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
emit('change', next)
|
||||||
|
}
|
||||||
|
draft.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeTag = (index: number) => {
|
||||||
|
const next = tags.value.filter((_, i) => i !== index)
|
||||||
|
emit('update:modelValue', next)
|
||||||
|
emit('change', next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeLastOnEmpty = () => {
|
||||||
|
if (draft.value.length === 0 && tags.value.length) {
|
||||||
|
removeTag(tags.value.length - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAll = () => {
|
||||||
|
emit('update:modelValue', [])
|
||||||
|
emit('change', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusInput = () => {
|
||||||
|
inputRef.value?.focus()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<!-- src/components/common/Toast.vue -->
|
||||||
|
<template>
|
||||||
|
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16">
|
||||||
|
<div
|
||||||
|
v-if="show && currentMessage"
|
||||||
|
class="alert shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'alert-error': currentMessage.type === 'error',
|
||||||
|
'alert-success': currentMessage.type === 'success',
|
||||||
|
'alert-info': currentMessage.type !== 'error' && currentMessage.type !== 'success',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span>{{ currentMessage.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUnmounted, watch } from 'vue';
|
||||||
|
import type { ToastMessage } from '@/composables/toast';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
queue: ToastMessage[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const show = ref(false);
|
||||||
|
const currentMessage = ref<ToastMessage | null>(null);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const processQueue = () => {
|
||||||
|
if (props.queue.length === 0) {
|
||||||
|
show.value = false;
|
||||||
|
currentMessage.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMessage.value = props.queue.shift() ?? null;
|
||||||
|
show.value = true;
|
||||||
|
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
processQueue();
|
||||||
|
}, currentMessage.value?.duration || 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => props.queue, () => {
|
||||||
|
if (!show.value) {
|
||||||
|
processQueue();
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,81 +1,70 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Breadcrumb and Title -->
|
<!-- Breadcrumb and Title:仅在有多级路径(列表/详情)时显示面包屑 -->
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="mb-5">
|
||||||
<h1 class="text-lg font-medium">{{ displayTitle }}</h1>
|
<nav v-if="breadcrumbItems.length > 1" aria-label="Breadcrumb" class="text-xs text-base-content/50">
|
||||||
<div class="text-xs breadcrumbs">
|
<ul class="flex flex-wrap items-center gap-1.5">
|
||||||
<ul>
|
<li v-for="(item, index) in breadcrumbItems" :key="index" class="flex items-center gap-1.5">
|
||||||
<li v-for="(item, index) in breadcrumbItems" :key="index">
|
<span v-if="index > 0" aria-hidden="true" class="text-base-content/30">/</span>
|
||||||
<template v-if="item.path">
|
<template v-if="item.path && index !== breadcrumbItems.length - 1">
|
||||||
<a
|
<RouterLink :to="item.path" class="transition-colors hover:text-base-content/80">
|
||||||
:href="item.path"
|
{{ item.label }}
|
||||||
class="text-gray-500"
|
</RouterLink>
|
||||||
>
|
|
||||||
{{ item.name }}
|
|
||||||
</a>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="text-gray-500">{{ item.name }}</span>
|
<span aria-current="page" class="text-base-content/80">{{ item.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</nav>
|
||||||
|
<h1 class="mt-1 text-xl font-semibold tracking-tight text-balance">{{ displayTitle }}</h1>
|
||||||
</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({
|
type Crumb = { label: string; path: string };
|
||||||
title: {
|
|
||||||
type: String,
|
const props = withDefaults(defineProps<{
|
||||||
default: null // 默认为null,将从路由中获取
|
title?: string | null; // 默认为null,将从路由 meta 中获取
|
||||||
},
|
|
||||||
// Optional custom breadcrumb items
|
// Optional custom breadcrumb items
|
||||||
customBreadcrumbs: {
|
customBreadcrumbs?: Crumb[];
|
||||||
type: Array,
|
}>(), {
|
||||||
default: () => []
|
title: null,
|
||||||
}
|
customBreadcrumbs: () => [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
// Generate breadcrumb items based on current route
|
// 按路由名显式定义多级面包屑:只有存在父级页面的路由才需要路径展示
|
||||||
// 生成面包屑项
|
const crumbsByRouteName: Record<string, Crumb[]> = {
|
||||||
const breadcrumbItems = computed(() => {
|
// 管理后台列表页:区域根
|
||||||
|
User: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||||
|
ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||||
|
// 详情页:区域根 / 列表页(末级为当前页标题)
|
||||||
|
UserView: [
|
||||||
|
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||||
|
{ label: '用户管理', path: '/dashboard/manager/users' },
|
||||||
|
],
|
||||||
|
ApiKeyView: [
|
||||||
|
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||||
|
{ label: '渠道管理', path: '/dashboard/manager/keys' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const breadcrumbItems = computed<Crumb[]>(() => {
|
||||||
if (props.customBreadcrumbs.length > 0) {
|
if (props.customBreadcrumbs.length > 0) {
|
||||||
return props.customBreadcrumbs;
|
return props.customBreadcrumbs;
|
||||||
}
|
}
|
||||||
|
return crumbsByRouteName[String(route.name)] ?? [];
|
||||||
// 获取当前路径并分割成段
|
|
||||||
const pathSegments = route.path.split('/').filter(segment => segment);
|
|
||||||
|
|
||||||
return pathSegments.map((segment, index) => {
|
|
||||||
const name = segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
||||||
|
|
||||||
// 对于最后一段,不设置链接
|
|
||||||
if (index === pathSegments.length - 1) {
|
|
||||||
return { name, path: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建到此段的路径
|
|
||||||
const path = '/' + pathSegments.slice(0, index + 1).join('/');
|
|
||||||
return { name, path };
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 显示的标题:如果提供了自定义标题则使用,否则使用当前路径的最后一段
|
// 显示的标题:自定义标题 → 路由 meta.title
|
||||||
const displayTitle = computed(() => {
|
const displayTitle = computed(() => {
|
||||||
if (props.title) {
|
if (props.title) {
|
||||||
return props.title;
|
return props.title;
|
||||||
}
|
}
|
||||||
|
return (route.meta.title as string) || 'Dashboard';
|
||||||
const pathSegments = route.path.split('/').filter(segment => segment);
|
|
||||||
if (pathSegments.length > 0) {
|
|
||||||
const lastSegment = pathSegments[pathSegments.length - 1];
|
|
||||||
return lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Dashboard';
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,65 +1,60 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="w-60 bg-base-100 border-r border-base-200 glass">
|
<aside class="sticky top-0 flex h-screen w-60 flex-col border-r border-base-300/70 bg-base-100">
|
||||||
<div class="flex items-center p-4 outline-none select-none backdrop-blur-lg">
|
<div class="flex select-none items-center gap-2.5 px-4 pb-2 pt-5">
|
||||||
<div class="w-12 h-12 rounded-full">
|
<img src="@/assets/logo.svg" alt="OpenTeam logo" class="h-9 w-9" @click="$router.push('/')" />
|
||||||
<img src='@/assets/logo.svg' class="" @click="$router.push('/')" >
|
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
|
||||||
</div>
|
|
||||||
<div class="p-0 text-2xl font-bold text-center">OpenTeam</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex-1 overflow-y-auto px-2 pt-1" aria-label="主导航">
|
||||||
<ul class="menu p-4 w-60 min-h-full bg-base-100 text-base-content">
|
<ul class="menu w-full gap-0.5 text-sm">
|
||||||
<li v-for="item in menuItems" :key="item.label">
|
<li class="menu-title mt-2 text-xs font-medium text-base-content/40">
|
||||||
<template v-if="item.type === 'link'">
|
{{ isAdminArea ? '管理后台' : '控制台' }}
|
||||||
<router-link :to="item.to" :class="{ 'active': isActive(item.to) }">
|
</li>
|
||||||
<component :is="item.icon" class="w-4" />
|
<li v-for="item in items" :key="item.to">
|
||||||
|
<router-link :to="item.to" :class="{ 'menu-active': isActive(item.to) }" class="rounded-lg py-2.5">
|
||||||
|
<component :is="item.icon" class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
|
|
||||||
</router-link>
|
</router-link>
|
||||||
</template>
|
</li>
|
||||||
<template v-else-if="item.type === 'title'">
|
</ul>
|
||||||
<span class="menu-title">
|
</nav>
|
||||||
<span>{{ item.label }}</span>
|
|
||||||
</span>
|
<!-- 区域互切入口:控制台 → 管理后台(仅管理员可见);管理后台 → 返回控制台 -->
|
||||||
</template>
|
<div v-if="!isAdminArea && isAdminUser" class="border-t border-base-300/70 p-2">
|
||||||
<template v-else-if="item.type === 'submenu'">
|
<ul class="menu w-full text-sm">
|
||||||
<details :open="item.open">
|
<li>
|
||||||
<summary>
|
<router-link to="/dashboard/manager/users" class="justify-between rounded-lg py-2.5">
|
||||||
<component :is="item.icon" class="w-4" />
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
{{ item.label }}
|
<ShieldIcon class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||||
<div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div>
|
管理后台
|
||||||
</summary>
|
</span>
|
||||||
<ul>
|
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
|
||||||
<li v-for="subItem in item.children" :key="subItem.label">
|
</router-link>
|
||||||
<router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }">
|
</li>
|
||||||
<component :is="subItem.icon" class="w-4" />
|
</ul>
|
||||||
{{ subItem.label }}
|
</div>
|
||||||
</router-link>
|
<div v-else-if="isAdminArea" class="border-t border-base-300/70 p-2">
|
||||||
</li>
|
<ul class="menu w-full text-sm">
|
||||||
</ul>
|
<li>
|
||||||
</details>
|
<router-link to="/dashboard/overview" class="justify-between rounded-lg py-2.5">
|
||||||
</template>
|
<span class="flex min-w-0 items-center gap-2">
|
||||||
</li>
|
<ArrowLeftIcon class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||||
</ul>
|
返回控制台
|
||||||
|
</span>
|
||||||
|
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
|
||||||
|
</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import {
|
import { computed } from 'vue';
|
||||||
LayoutDashboardIcon,
|
import { useRoute } from 'vue-router';
|
||||||
ShieldPlus,
|
import { ShieldIcon, ChevronRight, ArrowLeftIcon } from '@lucide/vue';
|
||||||
UsersRoundIcon,
|
import { consoleMenu, adminMenu, type MenuLink } from '@/utils/router_menu';
|
||||||
KeyRoundIcon,
|
import { useAuthStore } from '@/stores/auth';
|
||||||
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 +63,15 @@ const userrole = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
|
||||||
// 判断当前路由是否激活菜单项
|
// 当前处于管理后台区域(/dashboard/manager/*)还是控制台区域
|
||||||
const isActive = (path) => {
|
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'));
|
||||||
|
// 仅管理员(role >= 10)可见管理后台入口
|
||||||
|
const isAdminUser = computed(() => userrole.value >= 10);
|
||||||
|
|
||||||
|
const items = computed<MenuLink[]>(() => (isAdminArea.value ? adminMenu : consoleMenu));
|
||||||
|
|
||||||
|
const isActive = (path: string) => {
|
||||||
return route.path === path;
|
return route.path === path;
|
||||||
// return router.currentRoute.value.fullPath.startsWith(path);
|
|
||||||
};
|
};
|
||||||
|
</script>
|
||||||
let menuItems = reactive([
|
|
||||||
{ 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>
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// 主题偏好:浅色 / 深色 / 跟随系统;浅色映射为品牌 emerald 主题
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'auto'
|
||||||
|
export type ResolvedTheme = 'light' | 'dark'
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'theme'
|
||||||
|
const MEDIA = '(prefers-color-scheme: dark)'
|
||||||
|
|
||||||
|
function readStoredPreference(): ThemePreference {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (v === 'light' || v === 'dark' || v === 'auto') return v
|
||||||
|
} catch { /* localStorage 不可用 */ }
|
||||||
|
return 'auto'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTheme(p: ThemePreference): ResolvedTheme {
|
||||||
|
if (p !== 'auto') return p
|
||||||
|
try {
|
||||||
|
return window.matchMedia(MEDIA).matches ? 'dark' : 'light'
|
||||||
|
} catch {
|
||||||
|
return 'light'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme() {
|
||||||
|
resolvedTheme.value = resolveTheme(preference.value)
|
||||||
|
document.documentElement.setAttribute('data-theme', resolvedTheme.value === 'dark' ? 'dark' : 'emerald')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统深浅色切换时,auto 模式实时跟随(模块级监听,避免重复注册)
|
||||||
|
let mediaListenerBound = false
|
||||||
|
function ensureMediaListener() {
|
||||||
|
if (mediaListenerBound || typeof window === 'undefined') return
|
||||||
|
mediaListenerBound = true
|
||||||
|
try {
|
||||||
|
window.matchMedia(MEDIA).addEventListener('change', () => {
|
||||||
|
if (preference.value === 'auto') applyTheme()
|
||||||
|
})
|
||||||
|
} catch { /* 旧浏览器无 addEventListener */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const preference = ref<ThemePreference>(readStoredPreference())
|
||||||
|
const resolvedTheme = ref<ResolvedTheme>(resolveTheme(preference.value))
|
||||||
|
|
||||||
|
applyTheme()
|
||||||
|
ensureMediaListener()
|
||||||
|
|
||||||
|
function setPreference(p: ThemePreference) {
|
||||||
|
preference.value = p
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, p)
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
applyTheme()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return { preference, resolvedTheme, setPreference }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<!-- src/layouts/DashboardLayout.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-base-200">
|
||||||
|
<div class="drawer" :class="{ 'lg:drawer-open': isLargeSidebarOpen }">
|
||||||
|
<input id="ot-drawer" type="checkbox" class="drawer-toggle" />
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="drawer-content flex min-h-screen flex-col">
|
||||||
|
<!-- Top Navigation -->
|
||||||
|
<header class="sticky top-0 z-30 flex h-14 w-full items-center gap-1.5 border-b border-base-300/70 bg-base-100/90 px-2 backdrop-blur sm:px-4">
|
||||||
|
<!-- Mobile menu button -->
|
||||||
|
<label for="ot-drawer" class="btn btn-ghost btn-square btn-sm lg:hidden" aria-label="Open navigation menu">
|
||||||
|
<MenuIcon class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</label>
|
||||||
|
<!-- Desktop sidebar toggle -->
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-square btn-sm hidden lg:inline-flex"
|
||||||
|
@click="toggleSidebarLarge"
|
||||||
|
:aria-pressed="isLargeSidebarOpen"
|
||||||
|
:aria-label="isLargeSidebarOpen ? 'Collapse sidebar' : 'Expand sidebar'"
|
||||||
|
>
|
||||||
|
<PanelLeftIcon class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-auto"></div>
|
||||||
|
|
||||||
|
<!-- 下拉打开时的透明遮罩:点击任意处关闭(触屏可靠) -->
|
||||||
|
<div v-if="themeMenuOpen || userMenuOpen" class="fixed inset-0 z-30" aria-hidden="true" @click="closeMenus"></div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': themeMenuOpen }">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm" aria-label="切换主题" :aria-expanded="themeMenuOpen"
|
||||||
|
@click="toggleThemeMenu">
|
||||||
|
<SunIcon v-if="preference === 'light'" class="h-5 w-5" aria-hidden="true" />
|
||||||
|
<MoonIcon v-else-if="preference === 'dark'" class="h-5 w-5" aria-hidden="true" />
|
||||||
|
<MonitorIcon v-else class="h-5 w-5" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<ul class="menu dropdown-content z-40 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg"
|
||||||
|
@keydown.escape="closeMenus">
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'light' }" @click="selectTheme('light')"
|
||||||
|
:aria-pressed="preference === 'light'">
|
||||||
|
<SunIcon class="h-4 w-4" aria-hidden="true" />浅色
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'dark' }" @click="selectTheme('dark')"
|
||||||
|
:aria-pressed="preference === 'dark'">
|
||||||
|
<MoonIcon class="h-4 w-4" aria-hidden="true" />深色
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'auto' }" @click="selectTheme('auto')"
|
||||||
|
:aria-pressed="preference === 'auto'">
|
||||||
|
<MonitorIcon class="h-4 w-4" aria-hidden="true" />自动
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': userMenuOpen }">
|
||||||
|
<button class="btn btn-ghost btn-sm gap-2 px-2" aria-label="打开个人菜单" :aria-expanded="userMenuOpen"
|
||||||
|
@click="toggleUserMenu">
|
||||||
|
<!-- 有头像时显示头像 -->
|
||||||
|
<div v-if="userInfo.avatar" class="avatar">
|
||||||
|
<div class="mask mask-squircle w-7">
|
||||||
|
<img :src="userInfo.avatar" :alt="userInfo.username || 'User avatar'" width="28" height="28" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 没有头像时显示首字母 -->
|
||||||
|
<div v-else class="placeholder avatar">
|
||||||
|
<div class="flex w-7 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span class="text-xs font-medium">{{ userInitials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hidden flex-col items-start sm:flex">
|
||||||
|
<span class="text-sm leading-none font-medium">{{ userInfo.username }}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown class="h-3.5 w-3.5 text-base-content/50" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<ul class="menu dropdown-content z-40 mt-2 w-56 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg"
|
||||||
|
@keydown.escape="closeMenus">
|
||||||
|
<!-- 身份信息 -->
|
||||||
|
<li class="pointer-events-none">
|
||||||
|
<div class="flex items-center gap-2.5 px-2 py-2">
|
||||||
|
<div class="placeholder avatar">
|
||||||
|
<div class="flex h-9 w-9 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span class="text-sm font-medium">{{ userInitials }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="flex items-center gap-1.5 truncate text-sm font-medium">
|
||||||
|
<span class="truncate">{{ userInfo.name || userInfo.username }}</span>
|
||||||
|
<span class="badge badge-ghost badge-xs">{{ roleName }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="userInfo.email" class="truncate text-xs text-base-content/50">{{ userInfo.email }}</p>
|
||||||
|
<p v-else-if="userInfo.name" class="truncate text-xs text-base-content/50">{{ '@' + (userInfo.username || '') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true" class="pointer-events-none select-none"><hr class="-mx-2 my-1 border-base-content/10" /></li>
|
||||||
|
<li>
|
||||||
|
<a href="#" @click.prevent="handleMenuAction('profile')">
|
||||||
|
<SettingsIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
账户设置
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li v-if="isAdminUser && !isAdminArea">
|
||||||
|
<a href="#" @click.prevent="handleMenuAction('admin')">
|
||||||
|
<ShieldIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
管理后台
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li aria-hidden="true" class="pointer-events-none select-none"><hr class="-mx-2 my-1 border-base-content/10" /></li>
|
||||||
|
<li class="text-error">
|
||||||
|
<a href="#" @click.prevent="handleMenuAction('logout')">
|
||||||
|
<LogOut class="h-4 w-4" aria-hidden="true" />
|
||||||
|
退出登录
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<main class="mx-auto w-full max-w-6xl flex-1 px-3 py-6 sm:px-6 lg:px-8">
|
||||||
|
<router-view></router-view>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="drawer-side z-40">
|
||||||
|
<label for="ot-drawer" class="drawer-overlay" aria-label="Close navigation menu"></label>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { MenuIcon, SunIcon, MoonIcon, MonitorIcon, PanelLeftIcon, ChevronDown, SettingsIcon, ShieldIcon, LogOut } from '@lucide/vue'
|
||||||
|
import Sidebar from '@/components/dashboard/Sidebar.vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { useTheme } from '@/composables/theme'
|
||||||
|
import type { ThemePreference } from '@/composables/theme'
|
||||||
|
import type { UserInfo } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { preference, setPreference } = useTheme()
|
||||||
|
const isLargeSidebarOpen = ref(true)
|
||||||
|
|
||||||
|
// 移动端抽屉:路由切换后自动收起;下拉一并关闭
|
||||||
|
router.afterEach(() => {
|
||||||
|
const drawer = document.getElementById('ot-drawer') as HTMLInputElement | null
|
||||||
|
if (drawer) drawer.checked = false
|
||||||
|
userMenuOpen.value = false
|
||||||
|
themeMenuOpen.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下拉改为状态驱动(dropdown-open),不再依赖焦点——触屏上点菜单项不会因失焦而先收起
|
||||||
|
const userMenuOpen = ref(false)
|
||||||
|
const themeMenuOpen = ref(false)
|
||||||
|
|
||||||
|
const toggleUserMenu = () => {
|
||||||
|
userMenuOpen.value = !userMenuOpen.value
|
||||||
|
themeMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleThemeMenu = () => {
|
||||||
|
themeMenuOpen.value = !themeMenuOpen.value
|
||||||
|
userMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenus = () => {
|
||||||
|
userMenuOpen.value = false
|
||||||
|
themeMenuOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectTheme = (p: ThemePreference) => {
|
||||||
|
setPreference(p)
|
||||||
|
closeMenus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfo = computed<Partial<UserInfo>>(() => authStore.user || {})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!authStore.user) {
|
||||||
|
await authStore.getProfile()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userInitials = computed(() => {
|
||||||
|
if (userInfo.value.name) {
|
||||||
|
return userInfo.value.name
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2); // Max 2 letters
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userInfo.value.username) {
|
||||||
|
return userInfo.value.username
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2); // Max 2 letters
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'U';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 仅管理员可见「管理后台」入口(已处于后台区域时不再重复显示)
|
||||||
|
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'))
|
||||||
|
const isAdminUser = computed(() => (authStore.user?.role ?? 0) >= 10)
|
||||||
|
|
||||||
|
const roleName = computed(() => {
|
||||||
|
const role = authStore.user?.role ?? 0;
|
||||||
|
if (role > 10) return 'Root';
|
||||||
|
if (role > 0) return 'Admin';
|
||||||
|
return 'User';
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMenuAction = (action: 'profile' | 'admin' | 'logout') => {
|
||||||
|
closeMenus()
|
||||||
|
if (action === 'profile') {
|
||||||
|
router.push('/dashboard/settings/profile')
|
||||||
|
} else if (action === 'admin') {
|
||||||
|
router.push('/dashboard/manager/users')
|
||||||
|
} else if (action === 'logout') {
|
||||||
|
authStore.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleSidebarLarge = () => {
|
||||||
|
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -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')
|
||||||
@@ -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
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@plugin "daisyui" {
|
||||||
|
themes: light --default, dark, cupcake, emerald, pastel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收紧 emerald 主题的圆角令牌:默认 selector 圆角过大,小尺寸复选框会渲染成正圆 */
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "emerald";
|
||||||
|
--radius-selector: 0.5rem;
|
||||||
|
--radius-field: 0.5rem;
|
||||||
|
--radius-box: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB",
|
||||||
|
"Microsoft YaHei", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深浅色跟随 data-theme,修复滚动条与原生控件配色 */
|
||||||
|
html {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: var(--color-base-200);
|
||||||
|
color: var(--color-base-content);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端去除双击缩放延迟与点击高亮 */
|
||||||
|
button,
|
||||||
|
a,
|
||||||
|
[role="button"],
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"],
|
||||||
|
summary {
|
||||||
|
touch-action: manipulation;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统一可见键盘焦点(:focus-visible 不干扰鼠标点击) */
|
||||||
|
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹层内禁止滚动穿透 */
|
||||||
|
.modal-box {
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 普通主按钮统一黑白:浅色主题黑底白字,深色主题白底黑字(特殊按钮 success/error/warning/outline 不受影响) */
|
||||||
|
.btn-primary {
|
||||||
|
--btn-color: #171717;
|
||||||
|
--btn-fg: #ffffff;
|
||||||
|
--btn-soft-bg: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
--btn-bg: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .btn-primary {
|
||||||
|
--btn-color: #ffffff;
|
||||||
|
--btn-fg: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-theme="dark"] .btn-primary:hover {
|
||||||
|
--btn-bg: #e4e4e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 尊重系统“减弱动态效果”偏好 */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,75 +0,0 @@
|
|||||||
import {
|
|
||||||
LayoutDashboardIcon,
|
|
||||||
ShieldPlus,
|
|
||||||
UsersRoundIcon,
|
|
||||||
KeyRoundIcon,
|
|
||||||
MessageSquareIcon,
|
|
||||||
SettingsIcon,
|
|
||||||
UserIcon,
|
|
||||||
CommandIcon,
|
|
||||||
BracesIcon,
|
|
||||||
} from 'lucide-vue-next'
|
|
||||||
|
|
||||||
export const routes = [
|
|
||||||
{ 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, 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: '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: 'users', name: 'User', component: ()=>import('@/views/dashboard/User.vue'),meta: { title: 'Users', icon: UsersRoundIcon, showInSidebar: true } },
|
|
||||||
{ path: 'users/new', name: 'UserNew', component: ()=>import('@/views/dashboard/UserNew.vue'),meta: { title: 'UserNew', icon: UsersRoundIcon, showInSidebar: false } },
|
|
||||||
{ path: 'users/view', name: 'UserView', component: ()=>import('@/views/dashboard/UserView.vue'),meta: { title: 'UserView', icon: UsersRoundIcon, showInSidebar: false } },
|
|
||||||
{ path: 'keys', name: 'ApiKey', component: ()=>import('@/views/dashboard/Keys.vue') ,meta: { title: 'ApiKeys', icon: KeyRoundIcon, showInSidebar: true } },
|
|
||||||
{ path: 'keys/view', name: 'ApiKeyView', component: ()=>import('@/views/dashboard/KeyView.vue') ,meta: { title: 'ApiKeyView', icon: KeyRoundIcon, showInSidebar: false } },
|
|
||||||
] },
|
|
||||||
{ path: 'settings', name: 'Settings', meta: { title: 'Settings', icon: SettingsIcon, showInSidebar: true, open: false } , redirect: '/dashboard/settings/profile',children:[
|
|
||||||
{ path: 'profile', name: 'Profile', component: ()=>import('@/views/dashboard/Profile.vue'),meta: { title: 'Profile', icon: UserIcon, showInSidebar: true } },
|
|
||||||
]},
|
|
||||||
]},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function generateMenuItemsFromRoutes(routes, userRole, parentPath = '') {
|
|
||||||
const menuItems = [];
|
|
||||||
|
|
||||||
for (const route of routes) {
|
|
||||||
if (route.meta && route.meta.title && route.meta.showInSidebar) {
|
|
||||||
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.name === 'Manager' && userRole < 10) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
menuItem.type = 'submenu';
|
|
||||||
menuItem.open = route.meta.open !== undefined ? route.meta.open : false;
|
|
||||||
menuItem.badge = route.meta.badge;
|
|
||||||
menuItem.children = generateMenuItemsFromRoutes(route.children, userRole, fullPath);
|
|
||||||
} else {
|
|
||||||
menuItem.type = 'link';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (route.name === 'Overview') {
|
|
||||||
menuItems.push(menuItem);
|
|
||||||
menuItems.push({ type: 'title', label: 'Apps' });
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
menuItems.push(menuItem);
|
|
||||||
} else if (route.path === '/dashboard' && route.children) {
|
|
||||||
|
|
||||||
menuItems.push(...generateMenuItemsFromRoutes(route.children, userRole, '/dashboard'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItems;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
import {
|
||||||
|
GaugeIcon,
|
||||||
|
UsersRoundIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
} from '@lucide/vue'
|
||||||
|
|
||||||
|
export type MenuLink = { label: string; to: string; icon?: Component }
|
||||||
|
|
||||||
|
// 路由 meta 扩展字段
|
||||||
|
declare module 'vue-router' {
|
||||||
|
interface RouteMeta {
|
||||||
|
title?: string
|
||||||
|
icon?: Component
|
||||||
|
showInSidebar?: boolean
|
||||||
|
requiresAuth?: boolean
|
||||||
|
open?: boolean
|
||||||
|
badge?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const routes: RouteRecordRaw[] = [
|
||||||
|
{ path: '/', name: 'Home', component: () => import('@/views/Home.vue') },
|
||||||
|
{ path: '/404', name: '404', component: () => import('@/views/error/NotFound.vue') },
|
||||||
|
|
||||||
|
{ path: '/login', name: 'Login', component: () => import('@/views/auth/Login.vue') },
|
||||||
|
{ path: '/signup', name: 'Signup', component: () => import('@/views/auth/Signup.vue') },
|
||||||
|
|
||||||
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/NotFound.vue') }, // Catch all 404
|
||||||
|
{
|
||||||
|
path: '/dashboard',
|
||||||
|
name: 'Dashboard',
|
||||||
|
component: () => import('@/layouts/DashboardLayout.vue'),
|
||||||
|
meta: { requiresAuth: true, title: '控制台' },
|
||||||
|
redirect: '/dashboard/overview',
|
||||||
|
children: [
|
||||||
|
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
|
||||||
|
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'API Keys' } },
|
||||||
|
{
|
||||||
|
path: 'manager',
|
||||||
|
name: 'Manager',
|
||||||
|
meta: { title: '管理后台' },
|
||||||
|
redirect: '/dashboard/manager/users',
|
||||||
|
children: [
|
||||||
|
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
|
||||||
|
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } },
|
||||||
|
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
|
||||||
|
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
|
||||||
|
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
name: 'Settings',
|
||||||
|
meta: { title: '账户设置' },
|
||||||
|
redirect: '/dashboard/settings/profile',
|
||||||
|
children: [
|
||||||
|
{ path: 'profile', name: 'Profile', component: () => import('@/views/dashboard/Profile.vue'), meta: { title: '账户设置' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 控制台菜单(所有登录用户)
|
||||||
|
export const consoleMenu: MenuLink[] = [
|
||||||
|
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
|
||||||
|
{ label: 'API Keys', to: '/dashboard/tokens', icon: KeyRoundIcon },
|
||||||
|
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 管理后台菜单(role >= 10)
|
||||||
|
export const adminMenu: MenuLink[] = [
|
||||||
|
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||||
|
{ label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon },
|
||||||
|
]
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="min-h-screen bg-base-100 text-base-content">
|
|
||||||
<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="flex items-center h-12 w-12 rounded-full text-l">
|
|
||||||
<img src="../assets/logo.svg" alt="Logo" class="select-none">
|
|
||||||
<span class="hidden sm:flex text-xl font-bold">
|
|
||||||
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="flex-grow flex flex-col justify-center items-center pt-16">
|
|
||||||
<div class="text-center">
|
|
||||||
<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"> -->
|
|
||||||
</div>
|
|
||||||
<h1 class="text-5xl font-bold mb-4 text-rose-300">
|
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-400 text-lg max-w-lg mx-auto mb-8">
|
|
||||||
迷路了吗?<span class="line-through text-gray-200">卑鄙的</span>异乡客?
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-col sm:flex-row gap-4">
|
|
||||||
<a href="/" class="btn btn-primary">Go Back Home</a>
|
|
||||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" class="btn btn-outline">Visit GitHub Repo</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<footer class="footer footer-center text-base-content rounded p-10 mt-16 absolute bottom-0 w-full">
|
|
||||||
<nav>
|
|
||||||
<div class="grid grid-flow-col gap-4">
|
|
||||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
|
|
||||||
<path
|
|
||||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<aside>
|
|
||||||
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
|
|
||||||
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
|
|
||||||
</aside>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue';
|
|
||||||
|
|
||||||
const currentYear = ref('');
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
currentYear.value = new Date().getFullYear().toString();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* You can add specific styles for the 404 page here if needed */
|
|
||||||
</style>
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<!-- src/layouts/MainLayout.vue -->
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-base-100">
|
|
||||||
<div class="drawer drawer-overlay" :class="{ 'md:drawer-open': isLargeSidebarOpen }">
|
|
||||||
<!-- Sidebar Toggle for Mobile -->
|
|
||||||
<input id="drawer" type="checkbox" class="drawer-toggle" />
|
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="drawer-content flex flex-col">
|
|
||||||
<!-- Top Navigation -->
|
|
||||||
<header class="w-full navbar bg-base-100 border-b border-base-200 h-14 min-h-[3rem]">
|
|
||||||
<!-- Mobile menu button -->
|
|
||||||
<div class="flex-none">
|
|
||||||
<div class="md:hidden">
|
|
||||||
<label for="drawer" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
|
|
||||||
<MenuIcon class="w-5 h-5" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<!-- Desktop menu button -->
|
|
||||||
<div class="hidden md:flex">
|
|
||||||
<label @click="toggleSidebarLarge" class="btn btn-square btn-ghost h-10 min-h-[2.5rem]">
|
|
||||||
<MenuIcon class="w-5 h-5" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-auto space-x-1 justify-end">
|
|
||||||
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem]" @click="toggleTheme">
|
|
||||||
<SunIcon v-if="isDark" class="w-5 h-5" />
|
|
||||||
<MoonIcon v-else class="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-ghost btn-circle h-10 min-h-[2.5rem] hidden">
|
|
||||||
<div class="indicator">
|
|
||||||
<BellIcon class="w-5 h-5" />
|
|
||||||
<span class="badge badge-xs badge-primary indicator-item"></span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown dropdown-bottom dropdown-end">
|
|
||||||
<label tabindex="0" class="btn btn-ghost rounded-btn px-1.5 hover:bg-base-content/20 h-10 min-h-[2.5rem]">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- 有头像时显示头像 -->
|
|
||||||
<div v-if="userInfo.avatar" class="avatar">
|
|
||||||
<div class="mask mask-squircle w-8 h-8">
|
|
||||||
<img :src="userInfo.avatar" :alt="userInfo.name">
|
|
||||||
<!-- <img src='../assets/logo.svg' :alt="userInfo.name"> -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 没有头像时显示首字母 -->
|
|
||||||
<div v-else class="avatar placeholder">
|
|
||||||
<div class="bg-neutral text-neutral-content w-8 mask mask-squircle">
|
|
||||||
<span class="text-sm">{{ userInitials }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-start">
|
|
||||||
<p class="text-sm/none">{{ userInfo.username }}</p>
|
|
||||||
<p class="mt-1 text-xs/none text-primary">Edit</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<ul class="menu menu-sm dropdown-content mt-2 z-[1] p-1.5 shadow bg-base-100 rounded-box w-32">
|
|
||||||
<template v-for="item in userNavigation" :key="item.name || 'divider'">
|
|
||||||
<hr v-if="item.type === 'divider'" class="-mx-2 my-1 border-base-content/10">
|
|
||||||
<li v-else :class="item.class">
|
|
||||||
<a href="#" class="py-1.5" @click.prevent="handleNavigation(item)">
|
|
||||||
<component :is="item.icon" class="w-4 h-4" />
|
|
||||||
{{ item.name }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</template>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Main Content Area -->
|
|
||||||
<main class="flex-1 overflow-y-auto p-6 bg-base-200 px-1 sm:px-6">
|
|
||||||
<router-view></router-view>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<div class="drawer-side">
|
|
||||||
<label for="drawer" class="drawer-overlay"></label>
|
|
||||||
<Sidebar />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, computed, onMounted } from 'vue'
|
|
||||||
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, Settings, LogOut } from 'lucide-vue-next'
|
|
||||||
import Sidebar from '@/components/dashboard/Sidebar.vue'
|
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const isDark = ref(false)
|
|
||||||
const isLargeSidebarOpen = ref(true)
|
|
||||||
|
|
||||||
const userInfo = computed(() => {
|
|
||||||
return authStore.user || {}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!userInfo) {
|
|
||||||
await authStore.getProfile()
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const userInitials = computed(() => {
|
|
||||||
// If name exists, use it first
|
|
||||||
if (userInfo.value.name) {
|
|
||||||
return userInfo.value.name
|
|
||||||
.split(' ')
|
|
||||||
.map(word => word[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2); // Max 2 letters
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userInfo.value.username) {
|
|
||||||
return userInfo.value.username
|
|
||||||
.split(' ')
|
|
||||||
.map(word => word[0])
|
|
||||||
.join('')
|
|
||||||
.toUpperCase()
|
|
||||||
.slice(0, 2); // Max 2 letters
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'U';
|
|
||||||
});
|
|
||||||
|
|
||||||
const userNavigation = reactive([
|
|
||||||
{ name: 'Profile', icon: User },
|
|
||||||
{ type: 'divider' },
|
|
||||||
{ name: 'Logout', icon: LogOut, class: 'text-error' }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleNavigation = (item) => {
|
|
||||||
if (item.name === 'Profile') {
|
|
||||||
router.push('/dashboard/settings/profile')
|
|
||||||
} else if (item.name === 'Logout'){
|
|
||||||
authStore.logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebarLarge = () => {
|
|
||||||
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
isDark.value = !isDark.value
|
|
||||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'emerald')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
+130
-107
@@ -1,121 +1,166 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 text-base-content">
|
<div class="min-h-screen bg-base-100 text-base-content">
|
||||||
<div class="navbar fixed w-full top-0 z-50 backdrop-blur-sm bg-base-100/50">
|
<header class="fixed inset-x-0 top-0 z-50 border-b border-base-300/40 bg-base-100/80 backdrop-blur">
|
||||||
<div class="container mx-auto flex justify-between items-center p-1 rounded-box">
|
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-2.5">
|
||||||
<div class="flex items-center h-12 w-12 rounded-full text-l">
|
<a href="/" class="flex select-none items-center gap-2">
|
||||||
<img src="../assets/logo.svg" alt="Logo" class="select-none">
|
<img src="@/assets/logo.svg" alt="OpenTeam logo" width="36" height="36" class="h-9 w-9" />
|
||||||
<span class="hidden sm:flex text-xl font-bold">
|
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
|
||||||
<a href="/" class="text-base-content hover:no-underline">OpenTeam</a>
|
</a>
|
||||||
</span>
|
<div class="flex items-center gap-2">
|
||||||
</div>
|
<!-- 下拉打开时的透明遮罩:点击任意处关闭(触屏可靠) -->
|
||||||
<div class="flex items-center">
|
<div v-if="themeMenuOpen" class="fixed inset-0 z-40" aria-hidden="true" @click="themeMenuOpen = false"></div>
|
||||||
|
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': themeMenuOpen }">
|
||||||
|
<label id="theme-menu-label" class="sr-only">切换主题</label>
|
||||||
|
<button class="btn btn-ghost btn-sm btn-square h-9 w-9 min-h-9 rounded-full"
|
||||||
|
aria-labelledby="theme-menu-label" :aria-expanded="themeMenuOpen" @click="themeMenuOpen = !themeMenuOpen">
|
||||||
|
<SunIcon v-if="preference === 'light'" class="size-4" aria-hidden="true" />
|
||||||
|
<MoonIcon v-else-if="preference === 'dark'" class="size-4" aria-hidden="true" />
|
||||||
|
<MonitorIcon v-else class="size-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<ul class="menu dropdown-content z-50 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'light' }" @click="selectTheme('light')"
|
||||||
|
:aria-pressed="preference === 'light'">
|
||||||
|
<SunIcon class="size-4" aria-hidden="true" />浅色
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'dark' }" @click="selectTheme('dark')"
|
||||||
|
:aria-pressed="preference === 'dark'">
|
||||||
|
<MoonIcon class="size-4" aria-hidden="true" />深色
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button :class="{ 'menu-active': preference === 'auto' }" @click="selectTheme('auto')"
|
||||||
|
:aria-pressed="preference === 'auto'">
|
||||||
|
<MonitorIcon class="size-4" aria-hidden="true" />自动
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
|
||||||
class="btn btn-md h-10 min-h-10 px-3 md:flex bg-black text-white items-center justify-center whitespace-nowrap rounded-xl text-sm font-extralight ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-black/90 flex gap-2">
|
class="btn btn-ghost btn-sm h-9 min-h-9 gap-2 rounded-full">
|
||||||
<Icon icon="simple-icons:github" class="size-5" />
|
<Icon icon="simple-icons:github" class="size-4" aria-hidden="true" />
|
||||||
<span>Star on GitHub</span>
|
<span class="hidden sm:inline">Star</span>
|
||||||
<Icon icon="mingcute:star-fill" class="size-5 text-yellow-500 mb-0.5" />
|
<span class="inline-flex items-center gap-1 rounded-full bg-base-200 px-2 py-0.5 text-xs font-medium tabular-nums">
|
||||||
<div class="font-extralight">{{ star }}</div>
|
<Icon icon="mingcute:star-fill" class="size-3.5 text-warning" aria-hidden="true" />
|
||||||
|
{{ star }}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a class="hidden btn btn-outline font-extralight btn-md h-10 min-h-10 hover:bg-black px-1 ml-2"
|
<RouterLink to="/dashboard" class="btn btn-primary btn-sm h-9 min-h-9 rounded-full px-4">
|
||||||
@click="$router.push('/dashboard')">Dashboard</a>
|
Open Dashboard
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<main class="flex-grow flex flex-col justify-center items-center pt-16">
|
<main class="mx-auto max-w-5xl px-4 pb-16 pt-24">
|
||||||
<div class="text-center">
|
<!-- Hero -->
|
||||||
<div class="flex items-center justify-center my-4 outline-none select-none">
|
<section class="flex flex-col items-center text-center">
|
||||||
<img src="../assets/openteam.png" alt="Project Logo" class="h-40">
|
<img src="@/assets/openteam.png" alt="OpenTeam project logo" width="160" height="160" class="h-28 w-auto select-none" fetchpriority="high" />
|
||||||
</div>
|
<h1 class="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl">OpenTeam</h1>
|
||||||
<h1 class="text-4xl font-bold mb-4">
|
<p class="mt-4 max-w-2xl text-pretty text-lg text-base-content/70">
|
||||||
<a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a>
|
An open-source, team-shared service compatible with the OpenAI API — share LLM API usage across your team through a single endpoint.
|
||||||
</h1>
|
|
||||||
<p class="text-lg max-w-2xl mx-auto mb-8">
|
|
||||||
OpenTeam is an open-source, team-shared service. that is compatible with OpenAI API and can
|
|
||||||
be safely shared with others for API usage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-8 md:tooltip md:tooltip-left md:tooltip-open" data-tip="指向 OpenTeam 的 base_url">
|
|
||||||
<div class="join">
|
|
||||||
<input type="text" class="input input-bordered join-item grow focus:outline-none rounded-l-full"
|
|
||||||
v-model="url" />
|
|
||||||
<button class="hover:bg-black hover:text-white btn join-item rounded-r-full" @click="copyUrl">复制</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="w-full max-w-4xl mx-auto px-4 text-center">
|
|
||||||
<p class="text-sm mb-4">
|
|
||||||
👉Api-Keys: <a href="https://platform.openai.com/account/api-keys"
|
|
||||||
class="link">https://platform.openai.com/account/api-keys</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="card mb-8">
|
<div class="tooltip mt-8 w-full max-w-xl" data-tip="Point your client at the OpenTeam base URL">
|
||||||
<div class="card-body px-1 flex flex-col items-center">
|
<label class="mb-1.5 block text-left text-xs font-medium uppercase tracking-wider text-base-content/50" for="endpoint-url">
|
||||||
<h2 class="card-title text-xl font-extralight justify-center flex mb-4 p-2 rounded-lg border-2 border-dotted hover:cursor-alias"
|
Team API Endpoint
|
||||||
@click="$router.push('/dashboard')">开始使用</h2>
|
</label>
|
||||||
|
<div class="join w-full">
|
||||||
<p class="mb-0">使用OpenTeam 管理你的LLM API,仅需一个地址即可接入不同的大模型</p>
|
<input id="endpoint-url" type="text" readonly aria-label="Team API endpoint URL"
|
||||||
<LineSegmentFlow />
|
class="input input-bordered join-item grow font-mono text-sm" v-model="url" />
|
||||||
|
<button class="btn btn-primary join-item" @click="copyUrl">Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card backdrop-blur-sm shadow-xl mb-8">
|
<p class="mt-6 text-sm text-base-content/60">
|
||||||
<div class="card-body">
|
👉 Api-Keys:
|
||||||
<p class="mb-2">欢迎加入我们的Telegram频道,获取最新动态和帮助</p>
|
<a href="https://platform.openai.com/account/api-keys" class="link link-hover link-primary">platform.openai.com/account/api-keys</a>
|
||||||
<div class="flex justify-center mb-4">
|
</p>
|
||||||
<a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel">
|
</section>
|
||||||
<img src="../assets/openteam_channel.jpg" alt="Telegram Group QR Code"
|
|
||||||
class="w-40 fill-current backdrop-blur-0 select-none">
|
<!-- How it works -->
|
||||||
</a>
|
<section class="mt-12">
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body items-center px-4 pb-2 pt-6 text-center sm:px-8">
|
||||||
|
<h2 class="text-xl font-semibold tracking-tight text-pretty">One endpoint, every model</h2>
|
||||||
|
<p class="max-w-lg text-sm text-base-content/60">
|
||||||
|
Connect clients on the left with LLM providers on the right — OpenTeam routes and accounts for every request.
|
||||||
|
</p>
|
||||||
|
<div class="w-full" aria-hidden="true">
|
||||||
|
<LineSegmentFlow />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
<!-- Community -->
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body items-center text-center">
|
||||||
|
<h2 class="text-base font-semibold">Join our Telegram channel</h2>
|
||||||
|
<p class="text-sm text-base-content/60">Get the latest updates and help from the community.</p>
|
||||||
|
<a href="https://t.me/OpenTeamLLM" target="_blank" rel="noopener noreferrer" class="tooltip mt-3" data-tip="Telegram Channel">
|
||||||
|
<img src="@/assets/openteam_channel.jpg" alt="Telegram channel QR code" width="160" height="160"
|
||||||
|
loading="lazy" class="w-36 rounded-xl border border-base-300/60 select-none" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer footer-center text-base-content rounded p-10">
|
<footer class="border-t border-base-300/40 bg-base-100 pb-10 pt-8 text-center text-sm text-base-content/60">
|
||||||
<nav>
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" aria-label="OpenTeam on GitHub" class="inline-flex p-1">
|
||||||
<div class="grid grid-flow-col gap-4">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" class="fill-current" aria-hidden="true">
|
||||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank">
|
<path
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" class="fill-current">
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
<path
|
</svg>
|
||||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
</a>
|
||||||
</svg>
|
<p class="mt-3">
|
||||||
</a>
|
Copyright © {{ currentYear }} Mirrors2 ·
|
||||||
</div>
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="link link-hover">opencatd-open</a>
|
||||||
</nav>
|
</p>
|
||||||
<aside>
|
|
||||||
<p>Copyright © {{ currentYear }} - All right reserved by <a href="https://github.com/mirrors2" target="_blank"
|
|
||||||
class="text-gray-600 hover:text-gray-800 transition duration-300">Mirrors2.</a> </p>
|
|
||||||
</aside>
|
|
||||||
</footer>
|
</footer>
|
||||||
</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 { SunIcon, MoonIcon, MonitorIcon } from '@lucide/vue';
|
||||||
|
import LineSegmentFlow from '@/components/common/LineSegmentFlow.vue';
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
|
import { useToast } from '@/composables/toast';
|
||||||
|
import { useTheme } from '@/composables/theme';
|
||||||
|
import type { ThemePreference } from '@/composables/theme';
|
||||||
|
|
||||||
|
const { preference, setPreference } = useTheme();
|
||||||
|
|
||||||
|
// 下拉状态驱动(dropdown-open),触屏点选可靠
|
||||||
|
const themeMenuOpen = ref(false);
|
||||||
|
|
||||||
|
// daisyUI 下拉基于焦点展开:选择后移除焦点收起菜单
|
||||||
|
const selectTheme = (p: ThemePreference) => {
|
||||||
|
setPreference(p);
|
||||||
|
themeMenuOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const currentYear = ref('');
|
const 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('Copied to clipboard.', 'success');
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
setToast('复制失败,请手动复制。', 'error');
|
setToast('Copy failed — select the text and copy manually.', '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');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.repo.stars;
|
return data.repo.stars;
|
||||||
};
|
};
|
||||||
@@ -126,29 +171,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
getGithubStars().then((stars) => {
|
getGithubStars().then((stars) => {
|
||||||
star.value = stars;
|
star.value = stars;
|
||||||
});
|
}).catch(() => { /* 徽标数字失败可忽略 */ });
|
||||||
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.toast {
|
|
||||||
transition: opacity 0.3s, visibility 0.3s;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
position: fixed;
|
|
||||||
top: 64px;
|
|
||||||
/* 调整这个值以匹配你的 header 高度 */
|
|
||||||
right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast.visible {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
padding: 0 1em;
|
|
||||||
border-left: 0.25em solid #838989aa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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-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"
|
|
||||||
@click="$router.push('/')" />
|
|
||||||
|
|
||||||
<h2 class="card-title text-md sm:text-xl mb-6 justify-center flex">
|
|
||||||
Log in to Dashboard
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleLogin">
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="username">
|
|
||||||
<span class="label-text">Account</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="username" placeholder="username/email" class="input input-bordered w-full input-sm"
|
|
||||||
v-model="user.username" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="password">
|
|
||||||
<span class="label-text">Password</span>
|
|
||||||
<a href="#" class="label-text-alt link link-hover link-primary text-sm">
|
|
||||||
Forgot password?
|
|
||||||
</a>
|
|
||||||
</label>
|
|
||||||
<input type="password" id="password" placeholder="••••••••" class="input input-bordered w-full input-sm"
|
|
||||||
v-model="user.password" minlength="4" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-6">
|
|
||||||
<label class="label cursor-pointer justify-start gap-2">
|
|
||||||
<input type="checkbox" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
|
|
||||||
<span class="label-text text-sm">Remember me</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<button type="submit" class="btn btn-outline w-full btn-sm">
|
|
||||||
Log In
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm" @click="error = null">X</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="divider my-2 text-sm">OR</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-6">
|
|
||||||
<button @click="handlePasskeyLogin" class="btn btn-outline w-full btn-sm" :disabled="!supportWebAuth">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path fill="currentColor"
|
|
||||||
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
|
|
||||||
</svg>
|
|
||||||
Sign in with Passkey
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="hidden flex flex-col sm:flex-row gap-3 w-full">
|
|
||||||
<button @click="handleGithubLogin" class="btn btn-outline flex-1 btn-sm">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-github mr-1"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</button>
|
|
||||||
<button @click="handleGoogleLogin" class="btn btn-outline flex-1 btn-sm">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" class="bi bi-google mr-1"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M15.545 6.558a9.4 9.4 0 0 1 .139 1.626c0 2.434-.87 4.492-2.384 5.885h.002C11.978 15.292 10.158 16 8 16A8 8 0 1 1 8 0a7.7 7.7 0 0 1 5.352 2.082l-2.284 2.284A4.35 4.35 0 0 0 8 3.166c-2.087 0-3.86 1.408-4.492 3.304a4.8 4.8 0 0 0 0 3.063h.003c.635 1.896 2.405 3.301 4.492 3.301 1.078 0 2.004-.276 2.722-.764h-.003a3.7 3.7 0 0 0 1.599-2.431H8v-3.08z" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
</button>
|
|
||||||
<button @click="handleTelegramLogin" class="btn btn-outline flex-1 btn-sm">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
|
|
||||||
class="bi bi-telegram mr-1" viewBox="0 0 16 16">
|
|
||||||
<path
|
|
||||||
d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM8.287 5.906q-1.168.486-4.666 2.01-.567.225-.595.442c-.03.243.275.339.69.47l.175.055c.408.133.958.288 1.243.294q.39.01.868-.32 3.269-2.206 3.374-2.23c.05-.012.12-.026.166.016s.042.12.037.141c-.03.129-1.227 1.241-1.846 1.817-.193.18-.33.307-.358.336a8 8 0 0 1-.188.186c-.38.366-.664.64.015 1.088.327.216.589.393.85.571.284.194.568.387.936.629q.14.092.27.187c.331.236.63.448.997.414.214-.02.435-.22.547-.82.265-1.417.786-4.486.906-5.751a1.4 1.4 0 0 0-.013-.315.34.34 0 0 0-.114-.217.53.53 0 0 0-.31-.093c-.3.005-.763.166-2.984 1.09" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-center mt-6 text-sm">
|
|
||||||
Don't have an account?
|
|
||||||
<router-link to="/signup" class="link link-primary link-hover">
|
|
||||||
Sign up
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, inject, onMounted } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import { useWebAuthStore } from '@/stores/webauth';
|
|
||||||
// import request from '@/utils/request';
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const webauthStore = useWebAuthStore();
|
|
||||||
const { setToast } = inject('toast');
|
|
||||||
|
|
||||||
const error = ref(null)
|
|
||||||
const user = reactive({
|
|
||||||
username: localStorage.getItem('account') || '',
|
|
||||||
password: localStorage.getItem('password') || '',
|
|
||||||
rember: localStorage.getItem('rember') || false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const supportWebAuth = ref(false);
|
|
||||||
onMounted(() => {
|
|
||||||
if (localStorage.getItem('token')) {
|
|
||||||
router.push('/dashboard')
|
|
||||||
}
|
|
||||||
supportWebAuth.value = !!window.PublicKeyCredential;
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const response = await authStore.login({ username: user.username, password: user.password });
|
|
||||||
if (response.status === 200) {
|
|
||||||
if (user.rember) {
|
|
||||||
localStorage.setItem('account', user.username);
|
|
||||||
localStorage.setItem('password', user.password);
|
|
||||||
localStorage.setItem('rember', user.rember);
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('account');
|
|
||||||
localStorage.removeItem('password');
|
|
||||||
localStorage.removeItem('rember');
|
|
||||||
}
|
|
||||||
setToast('登录成功', 'success');
|
|
||||||
router.push('/dashboard');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login error:', err);
|
|
||||||
error.value = err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePasskeyLogin = async () => {
|
|
||||||
error.value = null;
|
|
||||||
try {
|
|
||||||
const res = await webauthStore.loginPasskey();
|
|
||||||
if (!!res.code && res.code === 200) {
|
|
||||||
setToast('登录成功', 'success');
|
|
||||||
router.push('/dashboard');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Passkey login error:', err);
|
|
||||||
error.value = err
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGithubLogin = () => {
|
|
||||||
alert('GitHub login is not yet implemented.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGoogleLogin = () => {
|
|
||||||
alert('Google login is not yet implemented.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTelegramLogin = () => {
|
|
||||||
alert('Telegram login is not yet implemented.');
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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-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('/')"/>
|
|
||||||
|
|
||||||
<h2 class="card-title text-md sm:text-xl mb-2 justify-center flex">
|
|
||||||
Create Your Account
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form @submit.prevent="handleRegister">
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="account">
|
|
||||||
<span class="label-text">Account</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" id="account" placeholder="username"
|
|
||||||
class="input input-bordered w-full input-sm" v-model="username" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<label class="label" for="password">
|
|
||||||
<span class="label-text">Password</span>
|
|
||||||
</label>
|
|
||||||
<input id="password" type="password" placeholder="password"
|
|
||||||
class="input input-bordered w-full input-sm" v-model="password" required minlength="4" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-6">
|
|
||||||
<label class="label" for="confirm-password">
|
|
||||||
<span class="label-text">Confirm Password</span>
|
|
||||||
</label>
|
|
||||||
<input type="password" id="confirm-password" placeholder="Retype your password"
|
|
||||||
class="input input-bordered w-full input-sm" v-model="confirmPassword" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control mb-4">
|
|
||||||
<button type="submit" class="btn btn-outline w-full btn-sm">
|
|
||||||
Sign Up
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div v-if="error" class="text-error text-sm mt-2 text-center">{{ error }}</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
|
|
||||||
<div class="text-center mt-4 text-sm">
|
|
||||||
Already have an account?
|
|
||||||
<router-link to="/login" class="link link-primary link-hover">
|
|
||||||
Login
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, inject } from 'vue'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const { setToast } = inject('toast');
|
|
||||||
|
|
||||||
const error = ref('');
|
|
||||||
|
|
||||||
const username = ref('');
|
|
||||||
const password = ref('');
|
|
||||||
const confirmPassword = ref('');
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
alert("密码不一致");
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
let res = await authStore.register({
|
|
||||||
username: username.value,
|
|
||||||
password: password.value
|
|
||||||
})
|
|
||||||
if (res.status === 200) {
|
|
||||||
setToast(res.data?.msg || '已注册', 'success');
|
|
||||||
setTimeout(() => {
|
|
||||||
router.push('/login');
|
|
||||||
}, 100);
|
|
||||||
error.value = ''
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error.value = 'Registration failed. Please try again.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-base-200 p-4">
|
||||||
|
<div class="card w-full max-w-md border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-6 sm:p-8">
|
||||||
|
<RouterLink to="/" class="mx-auto select-none" aria-label="Back to home">
|
||||||
|
<img src="@/assets/openteam.webp" alt="OpenTeam logo" width="176" height="128" class="h-28 w-auto" />
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Log in to Dashboard</h1>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert alert-error mt-4 text-sm">
|
||||||
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="flex-1">{{ error }}</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 space-y-4" @submit.prevent="handleLogin">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium" for="username">Account</label>
|
||||||
|
<input type="text" id="username" name="username" autocomplete="username" spellcheck="false"
|
||||||
|
placeholder="username or email" class="input input-bordered w-full"
|
||||||
|
v-model="user.username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium" for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" autocomplete="current-password"
|
||||||
|
placeholder="••••••••" class="input input-bordered w-full"
|
||||||
|
v-model="user.password" minlength="4" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||||
|
<input type="checkbox" name="remember" v-model="user.rember" class="checkbox checkbox-primary checkbox-sm" />
|
||||||
|
Remember me
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full" :disabled="loggingIn">
|
||||||
|
<span v-if="loggingIn" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="divider my-2 text-xs text-base-content/40">OR</div>
|
||||||
|
|
||||||
|
<button type="button" @click="handlePasskeyLogin" class="btn btn-outline w-full" :disabled="!supportWebAuth"
|
||||||
|
:aria-label="supportWebAuth ? 'Sign in with Passkey' : 'Passkey not supported in this browser'">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path fill="currentColor"
|
||||||
|
d="M7 13.23q-.517 0-.874-.356T5.769 12t.357-.874t.874-.357t.874.357t.357.874t-.357.874t-.874.357M7 17q-2.077 0-3.538-1.461T2 12t1.462-3.538T7 7q1.54 0 2.778.835q1.238.834 1.807 2.165h9.204l2 2l-3.193 3.154l-1.712-1.288l-1.807 1.326L14.298 14h-2.713q-.57 1.312-1.807 2.156T7 17m0-1q1.477 0 2.52-.889T10.856 13h3.76l1.43.967l1.858-1.333l1.621 1.222L21.381 12l-1-1h-9.525q-.292-1.223-1.336-2.111T7 8Q5.35 8 4.175 9.175T3 12t1.175 2.825T7 16" />
|
||||||
|
</svg>
|
||||||
|
Sign in with Passkey
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="mt-5 text-center text-sm text-base-content/60">
|
||||||
|
Don't have an account?
|
||||||
|
<RouterLink to="/signup" class="link link-primary link-hover">Sign up</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { CircleAlert } from '@lucide/vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useWebAuthStore } from '@/stores/webauth';
|
||||||
|
import { useToast } from '@/composables/toast';
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const webauthStore = useWebAuthStore();
|
||||||
|
const { setToast } = useToast();
|
||||||
|
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const loggingIn = ref(false)
|
||||||
|
const user = reactive({
|
||||||
|
username: localStorage.getItem('account') || '',
|
||||||
|
password: localStorage.getItem('password') || '',
|
||||||
|
rember: localStorage.getItem('rember') || false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const supportWebAuth = ref(false);
|
||||||
|
onMounted(() => {
|
||||||
|
if (localStorage.getItem('token')) {
|
||||||
|
router.push('/dashboard')
|
||||||
|
}
|
||||||
|
supportWebAuth.value = !!window.PublicKeyCredential;
|
||||||
|
})
|
||||||
|
|
||||||
|
// store 的 catch 里 throw 的是 error ref,这里统一取出可展示的错误信息
|
||||||
|
const errMsg = (err: any): string => {
|
||||||
|
if (typeof err === 'string') return err
|
||||||
|
if (err?.__v_isRef) return errMsg(err.value)
|
||||||
|
return err?.response?.data?.error || err?.message || String(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
error.value = null;
|
||||||
|
loggingIn.value = true;
|
||||||
|
try {
|
||||||
|
const response = await authStore.login({ username: user.username, password: user.password });
|
||||||
|
if (response.status === 200) {
|
||||||
|
if (user.rember) {
|
||||||
|
localStorage.setItem('account', user.username);
|
||||||
|
localStorage.setItem('password', user.password);
|
||||||
|
localStorage.setItem('rember', String(user.rember));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('account');
|
||||||
|
localStorage.removeItem('password');
|
||||||
|
localStorage.removeItem('rember');
|
||||||
|
}
|
||||||
|
setToast('Logged in successfully.', 'success');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Login error:', err);
|
||||||
|
error.value = errMsg(err)
|
||||||
|
} finally {
|
||||||
|
loggingIn.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasskeyLogin = async () => {
|
||||||
|
error.value = null;
|
||||||
|
loggingIn.value = true;
|
||||||
|
try {
|
||||||
|
const res = await webauthStore.loginPasskey();
|
||||||
|
if (!!res?.code && res.code === 200) {
|
||||||
|
setToast('Logged in successfully.', 'success');
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Passkey login error:', err);
|
||||||
|
error.value = errMsg(err)
|
||||||
|
} finally {
|
||||||
|
loggingIn.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen items-center justify-center bg-base-200 p-4">
|
||||||
|
<div class="card w-full max-w-md border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-6 sm:p-8">
|
||||||
|
<RouterLink to="/" class="mx-auto select-none" aria-label="Back to home">
|
||||||
|
<img src="@/assets/openteam.webp" alt="OpenTeam logo" width="176" height="128" class="h-28 w-auto" />
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Create Your Account</h1>
|
||||||
|
|
||||||
|
<div v-if="error" role="alert" class="alert alert-error mt-4 text-sm">
|
||||||
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="flex-1">{{ error }}</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = ''">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-4 space-y-4" @submit.prevent="handleRegister">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium" for="account">Account</label>
|
||||||
|
<input type="text" id="account" name="username" autocomplete="username" spellcheck="false"
|
||||||
|
placeholder="username" class="input input-bordered w-full" v-model="username" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium" for="password">Password</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="new-password"
|
||||||
|
placeholder="At least 4 characters" class="input input-bordered w-full"
|
||||||
|
v-model="password" required minlength="4" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium" for="confirm-password">Confirm Password</label>
|
||||||
|
<input type="password" id="confirm-password" name="confirm-password" autocomplete="new-password"
|
||||||
|
placeholder="Retype your password" class="input input-bordered w-full"
|
||||||
|
v-model="confirmPassword" required />
|
||||||
|
<p v-if="confirmPassword && password !== confirmPassword" class="mt-1.5 text-xs text-error" role="alert">
|
||||||
|
Passwords do not match.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary w-full" :disabled="registering">
|
||||||
|
<span v-if="registering" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-5 text-center text-sm text-base-content/60">
|
||||||
|
Already have an account?
|
||||||
|
<RouterLink to="/login" class="link link-primary link-hover">Log in</RouterLink>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { CircleAlert } from '@lucide/vue'
|
||||||
|
import { useToast } from '@/composables/toast'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const { setToast } = useToast();
|
||||||
|
|
||||||
|
const error = ref('');
|
||||||
|
const registering = ref(false);
|
||||||
|
|
||||||
|
const username = ref('');
|
||||||
|
const password = ref('');
|
||||||
|
const confirmPassword = ref('');
|
||||||
|
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = 'Passwords do not match.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
registering.value = true
|
||||||
|
try {
|
||||||
|
let res = await authStore.register({
|
||||||
|
username: username.value,
|
||||||
|
password: password.value
|
||||||
|
})
|
||||||
|
if (res.status === 200) {
|
||||||
|
setToast(res.data?.msg || 'Account created.', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login');
|
||||||
|
}, 100);
|
||||||
|
error.value = ''
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = 'Registration failed. Please try again.'
|
||||||
|
} finally {
|
||||||
|
registering.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -1,201 +1,160 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="mx-auto w-full max-w-3xl">
|
||||||
<BreadcrumbHeader />
|
<header class="mb-4 pr-8">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||||
|
<p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto">
|
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||||
<div class="mb-6 text-center md:text-left">
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<h1 class="text-2xl font-semibold text-base-content">
|
<span class="flex-1">{{ error }}</span>
|
||||||
Create New API Key
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/70 mt-1">
|
|
||||||
Enter the API key's details below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border border-base-300/40 shadow-sm">
|
|
||||||
<form @submit.prevent="createApiKey" class="card-body space-y-5 p-3 sm:p-8">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
|
||||||
Basic Information
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="name" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Name <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="name" type="text" v-model="newApiKey.name" placeholder="API Key Name"
|
|
||||||
class="input input-sm input-bordered w-full" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="apitype" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Type <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<select id="apitype" v-model="newApiKey.type" class="select select-sm select-bordered w-full pl-10"
|
|
||||||
required>
|
|
||||||
<option class="disabled" value="" selected>Select API Type</option>
|
|
||||||
<option value="openai">OpenAI</option>
|
|
||||||
<option value="claude">Claude</option>
|
|
||||||
<option value="gemini">Gemini</option>
|
|
||||||
<option value="azure">Azure</option>
|
|
||||||
<option value="github">Github</option>
|
|
||||||
<option value="openai-compatible">OpenAI Compatible</option>
|
|
||||||
</select>
|
|
||||||
<button type="button" @click="togglePasswordVisibility" 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"
|
|
||||||
id="password-visibility-toggle">
|
|
||||||
<img :src="apiKeyImageUrl(newApiKey.type)" class="w-5 h-5" alt="">
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="apikey" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
API Key <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="apikey" type="text" v-model="newApiKey.apikey" placeholder="Your API Key"
|
|
||||||
class="input input-sm input-bordered w-full" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="endpoint" class="label">
|
|
||||||
<span class="label-text">Endpoint</span>
|
|
||||||
</label>
|
|
||||||
<input id="endpoint" type="text" v-model="newApiKey.endpoint" placeholder="API Endpoint URL"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse collapse-arrow">
|
|
||||||
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
|
||||||
Advanced Options
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content px-0">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="resource_name" class="label">
|
|
||||||
<span class="label-text">Resource Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="resource_name" type="text" v-model="newApiKey.resource_name" placeholder="Resource Name"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- <div class="form-control">
|
|
||||||
<label for="deployment_name" class="label">
|
|
||||||
<span class="label-text">Deployment Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="deployment_name" type="text" v-model="newApiKey.deployment_name"
|
|
||||||
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="api_secret" class="label">
|
|
||||||
<span class="label-text">API Secret</span>
|
|
||||||
</label>
|
|
||||||
<input id="api_secret" type="text" v-model="newApiKey.api_secret" placeholder="API Secret"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="model_prefix" class="label">
|
|
||||||
<span class="label-text">Model Prefix</span>
|
|
||||||
</label>
|
|
||||||
<input id="model_prefix" type="text" v-model="newApiKey.model_prefix" placeholder="Model Prefix"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="model_alias" class="label">
|
|
||||||
<span class="label-text">Model Alias</span>
|
|
||||||
</label>
|
|
||||||
<textarea id="model_alias" v-model="newApiKey.model_alias" placeholder="{}"
|
|
||||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="parameters" class="label">
|
|
||||||
<span class="label-text">Parameters (JSON)</span>
|
|
||||||
</label>
|
|
||||||
<textarea id="parameters" v-model="newApiKey.parameters" placeholder="{}"
|
|
||||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control">
|
|
||||||
<label for="support_models" class="label">
|
|
||||||
<span class="label-text">Support Models</span>
|
|
||||||
</label>
|
|
||||||
<!-- <textarea id="support_models" v-model="newApiKey.support_models_text"
|
|
||||||
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
|
||||||
<el-input-tag v-model="newApiKey.support_models_array" :trigger="'Enter'" clearable
|
|
||||||
placeholder="Please input" @change="onchange_supportmodel" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Status</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3 h-9">
|
|
||||||
<input type="checkbox" v-model="newApiKey.active"
|
|
||||||
:class="`toggle toggle-sm ${newApiKey.active ? 'toggle-success' : 'toggle-error'}`" />
|
|
||||||
<span class="text-sm text-base-content/90">
|
|
||||||
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span>{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm" @click="error = null">X</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end pt-4 items-center gap-2">
|
|
||||||
<button @click="cancel" class="btn btn-sm btn-outline">Cancel</button>
|
|
||||||
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success"
|
|
||||||
:disabled="!isFormValid">
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createApiKey">
|
||||||
|
<div class="card-body gap-5 p-4 sm:p-6">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||||
|
Name <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="key-name" name="name" type="text" v-model="newApiKey.name" placeholder="e.g. prod-openai"
|
||||||
|
autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||||
|
Type <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<select id="key-type" name="type" v-model="newApiKey.type" required
|
||||||
|
class="select select-bordered w-full pl-10">
|
||||||
|
<option disabled value="">Select provider</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="claude">Claude</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="azure">Azure</option>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="openai-compatible">OpenAI Compatible</option>
|
||||||
|
</select>
|
||||||
|
<img :src="apiKeyImageUrl(newApiKey.type)" alt="" width="20" height="20"
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||||
|
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey"
|
||||||
|
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||||
|
class="input input-bordered w-full font-mono text-sm" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||||
|
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint"
|
||||||
|
placeholder="https://api.example.com/v1" spellcheck="false"
|
||||||
|
class="input input-bordered w-full font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-3">
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||||
|
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name"
|
||||||
|
placeholder="Azure resource name" autocomplete="off" spellcheck="false"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||||
|
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret"
|
||||||
|
placeholder="Optional secret" autocomplete="off" spellcheck="false"
|
||||||
|
class="input input-bordered w-full font-mono text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||||
|
<input id="key-prefix" name="model_prefix" type="text" v-model="newApiKey.model_prefix"
|
||||||
|
placeholder="e.g. azure-gpt" autocomplete="off" spellcheck="false"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||||
|
<textarea id="key-alias" name="model_alias" v-model="newApiKey.model_alias" rows="2"
|
||||||
|
placeholder='{"gpt-4o": "my-gpt4o"}' spellcheck="false"
|
||||||
|
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||||
|
<textarea id="key-parameters" name="parameters" v-model="newApiKey.parameters" rows="2"
|
||||||
|
placeholder="{}" spellcheck="false"
|
||||||
|
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="block">
|
||||||
|
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||||
|
<TagInput v-model="newApiKey.support_models_array" clearable
|
||||||
|
placeholder="Type a model and press Enter" @change="onchange_supportmodel" />
|
||||||
|
<span class="mt-1 block text-xs text-base-content/50">Restrict which models this key can serve. Empty allows all.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||||
|
<div class="flex h-9 items-center gap-3">
|
||||||
|
<input type="checkbox" name="active" v-model="newApiKey.active" role="switch"
|
||||||
|
class="toggle toggle-sm" :class="newApiKey.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
aria-label="Key active" />
|
||||||
|
<span class="text-sm text-base-content/70">
|
||||||
|
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||||
|
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Create API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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 { CircleAlert } from '@lucide/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)
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
// 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 +204,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 +213,7 @@ const apiKeyImageMap = {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const apiKeyImageUrl = (keytype) => {
|
const apiKeyImageUrl = (keytype: string) => {
|
||||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,6 +223,7 @@ const createApiKey = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||||
@@ -277,7 +237,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;
|
||||||
@@ -287,29 +247,22 @@ const createApiKey = async () => {
|
|||||||
if (res.data?.code === 200) {
|
if (res.data?.code === 200) {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
resetNewApiKey();
|
resetNewApiKey();
|
||||||
setToast('API Key created successfully', 'success')
|
setToast('API Key created successfully.', 'success')
|
||||||
// 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')
|
} finally {
|
||||||
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const emit = defineEmits(['closeModal'])
|
const emit = defineEmits<{
|
||||||
|
(e: 'closeModal', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Minimal custom styles if absolutely necessary */
|
|
||||||
.collapse .collapse-title {
|
|
||||||
min-height: 0;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,187 +1,159 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="space-y-5">
|
||||||
<BreadcrumbHeader title="Key Details" />
|
<BreadcrumbHeader title="渠道详情" />
|
||||||
|
|
||||||
<div class="divider mt-1 mb-0"></div>
|
<div v-if="key" class="space-y-5">
|
||||||
<div class="card border border-base-300/40 shadow-sm"v-if="key" >
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<form @submit.prevent="updateKey" class="card-body space-y-5 p-3 sm:p-8">
|
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
|
||||||
<div class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
|
||||||
Basic Information
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
</h2>
|
<div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||||
<div class="form-control">
|
Name <span class="text-error" aria-hidden="true">*</span>
|
||||||
<label for="name" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Name <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input id="name" type="text" v-model="key.name" placeholder="API Key Name"
|
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
|
||||||
class="input input-sm input-bordered w-full" required />
|
class="input input-bordered w-full" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="apitype" class="label">
|
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||||
<span class="label-text">
|
Type <span class="text-error" aria-hidden="true">*</span>
|
||||||
Type <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select id="apitype" v-model="key.type" class="select select-sm select-bordered w-full pl-10"
|
<select id="key-type" name="type" v-model="key.type" required
|
||||||
required>
|
class="select select-bordered w-full pl-10">
|
||||||
<option class="disabled" value="" selected>Select API Type</option>
|
<option disabled value="">Select provider</option>
|
||||||
<option value="openai">OpenAI</option>
|
<option value="openai">OpenAI</option>
|
||||||
<option value="claude">Claude</option>
|
<option value="claude">Claude</option>
|
||||||
<option value="gemini">Gemini</option>
|
<option value="gemini">Gemini</option>
|
||||||
<option value="azure">Azure</option>
|
<option value="azure">Azure</option>
|
||||||
<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" 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"
|
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
|
||||||
id="password-visibility-toggle">
|
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||||
<img :src="apiKeyImageUrl(key.type)" class="w-5 h-5" alt="">
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="apikey" class="label">
|
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||||
<span class="label-text">
|
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||||
API Key <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<input id="apikey" type="text" v-model="key.apikey" placeholder="Your API Key"
|
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
|
||||||
class="input input-sm input-bordered w-full" required />
|
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="endpoint" class="label">
|
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||||
<span class="label-text">Endpoint</span>
|
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
|
||||||
</label>
|
class="input input-bordered w-full font-mono text-sm" />
|
||||||
<input id="endpoint" type="text" v-model="key.endpoint" placeholder="API Endpoint URL"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="collapse collapse-arrow">
|
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||||
<input type="checkbox" checked class="min-h-0 py-2" />
|
<input type="checkbox" checked class="min-h-0 py-2" />
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||||
Advanced Options
|
Advanced Options
|
||||||
</div>
|
</div>
|
||||||
<div class="collapse-content px-0">
|
<div class="collapse-content px-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="resource_name" class="label">
|
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||||
<span class="label-text">Resource Name</span>
|
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
|
||||||
</label>
|
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
|
||||||
<input id="resource_name" type="text" v-model="key.resource_name" placeholder="Resource Name"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <div class="form-control">
|
<div>
|
||||||
<label for="deployment_name" class="label">
|
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||||
<span class="label-text">Deployment Name</span>
|
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
|
||||||
</label>
|
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
|
||||||
<input id="deployment_name" type="text" v-model="key.deployment_name"
|
|
||||||
placeholder="Deployment Name" class="input input-sm input-bordered w-full" />
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="api_secret" class="label">
|
|
||||||
<span class="label-text">API Secret</span>
|
|
||||||
</label>
|
|
||||||
<input id="api_secret" type="text" v-model="key.api_secret" placeholder="API Secret"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="model_prefix" class="label">
|
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||||
<span class="label-text">Model Prefix</span>
|
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
|
||||||
</label>
|
spellcheck="false" class="input input-bordered w-full" />
|
||||||
<input id="model_prefix" type="text" v-model="key.model_prefix" placeholder="Model Prefix"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="model_alias" class="label">
|
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||||
<span class="label-text">Model Alias</span>
|
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
|
||||||
</label>
|
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||||
<textarea id="model_alias" v-model="key.model_alias" placeholder="{}"
|
|
||||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label for="parameters" class="label">
|
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||||
<span class="label-text">Parameters (JSON)</span>
|
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
|
||||||
</label>
|
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||||
<textarea id="parameters" v-model="key.parameters" placeholder="{}"
|
|
||||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control">
|
<label class="block">
|
||||||
<label for="support_models" class="label">
|
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||||
<span class="label-text">Support Models</span>
|
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
|
||||||
</label>
|
@change="onchange_supportmodel" />
|
||||||
<!-- <textarea id="support_models" v-model="key.support_models_text"
|
</label>
|
||||||
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
|
||||||
<el-input-tag v-model="key.support_models_array" :trigger="'Enter'" clearable
|
|
||||||
placeholder="Please input" @change="onchange_supportmodel"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
<div>
|
||||||
<label class="label">
|
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||||
<span class="label-text">Status</span>
|
<div class="flex h-9 items-center gap-3">
|
||||||
</label>
|
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
|
||||||
<div class="flex items-center space-x-3 h-9">
|
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
|
||||||
<input type="checkbox" v-model="key.active"
|
<span class="text-sm text-base-content/70">
|
||||||
:class="`toggle toggle-sm ${key.active ? 'toggle-success' : 'toggle-error'}`" />
|
|
||||||
<span class="text-sm text-base-content/90">
|
|
||||||
{{ key.active ? 'Active' : 'Inactive' }}
|
{{ key.active ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4 items-center gap-2">
|
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||||
<button @click="cancel" class="btn btn-sm btn-outline">Back</button>
|
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
|
||||||
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success">
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||||
Update
|
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, inject, reactive } from 'vue';
|
import { computed, onMounted, reactive, ref } 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 updating = ref(false);
|
||||||
|
|
||||||
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 +166,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 +174,18 @@ 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;
|
||||||
|
updating.value = true;
|
||||||
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,8 +193,10 @@ 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);
|
||||||
|
} finally {
|
||||||
|
updating.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -228,4 +204,4 @@ const cancel = () => {
|
|||||||
router.push({ name: 'ApiKey' });
|
router.push({ name: 'ApiKey' });
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,174 +1,156 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-4">
|
<div class="space-y-5">
|
||||||
<!-- Breadcrumb and Title -->
|
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
|
<p class="text-sm text-base-content/60">Upstream provider keys used to serve model requests.</p>
|
||||||
<div class="flex flex-1 items-center space-x-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<div class="dropdown dropdown-end">
|
||||||
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
|
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||||
placeholder="Filter" value="">
|
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<div class="dropdown">
|
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||||
<label tabindex="0"
|
<li>
|
||||||
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
|
<button class="text-error" @click="handleBatchAction('delete')">
|
||||||
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
|
||||||
<path fill="currentColor" fill-rule="evenodd"
|
</button>
|
||||||
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
|
</li>
|
||||||
clip-rule="evenodd"></path>
|
<li>
|
||||||
</svg>
|
<button @click="handleBatchAction('disable')">
|
||||||
Status
|
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
|
||||||
</label>
|
</button>
|
||||||
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
</li>
|
||||||
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
<li>
|
||||||
<a class="px-2 mx-0 hover:rounded-none">
|
<button @click="handleBatchAction('enable')">
|
||||||
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
|
||||||
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
</button>
|
||||||
{{ status }}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 items-center justify-end space-x-2">
|
|
||||||
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
|
||||||
<PlusIcon class="w-3.5 h-3.5" />New
|
|
||||||
</button>
|
|
||||||
<dialog id="myModal" class="modal" ref="modalRef">
|
|
||||||
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
|
||||||
</form>
|
|
||||||
<KeyNew @closeModal="closeModal" />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button>关闭</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dropdown dropdown-end dropdown-hover">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
|
|
||||||
<Settings2Icon class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
|
||||||
@click="handleBatchAction('delete')">
|
|
||||||
<TrashIcon class="w-4 h-4" />删除
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<hr class="-mx-2 my-1 border-base-content/10">
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
|
||||||
@click="handleBatchAction('disable')">
|
|
||||||
<BadgeXIcon class="w-4 h-4" />禁用
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
|
|
||||||
@click="handleBatchAction('enable')">
|
|
||||||
<BadgeCheckIcon class="w-4 h-4" />启用
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter -->
|
||||||
|
<details class="dropdown dropdown-end" v-if="keys.length">
|
||||||
|
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||||
|
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Status
|
||||||
|
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||||
|
<li v-for="status in statusOptions" :key="status">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs" :checked="selectedStatuses.some(item => item.status === status)"
|
||||||
|
@change="toggleStatusFilter(status)" />
|
||||||
|
{{ status }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<table class="table table-sm">
|
<div class="overflow-x-auto" v-if="keys.length">
|
||||||
<!-- Table Header -->
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th>
|
<th class="pl-4">
|
||||||
<div class="flex gap-1 items-center">
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
aria-label="Select all keys" />
|
||||||
</div>
|
</th>
|
||||||
</th>
|
<th>Type</th>
|
||||||
<th>Type</th>
|
<th>Name</th>
|
||||||
<th>Name</th>
|
<th>Active</th>
|
||||||
<th>Active</th>
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
<!-- <th>Key</th> -->
|
</tr>
|
||||||
<!-- <th>Endpoint</th> -->
|
</thead>
|
||||||
<th></th>
|
<tbody>
|
||||||
</tr>
|
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
</thead>
|
<td class="pl-4">
|
||||||
<!-- Table Body -->
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="key in keys" :key="key.id"
|
|
||||||
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
|
|
||||||
<td>
|
|
||||||
<div class="flex gap-1 items-center">
|
|
||||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
||||||
@change="toggleUserSelection(key)" />
|
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" />
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td>
|
||||||
<td class="text-xs dark:text-white">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex gap-1 items-center">
|
<img :src="displayIcon(key.type)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||||
<span class="backdrop-blur-lg glass rounded-full border-none"> <img :src="displayIcon(key.type)"
|
<span class="text-sm capitalize">{{ key.type }}</span>
|
||||||
class="w-5 h-5" alt=""></span>
|
</div>
|
||||||
{{ key.type }}
|
</td>
|
||||||
</div>
|
<td class="max-w-40 truncate font-medium">{{ key.name }}</td>
|
||||||
</td>
|
<td>
|
||||||
<td class="text-xs dark:text-white">{{ key.name }}</td>
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||||
<td>
|
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)"
|
||||||
<div class="flex gap-1">
|
:aria-label="`Toggle key ${key.name}`" />
|
||||||
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
|
</td>
|
||||||
v-model="key.active" @change="updateStatus(key)" />
|
<td class="pr-3">
|
||||||
</div>
|
<div class="flex items-center justify-end gap-1">
|
||||||
</td>
|
<button class="btn btn-ghost btn-xs btn-square" @click="viewKey(key)" aria-label="View key details">
|
||||||
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
|
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
|
</button>
|
||||||
<td>
|
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)"
|
||||||
<div class="flex gap-2">
|
aria-label="Delete key">
|
||||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
|
|
||||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
|
</td>
|
||||||
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
</tr>
|
||||||
@click="confirmDeleteKey(key)">
|
</tbody>
|
||||||
<TrashIcon class="w-4 h-4 dark:text-white" />
|
</table>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Empty state -->
|
||||||
</td>
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||||
</tr>
|
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||||
</tbody>
|
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||||
</table>
|
<p class="max-w-xs text-sm text-base-content/60">
|
||||||
|
Add an OpenAI, Claude, Gemini or compatible provider key to start serving requests.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
|
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||||
|
|
||||||
|
<!-- New key modal -->
|
||||||
|
<dialog ref="modalRef" class="modal">
|
||||||
|
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||||
|
</form>
|
||||||
|
<KeyNew @closeModal="closeModal" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close dialog">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</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, KeyRoundIcon, ListFilterIcon
|
||||||
} 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 +166,13 @@ 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));
|
await keyStore.fetchKeys(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
|
||||||
await keyStore.fetchKeys(size, page, active);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件挂载时加载用户数据
|
|
||||||
// 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 +181,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 +193,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 +201,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 +212,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 +228,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 +247,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 +263,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 +288,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 +313,10 @@ const displayIcon = (apitype) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
const modalRef = ref(null);
|
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||||
|
const openModal = () => {
|
||||||
|
modalRef.value?.showModal();
|
||||||
|
};
|
||||||
const closeModal = async () => {
|
const closeModal = async () => {
|
||||||
if (modalRef.value) {
|
if (modalRef.value) {
|
||||||
modalRef.value.close();
|
modalRef.value.close();
|
||||||
|
|||||||
@@ -1,156 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-3 md:p-6">
|
<div class="space-y-6">
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<div class="card shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl mb-6" >
|
<!-- Welcome hero(随时间段切换配色) -->
|
||||||
<div :class="['card-body text-white', getGradientClass()]">
|
<section
|
||||||
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
class="relative overflow-hidden rounded-2xl bg-gradient-to-br text-white shadow-sm"
|
||||||
<div class="avatar">
|
:class="getTimeTheme()">
|
||||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
|
<div class="flex flex-col items-center gap-4 p-6 text-center sm:flex-row sm:p-8 sm:text-left">
|
||||||
v-if="!user?.avatar_url">
|
<div class="placeholder avatar">
|
||||||
<div
|
<div
|
||||||
class="bg-white text-primary-content font-bold flex items-center justify-center w-full h-full">
|
class="flex w-16 items-center justify-center rounded-full bg-white/15 ring-2 ring-white/25 sm:w-20">
|
||||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
<span v-if="!user?.avatar_url" class="text-2xl font-bold sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
</div>
|
<img v-else :src="user?.avatar_url" :alt="user?.name || user?.username || 'Avatar'"
|
||||||
</div>
|
class="h-full w-full object-cover" />
|
||||||
<div class="w-16 h-16 sm:w-20 sm:h-20 rounded-full ring ring-white ring-offset-base-100 ring-offset-2"
|
|
||||||
v-else>
|
|
||||||
<img :src="user?.avatar_url" :alt="user?.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="text-center sm:text-left">
|
|
||||||
<h1 class="text-2xl sm:text-4xl font-extrabold tracking-tight">
|
|
||||||
<span class="mr-2">👋 </span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-white/80 text-base sm:text-lg mt-1 font-medium">欢迎回到您的个人仪表盘</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<h2 class="text-xl font-bold tracking-tight text-balance sm:text-2xl">
|
||||||
|
<span class="mr-1.5" aria-hidden="true">👋</span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 lg:gap-8">
|
|
||||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
|
||||||
<div class="card-body p-4 md:p-6">
|
|
||||||
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
||||||
</svg>
|
|
||||||
基本信息
|
|
||||||
</h2>
|
</h2>
|
||||||
<div class="divider my-1"></div>
|
<p class="mt-1 text-sm text-white/80">欢迎回到您的个人仪表盘</p>
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-base-content/70 w-20">用户名</span>
|
|
||||||
<span class="badge">{{ user?.username }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-base-content/70 w-20">显示名称</span>
|
|
||||||
<span class="badge">{{ user?.name || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-base-content/70 w-20">邮箱</span>
|
|
||||||
<span class="badge truncate max-w-full">{{ user?.email || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-base-content/70 w-20">角色</span>
|
|
||||||
<span class="badge" :class="user?.role > 0 ? 'badge-warning' : 'badge-ghost'">{{
|
|
||||||
getRoleName(user?.role || 0) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div class="card-body p-4 md:p-6">
|
<!-- 基本信息 -->
|
||||||
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
<div class="card-body p-5">
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<h3 class="card-title pb-1 text-base font-semibold">基本信息</h3>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<dl class="divide-y divide-base-300/40 text-sm">
|
||||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
</svg>
|
<dt class="shrink-0 text-base-content/60">用户名</dt>
|
||||||
账户状态
|
<dd class="truncate font-medium">{{ user?.username }}</dd>
|
||||||
</h2>
|
|
||||||
<div class="divider my-1"></div>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-base-content/70 w-20">状态</span>
|
|
||||||
<span :class="[
|
|
||||||
'badge badge-outline',
|
|
||||||
user?.active ? 'badge-success' : 'badge-error'
|
|
||||||
]">
|
|
||||||
{{ user?.active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
<span class="font-semibold text-base-content/70 w-20">时区</span>
|
<dt class="shrink-0 text-base-content/60">显示名称</dt>
|
||||||
<span class="badge ">{{ user?.timezone || 'UTC' }}</span>
|
<dd class="truncate font-medium">{{ user?.name || '—' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
<span class="font-semibold text-base-content/70 w-20">语言</span>
|
<dt class="shrink-0 text-base-content/60">邮箱</dt>
|
||||||
<span class="badge">{{ user?.language || 'en' }}</span>
|
<dd class="truncate font-medium">{{ user?.email || '—' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
<span class="font-semibold text-base-content/70 w-20">最后活动</span>
|
<dt class="shrink-0 text-base-content/60">角色</dt>
|
||||||
<span class="badge">{{ getLastActive() }}</span>
|
<dd>
|
||||||
|
<span class="badge badge-sm"
|
||||||
|
:class="(user?.role || 0) > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">{{
|
||||||
|
getRoleName(user?.role || 0) }}</span>
|
||||||
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300 border border-base-200">
|
<!-- 账户状态 -->
|
||||||
<div class="card-body p-4 md:p-6">
|
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<h2 class="card-title text-lg md:text-xl font-bold flex items-center gap-2">
|
<div class="card-body p-5">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 md:h-6 md:w-6 text-base-content"
|
<h3 class="card-title pb-1 text-base font-semibold">账户状态</h3>
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<dl class="divide-y divide-base-300/40 text-sm">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
<dt class="shrink-0 text-base-content/60">状态</dt>
|
||||||
</svg>
|
<dd>
|
||||||
配额信息
|
<span class="badge badge-sm"
|
||||||
</h2>
|
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||||
<div class="divider my-1"></div>
|
{{ user?.active ? 'Active' : 'Inactive' }}
|
||||||
<div class="space-y-4">
|
</span>
|
||||||
<div v-if="user?.unlimited_quota">
|
</dd>
|
||||||
<div class="flex flex-wrap justify-between mb-2">
|
|
||||||
<span class="font-semibold text-base-content/70">使用量</span>
|
|
||||||
<span class="badge badge-lg text-success">无限制</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
<div class="flex flex-wrap justify-between mb-2">
|
<dt class="shrink-0 text-base-content/60">时区</dt>
|
||||||
<span class="font-semibold text-base-content/70">使用量</span>
|
<dd class="font-medium">{{ user?.timezone || 'UTC' }}</dd>
|
||||||
<span :class="getQuotaColor">
|
</div>
|
||||||
{{ formatQuota(user?.used_quota || 0, user?.quota||0) }}
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<dt class="shrink-0 text-base-content/60">语言</dt>
|
||||||
|
<dd class="font-medium uppercase">{{ user?.language || 'en' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||||
|
<dt class="shrink-0 text-base-content/60">最后活动</dt>
|
||||||
|
<dd class="font-medium tabular-nums">{{ getLastActive() }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<!-- 配额信息 -->
|
||||||
|
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-5">
|
||||||
|
<h3 class="card-title pb-1 text-base font-semibold">配额信息</h3>
|
||||||
|
<div class="flex-1 space-y-4 pt-1">
|
||||||
|
<div v-if="user?.unlimited_quota" class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-base-content/60">使用量</span>
|
||||||
|
<span class="badge badge-success badge-soft">无限制</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2 text-sm">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-base-content/60">使用量</span>
|
||||||
|
<span class="font-medium tabular-nums" :class="getQuotaColor">
|
||||||
|
{{ formatQuota(user?.used_quota || 0, user?.quota || 0) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<progress class="progress w-full"
|
<progress class="progress w-full" :class="getQuotaColorClass" :value="quotaPercentage"
|
||||||
:class="getQuotaColorClass"
|
max="100" :aria-label="`Quota used ${quotaPercentage.toFixed(1)}%`"></progress>
|
||||||
:value="quotaPercentage"
|
|
||||||
max="100"></progress>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats stats-vertical shadow w-full bg-base-100">
|
<div class="grid grid-cols-2 gap-3 border-t border-base-300/40 pt-3">
|
||||||
<div class="stat p-2 md:p-4">
|
<div>
|
||||||
<div class="stat-title text-xs md:text-sm">创建时间</div>
|
<p class="text-xs text-base-content/50">创建时间</p>
|
||||||
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.created_at) }}
|
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.created_at) }}</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat p-2 md:p-4">
|
<div>
|
||||||
<div class="stat-title text-xs md:text-sm">更新时间</div>
|
<p class="text-xs text-base-content/50">更新时间</p>
|
||||||
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.updated_at) }}
|
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.updated_at) }}</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</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 +139,28 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
const getTimeOfDay = () => {
|
const getTimeOfDay = (): string => {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 6) return '夜深了';
|
||||||
if (hour < 12) return '早上好';
|
if (hour < 12) return '早上好';
|
||||||
if (hour < 18) return '下午好';
|
if (hour < 18) return '下午好';
|
||||||
return '晚上好';
|
return '晚上好';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatQuota = (used, total) => {
|
// 横幅随时间段切换配色:白色文字位于渐变左侧深色端,保证对比度
|
||||||
|
const getTimeTheme = (): string => {
|
||||||
|
const hour = new Date().getHours();
|
||||||
|
if (hour < 6) return 'from-slate-900 via-slate-800 to-indigo-950'; // 深夜
|
||||||
|
if (hour < 12) return 'from-sky-600 via-sky-500 to-amber-400'; // 早晨
|
||||||
|
if (hour < 18) return 'from-blue-600 via-sky-500 to-cyan-400'; // 白天
|
||||||
|
return 'from-slate-900 via-indigo-950 to-slate-800'; // 夜晚
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatQuota = (used: number, total: number): string => {
|
||||||
if (total === 0) return '无限制';
|
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 +170,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 +179,7 @@ const getRoleName = (role) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 格式化日期时间
|
// 格式化日期时间
|
||||||
function formatDateTime(unixTimestamp) {
|
function formatDateTime(unixTimestamp?: number): string {
|
||||||
// 如果时间戳不存在或为0,返回'未知'
|
// 如果时间戳不存在或为0,返回'未知'
|
||||||
if (!unixTimestamp) return '未知';
|
if (!unixTimestamp) return '未知';
|
||||||
|
|
||||||
@@ -215,21 +197,12 @@ function formatDateTime(unixTimestamp) {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取背景渐变类
|
|
||||||
const getGradientClass = () => {
|
|
||||||
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 < 12) return 'bg-gradient-to-r from-[#8cc7f1] to-[#cf6f26] bg-opacity-50 backdrop-blur-lg'; // 早晨:温暖的杏仁色渐变到深灰
|
|
||||||
if (hour < 18) return 'bg-gradient-to-r from-[#e3f2fd] to-[#ad4212] bg-opacity-50 backdrop-blur-lg'; // 下午:清新的天空蓝渐变到近黑
|
|
||||||
return 'bg-gradient-to-r from-[#000000] to-[#434343] bg-opacity-50 backdrop-blur-lg'; // 夜晚:纯黑到深灰
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 计算配额百分比
|
// 计算配额百分比
|
||||||
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 +222,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));
|
||||||
@@ -269,23 +242,3 @@ const getLastActive = () => {
|
|||||||
return '刚刚';
|
return '刚刚';
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 添加一些过渡动画 */
|
|
||||||
.card {
|
|
||||||
transform-origin: center;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 确保响应式设计中文本不会溢出 */
|
|
||||||
.badge {
|
|
||||||
white-space: normal;
|
|
||||||
height: auto;
|
|
||||||
min-height: 1.6rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,192 +1,148 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="mx-auto max-w-3xl space-y-5">
|
||||||
<BreadcrumbHeader title="User Details" />
|
<BreadcrumbHeader title="账户设置" />
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto space-y-4">
|
<div v-if="user" class="space-y-5">
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<!-- Basic Information -->
|
||||||
<div class="card-body px-2 sm:px-8">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
<div class="card-body gap-5 p-4 sm:p-6">
|
||||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
<section class="space-y-4">
|
||||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
<div class="flex items-center gap-4">
|
||||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||||
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
|
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="avatar" v-else>
|
||||||
<div class="avatar" v-else>
|
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||||
<img :src="user?.avatar_url" :alt="user?.name" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="min-w-0">
|
||||||
|
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name }}</h2>
|
||||||
<div class="flex-grow">
|
<p class="truncate text-sm text-base-content/60">{{ user?.username }}</p>
|
||||||
<h2 class="text-xl font-semibold text-base-content">
|
|
||||||
{{ user?.name }}
|
|
||||||
</h2>
|
|
||||||
<p class="text-sm text-base-content/80">{{ user?.username }}</p>
|
|
||||||
<div class="flex items-center gap-2 mt-2">
|
|
||||||
<span class="badge badge-outline"
|
|
||||||
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
|
||||||
formatRole(user?.role) }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
|
||||||
|
<span class="badge badge-sm"
|
||||||
<div class="text-right sm:text-left">
|
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-medium text-base-content/90">Status:</span>
|
|
||||||
<span class="badge badge-outline"
|
|
||||||
:class="user.active ? 'badge-success' : 'badge-error'">
|
|
||||||
{{ user.active ? 'Active' : 'Inactive' }}
|
{{ user.active ? 'Active' : 'Inactive' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span class="flex items-center gap-1 text-base-content/60">
|
||||||
<div class="mt-2">
|
Quota:
|
||||||
<span class="font-medium text-base-content/90">Quota:</span>
|
<template v-if="user.unlimited_quota">
|
||||||
<template v-if="user.unlimited_quota">
|
<Infinity class="inline h-4 w-4" aria-label="Unlimited" />
|
||||||
<Infinity class="inline-block w-9 text-base-content/70" />
|
<span class="tabular-nums">({{ user.used_quota }} used)</span>
|
||||||
<span class="text-sm text-base-content/70">({{ user.used_quota }} used)</span>
|
</template>
|
||||||
</template>
|
<span v-else class="tabular-nums">{{ user.used_quota }} / {{ user.quota }}</span>
|
||||||
<template v-else>
|
</span>
|
||||||
<span class="text-sm text-base-content/70">{{ user.used_quota }} / {{ user.quota
|
|
||||||
}}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 border-t border-base-300/40 pt-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
|
|
||||||
|
<div v-if="basicinfo_error" role="alert" class="alert alert-error text-sm">
|
||||||
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="flex-1">{{ basicinfo_error }}</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="basicinfo_error = null">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="pf-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||||
|
<input id="pf-name" name="name" type="text" v-model="basicinfo.name" placeholder="Full name"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="pf-username" class="mb-1 block text-sm font-medium">
|
||||||
|
Username <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="pf-username" name="username" type="text" v-model="basicinfo.username"
|
||||||
|
spellcheck="false" autocomplete="off" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="pf-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="pf-email" name="email" type="email" v-model="basicinfo.email"
|
||||||
|
placeholder="name@example.com" spellcheck="false"
|
||||||
|
class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
aria-label="Email verification status">
|
||||||
|
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||||
|
<span v-else class="tooltip tooltip-left" data-tip="Send verification email">
|
||||||
|
<Send class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="savingBasic">
|
||||||
|
<span v-if="savingBasic" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Update Information
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body gap-5 p-4 sm:p-6">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Password</h3>
|
||||||
|
|
||||||
|
<div v-if="password_error" role="alert" class="alert alert-error text-sm">
|
||||||
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
|
<span class="flex-1">{{ password_error }}</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="password_error = null">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider mt-4 mb-0"></div>
|
<form @submit.prevent="updatePassword" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-3">
|
||||||
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 mt-4">
|
|
||||||
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
|
||||||
<Bookmark class="h-5 w-5 text-base-content/80" /> Basic Information
|
|
||||||
</h3>
|
|
||||||
<div v-if="basicinfo_error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<span>{{ basicinfo_error }}</span>
|
<label for="pf-old-password" class="mb-1 block text-sm font-medium">Old Password</label>
|
||||||
|
<input id="pf-old-password" name="old_password" type="password" autocomplete="current-password"
|
||||||
|
v-model="passwordData.oldPassword" placeholder="Current password"
|
||||||
|
class="input input-bordered w-full" />
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm" @click="basicinfo_error = null">X</button>
|
<div>
|
||||||
</div>
|
<label for="pf-new-password" class="mb-1 block text-sm font-medium">New Password</label>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="name" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="name" type="text" v-model="basicinfo.name" placeholder="Full name"
|
|
||||||
class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="username" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">
|
|
||||||
Username <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="username" type="text" v-model="basicinfo.username"
|
|
||||||
placeholder="Select a username" class="input input-bordered input-sm w-full"
|
|
||||||
required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="email" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Email
|
|
||||||
Address</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input id="email" type="email" v-model="basicinfo.email"
|
<input id="pf-new-password" name="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
|
||||||
placeholder="email@example.com" class="input input-bordered input-sm w-full" />
|
v-model="passwordData.newPassword" placeholder="At least 4 characters" autocomplete="new-password"
|
||||||
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
class="input input-bordered w-full pr-10" />
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
|
||||||
aria-label="Toggle email verification">
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
<BadgeCheck v-if="user.email_verified"
|
:aria-label="isNewPasswordVisible ? 'Hide new password' : 'Show new password'">
|
||||||
class="w-4 h-4 bg-green-300 rounded-full" />
|
<EyeOff v-if="!isNewPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
<Send class="w-4 h-4" />
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="pf-confirm-password" class="mb-1 block text-sm font-medium">Confirm New Password</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="pf-confirm-password" name="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="passwordData.confirmPassword" placeholder="Retype new password" autocomplete="new-password"
|
||||||
|
class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
:aria-label="isConfirmPasswordVisible ? 'Hide confirmed password' : 'Show confirmed password'">
|
||||||
|
<EyeOff v-if="!isConfirmPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions justify-end pt-4">
|
|
||||||
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
|
||||||
Update Information
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="max-w-3xl mx-auto">
|
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
|
|
||||||
<div class="flex justify-center items-center py-10">
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<div class="flex justify-end">
|
||||||
<div class="card-body space-y-4 space-y-4 px-2 sm:px-8">
|
<button type="submit" class="btn btn-outline btn-warning btn-sm px-5" :disabled="changingPassword">
|
||||||
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
<span v-if="changingPassword" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
<Bookmark class="h-5 w-5 text-base-content/80" /> Password
|
|
||||||
</h3>
|
|
||||||
<div v-if="password_error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none"
|
|
||||||
viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span>{{ password_error }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm" @click="password_error = null">X</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form @submit.prevent="updatePassword" class="space-y-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="old_password" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Old Password</span>
|
|
||||||
</label>
|
|
||||||
<input id="old_password" type="password" v-model="passwordData.oldPassword"
|
|
||||||
placeholder="Enter current password" class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="new_password" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">New Password</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
|
|
||||||
v-model="passwordData.newPassword" placeholder="Enter new password"
|
|
||||||
class="input input-bordered input-sm w-full pr-10" />
|
|
||||||
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle new password visibility">
|
|
||||||
<EyeOff v-if="!isNewPasswordVisible" class="w-4 h-4" />
|
|
||||||
<Eye v-else class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="confirm_password" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Confirm New
|
|
||||||
Password</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
|
|
||||||
v-model="passwordData.confirmPassword" placeholder="Confirm new password"
|
|
||||||
class="input input-bordered input-sm w-full pr-10" />
|
|
||||||
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle confirm password visibility">
|
|
||||||
<EyeOff v-if="!isConfirmPasswordVisible" class="w-4 h-4" />
|
|
||||||
<Eye v-else class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-actions justify-end pt-4">
|
|
||||||
<button type="submit" class="btn btn-outline btn-warning btn-sm">
|
|
||||||
Change Password
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -194,76 +150,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<!-- Passkeys -->
|
||||||
<div class="card-body space-y-4 px-2 sm:px-8">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
<div class="card-body gap-4 p-4 sm:p-6">
|
||||||
<Bookmark class="h-5 w-5 text-base-content/80" /> Passkeys
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
</h3>
|
<div>
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Passkeys</h3>
|
||||||
<p class="text-sm text-base-content/80">Manage your passkeys for secure and passwordless login.</p>
|
<p class="mt-1 text-sm text-base-content/60">Sign in securely without a password.</p>
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
|
||||||
<div class="card-body px-2 sm:px-8">
|
|
||||||
<div v-if="passkeys" class="overflow-x-auto -mx-6">
|
|
||||||
<table class="table table-sm w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
|
||||||
<th class="px-2 py-3">Name</th>
|
|
||||||
<th class="px-2 py-3">Create Time</th>
|
|
||||||
<th class="px-2 py-3">SignCount</th>
|
|
||||||
<th class="px-2 py-3">Remark</th>
|
|
||||||
<th class="text-right px-2 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="passkey in passkeys" :key="passkey.id" class="hover">
|
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ passkey.name }}</td>
|
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ formatDateTime(passkey.created_at) }}</td>
|
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ passkey.sign_count }}</td>
|
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ passkey.device_type }}</td>
|
|
||||||
<td class="text-right px-2 py-3">
|
|
||||||
<button class="btn btn-ghost btn-xs btn-square text-error"
|
|
||||||
@click="confirmRmovePasskey(passkey)" aria-label="Revoke token">
|
|
||||||
<TrashIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p v-else class="text-center text-base-content/70 py-4">No passkeys found</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<button class="btn btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth"
|
||||||
<div class="card-actions justify-end pt-4">
|
:aria-label="supportWebAuth ? 'Add passkey' : 'WebAuthn not supported in this browser'">
|
||||||
<button class="btn btn-outline btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth">
|
<Fingerprint class="h-4 w-4" aria-hidden="true" />Add Passkey
|
||||||
Add Passkey
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="passkeys && passkeys.length" class="-mx-4 overflow-x-auto sm:-mx-6">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
|
<th class="pl-4">Name</th>
|
||||||
|
<th>Create Time</th>
|
||||||
|
<th>Sign Count</th>
|
||||||
|
<th>Device</th>
|
||||||
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
|
<td class="pl-4 font-medium">{{ passkey.name }}</td>
|
||||||
|
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</td>
|
||||||
|
<td class="tabular-nums">{{ passkey.sign_count }}</td>
|
||||||
|
<td class="text-base-content/70">{{ passkey.device_type }}</td>
|
||||||
|
<td class="pr-4 text-right">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
|
||||||
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-lg border border-dashed border-base-300 py-8 text-center">
|
||||||
|
<Fingerprint class="mx-auto h-8 w-8 text-base-content/20" aria-hidden="true" />
|
||||||
|
<p class="mt-2 text-sm text-base-content/50">No passkeys registered yet.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<!-- Linked accounts (todo) -->
|
||||||
<div class="card-body space-y-4 px-2 sm:px-8">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<h3 class="text-base font-medium text-base-content mb-3 flex items-center gap-2">
|
<div class="card-body gap-4 p-4 sm:p-6">
|
||||||
<Bookmark class="h-5 w-5 text-base-content/80" /> Linked Accounts (todo)
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Linked Accounts</h3>
|
||||||
</h3>
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<p class="text-sm text-base-content/80">Manage your linked social accounts for easier login.</p>
|
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<div class="border rounded-md p-4 flex items-center justify-between">
|
<img src="/assets/github.svg" alt="GitHub" width="24" height="24" class="h-6 w-6" />
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<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 type="button" class="btn btn-outline btn-xs" @click="toggleGithubConnection">
|
||||||
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
|
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="border rounded-md p-4 flex items-center justify-between">
|
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 text-sm">
|
||||||
<Send class="w-6 h-6 fill-current text-gray-500" />
|
<Send class="h-5 w-5 fill-current text-base-content/50" aria-hidden="true" />
|
||||||
<span>Telegram</span>
|
<span>Telegram</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm" :class="isTelegramConnected ? 'btn-success' : 'btn-outline'">
|
<button type="button" class="btn btn-outline btn-xs" @click="toggleTelegramConnection">
|
||||||
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
|
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,29 +226,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading profile">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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, TrashIcon, Infinity, CircleAlert, Fingerprint } 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 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 savingBasic = ref(false);
|
||||||
|
const changingPassword = ref(false);
|
||||||
|
|
||||||
const basicinfo = ref({
|
const basicinfo = ref({
|
||||||
name: user.value?.name || '',
|
name: user.value?.name || '',
|
||||||
@@ -336,19 +300,22 @@ const confirmUpdateBasicInfo = () => {
|
|||||||
|
|
||||||
const updateBasicInfo = async () => {
|
const updateBasicInfo = async () => {
|
||||||
if (!basicinfo.value) return;
|
if (!basicinfo.value) return;
|
||||||
|
savingBasic.value = true;
|
||||||
try {
|
try {
|
||||||
console.log('updateBasicInfo', basicinfo.value);
|
console.log('updateBasicInfo', basicinfo.value);
|
||||||
const res = await authStore.updateProfile(basicinfo.value);
|
const res = await authStore.updateProfile(basicinfo.value);
|
||||||
if (res.data?.code == 200) {
|
if (res.data?.code == 200) {
|
||||||
setToast('Basic information updated successfully', 'success');
|
setToast('Basic information updated successfully.', 'success');
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
} finally {
|
||||||
|
savingBasic.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -358,6 +325,7 @@ const updatePassword = async () => {
|
|||||||
setToast('新密码和确认密码不匹配', 'error');
|
setToast('新密码和确认密码不匹配', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
changingPassword.value = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
password: passwordData.value.oldPassword,
|
password: passwordData.value.oldPassword,
|
||||||
@@ -366,24 +334,26 @@ const updatePassword = async () => {
|
|||||||
console.log('payload', payload)
|
console.log('payload', payload)
|
||||||
const res = await authStore.updatePassword(payload);
|
const res = await authStore.updatePassword(payload);
|
||||||
if (res.data?.code == 200) {
|
if (res.data?.code == 200) {
|
||||||
setToast('Password updated successfully', 'success');
|
setToast('Password updated successfully.', 'success');
|
||||||
}
|
}
|
||||||
passwordData.value.oldPassword = '';
|
passwordData.value.oldPassword = '';
|
||||||
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) || '更新失败';
|
||||||
|
} finally {
|
||||||
|
changingPassword.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 格式化角色
|
// 格式化角色
|
||||||
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,39 +392,39 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,216 +1,146 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="mx-auto max-w-3xl space-y-5">
|
||||||
<BreadcrumbHeader title="User Details" />
|
<BreadcrumbHeader title="账户设置" />
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto">
|
<div v-if="user" class="space-y-5">
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<!-- Profile header -->
|
||||||
<div class="card-body space-y-5 p-3 sm:p-8">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="flex flex-col md:flex-row items-center gap-6">
|
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
|
||||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="avatar" v-else>
|
|
||||||
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
|
||||||
<img :src="user?.avatar_url" :alt="user?.name" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow text-center md:text-left">
|
|
||||||
<h2 class="text-2xl font-semibold text-base-content">
|
|
||||||
{{ user?.name || user?.username }}
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
|
|
||||||
<span class="badge border-none px-0">
|
|
||||||
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
|
|
||||||
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
|
|
||||||
</span>
|
|
||||||
<span class="badge badge-outline"
|
|
||||||
:class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
|
||||||
formatRole(user?.role) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-4 md:mt-0">
|
|
||||||
<input type="checkbox" class="toggle toggle-md"
|
|
||||||
:class="user.active ? 'toggle-success' : 'toggle-error'" v-model="user.active"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divider mt-1 mb-0"></div>
|
|
||||||
|
|
||||||
<form @submit.prevent="updateUser" class="space-y-4">
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<h3 class="text-base font-medium text-base-content mb-3">
|
|
||||||
Basic Information
|
|
||||||
</h3>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="name" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="name" type="text" v-model="user.name" placeholder="Full name"
|
|
||||||
class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="username" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">
|
|
||||||
Username <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="username" type="text" v-model="user.username"
|
|
||||||
placeholder="Select a username" class="input input-bordered input-sm w-full"
|
|
||||||
required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="email" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Email
|
|
||||||
Address</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="email" type="email" v-model="user.email"
|
|
||||||
placeholder="email@example.com"
|
|
||||||
class="input input-bordered input-sm w-full" />
|
|
||||||
<button type="button" @click="toggleEmailVerify"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle password visibility">
|
|
||||||
<BadgeCheck v-if="user.email_verified"
|
|
||||||
class="w-4 h-4 bg-green-300 rounded-full" />
|
|
||||||
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
|
||||||
<Send class="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="password" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">
|
|
||||||
Password <span class="text-xs text-base-content/60">(Leave blank to keep
|
|
||||||
unchanged)</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="password" :type="isPasswordVisible ? 'text' : 'password'"
|
|
||||||
v-model="user.password" placeholder="Enter new password"
|
|
||||||
class="input input-bordered input-sm w-full pr-10" />
|
|
||||||
<button type="button" @click="togglePasswordVisibility"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle password visibility">
|
|
||||||
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
|
|
||||||
<Eye v-else class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
|
|
||||||
<input type="checkbox" class="min-h-0 py-2" checked />
|
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-2">
|
|
||||||
Advanced Options
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="role" class="label pb-1">
|
|
||||||
<span
|
|
||||||
class="label-text text-sm font-medium text-base-content/80">Role</span>
|
|
||||||
</label>
|
|
||||||
<select id="role" v-model="user.role"
|
|
||||||
class="select select-bordered select-sm w-full">
|
|
||||||
<option :value="0">User</option>
|
|
||||||
<option :value="10">Admin</option>
|
|
||||||
<option v-if="user.role > 10" :value="20">Root</option>
|
|
||||||
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="language" class="label pb-1">
|
|
||||||
<span
|
|
||||||
class="label-text text-sm font-medium text-base-content/80">Language</span>
|
|
||||||
</label>
|
|
||||||
<select id="language" v-model="user.language"
|
|
||||||
class="select select-bordered select-sm w-full">
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="zh">中文</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control w-full">
|
|
||||||
<label for="quota" class="label pb-1">
|
|
||||||
<span
|
|
||||||
class="label-text text-sm font-medium text-base-content/80">Quota</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input id="quota" type="number" v-model="user.quota"
|
|
||||||
placeholder="Enter quota amount"
|
|
||||||
class="input input-bordered input-sm flex-grow"
|
|
||||||
:disabled="user.unlimited_quota" />
|
|
||||||
<label class="label cursor-pointer space-x-2 p-0">
|
|
||||||
<input type="checkbox" v-model="user.unlimited_quota"
|
|
||||||
class="checkbox checkbox-sm" />
|
|
||||||
<span class="label-text text-sm text-base-content/90">Unlimited</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end pt-4">
|
|
||||||
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="max-w-3xl mx-auto">
|
<div class="avatar" v-else>
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm">
|
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
<div class="card-body">
|
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||||
<div class="flex justify-center items-center py-10">
|
</div>
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||||
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-sm"
|
||||||
|
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||||
|
{{ user.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
<span class="badge badge-sm"
|
||||||
|
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||||
|
{{ formatRole(user?.role) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit form -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="st-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||||
|
<input id="st-name" name="name" type="text" v-model="user.name" placeholder="Full name"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="st-username" class="mb-1 block text-sm font-medium">
|
||||||
|
Username <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="st-username" name="username" type="text" v-model="user.username" spellcheck="false"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="st-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="st-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
|
||||||
|
spellcheck="false" class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
aria-label="Email verification status">
|
||||||
|
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||||
|
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
|
||||||
|
<Send class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="st-password" class="mb-1 block text-sm font-medium">
|
||||||
|
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="st-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
|
||||||
|
class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||||
|
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="st-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||||
|
<select id="st-language" name="language" v-model="user.language" class="select select-bordered w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading settings">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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 } 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 saving = ref(false);
|
||||||
|
|
||||||
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;
|
||||||
|
saving.value = true;
|
||||||
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,14 +149,16 @@ 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('Settings saved successfully.', '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);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,11 +171,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';
|
||||||
@@ -258,4 +190,4 @@ const toggleEmailVerify = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,159 +1,130 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="mx-auto w-full max-w-3xl">
|
||||||
<BreadcrumbHeader />
|
<header class="mb-4 pr-8">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2>
|
||||||
|
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto">
|
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||||
<div class="mb-6 text-center md:text-left">
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<h1 class="text-2xl font-semibold text-base-content">
|
<span class="flex-1">Error: {{ error }}</span>
|
||||||
Create New Token
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/70 mt-1">
|
|
||||||
Enter the token's details below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span>Error! {{ error }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm" @click="error = null">X</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border border-base-300/40 shadow-sm">
|
|
||||||
<form @submit.prevent="createToken" class="card-body space-y-5 p-3 sm:p-8">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
|
||||||
Basic Information
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="username" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Name <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="username" type="text" v-model="newToken.name" placeholder="New Token Name"
|
|
||||||
class="input input-sm input-bordered w-full" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse collapse-arrow">
|
|
||||||
<input type="checkbox" v-model="showAdvancedOptions" value="false" class="min-h-0 py-2" />
|
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
|
||||||
Advanced Options
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content px-0">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="password" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Key
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="token" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
<button type="button" @click="toggleTokenVisibility"
|
|
||||||
class="absolute inset-y-0 right-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="token-visibility-toggle">
|
|
||||||
|
|
||||||
<template v-if="!isTokenVisible">
|
|
||||||
<EyeOff class="w-5 h-5" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<Eye class="w-5 h-5" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label label-text text-xs">Expired at</label>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input type="date" v-model="newToken.format_expired_at" class="input input-bordered input-sm w-full"
|
|
||||||
placeholder="年/月/日" :disabled="newToken.never_expired" />
|
|
||||||
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
|
||||||
<input type="checkbox" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
|
||||||
<span class="text-sm text-base-content/90">Never</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="quota" class="label">
|
|
||||||
<span class="label-text">Quota</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input id="quota" type="number" v-model="newToken.quota" placeholder="Enter quota amount"
|
|
||||||
class="input input-sm input-bordered w-1/2 flex-grow" :disabled="newToken.unlimited_quota" />
|
|
||||||
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
|
||||||
<input type="checkbox" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
|
||||||
<span class="text-sm text-base-content/90">Unlimited</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Status</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3 h-9">
|
|
||||||
<input type="checkbox" v-model="newToken.active"
|
|
||||||
:class="`toggle toggle-sm ${newToken.active ? 'toggle-success' : 'toggle-error'}`" />
|
|
||||||
<span class="text-sm text-base-content/90">
|
|
||||||
{{ newToken.active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end pt-4 gap-5">
|
|
||||||
<button type="button" class="btn btn-outline btn-error btn-sm h-9 px-4 text-sm font-medium" @click="cancel">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
|
|
||||||
Create
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createToken">
|
||||||
|
<div class="card-body gap-5 p-4 sm:p-6">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="token-name" class="mb-1 block text-sm font-medium">
|
||||||
|
Name <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="token-name" name="name" type="text" v-model="newToken.name"
|
||||||
|
placeholder="e.g. my-laptop" autocomplete="off"
|
||||||
|
class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-3">
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
||||||
|
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate"
|
||||||
|
class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleTokenVisibility"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
:aria-label="isTokenVisible ? 'Hide token key' : 'Show token key'"
|
||||||
|
id="token-visibility-toggle">
|
||||||
|
<EyeOff v-if="!isTokenVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at"
|
||||||
|
class="input input-bordered w-full" :disabled="newToken.never_expired" />
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||||
|
<input type="checkbox" name="never_expired" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
||||||
|
Never
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="token-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="token-quota" name="quota" type="number" inputmode="numeric" v-model="newToken.quota"
|
||||||
|
placeholder="e.g. 10" autocomplete="off"
|
||||||
|
class="input input-bordered w-full flex-grow" :disabled="newToken.unlimited_quota" />
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||||
|
<input type="checkbox" name="unlimited_quota" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
Unlimited
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||||
|
<div class="flex h-9 items-center gap-3">
|
||||||
|
<input type="checkbox" name="active" v-model="newToken.active" role="switch"
|
||||||
|
class="toggle toggle-sm" :class="newToken.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
aria-label="Token active" />
|
||||||
|
<span class="text-sm text-base-content/70">
|
||||||
|
{{ newToken.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Create Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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 { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||||
import { Eye, EyeOff } from 'lucide-vue-next'
|
import { dateToUnix } from '@/utils/format-date';
|
||||||
import { dateToUnix } from '@/utils/format-date.js'
|
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 creating = ref(false)
|
||||||
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,
|
||||||
@@ -205,20 +176,23 @@ const createToken = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const res = await authStore.createToken(newToken.value)
|
const res = await authStore.createToken(newToken.value)
|
||||||
if (res.data?.code === 200) {
|
if (res.data?.code === 200) {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
resetnewToken();
|
resetnewToken();
|
||||||
setToast(`Token ${newToken.value.name} created`, 'success')
|
setToast('Token created successfully.', 'success')
|
||||||
emit('closeModal', true)
|
emit('closeModal', true)
|
||||||
} else {
|
} else {
|
||||||
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'
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,10 +201,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,15 +208,8 @@ function toggleTokenVisibility() {
|
|||||||
isTokenVisible.value = !isTokenVisible.value;
|
isTokenVisible.value = !isTokenVisible.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['closeModal'])
|
const emit = defineEmits<{
|
||||||
|
(e: 'closeModal', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Minimal custom styles if absolutely necessary */
|
|
||||||
.collapse .collapse-title {
|
|
||||||
min-height: 0;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,156 +1,154 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-4">
|
<div class="space-y-5">
|
||||||
<!-- Breadcrumb and Title -->
|
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 mb-4 justify-end items-center">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||||
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
|
||||||
<PlusIcon class="w-3.5 h-3.5" />New
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
|
||||||
</button>
|
</button>
|
||||||
<dialog id="myModal" class="modal" ref="modalRef">
|
|
||||||
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
|
||||||
</form>
|
|
||||||
<TokenNew @closeModal="closeModal" />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button>关闭</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
|
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
|
||||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
|
<table class="table table-sm">
|
||||||
<table class="table table-sm w-full">
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th class="px-2 py-3">Token</th>
|
<th class="pl-4">Token</th>
|
||||||
<th class="px-2 py-3">Status</th>
|
<th>Status</th>
|
||||||
<!-- <th class="px-2 py-3">Key</th> -->
|
<th>Expired</th>
|
||||||
<th class="px-2 py-3">Expired</th>
|
<th class="text-right">Quota</th>
|
||||||
<th class="px-2 py-3">Quota</th>
|
<th class="text-right">Used</th>
|
||||||
<th class="px-2 py-3">Used</th>
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
<th class="text-right px-2 py-3"></th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
<td class="pl-4 font-medium">{{ token.name }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="checkbox" class="toggle toggle-xs"
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||||
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
|
:class="!token.active && 'toggle-error'" v-model="token.active"
|
||||||
@change="updateStatus(token)" />
|
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
|
||||||
</td>
|
</td>
|
||||||
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
|
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
||||||
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at) }}</td>
|
<td class="text-right tabular-nums">
|
||||||
<td class="px-2 py-3">
|
|
||||||
<template v-if="token.unlimited_quota">
|
<template v-if="token.unlimited_quota">
|
||||||
<Infinity />
|
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||||
|
<span class="sr-only">Unlimited</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ token.quota }}</template>
|
<template v-else>{{ token.quota }}</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
||||||
<td class="text-right px-1 py-3">
|
<td class="pr-3 text-right">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center justify-end gap-1">
|
||||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
|
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
|
||||||
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
|
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
<EyeIcon class="w-5 h-5 dark:text-white" />
|
</button>
|
||||||
</button>
|
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
|
||||||
</div>
|
@click="cleanUsedToken(token)" aria-label="Reset used quota">
|
||||||
|
<Eraser class="h-4 w-4" aria-hidden="true" />
|
||||||
<div class="md:tooltip" data-tip="clean used">
|
</button>
|
||||||
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
|
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
aria-label="Revoke token">
|
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
||||||
<Eraser class="w-5 h-5" />
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button v-if="token.name !== 'default'"
|
|
||||||
class="btn btn-ghost btn-xs btn-square text-error items-center" @click="confirmRevokeToken(token)"
|
|
||||||
aria-label="Revoke token">
|
|
||||||
<TrashIcon class="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<dialog id="myToken" class="modal" ref="tokenRef">
|
|
||||||
<div class="modal-box px-0 sm:px-8">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
|
||||||
</form>
|
|
||||||
<QRCodeCard :value="qrCodeValue" :size="120" />
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button>关闭</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
<!-- Empty state -->
|
||||||
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||||
|
<Braces class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||||
|
<h2 class="text-sm font-semibold">No tokens yet</h2>
|
||||||
|
<p class="max-w-xs text-sm text-base-content/60">
|
||||||
|
Create a token to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- </div> -->
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- New token modal -->
|
||||||
|
<dialog ref="modalRef" class="modal">
|
||||||
|
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||||
|
</form>
|
||||||
|
<TokenNew @closeModal="closeModal" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close dialog">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Token QR modal -->
|
||||||
|
<dialog ref="tokenRef" class="modal">
|
||||||
|
<div class="modal-box max-w-sm px-0 sm:px-6">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||||
|
</form>
|
||||||
|
<QRCodeCard :value="qrCodeValue" :size="120" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close dialog">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</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, Braces
|
||||||
} 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 +156,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 +173,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 +200,10 @@ const qrCodeValue = ref('');
|
|||||||
|
|
||||||
|
|
||||||
// 关闭模态框
|
// 关闭模态框
|
||||||
const modalRef = ref(null);
|
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||||
|
const openModal = () => {
|
||||||
|
modalRef.value?.showModal();
|
||||||
|
};
|
||||||
const closeModal = async () => {
|
const closeModal = async () => {
|
||||||
if (modalRef.value) {
|
if (modalRef.value) {
|
||||||
modalRef.value.close();
|
modalRef.value.close();
|
||||||
|
|||||||
@@ -1,170 +1,171 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-4">
|
<div class="space-y-5">
|
||||||
<!-- Breadcrumb and Title -->
|
|
||||||
<BreadcrumbHeader />
|
<BreadcrumbHeader />
|
||||||
|
|
||||||
<!-- Search and Controls -->
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<!-- <div v-if="userStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
<p class="text-sm text-base-content/60">Team members and their access.</p>
|
||||||
<div class="flex flex-wrap gap-2 mb-4">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex flex-1 items-center space-x-2">
|
<div class="dropdown dropdown-end">
|
||||||
<input
|
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||||
class="flex rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 h-8 w-[120px] lg:w-[250px]"
|
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
|
||||||
placeholder="Filter" value="">
|
</button>
|
||||||
|
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||||
<div class="dropdown">
|
<li>
|
||||||
<label tabindex="0"
|
<button class="text-error" @click="handleBatchAction('delete')">
|
||||||
class="inline-flex items-center justify-center flex gap-2 whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground rounded-md px-3 text-xs h-8 border-dashed">
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
|
||||||
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
</button>
|
||||||
<path fill="currentColor" fill-rule="evenodd"
|
</li>
|
||||||
d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0a5.673 5.673 0 0 1-11.346 0M7.5 4a.5.5 0 0 1 .5.5V7h2.5a.5.5 0 1 1 0 1H8v2.5a.5.5 0 0 1-1 0V8H4.5a.5.5 0 0 1 0-1H7V4.5a.5.5 0 0 1 .5-.5"
|
<li>
|
||||||
clip-rule="evenodd"></path>
|
<button @click="handleBatchAction('disable')">
|
||||||
</svg>
|
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
|
||||||
Status
|
</button>
|
||||||
</label>
|
</li>
|
||||||
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
<li>
|
||||||
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
<button @click="handleBatchAction('enable')">
|
||||||
<a class="px-2 mx-0 hover:rounded-none">
|
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
|
||||||
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
</button>
|
||||||
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
|
||||||
{{ status }}
|
|
||||||
</a>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new user">
|
||||||
</div>
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />New User
|
||||||
|
|
||||||
<div class="flex flex-1 items-center justify-end space-x-1">
|
|
||||||
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
|
||||||
<PlusIcon class="w-3.5 h-3.5" />New
|
|
||||||
</button>
|
</button>
|
||||||
<dialog id="myModal" class="modal" ref="modalRef">
|
|
||||||
<div class="modal-box w-11/12 max-w-5xl h-screen px-0 sm:px-8">
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</button>
|
|
||||||
</form>
|
|
||||||
<UserNew @closeModal="closeModal" />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button>关闭</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dropdown dropdown-end dropdown-hover">
|
|
||||||
<div tabindex="0" role="button" class="btn btn-ghost btn-sm p-1 h-8 w-8">
|
|
||||||
<Settings2Icon class="w-6 h-6" />
|
|
||||||
</div>
|
|
||||||
<ul tabindex="0" class="dropdown-content menu p-2 shadow bg-base-100 rounded-box z-[1] w-20 text-sm">
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
|
||||||
@click="handleBatchAction('delete')">
|
|
||||||
<TrashIcon class="w-4 h-4" />删除
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<hr class="-mx-2 my-1 border-base-content/10">
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-rose-100 hover:text-rose-600 transition-colors"
|
|
||||||
@click="handleBatchAction('disable')">
|
|
||||||
<BadgeXIcon class="w-4 h-4" />禁用
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<div class="btn btn-xs p-1 text-xs hover:bg-green-100 hover:text-green-600 transition-colors"
|
|
||||||
@click="handleBatchAction('enable')">
|
|
||||||
<BadgeCheckIcon class="w-4 h-4" />启用
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status filter -->
|
||||||
|
<details class="dropdown dropdown-end" v-if="users.length">
|
||||||
|
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||||
|
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Status
|
||||||
|
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||||
|
</summary>
|
||||||
|
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||||
|
<li v-for="status in statusOptions" :key="status">
|
||||||
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
|
<input type="checkbox" class="checkbox checkbox-xs"
|
||||||
|
:checked="selectedStatuses.some(item => item.status === status)"
|
||||||
|
@change="toggleStatusFilter(status)" />
|
||||||
|
{{ status }}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<!-- Table -->
|
<!-- Table -->
|
||||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<table class="table table-sm">
|
<div class="overflow-x-auto" v-if="users.length">
|
||||||
<!-- Table Header -->
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th>
|
<th class="pl-4">
|
||||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||||
</th>
|
aria-label="Select all users" />
|
||||||
<th>ID</th>
|
</th>
|
||||||
<th>Name</th>
|
<th>ID</th>
|
||||||
<th>Active</th>
|
<th>Name</th>
|
||||||
<th>Quota</th>
|
<th>Active</th>
|
||||||
<th>Used</th>
|
<th class="text-right">Quota</th>
|
||||||
<th></th>
|
<th class="text-right">Used</th>
|
||||||
</tr>
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
</thead>
|
</tr>
|
||||||
<!-- Table Body -->
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="user in users" :key="user.id"
|
<tr v-for="user in users" :key="user.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
|
<td class="pl-4">
|
||||||
<td>
|
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
|
||||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
|
@change="toggleUserSelection(user)" :aria-label="`Select user ${user.username}`" />
|
||||||
@change="toggleUserSelection(user)" />
|
</td>
|
||||||
</td>
|
<td class="tabular-nums text-base-content/60">{{ user.id }}</td>
|
||||||
<td class="text-xs dark:text-white">{{ user.id }}</td>
|
<td class="max-w-40 truncate font-medium">
|
||||||
<td class="text-xs dark:text-white">{{ user.username }}</td>
|
<div class="flex items-center gap-2">
|
||||||
<td>
|
<div class="placeholder avatar">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
<span class="text-[10px] font-medium">{{ user.username?.[0]?.toUpperCase() }}</span>
|
||||||
v-model="user.active" @change="updateStatus(user)" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
{{ user.username }}
|
||||||
<td class="text-xs dark:text-white">
|
</div>
|
||||||
<template v-if="user.unlimited_quota">
|
</td>
|
||||||
<Infinity />
|
<td>
|
||||||
</template>
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||||
<template v-else>{{ user.quota }}</template>
|
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
|
||||||
</td>
|
:aria-label="`Toggle user ${user.username}`" />
|
||||||
<td class="text-xs dark:text-white">{{ user.used_quota }}</td>
|
</td>
|
||||||
<td>
|
<td class="text-right tabular-nums">
|
||||||
<div class="flex gap-1">
|
<template v-if="user.unlimited_quota">
|
||||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
|
<span class="sr-only">Unlimited</span>
|
||||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
</template>
|
||||||
|
<template v-else>{{ user.quota }}</template>
|
||||||
|
</td>
|
||||||
|
<td class="text-right tabular-nums">{{ user.used_quota }}</td>
|
||||||
|
<td class="pr-3">
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<button class="btn btn-ghost btn-xs btn-square" @click="viewUser(user)" aria-label="View user details">
|
||||||
|
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button v-if="user.role < 20" class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
|
@click="confirmDeleteUser(user)" aria-label="Delete user">
|
||||||
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
|
</td>
|
||||||
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
</tr>
|
||||||
@click="confirmDeleteUser(user)">
|
</tbody>
|
||||||
<TrashIcon class="w-4 h-4 dark:text-white" />
|
</table>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<!-- Empty state -->
|
||||||
</td>
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||||
</tr>
|
<UsersRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||||
</tbody>
|
<h2 class="text-sm font-semibold">No users yet</h2>
|
||||||
</table>
|
<p class="max-w-xs text-sm text-base-content/60">
|
||||||
|
Invite teammates by creating accounts with their own quotas.
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||||
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" @changePageSize="changePageSize" />
|
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||||
|
|
||||||
|
<!-- New user modal -->
|
||||||
|
<dialog ref="modalRef" class="modal">
|
||||||
|
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||||
|
</form>
|
||||||
|
<UserNew @closeModal="closeModal" />
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button aria-label="Close dialog">close</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
</div>
|
</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, UsersRoundIcon, Infinity, ListFilterIcon
|
||||||
} 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 +173,9 @@ 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));
|
await userStore.listUser(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
|
||||||
await userStore.listUser(size, page, active);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 组件挂载时加载用户数据
|
// 组件挂载时加载用户数据
|
||||||
@@ -184,7 +184,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 +193,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 +210,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 +221,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,15 +237,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) {
|
|
||||||
return setToast('请选择用户', 'error');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -255,20 +250,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 +271,48 @@ 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 openModal = () => {
|
||||||
|
modalRef.value?.showModal();
|
||||||
|
};
|
||||||
const closeModal = async () => {
|
const closeModal = async () => {
|
||||||
if (modalRef.value) {
|
if (modalRef.value) {
|
||||||
modalRef.value.close();
|
modalRef.value.close();
|
||||||
|
|||||||
@@ -1,173 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="mx-auto w-full max-w-3xl">
|
||||||
<BreadcrumbHeader />
|
<header class="mb-4 pr-8">
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">Create New User</h2>
|
||||||
|
<p class="mt-0.5 text-sm text-base-content/60">Invite a teammate with their own account and quota.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto">
|
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||||
<div class="mb-6 text-center md:text-left">
|
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||||
<h1 class="text-2xl font-semibold text-base-content">
|
<span class="flex-1">Error: {{ error }}</span>
|
||||||
Create New User
|
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||||
</h1>
|
|
||||||
<p class="text-sm text-base-content/70 mt-1">
|
|
||||||
Enter the user's details below.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="error" role="alert" class="alert shadow-lg bg-rose-100">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<span>Error! {{ error }}</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm" @click="error = null">X</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border border-base-300/40 shadow-sm">
|
|
||||||
<form @submit.prevent="createUser" class="card-body space-y-5 p-3 sm:p-8">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h2 class="text-base font-medium text-base-content border-b border-base-300/30 pb-2 mb-4">
|
|
||||||
Basic Information
|
|
||||||
</h2>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="username" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Username <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="username" type="text" v-model="newUser.username" placeholder="Select a username"
|
|
||||||
class="input input-sm input-bordered w-full" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="password" class="label">
|
|
||||||
<span class="label-text">
|
|
||||||
Password <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="newUser.password"
|
|
||||||
class="input input-sm input-bordered w-full" required />
|
|
||||||
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
|
||||||
class="absolute inset-y-0 right-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">
|
|
||||||
|
|
||||||
<template v-if="!isPasswordVisible">
|
|
||||||
<EyeOff class="w-5 h-5" />
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<Eye class="w-5 h-5" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="name" class="label">
|
|
||||||
<span class="label-text">Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="name" type="text" v-model="newUser.name" placeholder="Full name"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="email" class="label">
|
|
||||||
<span class="label-text">Email Address</span>
|
|
||||||
</label>
|
|
||||||
<input id="email" type="email" v-model="newUser.email" placeholder="email@example.com"
|
|
||||||
class="input input-sm input-bordered w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse collapse-arrow">
|
|
||||||
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-1 px-0">
|
|
||||||
Advanced Options
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content px-0">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-4 pt-3">
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="role" class="label">
|
|
||||||
<span class="label-text">Role</span>
|
|
||||||
</label>
|
|
||||||
<select id="role" v-model="newUser.role" class="select select-sm select-bordered w-full">
|
|
||||||
<option :value="0">User</option>
|
|
||||||
<option :value="10">Admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label for="language" class="label">
|
|
||||||
<span class="label-text">Language</span>
|
|
||||||
</label>
|
|
||||||
<select id="language" v-model="newUser.language" class="select select-sm select-bordered w-full">
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="zh">中文</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control">
|
|
||||||
<label for="quota" class="label">
|
|
||||||
<span class="label-text">Quota</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input id="quota" type="number" v-model="newUser.quota" placeholder="Enter quota amount"
|
|
||||||
class="input input-sm input-bordered flex-grow" :disabled="newUser.unlimited_quota" />
|
|
||||||
<label class="flex items-center space-x-2 cursor-pointer whitespace-nowrap">
|
|
||||||
<input type="checkbox" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
|
||||||
<span class="text-sm text-base-content/90">Unlimited</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
|
||||||
<label class="label">
|
|
||||||
<span class="label-text">Status</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3 h-9">
|
|
||||||
<input type="checkbox" v-model="newUser.active"
|
|
||||||
:class="`toggle toggle-sm ${newUser.active ? 'toggle-success' : 'toggle-error'}`" />
|
|
||||||
<span class="text-sm text-base-content/90">
|
|
||||||
{{ newUser.active ? 'Active' : 'Inactive' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
|
||||||
<button type="submit" class="btn btn-outline btn-sm h-9 px-4 text-sm font-medium" :disabled="!isFormValid">
|
|
||||||
Create User
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createUser">
|
||||||
|
<div class="card-body gap-5 p-4 sm:p-6">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="user-username" class="mb-1 block text-sm font-medium">
|
||||||
|
Username <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="user-username" name="username" type="text" v-model="newUser.username"
|
||||||
|
placeholder="e.g. alice" autocomplete="off" spellcheck="false"
|
||||||
|
class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="user-password" class="mb-1 block text-sm font-medium">
|
||||||
|
Password <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="user-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="newUser.password" autocomplete="new-password" placeholder="At least 4 characters"
|
||||||
|
class="input input-bordered w-full pr-10" required />
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||||
|
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="user-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||||
|
<input id="user-name" name="name" type="text" v-model="newUser.name" placeholder="Full name"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="user-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||||
|
<input id="user-email" name="email" type="email" v-model="newUser.email"
|
||||||
|
placeholder="name@example.com" spellcheck="false" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||||
|
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||||
|
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-3">
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="user-role" class="mb-1 block text-sm font-medium">Role</label>
|
||||||
|
<select id="user-role" name="role" v-model="newUser.role" class="select select-bordered w-full">
|
||||||
|
<option :value="0">User</option>
|
||||||
|
<option :value="10">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="user-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||||
|
<select id="user-language" name="language" v-model="newUser.language" class="select select-bordered w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="user-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="user-quota" name="quota" type="number" inputmode="numeric" v-model="newUser.quota"
|
||||||
|
placeholder="e.g. 10" autocomplete="off"
|
||||||
|
class="input input-bordered w-full flex-grow" :disabled="newUser.unlimited_quota" />
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||||
|
<input type="checkbox" name="unlimited_quota" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
Unlimited
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||||
|
<div class="flex h-9 items-center gap-3">
|
||||||
|
<input type="checkbox" name="active" v-model="newUser.active" role="switch"
|
||||||
|
class="toggle toggle-sm" :class="newUser.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
aria-label="User active" />
|
||||||
|
<span class="text-sm text-base-content/70">
|
||||||
|
{{ newUser.active ? 'Active' : 'Inactive' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||||
|
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</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 { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||||
import { Eye, EyeOff } from 'lucide-vue-next'
|
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)
|
||||||
|
const creating = ref(false)
|
||||||
|
|
||||||
// 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 +155,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,6 +173,7 @@ const createUser = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
creating.value = true
|
||||||
try {
|
try {
|
||||||
const res = await userStore.createUser({
|
const res = await userStore.createUser({
|
||||||
username: newUser.value.username,
|
username: newUser.value.username,
|
||||||
@@ -222,15 +190,15 @@ const createUser = async () => {
|
|||||||
if (res.data?.code === 200) {
|
if (res.data?.code === 200) {
|
||||||
error.value = null;
|
error.value = null;
|
||||||
resetNewUser();
|
resetNewUser();
|
||||||
setToast('User created successfully', 'success')
|
setToast('User created successfully.', 'success')
|
||||||
// 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')
|
} finally {
|
||||||
|
creating.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,15 +209,8 @@ function togglePasswordVisibility() {
|
|||||||
isPasswordVisible.value = !isPasswordVisible.value;
|
isPasswordVisible.value = !isPasswordVisible.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits(['closeModal'])
|
const emit = defineEmits<{
|
||||||
|
(e: 'closeModal', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Minimal custom styles if absolutely necessary */
|
|
||||||
.collapse .collapse-title {
|
|
||||||
min-height: 0;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,205 +1,178 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
<div class="space-y-5">
|
||||||
<BreadcrumbHeader title="User Details" />
|
<BreadcrumbHeader title="用户详情" />
|
||||||
|
|
||||||
<div class="max-w-3xl mx-auto">
|
<div v-if="user" class="space-y-5">
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
<!-- Profile header -->
|
||||||
<div class="card-body space-y-5 p-3 sm:p-8">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="flex flex-col md:flex-row items-center gap-6">
|
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
|
||||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="avatar" v-else>
|
</div>
|
||||||
<div class="w-16 sm:w-20 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
<div class="avatar" v-else>
|
||||||
<img :src="user?.avatar_url" :alt="user?.name" />
|
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||||
</div>
|
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow text-center md:text-left">
|
|
||||||
<h2 class="text-2xl font-semibold text-base-content">
|
|
||||||
{{ user?.name || user?.username }}
|
|
||||||
</h2>
|
|
||||||
<div class="flex items-center justify-center md:justify-start gap-2 mt-2">
|
|
||||||
<span class="badge border-none px-0">
|
|
||||||
<CircleCheckBig v-if="user.active" class="h-5 w-5 bg-green-300 rounded-full" />
|
|
||||||
<CircleX v-else class="h-5 w-5 bg-rose-300 rounded-full" />
|
|
||||||
</span>
|
|
||||||
<span class="badge badge-outline" :class="user.role > 0 ? 'badge-warning' : 'badge-success'">{{
|
|
||||||
formatRole(user?.role) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 mt-4 md:mt-0">
|
|
||||||
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
|
||||||
v-model="user.active" @change="updateStatus(user)" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider mt-1 mb-0"></div>
|
<div class="min-w-0 flex-1">
|
||||||
|
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||||
<form @submit.prevent="updateUser" class="space-y-4">
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
|
<span class="badge badge-sm"
|
||||||
<div class="space-y-3">
|
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||||
<h3 class="text-base font-medium text-base-content mb-3">
|
{{ user.active ? 'Active' : 'Inactive' }}
|
||||||
Basic Information
|
</span>
|
||||||
</h3>
|
<span class="badge badge-sm"
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3">
|
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||||
<div class="form-control w-full">
|
{{ formatRole(user?.role) }}
|
||||||
<label for="name" class="label pb-1">
|
</span>
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Name</span>
|
|
||||||
</label>
|
|
||||||
<input id="name" type="text" v-model="user.name" placeholder="Full name"
|
|
||||||
class="input input-bordered input-sm w-full" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="username" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">
|
|
||||||
Username <span class="text-red-500">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input id="username" type="text" v-model="user.username" placeholder="Select a username"
|
|
||||||
class="input input-bordered input-sm w-full" required />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="email" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Email Address</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="email" type="email" v-model="user.email" placeholder="email@example.com"
|
|
||||||
class="input input-bordered input-sm w-full" />
|
|
||||||
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle password visibility">
|
|
||||||
<BadgeCheck v-if="user.email_verified" class="w-4 h-4 bg-green-300 rounded-full" />
|
|
||||||
<div v-else class="tooltip tooltip-top" data-tip="Send verification email">
|
|
||||||
<Send class="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="password" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">
|
|
||||||
Password <span class="text-xs text-base-content/60">(Leave blank to keep unchanged)</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="relative">
|
|
||||||
<input id="password" :type="isPasswordVisible ? 'text' : 'password'" v-model="user.password"
|
|
||||||
placeholder="Enter new password" class="input input-bordered input-sm w-full pr-10" />
|
|
||||||
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
|
||||||
class="absolute inset-y-0 right-0 px-3 flex items-center text-base-content/60 hover:text-base-content focus:outline-none rounded-r-md"
|
|
||||||
aria-label="Toggle password visibility">
|
|
||||||
<EyeOff v-if="!isPasswordVisible" class="w-4 h-4" />
|
|
||||||
<Eye v-else class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="collapse collapse-arrow border border-base-300/30 rounded-md mt-4">
|
|
||||||
<input type="checkbox" class="min-h-0 py-2" checked />
|
|
||||||
<div class="collapse-title text-base font-medium min-h-0 py-2">
|
|
||||||
Advanced Options
|
|
||||||
</div>
|
|
||||||
<div class="collapse-content">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-3 pt-2">
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="role" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Role</span>
|
|
||||||
</label>
|
|
||||||
<select id="role" v-model="user.role" class="select select-bordered select-sm w-full">
|
|
||||||
<option :value="0">User</option>
|
|
||||||
<option :value="10">Admin</option>
|
|
||||||
<option v-if="user.role > 10" :value="20">Root</option>
|
|
||||||
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control w-full">
|
|
||||||
<label for="language" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Language</span>
|
|
||||||
</label>
|
|
||||||
<select id="language" v-model="user.language" class="select select-bordered select-sm w-full">
|
|
||||||
<option value="en">English</option>
|
|
||||||
<option value="zh">中文</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="md:col-span-2 form-control w-full">
|
|
||||||
<label for="quota" class="label pb-1">
|
|
||||||
<span class="label-text text-sm font-medium text-base-content/80">Quota</span>
|
|
||||||
</label>
|
|
||||||
<div class="flex items-center space-x-3">
|
|
||||||
<input id="quota" type="number" v-model="user.quota" placeholder="Enter quota amount"
|
|
||||||
class="input input-bordered input-sm w-1/2" :disabled="user.unlimited_quota" />
|
|
||||||
<label class="label cursor-pointer space-x-2 p-0">
|
|
||||||
<input type="checkbox" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
|
|
||||||
<span class="label-text text-sm text-base-content/90">Unlimited</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card-actions justify-end pt-4">
|
|
||||||
<button type="submit" class="btn btn-outline btn-success btn-sm">
|
|
||||||
Update
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="max-w-3xl mx-auto">
|
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="flex justify-center items-center py-10">
|
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||||
|
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Token 显示 -->
|
<!-- Edit form -->
|
||||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
<div class="card-body">
|
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
|
||||||
<h3 class="card-title text-lg">Tokens</h3>
|
<section class="space-y-4">
|
||||||
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
|
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||||
<table class="table table-sm w-full">
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="uv-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||||
|
<input id="uv-name" name="name" type="text" v-model="user.name" placeholder="Full name"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="uv-username" class="mb-1 block text-sm font-medium">
|
||||||
|
Username <span class="text-error" aria-hidden="true">*</span>
|
||||||
|
</label>
|
||||||
|
<input id="uv-username" name="username" type="text" v-model="user.username" spellcheck="false"
|
||||||
|
autocomplete="off" class="input input-bordered w-full" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="uv-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="uv-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
|
||||||
|
spellcheck="false" class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
aria-label="Email verification status">
|
||||||
|
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||||
|
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
|
||||||
|
<Send class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="uv-password" class="mb-1 block text-sm font-medium">
|
||||||
|
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input id="uv-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||||
|
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
|
||||||
|
class="input input-bordered w-full pr-10" />
|
||||||
|
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||||
|
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||||
|
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||||
|
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||||
|
<input type="checkbox" class="min-h-0 py-2" checked />
|
||||||
|
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||||
|
Advanced Options
|
||||||
|
</div>
|
||||||
|
<div class="collapse-content px-3">
|
||||||
|
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="uv-role" class="mb-1 block text-sm font-medium">Role</label>
|
||||||
|
<select id="uv-role" name="role" v-model="user.role" class="select select-bordered w-full">
|
||||||
|
<option :value="0">User</option>
|
||||||
|
<option :value="10">Admin</option>
|
||||||
|
<option v-if="user.role > 10" :value="20">Root</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="uv-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||||
|
<select id="uv-language" name="language" v-model="user.language" class="select select-bordered w-full">
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="uv-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="uv-quota" name="quota" type="number" inputmode="numeric" v-model="user.quota"
|
||||||
|
placeholder="e.g. 10" autocomplete="off"
|
||||||
|
class="input input-bordered w-full flex-grow" :disabled="user.unlimited_quota" />
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||||
|
<input type="checkbox" name="unlimited_quota" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
|
||||||
|
Unlimited
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
|
||||||
|
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tokens -->
|
||||||
|
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body p-4 sm:p-5">
|
||||||
|
<h3 class="card-title pb-1 text-base font-semibold">Tokens</h3>
|
||||||
|
<div v-if="user.tokens && user.tokens.length" class="-mx-4 overflow-x-auto sm:-mx-5">
|
||||||
|
<table class="table table-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||||
<th class="px-2 py-3">Token Name</th>
|
<th class="pl-4">Token Name</th>
|
||||||
<th class="px-2 py-3">Key</th>
|
<th>Key</th>
|
||||||
<th class="px-2 py-3">Expired At</th>
|
<th>Expired At</th>
|
||||||
<th class="px-2 py-3">Quota</th>
|
<th class="text-right">Quota</th>
|
||||||
<th class="px-2 py-3">Used</th>
|
<th class="text-right">Used</th>
|
||||||
<th class="text-right px-2 py-3"></th>
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
<td class="pl-4 font-medium">{{ token.name }}</td>
|
||||||
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
|
<td class="max-w-32 truncate font-mono text-xs text-base-content/60">{{ token.key }}</td>
|
||||||
<td class="px-2 py-3">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
|
<td class="tabular-nums text-base-content/70">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
|
||||||
<td class="px-2 py-3">
|
<td class="text-right tabular-nums">
|
||||||
<template v-if="token.unlimited_quota">
|
<template v-if="token.unlimited_quota">
|
||||||
<Infinity />
|
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||||
|
<span class="sr-only">Unlimited</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>{{ token.quota }}</template>
|
<template v-else>{{ token.quota }}</template>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
||||||
<td class="text-right px-2 py-3">
|
<td class="pr-4 text-right">
|
||||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||||
@click="revokeToken(token.id)" aria-label="Revoke token">
|
@click="revokeToken(token.id)" aria-label="Revoke token">
|
||||||
<TrashIcon class="w-4 h-4" />
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -207,29 +180,43 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-else class="text-center text-base-content/70 py-4">No tokens found</p>
|
<div v-else class="py-8 text-center">
|
||||||
|
<p class="text-sm text-base-content/50">This user has no tokens yet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading user details">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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, 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 saving = ref(false);
|
||||||
|
|
||||||
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 +224,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,17 +234,17 @@ 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');
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = async () => {
|
const updateUser = async () => {
|
||||||
if (!user.value) return;
|
if (!user.value) return;
|
||||||
|
saving.value = true;
|
||||||
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,14 +257,16 @@ 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);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -290,33 +279,19 @@ 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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// const updateStatus = async (updatedUser) => {
|
|
||||||
// try {
|
|
||||||
// const response = await userStore.editUser(updatedUser.id, { active: updatedUser.active });
|
|
||||||
// if (response.data?.data?.code == 200) {
|
|
||||||
// setToast('User ${updatedUser.id} status updated', 'success');
|
|
||||||
// }
|
|
||||||
// } catch (err) {
|
|
||||||
// updatedUser.active = !updatedUser.active;
|
|
||||||
// console.log('Error updating user status:', err.response?.data?.data?.error);
|
|
||||||
// setToast(err.response?.data?.data?.error, 'error')
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 格式化日期
|
// 格式化日期
|
||||||
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 +302,7 @@ const formatDate = (dateString) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 删除token
|
// 删除token
|
||||||
const revokeToken = (tokenId) => {
|
const revokeToken = (tokenId: TokenInfo['id']) => {
|
||||||
console.log('Revoking token:', tokenId);
|
console.log('Revoking token:', tokenId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -338,4 +313,4 @@ const toggleEmailVerify = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen flex-col bg-base-100 text-base-content">
|
||||||
|
<header class="border-b border-base-300/40">
|
||||||
|
<div class="mx-auto flex max-w-5xl items-center gap-2 px-4 py-3">
|
||||||
|
<img src="@/assets/logo.svg" alt="OpenTeam logo" width="36" height="36" class="h-9 w-9 select-none" />
|
||||||
|
<a href="/" class="text-lg font-bold tracking-tight">OpenTeam</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||||
|
<p class="text-7xl font-black tabular-nums tracking-tight text-primary/80 sm:text-8xl">404</p>
|
||||||
|
<h1 class="mt-4 text-2xl font-semibold tracking-tight">Page not found</h1>
|
||||||
|
<p class="mt-2 max-w-md text-pretty text-base-content/60">
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<a href="/" class="btn btn-primary">Go Back Home</a>
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="btn btn-outline">
|
||||||
|
Visit GitHub Repo
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="pb-8 text-center text-sm text-base-content/50">
|
||||||
|
<p>
|
||||||
|
Copyright © {{ currentYear }} Mirrors2 ·
|
||||||
|
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="link link-hover">opencatd-open</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
|
||||||
|
const currentYear = ref('');
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
currentYear.value = new Date().getFullYear().toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
/** 后端 API 基础路径,默认 /api */
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
@@ -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"], // 可以根据需要添加或修改主题
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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: {
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user