redesign: site-wide UI/UX overhaul per web-interface-guidelines
- unified design system: focus-visible rings, reduced-motion, color-scheme, theme persistence, tightened emerald radius tokens (checkbox circle bug) - redesigned all pages: Home/Login/Signup/404, dashboard shell, Overview hero (time-based gradient), list pages with empty states, form modals, detail pages - role-scoped navigation: console menu vs admin console with cross-switch entries, Chinese route titles and breadcrumbs - theme switcher (light/dark/auto) via composables/theme, home + dashboard - state-driven dropdowns (touch-safe logout), menu divider pointer-events fix - btn-primary unified to black-on-white / white-on-black per theme - update REFACTOR_PLAN.md (increments 2-6)
This commit is contained in:
@@ -228,3 +228,59 @@ src/
|
||||
- `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 会话。
|
||||
|
||||
@@ -4,7 +4,19 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="OpenTeam — an open-source, team-shared service compatible with the OpenAI API." />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<title>OpenTeam</title>
|
||||
<script>
|
||||
// 恢复主题偏好(light/dark/auto,auto 跟随系统),避免闪烁
|
||||
try {
|
||||
var __p = localStorage.getItem('theme') || 'auto'
|
||||
var __dark = __p === 'dark' || (__p === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.setAttribute('data-theme', __dark ? 'dark' : 'emerald')
|
||||
} catch (e) {
|
||||
document.documentElement.setAttribute('data-theme', 'emerald')
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<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 }} 项
|
||||
<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 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>
|
||||
<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="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"
|
||||
class="select select-sm w-16 border-base-300 bg-base-100"
|
||||
:value="pageSize"
|
||||
@change="emitChangePageSize($event)"
|
||||
>
|
||||
@@ -16,53 +16,51 @@
|
||||
{{ option }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<span class="px-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ currentPage }}/{{ totalPages }}
|
||||
</span>
|
||||
<span class="text-base-content/60 tabular-nums">{{ currentPage }} / {{ totalPages }}</span>
|
||||
|
||||
<div class="flex items-center">
|
||||
<div class="join">
|
||||
<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"
|
||||
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||
:disabled="currentPage === 1"
|
||||
@click="emitChangePage(1, pageSize)"
|
||||
aria-label="第一页"
|
||||
@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">
|
||||
<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-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"
|
||||
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||
:disabled="currentPage === 1"
|
||||
@click="emitChangePage(currentPage - 1, pageSize)"
|
||||
aria-label="上一页"
|
||||
@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">
|
||||
<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-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"
|
||||
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="emitChangePage(currentPage + 1, pageSize)"
|
||||
aria-label="下一页"
|
||||
@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">
|
||||
<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-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"
|
||||
class="btn btn-sm join-item border-base-300 bg-base-100"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="emitChangePage(totalPages, pageSize)"
|
||||
aria-label="最后一页"
|
||||
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">
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="input input-sm input-bordered w-full h-auto min-h-9 flex flex-wrap items-center gap-1 py-1 px-2"
|
||||
:class="{ 'input-disabled': disabled }"
|
||||
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
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<!-- src/components/Toast.vue -->
|
||||
<!-- src/components/common/Toast.vue -->
|
||||
<template>
|
||||
<div
|
||||
v-if="show"
|
||||
class="mt-20 toast "
|
||||
:class="{ 'toast-error': currentMessage?.type === 'error', 'toast-success': currentMessage?.type === 'success' }"
|
||||
>
|
||||
<div aria-live="polite" class="toast toast-top toast-end z-50 mt-16">
|
||||
<div
|
||||
class="alert"
|
||||
:class="{ 'alert-error': currentMessage?.type === 'error', 'alert-success': currentMessage?.type === 'success' }"
|
||||
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>
|
||||
<span>{{ currentMessage.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,8 +29,8 @@ let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const processQueue = () => {
|
||||
if (props.queue.length === 0) {
|
||||
show.value = false;
|
||||
currentMessage.value = null;
|
||||
show.value = false;
|
||||
currentMessage.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,15 +38,14 @@ const processQueue = () => {
|
||||
show.value = true;
|
||||
|
||||
timer = setTimeout(() => {
|
||||
processQueue();
|
||||
processQueue();
|
||||
}, currentMessage.value?.duration || 3000);
|
||||
|
||||
};
|
||||
|
||||
watch(() => props.queue, () => {
|
||||
if(!show.value) {
|
||||
processQueue()
|
||||
}
|
||||
if (!show.value) {
|
||||
processQueue();
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -53,6 +53,4 @@ onUnmounted(() => {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<template>
|
||||
<!-- Breadcrumb and Title -->
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h1 class="text-lg font-medium">{{ displayTitle }}</h1>
|
||||
<div class="text-xs breadcrumbs">
|
||||
<ul>
|
||||
<li v-for="(item, index) in breadcrumbItems" :key="index">
|
||||
<template v-if="item.path">
|
||||
<a
|
||||
:href="item.path"
|
||||
class="text-gray-500"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<!-- Breadcrumb and Title:仅在有多级路径(列表/详情)时显示面包屑 -->
|
||||
<div class="mb-5">
|
||||
<nav v-if="breadcrumbItems.length > 1" aria-label="Breadcrumb" class="text-xs text-base-content/50">
|
||||
<ul class="flex flex-wrap items-center gap-1.5">
|
||||
<li v-for="(item, index) in breadcrumbItems" :key="index" class="flex items-center gap-1.5">
|
||||
<span v-if="index > 0" aria-hidden="true" class="text-base-content/30">/</span>
|
||||
<template v-if="item.path && index !== breadcrumbItems.length - 1">
|
||||
<RouterLink :to="item.path" class="transition-colors hover:text-base-content/80">
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-gray-500">{{ item.name }}</span>
|
||||
<span aria-current="page" class="text-base-content/80">{{ item.label }}</span>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
<h1 class="mt-1 text-xl font-semibold tracking-tight text-balance">{{ displayTitle }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -26,10 +24,12 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
|
||||
type Crumb = { label: string; path: string };
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string | null; // 默认为null,将从路由中获取
|
||||
title?: string | null; // 默认为null,将从路由 meta 中获取
|
||||
// Optional custom breadcrumb items
|
||||
customBreadcrumbs?: { name: string; path: string }[];
|
||||
customBreadcrumbs?: Crumb[];
|
||||
}>(), {
|
||||
title: null,
|
||||
customBreadcrumbs: () => [],
|
||||
@@ -37,42 +37,34 @@ const props = withDefaults(defineProps<{
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
// Generate breadcrumb items based on current route
|
||||
// 生成面包屑项
|
||||
const breadcrumbItems = computed<{ name: string; path: string }[]>(() => {
|
||||
// 按路由名显式定义多级面包屑:只有存在父级页面的路由才需要路径展示
|
||||
const crumbsByRouteName: Record<string, Crumb[]> = {
|
||||
// 管理后台列表页:区域根
|
||||
User: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||
ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||
// 详情页:区域根 / 列表页(末级为当前页标题)
|
||||
UserView: [
|
||||
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||
{ label: '用户管理', path: '/dashboard/manager/users' },
|
||||
],
|
||||
ApiKeyView: [
|
||||
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||
{ label: '渠道管理', path: '/dashboard/manager/keys' },
|
||||
],
|
||||
};
|
||||
|
||||
const breadcrumbItems = computed<Crumb[]>(() => {
|
||||
if (props.customBreadcrumbs.length > 0) {
|
||||
return props.customBreadcrumbs;
|
||||
}
|
||||
|
||||
// 获取当前路径并分割成段
|
||||
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 };
|
||||
});
|
||||
return crumbsByRouteName[String(route.name)] ?? [];
|
||||
});
|
||||
|
||||
// 显示的标题:如果提供了自定义标题则使用,否则使用当前路径的最后一段
|
||||
// 显示的标题:自定义标题 → 路由 meta.title
|
||||
const displayTitle = computed(() => {
|
||||
if (props.title) {
|
||||
return props.title;
|
||||
}
|
||||
|
||||
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';
|
||||
return (route.meta.title as string) || 'Dashboard';
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,53 +1,59 @@
|
||||
<template>
|
||||
<aside class="w-60 bg-base-100 border-r border-base-200 glass">
|
||||
<div class="flex items-center p-4 outline-none select-none backdrop-blur-lg">
|
||||
<div class="w-12 h-12 rounded-full">
|
||||
<img src='@/assets/logo.svg' class="" @click="$router.push('/')" >
|
||||
</div>
|
||||
<div class="p-0 text-2xl font-bold text-center">OpenTeam</div>
|
||||
<aside class="sticky top-0 flex h-screen w-60 flex-col border-r border-base-300/70 bg-base-100">
|
||||
<div class="flex select-none items-center gap-2.5 px-4 pb-2 pt-5">
|
||||
<img src="@/assets/logo.svg" alt="OpenTeam logo" class="h-9 w-9" @click="$router.push('/')" />
|
||||
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="menu p-4 w-60 min-h-full bg-base-100 text-base-content">
|
||||
<li v-for="item in menuItems" :key="item.label">
|
||||
<template v-if="item.type === 'link'">
|
||||
<router-link :to="item.to" :class="{ 'active': isActive(item.to) }">
|
||||
<component :is="item.icon" class="w-4" />
|
||||
<nav class="flex-1 overflow-y-auto px-2 pt-1" aria-label="主导航">
|
||||
<ul class="menu w-full gap-0.5 text-sm">
|
||||
<li class="menu-title mt-2 text-xs font-medium text-base-content/40">
|
||||
{{ isAdminArea ? '管理后台' : '控制台' }}
|
||||
</li>
|
||||
<li v-for="item in items" :key="item.to">
|
||||
<router-link :to="item.to" :class="{ 'menu-active': isActive(item.to) }" class="rounded-lg py-2.5">
|
||||
<component :is="item.icon" class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
{{ item.label }}
|
||||
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'title'">
|
||||
<span class="menu-title">
|
||||
<span>{{ item.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="item.type === 'submenu'">
|
||||
<details :open="item.open">
|
||||
<summary>
|
||||
<component :is="item.icon" class="w-4" />
|
||||
{{ item.label }}
|
||||
<div v-if="item.badge" class="badge badge-sm">{{ item.badge }}</div>
|
||||
</summary>
|
||||
<ul>
|
||||
<li v-for="subItem in (item.children as MenuLink[])" :key="subItem.label">
|
||||
<router-link :to="subItem.to" :class="{ 'active': isActive(subItem.to) }">
|
||||
<component :is="subItem.icon" class="w-4" />
|
||||
{{ subItem.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<!-- 区域互切入口:控制台 → 管理后台(仅管理员可见);管理后台 → 返回控制台 -->
|
||||
<div v-if="!isAdminArea && isAdminUser" class="border-t border-base-300/70 p-2">
|
||||
<ul class="menu w-full text-sm">
|
||||
<li>
|
||||
<router-link to="/dashboard/manager/users" class="justify-between rounded-lg py-2.5">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<ShieldIcon class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
管理后台
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-else-if="isAdminArea" class="border-t border-base-300/70 p-2">
|
||||
<ul class="menu w-full text-sm">
|
||||
<li>
|
||||
<router-link to="/dashboard/overview" class="justify-between rounded-lg py-2.5">
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<ArrowLeftIcon class="h-[18px] w-[18px]" aria-hidden="true" />
|
||||
返回控制台
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-base-content/40" aria-hidden="true" />
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { routes, generateMenuItemsFromRoutes, type MenuItem, type MenuLink } from '@/utils/router_menu'
|
||||
import { ShieldIcon, ChevronRight, ArrowLeftIcon } from '@lucide/vue';
|
||||
import { consoleMenu, adminMenu, type MenuLink } from '@/utils/router_menu';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
@@ -57,12 +63,15 @@ const userrole = computed(() => {
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
// 判断当前路由是否激活菜单项
|
||||
|
||||
// 当前处于管理后台区域(/dashboard/manager/*)还是控制台区域
|
||||
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'));
|
||||
// 仅管理员(role >= 10)可见管理后台入口
|
||||
const isAdminUser = computed(() => userrole.value >= 10);
|
||||
|
||||
const items = computed<MenuLink[]>(() => (isAdminArea.value ? adminMenu : consoleMenu));
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return route.path === path;
|
||||
// return router.currentRoute.value.fullPath.startsWith(path);
|
||||
};
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => generateMenuItemsFromRoutes(routes, userrole.value));
|
||||
|
||||
</script>
|
||||
</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 }
|
||||
}
|
||||
@@ -1,86 +1,135 @@
|
||||
<!-- src/layouts/MainLayout.vue -->
|
||||
<!-- src/layouts/DashboardLayout.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" />
|
||||
<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 flex-col">
|
||||
<div class="drawer-content flex min-h-screen flex-col">
|
||||
<!-- Top Navigation -->
|
||||
<header class="w-full navbar bg-base-100 border-b border-base-200 h-14 min-h-[3rem]">
|
||||
<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 -->
|
||||
<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>
|
||||
<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="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 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>
|
||||
<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"> -->
|
||||
<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 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 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>
|
||||
</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>
|
||||
</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="flex-1 overflow-y-auto p-6 bg-base-200 px-1 sm:px-6">
|
||||
<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">
|
||||
<label for="drawer" class="drawer-overlay"></label>
|
||||
<div class="drawer-side z-40">
|
||||
<label for="ot-drawer" class="drawer-overlay" aria-label="Close navigation menu"></label>
|
||||
<Sidebar />
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,41 +137,62 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, computed, onMounted } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import { MenuIcon, SunIcon, MoonIcon, BellIcon, User, LogOut } from '@lucide/vue'
|
||||
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 } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTheme } from '@/composables/theme'
|
||||
import type { ThemePreference } from '@/composables/theme'
|
||||
import type { UserInfo } from '@/types'
|
||||
|
||||
type NavItem = {
|
||||
name?: string;
|
||||
icon?: Component;
|
||||
type?: 'divider';
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const isDark = ref(false)
|
||||
const { preference, setPreference } = useTheme()
|
||||
const isLargeSidebarOpen = ref(true)
|
||||
|
||||
const userInfo = computed<Partial<UserInfo>>(() => {
|
||||
return authStore.user || {}
|
||||
// 移动端抽屉:路由切换后自动收起;下拉一并关闭
|
||||
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 (!userInfo.value) {
|
||||
if (!authStore.user) {
|
||||
await authStore.getProfile()
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
|
||||
const userInitials = computed(() => {
|
||||
// If name exists, use it first
|
||||
if (userInfo.value.name) {
|
||||
return userInfo.value.name
|
||||
.split(' ')
|
||||
@@ -144,16 +214,24 @@ const userInitials = computed(() => {
|
||||
return 'U';
|
||||
});
|
||||
|
||||
const userNavigation = reactive<NavItem[]>([
|
||||
{ name: 'Profile', icon: User },
|
||||
{ type: 'divider' },
|
||||
{ name: 'Logout', icon: LogOut, class: 'text-error' }
|
||||
]);
|
||||
// 仅管理员可见「管理后台」入口(已处于后台区域时不再重复显示)
|
||||
const isAdminArea = computed(() => route.path.startsWith('/dashboard/manager'))
|
||||
const isAdminUser = computed(() => (authStore.user?.role ?? 0) >= 10)
|
||||
|
||||
const handleNavigation = (item: NavItem) => {
|
||||
if (item.name === 'Profile') {
|
||||
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 (item.name === 'Logout'){
|
||||
} else if (action === 'admin') {
|
||||
router.push('/dashboard/manager/users')
|
||||
} else if (action === 'logout') {
|
||||
authStore.logout()
|
||||
}
|
||||
}
|
||||
@@ -161,9 +239,4 @@ const handleNavigation = (item: NavItem) => {
|
||||
const toggleSidebarLarge = () => {
|
||||
isLargeSidebarOpen.value = !isLargeSidebarOpen.value
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.setAttribute('data-theme', isDark.value ? 'dark' : 'emerald')
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,87 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import {
|
||||
LayoutDashboardIcon,
|
||||
GaugeIcon,
|
||||
UsersRoundIcon,
|
||||
KeyRoundIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
CommandIcon,
|
||||
BracesIcon,
|
||||
GlobeIcon,
|
||||
} from '@lucide/vue'
|
||||
|
||||
export type MenuLink = { type: 'link'; label: string; to: string; icon?: Component }
|
||||
|
||||
export type MenuItem =
|
||||
| MenuLink
|
||||
| { type: 'title'; label: string }
|
||||
| { type: 'submenu'; label: string; to?: string; icon?: Component; open?: boolean; badge?: string; children?: MenuItem[] }
|
||||
export type MenuLink = { label: string; to: string; icon?: Component }
|
||||
|
||||
// 路由 meta 扩展字段
|
||||
declare module 'vue-router' {
|
||||
@@ -30,78 +23,57 @@ declare module 'vue-router' {
|
||||
}
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', name: 'Home',component: () => import('@/views/Home.vue') },
|
||||
{ path: '/404', name: '404',component: () => import('@/views/error/NotFound.vue') },
|
||||
{ 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: '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 } },
|
||||
]},
|
||||
]},
|
||||
{ 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 function generateMenuItemsFromRoutes(routes: RouteRecordRaw[], userRole: number, parentPath = ''): MenuItem[] {
|
||||
const menuItems: MenuItem[] = [];
|
||||
// 控制台菜单(所有登录用户)
|
||||
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 },
|
||||
]
|
||||
|
||||
for (const route of routes) {
|
||||
if (route.meta && route.meta.title && route.meta.showInSidebar) {
|
||||
const fullPath = parentPath + '/' + route.path.replace(/^\//, '');
|
||||
|
||||
if (route.children && route.children.length > 0) {
|
||||
if (route.name === 'Manager' && userRole < 10) {
|
||||
continue;
|
||||
}
|
||||
const menuItem: MenuItem = {
|
||||
type: 'submenu',
|
||||
label: route.meta.title,
|
||||
to: fullPath,
|
||||
icon: route.meta.icon,
|
||||
open: route.meta.open !== undefined ? route.meta.open : false,
|
||||
badge: route.meta.badge,
|
||||
children: generateMenuItemsFromRoutes(route.children, userRole, fullPath),
|
||||
};
|
||||
|
||||
if (route.name === 'Overview') {
|
||||
menuItems.push(menuItem);
|
||||
menuItems.push({ type: 'title', label: 'Apps' });
|
||||
continue
|
||||
}
|
||||
|
||||
menuItems.push(menuItem);
|
||||
} else {
|
||||
const menuItem: MenuItem = {
|
||||
type: 'link',
|
||||
label: route.meta.title,
|
||||
to: fullPath,
|
||||
icon: route.meta.icon,
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
// 管理后台菜单(role >= 10)
|
||||
export const adminMenu: MenuLink[] = [
|
||||
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||
{ label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon },
|
||||
]
|
||||
|
||||
+123
-101
@@ -1,105 +1,149 @@
|
||||
<template>
|
||||
<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">
|
||||
<div class="container mx-auto flex justify-between items-center p-1 rounded-box">
|
||||
<div class="flex items-center h-12 w-12 rounded-full text-l">
|
||||
<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">
|
||||
<header class="fixed inset-x-0 top-0 z-50 border-b border-base-300/40 bg-base-100/80 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-2.5">
|
||||
<a href="/" class="flex select-none items-center gap-2">
|
||||
<img src="@/assets/logo.svg" alt="OpenTeam logo" width="36" height="36" class="h-9 w-9" />
|
||||
<span class="text-lg font-bold tracking-tight">OpenTeam</span>
|
||||
</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 下拉打开时的透明遮罩:点击任意处关闭(触屏可靠) -->
|
||||
<div v-if="themeMenuOpen" class="fixed inset-0 z-40" aria-hidden="true" @click="themeMenuOpen = false"></div>
|
||||
<div class="dropdown dropdown-end" :class="{ 'dropdown-open': themeMenuOpen }">
|
||||
<label id="theme-menu-label" class="sr-only">切换主题</label>
|
||||
<button class="btn btn-ghost btn-sm btn-square h-9 w-9 min-h-9 rounded-full"
|
||||
aria-labelledby="theme-menu-label" :aria-expanded="themeMenuOpen" @click="themeMenuOpen = !themeMenuOpen">
|
||||
<SunIcon v-if="preference === 'light'" class="size-4" aria-hidden="true" />
|
||||
<MoonIcon v-else-if="preference === 'dark'" class="size-4" aria-hidden="true" />
|
||||
<MonitorIcon v-else class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<ul class="menu dropdown-content z-50 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||
<li>
|
||||
<button :class="{ 'menu-active': preference === 'light' }" @click="selectTheme('light')"
|
||||
:aria-pressed="preference === 'light'">
|
||||
<SunIcon class="size-4" aria-hidden="true" />浅色
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button :class="{ 'menu-active': preference === 'dark' }" @click="selectTheme('dark')"
|
||||
:aria-pressed="preference === 'dark'">
|
||||
<MoonIcon class="size-4" aria-hidden="true" />深色
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button :class="{ 'menu-active': preference === 'auto' }" @click="selectTheme('auto')"
|
||||
:aria-pressed="preference === 'auto'">
|
||||
<MonitorIcon class="size-4" aria-hidden="true" />自动
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer"
|
||||
class="btn btn-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">
|
||||
<Icon icon="simple-icons:github" class="size-5" />
|
||||
<span>Star on GitHub</span>
|
||||
<Icon icon="mingcute:star-fill" class="size-5 text-yellow-500 mb-0.5" />
|
||||
<div class="font-extralight">{{ star }}</div>
|
||||
class="btn btn-ghost btn-sm h-9 min-h-9 gap-2 rounded-full">
|
||||
<Icon icon="simple-icons:github" class="size-4" aria-hidden="true" />
|
||||
<span class="hidden sm:inline">Star</span>
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-base-200 px-2 py-0.5 text-xs font-medium tabular-nums">
|
||||
<Icon icon="mingcute:star-fill" class="size-3.5 text-warning" aria-hidden="true" />
|
||||
{{ star }}
|
||||
</span>
|
||||
</a>
|
||||
<a class="hidden btn btn-outline font-extralight btn-md h-10 min-h-10 hover:bg-black px-1 ml-2"
|
||||
@click="$router.push('/dashboard')">Dashboard</a>
|
||||
<RouterLink to="/dashboard" class="btn btn-primary btn-sm h-9 min-h-9 rounded-full px-4">
|
||||
Open Dashboard
|
||||
</RouterLink>
|
||||
</div>
|
||||
</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-4 outline-none select-none">
|
||||
<img src="@/assets/openteam.png" alt="Project Logo" class="h-40">
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold mb-4">
|
||||
<a class="text-gray-600" href="https://github.com/mirrors2/opencatd-open">OpenTeam</a>
|
||||
</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>
|
||||
<main class="mx-auto max-w-5xl px-4 pb-16 pt-24">
|
||||
<!-- Hero -->
|
||||
<section class="flex flex-col items-center text-center">
|
||||
<img src="@/assets/openteam.png" alt="OpenTeam project logo" width="160" height="160" class="h-28 w-auto select-none" fetchpriority="high" />
|
||||
<h1 class="mt-4 text-4xl font-bold tracking-tight text-balance sm:text-5xl">OpenTeam</h1>
|
||||
<p class="mt-4 max-w-2xl text-pretty text-lg text-base-content/70">
|
||||
An open-source, team-shared service compatible with the OpenAI API — share LLM API usage across your team through a single endpoint.
|
||||
</p>
|
||||
|
||||
<div class="card mb-8">
|
||||
<div class="card-body px-1 flex flex-col items-center">
|
||||
<h2 class="card-title text-xl font-extralight justify-center flex mb-4 p-2 rounded-lg border-2 border-dotted hover:cursor-alias"
|
||||
@click="$router.push('/dashboard')">开始使用</h2>
|
||||
|
||||
<p class="mb-0">使用OpenTeam 管理你的LLM API,仅需一个地址即可接入不同的大模型</p>
|
||||
<LineSegmentFlow />
|
||||
<div class="tooltip mt-8 w-full max-w-xl" data-tip="Point your client at the OpenTeam base URL">
|
||||
<label class="mb-1.5 block text-left text-xs font-medium uppercase tracking-wider text-base-content/50" for="endpoint-url">
|
||||
Team API Endpoint
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<input id="endpoint-url" type="text" readonly aria-label="Team API endpoint URL"
|
||||
class="input input-bordered join-item grow font-mono text-sm" v-model="url" />
|
||||
<button class="btn btn-primary join-item" @click="copyUrl">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card backdrop-blur-sm shadow-xl mb-8">
|
||||
<div class="card-body">
|
||||
<p class="mb-2">欢迎加入我们的Telegram频道,获取最新动态和帮助</p>
|
||||
<div class="flex justify-center mb-4">
|
||||
<a href="https://t.me/OpenTeamLLM" target="_blank" class="tooltip tooltip-bottom backdrop-blur-0" data-tip="Telegram Channel">
|
||||
<img src="@/assets/openteam_channel.jpg" alt="Telegram Group QR Code"
|
||||
class="w-40 fill-current backdrop-blur-0 select-none">
|
||||
</a>
|
||||
|
||||
<p class="mt-6 text-sm text-base-content/60">
|
||||
👉 Api-Keys:
|
||||
<a href="https://platform.openai.com/account/api-keys" class="link link-hover link-primary">platform.openai.com/account/api-keys</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section class="mt-12">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body items-center px-4 pb-2 pt-6 text-center sm:px-8">
|
||||
<h2 class="text-xl font-semibold tracking-tight text-pretty">One endpoint, every model</h2>
|
||||
<p class="max-w-lg text-sm text-base-content/60">
|
||||
Connect clients on the left with LLM providers on the right — OpenTeam routes and accounts for every request.
|
||||
</p>
|
||||
<div class="w-full" aria-hidden="true">
|
||||
<LineSegmentFlow />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</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>
|
||||
|
||||
<footer class="footer footer-center text-base-content rounded p-10">
|
||||
<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 class="border-t border-base-300/40 bg-base-100 pb-10 pt-8 text-center text-sm text-base-content/60">
|
||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" aria-label="OpenTeam on GitHub" class="inline-flex p-1">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" class="fill-current" aria-hidden="true">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||
</svg>
|
||||
</a>
|
||||
<p class="mt-3">
|
||||
Copyright © {{ currentYear }} Mirrors2 ·
|
||||
<a href="https://github.com/mirrors2/opencatd-open" target="_blank" rel="noopener noreferrer" class="link link-hover">opencatd-open</a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { SunIcon, MoonIcon, MonitorIcon } from '@lucide/vue';
|
||||
import LineSegmentFlow from '@/components/common/LineSegmentFlow.vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import { useTheme } from '@/composables/theme';
|
||||
import type { ThemePreference } from '@/composables/theme';
|
||||
|
||||
const { preference, setPreference } = useTheme();
|
||||
|
||||
// 下拉状态驱动(dropdown-open),触屏点选可靠
|
||||
const themeMenuOpen = ref(false);
|
||||
|
||||
// daisyUI 下拉基于焦点展开:选择后移除焦点收起菜单
|
||||
const selectTheme = (p: ThemePreference) => {
|
||||
setPreference(p);
|
||||
themeMenuOpen.value = false;
|
||||
};
|
||||
|
||||
const currentYear = ref('');
|
||||
const url = ref('');
|
||||
@@ -108,15 +152,15 @@ const { setToast } = useToast();
|
||||
const copyUrl = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url.value);
|
||||
setToast('复制成功!', 'info');
|
||||
setToast('Copied to clipboard.', 'success');
|
||||
} catch (err: any) {
|
||||
setToast('复制失败,请手动复制。', 'error');
|
||||
setToast('Copy failed — select the text and copy manually.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const star = ref(0);
|
||||
const getGithubStars = async (): Promise<number> => {
|
||||
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam', { next: { revalidate: 3600 } } as RequestInit);
|
||||
const res = await fetch('https://ungh.cc/repos/mirrors2/openteam');
|
||||
const data = await res.json();
|
||||
return data.repo.stars;
|
||||
};
|
||||
@@ -127,29 +171,7 @@ onMounted(() => {
|
||||
|
||||
getGithubStars().then((stars) => {
|
||||
star.value = stars;
|
||||
});
|
||||
}).catch(() => { /* 徽标数字失败可忽略 */ });
|
||||
|
||||
});
|
||||
</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,103 +1,60 @@
|
||||
<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('/')" />
|
||||
<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>
|
||||
|
||||
<h2 class="card-title text-md sm:text-xl mb-6 justify-center flex">
|
||||
Log in to Dashboard
|
||||
</h2>
|
||||
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Log in to Dashboard</h1>
|
||||
|
||||
<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"
|
||||
<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 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"
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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-sm">OR</div>
|
||||
<div class="divider my-2 text-xs text-base-content/40">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 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>
|
||||
|
||||
</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">
|
||||
<p class="mt-5 text-center text-sm text-base-content/60">
|
||||
Don't have an account?
|
||||
<router-link to="/signup" class="link link-primary link-hover">
|
||||
Sign up
|
||||
</router-link>
|
||||
</div>
|
||||
<RouterLink to="/signup" class="link link-primary link-hover">Sign up</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -106,10 +63,10 @@
|
||||
<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';
|
||||
// import request from '@/api/client';
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore();
|
||||
@@ -117,6 +74,7 @@ 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') || '',
|
||||
@@ -141,6 +99,7 @@ const errMsg = (err: any): string => {
|
||||
|
||||
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) {
|
||||
@@ -153,38 +112,31 @@ const handleLogin = async () => {
|
||||
localStorage.removeItem('password');
|
||||
localStorage.removeItem('rember');
|
||||
}
|
||||
setToast('登录成功', 'success');
|
||||
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('登录成功', 'success');
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</script>
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
<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('/')"/>
|
||||
<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>
|
||||
|
||||
<h2 class="card-title text-md sm:text-xl mb-2 justify-center flex">
|
||||
Create Your Account
|
||||
</h2>
|
||||
<h1 class="mt-2 text-center text-xl font-semibold tracking-tight">Create Your Account</h1>
|
||||
|
||||
<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 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 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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
||||
<div class="text-center mt-4 text-sm">
|
||||
<p class="mt-5 text-center text-sm text-base-content/60">
|
||||
Already have an account?
|
||||
<router-link to="/login" class="link link-primary link-hover">
|
||||
Login
|
||||
</router-link>
|
||||
</div>
|
||||
<RouterLink to="/login" class="link link-primary link-hover">Log in</RouterLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,6 +57,7 @@
|
||||
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()
|
||||
@@ -64,6 +65,7 @@ const authStore = useAuthStore()
|
||||
const { setToast } = useToast();
|
||||
|
||||
const error = ref('');
|
||||
const registering = ref(false);
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -71,24 +73,27 @@ const confirmPassword = ref('');
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (password.value !== confirmPassword.value) {
|
||||
alert("密码不一致");
|
||||
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 || '已注册', 'success');
|
||||
setToast(res.data?.msg || 'Account created.', 'success');
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 100);
|
||||
error.value = ''
|
||||
}
|
||||
} catch (e) {
|
||||
} catch (e: any) {
|
||||
error.value = 'Registration failed. Please try again.'
|
||||
} finally {
|
||||
registering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,196 +1,154 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader />
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
|
||||
</header>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6 text-center md:text-left">
|
||||
<h1 class="text-2xl font-semibold text-base-content">
|
||||
Create New API Key
|
||||
</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" 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> -->
|
||||
<TagInput v-model="newApiKey.support_models_array" 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 v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1">{{ error }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createApiKey">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="newApiKey.name" placeholder="e.g. prod-openai"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="newApiKey.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(newApiKey.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey"
|
||||
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint"
|
||||
placeholder="https://api.example.com/v1" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||
Advanced Options
|
||||
</div>
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name"
|
||||
placeholder="Azure resource name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret"
|
||||
placeholder="Optional secret" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="newApiKey.model_prefix"
|
||||
placeholder="e.g. azure-gpt" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="newApiKey.model_alias" rows="2"
|
||||
placeholder='{"gpt-4o": "my-gpt4o"}' spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="newApiKey.parameters" rows="2"
|
||||
placeholder="{}" spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="newApiKey.support_models_array" clearable
|
||||
placeholder="Type a model and press Enter" @change="onchange_supportmodel" />
|
||||
<span class="mt-1 block text-xs text-base-content/50">Restrict which models this key can serve. Empty allows all.</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newApiKey.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newApiKey.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Key active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import TagInput from '@/components/common/TagInput.vue';
|
||||
import { CircleAlert } from '@lucide/vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { NewApiKeyPayload } from '@/types';
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
|
||||
// Control advanced options visibility
|
||||
const showAdvancedOptions = ref(false)
|
||||
@@ -265,6 +223,7 @@ const createApiKey = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
try {
|
||||
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||
@@ -288,8 +247,7 @@ const createApiKey = async () => {
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetNewApiKey();
|
||||
setToast('API Key created successfully', 'success')
|
||||
// Optionally navigate or reset form
|
||||
setToast('API Key created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
} else {
|
||||
setToast(res.data?.error || res.data?.message || 'Failed to create API Key', 'error')
|
||||
@@ -297,7 +255,8 @@ const createApiKey = async () => {
|
||||
} catch (err: any) {
|
||||
console.log('createApiKey error:', err)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,12 +266,3 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
</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,166 +1,140 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader title="Key Details" />
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader title="渠道详情" />
|
||||
|
||||
<div class="divider mt-1 mb-0"></div>
|
||||
<div class="card border border-base-300/40 shadow-sm"v-if="key" >
|
||||
<form @submit.prevent="updateKey" 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>
|
||||
<div v-if="key" class="space-y-5">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="name" type="text" v-model="key.name" placeholder="API Key Name"
|
||||
class="input input-sm input-bordered w-full" required />
|
||||
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="apitype" class="label">
|
||||
<span class="label-text">
|
||||
Type <span class="text-red-500">*</span>
|
||||
</span>
|
||||
<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="apitype" v-model="key.type" class="select select-sm select-bordered w-full pl-10"
|
||||
required>
|
||||
<option class="disabled" value="" selected>Select API Type</option>
|
||||
<select id="key-type" name="type" v-model="key.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">Github</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</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"
|
||||
id="password-visibility-toggle">
|
||||
<img :src="apiKeyImageUrl(key.type)" class="w-5 h-5" alt="">
|
||||
</button>
|
||||
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="apikey" class="label">
|
||||
<span class="label-text">
|
||||
API Key <span class="text-red-500">*</span>
|
||||
</span>
|
||||
<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="apikey" type="text" v-model="key.apikey" placeholder="Your API Key"
|
||||
class="input input-sm input-bordered w-full" required />
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="endpoint" class="label">
|
||||
<span class="label-text">Endpoint</span>
|
||||
</label>
|
||||
<input id="endpoint" type="text" v-model="key.endpoint" placeholder="API Endpoint URL"
|
||||
class="input input-sm input-bordered w-full" />
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
<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
|
||||
</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="key.resource_name" placeholder="Resource Name"
|
||||
class="input input-sm input-bordered w-full" />
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<!-- <div 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="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>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div 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="key.model_prefix" placeholder="Model Prefix"
|
||||
class="input input-sm input-bordered w-full" />
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="model_alias" class="label">
|
||||
<span class="label-text">Model Alias</span>
|
||||
</label>
|
||||
<textarea id="model_alias" v-model="key.model_alias" placeholder="{}"
|
||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label for="parameters" class="label">
|
||||
<span class="label-text">Parameters (JSON)</span>
|
||||
</label>
|
||||
<textarea id="parameters" v-model="key.parameters" placeholder="{}"
|
||||
class="textarea textarea-sm textarea-bordered w-full"></textarea>
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<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="key.support_models_text"
|
||||
placeholder='["model1", "model2"]' class="textarea textarea-sm textarea-bordered w-full"></textarea> -->
|
||||
<TagInput v-model="key.support_models_array" clearable
|
||||
placeholder="Please input" @change="onchange_supportmodel" />
|
||||
</div>
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
|
||||
@change="onchange_supportmodel" />
|
||||
</label>
|
||||
|
||||
<div 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="key.active"
|
||||
:class="`toggle toggle-sm ${key.active ? 'toggle-success' : 'toggle-error'}`" />
|
||||
<span class="text-sm text-base-content/90">
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
|
||||
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ key.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4 items-center gap-2">
|
||||
<button @click="cancel" class="btn btn-sm btn-outline">Back</button>
|
||||
<button type="submit" class="btn btn-outline btn-sm px-4 text-sm font-medium btn-success">
|
||||
Update
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive } from 'vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { useKeyStore } from '../../stores/key';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import TagInput from '@/components/common/TagInput.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
@@ -169,6 +143,7 @@ const route = useRoute();
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
|
||||
const keyId = computed(() => route.query.id);
|
||||
|
||||
@@ -210,6 +185,7 @@ const onchange_supportmodel = () => {
|
||||
|
||||
const updateKey = async () => {
|
||||
if (!key.value) return;
|
||||
updating.value = true;
|
||||
try {
|
||||
const res = await keyStore.updateKey(key.value);
|
||||
console.log('updateKey', res)
|
||||
@@ -219,6 +195,8 @@ const updateKey = async () => {
|
||||
await keyStore.refreshKey(key.value.id);
|
||||
} catch (err: any) {
|
||||
console.error('Error updating key:', err);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,4 +204,4 @@ const cancel = () => {
|
||||
router.push({ name: 'ApiKey' });
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,155 +1,135 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-4">
|
||||
<!-- Breadcrumb and Title -->
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<!-- <div v-if="keyStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
||||
<div class="flex flex-wrap gap-2 mb-4" v-if="keys">
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
<input
|
||||
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]"
|
||||
placeholder="Filter" value="">
|
||||
|
||||
<div class="dropdown">
|
||||
<label tabindex="0"
|
||||
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">
|
||||
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
||||
<path fill="currentColor" fill-rule="evenodd"
|
||||
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"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Status
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
||||
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
||||
<a class="px-2 mx-0 hover:rounded-none">
|
||||
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
||||
{{ status }}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Upstream provider keys used to serve model requests.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||
<li>
|
||||
<button class="text-error" @click="handleBatchAction('delete')">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleBatchAction('disable')">
|
||||
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleBatchAction('enable')">
|
||||
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div 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>
|
||||
|
||||
<!-- Status filter -->
|
||||
<details class="dropdown dropdown-end" v-if="keys.length">
|
||||
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Status
|
||||
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||
</summary>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||
<li v-for="status in statusOptions" :key="status">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" :checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" />
|
||||
{{ status }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
||||
<table class="table table-sm">
|
||||
<!-- Table Header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="flex gap-1 items-center">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
||||
</div>
|
||||
</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<!-- <th>Key</th> -->
|
||||
<!-- <th>Endpoint</th> -->
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- 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">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="keys.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||
aria-label="Select all keys" />
|
||||
</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
||||
@change="toggleUserSelection(key)" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs dark:text-white">
|
||||
<div class="flex gap-1 items-center">
|
||||
<span class="backdrop-blur-lg glass rounded-full border-none"> <img :src="displayIcon(key.type)"
|
||||
class="w-5 h-5" alt=""></span>
|
||||
{{ key.type }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs dark:text-white">{{ key.name }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<input type="checkbox" class="toggle toggle-xs" :class="key.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="key.active" @change="updateStatus(key)" />
|
||||
</div>
|
||||
</td>
|
||||
<!-- <td class="text-xs dark:text-white">{{ key.apikey }}</td> -->
|
||||
<!-- <td class="text-xs dark:text-white">{{ key.endpoint }}</td> -->
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewKey(key)">
|
||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" />
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="displayIcon(key.type)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||
<span class="text-sm capitalize">{{ key.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-40 truncate font-medium">{{ key.name }}</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)"
|
||||
:aria-label="`Toggle key ${key.name}`" />
|
||||
</td>
|
||||
<td class="pr-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewKey(key)" aria-label="View key details">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)"
|
||||
aria-label="Delete key">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
||||
@click="confirmDeleteKey(key)">
|
||||
<TrashIcon class="w-4 h-4 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Add an OpenAI, Claude, Gemini or compatible provider key to start serving requests.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New key modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<KeyNew @closeModal="closeModal" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -165,7 +145,7 @@ import type { ApiKey } from '@/types';
|
||||
|
||||
import {
|
||||
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||
TrashIcon, Infinity
|
||||
TrashIcon, KeyRoundIcon, ListFilterIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -188,7 +168,6 @@ const totalItems = computed(() => keyStore.totalKeys);
|
||||
// 封装公共的用户列表获取方法
|
||||
const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -335,6 +314,9 @@ const displayIcon = (apitype: string) => {
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
|
||||
@@ -1,150 +1,122 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-3 md:p-6">
|
||||
<div class="space-y-6">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="card shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl mb-6" >
|
||||
<div :class="['card-body text-white', getGradientClass()]">
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4 sm:gap-6">
|
||||
<div class="avatar">
|
||||
<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-if="!user?.avatar_url">
|
||||
<div
|
||||
class="bg-white text-primary-content font-bold flex items-center justify-center w-full h-full">
|
||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<!-- Welcome hero(随时间段切换配色) -->
|
||||
<section
|
||||
class="relative overflow-hidden rounded-2xl bg-gradient-to-br text-white shadow-sm"
|
||||
:class="getTimeTheme()">
|
||||
<div class="flex flex-col items-center gap-4 p-6 text-center sm:flex-row sm:p-8 sm:text-left">
|
||||
<div class="placeholder avatar">
|
||||
<div
|
||||
class="flex w-16 items-center justify-center rounded-full bg-white/15 ring-2 ring-white/25 sm:w-20">
|
||||
<span v-if="!user?.avatar_url" class="text-2xl font-bold sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
<img v-else :src="user?.avatar_url" :alt="user?.name || user?.username || 'Avatar'"
|
||||
class="h-full w-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
基本信息
|
||||
<div>
|
||||
<h2 class="text-xl font-bold tracking-tight text-balance sm:text-2xl">
|
||||
<span class="mr-1.5" aria-hidden="true">👋</span>{{ getTimeOfDay() }},{{ user?.name || user?.username }}
|
||||
</h2>
|
||||
<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">{{ 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) > 0 ? 'badge-warning' : 'badge-ghost'">{{
|
||||
getRoleName(user?.role || 0) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-sm text-white/80">欢迎回到您的个人仪表盘</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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="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" />
|
||||
</svg>
|
||||
账户状态
|
||||
</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 class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- 基本信息 -->
|
||||
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="card-title pb-1 text-base font-semibold">基本信息</h3>
|
||||
<dl class="divide-y divide-base-300/40 text-sm">
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">用户名</dt>
|
||||
<dd class="truncate font-medium">{{ user?.username }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base-content/70 w-20">时区</span>
|
||||
<span class="badge ">{{ user?.timezone || 'UTC' }}</span>
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">显示名称</dt>
|
||||
<dd class="truncate font-medium">{{ user?.name || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base-content/70 w-20">语言</span>
|
||||
<span class="badge">{{ user?.language || 'en' }}</span>
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">邮箱</dt>
|
||||
<dd class="truncate font-medium">{{ user?.email || '—' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base-content/70 w-20">最后活动</span>
|
||||
<span class="badge">{{ getLastActive() }}</span>
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">角色</dt>
|
||||
<dd>
|
||||
<span class="badge badge-sm"
|
||||
:class="(user?.role || 0) > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">{{
|
||||
getRoleName(user?.role || 0) }}</span>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</dl>
|
||||
</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">
|
||||
<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="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" />
|
||||
</svg>
|
||||
配额信息
|
||||
</h2>
|
||||
<div class="divider my-1"></div>
|
||||
<div class="space-y-4">
|
||||
<div v-if="user?.unlimited_quota">
|
||||
<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>
|
||||
<!-- 账户状态 -->
|
||||
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="card-title pb-1 text-base font-semibold">账户状态</h3>
|
||||
<dl class="divide-y divide-base-300/40 text-sm">
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">状态</dt>
|
||||
<dd>
|
||||
<span class="badge badge-sm"
|
||||
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user?.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="flex flex-wrap justify-between mb-2">
|
||||
<span class="font-semibold text-base-content/70">使用量</span>
|
||||
<span :class="getQuotaColor">
|
||||
{{ 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">{{ user?.timezone || 'UTC' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">语言</dt>
|
||||
<dd class="font-medium uppercase">{{ user?.language || 'en' }}</dd>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 py-2.5">
|
||||
<dt class="shrink-0 text-base-content/60">最后活动</dt>
|
||||
<dd class="font-medium tabular-nums">{{ getLastActive() }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- 配额信息 -->
|
||||
<article class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body p-5">
|
||||
<h3 class="card-title pb-1 text-base font-semibold">配额信息</h3>
|
||||
<div class="flex-1 space-y-4 pt-1">
|
||||
<div v-if="user?.unlimited_quota" class="flex items-center justify-between text-sm">
|
||||
<span class="text-base-content/60">使用量</span>
|
||||
<span class="badge badge-success badge-soft">无限制</span>
|
||||
</div>
|
||||
<div v-else class="space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-base-content/60">使用量</span>
|
||||
<span class="font-medium tabular-nums" :class="getQuotaColor">
|
||||
{{ formatQuota(user?.used_quota || 0, user?.quota || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<progress class="progress w-full"
|
||||
:class="getQuotaColorClass"
|
||||
:value="quotaPercentage"
|
||||
max="100"></progress>
|
||||
<progress class="progress w-full" :class="getQuotaColorClass" :value="quotaPercentage"
|
||||
max="100" :aria-label="`Quota used ${quotaPercentage.toFixed(1)}%`"></progress>
|
||||
</div>
|
||||
|
||||
<div class="stats stats-vertical shadow w-full bg-base-100">
|
||||
<div class="stat p-2 md:p-4">
|
||||
<div class="stat-title text-xs md:text-sm">创建时间</div>
|
||||
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.created_at) }}
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 border-t border-base-300/40 pt-3">
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">创建时间</p>
|
||||
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.created_at) }}</p>
|
||||
</div>
|
||||
<div class="stat p-2 md:p-4">
|
||||
<div class="stat-title text-xs md:text-sm">更新时间</div>
|
||||
<div class="stat-value text-sm md:text-base">{{ formatDateTime(user?.updated_at) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-base-content/50">更新时间</p>
|
||||
<p class="mt-0.5 text-sm font-medium tabular-nums">{{ formatDateTime(user?.updated_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -169,11 +141,21 @@ onMounted(async () => {
|
||||
|
||||
const getTimeOfDay = (): string => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 12) return '早上好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
};
|
||||
|
||||
// 横幅随时间段切换配色:白色文字位于渐变左侧深色端,保证对比度
|
||||
const getTimeTheme = (): string => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return 'from-slate-900 via-slate-800 to-indigo-950'; // 深夜
|
||||
if (hour < 12) return 'from-sky-600 via-sky-500 to-amber-400'; // 早晨
|
||||
if (hour < 18) return 'from-blue-600 via-sky-500 to-cyan-400'; // 白天
|
||||
return 'from-slate-900 via-indigo-950 to-slate-800'; // 夜晚
|
||||
};
|
||||
|
||||
const formatQuota = (used: number, total: number): string => {
|
||||
if (total === 0) return '无限制';
|
||||
|
||||
@@ -215,15 +197,6 @@ function formatDateTime(unixTimestamp?: number): string {
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 获取背景渐变类
|
||||
const getGradientClass = (): string => {
|
||||
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'; // 夜晚:纯黑到深灰
|
||||
};
|
||||
|
||||
|
||||
|
||||
// 计算配额百分比
|
||||
@@ -269,23 +242,3 @@ const getLastActive = (): string => {
|
||||
return '刚刚';
|
||||
};
|
||||
</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>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader title="User Details" />
|
||||
<div class="mx-auto max-w-3xl space-y-5">
|
||||
<BreadcrumbHeader title="账户设置" />
|
||||
|
||||
<div class="max-w-3xl mx-auto space-y-4">
|
||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||
<div class="card-body px-2 sm:px-8">
|
||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-6">
|
||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
<div v-if="user" class="space-y-5">
|
||||
<!-- Basic Information -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="avatar" v-else>
|
||||
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow">
|
||||
<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 class="min-w-0">
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name }}</h2>
|
||||
<p class="truncate text-sm text-base-content/60">{{ user?.username }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right sm:text-left">
|
||||
<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'">
|
||||
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<span class="font-medium text-base-content/90">Quota:</span>
|
||||
<template v-if="user.unlimited_quota">
|
||||
<Infinity class="inline-block w-9 text-base-content/70" />
|
||||
<span class="text-sm text-base-content/70">({{ user.used_quota }} used)</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-sm text-base-content/70">{{ user.used_quota }} / {{ user.quota
|
||||
}}</span>
|
||||
</template>
|
||||
<span class="flex items-center gap-1 text-base-content/60">
|
||||
Quota:
|
||||
<template v-if="user.unlimited_quota">
|
||||
<Infinity class="inline h-4 w-4" aria-label="Unlimited" />
|
||||
<span class="tabular-nums">({{ user.used_quota }} used)</span>
|
||||
</template>
|
||||
<span v-else class="tabular-nums">{{ user.used_quota }} / {{ user.quota }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="confirmUpdateBasicInfo" class="space-y-4 border-t border-base-300/40 pt-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
|
||||
<div v-if="basicinfo_error" role="alert" class="alert alert-error text-sm">
|
||||
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1">{{ basicinfo_error }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="basicinfo_error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="pf-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||
<input id="pf-name" name="name" type="text" v-model="basicinfo.name" placeholder="Full name"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pf-username" class="mb-1 block text-sm font-medium">
|
||||
Username <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="pf-username" name="username" type="text" v-model="basicinfo.username"
|
||||
spellcheck="false" autocomplete="off" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pf-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||
<div class="relative">
|
||||
<input id="pf-email" name="email" type="email" v-model="basicinfo.email"
|
||||
placeholder="name@example.com" spellcheck="false"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
aria-label="Email verification status">
|
||||
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||
<span v-else class="tooltip tooltip-left" data-tip="Send verification email">
|
||||
<Send class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="savingBasic">
|
||||
<span v-if="savingBasic" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Update Information
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Password</h3>
|
||||
|
||||
<div v-if="password_error" role="alert" class="alert alert-error text-sm">
|
||||
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1">{{ password_error }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="password_error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="divider mt-4 mb-0"></div>
|
||||
|
||||
<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>
|
||||
<form @submit.prevent="updatePassword" class="space-y-4">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-3">
|
||||
<div>
|
||||
<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>
|
||||
<button class="btn btn-sm" @click="basicinfo_error = null">X</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<label for="pf-new-password" class="mb-1 block text-sm font-medium">New Password</label>
|
||||
<div class="relative">
|
||||
<input id="email" type="email" v-model="basicinfo.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 email verification">
|
||||
<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>
|
||||
<input id="pf-new-password" name="new_password" :type="isNewPasswordVisible ? 'text' : 'password'"
|
||||
v-model="passwordData.newPassword" placeholder="At least 4 characters" autocomplete="new-password"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleNewPasswordVisibility" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isNewPasswordVisible ? 'Hide new password' : 'Show new password'">
|
||||
<EyeOff v-if="!isNewPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="pf-confirm-password" class="mb-1 block text-sm font-medium">Confirm New Password</label>
|
||||
<div class="relative">
|
||||
<input id="pf-confirm-password" name="confirm_password" :type="isConfirmPasswordVisible ? 'text' : 'password'"
|
||||
v-model="passwordData.confirmPassword" placeholder="Retype new password" autocomplete="new-password"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleConfirmPasswordVisibility" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isConfirmPasswordVisible ? 'Hide confirmed password' : 'Show confirmed password'">
|
||||
<EyeOff v-if="!isConfirmPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="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="card-body space-y-4 space-y-4 px-2 sm:px-8">
|
||||
<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" /> 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">
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="btn btn-outline btn-warning btn-sm px-5" :disabled="changingPassword">
|
||||
<span v-if="changingPassword" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Change Password
|
||||
</button>
|
||||
</div>
|
||||
@@ -194,76 +150,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||
<div class="card-body space-y-4 px-2 sm:px-8">
|
||||
<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" /> Passkeys
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-base-content/80">Manage your passkeys for secure and passwordless login.</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>
|
||||
<!-- Passkeys -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-4 p-4 sm:p-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Passkeys</h3>
|
||||
<p class="mt-1 text-sm text-base-content/60">Sign in securely without a password.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-actions justify-end pt-4">
|
||||
<button class="btn btn-outline btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth">
|
||||
Add Passkey
|
||||
<button class="btn btn-primary btn-sm" @click="newpasskey" :disabled="!supportWebAuth"
|
||||
:aria-label="supportWebAuth ? 'Add passkey' : 'WebAuthn not supported in this browser'">
|
||||
<Fingerprint class="h-4 w-4" aria-hidden="true" />Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="passkeys && passkeys.length" class="-mx-4 overflow-x-auto sm:-mx-6">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Name</th>
|
||||
<th>Create Time</th>
|
||||
<th>Sign Count</th>
|
||||
<th>Device</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="passkey in passkeys" :key="passkey.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ passkey.name }}</td>
|
||||
<td class="tabular-nums text-base-content/70">{{ formatDateTime(passkey.created_at) }}</td>
|
||||
<td class="tabular-nums">{{ passkey.sign_count }}</td>
|
||||
<td class="text-base-content/70">{{ passkey.device_type }}</td>
|
||||
<td class="pr-4 text-right">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmRmovePasskey(passkey)" aria-label="Delete passkey">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-lg border border-dashed border-base-300 py-8 text-center">
|
||||
<Fingerprint class="mx-auto h-8 w-8 text-base-content/20" aria-hidden="true" />
|
||||
<p class="mt-2 text-sm text-base-content/50">No passkeys registered yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||
<div class="card-body space-y-4 px-2 sm:px-8">
|
||||
<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" /> Linked Accounts (todo)
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/80">Manage your linked social accounts for easier login.</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="border rounded-md p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/assets/github.svg" alt="GitHub" class="w-6 h-6 text-base-content/80" />
|
||||
<!-- Linked accounts (todo) -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-4 p-4 sm:p-6">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Linked Accounts</h3>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<img src="/assets/github.svg" alt="GitHub" width="24" height="24" class="h-6 w-6" />
|
||||
<span>GitHub</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" :class="isGithubConnected ? 'btn-success' : 'btn-outline'">
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="toggleGithubConnection">
|
||||
{{ isGithubConnected ? 'Disconnect' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="border rounded-md p-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Send class="w-6 h-6 fill-current text-gray-500" />
|
||||
<div class="flex items-center justify-between rounded-lg border border-base-300/60 p-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<Send class="h-5 w-5 fill-current text-base-content/50" aria-hidden="true" />
|
||||
<span>Telegram</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" :class="isTelegramConnected ? 'btn-success' : 'btn-outline'">
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="toggleTelegramConnection">
|
||||
{{ isTelegramConnected ? 'Disconnect' : 'Connect' }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -271,15 +226,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading profile">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Bookmark, Infinity, Info } from '@lucide/vue';
|
||||
import { Eye, EyeOff, BadgeCheck, Send, TrashIcon, Infinity, CircleAlert, Fingerprint } from '@lucide/vue';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useWebAuthStore } from '@/stores/webauth';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import { useWebAuthStore } from '../../stores/webauth';
|
||||
import { formatDateTime } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { UserInfo, PasskeyInfo } from '@/types';
|
||||
@@ -288,11 +252,12 @@ const authStore = useAuthStore();
|
||||
const webAuthStore = useWebAuthStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
const loading = computed(() => authStore.loading);
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const basicinfo_error = ref<string | null>(null);
|
||||
const password_error = ref<string | null>(null);
|
||||
const savingBasic = ref(false);
|
||||
const changingPassword = ref(false);
|
||||
|
||||
const basicinfo = ref({
|
||||
name: user.value?.name || '',
|
||||
@@ -335,11 +300,12 @@ const confirmUpdateBasicInfo = () => {
|
||||
|
||||
const updateBasicInfo = async () => {
|
||||
if (!basicinfo.value) return;
|
||||
savingBasic.value = true;
|
||||
try {
|
||||
console.log('updateBasicInfo', basicinfo.value);
|
||||
const res = await authStore.updateProfile(basicinfo.value);
|
||||
if (res.data?.code == 200) {
|
||||
setToast('Basic information updated successfully', 'success');
|
||||
setToast('Basic information updated successfully.', 'success');
|
||||
}
|
||||
|
||||
await authStore.refreshProfile(); // Refresh user data
|
||||
@@ -348,6 +314,8 @@ const updateBasicInfo = async () => {
|
||||
console.log('Error updating basic info:', err);
|
||||
basicinfo_error.value = err?.message || String(err) || '更新失败';
|
||||
setToast('Failed to update basic information', 'error');
|
||||
} finally {
|
||||
savingBasic.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -357,6 +325,7 @@ const updatePassword = async () => {
|
||||
setToast('新密码和确认密码不匹配', 'error');
|
||||
return;
|
||||
}
|
||||
changingPassword.value = true;
|
||||
try {
|
||||
const payload = {
|
||||
password: passwordData.value.oldPassword,
|
||||
@@ -365,7 +334,7 @@ const updatePassword = async () => {
|
||||
console.log('payload', payload)
|
||||
const res = await authStore.updatePassword(payload);
|
||||
if (res.data?.code == 200) {
|
||||
setToast('Password updated successfully', 'success');
|
||||
setToast('Password updated successfully.', 'success');
|
||||
}
|
||||
passwordData.value.oldPassword = '';
|
||||
passwordData.value.newPassword = '';
|
||||
@@ -374,6 +343,8 @@ const updatePassword = async () => {
|
||||
} catch (err: any) {
|
||||
console.error('Error updating password:', err);
|
||||
password_error.value = err?.message || String(err) || '更新失败';
|
||||
} finally {
|
||||
changingPassword.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -449,11 +420,11 @@ const removePasskey = async (id: PasskeyInfo['id']) => {
|
||||
try {
|
||||
const res = await webAuthStore.deletePasskey(id);
|
||||
if (res?.data?.code == 200) {
|
||||
setToast('Passkey removed successfully', 'success');
|
||||
setToast('Passkey removed successfully.', 'success');
|
||||
}
|
||||
await getPasskeys();
|
||||
} catch (err: any) {
|
||||
console.log('err', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,206 +1,134 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader title="User Details" />
|
||||
<div class="mx-auto max-w-3xl space-y-5">
|
||||
<BreadcrumbHeader title="账户设置" />
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||
<div class="card-body space-y-5 p-3 sm:p-8">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6">
|
||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</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 v-if="user" class="space-y-5">
|
||||
<!-- Profile header -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
|
||||
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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 class="avatar" v-else>
|
||||
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
{{ formatRole(user?.role) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit form -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="st-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||
<input id="st-name" name="name" type="text" v-model="user.name" placeholder="Full name"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="st-username" class="mb-1 block text-sm font-medium">
|
||||
Username <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="st-username" name="username" type="text" v-model="user.username" spellcheck="false"
|
||||
autocomplete="off" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="st-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||
<div class="relative">
|
||||
<input id="st-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
|
||||
spellcheck="false" class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
aria-label="Email verification status">
|
||||
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
|
||||
<Send class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="st-password" class="mb-1 block text-sm font-medium">
|
||||
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input id="st-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="st-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||
<select id="st-language" name="language" v-model="user.language" class="select select-bordered w-full">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading settings">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig } from '@lucide/vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Eye, EyeOff, BadgeCheck, Send } from '@lucide/vue';
|
||||
import { useAuthStore } from '../../stores/auth';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { UserInfo } from '@/types';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { setToast } = useToast();
|
||||
const saving = ref(false);
|
||||
|
||||
const loading = computed(() => authStore.loading);
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -210,6 +138,7 @@ onMounted(async () => {
|
||||
// 原实现误引用了未定义的 userStore/userId,这里改为更新当前登录用户资料
|
||||
const updateUser = async () => {
|
||||
if (!user.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: Partial<UserInfo> = {
|
||||
name: user.value.name,
|
||||
@@ -223,11 +152,13 @@ const updateUser = async () => {
|
||||
const res = await authStore.updateProfile(payload);
|
||||
console.log('updateUser', res)
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`User ${user.value.username} updated`, 'success');
|
||||
setToast('Settings saved successfully.', 'success');
|
||||
}
|
||||
await authStore.refreshProfile();
|
||||
} catch (err: any) {
|
||||
console.error('Error updating user:', err.response?.data?.data?.error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,4 +190,4 @@ const toggleEmailVerify = () => {
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,143 +1,113 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader />
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||
</header>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6 text-center md:text-left">
|
||||
<h1 class="text-2xl font-semibold text-base-content">
|
||||
Create New Token
|
||||
</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 v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1">Error: {{ error }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createToken">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="token-name" name="name" type="text" v-model="newToken.name"
|
||||
placeholder="e.g. my-laptop" autocomplete="off"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||
Advanced Options
|
||||
</div>
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label>
|
||||
<div class="relative">
|
||||
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
||||
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleTokenVisibility"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isTokenVisible ? 'Hide token key' : 'Show token key'"
|
||||
id="token-visibility-toggle">
|
||||
<EyeOff v-if="!isTokenVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at"
|
||||
class="input input-bordered w-full" :disabled="newToken.never_expired" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="never_expired" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
||||
Never
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-quota" name="quota" type="number" inputmode="numeric" v-model="newToken.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newToken.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newToken.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newToken.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Token active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newToken.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { Eye, EyeOff } from '@lucide/vue'
|
||||
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||
import { dateToUnix } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { TokenPayload } from '@/types';
|
||||
@@ -145,6 +115,7 @@ import type { TokenPayload } from '@/types';
|
||||
const authStore = useAuthStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
const user = computed(() => authStore.user);
|
||||
|
||||
const showAdvancedOptions = ref(false)
|
||||
@@ -205,12 +176,13 @@ const createToken = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await authStore.createToken(newToken.value)
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetnewToken();
|
||||
setToast(`Token ${newToken.value.name} created`, 'success')
|
||||
setToast('Token created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
} else {
|
||||
console.log(res)
|
||||
@@ -219,6 +191,8 @@ const createToken = async () => {
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to create token'
|
||||
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,12 +213,3 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
</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,105 +1,103 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-4">
|
||||
<!-- Breadcrumb and Title -->
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-4 justify-end items-center">
|
||||
|
||||
<button class="btn btn-outline btn-success btn-sm gap-1" onclick="myModal.showModal()">
|
||||
<PlusIcon class="w-3.5 h-3.5" />New
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
|
||||
</button>
|
||||
<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>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6">
|
||||
|
||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200" v-if="user">
|
||||
<table class="table table-sm w-full">
|
||||
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||
<th class="px-2 py-3">Token</th>
|
||||
<th class="px-2 py-3">Status</th>
|
||||
<!-- <th class="px-2 py-3">Key</th> -->
|
||||
<th class="px-2 py-3">Expired</th>
|
||||
<th class="px-2 py-3">Quota</th>
|
||||
<th class="px-2 py-3">Used</th>
|
||||
<th class="text-right px-2 py-3"></th>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Token</th>
|
||||
<th>Status</th>
|
||||
<th>Expired</th>
|
||||
<th class="text-right">Quota</th>
|
||||
<th class="text-right">Used</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ token.name }}</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-xs"
|
||||
:class="token.active ? 'toggle-success' : 'toggle-error'" v-model="token.active"
|
||||
@change="updateStatus(token)" />
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!token.active && 'toggle-error'" v-model="token.active"
|
||||
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
|
||||
</td>
|
||||
<!-- <td class="font-mono text-xs px-2 py-3">{{ token.key }}</td> -->
|
||||
<td class="px-2 py-3">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
||||
<td class="px-2 py-3">
|
||||
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="token.unlimited_quota">
|
||||
<Infinity />
|
||||
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||
<span class="sr-only">Unlimited</span>
|
||||
</template>
|
||||
<template v-else>{{ token.quota }}</template>
|
||||
</td>
|
||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||
<td class="text-right px-1 py-3">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open pt-1" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square" onclick="" @click="viewToken(token)">
|
||||
<EyeIcon class="w-5 h-5 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="md:tooltip" data-tip="clean used">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-sky-300 mt-1" @click="cleanUsedToken(token)"
|
||||
aria-label="Revoke token">
|
||||
<Eraser class="w-5 h-5" />
|
||||
</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" />
|
||||
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
||||
<td class="pr-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
|
||||
@click="cleanUsedToken(token)" aria-label="Reset used quota">
|
||||
<Eraser class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
<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> -->
|
||||
|
||||
|
||||
<!-- New token modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<TokenNew @closeModal="closeModal" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Token QR modal -->
|
||||
<dialog ref="tokenRef" class="modal">
|
||||
<div class="modal-box max-w-sm px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<QRCodeCard :value="qrCodeValue" :size="120" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -110,7 +108,7 @@ import QRCodeCard from '@/components/common/QRCodeCard.vue';
|
||||
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser
|
||||
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser, Braces
|
||||
} from '@lucide/vue';
|
||||
import { unixToDate } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
@@ -203,6 +201,9 @@ const qrCodeValue = ref('');
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
|
||||
@@ -1,151 +1,150 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-4">
|
||||
<!-- Breadcrumb and Title -->
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<!-- Search and Controls -->
|
||||
<!-- <div v-if="userStore.loading" class="loading loading-spinner loading-lg"></div> -->
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<div class="flex flex-1 items-center space-x-2">
|
||||
<input
|
||||
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]"
|
||||
placeholder="Filter" value="">
|
||||
|
||||
<div class="dropdown">
|
||||
<label tabindex="0"
|
||||
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">
|
||||
<svg viewBox="0 0 15 15" width="1.2em" height="1.2em" class="mr-2 h-4 w-4">
|
||||
<path fill="currentColor" fill-rule="evenodd"
|
||||
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"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Status
|
||||
</label>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu shadow-lg bg-base-100 rounded-none w-24 px-0">
|
||||
<li v-for="status in statusOptions" :key="status" class="px-0 mx-0">
|
||||
<a class="px-2 mx-0 hover:rounded-none">
|
||||
<input type="checkbox" :checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" class="checkbox checkbox-xs" />
|
||||
{{ status }}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Team members and their access.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||
<Settings2Icon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-36 rounded-box border border-base-300/60 bg-base-100 p-1.5 text-sm shadow-lg">
|
||||
<li>
|
||||
<button class="text-error" @click="handleBatchAction('delete')">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />Delete
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleBatchAction('disable')">
|
||||
<BadgeXIcon class="h-4 w-4" aria-hidden="true" />Disable
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleBatchAction('enable')">
|
||||
<BadgeCheckIcon class="h-4 w-4" aria-hidden="true" />Enable
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<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 class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new user">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New User
|
||||
</button>
|
||||
<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 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>
|
||||
|
||||
<!-- Status filter -->
|
||||
<details class="dropdown dropdown-end" v-if="users.length">
|
||||
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Status
|
||||
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||
</summary>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||
<li v-for="status in statusOptions" :key="status">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" class="checkbox checkbox-xs"
|
||||
:checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" />
|
||||
{{ status }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card bg-base-100 shadow-xs overflow-x-auto dark:bg-base-200">
|
||||
<table class="table table-sm">
|
||||
<!-- Table Header -->
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll" />
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<th>Quota</th>
|
||||
<th>Used</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<!-- Table Body -->
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id"
|
||||
class="hover:bg-gray-500/50 dark:hover:bg-neutral-600 transition-colors">
|
||||
<td>
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
|
||||
@change="toggleUserSelection(user)" />
|
||||
</td>
|
||||
<td class="text-xs dark:text-white">{{ user.id }}</td>
|
||||
<td class="text-xs dark:text-white">{{ user.username }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1">
|
||||
<input type="checkbox" class="toggle toggle-xs" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="user.active" @change="updateStatus(user)" />
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs dark:text-white">
|
||||
<template v-if="user.unlimited_quota">
|
||||
<Infinity />
|
||||
</template>
|
||||
<template v-else>{{ user.quota }}</template>
|
||||
</td>
|
||||
<td class="text-xs dark:text-white">{{ user.used_quota }}</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="预览">
|
||||
<button class="btn btn-ghost btn-xs btn-square " @click="viewUser(user)">
|
||||
<EyeIcon class="w-4 h-4 dark:text-white" />
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="users.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||
aria-label="Select all users" />
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<th class="text-right">Quota</th>
|
||||
<th class="text-right">Used</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="user.selected"
|
||||
@change="toggleUserSelection(user)" :aria-label="`Select user ${user.username}`" />
|
||||
</td>
|
||||
<td class="tabular-nums text-base-content/60">{{ user.id }}</td>
|
||||
<td class="max-w-40 truncate font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="placeholder avatar">
|
||||
<div class="flex h-6 w-6 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||
<span class="text-[10px] font-medium">{{ user.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ user.username }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
|
||||
:aria-label="`Toggle user ${user.username}`" />
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="user.unlimited_quota">
|
||||
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||
<span class="sr-only">Unlimited</span>
|
||||
</template>
|
||||
<template v-else>{{ user.quota }}</template>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">{{ user.used_quota }}</td>
|
||||
<td class="pr-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewUser(user)" aria-label="View user details">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button v-if="user.role < 20" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmDeleteUser(user)" aria-label="Delete user">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="lg:tooltip lg:tooltip-top lg:tooltip-open" data-tip="删除" v-if="user.role < 20">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/30"
|
||||
@click="confirmDeleteUser(user)">
|
||||
<TrashIcon class="w-4 h-4 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<UsersRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No users yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Invite teammates by creating accounts with their own quotas.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New user modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<UserNew @closeModal="closeModal" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -160,7 +159,7 @@ import { useToast } from '@/composables/toast';
|
||||
import type { UserInfo } from '@/types';
|
||||
import {
|
||||
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||
TrashIcon, Infinity
|
||||
TrashIcon, UsersRoundIcon, Infinity, ListFilterIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -176,7 +175,6 @@ const totalItems = computed(() => userStore.totalUsers);
|
||||
// 封装公共的用户列表获取方法
|
||||
const listUsers = async (size?: number, page?: number, active?: boolean[] | boolean) => {
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -246,9 +244,6 @@ const handleBatchAction = async (action: string) => {
|
||||
if (!['enable', 'disable', 'delete'].includes(action)) {
|
||||
return setToast(`无效的操作 ${action}`, 'error');
|
||||
}
|
||||
if (selectedUsers.value.length === 0) {
|
||||
return setToast('请选择用户', 'error');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await userStore.userOption(action, selectedUsers.value.map(user => user.id));
|
||||
@@ -315,6 +310,9 @@ const deleteUser = async (user: UserInfo) => {
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
|
||||
@@ -1,167 +1,134 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader />
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New User</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Invite a teammate with their own account and quota.</p>
|
||||
</header>
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="mb-6 text-center md:text-left">
|
||||
<h1 class="text-2xl font-semibold text-base-content">
|
||||
Create New User
|
||||
</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 v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||
<CircleAlert class="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span class="flex-1">Error: {{ error }}</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createUser">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="user-username" class="mb-1 block text-sm font-medium">
|
||||
Username <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="user-username" name="username" type="text" v-model="newUser.username"
|
||||
placeholder="e.g. alice" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-password" class="mb-1 block text-sm font-medium">
|
||||
Password <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input id="user-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||
v-model="newUser.password" autocomplete="new-password" placeholder="At least 4 characters"
|
||||
class="input input-bordered w-full pr-10" required />
|
||||
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||
<input id="user-name" name="name" type="text" v-model="newUser.name" placeholder="Full name"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||
<input id="user-email" name="email" type="email" v-model="newUser.email"
|
||||
placeholder="name@example.com" spellcheck="false" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||
<input type="checkbox" v-model="showAdvancedOptions" class="min-h-0 py-2" />
|
||||
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||
Advanced Options
|
||||
</div>
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="user-role" class="mb-1 block text-sm font-medium">Role</label>
|
||||
<select id="user-role" name="role" v-model="newUser.role" class="select select-bordered w-full">
|
||||
<option :value="0">User</option>
|
||||
<option :value="10">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||
<select id="user-language" name="language" v-model="newUser.language" class="select select-bordered w-full">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="user-quota" name="quota" type="number" inputmode="numeric" v-model="newUser.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newUser.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newUser.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newUser.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="User active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newUser.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { Eye, EyeOff } from '@lucide/vue'
|
||||
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { NewUserPayload } from '@/types';
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
|
||||
// Control advanced options visibility
|
||||
const showAdvancedOptions = ref(false)
|
||||
@@ -206,6 +173,7 @@ const createUser = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await userStore.createUser({
|
||||
username: newUser.value.username,
|
||||
@@ -222,15 +190,15 @@ const createUser = async () => {
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetNewUser();
|
||||
setToast('User created successfully', 'success')
|
||||
// Optionally navigate or reset form
|
||||
setToast('User created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
} else {
|
||||
setToast(res.data?.error || res.data?.message || 'Failed to create user', 'error')
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to create user'
|
||||
// setToast(error.response?.data?.error || 'Failed to create user', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,12 +214,3 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
</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>
|
||||
<div class="min-h-screen bg-base-100 p-2 md:p-6">
|
||||
<BreadcrumbHeader title="User Details" />
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader title="用户详情" />
|
||||
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="card card-bordered bg-base-100 shadow-sm" v-if="user">
|
||||
<div class="card-body space-y-5 p-3 sm:p-8">
|
||||
<div class="flex flex-col md:flex-row items-center gap-6">
|
||||
<div class="avatar placeholder" v-if="!user?.avatar_url">
|
||||
<div class="glass bg-neutral text-neutral-content rounded-full w-16 sm:w-20">
|
||||
<span class="text-2xl sm:text-3xl">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
<div v-if="user" class="space-y-5">
|
||||
<!-- Profile header -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body flex-row items-center gap-4 p-4 sm:p-5">
|
||||
<div class="placeholder avatar" v-if="!user?.avatar_url">
|
||||
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-neutral text-neutral-content">
|
||||
<span class="text-xl font-semibold">{{ user?.username?.[0]?.toUpperCase() }}</span>
|
||||
</div>
|
||||
<div 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" @change="updateStatus(user)" />
|
||||
</div>
|
||||
<div class="avatar" v-else>
|
||||
<div class="h-14 w-14 rounded-full ring ring-primary ring-offset-base-100 ring-offset-2">
|
||||
<img :src="user?.avatar_url" :alt="user?.name || user?.username" width="56" height="56" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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" 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 class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
{{ formatRole(user?.role) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token 显示 -->
|
||||
<div class="card card-bordered bg-base-100 shadow-sm mt-6" v-if="user">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Tokens</h3>
|
||||
<div v-if="user.tokens && user.tokens.length" class="overflow-x-auto -mx-6">
|
||||
<table class="table table-sm w-full">
|
||||
<!-- Edit form -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateUser" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="uv-name" class="mb-1 block text-sm font-medium">Name</label>
|
||||
<input id="uv-name" name="name" type="text" v-model="user.name" placeholder="Full name"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="uv-username" class="mb-1 block text-sm font-medium">
|
||||
Username <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="uv-username" name="username" type="text" v-model="user.username" spellcheck="false"
|
||||
autocomplete="off" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="uv-email" class="mb-1 block text-sm font-medium">Email Address</label>
|
||||
<div class="relative">
|
||||
<input id="uv-email" name="email" type="email" v-model="user.email" placeholder="name@example.com"
|
||||
spellcheck="false" class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleEmailVerify" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
aria-label="Email verification status">
|
||||
<BadgeCheck v-if="user.email_verified" class="h-4 w-4 text-success" aria-hidden="true" />
|
||||
<span v-else class="tooltip tooltip-left" data-tip="Verification not sent yet">
|
||||
<Send class="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="uv-password" class="mb-1 block text-sm font-medium">
|
||||
Password <span class="text-xs font-normal text-base-content/50">(leave blank to keep unchanged)</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input id="uv-password" name="password" :type="isPasswordVisible ? 'text' : 'password'"
|
||||
v-model="user.password" placeholder="Enter new password" autocomplete="new-password"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="togglePasswordVisibility" tabindex="-1"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isPasswordVisible ? 'Hide password' : 'Show password'">
|
||||
<EyeOff v-if="!isPasswordVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="collapse collapse-arrow rounded-lg border border-base-300/60">
|
||||
<input type="checkbox" class="min-h-0 py-2" checked />
|
||||
<div class="collapse-title min-h-0 px-3 py-2 text-sm font-medium">
|
||||
Advanced Options
|
||||
</div>
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="uv-role" class="mb-1 block text-sm font-medium">Role</label>
|
||||
<select id="uv-role" name="role" v-model="user.role" class="select select-bordered w-full">
|
||||
<option :value="0">User</option>
|
||||
<option :value="10">Admin</option>
|
||||
<option v-if="user.role > 10" :value="20">Root</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="uv-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||
<select id="uv-language" name="language" v-model="user.language" class="select select-bordered w-full">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="uv-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="uv-quota" name="quota" type="number" inputmode="numeric" v-model="user.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="user.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="user.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="saving">
|
||||
<span v-if="saving" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tokens -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body p-4 sm:p-5">
|
||||
<h3 class="card-title pb-1 text-base font-semibold">Tokens</h3>
|
||||
<div v-if="user.tokens && user.tokens.length" class="-mx-4 overflow-x-auto sm:-mx-5">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs text-base-content/70 uppercase bg-base-200">
|
||||
<th class="px-2 py-3">Token Name</th>
|
||||
<th class="px-2 py-3">Key</th>
|
||||
<th class="px-2 py-3">Expired At</th>
|
||||
<th class="px-2 py-3">Quota</th>
|
||||
<th class="px-2 py-3">Used</th>
|
||||
<th class="text-right px-2 py-3"></th>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Token Name</th>
|
||||
<th>Key</th>
|
||||
<th>Expired At</th>
|
||||
<th class="text-right">Quota</th>
|
||||
<th class="text-right">Used</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="hover">
|
||||
<td class="font-mono text-xs px-2 py-3">{{ token.name }}</td>
|
||||
<td class="font-mono text-xs px-2 py-3">{{ token.key }}</td>
|
||||
<td class="px-2 py-3">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
|
||||
<td class="px-2 py-3">
|
||||
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ token.name }}</td>
|
||||
<td class="max-w-32 truncate font-mono text-xs text-base-content/60">{{ token.key }}</td>
|
||||
<td class="tabular-nums text-base-content/70">{{ token.expiredAt == -1 ? 'Never' : formatDate(token.expiredAt) }}</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="token.unlimited_quota">
|
||||
<Infinity />
|
||||
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
||||
<span class="sr-only">Unlimited</span>
|
||||
</template>
|
||||
<template v-else>{{ token.quota }}</template>
|
||||
</td>
|
||||
<td class="px-2 py-3">{{ token.used_quota }}</td>
|
||||
<td class="text-right px-2 py-3">
|
||||
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
||||
<td class="pr-4 text-right">
|
||||
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="revokeToken(token.id)" aria-label="Revoke token">
|
||||
<TrashIcon class="w-4 h-4" />
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -207,7 +180,18 @@
|
||||
</table>
|
||||
</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>
|
||||
@@ -217,8 +201,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { Eye, EyeOff, BadgeCheck, Send, CircleX, CircleCheckBig, TrashIcon, Infinity } from '@lucide/vue';
|
||||
import { useUserStore } from '@/stores/user';
|
||||
import { Eye, EyeOff, BadgeCheck, Send, TrashIcon, Infinity } from '@lucide/vue';
|
||||
import { useUserStore } from '../../stores/user';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { UserInfo, TokenInfo } from '@/types';
|
||||
@@ -226,6 +210,7 @@ import type { UserInfo, TokenInfo } from '@/types';
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const { setToast } = useToast();
|
||||
const saving = ref(false);
|
||||
|
||||
const userId = computed(() => route.query.id);
|
||||
|
||||
@@ -252,12 +237,12 @@ const updateStatus = async (user: UserInfo) => {
|
||||
} catch (error: any) {
|
||||
user.active = !user.active;
|
||||
console.error('状态更新失败:', error);
|
||||
// setToast(error.response.data?.error || '状态更新失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async () => {
|
||||
if (!user.value) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: Partial<UserInfo> = {
|
||||
name: user.value.name,
|
||||
@@ -280,6 +265,8 @@ const updateUser = async () => {
|
||||
await userStore.refreshUser(userId.value as string);
|
||||
} catch (err: any) {
|
||||
console.error('Error updating user:', err.response?.data?.data?.error);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -303,20 +290,6 @@ const formatRole = (role?: number): string => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// 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?: number): string | null => {
|
||||
if (!dateString) return null;
|
||||
@@ -340,4 +313,4 @@ const toggleEmailVerify = () => {
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,51 +1,31 @@
|
||||
<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 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-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>
|
||||
<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="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 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>
|
||||
@@ -59,7 +39,3 @@ onMounted(() => {
|
||||
currentYear.value = new Date().getFullYear().toString();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* You can add specific styles for the 404 page here if needed */
|
||||
</style>
|
||||
Reference in New Issue
Block a user