1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-21 17:53:11 +00:00
This commit is contained in:
SunWuyuan 2025-10-06 11:10:54 +08:00
parent aec482cbcb
commit 0fca7900c8
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
121 changed files with 68 additions and 12343 deletions

View File

@ -1,137 +0,0 @@
## 快速开始
如果你想快速体验 Classworks我们推荐使用 SQLite 版本。可以零配置运行。
## 部署方案
### MySQL 版本
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: mysql
environment:
- NODE_ENV=production
- MYSQL_DATABASE_URL=mysql://user:password@mysql:3306/classworks
ports:
- 3000:3000
depends_on:
mysql:
condition: service_healthy
mysql:
image: mysql:8
environment:
- MYSQL_DATABASE=classworks
- MYSQL_USER=user
- MYSQL_PASSWORD=password
- MYSQL_ROOT_PASSWORD=rootpassword
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
volumes:
mysql_data:
```
默认配置:
- 数据库版本MySQL 8
- 默认端口3306
- 数据持久化:自动配置
### PostgreSQL 版本
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: postgres
environment:
- NODE_ENV=production
- PG_DATABASE_URL=postgresql://user:password@postgres:5432/classworks
ports:
- 3000:3000
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=classworks
- POSTGRES_USER=user
- POSTGRES_PASSWORD=password
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d classworks"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
```
默认配置:
- 数据库版本PostgreSQL 15 Alpine
- 默认端口5432
- 数据持久化:自动配置
### SQLite 版本
将以下内容保存为 `docker-compose.yml`
```yaml
version: '3.8'
services:
app:
build:
context: .
args:
DATABASE_TYPE: sqlite
ports:
- 3000:3000
environment:
- NODE_ENV=production
volumes:
- sqlite_data:/data
volumes:
sqlite_data:
```
## 使用说明
1. 选择你需要的版本,将对应的配置复制到 `docker-compose.yml` 文件中
2. 根据需要修改环境变量(见下方环境变量配置)
3. 运行 `docker compose up -d` 启动服务
## 环境变量配置
```
# Axiom.co 遥测配置 可选
AXIOM_DATASET=
AXIOM_TOKEN=
# 网站密钥 可选
SITE_KEY=
# 服务端口 可选 默认3000
PORT=
```

View File

@ -1,17 +1,9 @@
#!/usr/bin/env node
import { execSync } from "child_process";
import fs from "fs";
import path from "path";
import dotenv from "dotenv";
dotenv.config();
const PRISMA_DIR = path.join(process.cwd(), "prisma");
const DATABASE_TYPE = process.env.DATABASE_TYPE || "sqlite";
const DATABASE_URL =
DATABASE_TYPE === "sqlite"
? "file:/data/db.sqlite"
: process.env.DATABASE_URL;
// 🔄 执行数据库迁移函数
function runDatabaseMigration() {
@ -28,48 +20,6 @@ function runDatabaseMigration() {
// 🧱 数据库初始化函数
function setupDatabase() {
try {
// 如果是 SQLite确保 /data 目录存在
if (DATABASE_TYPE === "sqlite") {
if (!fs.existsSync("/data")) {
fs.mkdirSync("/data", { recursive: true });
}
} else if (!DATABASE_URL) {
console.error("❌ 缺少 DATABASE_URL 环境变量");
process.exit(1);
}
// 从对应数据库类型的配置目录中复制配置文件
const sourceDir = path.join(PRISMA_DIR, "database", DATABASE_TYPE);
if (!fs.existsSync(sourceDir)) {
console.error(`❌ 数据库配置未找到:${sourceDir}`);
process.exit(1);
}
// 递归复制函数
function copyRecursive(src, dest) {
const stats = fs.statSync(src);
if (stats.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src);
for (const entry of entries) {
copyRecursive(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
}
}
// 将所有配置文件和目录复制到 prisma 根目录下
const entries = fs.readdirSync(sourceDir);
for (const entry of entries) {
const sourcePath = path.join(sourceDir, entry);
const targetPath = path.join(PRISMA_DIR, entry);
copyRecursive(sourcePath, targetPath);
}
console.log(`✅ 已复制 ${DATABASE_TYPE} 数据库配置文件和目录`);
// 执行数据库迁移
runDatabaseMigration();
} catch (error) {
@ -95,7 +45,6 @@ function buildLocal() {
// 🚀 启动服务函数
function startServer() {
try {
console.log(`🚀 使用 ${DATABASE_TYPE} 数据库启动服务中...`);
execSync("npm run start", { stdio: "inherit" }); // 启动项目
} catch (error) {
console.error("❌ 服务启动失败:", error.message);

View File

@ -10,7 +10,6 @@ services:
- "3000:3000"
environment:
- NODE_ENV=production
- DATABASE_TYPE=sqlite
- DATABASE_URL=
volumes:
- .data:/app/data

View File

@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Read(//d/Classworks/ClassworksServer/prisma/**)"
],
"deny": [],
"ask": []
}
}

View File

@ -1,8 +0,0 @@
# Backend API Base URL (后端服务地址)
VITE_API_BASE_URL=http://localhost:3000
# Site Key for authentication (站点密钥)
VITE_SITE_KEY=your-site-key-here
# Assets URL for app icons (应用图标资源地址)
VITE_ASSETS_URL=http://localhost:3000/assets

24
kv-admin/.gitignore vendored
View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1 +0,0 @@
node-linker=hoisted

View File

@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

View File

@ -1,281 +0,0 @@
# KV 服务管理应用
一个基于 Vue 3 + JavaScript + shadcn-vue 的 KV 存储服务管理界面,支持多应用 Token 管理和本地设备码生成。
## 功能特性
- 🔑 **多 Token 管理**:管理多个应用的访问 Token
- 🔐 **本地设备码生成**:自动生成设备授权码,无需服务器
- 📊 **KV 空间信息**:实时显示当前 KV 空间的使用情况
- 💾 **数据管理**:浏览、创建、编辑和删除 KV 键值对
- 🔍 **搜索过滤**:支持键名搜索和多种排序方式
- 📱 **响应式设计**:适配桌面和移动设备
- 🎨 **现代 UI**shadcn-vue 组件库,简洁清爽
- ⚡ **快速开发**Vite 驱动HMR 即时更新
- 🗂️ **约定式路由**:基于文件系统的自动路由
## 技术栈
- **框架**Vue 3 + JavaScript
- **构建工具**Vite
- **UI 组件**shadcn-vue
- **样式**Tailwind CSS v4
- **路由**Vue Router + unplugin-vue-router (约定式路由)
- **图标**Lucide Icons
- **状态管理**LocalStorage (轻量级)
## 快速开始
### 1. 安装依赖
```bash
pnpm install
```
### 2. 配置环境变量
复制 `.env.example``.env` 并填写配置:
```bash
cp .env.example .env
```
编辑 `.env` 文件:
```env
VITE_API_BASE_URL=http://localhost:3000
VITE_SITE_KEY=your-site-key-here
```
### 3. 启动开发服务器
```bash
pnpm dev
```
应用将在 http://localhost:5173 运行
### 4. 构建生产版本
```bash
pnpm build
```
构建产物将输出到 `dist` 目录。
## 项目结构
```
kv-admin/
├── src/
│ ├── components/
│ │ └── ui/ # shadcn-vue 组件
│ ├── pages/ # 约定式路由页面
│ │ ├── index.vue # Token 管理页面 (/)
│ │ └── dashboard.vue # KV 数据管理 (/dashboard)
│ ├── lib/
│ │ ├── api.js # API 客户端
│ │ ├── tokenStore.js # Token 存储管理
│ │ └── utils.js # 工具函数
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ └── style.css # 全局样式
├── .env.example # 环境变量模板
├── components.json # shadcn-vue 配置
├── jsconfig.json # JavaScript 配置
├── vite.config.js # Vite 配置
└── package.json
```
## 核心功能说明
### 1. Token 管理(首页)
- **添加应用 Token**:输入应用名称和 Token系统自动生成设备码
- **设备码生成**:本地随机生成格式如 `XXXX-XXXX-XXXX-XXXX` 的设备码
- **多 Token 支持**:可以添加多个应用的 Token方便切换
- **活跃 Token**:选择当前要使用的 Token
- **KV 空间信息**:显示当前活跃应用的 KV 数据统计
- **Token 可见性**:支持显示/隐藏 Token 值
- **复制功能**:一键复制设备码和 Token
### 2. 数据管理Dashboard
- **浏览数据**:查看当前应用的所有 KV 键值对
- **搜索**:通过键名快速查找
- **排序**:按键名、创建时间或更新时间排序
- **创建**添加新的键值对JSON 格式)
- **编辑**:修改现有键值对的内容
- **查看详情**:查看完整的键值对信息和元数据
- **删除**:删除不需要的键值对
- **分页**:支持大量数据的分页浏览
### 设备码说明
**什么是设备码?**
- 设备码是应用授权的密钥,相当于一个唯一标识符
- 格式:`XXXX-XXXX-XXXX-XXXX`4段每段4个字母/数字)
- **本地生成**:无需服务器接口,在浏览器端随机生成
- **用途**:用于标识和授权特定的应用或设备访问 KV 服务
**工作流程:**
1. 用户添加应用 Token 时,系统自动生成设备码
2. 设备码与 Token 绑定存储在本地
3. 应用可以使用设备码作为标识符进行授权验证
## API 端点
应用与以下 API 端点交互:
### KV 存储
- `GET /kv` - 获取键值对列表
- `GET /kv/_keys` - 获取键名列表
- `GET /kv/:key` - 获取指定键的值
- `GET /kv/:key/metadata` - 获取键的元数据
- `POST /kv/:key` - 创建或更新键值对
- `DELETE /kv/:key` - 删除键值对
- `POST /kv/_batchimport` - 批量导入
## 数据存储
应用使用 LocalStorage 存储以下数据:
- `kv_tokens` - Token 列表数据
```json
[
{
"id": "1234567890",
"token": "your-token-here",
"appName": "我的应用",
"deviceCode": "ABCD-1234-EFGH-5678",
"createdAt": "2025-01-01T00:00:00.000Z",
"lastUsed": "2025-01-01T00:00:00.000Z"
}
]
```
- `kv_active_token` - 当前活跃的 Token ID
## 约定式路由
本项目使用 `unplugin-vue-router` 实现约定式路由,无需手动配置路由:
- `src/pages/index.vue``/` (Token 管理页面)
- `src/pages/dashboard.vue``/dashboard` (数据管理页面)
### 路由元信息
在页面组件中使用 `defineOptions` 设置路由元信息:
```vue
<script setup>
defineOptions({
meta: {
requiresAuth: true
}
})
</script>
```
### 导航守卫
路由守卫在 `src/main.js` 中配置,自动处理授权检查:
```javascript
router.beforeEach((to, _from, next) => {
const requiresAuth = to.meta?.requiresAuth
const activeToken = tokenStore.getActiveToken()
if (requiresAuth && !activeToken) {
next({ path: '/' })
} else {
next()
}
})
```
## 开发
### 添加新页面
`src/pages/` 目录下创建新的 `.vue` 文件,路由会自动生成:
```
src/pages/
├── index.vue → /
├── dashboard.vue → /dashboard
└── settings.vue → /settings (自动添加)
```
### 添加新组件
使用 shadcn-vue CLI 添加组件:
```bash
pnpm dlx shadcn-vue@latest add [component-name]
```
## 部署
### Vercel / Netlify
这些平台会自动检测 Vite 项目并进行构建。只需连接 Git 仓库即可。
### 传统服务器
构建后将 `dist` 目录部署到您的 Web 服务器,确保配置 SPA 回退规则:
**Nginx 示例**
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
## 使用流程
### 首次使用
1. 访问首页
2. 点击"添加应用"
3. 输入应用名称(可选)和访问 Token
4. 系统自动生成设备码并保存
5. 点击"管理数据"进入数据管理页面
### 切换应用
1. 在首页的应用列表中
2. 点击要切换的应用行的"选择"按钮
3. 该应用变为"活跃"状态
4. KV 空间信息自动更新
5. 点击"管理数据"查看该应用的数据
### 管理数据
1. 在数据管理页面可以进行 CRUD 操作
2. 使用搜索框快速查找键名
3. 使用排序和分页功能浏览大量数据
4. 点击左上角的"主页"图标返回 Token 管理页面
## 安全建议
1. 始终使用 HTTPS 部署生产环境
2. 定期更换访问 Token
3. 不要在前端代码中硬编码敏感信息
4. 使用环境变量管理配置
5. 实施适当的 CORS 策略
6. LocalStorage 数据在浏览器端存储,注意隐私保护
## 技术亮点
- ✅ **纯 JavaScript**:无 TypeScript 依赖,更简单轻量
- ✅ **约定式路由**:基于文件系统,自动生成路由
- ✅ **本地设备码**:客户端生成,无需服务器接口
- ✅ **多 Token 管理**:支持多应用切换
- ✅ **现代化工具链**Vite + Vue 3 组合式 API
- ✅ **完整的 UI 组件**44 个 shadcn-vue 组件
- ✅ **响应式设计**Tailwind CSS v4
- ✅ **轻量级状态**LocalStorage 管理,无需额外状态库
## 许可证
MIT

View File

@ -1,20 +0,0 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": false,
"tailwind": {
"config": "",
"css": "src/style.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Classworks KV</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@ -1,9 +0,0 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

View File

@ -1,43 +0,0 @@
{
"name": "kv-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.3",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/vue-table": "^8.21.3",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^13.9.0",
"axios": "^1.12.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.544.0",
"lucide-vue-next": "^0.544.0",
"marked": "^16.3.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.5.1",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"vee-validate": "^4.15.1",
"vue": "^3.5.21",
"vue-router": "^4.5.1",
"vue-sonner": "^2.0.9",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/devtools": "^8.0.2",
"tw-animate-css": "^1.4.0",
"unplugin-vue-router": "^0.15.0",
"vite": "^7.1.7",
"vite-plugin-vue-devtools": "^8.0.2"
}
}

3915
kv-admin/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,10 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import { Toaster } from '@/components/ui/sonner'
import 'vue-sonner/style.css'
</script>
<template>
<RouterView />
<Toaster class="pointer-events-auto" />
</template>

View File

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

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -1,296 +0,0 @@
<script setup>
import { ref, computed } from "vue";
import { marked } from "marked";
import axios from "@/lib/axios";
import Card from "./ui/card/Card.vue";
import CardHeader from "./ui/card/CardHeader.vue";
import CardTitle from "./ui/card/CardTitle.vue";
import CardDescription from "./ui/card/CardDescription.vue";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "./ui/dialog";
import { ExternalLink } from "lucide-vue-next";
import { cn } from "@/lib/utils";
const props = defineProps({
appId: {
type: Number,
required: true,
},
class: {
type: null,
required: false,
},
compact: {
type: Boolean,
default: false,
},
});
const app = ref(null);
const readme = ref("");
const loading = ref(true);
const error = ref(null);
const showDialog = ref(false);
// assets URL
const assetsBaseUrl = "https://zerocat-bitiful.houlangs.com/material/asset";
// logo_url URL
const iconUrl = computed(() => {
if (!app.value?.logo_url) return null;
return `${assetsBaseUrl}/${app.value.logo_url}`;
});
// Markdown HTML
const renderedReadme = computed(() => {
if (!readme.value) return "";
return marked(readme.value);
});
//
const fetchApp = async () => {
try {
const response = await fetch(`https://zerocat-api.houlangs.com/oauth/applications/${props.appId}`);
if (!response.ok) {
throw new Error(`Failed to fetch app info: ${response.status}`);
}
app.value = await response.json();
if (app.value.homepage_url) {
await fetchReadme();
}
} catch (err) {
error.value = err.message;
} finally {
loading.value = false;
}
};
// Git README
const fetchReadme = async () => {
if (!app.value?.homepage_url) return;
const url = app.value.homepage_url;
let readmeUrl = null;
try {
// GitHub
if (url.includes("github.com")) {
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
// main master
let response = await fetch(readmeUrl);
if (!response.ok) {
readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`;
response = await fetch(readmeUrl);
}
if (response.ok) {
readme.value = await response.text();
return;
}
}
}
// GitLab
if (url.includes("gitlab.com")) {
const match = url.match(/gitlab\.com\/([^\/]+\/[^\/]+?)(?:\.git)?$/);
if (match) {
const [, path] = match;
readmeUrl = `https://gitlab.com/${path}/-/raw/main/README.md`;
let response = await fetch(readmeUrl);
if (!response.ok) {
readmeUrl = `https://gitlab.com/${path}/-/raw/master/README.md`;
response = await fetch(readmeUrl);
}
if (response.ok) {
readme.value = await response.text();
return;
}
}
}
// Bitbucket
if (url.includes("bitbucket.org")) {
const match = url.match(/bitbucket\.org\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
if (match) {
const [, owner, repo] = match;
readmeUrl = `https://bitbucket.org/${owner}/${repo}/raw/main/README.md`;
let response = await fetch(readmeUrl);
if (!response.ok) {
readmeUrl = `https://bitbucket.org/${owner}/${repo}/raw/master/README.md`;
response = await fetch(readmeUrl);
}
if (response.ok) {
readme.value = await response.text();
return;
}
}
}
// Gitea/Forgejo
const genericMatch = url.match(
/https?:\/\/([^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?$/
);
if (genericMatch) {
const [, domain, owner, repo] = genericMatch;
// Gitea/Forgejo
readmeUrl = `https://${domain}/${owner}/${repo}/raw/branch/main/README.md`;
let response = await fetch(readmeUrl);
if (!response.ok) {
readmeUrl = `https://${domain}/${owner}/${repo}/raw/branch/master/README.md`;
response = await fetch(readmeUrl);
}
if (response.ok) {
readme.value = await response.text();
return;
}
}
//
const directResponse = await fetch(url);
if (directResponse.ok) {
readme.value = await directResponse.text();
}
} catch (err) {
console.warn("Failed to fetch README:", err);
}
};
//
fetchApp();
</script>
<template>
<!-- 卡片视图 -->
<Card
:class="
cn(
'app-card cursor-pointer hover:shadow-lg transition-shadow',
props.class
)
"
@click="showDialog = true"
>
<CardHeader v-if="loading" class="px-6">
<div class="animate-pulse">加载中...</div>
</CardHeader>
<template v-else-if="error">
<CardHeader class="px-6">
<CardTitle class="text-red-500">错误</CardTitle>
<CardDescription>{{ error }}</CardDescription>
</CardHeader>
</template>
<template v-else-if="app">
<CardHeader class="px-6">
<div class="flex items-start gap-4">
<img
v-if="iconUrl"
:src="iconUrl"
:alt="app.name"
class="w-12 h-12 rounded-lg object-cover shrink-0"
@error="(e) => (e.target.style.display = 'none')"
/>
<div class="flex-1 min-w-0">
<CardTitle class="text-lg truncate">{{ app.name }}</CardTitle>
<CardDescription v-if="app.description" class="line-clamp-2">
{{ app.description }}
</CardDescription>
<div class="mt-2 text-xs text-muted-foreground">
<span>{{ app.owner?.display_name || app.owner?.username }}</span>
</div>
</div>
</div>
</CardHeader>
</template>
</Card>
<!-- 详情对话框 -->
<Dialog v-model:open="showDialog">
<DialogContent class="max-w-3xl max-h-[85vh] overflow-y-auto">
<DialogHeader v-if="app">
<div class="flex items-start gap-4 mb-4">
<img
v-if="iconUrl"
:src="iconUrl"
:alt="app.name"
class="w-20 h-20 rounded-lg object-cover"
@error="(e) => (e.target.style.display = 'none')"
/>
<div class="flex-1">
<DialogTitle class="text-2xl mb-2">{{ app.name }}</DialogTitle>
<DialogDescription v-if="app.description" class="text-base">
{{ app.description }}
</DialogDescription>
</div>
</div>
<!-- 应用元信息 -->
<div class="grid grid-cols-2 gap-4 py-4 border-y">
<div class="space-y-1">
<div class="text-sm text-muted-foreground">开发者</div>
<div class="font-medium">{{ app.owner?.display_name || app.owner?.username }}</div>
</div>
<div v-if="app.homepage_url" class="space-y-1">
<div class="text-sm text-muted-foreground">应用主页</div>
<a
:href="app.homepage_url"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1"
>
访问
<ExternalLink class="h-3 w-3" />
</a>
</div>
<div v-if="app.terms_url" class="space-y-1">
<div class="text-sm text-muted-foreground">服务条款</div>
<a
:href="app.terms_url"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
>
查看
<ExternalLink class="h-3 w-3" />
</a>
</div>
<div v-if="app.privacy_url" class="space-y-1">
<div class="text-sm text-muted-foreground">隐私政策</div>
<a
:href="app.privacy_url"
target="_blank"
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
>
查看
<ExternalLink class="h-3 w-3" />
</a>
</div>
</div>
</DialogHeader>
<!-- README 内容 -->
<div v-if="readme" class="mt-6">
<h3 class="text-lg font-semibold mb-4">README</h3>
<div
class="prose prose-sm dark:prose-invert max-w-none border rounded-lg p-6 bg-muted/30 prose-headings:font-semibold prose-a:text-primary prose-blockquote:border-l-2 prose-blockquote:pl-4 prose-img:rounded-md prose-table:w-full break-words"
v-html="renderedReadme"
></div>
</div>
<div
v-else-if="!loading && app?.homepage_url"
class="mt-6 text-center text-muted-foreground"
>
无法加载 README 文件
</div>
</DialogContent>
</Dialog>
</template>

View File

@ -1,400 +0,0 @@
<script setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useAccountStore } from '@/stores/account'
import { deviceStore, generateUUID } from '@/lib/deviceStore'
import { apiClient } from '@/lib/api'
import { Button } from '@/components/ui/button'
import LoginDialog from '@/components/LoginDialog.vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Checkbox } from '@/components/ui/checkbox'
import { Shuffle, Download, Plus, AlertTriangle } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
required: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'confirm', 'openLogin'])
const accountStore = useAccountStore()
const newUuid = ref('')
const deviceName = ref('')
const bindToAccount = ref(false)
const accountDevices = ref([])
const loadingDevices = ref(false)
const activeTab = ref('load') // 'load' 'register'
const showLoginDialog = ref(false) //
const isOpen = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
//
watch(isOpen, (newVal) => {
if (newVal && accountStore.isAuthenticated && activeTab.value === 'load') {
loadAccountDevices()
}
// UUID
if (newVal && activeTab.value === 'register' && !newUuid.value) {
generateRandomUuid()
}
})
//
watch(activeTab, (newVal) => {
if (newVal === 'load' && accountStore.isAuthenticated && isOpen.value) {
loadAccountDevices()
}
if (newVal === 'register' && !newUuid.value) {
generateRandomUuid()
}
})
//
watch(() => accountStore.isAuthenticated, (isAuth) => {
if (isAuth && activeTab.value === 'register') {
bindToAccount.value = true
} else if (!isAuth) {
bindToAccount.value = false
}
})
// UUID
const generateRandomUuid = () => {
newUuid.value = generateUUID()
}
//
const handleOpenLogin = () => {
showLoginDialog.value = true
}
//
const handleLoginSuccess = async (token) => {
//
showLoginDialog.value = false
//
await accountStore.login(token)
//
if (activeTab.value === 'load') {
await loadAccountDevices()
} else {
//
bindToAccount.value = true
}
}
//
const loadAccountDevices = async () => {
if (!accountStore.isAuthenticated) {
return
}
loadingDevices.value = true
try {
const response = await apiClient.getAccountDevices(accountStore.token)
accountDevices.value = response.data || []
if (accountDevices.value.length === 0) {
toast.info('您的账户暂未绑定任何设备')
}
} catch (error) {
toast.error('加载设备列表失败:' + error.message)
} finally {
loadingDevices.value = false
}
}
//
const loadDevice = (device) => {
deviceStore.setDeviceUuid(device.uuid)
isOpen.value = false
emit('confirm')
resetForm()
toast.success(`已切换到设备: ${device.name || device.uuid}`)
}
//
const registerDevice = async () => {
if (!newUuid.value.trim()) {
toast.error('请输入或生成UUID')
return
}
if (!deviceName.value.trim()) {
toast.error('请输入设备名称')
return
}
try {
// 1. UUID
deviceStore.setDeviceUuid(newUuid.value.trim())
// 2.
await apiClient.registerDevice(
newUuid.value.trim(),
deviceName.value.trim(),
accountStore.isAuthenticated ? accountStore.token : null
)
// 3.
if (bindToAccount.value && accountStore.isAuthenticated) {
try {
await apiClient.bindDeviceToAccount(accountStore.token, newUuid.value.trim())
} catch (error) {
console.warn('设备绑定失败:', error.message)
toast.warning('设备注册成功,但绑定到账户失败')
}
}
toast.success(`设备注册成功UUID: ${newUuid.value.trim()}`)
isOpen.value = false
emit('confirm')
resetForm()
const message = bindToAccount.value
? '设备已注册并绑定到您的账户'
: '设备已注册'
toast.success(message)
} catch (error) {
toast.error('注册失败:' + error.message)
}
}
//
const resetForm = () => {
newUuid.value = ''
deviceName.value = ''
bindToAccount.value = accountStore.isAuthenticated
accountDevices.value = []
activeTab.value = 'load'
}
//
const handleClose = () => {
// required
if (props.required) {
toast.error('请先注册或加载设备')
return
}
resetForm()
isOpen.value = false
}
// ESC
const handleKeydown = (e) => {
if (e.key === 'Escape' && props.required) {
e.preventDefault()
e.stopPropagation()
toast.error('请先注册或加载设备')
}
}
// /
onMounted(() => {
document.addEventListener('keydown', handleKeydown, true)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown, true)
})
</script>
<template>
<Dialog
v-model:open="isOpen"
@update:open="(val) => !val && (props.required ? isOpen = true : handleClose())">
<DialogContent class="max-w-2xl">
<DialogHeader>
<DialogTitle>设备管理</DialogTitle>
<DialogDescription>
加载账户设备或注册新设备
</DialogDescription>
<!-- 必需模式的提示 -->
<div v-if="props.required" class="mt-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900">
<div class="flex items-start gap-2">
<AlertTriangle class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
<div>
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">请先注册或加载设备</p>
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
您需要注册或加载一个设备才能继续使用
</p>
</div>
</div>
</div>
</DialogHeader>
<Tabs v-model="activeTab" class="w-full">
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="load">
<Download class="h-4 w-4 mr-2" />
加载设备
</TabsTrigger>
<TabsTrigger value="register">
<Plus class="h-4 w-4 mr-2" />
注册设备
</TabsTrigger>
</TabsList>
<!-- 加载设备选项卡 -->
<TabsContent value="load" class="space-y-4 mt-4">
<div v-if="!accountStore.isAuthenticated" class="text-center py-8">
<p class="text-muted-foreground mb-4">请先登录以查看您的设备列表</p>
<Button variant="outline" @click="handleOpenLogin">
登录账户
</Button>
</div>
<div v-else-if="loadingDevices" class="text-center py-8">
<p class="text-muted-foreground">加载中...</p>
</div>
<div v-else-if="accountDevices.length === 0" class="text-center py-8">
<p class="text-muted-foreground mb-4">您的账户暂未绑定任何设备</p>
<Button variant="outline" @click="activeTab = 'register'">
<Plus class="h-4 w-4 mr-2" />
注册新设备
</Button>
</div>
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
<div
v-for="device in accountDevices"
:key="device.uuid"
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
@click="loadDevice(device)"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="font-medium text-base">
{{ device.name || '未命名设备' }}
</div>
<code class="text-xs text-muted-foreground block mt-1">
{{ device.uuid }}
</code>
<div class="text-xs text-muted-foreground mt-2">
创建时间: {{ new Date(device.createdAt).toLocaleString('zh-CN') }}
</div>
</div>
<Button
variant="ghost"
size="sm"
@click.stop="loadDevice(device)"
>
加载
</Button>
</div>
</div>
</div>
</TabsContent>
<!-- 注册设备选项卡 -->
<TabsContent value="register" class="space-y-4 mt-4">
<div class="space-y-4">
<!-- UUID输入 -->
<div class="space-y-2">
<Label for="registerUuid">设备 UUID</Label>
<div class="flex gap-2">
<Input
id="registerUuid"
v-model="newUuid"
placeholder="自动生成或手动输入UUID"
class="flex-1"
/>
<Button
variant="outline"
size="icon"
@click="generateRandomUuid"
title="生成随机UUID"
>
<Shuffle class="h-4 w-4" />
</Button>
</div>
</div>
<!-- 设备名称输入 -->
<div class="space-y-2">
<Label for="deviceName">设备名称</Label>
<Input
id="deviceName"
v-model="deviceName"
placeholder="为设备设置一个易于识别的名称"
required
/>
</div>
<Separator />
<!-- 绑定到账户选项 -->
<div class="flex items-start space-x-3 p-4 rounded-lg border">
<Checkbox
id="bindToAccount"
v-model:checked="bindToAccount"
:disabled="!accountStore.isAuthenticated"
/>
<div class="flex-1">
<label
for="bindToAccount"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>
绑定到账户
</label>
<p class="text-xs text-muted-foreground mt-1">
{{ accountStore.isAuthenticated
? `将此设备绑定到账户 ${accountStore.userName},绑定后可在其他设备上快速加载`
: '登录后可以将设备绑定到您的账户'
}}
</p>
</div>
</div>
<!-- 提示信息 -->
<div class="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-3">
<p><strong>提示:</strong></p>
<ul class="list-disc list-inside mt-1 space-y-1">
<li>UUID将保存到本地浏览器存储</li>
<li v-if="deviceName">设备名称将帮助您快速识别不同的设备</li>
<li v-if="bindToAccount && accountStore.isAuthenticated">绑定后可在任何设备上通过账户加载</li>
</ul>
</div>
</div>
<div class="flex justify-end gap-2 pt-2">
<Button
variant="outline"
@click="handleClose"
:disabled="props.required"
:title="props.required ? '必须先注册设备' : '取消'"
>
取消
</Button>
<Button @click="registerDevice" :disabled="!newUuid.trim() || !deviceName.trim()">
<Plus class="h-4 w-4 mr-2" />
注册设备
</Button>
</div>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
<!-- 登录对话框 -->
<LoginDialog
v-model="showLoginDialog"
:on-success="handleLoginSuccess"
/>
</template>

View File

@ -1,136 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useAccountStore } from '@/stores/account'
import { apiClient } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Edit } from 'lucide-vue-next'
import { toast } from 'vue-sonner'
import PasswordInput from './PasswordInput.vue'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
deviceUuid: {
type: String,
required: true
},
currentName: {
type: String,
default: ''
},
hasPassword: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const accountStore = useAccountStore()
const deviceName = ref('')
const password = ref('')
const isSubmitting = ref(false)
const isOpen = computed({
get: () => props.modelValue,
set: (val) => {
if (val) {
deviceName.value = props.currentName || ''
password.value = ''
}
emit('update:modelValue', val)
}
})
const needsPassword = computed(() => {
return props.hasPassword && !accountStore.isAuthenticated
})
const updateDeviceName = async () => {
if (!deviceName.value.trim()) {
toast.error('请输入设备名称')
return
}
isSubmitting.value = true
try {
await apiClient.setDeviceName(
props.deviceUuid,
deviceName.value.trim(),
needsPassword.value ? password.value : null,
accountStore.isAuthenticated ? accountStore.token : null
)
toast.success('设备名称已更新')
isOpen.value = false
emit('success', deviceName.value.trim())
} catch (error) {
toast.error('更新失败:' + error.message)
} finally {
isSubmitting.value = false
}
}
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>
<div class="flex items-center gap-2">
<Edit class="h-5 w-5" />
编辑设备名称
</div>
</DialogTitle>
<DialogDescription>
为设备设置一个易于识别的名称
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="deviceName">设备名称</Label>
<Input
id="deviceName"
v-model="deviceName"
placeholder="输入设备名称"
@keyup.enter="updateDeviceName"
/>
</div>
<div v-if="needsPassword">
<PasswordInput
v-model="password"
label="设备密码"
placeholder="输入设备密码"
:device-uuid="deviceUuid"
:show-hint="true"
:show-strength="false"
required
/>
</div>
<div v-if="accountStore.isAuthenticated && hasPassword" class="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<p class="text-sm text-blue-700 dark:text-blue-300">
您已登录绑定的账户无需输入密码
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="isOpen = false" :disabled="isSubmitting">
取消
</Button>
<Button @click="updateDeviceName" :disabled="isSubmitting || !deviceName.trim()">
{{ isSubmitting ? '更新中...' : '确认' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -1,41 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -1,220 +0,0 @@
<template>
<div>
<!-- 登录弹框 -->
<Dialog
v-model:open="isOpen"
:default-open="false"
>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>账户登录</DialogTitle>
<DialogDescription>
选择一个OAuth提供者进行登录
</DialogDescription>
</DialogHeader>
<div class="space-y-3">
<div v-if="providers.length === 0" class="text-center py-4 text-muted-foreground">
正在加载登录方式...
</div>
<button
v-for="provider in providers"
:key="provider.id"
@click="handleLogin(provider)"
class="w-full flex items-center gap-3 px-4 py-3 border rounded-lg hover:bg-accent transition-colors"
:style="{ borderColor: provider.color + '20' }"
>
<div class="w-10 h-10 flex items-center justify-center rounded-lg" :style="{ backgroundColor: provider.color + '10' }">
<component :is="getProviderIcon(provider.icon)" class="w-6 h-6" :style="{ color: provider.color }" />
</div>
<div class="flex-1 text-left">
<div class="font-medium">{{ provider.name }}</div>
<div class="text-sm text-muted-foreground">{{ provider.description }}</div>
</div>
<ChevronRight class="w-5 h-5 text-muted-foreground" />
</button>
</div>
</DialogContent>
</Dialog>
<!-- 登录状态处理 -->
<div v-if="isAuthenticating" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-background p-6 rounded-lg shadow-xl">
<div class="flex items-center gap-3">
<Loader2 class="w-5 h-5 animate-spin" />
<span>正在进行身份验证...</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Github, Globe, ChevronRight, Loader2 } from 'lucide-vue-next'
import { apiClient } from '@/lib/api'
import { toast } from 'vue-sonner'
const props = defineProps({
modelValue: Boolean,
onSuccess: Function,
})
const emit = defineEmits(['update:modelValue'])
const isOpen = ref(false)
const providers = ref([])
const isAuthenticating = ref(false)
let authWindow = null
// props
watch(() => props.modelValue, (val) => {
isOpen.value = val
})
//
watch(isOpen, (val) => {
emit('update:modelValue', val)
})
//
const getProviderIcon = (icon) => {
const icons = {
github: Github,
zerocat: Globe,
}
return icons[icon] || Globe
}
// OAuth
const loadProviders = async () => {
try {
const response = await apiClient.getOAuthProviders()
providers.value = response.data || []
} catch (error) {
console.error('Failed to load OAuth providers:', error)
toast.error('无法加载登录方式', {
description: '请检查网络连接'
})
}
}
//
const handleLogin = (provider) => {
// OAuth URL
const authUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'}${provider.authUrl}`
// OAuth
const width = 600
const height = 700
const left = (window.screen.width - width) / 2
const top = (window.screen.height - height) / 2
authWindow = window.open(
authUrl,
`oauth_${provider.id}`,
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`
)
isAuthenticating.value = true
isOpen.value = false
// OAuth
const handleMessage = (event) => {
//
if (event.origin !== window.location.origin) {
return
}
if (event.data.type === 'oauth_success') {
clearInterval(checkInterval)
clearTimeout(timeoutId)
isAuthenticating.value = false
window.removeEventListener('message', handleMessage)
if (authWindow && !authWindow.closed) {
authWindow.close()
}
toast.success('登录成功', {
description: `已通过 ${event.data.provider} 登录`
})
//
if (props.onSuccess) {
props.onSuccess(event.data.token)
}
} else if (event.data.type === 'oauth_error') {
clearInterval(checkInterval)
clearTimeout(timeoutId)
isAuthenticating.value = false
window.removeEventListener('message', handleMessage)
if (authWindow && !authWindow.closed) {
authWindow.close()
}
toast.error('登录失败', {
description: event.data.error
})
}
}
window.addEventListener('message', handleMessage)
// OAuth
const checkInterval = setInterval(() => {
try {
//
if (authWindow && authWindow.closed) {
clearInterval(checkInterval)
clearTimeout(timeoutId)
window.removeEventListener('message', handleMessage)
isAuthenticating.value = false
// localStoragetoken
const token = localStorage.getItem('auth_token')
const authProvider = localStorage.getItem('auth_provider')
if (token) {
toast.success('登录成功', {
description: `已通过 ${authProvider} 登录`
})
//
if (props.onSuccess) {
props.onSuccess(token)
}
}
}
} catch (error) {
//
}
}, 500)
// 30
const timeoutId = setTimeout(() => {
clearInterval(checkInterval)
window.removeEventListener('message', handleMessage)
if (authWindow && !authWindow.closed) {
authWindow.close()
}
if (isAuthenticating.value) {
isAuthenticating.value = false
toast.error('登录超时', {
description: '请重试'
})
}
}, 30000)
}
onMounted(() => {
loadProviders()
})
</script>

View File

@ -1,262 +0,0 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { apiClient } from '@/lib/api'
import { deviceStore } from '@/lib/deviceStore'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Button } from '@/components/ui/button'
import {
HelpCircle,
Info,
AlertCircle
} from 'lucide-vue-next'
const props = defineProps({
//
modelValue: {
type: String,
default: ''
},
label: {
type: String,
default: '密码'
},
placeholder: {
type: String,
default: '输入密码'
},
id: {
type: String,
default: () => `password-${Math.random().toString(36).substr(2, 9)}`
},
//
showHint: {
type: Boolean,
default: true
},
//
deviceUuid: {
type: String,
default: null
},
customHint: {
type: String,
default: ''
},
//
required: {
type: Boolean,
default: false
},
confirmPassword: {
type: String,
default: ''
},
//
error: {
type: String,
default: ''
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
//
const passwordHint = ref('')
const showHintPopup = ref(false)
const isLoading = ref(false)
const localValue = ref(props.modelValue)
// UUID
const effectiveDeviceUuid = computed(() => {
return props.deviceUuid || deviceStore.getDeviceUuid()
})
//
const validationState = computed(() => {
const errors = []
if (props.required && !localValue.value) {
errors.push('密码不能为空')
}
if (props.confirmPassword && localValue.value && localValue.value !== props.confirmPassword) {
errors.push('两次输入的密码不一致')
}
if (props.error) {
errors.push(props.error)
}
return {
isValid: errors.length === 0,
errors
}
})
//
const loadPasswordHint = async () => {
if (!props.showHint || props.customHint) {
passwordHint.value = props.customHint
return
}
if (!effectiveDeviceUuid.value) return
isLoading.value = true
try {
// API
const deviceInfo = await apiClient.getDeviceInfo(effectiveDeviceUuid.value)
if (deviceInfo.passwordHint) {
passwordHint.value = deviceInfo.passwordHint
} else {
// API
const data = await apiClient.getPasswordHint(effectiveDeviceUuid.value)
if (data.hint) {
passwordHint.value = data.hint
}
}
} catch (error) {
console.log('Failed to load password hint:', error)
// 使localStorage
} finally {
isLoading.value = false
}
}
//
const handleInput = (event) => {
localValue.value = event.target.value
emit('update:modelValue', localValue.value)
}
//
watch(() => props.modelValue, (newVal) => {
localValue.value = newVal
})
//
watch(() => props.customHint, (newVal) => {
if (newVal) {
passwordHint.value = newVal
}
})
onMounted(() => {
loadPasswordHint()
})
</script>
<template>
<div class="space-y-2">
<!-- 标签行 -->
<div v-if="label" class="flex items-center justify-between">
<Label :for="id" class="text-sm font-medium">
{{ label }}
<span v-if="required" class="text-red-500 ml-0.5">*</span>
</Label>
<!-- 密码提示按钮 -->
<button
v-if="showHint && passwordHint"
type="button"
@click="showHintPopup = !showHintPopup"
class="group relative"
>
<div class="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
<HelpCircle class="h-3.5 w-3.5" />
<span>密码提示</span>
</div>
<!-- 密码提示弹出框 -->
<div
v-if="showHintPopup"
class="absolute right-0 top-6 z-50 w-64 animate-in fade-in slide-in-from-top-1"
>
<div class="rounded-lg border bg-popover p-3 shadow-lg">
<div class="flex items-start gap-2">
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
<div class="space-y-1">
<p class="text-xs font-medium">密码提示</p>
<p class="text-xs text-muted-foreground">{{ passwordHint }}</p>
</div>
</div>
</div>
</div>
</button>
</div>
<!-- 密码输入框 -->
<div class="relative">
<div class="relative">
<Input
:id="id"
type="text"
:value="localValue"
@input="handleInput"
:placeholder="placeholder"
:disabled="disabled"
:class="{
'border-red-500': !validationState.isValid && localValue
}"
/>
<!-- 可见性切换按钮已移除 -->
</div>
<!-- 内联密码提示紧凑模式 -->
<div
v-if="showHint && passwordHint && !showHintPopup && !localValue"
class="absolute left-0 -bottom-5 text-xs text-muted-foreground flex items-center gap-1"
>
<HelpCircle class="h-3 w-3" />
<span class="truncate max-w-[200px]">{{ passwordHint }}</span>
</div>
</div>
<!-- 错误信息 -->
<div v-if="!validationState.isValid && localValue" class="space-y-1">
<div
v-for="(error, index) in validationState.errors"
:key="index"
class="flex items-center gap-1.5 text-xs text-red-500"
>
<AlertCircle class="h-3 w-3" />
<span>{{ error }}</span>
</div>
</div>
</div>
</template>
<style scoped>
/* 添加动画 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.2s ease-out;
}
</style>

View File

@ -1,274 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useAccountStore } from '@/stores/account'
import { apiClient } from '@/lib/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import PasswordInput from './PasswordInput.vue'
import { toast } from 'vue-sonner'
const props = defineProps({
modelValue: {
type: Boolean,
default: false
},
deviceUuid: {
type: String,
required: true
},
deviceName: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue', 'success'])
const accountStore = useAccountStore()
const password = ref('')
const isSubmitting = ref(false)
const showDeleteConfirm = ref(false)
const showHintDialog = ref(false)
const passwordHint = ref('')
const isSettingHint = ref(false)
const isOpen = computed({
get: () => props.modelValue,
set: (val) => {
if (!val) {
password.value = ''
}
emit('update:modelValue', val)
}
})
const resetPassword = async () => {
if (!password.value.trim()) {
toast.error('请输入新密码')
return
}
isSubmitting.value = true
try {
// 使
if (accountStore.isAuthenticated) {
await apiClient.resetDevicePasswordAsOwner(
props.deviceUuid,
password.value,
null, // passwordHint
accountStore.token
)
} else {
// 使
await apiClient.setDevicePassword(
props.deviceUuid,
{ password: password.value }
)
}
toast.success('密码重置成功')
isOpen.value = false
emit('success')
} catch (error) {
toast.error('重置密码失败:' + error.message)
} finally {
isSubmitting.value = false
}
}
const confirmDeletePassword = () => {
//
isOpen.value = false
//
setTimeout(() => {
showDeleteConfirm.value = true
}, 100)
}
const deletePassword = async () => {
isSubmitting.value = true
try {
await apiClient.deleteDevicePassword(props.deviceUuid, null, accountStore.token)
toast.success('密码已删除')
showDeleteConfirm.value = false
emit('success')
} catch (error) {
toast.error('删除密码失败:' + error.message)
} finally {
isSubmitting.value = false
}
}
const openHintDialog = () => {
//
isOpen.value = false
//
setTimeout(() => {
showHintDialog.value = true
}, 100)
}
const setPasswordHint = async () => {
isSettingHint.value = true
try {
await apiClient.setDevicePasswordHint(
props.deviceUuid,
passwordHint.value,
null,
accountStore.token
)
toast.success('密码提示已设置')
showHintDialog.value = false
passwordHint.value = ''
emit('success')
} catch (error) {
toast.error('设置密码提示失败:' + error.message)
} finally {
isSettingHint.value = false
}
}
const handleDeleteCancel = () => {
showDeleteConfirm.value = false
//
setTimeout(() => {
isOpen.value = true
}, 100)
}
const handleHintCancel = () => {
showHintDialog.value = false
passwordHint.value = ''
//
setTimeout(() => {
isOpen.value = true
}, 100)
}
</script>
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>重置设备密码</DialogTitle>
<DialogDescription>
为设备 {{ deviceName || deviceUuid }} 设置新密码
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
<p class="text-sm text-blue-700 dark:text-blue-300">
您已登录绑定的账户可以直接重置密码而无需输入当前密码
</p>
</div>
<div>
<PasswordInput
v-model="password"
label="新密码"
placeholder="输入新密码"
:show-hint="false"
:show-strength="true"
:min-length="8"
required
/>
</div>
</div>
<DialogFooter class="flex-col gap-2 sm:flex-row sm:justify-between">
<div class="flex gap-2">
<Button
variant="destructive"
@click="confirmDeletePassword"
:disabled="isSubmitting"
class="flex-1 sm:flex-none"
>
删除密码
</Button>
<Button
variant="outline"
@click="openHintDialog"
:disabled="isSubmitting"
class="flex-1 sm:flex-none"
>
设置提示
</Button>
</div>
<div class="flex gap-2">
<Button variant="outline" @click="isOpen = false" :disabled="isSubmitting">
取消
</Button>
<Button @click="resetPassword" :disabled="isSubmitting || !password.trim()">
{{ isSubmitting ? '重置中...' : '确认重置' }}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<!-- 删除密码确认对话框 -->
<AlertDialog v-model:open="showDeleteConfirm">
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除密码</AlertDialogTitle>
<AlertDialogDescription>
确定要删除设备 "{{ deviceName || deviceUuid }}" 的密码吗删除后任何人都可以访问该设备
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="handleDeleteCancel">取消</AlertDialogCancel>
<AlertDialogAction @click="deletePassword" :disabled="isSubmitting">
{{ isSubmitting ? '删除中...' : '确认删除' }}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- 设置密码提示对话框 -->
<Dialog v-model:open="showHintDialog">
<DialogContent class="max-w-md">
<DialogHeader>
<DialogTitle>设置密码提示</DialogTitle>
<DialogDescription>
为设备 {{ deviceName || deviceUuid }} 设置密码提示
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div>
<Label for="hint">密码提示</Label>
<Input
id="hint"
v-model="passwordHint"
placeholder="输入密码提示(可选)"
:disabled="isSettingHint"
class="mt-1.5"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleHintCancel" :disabled="isSettingHint">
取消
</Button>
<Button @click="setPasswordHint" :disabled="isSettingHint">
{{ isSettingHint ? '设置中...' : '确认设置' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@ -1,17 +0,0 @@
<script setup>
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@ -1,23 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogAction } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogAction
v-bind="delegatedProps"
:class="cn(buttonVariants(), props.class)"
>
<slot />
</AlertDialogAction>
</template>

View File

@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogCancel } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
"
>
<slot />
</AlertDialogCancel>
</template>

View File

@ -1,51 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@ -1,23 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogDescription } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
"
>
<slot />
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,23 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogTitle } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
import { AlertDialogTrigger } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@ -1,9 +0,0 @@
export { default as AlertDialog } from "./AlertDialog.vue";
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";

View File

@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { badgeVariants } from ".";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
variant: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Primitive
data-slot="badge"
:class="cn(badgeVariants({ variant }), props.class)"
v-bind="delegatedProps"
>
<slot />
</Primitive>
</template>

View File

@ -1,24 +0,0 @@
import { cva } from "class-variance-authority";
export { default as Badge } from "./Badge.vue";
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
},
);

View File

@ -1,24 +0,0 @@
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "button" },
});
</script>
<template>
<Primitive
data-slot="button"
:as="as"
:as-child="asChild"
:class="cn(buttonVariants({ variant, size }), props.class)"
>
<slot />
</Primitive>
</template>

View File

@ -1,34 +0,0 @@
import { cva } from "class-variance-authority";
export { default as Button } from "./Button.vue";
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-action"
:class="
cn(
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -1,13 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div data-slot="card-content" :class="cn('px-6', props.class)">
<slot />
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<p
data-slot="card-description"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</p>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-footer"
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="card-header"
:class="
cn(
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<h3
data-slot="card-title"
:class="cn('leading-none font-semibold', props.class)"
>
<slot />
</h3>
</template>

View File

@ -1,7 +0,0 @@
export { default as Card } from "./Card.vue";
export { default as CardAction } from "./CardAction.vue";
export { default as CardContent } from "./CardContent.vue";
export { default as CardDescription } from "./CardDescription.vue";
export { default as CardFooter } from "./CardFooter.vue";
export { default as CardHeader } from "./CardHeader.vue";
export { default as CardTitle } from "./CardTitle.vue";

View File

@ -1,46 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [Boolean, String], required: false },
modelValue: { type: [Boolean, String, null], required: false },
disabled: { type: Boolean, required: false },
value: { type: null, required: false },
id: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<CheckboxRoot
data-slot="checkbox"
v-bind="forwarded"
:class="
cn(
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<CheckboxIndicator
data-slot="checkbox-indicator"
class="flex items-center justify-center text-current transition-none"
>
<slot>
<Check class="size-3.5" />
</slot>
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@ -1 +0,0 @@
export { default as Checkbox } from "./Checkbox.vue";

View File

@ -1,18 +0,0 @@
<script setup>
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
modal: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<DialogRoot data-slot="dialog" v-bind="forwarded">
<slot />
</DialogRoot>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
import { DialogClose } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<DialogClose data-slot="dialog-close" v-bind="props">
<slot />
</DialogClose>
</template>

View File

@ -1,57 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import DialogOverlay from "./DialogOverlay.vue";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay />
<DialogContent
data-slot="dialog-content"
v-bind="forwarded"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
<DialogClose
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<X />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogPortal>
</template>

View File

@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogDescription, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</DialogDescription>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="dialog-footer"
:class="
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
"
>
<slot />
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
data-slot="dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -1,29 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogOverlay } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="
cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
props.class,
)
"
>
<slot />
</DialogOverlay>
</template>

View File

@ -1,71 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { X } from "lucide-vue-next";
import {
DialogClose,
DialogContent,
DialogOverlay,
DialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<DialogPortal>
<DialogOverlay
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
>
<DialogContent
:class="
cn(
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
props.class,
)
"
v-bind="forwarded"
@pointer-down-outside="
(event) => {
const originalEvent = event.detail.originalEvent;
const target = originalEvent.target;
if (
originalEvent.offsetX > target.clientWidth ||
originalEvent.offsetY > target.clientHeight
) {
event.preventDefault();
}
}
"
>
<slot />
<DialogClose
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
>
<X class="w-4 h-4" />
<span class="sr-only">Close</span>
</DialogClose>
</DialogContent>
</DialogOverlay>
</DialogPortal>
</template>

View File

@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { DialogTitle, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
>
<slot />
</DialogTitle>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
import { DialogTrigger } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
<slot />
</DialogTrigger>
</template>

View File

@ -1,10 +0,0 @@
export { default as Dialog } from "./Dialog.vue";
export { default as DialogClose } from "./DialogClose.vue";
export { default as DialogContent } from "./DialogContent.vue";
export { default as DialogDescription } from "./DialogDescription.vue";
export { default as DialogFooter } from "./DialogFooter.vue";
export { default as DialogHeader } from "./DialogHeader.vue";
export { default as DialogOverlay } from "./DialogOverlay.vue";
export { default as DialogScrollContent } from "./DialogScrollContent.vue";
export { default as DialogTitle } from "./DialogTitle.vue";
export { default as DialogTrigger } from "./DialogTrigger.vue";

View File

@ -1,22 +0,0 @@
<script setup>
import { defineProps } from 'vue'
defineProps({
disabled: {
type: Boolean,
default: false
}
})
</script>
<template>
<div
role="menuitem"
class="px-4 py-2 text-sm cursor-pointer flex items-center gap-2 hover:bg-muted hover:text-foreground transition-colors"
:class="{ 'opacity-50 cursor-not-allowed': disabled }"
:tabindex="disabled ? -1 : 0"
v-bind="$attrs"
>
<slot></slot>
</div>
</template>

View File

@ -1,62 +0,0 @@
<script setup>
import { ref, onMounted, onUnmounted, defineProps, defineEmits } from 'vue'
const props = defineProps({
open: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:open'])
const isOpen = ref(props.open)
const menuRef = ref(null)
const toggle = () => {
isOpen.value = !isOpen.value
emit('update:open', isOpen.value)
}
const close = () => {
if (isOpen.value) {
isOpen.value = false
emit('update:open', false)
}
}
const handleClickOutside = (event) => {
if (menuRef.value && !menuRef.value.contains(event.target)) {
close()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<template>
<div ref="menuRef" class="relative inline-block text-left">
<slot name="trigger" :toggle="toggle" :open="isOpen"></slot>
<div
v-if="isOpen"
class="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-popover border border-border z-50"
:class="$attrs.class"
>
<div
class="py-1 rounded-md bg-popover text-popover-foreground"
role="menu"
aria-orientation="vertical"
aria-labelledby="options-menu"
>
<slot></slot>
</div>
</div>
</div>
</template>

View File

@ -1,32 +0,0 @@
<script setup>
import { useVModel } from "@vueuse/core";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: [String, Number], required: false },
modelValue: { type: [String, Number], required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const modelValue = useVModel(props, "modelValue", emits, {
passive: true,
defaultValue: props.defaultValue,
});
</script>
<template>
<input
v-model="modelValue"
data-slot="input"
:class="
cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,
)
"
/>
</template>

View File

@ -1 +0,0 @@
export { default as Input } from "./Input.vue";

View File

@ -1,29 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Label } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Label
data-slot="label"
v-bind="delegatedProps"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
props.class,
)
"
>
<slot />
</Label>
</template>

View File

@ -1 +0,0 @@
export { default as Label } from "./Label.vue";

View File

@ -1,26 +0,0 @@
<script setup>
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
defaultValue: { type: null, required: false },
modelValue: { type: null, required: false },
by: { type: [String, Function], required: false },
dir: { type: String, required: false },
multiple: { type: Boolean, required: false },
autocomplete: { type: String, required: false },
disabled: { type: Boolean, required: false },
name: { type: String, required: false },
required: { type: Boolean, required: false },
});
const emits = defineEmits(["update:modelValue", "update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<SelectRoot data-slot="select" v-bind="forwarded">
<slot />
</SelectRoot>
</template>

View File

@ -1,81 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
SelectContent,
SelectPortal,
SelectViewport,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
defineOptions({
inheritAttrs: false,
});
const props = defineProps({
forceMount: { type: Boolean, required: false },
position: { type: String, required: false, default: "popper" },
bodyLock: { type: Boolean, required: false },
side: { type: null, required: false },
sideOffset: { type: Number, required: false },
sideFlip: { type: Boolean, required: false },
align: { type: null, required: false },
alignOffset: { type: Number, required: false },
alignFlip: { type: Boolean, required: false },
avoidCollisions: { type: Boolean, required: false },
collisionBoundary: { type: null, required: false },
collisionPadding: { type: [Number, Object], required: false },
arrowPadding: { type: Number, required: false },
sticky: { type: String, required: false },
hideWhenDetached: { type: Boolean, required: false },
positionStrategy: { type: String, required: false },
updatePositionStrategy: { type: String, required: false },
disableUpdateOnLayoutShift: { type: Boolean, required: false },
prioritizePosition: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"closeAutoFocus",
"escapeKeyDown",
"pointerDownOutside",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<SelectPortal>
<SelectContent
data-slot="select-content"
v-bind="{ ...forwarded, ...$attrs }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,
)
"
>
<SelectScrollUpButton />
<SelectViewport
:class="
cn(
'p-1',
position === 'popper' &&
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
)
"
>
<slot />
</SelectViewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPortal>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
import { SelectGroup } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectGroup data-slot="select-group" v-bind="props">
<slot />
</SelectGroup>
</template>

View File

@ -1,47 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Check } from "lucide-vue-next";
import {
SelectItem,
SelectItemIndicator,
SelectItemText,
useForwardProps,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: null, required: true },
disabled: { type: Boolean, required: false },
textValue: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectItem
data-slot="select-item"
v-bind="forwardedProps"
:class="
cn(
`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,
props.class,
)
"
>
<span class="absolute right-2 flex size-3.5 items-center justify-center">
<SelectItemIndicator>
<Check class="size-4" />
</SelectItemIndicator>
</span>
<SelectItemText>
<slot />
</SelectItemText>
</SelectItem>
</template>

View File

@ -1,14 +0,0 @@
<script setup>
import { SelectItemText } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectItemText data-slot="select-item-text" v-bind="props">
<slot />
</SelectItemText>
</template>

View File

@ -1,20 +0,0 @@
<script setup>
import { SelectLabel } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
for: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
</script>
<template>
<SelectLabel
data-slot="select-label"
:class="cn('px-2 py-1.5 text-sm font-medium', props.class)"
>
<slot />
</SelectLabel>
</template>

View File

@ -1,30 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollDownButton
data-slot="select-scroll-down-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronDown class="size-4" />
</slot>
</SelectScrollDownButton>
</template>

View File

@ -1,30 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronUp } from "lucide-vue-next";
import { SelectScrollUpButton, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectScrollUpButton
data-slot="select-scroll-up-button"
v-bind="forwardedProps"
:class="
cn('flex cursor-default items-center justify-center py-1', props.class)
"
>
<slot>
<ChevronUp class="size-4" />
</slot>
</SelectScrollUpButton>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { SelectSeparator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<SelectSeparator
data-slot="select-separator"
v-bind="delegatedProps"
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
/>
</template>

View File

@ -1,37 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
reference: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
size: { type: String, required: false, default: "default" },
});
const delegatedProps = reactiveOmit(props, "class", "size");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<SelectTrigger
data-slot="select-trigger"
:data-size="size"
v-bind="forwardedProps"
:class="
cn(
`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
"
>
<slot />
<SelectIcon as-child>
<ChevronDown class="size-4 opacity-50" />
</SelectIcon>
</SelectTrigger>
</template>

View File

@ -1,15 +0,0 @@
<script setup>
import { SelectValue } from "reka-ui";
const props = defineProps({
placeholder: { type: String, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<SelectValue data-slot="select-value" v-bind="props">
<slot />
</SelectValue>
</template>

View File

@ -1,11 +0,0 @@
export { default as Select } from "./Select.vue";
export { default as SelectContent } from "./SelectContent.vue";
export { default as SelectGroup } from "./SelectGroup.vue";
export { default as SelectItem } from "./SelectItem.vue";
export { default as SelectItemText } from "./SelectItemText.vue";
export { default as SelectLabel } from "./SelectLabel.vue";
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
export { default as SelectSeparator } from "./SelectSeparator.vue";
export { default as SelectTrigger } from "./SelectTrigger.vue";
export { default as SelectValue } from "./SelectValue.vue";

View File

@ -1,28 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { Separator } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
orientation: { type: String, required: false, default: "horizontal" },
decorative: { type: Boolean, required: false, default: true },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<Separator
data-slot="separator-root"
v-bind="delegatedProps"
:class="
cn(
`bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px`,
props.class,
)
"
/>
</template>

View File

@ -1 +0,0 @@
export { default as Separator } from "./Separator.vue";

View File

@ -1,39 +0,0 @@
<script setup>
import { Toaster as Sonner } from "vue-sonner";
const props = defineProps({
id: { type: String, required: false },
invert: { type: Boolean, required: false },
theme: { type: String, required: false },
position: { type: String, required: false },
closeButtonPosition: { type: String, required: false },
hotkey: { type: Array, required: false },
richColors: { type: Boolean, required: false },
expand: { type: Boolean, required: false },
duration: { type: Number, required: false },
gap: { type: Number, required: false },
visibleToasts: { type: Number, required: false },
closeButton: { type: Boolean, required: false },
toastOptions: { type: Object, required: false },
class: { type: String, required: false },
style: { type: Object, required: false },
offset: { type: [Object, String, Number], required: false },
mobileOffset: { type: [Object, String, Number], required: false },
dir: { type: String, required: false },
swipeDirections: { type: Array, required: false },
icons: { type: Object, required: false },
containerAriaLabel: { type: String, required: false },
});
</script>
<template>
<Sonner
class="toaster group"
v-bind="props"
:style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',
}"
/>
</template>

View File

@ -1 +0,0 @@
export { default as Toaster } from "./Sonner.vue";

View File

@ -1,18 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div data-slot="table-container" class="relative w-full overflow-auto">
<table
data-slot="table"
:class="cn('w-full caption-bottom text-sm', props.class)"
>
<slot />
</table>
</div>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tbody
data-slot="table-body"
:class="cn('[&_tr:last-child]:border-0', props.class)"
>
<slot />
</tbody>
</template>

View File

@ -1,16 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<caption
data-slot="table-caption"
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
>
<slot />
</caption>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<td
data-slot="table-cell"
:class="
cn(
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</td>
</template>

View File

@ -1,31 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { cn } from "@/lib/utils";
import TableCell from "./TableCell.vue";
import TableRow from "./TableRow.vue";
const props = defineProps({
class: { type: null, required: false },
colspan: { type: Number, required: false, default: 1 },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<TableRow>
<TableCell
:class="
cn(
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
props.class,
)
"
v-bind="delegatedProps"
>
<div class="flex items-center justify-center py-10">
<slot />
</div>
</TableCell>
</TableRow>
</template>

View File

@ -1,18 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tfoot
data-slot="table-footer"
:class="
cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)
"
>
<slot />
</tfoot>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<th
data-slot="table-head"
:class="
cn(
'text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
props.class,
)
"
>
<slot />
</th>
</template>

View File

@ -1,13 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>

View File

@ -1,21 +0,0 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<tr
data-slot="table-row"
:class="
cn(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
props.class,
)
"
>
<slot />
</tr>
</template>

View File

@ -1,9 +0,0 @@
export { default as Table } from "./Table.vue";
export { default as TableBody } from "./TableBody.vue";
export { default as TableCaption } from "./TableCaption.vue";
export { default as TableCell } from "./TableCell.vue";
export { default as TableEmpty } from "./TableEmpty.vue";
export { default as TableFooter } from "./TableFooter.vue";
export { default as TableHead } from "./TableHead.vue";
export { default as TableHeader } from "./TableHeader.vue";
export { default as TableRow } from "./TableRow.vue";

View File

@ -1,7 +0,0 @@
import { isFunction } from "@tanstack/vue-table";
export function valueUpdater(updaterOrValue, ref) {
ref.value = isFunction(updaterOrValue)
? updaterOrValue(ref.value)
: updaterOrValue;
}

View File

@ -1,31 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { TabsRoot, useForwardPropsEmits } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
defaultValue: { type: null, required: false },
orientation: { type: String, required: false },
dir: { type: String, required: false },
activationMode: { type: String, required: false },
modelValue: { type: null, required: false },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<TabsRoot
data-slot="tabs"
v-bind="forwarded"
:class="cn('flex flex-col gap-2', props.class)"
>
<slot />
</TabsRoot>
</template>

View File

@ -1,25 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { TabsContent } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: [String, Number], required: true },
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<TabsContent
data-slot="tabs-content"
:class="cn('flex-1 outline-none', props.class)"
v-bind="delegatedProps"
>
<slot />
</TabsContent>
</template>

View File

@ -1,29 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { TabsList } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
loop: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<TabsList
data-slot="tabs-list"
v-bind="delegatedProps"
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
props.class,
)
"
>
<slot />
</TabsList>
</template>

View File

@ -1,32 +0,0 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { TabsTrigger, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
value: { type: [String, Number], required: true },
disabled: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<TabsTrigger
data-slot="tabs-trigger"
v-bind="forwardedProps"
:class="
cn(
`data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
"
>
<slot />
</TabsTrigger>
</template>

View File

@ -1,4 +0,0 @@
export { default as Tabs } from "./Tabs.vue";
export { default as TabsContent } from "./TabsContent.vue";
export { default as TabsList } from "./TabsList.vue";
export { default as TabsTrigger } from "./TabsTrigger.vue";

View File

@ -1,126 +0,0 @@
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAccountStore } from '@/stores/account'
import { toast } from 'vue-sonner'
/**
* 处理OAuth回调
* 检查URL参数中是否有OAuth回调信息
*/
export function useOAuthCallback() {
const route = useRoute()
const router = useRouter()
const accountStore = useAccountStore()
const handleOAuthCallback = async () => {
const { token, provider, success, error } = route.query
// 检查是否是OAuth回调
if (!success && !error) {
return
}
// 处理成功回调
if (success === 'true' && token) {
try {
// 保存token到localStorage
localStorage.setItem('auth_token', token)
localStorage.setItem('auth_provider', provider)
// 登录到store
await accountStore.login(token)
// 显示成功提示
toast.success('登录成功', {
description: `已通过 ${provider} 登录`
})
// 清除URL参数
router.replace({ query: {} })
// 触发storage事件通知其他窗口
window.dispatchEvent(new StorageEvent('storage', {
key: 'auth_token',
newValue: token,
url: window.location.href
}))
// 如果是在新窗口中打开的OAuth回调自动关闭窗口
if (window.opener) {
// 通知父窗口登录成功
window.opener.postMessage({
type: 'oauth_success',
token,
provider
}, window.location.origin)
// 延迟关闭窗口,确保消息已发送
setTimeout(() => {
window.close()
}, 1000)
}
} catch (err) {
toast.error('登录失败', {
description: err.message || '处理登录信息时出错'
})
}
}
// 处理错误回调
if (success === 'false' || error) {
const errorMessages = {
'invalid_state': 'State验证失败可能存在安全风险',
'access_denied': '用户拒绝了授权请求',
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
}
const errorMsg = errorMessages[error] || error || '登录过程中出现错误'
toast.error('登录失败', {
description: errorMsg
})
// 清除URL参数
router.replace({ query: {} })
// 如果是在新窗口中打开的OAuth回调自动关闭窗口
if (window.opener) {
// 通知父窗口登录失败
window.opener.postMessage({
type: 'oauth_error',
error: errorMsg
}, window.location.origin)
// 延迟关闭窗口
setTimeout(() => {
window.close()
}, 1000)
}
}
}
onMounted(() => {
handleOAuthCallback()
})
// 监听storage事件处理其他标签页的登录
const handleStorageChange = (e) => {
if (e.key === 'auth_token' && e.newValue) {
// 其他标签页已登录,刷新当前页面的状态
accountStore.login(e.newValue)
}
}
onMounted(() => {
window.addEventListener('storage', handleStorageChange)
})
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange)
})
return {
handleOAuthCallback
}
}

View File

@ -1,533 +0,0 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
class ApiClient {
constructor(baseUrl, siteKey) {
this.baseUrl = baseUrl
this.siteKey = siteKey
}
async fetch(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
'x-site-key': this.siteKey,
...options.headers,
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }))
throw new Error(error.message || `HTTP ${response.status}`)
}
if (response.status === 204) {
return {}
}
return response.json()
}
// 带认证的fetch
async authenticatedFetch(endpoint, options = {}, token = null) {
const headers = {
...options.headers,
}
// 如果提供了token添加Authorization头
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return this.fetch(endpoint, {
...options,
headers,
})
}
// 应用相关 API
async getApps(params = {}) {
const query = new URLSearchParams(params).toString()
return this.fetch(`/apps${query ? `?${query}` : ''}`)
}
async getApp(appId) {
return this.fetch(`/apps/info/${appId}`)
}
async getAppInstallations(appId, deviceUuid, params = {}) {
const query = new URLSearchParams(params).toString()
return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, {
headers: {
'x-device-uuid': deviceUuid,
},
})
}
// Token 管理 API
async getDeviceTokens(deviceUuid, options = {}) {
const params = new URLSearchParams({
uuid: deviceUuid,
});
return this.fetch(`/apps/tokens?${params}`);
}
async revokeToken(targetToken, authOptions = {}) {
const { deviceUuid, password, usePathParam = true, bearerToken } = authOptions;
if (usePathParam) {
// 使用路径参数方式 (推荐)
const headers = {};
if (bearerToken) {
headers['Authorization'] = `Bearer ${bearerToken}`;
} else if (deviceUuid) {
headers['x-device-uuid'] = deviceUuid;
if (password) {
headers['x-device-password'] = password;
}
}
return this.fetch(`/apps/tokens/${targetToken}`, {
method: 'DELETE',
headers,
});
} else {
// 使用查询参数方式 (向后兼容)
const params = new URLSearchParams({ token: targetToken });
const headers = {};
if (bearerToken) {
headers['Authorization'] = `Bearer ${bearerToken}`;
} else if (deviceUuid) {
headers['x-device-uuid'] = deviceUuid;
if (password) {
headers['x-device-password'] = password;
}
}
return this.fetch(`/apps/tokens?${params}`, {
method: 'DELETE',
headers,
});
}
}
// 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
async authorizeApp(appId, deviceUuid, options = {}) {
const { password, note, token } = options;
const headers = {
'x-device-uuid': deviceUuid,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 使用新的安装接口
return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}?password=${password}`, {
method: 'POST',
headers,
body: JSON.stringify({ note: note || '应用授权' }),
});
}
// 设备级别的应用卸载,使用新的 uninstall 接口
async revokeDeviceToken(deviceUuid, installId, password = null, token = null) {
const params = new URLSearchParams({ uuid: deviceUuid });
const headers = {};
if (password) {
params.set('password', password);
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, {
method: 'DELETE',
headers,
});
}
// 设备密码管理 API
async setDevicePassword(deviceUuid, data, token = null) {
const { newPassword, currentPassword, passwordHint } = data;
// 检查设备是否已设置密码
const deviceInfo = await this.getDeviceInfo(deviceUuid);
const hasPassword = deviceInfo.hasPassword;
if (hasPassword) {
// 使用PUT修改密码
const params = new URLSearchParams();
params.set('uuid', deviceUuid);
params.set('newPassword', newPassword);
if (currentPassword) {
params.set('currentPassword', currentPassword);
}
if (passwordHint !== undefined) {
params.set('passwordHint', passwordHint);
}
const headers = {};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
method: 'PUT',
headers,
});
} else {
// 使用POST初次设置密码
const params = new URLSearchParams();
params.set('newPassword', newPassword);
if (passwordHint !== undefined) {
params.set('passwordHint', passwordHint);
}
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
method: 'POST',
});
}
}
async deleteDevicePassword(deviceUuid, password, token = null) {
const params = new URLSearchParams({ uuid: deviceUuid });
const headers = {};
// 如果提供了账户token使用JWT认证
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (password) {
params.set('password', password);
}
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
method: 'DELETE',
headers,
});
}
async setDevicePasswordHint(deviceUuid, hint, password = null, token = null) {
return this.authenticatedFetch(`/devices/${deviceUuid}/password-hint`, {
method: 'PUT',
body: JSON.stringify({ hint, password }),
}, token)
}
async getDevicePasswordHint(deviceUuid) {
return this.fetch(`/devices/${deviceUuid}/password-hint`)
}
// 设备授权相关 API
async bindDeviceCode(deviceCode, token) {
return this.fetch('/auth/device/bind', {
method: 'POST',
body: JSON.stringify({ device_code: deviceCode, token }),
})
}
async getDeviceCodeStatus(deviceCode) {
return this.fetch(`/auth/device/status?device_code=${deviceCode}`)
}
// KV 存储管理 API
async listKVItems(token, params = {}) {
const query = new URLSearchParams(params).toString()
return this.fetch(`/kv${query ? `?${query}` : ''}`, {
headers: { 'x-app-token': token }
})
}
async getKVItem(token, key) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
headers: { 'x-app-token': token }
})
}
async setKVItem(token, key, value) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { 'x-app-token': token },
body: JSON.stringify(value),
})
}
async deleteKVItem(token, key) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
method: 'DELETE',
headers: { 'x-app-token': token }
})
}
async getKVKeys(token, pattern = '*') {
return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, {
headers: { 'x-app-token': token }
})
}
// 设备信息 API
async getDeviceInfo(deviceUuid) {
return this.fetch(`/devices/${deviceUuid}`)
}
// 获取设备应用列表 API (公开接口,无需认证)
async getDeviceApps(deviceUuid) {
return this.fetch(`/apps/devices/${deviceUuid}/apps`)
}
// 密码提示管理 API
async getPasswordHint(deviceUuid) {
try {
const response = await this.fetch(`/devices/${deviceUuid}`)
return { hint: response.device?.passwordHint || '' }
} catch (error) {
// 如果接口不存在,返回空提示
return { hint: '' }
}
}
async setPasswordHint(deviceUuid, hint, password) {
try {
return await this.fetch(`/devices/${deviceUuid}/password-hint?password=${encodeURIComponent(password)}`, {
method: 'PUT',
headers: {
'x-device-uuid': deviceUuid,
},
body: JSON.stringify({ passwordHint: hint }),
})
} catch (error) {
// 如果接口不存在,忽略错误
console.log('Password hint API not available')
return { success: false }
}
}
// 账户相关 API
async getOAuthProviders() {
return this.fetch('/accounts/oauth/providers')
}
async getAccountProfile(token) {
return this.fetch('/accounts/profile', {
headers: { 'Authorization': `Bearer ${token}` }
})
}
async getAccountDevices(token) {
return this.fetch('/accounts/devices', {
headers: { 'Authorization': `Bearer ${token}` }
})
}
async bindDevice(token, deviceUuid) {
return this.fetch('/accounts/devices/bind', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ uuid: deviceUuid }),
})
}
async unbindDevice(token, deviceUuid) {
return this.fetch('/accounts/devices/unbind', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ uuid: deviceUuid }),
})
}
async getDeviceAccount(deviceUuid) {
return this.fetch(`/accounts/device/${deviceUuid}/account`)
}
// 绑定设备到当前账户
async bindDeviceToAccount(token, deviceUuid) {
return this.authenticatedFetch('/accounts/devices/bind', {
method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }),
}, token)
}
// 解绑设备
async unbindDeviceFromAccount(token, deviceUuid) {
return this.authenticatedFetch('/accounts/devices/unbind', {
method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }),
}, token)
}
// 批量解绑设备
async batchUnbindDevices(token, deviceUuids) {
return this.authenticatedFetch('/accounts/devices/unbind', {
method: 'POST',
body: JSON.stringify({ uuids: deviceUuids }),
}, token)
}
// 设备名称管理 API
async setDeviceName(deviceUuid, name, password = null, token = null) {
const headers = {
'x-device-uuid': deviceUuid,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
if (password) {
headers['x-device-password'] = password;
}
return this.fetch(`/devices/${deviceUuid}/name`, {
method: 'PUT',
headers,
body: JSON.stringify({ name }),
});
}
// 修改设备密码 API
async updateDevicePassword(deviceUuid, currentPassword, newPassword, passwordHint = null, token = null) {
const headers = {
'x-device-uuid': deviceUuid,
};
// 如果提供了账户token使用JWT认证账户拥有者无需当前密码
if (token) {
headers['Authorization'] = `Bearer ${token}`;
} else if (currentPassword) {
headers['x-device-password'] = currentPassword;
}
const body = { newPassword, passwordHint };
// 只有在非账户拥有者时才需要发送当前密码
if (!token && currentPassword) {
body.currentPassword = currentPassword;
}
return this.fetch(`/devices/${deviceUuid}/password`, {
method: 'PUT',
headers,
body: JSON.stringify(body),
});
}
// 验证设备密码 API
async verifyDevicePassword(deviceUuid, password) {
return this.fetch(`/devices/${deviceUuid}`, {
method: 'GET',
headers: {
'x-device-uuid': deviceUuid,
'x-device-password': password,
},
});
}
// 设备注册 API
async registerDevice(uuid, deviceName, token = null) {
return this.authenticatedFetch('/devices', {
method: 'POST',
body: JSON.stringify({ uuid, deviceName }),
}, token)
}
// 账户拥有者重置设备密码 API
async resetDevicePasswordAsOwner(deviceUuid, newPassword, passwordHint = null, token) {
return this.fetch(`/devices/${deviceUuid}/password`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'x-device-uuid': deviceUuid,
},
body: JSON.stringify({ newPassword, passwordHint }),
});
}
// 兼容性方法 - 保持旧的API调用方式
async getTokens(deviceUuid, options = {}) {
return this.getDeviceTokens(deviceUuid, options);
}
async deleteToken(targetToken, deviceUuid = null) {
// 向后兼容的删除方法
return this.revokeToken(targetToken, { deviceUuid, usePathParam: true });
}
// 便捷方法使用设备UUID和密码删除token
async revokeTokenByDevice(targetToken, deviceUuid, password = null) {
return this.revokeToken(targetToken, {
deviceUuid,
password,
usePathParam: true
});
}
// 便捷方法使用账户token删除token
async revokeTokenByAccount(targetToken, bearerToken) {
return this.revokeToken(targetToken, {
bearerToken,
usePathParam: true
});
}
// 便捷方法:应用自撤销
async revokeOwnToken(targetToken) {
return this.fetch(`/apps/tokens/${targetToken}`, {
method: 'DELETE',
headers: {
'x-app-token': targetToken,
},
});
}
// 新的便捷方法
async getTokensWithAuth(authType, authValue, options = {}) {
const headers = {};
const params = new URLSearchParams(options);
switch (authType) {
case 'uuid':
headers['x-device-uuid'] = authValue;
params.set('uuid', authValue);
break;
case 'token':
headers['x-app-token'] = authValue;
break;
case 'bearer':
headers['Authorization'] = `Bearer ${authValue}`;
break;
}
return this.fetch(`/apps/tokens?${params}`, { headers });
}
async revokeTokenWithAuth(targetToken, authType, authValue) {
const headers = {};
const params = new URLSearchParams({ token: targetToken });
switch (authType) {
case 'uuid':
headers['x-device-uuid'] = authValue;
break;
case 'token':
headers['x-app-token'] = authValue;
break;
case 'bearer':
headers['Authorization'] = `Bearer ${authValue}`;
break;
}
return this.fetch(`/apps/tokens?${params}`, {
method: 'DELETE',
headers,
});
}
}
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)

View File

@ -1,37 +0,0 @@
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
// 创建 axios 实例
const axiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
'x-site-key': SITE_KEY,
},
})
// 请求拦截器
axiosInstance.interceptors.request.use(
(config) => {
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
axiosInstance.interceptors.response.use(
(response) => {
return response.data
},
(error) => {
const message = error.response?.data?.message || error.message || 'Unknown error'
return Promise.reject(new Error(message))
}
)
export default axiosInstance

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