mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-07 21:13:10 +00:00
继续一大堆功能实现
This commit is contained in:
parent
521522c1d2
commit
7b1e224f70
18
.env.oauth.example
Normal file
18
.env.oauth.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# OAuth配置示例
|
||||||
|
# 复制此文件为 .env 并填入实际的值
|
||||||
|
|
||||||
|
# 服务基础URL(用于生成回调地址)
|
||||||
|
BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# GitHub OAuth
|
||||||
|
# 在 https://github.com/settings/developers 创建OAuth App
|
||||||
|
# Authorization callback URL: http://localhost:3000/accounts/oauth/github/callback
|
||||||
|
GITHUB_CLIENT_ID=your_github_client_id
|
||||||
|
GITHUB_CLIENT_SECRET=your_github_client_secret
|
||||||
|
|
||||||
|
# ZeroCat OAuth
|
||||||
|
# 在 ZeroCat 开发者平台创建应用
|
||||||
|
# 回调地址: http://localhost:3000/accounts/oauth/zerocat/callback
|
||||||
|
# 权限范围: user:basic user:email
|
||||||
|
ZEROCAT_CLIENT_ID=your_zerocat_client_id
|
||||||
|
ZEROCAT_CLIENT_SECRET=your_zerocat_client_secret
|
||||||
8
app.js
8
app.js
@ -17,7 +17,9 @@ import {
|
|||||||
|
|
||||||
import kvRouter from "./routes/kv-token.js";
|
import kvRouter from "./routes/kv-token.js";
|
||||||
import appsRouter from "./routes/apps.js";
|
import appsRouter from "./routes/apps.js";
|
||||||
|
import deviceRouter from "./routes/device.js";
|
||||||
import deviceAuthRouter from "./routes/device-auth.js";
|
import deviceAuthRouter from "./routes/device-auth.js";
|
||||||
|
import accountsRouter from "./routes/accounts.js";
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
@ -85,12 +87,18 @@ app.get("/check", apiLimiter, (req, res) => {
|
|||||||
// Mount the Apps router with API rate limiting
|
// Mount the Apps router with API rate limiting
|
||||||
app.use("/apps", apiLimiter, appsRouter);
|
app.use("/apps", apiLimiter, appsRouter);
|
||||||
|
|
||||||
|
// Mount the Device router with API rate limiting
|
||||||
|
app.use("/devices", apiLimiter, deviceRouter);
|
||||||
|
|
||||||
// Mount the KV store router with token-based rate limiting (更宽松的限速)
|
// Mount the KV store router with token-based rate limiting (更宽松的限速)
|
||||||
app.use("/kv", tokenBasedRateLimiter, kvRouter);
|
app.use("/kv", tokenBasedRateLimiter, kvRouter);
|
||||||
|
|
||||||
// Mount the Device Authorization router with API rate limiting
|
// Mount the Device Authorization router with API rate limiting
|
||||||
app.use("/auth", apiLimiter, deviceAuthRouter);
|
app.use("/auth", apiLimiter, deviceAuthRouter);
|
||||||
|
|
||||||
|
// Mount the Accounts router with API rate limiting
|
||||||
|
app.use("/accounts", apiLimiter, accountsRouter);
|
||||||
|
|
||||||
// 兜底404路由 - 处理所有未匹配的路由
|
// 兜底404路由 - 处理所有未匹配的路由
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
|
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);
|
||||||
|
|||||||
111
cli/README.md
111
cli/README.md
@ -1,22 +1,33 @@
|
|||||||
# 设备授权流程 - CLI 工具
|
# 设备授权流程 - CLI 工具
|
||||||
|
|
||||||
命令行工具,用于通过设备授权流程获取访问令牌。
|
命令行工具,用于通过设备授权流程获取访问令牌。支持两种授权模式:
|
||||||
|
|
||||||
|
- **设备代码模式** (`get-token.js`) - 用户手动输入设备代码完成授权
|
||||||
|
- **回调模式** (`get-token-callback.js`) - 通过浏览器回调自动完成授权
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
### 基本使用
|
### 1. 设备代码模式(推荐用于无GUI环境)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node cli/get-token.js
|
node cli/get-token.js
|
||||||
```
|
```
|
||||||
|
|
||||||
### 配置环境变量
|
### 2. 回调模式(推荐用于桌面环境)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node cli/get-token-callback.js
|
||||||
|
```
|
||||||
|
|
||||||
|
### 环境变量配置
|
||||||
|
|
||||||
|
两种模式都支持以下环境变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 设置API服务器地址(默认: http://localhost:3030)
|
# 设置API服务器地址(默认: http://localhost:3030)
|
||||||
export API_BASE_URL=https://your-api-server.com
|
export API_BASE_URL=https://your-api-server.com
|
||||||
|
|
||||||
# 设置授权页面地址(默认: https://classworks.xiaomo.tech/authorize)
|
# 设置授权页面地址(默认: http://localhost:5173/authorize)
|
||||||
export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize
|
export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize
|
||||||
|
|
||||||
# 设置应用ID(默认: 1)
|
# 设置应用ID(默认: 1)
|
||||||
@ -25,8 +36,13 @@ export APP_ID=1
|
|||||||
# 设置站点密钥(如果需要)
|
# 设置站点密钥(如果需要)
|
||||||
export SITE_KEY=your-site-key
|
export SITE_KEY=your-site-key
|
||||||
|
|
||||||
|
# 回调模式特有配置
|
||||||
|
export CALLBACK_PORT=8080 # 回调服务器端口(默认: 8080)
|
||||||
|
export TIMEOUT=300 # 授权超时时间(默认: 300秒)
|
||||||
|
|
||||||
# 运行工具
|
# 运行工具
|
||||||
node cli/get-token.js
|
node cli/get-token.js # 设备代码模式
|
||||||
|
node cli/get-token-callback.js # 回调模式
|
||||||
```
|
```
|
||||||
|
|
||||||
### 使其可执行(Linux/Mac)
|
### 使其可执行(Linux/Mac)
|
||||||
@ -38,15 +54,28 @@ chmod +x cli/get-token.js
|
|||||||
|
|
||||||
## 工作流程
|
## 工作流程
|
||||||
|
|
||||||
|
### 设备代码模式 (`get-token.js`)
|
||||||
|
|
||||||
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
|
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
|
||||||
2. **显示授权链接** - 在终端显示完整的授权URL,包含设备代码
|
2. **显示授权链接** - 在终端显示完整的授权URL,包含设备代码
|
||||||
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
|
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
|
||||||
4. **获取令牌** - 工具自动轮询并获取令牌
|
4. **获取令牌** - 工具自动轮询并获取令牌
|
||||||
5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt`
|
5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt`
|
||||||
|
|
||||||
|
### 回调模式 (`get-token-callback.js`)
|
||||||
|
|
||||||
|
1. **获取设备UUID** - 自动获取或生成设备UUID
|
||||||
|
2. **启动回调服务器** - 在本地启动HTTP服务器监听回调
|
||||||
|
3. **打开授权页面** - 自动在浏览器中打开授权页面
|
||||||
|
4. **用户授权** - 用户在浏览器中完成授权操作
|
||||||
|
5. **接收回调** - 本地服务器接收授权回调并获取令牌
|
||||||
|
6. **保存令牌** - 令牌会保存到 `~/.classworks/token-callback.txt`
|
||||||
|
|
||||||
## 输出示例
|
## 输出示例
|
||||||
|
|
||||||
```
|
### 设备代码模式输出
|
||||||
|
|
||||||
|
```text
|
||||||
设备授权流程 - 令牌获取工具
|
设备授权流程 - 令牌获取工具
|
||||||
|
|
||||||
✓ 设备授权码生成成功!
|
✓ 设备授权码生成成功!
|
||||||
@ -80,19 +109,83 @@ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
|||||||
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
|
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 回调模式输出
|
||||||
|
|
||||||
|
```text
|
||||||
|
回调授权流程 - 令牌获取工具
|
||||||
|
|
||||||
|
ℹ 正在获取设备UUID...
|
||||||
|
✓ 设备UUID: 1234567890abcdef1234567890abcdef
|
||||||
|
|
||||||
|
============================================================
|
||||||
|
请访问以下地址完成授权:
|
||||||
|
|
||||||
|
http://localhost:5173/authorize?app_id=1&mode=callback&callback_url=http://localhost:8080/callback&state=abc123
|
||||||
|
|
||||||
|
设备UUID: 1234567890abcdef1234567890abcdef
|
||||||
|
状态参数: abc123
|
||||||
|
============================================================
|
||||||
|
ℹ 回调地址: http://localhost:8080/callback
|
||||||
|
ℹ API服务器: http://localhost:3030
|
||||||
|
ℹ 超时时间: 300 秒
|
||||||
|
|
||||||
|
ℹ 正在启动回调服务器...
|
||||||
|
✓ 回调服务器已启动: http://localhost:8080/callback
|
||||||
|
ℹ 正在尝试打开浏览器...
|
||||||
|
✓ 已尝试打开浏览器
|
||||||
|
ℹ 等待授权完成...
|
||||||
|
|
||||||
|
==================================================
|
||||||
|
✓ 授权成功!令牌获取完成
|
||||||
|
==================================================
|
||||||
|
|
||||||
|
您的访问令牌:
|
||||||
|
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
|
||||||
|
✓ 令牌已保存到: /home/user/.classworks/token-callback.txt
|
||||||
|
|
||||||
|
使用示例:
|
||||||
|
curl -H "Authorization: Bearer eyJhbGc..." http://localhost:3030/kv
|
||||||
|
```
|
||||||
|
|
||||||
## 配置选项
|
## 配置选项
|
||||||
|
|
||||||
可以通过修改 `cli/get-token.js` 中的 `CONFIG` 对象或设置环境变量来调整:
|
### 通用配置
|
||||||
|
|
||||||
- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: http://localhost:3030)
|
可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整:
|
||||||
- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: https://classworks.xiaomo.tech/authorize)
|
|
||||||
|
- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`)
|
||||||
|
- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`)
|
||||||
- `appId` / `APP_ID` - 应用ID(默认: 1)
|
- `appId` / `APP_ID` - 应用ID(默认: 1)
|
||||||
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
|
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
|
||||||
|
|
||||||
|
### 设备代码模式专用配置
|
||||||
|
|
||||||
- `pollInterval` - 轮询间隔(秒,默认3秒)
|
- `pollInterval` - 轮询间隔(秒,默认3秒)
|
||||||
- `maxPolls` - 最大轮询次数(默认100次)
|
- `maxPolls` - 最大轮询次数(默认100次)
|
||||||
|
|
||||||
|
### 回调模式专用配置
|
||||||
|
|
||||||
|
- `callbackPort` / `CALLBACK_PORT` - 回调服务器端口(默认: 8080)
|
||||||
|
- `timeout` / `TIMEOUT` - 授权超时时间(秒,默认: 300)
|
||||||
|
- `callbackPath` - 回调路径(默认: /callback)
|
||||||
|
|
||||||
## 错误处理
|
## 错误处理
|
||||||
|
|
||||||
|
### 设备代码模式
|
||||||
|
|
||||||
- 如果设备代码过期,会显示错误并退出
|
- 如果设备代码过期,会显示错误并退出
|
||||||
- 如果轮询超时(默认5分钟),会显示超时错误
|
- 如果轮询超时(默认5分钟),会显示超时错误
|
||||||
- 如果无法连接到服务器,会显示连接错误
|
- 如果无法连接到服务器,会显示连接错误
|
||||||
|
|
||||||
|
### 回调模式
|
||||||
|
|
||||||
|
- 如果回调端口被占用,会提示更换端口
|
||||||
|
- 如果授权超时,会显示超时错误并提示延长超时时间
|
||||||
|
- 如果状态参数不匹配,会拒绝授权防止CSRF攻击
|
||||||
|
- 如果无法连接到服务器,会显示连接错误
|
||||||
|
|
||||||
|
## 选择模式建议
|
||||||
|
|
||||||
|
- **设备代码模式** - 适用于无GUI环境、服务器环境、或无法启动本地服务器的场景
|
||||||
|
- **回调模式** - 适用于桌面环境、开发环境、或希望更流畅授权体验的场景
|
||||||
|
|||||||
422
cli/get-token-callback.js
Normal file
422
cli/get-token-callback.js
Normal file
@ -0,0 +1,422 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回调授权流程 - 命令行工具
|
||||||
|
*
|
||||||
|
* 用于演示回调授权流程,获取访问令牌
|
||||||
|
* 通过启动本地HTTP服务器接收回调来获取令牌
|
||||||
|
*
|
||||||
|
* 使用方法:
|
||||||
|
* node cli/get-token-callback.js
|
||||||
|
* 或配置为可执行:chmod +x cli/get-token-callback.js && ./cli/get-token-callback.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
import http from 'http';
|
||||||
|
import url from 'url';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
// 配置
|
||||||
|
const CONFIG = {
|
||||||
|
// API服务器地址
|
||||||
|
baseUrl: process.env.API_BASE_URL || 'http://localhost:3030',
|
||||||
|
// 站点密钥
|
||||||
|
siteKey: process.env.SITE_KEY || '',
|
||||||
|
// 应用ID
|
||||||
|
appId: process.env.APP_ID || '1',
|
||||||
|
// 授权页面地址(Classworks前端)
|
||||||
|
authPageUrl: process.env.AUTH_PAGE_URL || 'http://localhost:5173/authorize',
|
||||||
|
// 本地回调服务器端口
|
||||||
|
callbackPort: process.env.CALLBACK_PORT || '8080',
|
||||||
|
// 回调路径
|
||||||
|
callbackPath: '/callback',
|
||||||
|
// 超时时间(秒)
|
||||||
|
timeout: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 颜色输出
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
bright: '\x1b[1m',
|
||||||
|
dim: '\x1b[2m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
cyan: '\x1b[36m',
|
||||||
|
};
|
||||||
|
|
||||||
|
function log(message, color = '') {
|
||||||
|
console.log(`${color}${message}${colors.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSuccess(message) {
|
||||||
|
log(`✓ ${message}`, colors.green);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(message) {
|
||||||
|
log(`✗ ${message}`, colors.red);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logInfo(message) {
|
||||||
|
log(`ℹ ${message}`, colors.cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logWarning(message) {
|
||||||
|
log(`⚠ ${message}`, colors.yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP请求封装
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const requestUrl = `${CONFIG.baseUrl}${path}`;
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (CONFIG.siteKey) {
|
||||||
|
headers['X-Site-Key'] = CONFIG.siteKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(requestUrl, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('fetch')) {
|
||||||
|
throw new Error(`无法连接到服务器: ${CONFIG.baseUrl}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机状态字符串
|
||||||
|
function generateState() {
|
||||||
|
return randomBytes(16).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取设备UUID
|
||||||
|
async function getDeviceUuid() {
|
||||||
|
try {
|
||||||
|
const deviceInfo = await request('/device/info');
|
||||||
|
return deviceInfo.uuid;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果设备不存在,生成新的UUID
|
||||||
|
const uuid = randomBytes(16).toString('hex');
|
||||||
|
logInfo(`生成新的设备UUID: ${uuid}`);
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建回调服务器
|
||||||
|
function createCallbackServer(state) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let server;
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
const handleRequest = (req, res) => {
|
||||||
|
if (resolved) return;
|
||||||
|
|
||||||
|
const parsedUrl = url.parse(req.url, true);
|
||||||
|
|
||||||
|
if (parsedUrl.pathname === CONFIG.callbackPath) {
|
||||||
|
const { token, error, state: returnedState } = parsedUrl.query;
|
||||||
|
|
||||||
|
// 验证状态参数
|
||||||
|
if (returnedState !== state) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>授权失败</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="error">授权失败</h1>
|
||||||
|
<p>状态参数不匹配,可能存在安全风险。</p>
|
||||||
|
<p>请重新尝试授权流程。</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
resolved = true;
|
||||||
|
server.close();
|
||||||
|
reject(new Error('状态参数不匹配'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>授权失败</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="error">授权失败</h1>
|
||||||
|
<p>${error}</p>
|
||||||
|
<p>您可以关闭此页面并重新尝试。</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
resolved = true;
|
||||||
|
server.close();
|
||||||
|
reject(new Error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>授权成功</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||||
|
.success { color: #2e7d32; }
|
||||||
|
.token { background: #f5f5f5; padding: 10px; border-radius: 4px; margin: 20px; font-family: monospace; word-break: break-all; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="success">授权成功!</h1>
|
||||||
|
<p>令牌已成功获取,您可以关闭此页面。</p>
|
||||||
|
<div class="token">${token}</div>
|
||||||
|
<p><small>令牌已自动复制到命令行界面</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
resolved = true;
|
||||||
|
server.close();
|
||||||
|
resolve(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有token和error参数
|
||||||
|
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||||
|
res.end(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>无效请求</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
|
||||||
|
.error { color: #d32f2f; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 class="error">无效请求</h1>
|
||||||
|
<p>缺少必要的参数。</p>
|
||||||
|
<p>请重新尝试授权流程。</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
resolved = true;
|
||||||
|
server.close();
|
||||||
|
reject(new Error('缺少必要的参数'));
|
||||||
|
} else {
|
||||||
|
// 404 for other paths
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Not Found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
server = http.createServer(handleRequest);
|
||||||
|
|
||||||
|
server.listen(CONFIG.callbackPort, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
logSuccess(`回调服务器已启动: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
server.close();
|
||||||
|
reject(new Error('授权超时'));
|
||||||
|
}
|
||||||
|
}, CONFIG.timeout * 1000);
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (!resolved) {
|
||||||
|
resolved = true;
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开浏览器
|
||||||
|
async function openBrowser(url) {
|
||||||
|
const { spawn } = await import('child_process');
|
||||||
|
|
||||||
|
let command;
|
||||||
|
let args;
|
||||||
|
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
command = 'cmd';
|
||||||
|
args = ['/c', 'start', url];
|
||||||
|
} else if (process.platform === 'darwin') {
|
||||||
|
command = 'open';
|
||||||
|
args = [url];
|
||||||
|
} else {
|
||||||
|
command = 'xdg-open';
|
||||||
|
args = [url];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
spawn(command, args, { detached: true, stdio: 'ignore' });
|
||||||
|
logSuccess('已尝试打开浏览器');
|
||||||
|
} catch (error) {
|
||||||
|
logWarning('无法自动打开浏览器,请手动打开授权链接');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示授权信息
|
||||||
|
function displayAuthInfo(authUrl, deviceUuid, state) {
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
log(` 请访问以下地址完成授权:`, colors.bright);
|
||||||
|
console.log('');
|
||||||
|
log(` ${authUrl}`, colors.cyan + colors.bright);
|
||||||
|
console.log('');
|
||||||
|
log(` 设备UUID: ${deviceUuid}`, colors.green);
|
||||||
|
log(` 状态参数: ${state}`, colors.dim);
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
logInfo(`回调地址: http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`);
|
||||||
|
logInfo(`API服务器: ${CONFIG.baseUrl}`);
|
||||||
|
logInfo(`超时时间: ${CONFIG.timeout} 秒`);
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存令牌到文件
|
||||||
|
async function saveToken(token) {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const os = await import('os');
|
||||||
|
|
||||||
|
const tokenDir = path.join(os.homedir(), '.classworks');
|
||||||
|
const tokenFile = path.join(tokenDir, 'token-callback.txt');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 确保目录存在
|
||||||
|
if (!fs.existsSync(tokenDir)) {
|
||||||
|
fs.mkdirSync(tokenDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入令牌
|
||||||
|
fs.writeFileSync(tokenFile, token, 'utf8');
|
||||||
|
logSuccess(`令牌已保存到: ${tokenFile}`);
|
||||||
|
} catch (error) {
|
||||||
|
logWarning(`无法保存令牌到文件: ${error.message}`);
|
||||||
|
logInfo('您可以手动保存令牌');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主函数
|
||||||
|
async function main() {
|
||||||
|
console.log('\n' + colors.cyan + colors.bright + '回调授权流程 - 令牌获取工具' + colors.reset + '\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查配置
|
||||||
|
if (!CONFIG.siteKey) {
|
||||||
|
logWarning('未设置 SITE_KEY 环境变量,可能需要站点密钥才能访问');
|
||||||
|
logInfo('设置方法: export SITE_KEY=your-site-key');
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 获取设备UUID
|
||||||
|
logInfo('正在获取设备UUID...');
|
||||||
|
const deviceUuid = await getDeviceUuid();
|
||||||
|
logSuccess(`设备UUID: ${deviceUuid}`);
|
||||||
|
|
||||||
|
// 2. 生成状态参数
|
||||||
|
const state = generateState();
|
||||||
|
|
||||||
|
// 3. 构建回调URL
|
||||||
|
const callbackUrl = `http://localhost:${CONFIG.callbackPort}${CONFIG.callbackPath}`;
|
||||||
|
|
||||||
|
// 4. 构建授权URL
|
||||||
|
const authUrl = new URL(CONFIG.authPageUrl);
|
||||||
|
authUrl.searchParams.set('app_id', CONFIG.appId);
|
||||||
|
authUrl.searchParams.set('mode', 'callback');
|
||||||
|
authUrl.searchParams.set('callback_url', callbackUrl);
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
// 5. 显示授权信息
|
||||||
|
displayAuthInfo(authUrl.toString(), deviceUuid, state);
|
||||||
|
|
||||||
|
// 6. 启动回调服务器
|
||||||
|
logInfo('正在启动回调服务器...');
|
||||||
|
const serverPromise = createCallbackServer(state);
|
||||||
|
|
||||||
|
// 7. 打开浏览器
|
||||||
|
logInfo('正在尝试打开浏览器...');
|
||||||
|
await openBrowser(authUrl.toString());
|
||||||
|
|
||||||
|
// 8. 等待授权完成
|
||||||
|
logInfo('等待授权完成...\n');
|
||||||
|
const token = await serverPromise;
|
||||||
|
|
||||||
|
// 9. 显示令牌
|
||||||
|
console.log('\n' + '='.repeat(50));
|
||||||
|
logSuccess('授权成功!令牌获取完成');
|
||||||
|
console.log('='.repeat(50));
|
||||||
|
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
|
||||||
|
log(token, colors.green);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
// 10. 保存令牌
|
||||||
|
await saveToken(token);
|
||||||
|
|
||||||
|
// 11. 使用示例
|
||||||
|
console.log('\n' + colors.bright + '使用示例:' + colors.reset);
|
||||||
|
console.log(` curl -H "Authorization: Bearer ${token}" ${CONFIG.baseUrl}/kv`);
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.log('');
|
||||||
|
logError(`错误: ${error.message}`);
|
||||||
|
|
||||||
|
// 提供一些常见问题的解决方案
|
||||||
|
if (error.message.includes('EADDRINUSE')) {
|
||||||
|
logInfo(`端口 ${CONFIG.callbackPort} 已被占用,请尝试设置不同的端口:`);
|
||||||
|
logInfo(`CALLBACK_PORT=8081 node cli/get-token-callback.js`);
|
||||||
|
} else if (error.message.includes('无法连接到服务器')) {
|
||||||
|
logInfo('请检查API服务器是否正在运行');
|
||||||
|
logInfo(`当前API地址: ${CONFIG.baseUrl}`);
|
||||||
|
} else if (error.message.includes('授权超时')) {
|
||||||
|
logInfo(`授权超时(${CONFIG.timeout}秒),请重新尝试`);
|
||||||
|
logInfo('您可以设置更长的超时时间:TIMEOUT=600 node cli/get-token-callback.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 运行
|
||||||
|
main();
|
||||||
39
config/oauth.js
Normal file
39
config/oauth.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// OAuth 提供者配置
|
||||||
|
export const oauthProviders = {
|
||||||
|
github: {
|
||||||
|
clientId: process.env.GITHUB_CLIENT_ID,
|
||||||
|
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||||
|
authorizationURL: "https://github.com/login/oauth/authorize",
|
||||||
|
tokenURL: "https://github.com/login/oauth/access_token",
|
||||||
|
userInfoURL: "https://api.github.com/user",
|
||||||
|
scope: "read:user user:email",
|
||||||
|
name: "GitHub",
|
||||||
|
icon: "github",
|
||||||
|
color: "#24292e",
|
||||||
|
description: "使用 GitHub 账号登录",
|
||||||
|
},
|
||||||
|
zerocat: {
|
||||||
|
clientId: process.env.ZEROCAT_CLIENT_ID,
|
||||||
|
clientSecret: process.env.ZEROCAT_CLIENT_SECRET,
|
||||||
|
authorizationURL: "https://zerocat-api.houlangs.com/oauth/authorize",
|
||||||
|
tokenURL: "https://zerocat-api.houlangs.com/oauth/token",
|
||||||
|
userInfoURL: "https://zerocat-api.houlangs.com/oauth/userinfo",
|
||||||
|
scope: "user:basic user:email",
|
||||||
|
name: "ZeroCat",
|
||||||
|
icon: "zerocat",
|
||||||
|
color: "#6366f1",
|
||||||
|
description: "使用 ZeroCat 账号登录",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取OAuth回调URL
|
||||||
|
export function getCallbackURL(provider) {
|
||||||
|
const baseUrl = process.env.BASE_URL;
|
||||||
|
return `${baseUrl}/accounts/oauth/${provider}/callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成随机state参数
|
||||||
|
export function generateState() {
|
||||||
|
return Math.random().toString(36).substring(2, 15) +
|
||||||
|
Math.random().toString(36).substring(2, 15);
|
||||||
|
}
|
||||||
@ -1,671 +0,0 @@
|
|||||||
# API 使用示例 - cURL
|
|
||||||
|
|
||||||
本文档提供所有API接口的完整cURL示例。
|
|
||||||
|
|
||||||
## 环境变量设置
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 设置基础URL和站点密钥
|
|
||||||
export BASE_URL="http://localhost:3030"
|
|
||||||
export SITE_KEY="your-site-key-here"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. 应用管理 API
|
|
||||||
|
|
||||||
### 1.1 获取应用列表
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 基本查询
|
|
||||||
curl -X GET "http://localhost:3030/apps" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 带分页和搜索
|
|
||||||
curl -X GET "http://localhost:3030/apps?limit=10&skip=0&search=my-app" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apps": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "我的应用",
|
|
||||||
"description": "应用描述",
|
|
||||||
"developerName": "开发者名称",
|
|
||||||
"developerLink": "https://developer.com",
|
|
||||||
"homepageLink": "https://app.com",
|
|
||||||
"iconHash": "abc123",
|
|
||||||
"metadata": {},
|
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"skip": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 获取单个应用详情
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/apps/1" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "我的应用",
|
|
||||||
"description": "应用描述",
|
|
||||||
"developerName": "开发者名称",
|
|
||||||
"developerLink": "https://developer.com",
|
|
||||||
"homepageLink": "https://app.com",
|
|
||||||
"iconHash": "abc123",
|
|
||||||
"metadata": {},
|
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.4 获取应用的所有安装记录
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/apps/1/installations?limit=10&skip=0" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"appId": 1,
|
|
||||||
"installations": [
|
|
||||||
{
|
|
||||||
"id": "clx1234567890",
|
|
||||||
"token": "a1b2c3d4e5f6...",
|
|
||||||
"device": {
|
|
||||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"name": "我的设备"
|
|
||||||
},
|
|
||||||
"note": "完整访问",
|
|
||||||
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"skip": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Token 管理 API
|
|
||||||
|
|
||||||
### 2.1 获取设备的所有Token
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/apps/devices/550e8400-e29b-41d4-a716-446655440000/tokens" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"deviceName": "我的设备",
|
|
||||||
"tokens": [
|
|
||||||
{
|
|
||||||
"id": "clx1234567890",
|
|
||||||
"token": "a1b2c3d4e5f6...",
|
|
||||||
"app": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "我的应用",
|
|
||||||
"description": "应用描述",
|
|
||||||
"developerName": "开发者",
|
|
||||||
"iconHash": "abc123"
|
|
||||||
},
|
|
||||||
"note": "完整访问",
|
|
||||||
"installedAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 撤销Token
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3030/apps/tokens/a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:** HTTP 204 No Content
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "Token不存在"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. KV 操作 API(需要Token)
|
|
||||||
|
|
||||||
**设置Token环境变量:**
|
|
||||||
```bash
|
|
||||||
export TOKEN="a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.1 获取所有键(含元数据)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 基本查询
|
|
||||||
curl -X GET "http://localhost:3030/kv" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 带分页和排序
|
|
||||||
curl -X GET "http://localhost:3030/kv?sortBy=key&sortDir=asc&limit=50&skip=0" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 按更新时间排序
|
|
||||||
curl -X GET "http://localhost:3030/kv?sortBy=updatedAt&sortDir=desc&limit=20" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "user.profile",
|
|
||||||
"metadata": {
|
|
||||||
"creatorIp": "192.168.1.1",
|
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_rows": 1,
|
|
||||||
"load_more": "/kv?sortBy=key&sortDir=asc&limit=50&skip=50"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 获取键名列表(仅键名)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/_keys" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 带分页
|
|
||||||
curl -X GET "http://localhost:3030/kv/_keys?limit=100&skip=0" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keys": [
|
|
||||||
"user.profile",
|
|
||||||
"user.settings",
|
|
||||||
"app.config"
|
|
||||||
],
|
|
||||||
"total_rows": 3,
|
|
||||||
"current_page": {
|
|
||||||
"limit": 100,
|
|
||||||
"skip": 0,
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.3 获取键值
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 Authorization header(推荐)
|
|
||||||
curl -X GET "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 使用 query 参数
|
|
||||||
curl -X GET "http://localhost:3030/kv/user.profile?token=${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "张三",
|
|
||||||
"email": "zhangsan@example.com",
|
|
||||||
"avatar": "https://example.com/avatar.jpg",
|
|
||||||
"preferences": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应(键不存在):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "未找到键名为 'user.profile' 的记录"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.4 获取键的元数据
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/user.profile/metadata" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "user.profile",
|
|
||||||
"metadata": {
|
|
||||||
"creatorIp": "192.168.1.1",
|
|
||||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-01T12:30:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.5 创建/更新键值
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 创建新键
|
|
||||||
curl -X POST "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"name": "张三",
|
|
||||||
"email": "zhangsan@example.com",
|
|
||||||
"avatar": "https://example.com/avatar.jpg"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 更新已存在的键
|
|
||||||
curl -X POST "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"name": "张三",
|
|
||||||
"email": "newemail@example.com",
|
|
||||||
"avatar": "https://example.com/new-avatar.jpg",
|
|
||||||
"updatedBy": "admin"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 存储数组
|
|
||||||
curl -X POST "http://localhost:3030/kv/user.tags" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '["developer", "admin", "vip"]'
|
|
||||||
|
|
||||||
# 存储嵌套对象
|
|
||||||
curl -X POST "http://localhost:3030/kv/app.config" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"database": {
|
|
||||||
"host": "localhost",
|
|
||||||
"port": 3306,
|
|
||||||
"name": "mydb"
|
|
||||||
},
|
|
||||||
"cache": {
|
|
||||||
"enabled": true,
|
|
||||||
"ttl": 3600
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例(创建):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "user.profile",
|
|
||||||
"created": true,
|
|
||||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例(更新):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "user.profile",
|
|
||||||
"created": false,
|
|
||||||
"updatedAt": "2025-01-01T12:30:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.6 批量导入键值对
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3030/kv/_batchimport" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"user.profile": {
|
|
||||||
"name": "张三",
|
|
||||||
"email": "zhangsan@example.com"
|
|
||||||
},
|
|
||||||
"user.settings": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN"
|
|
||||||
},
|
|
||||||
"app.config": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"debug": false
|
|
||||||
}
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"total": 3,
|
|
||||||
"successful": 3,
|
|
||||||
"failed": 0,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "user.profile",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user.settings",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "app.config",
|
|
||||||
"created": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**部分失败响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"total": 3,
|
|
||||||
"successful": 2,
|
|
||||||
"failed": 1,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "user.profile",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user.settings",
|
|
||||||
"created": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"key": "invalid.key",
|
|
||||||
"error": "验证失败"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.7 删除键值对
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:** HTTP 204 No Content
|
|
||||||
|
|
||||||
**错误响应(键不存在):**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "未找到键名为 'user.profile' 的记录"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 完整工作流示例
|
|
||||||
|
|
||||||
### 场景:应用首次访问设备的KV存储
|
|
||||||
|
|
||||||
```bash
|
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# 1. 设置环境变量
|
|
||||||
export BASE_URL="http://localhost:3030"
|
|
||||||
export SITE_KEY="your-site-key"
|
|
||||||
export APP_ID="1"
|
|
||||||
export DEVICE_UUID="550e8400-e29b-41d4-a716-446655440000"
|
|
||||||
export DEVICE_PASSWORD="my-password"
|
|
||||||
|
|
||||||
# 2. 为应用授权获取token
|
|
||||||
echo "正在授权应用..."
|
|
||||||
RESPONSE=$(curl -s -X POST "http://localhost:3030/apps/${APP_ID}/authorize" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d "{
|
|
||||||
\"deviceUuid\": \"${DEVICE_UUID}\",
|
|
||||||
\"password\": \"${DEVICE_PASSWORD}\",
|
|
||||||
\"readOnly\": false,
|
|
||||||
\"note\": \"自动授权\"
|
|
||||||
}")
|
|
||||||
|
|
||||||
# 3. 提取token
|
|
||||||
TOKEN=$(echo $RESPONSE | jq -r '.token')
|
|
||||||
echo "获取到Token: ${TOKEN:0:20}..."
|
|
||||||
|
|
||||||
# 4. 写入数据
|
|
||||||
echo "写入用户配置..."
|
|
||||||
curl -X POST "http://localhost:3030/kv/user.config" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"theme": "dark",
|
|
||||||
"notifications": true,
|
|
||||||
"language": "zh-CN"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# 5. 读取数据
|
|
||||||
echo "读取用户配置..."
|
|
||||||
curl -X GET "http://localhost:3030/kv/user.config" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 6. 获取所有键名
|
|
||||||
echo "获取所有键名..."
|
|
||||||
curl -X GET "http://localhost:3030/kv/_keys" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
|
|
||||||
# 7. 批量导入数据
|
|
||||||
echo "批量导入数据..."
|
|
||||||
curl -X POST "http://localhost:3030/kv/_batchimport" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"user.profile": {"name": "张三", "age": 25},
|
|
||||||
"user.preferences": {"color": "blue"},
|
|
||||||
"app.version": {"current": "1.0.0"}
|
|
||||||
}'
|
|
||||||
|
|
||||||
echo "完成!"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 错误处理示例
|
|
||||||
|
|
||||||
### 5.1 Token认证失败
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用无效token
|
|
||||||
curl -X GET "http://localhost:3030/kv/mykey" \
|
|
||||||
-H "Authorization: Bearer invalid-token" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "无效的身份验证令牌"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.2 缺少Token
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/mykey" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "未提供身份验证令牌"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.3 站点密钥错误
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/apps" \
|
|
||||||
-H "x-site-key: wrong-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "无效的站点密钥"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.4 设备不存在
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3030/apps/1/authorize" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{
|
|
||||||
"deviceUuid": "non-existent-uuid"
|
|
||||||
}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "设备不存在"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 高级用例
|
|
||||||
|
|
||||||
### 6.1 使用jq处理响应
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 提取所有键名
|
|
||||||
curl -s -X GET "http://localhost:3030/kv/_keys" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
| jq -r '.keys[]'
|
|
||||||
|
|
||||||
# 获取token并保存
|
|
||||||
TOKEN=$(curl -s -X POST "http://localhost:3030/apps/1/authorize" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d '{"deviceUuid":"550e8400-e29b-41d4-a716-446655440000"}' \
|
|
||||||
| jq -r '.token')
|
|
||||||
|
|
||||||
# 格式化输出
|
|
||||||
curl -s -X GET "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
| jq '.'
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 循环批量操作
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 批量创建键值对
|
|
||||||
for i in {1..10}; do
|
|
||||||
curl -X POST "http://localhost:3030/kv/item.${i}" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d "{\"id\": ${i}, \"name\": \"Item ${i}\"}"
|
|
||||||
echo "Created item.${i}"
|
|
||||||
done
|
|
||||||
|
|
||||||
# 批量读取
|
|
||||||
for key in user.profile user.settings app.config; do
|
|
||||||
echo "Reading ${key}:"
|
|
||||||
curl -s -X GET "http://localhost:3030/kv/${key}" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
| jq '.'
|
|
||||||
done
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 条件更新模式
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 读取当前值
|
|
||||||
CURRENT=$(curl -s -X GET "http://localhost:3030/kv/counter" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}")
|
|
||||||
|
|
||||||
# 修改值
|
|
||||||
NEW_VALUE=$(echo $CURRENT | jq '.count += 1')
|
|
||||||
|
|
||||||
# 写回
|
|
||||||
curl -X POST "http://localhost:3030/kv/counter" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-d "${NEW_VALUE}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 性能测试
|
|
||||||
|
|
||||||
### 7.1 并发请求测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 使用 xargs 进行并发测试
|
|
||||||
seq 1 10 | xargs -P 10 -I {} curl -s -X GET "http://localhost:3030/kv/test.key" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-o /dev/null -w "Request {}: %{http_code} in %{time_total}s\n"
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 响应时间测试
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测量单个请求时间
|
|
||||||
curl -X GET "http://localhost:3030/kv/user.profile" \
|
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H "x-site-key: ${SITE_KEY}" \
|
|
||||||
-w "\nTotal time: %{time_total}s\n" \
|
|
||||||
-o /dev/null -s
|
|
||||||
```
|
|
||||||
@ -1,226 +0,0 @@
|
|||||||
# API 重构文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本次重构将数据库从基于 `namespace` (UUID字符串) 的架构迁移到基于 `deviceId` (自增整数) 的架构,并实现了完整的token授权系统。
|
|
||||||
|
|
||||||
## 数据库变更
|
|
||||||
|
|
||||||
### Device 表
|
|
||||||
- **主键变更**: `uuid` (VARCHAR) → `id` (INT AUTO_INCREMENT)
|
|
||||||
- **uuid**: 改为 UNIQUE 索引
|
|
||||||
- **新增字段**: `accountId` (用于未来关联社区账户)
|
|
||||||
|
|
||||||
### KVStore 表
|
|
||||||
- **外键变更**: `namespace` (VARCHAR) → `deviceId` (INT)
|
|
||||||
- **主键**: `(deviceId, key)` 复合主键
|
|
||||||
- **关联**: 外键关联 `Device.id`,支持级联删除
|
|
||||||
|
|
||||||
### 新增表
|
|
||||||
|
|
||||||
#### App 表
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `App` (
|
|
||||||
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
||||||
`name` VARCHAR(191) NOT NULL,
|
|
||||||
`description` VARCHAR(191),
|
|
||||||
`developerName` VARCHAR(191) NOT NULL,
|
|
||||||
`developerLink` VARCHAR(191),
|
|
||||||
`homepageLink` VARCHAR(191),
|
|
||||||
`iconHash` VARCHAR(191),
|
|
||||||
`metadata` JSON,
|
|
||||||
`createdAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
#### AppInstall 表
|
|
||||||
```sql
|
|
||||||
CREATE TABLE `AppInstall` (
|
|
||||||
`id` VARCHAR(191) PRIMARY KEY,
|
|
||||||
`deviceId` INT NOT NULL,
|
|
||||||
`appId` INT NOT NULL,
|
|
||||||
`token` VARCHAR(191) UNIQUE NOT NULL,
|
|
||||||
`note` VARCHAR(191),
|
|
||||||
`installedAt` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
|
|
||||||
`updatedAt` DATETIME(3) NOT NULL,
|
|
||||||
FOREIGN KEY (`deviceId`) REFERENCES `Device`(`id`) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (`appId`) REFERENCES `App`(`id`) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 架构
|
|
||||||
|
|
||||||
### 1. 应用授权流程
|
|
||||||
|
|
||||||
#### POST /apps/:appId/authorize
|
|
||||||
为应用获取访问token
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceUuid": "设备UUID",
|
|
||||||
"password": "设备密码(如需要)",
|
|
||||||
"readOnly": false,
|
|
||||||
"note": "备注信息"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "生成的访问token",
|
|
||||||
"appId": 1,
|
|
||||||
"appName": "应用名称",
|
|
||||||
"deviceUuid": "设备UUID",
|
|
||||||
"deviceName": "设备名称",
|
|
||||||
"readOnly": false,
|
|
||||||
"note": "读写访问",
|
|
||||||
"authorizedAt": "2025-01-01T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Token-based KV 操作(唯一方式)
|
|
||||||
|
|
||||||
⚠️ **重要变更**: 所有KV操作现在仅支持基于token的访问,旧的 `/kv/:namespace/:key` API已移除。
|
|
||||||
|
|
||||||
#### Token提供方式
|
|
||||||
1. Authorization Header: `Authorization: Bearer <token>`
|
|
||||||
2. Query 参数: `?token=<token>`
|
|
||||||
3. Request Body: `{"token": "<token>"}`
|
|
||||||
|
|
||||||
#### KV API端点
|
|
||||||
```
|
|
||||||
GET /kv - 列出所有键(含元数据)
|
|
||||||
GET /kv/_keys - 列出所有键名(仅键名)
|
|
||||||
GET /kv/:key - 获取键值
|
|
||||||
GET /kv/:key/metadata - 获取键元数据
|
|
||||||
POST /kv/:key - 创建/更新键值
|
|
||||||
POST /kv/_batchimport - 批量导入
|
|
||||||
DELETE /kv/:key - 删除键值
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 主要接口
|
|
||||||
|
|
||||||
#### 应用管理
|
|
||||||
- `GET /apps` - 获取应用列表
|
|
||||||
- `GET /apps/:id` - 获取应用详情
|
|
||||||
- `POST /apps/:id/authorize` - 授权应用获取token
|
|
||||||
- `GET /apps/:id/installations` - 获取应用的所有安装记录
|
|
||||||
|
|
||||||
#### Token管理
|
|
||||||
- `GET /apps/devices/:deviceUuid/tokens` - 获取设备的所有token
|
|
||||||
- `DELETE /apps/tokens/:token` - 撤销token
|
|
||||||
|
|
||||||
#### KV操作(仅Token方式)
|
|
||||||
- `GET /kv` - 列出所有键(含元数据)
|
|
||||||
- `GET /kv/_keys` - 列出所有键名(仅键名)
|
|
||||||
- `GET /kv/:key` - 获取键值
|
|
||||||
- `GET /kv/:key/metadata` - 获取键元数据
|
|
||||||
- `POST /kv/:key` - 创建/更新键值
|
|
||||||
- `POST /kv/_batchimport` - 批量导入
|
|
||||||
- `DELETE /kv/:key` - 删除键值
|
|
||||||
|
|
||||||
## 使用示例
|
|
||||||
|
|
||||||
### 1. 授权应用
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('http://localhost:3000/apps/1/authorize', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-site-key': 'your-site-key'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
deviceUuid: 'your-device-uuid',
|
|
||||||
password: 'device-password-if-needed',
|
|
||||||
readOnly: false,
|
|
||||||
note: '我的应用授权'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const { token } = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 使用Token读取KV
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('http://localhost:3000/kv/mykey', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'x-site-key': 'your-site-key'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const value = await response.json();
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 使用Token写入KV
|
|
||||||
```javascript
|
|
||||||
const response = await fetch('http://localhost:3000/kv/mykey', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-site-key': 'your-site-key'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
data: 'my value',
|
|
||||||
timestamp: Date.now()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 迁移指南
|
|
||||||
|
|
||||||
### 数据库迁移
|
|
||||||
|
|
||||||
1. 标记旧迁移为已应用:
|
|
||||||
```bash
|
|
||||||
npx prisma migrate resolve --applied 20250524123414_2025_05_25
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 执行新迁移:
|
|
||||||
```bash
|
|
||||||
npx prisma migrate deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
### 代码更新
|
|
||||||
|
|
||||||
⚠️ **破坏性变更**: 旧的基于namespace的API已完全移除。
|
|
||||||
|
|
||||||
**旧代码(不再支持):**
|
|
||||||
```javascript
|
|
||||||
// ❌ 已移除
|
|
||||||
GET /kv/:namespace/:key
|
|
||||||
POST /kv/:namespace/:key
|
|
||||||
DELETE /kv/:namespace/:key
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码(唯一方式):**
|
|
||||||
```javascript
|
|
||||||
// ✅ 使用token-based API
|
|
||||||
GET /kv/:key
|
|
||||||
POST /kv/:key
|
|
||||||
DELETE /kv/:key
|
|
||||||
|
|
||||||
// 必须在header中提供token
|
|
||||||
Headers: {
|
|
||||||
'Authorization': 'Bearer <token>',
|
|
||||||
'x-site-key': 'your-site-key'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**迁移步骤:**
|
|
||||||
1. 为每个需要访问KV的应用调用 `POST /apps/:id/authorize` 获取token
|
|
||||||
2. 将所有KV API调用从 `/kv/:namespace/:key` 改为 `/kv/:key`
|
|
||||||
3. 在所有请求中添加 `Authorization: Bearer <token>` header
|
|
||||||
4. 测试确保所有功能正常
|
|
||||||
|
|
||||||
## 优势
|
|
||||||
|
|
||||||
1. **安全性提升**: Token-based认证,无需在URL中暴露namespace
|
|
||||||
2. **多设备支持**: 同一UUID可在不同设备上使用不同token
|
|
||||||
3. **细粒度权限**: 可为每个应用授权只读或读写权限
|
|
||||||
4. **易于管理**: 可随时撤销token,不影响其他授权
|
|
||||||
5. **性能优化**: 使用整数ID作为外键,查询效率更高
|
|
||||||
6. **简化API**: 统一的token认证方式,无需在URL中指定namespace
|
|
||||||
@ -1,600 +0,0 @@
|
|||||||
# 前端迁移指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本文档描述了后端中间件系统的重构,以及前端需要如何适配这些变化。核心变化是统一了设备信息获取和权限验证流程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 核心变化
|
|
||||||
|
|
||||||
### 1. 统一的设备中间件系统
|
|
||||||
|
|
||||||
后端现在使用统一的中间件处理所有与设备UUID相关的操作:
|
|
||||||
|
|
||||||
- **`deviceMiddleware`**: 自动获取或创建设备,设备不存在时自动创建
|
|
||||||
- **`requireWriteAuth`**: 验证写权限,检查设备密码
|
|
||||||
- **`tokenAuth`**: Token认证,用于应用访问
|
|
||||||
|
|
||||||
### 2. 设备自动创建
|
|
||||||
|
|
||||||
**重要变化**: 当使用一个新的UUID访问API时,后端会自动创建该设备,无需手动调用创建设备接口。
|
|
||||||
|
|
||||||
### 3. 权限模型
|
|
||||||
|
|
||||||
- **读操作**: 永远不需要密码
|
|
||||||
- **写操作**: 如果设备设置了密码则需要验证,否则直接允许
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
|
|
||||||
## 场景1: 基于UUID的直接访问
|
|
||||||
|
|
||||||
适用于:用户直接操作设备数据(设备配置、设备管理等)
|
|
||||||
|
|
||||||
### 读操作(无需密码)
|
|
||||||
|
|
||||||
**请求方式**: `GET /device/:deviceUuid/*`
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- 设备不存在时自动创建
|
|
||||||
- 无需提供密码
|
|
||||||
- 任何知道UUID的人都可以读取
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
GET /device/550e8400-e29b-41d4-a716-446655440000/info
|
|
||||||
Headers:
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"uuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"name": null,
|
|
||||||
"password": null,
|
|
||||||
"passwordHint": null,
|
|
||||||
"accountId": null,
|
|
||||||
"createdAt": "2025-01-30T10:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-30T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 写操作(需要密码验证)
|
|
||||||
|
|
||||||
**请求方式**: `POST|PUT|DELETE /device/:deviceUuid/*`
|
|
||||||
|
|
||||||
**特点**:
|
|
||||||
- 设备不存在时自动创建
|
|
||||||
- 如果设备设置了密码,必须提供正确密码
|
|
||||||
- 如果设备没有密码,直接允许写入
|
|
||||||
|
|
||||||
#### 密码提供方式
|
|
||||||
|
|
||||||
**方式1: 通过请求体(推荐)**
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /device/550e8400-e29b-41d4-a716-446655440000/config
|
|
||||||
Headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
x-site-key: your-site-key
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"password": "device-password",
|
|
||||||
"data": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式2: 通过查询参数**
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /device/550e8400-e29b-41d4-a716-446655440000/config?password=device-password
|
|
||||||
Headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
x-site-key: your-site-key
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "数据已更新",
|
|
||||||
"updatedAt": "2025-01-30T10:05:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 需要密码** (401):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "此操作需要密码",
|
|
||||||
"passwordHint": "您的生日(8位数字)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 密码错误** (401):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "密码错误"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 场景2: 基于Token的应用访问
|
|
||||||
|
|
||||||
适用于:应用访问KV存储数据
|
|
||||||
|
|
||||||
### 步骤1: 获取Token
|
|
||||||
|
|
||||||
**请求方式**: `POST /apps/:appId/authorize`
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
POST /apps/1/authorize
|
|
||||||
Headers:
|
|
||||||
Content-Type: application/json
|
|
||||||
x-site-key: your-site-key
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"password": "device-password",
|
|
||||||
"note": "我的应用授权"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**:
|
|
||||||
- `deviceUuid`: 必填,设备UUID
|
|
||||||
- `password`: 如果设备有密码则必填
|
|
||||||
- `note`: 可选,授权备注
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "clxxx123456789abcdefg",
|
|
||||||
"appId": 1,
|
|
||||||
"appName": "我的应用",
|
|
||||||
"deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"deviceName": null,
|
|
||||||
"note": "我的应用授权",
|
|
||||||
"authorizedAt": "2025-01-30T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 需要密码** (401):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "此操作需要密码",
|
|
||||||
"passwordHint": "您的生日(8位数字)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 步骤2: 使用Token访问KV存储
|
|
||||||
|
|
||||||
#### Token提供方式
|
|
||||||
|
|
||||||
**方式1: Authorization Header(推荐)**
|
|
||||||
```http
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式2: Query参数**
|
|
||||||
```http
|
|
||||||
?token=clxxx123456789abcdefg
|
|
||||||
```
|
|
||||||
|
|
||||||
**方式3: Request Body**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "clxxx123456789abcdefg",
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### KV API端点
|
|
||||||
|
|
||||||
| 方法 | 端点 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| GET | `/kv` | 列出所有键(含元数据) |
|
|
||||||
| GET | `/kv/_keys` | 列出所有键名(仅键名) |
|
|
||||||
| GET | `/kv/:key` | 获取键值 |
|
|
||||||
| GET | `/kv/:key/metadata` | 获取键元数据 |
|
|
||||||
| POST | `/kv/:key` | 创建/更新键值 |
|
|
||||||
| POST | `/kv/_batchimport` | 批量导入 |
|
|
||||||
| DELETE | `/kv/:key` | 删除键值 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /kv - 列出所有键(含元数据)
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
GET /kv?sortBy=key&sortDir=asc&limit=10&skip=0
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询参数**:
|
|
||||||
- `sortBy`: 排序字段(key/createdAt/updatedAt),默认 key
|
|
||||||
- `sortDir`: 排序方向(asc/desc),默认 asc
|
|
||||||
- `limit`: 每页数量,默认 100
|
|
||||||
- `skip`: 跳过数量,默认 0
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "config",
|
|
||||||
"metadata": {
|
|
||||||
"creatorIp": "192.168.1.1",
|
|
||||||
"createdAt": "2025-01-30T10:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-30T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "user.name",
|
|
||||||
"metadata": {
|
|
||||||
"creatorIp": "192.168.1.1",
|
|
||||||
"createdAt": "2025-01-30T10:01:00.000Z",
|
|
||||||
"updatedAt": "2025-01-30T10:01:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_rows": 25,
|
|
||||||
"load_more": "/kv?sortBy=key&sortDir=asc&limit=10&skip=10"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /kv/_keys - 列出所有键名
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
GET /kv/_keys?limit=50&skip=0
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keys": ["config", "user.name", "user.theme", "app.settings"],
|
|
||||||
"total_rows": 4,
|
|
||||||
"current_page": {
|
|
||||||
"limit": 50,
|
|
||||||
"skip": 0,
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /kv/:key - 获取键值
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
GET /kv/config
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN",
|
|
||||||
"fontSize": 14
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 键不存在** (404):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "未找到键名为 'config' 的记录"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /kv/:key/metadata - 获取键元数据
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
GET /kv/config/metadata
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "config",
|
|
||||||
"metadata": {
|
|
||||||
"creatorIp": "192.168.1.1",
|
|
||||||
"createdAt": "2025-01-30T10:00:00.000Z",
|
|
||||||
"updatedAt": "2025-01-30T10:05:00.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /kv/:key - 创建/更新键值
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
POST /kv/config
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
Content-Type: application/json
|
|
||||||
x-site-key: your-site-key
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN",
|
|
||||||
"fontSize": 14
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"key": "config",
|
|
||||||
"created": false,
|
|
||||||
"updatedAt": "2025-01-30T10:10:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**说明**:
|
|
||||||
- `created`: true表示新建,false表示更新
|
|
||||||
|
|
||||||
**错误响应 - 空值** (400):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "请提供有效的JSON值"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /kv/_batchimport - 批量导入
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
POST /kv/_batchimport
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
Content-Type: application/json
|
|
||||||
x-site-key: your-site-key
|
|
||||||
Body:
|
|
||||||
{
|
|
||||||
"config": {
|
|
||||||
"theme": "dark",
|
|
||||||
"language": "zh-CN"
|
|
||||||
},
|
|
||||||
"user.name": {
|
|
||||||
"firstName": "John",
|
|
||||||
"lastName": "Doe"
|
|
||||||
},
|
|
||||||
"app.settings": {
|
|
||||||
"notifications": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"total": 3,
|
|
||||||
"successful": 3,
|
|
||||||
"failed": 0,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "config",
|
|
||||||
"created": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user.name",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "app.settings",
|
|
||||||
"created": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**部分失败响应** (200):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"deviceId": 1,
|
|
||||||
"total": 3,
|
|
||||||
"successful": 2,
|
|
||||||
"failed": 1,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "config",
|
|
||||||
"created": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "user.name",
|
|
||||||
"created": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"errors": [
|
|
||||||
{
|
|
||||||
"key": "app.settings",
|
|
||||||
"error": "Invalid value"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /kv/:key - 删除键值
|
|
||||||
|
|
||||||
**请求示例**:
|
|
||||||
```http
|
|
||||||
DELETE /kv/config
|
|
||||||
Headers:
|
|
||||||
Authorization: Bearer clxxx123456789abcdefg
|
|
||||||
x-site-key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应** (204):
|
|
||||||
```
|
|
||||||
无响应体
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应 - 键不存在** (404):
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 404,
|
|
||||||
"message": "未找到键名为 'config' 的记录"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 错误码参考
|
|
||||||
|
|
||||||
| 状态码 | 说明 | 场景 |
|
|
||||||
|--------|------|------|
|
|
||||||
| 200 | 成功 | 操作成功 |
|
|
||||||
| 204 | 成功(无内容) | 删除成功 |
|
|
||||||
| 400 | 请求错误 | 参数缺失或格式错误 |
|
|
||||||
| 401 | 未授权 | 需要密码、密码错误、Token无效 |
|
|
||||||
| 403 | 禁止访问 | 权限不足 |
|
|
||||||
| 404 | 未找到 | 资源不存在 |
|
|
||||||
| 500 | 服务器错误 | 服务器内部错误 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 401错误详解
|
|
||||||
|
|
||||||
### 需要密码
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "此操作需要密码",
|
|
||||||
"passwordHint": "您的生日(8位数字)"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**处理方式**: 提示用户输入密码,使用 `passwordHint` 作为提示信息
|
|
||||||
|
|
||||||
### 密码错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "密码错误"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**处理方式**: 提示用户密码错误,允许重试
|
|
||||||
|
|
||||||
### Token无效
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "未提供身份验证令牌"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
或
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "无效的身份验证令牌"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**处理方式**: 清除本地Token,引导用户重新授权
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 迁移检查清单
|
|
||||||
|
|
||||||
### Phase 1: 基础适配
|
|
||||||
- [ ] 移除手动创建设备的逻辑(设备会自动创建)
|
|
||||||
- [ ] 更新密码提供方式(从header改为body/query)
|
|
||||||
- [ ] 实现统一的错误处理
|
|
||||||
- [ ] 更新API端点路径
|
|
||||||
|
|
||||||
### Phase 2: Token集成
|
|
||||||
- [ ] 实现应用授权流程(POST /apps/:appId/authorize)
|
|
||||||
- [ ] 集成Token到KV操作
|
|
||||||
- [ ] 实现Token存储和管理(localStorage)
|
|
||||||
- [ ] 处理Token过期/无效场景
|
|
||||||
|
|
||||||
### Phase 3: 优化
|
|
||||||
- [ ] 封装统一的API客户端
|
|
||||||
- [ ] 实现请求重试机制
|
|
||||||
- [ ] 添加Loading状态管理
|
|
||||||
- [ ] 优化错误提示用户体验
|
|
||||||
|
|
||||||
### Phase 4: 测试
|
|
||||||
- [ ] 测试设备自动创建
|
|
||||||
- [ ] 测试密码验证流程(需要密码、密码错误、密码正确)
|
|
||||||
- [ ] 测试Token授权流程
|
|
||||||
- [ ] 测试各种错误场景(404、401、400等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 关键注意事项
|
|
||||||
|
|
||||||
### 1. 设备自动创建
|
|
||||||
- ✅ 无需手动创建设备,首次访问自动创建
|
|
||||||
- ✅ 简化前端流程,减少API调用
|
|
||||||
- ⚠️ 确保UUID使用正确的格式(建议使用uuidv4)
|
|
||||||
|
|
||||||
### 2. 密码处理
|
|
||||||
- ✅ 读操作永远不需要密码
|
|
||||||
- ✅ 写操作只在设备设置了密码时才需要
|
|
||||||
- ⚠️ 密码通过body或query提供,不要放在header中
|
|
||||||
- ⚠️ 注意区分"需要密码"和"密码错误"两种情况
|
|
||||||
|
|
||||||
### 3. Token管理
|
|
||||||
- ✅ Token一次获取,可重复使用
|
|
||||||
- ✅ Token与设备和应用绑定
|
|
||||||
- ⚠️ Token需要安全存储(localStorage/sessionStorage)
|
|
||||||
- ⚠️ Token失效时需要重新授权
|
|
||||||
|
|
||||||
### 4. Header要求
|
|
||||||
- 所有请求必须携带 `x-site-key` header
|
|
||||||
- Token认证使用 `Authorization: Bearer <token>` header(推荐)
|
|
||||||
|
|
||||||
---
|
|
||||||
257
docs/apps.md
257
docs/apps.md
@ -1,257 +0,0 @@
|
|||||||
# Apps API
|
|
||||||
|
|
||||||
## 1. 获取应用权限信息 (Get Application Permissions)
|
|
||||||
|
|
||||||
- **GET** `/apps/token/:token/permissions`
|
|
||||||
|
|
||||||
通过token获取应用权限信息。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `token` (string, required): 应用访问令牌
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3000/api/apps/token/your-app-token/permissions" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"appId": 1,
|
|
||||||
"permissionPrefix": "myapp",
|
|
||||||
"specialPermissions": [],
|
|
||||||
"permissionKey": [],
|
|
||||||
"app": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "应用名称",
|
|
||||||
"description": "应用描述",
|
|
||||||
"developerName": "开发者"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 获取应用列表 (Get App List)
|
|
||||||
|
|
||||||
- **GET** `/apps`
|
|
||||||
|
|
||||||
获取应用列表,支持搜索和分页。
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
- `limit` (integer, optional, default: 20): 每页数量
|
|
||||||
- `skip` (integer, optional, default: 0): 跳过数量
|
|
||||||
- `search` (string, optional): 搜索关键词
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3000/api/apps?limit=10&skip=0&search=test" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apps": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test App",
|
|
||||||
"description": "An application for testing.",
|
|
||||||
"developerName": "Test Developer",
|
|
||||||
"permissionPrefix": "testapp",
|
|
||||||
"specialPermissions": [],
|
|
||||||
"permissionKey": [],
|
|
||||||
"version": "1.0.0",
|
|
||||||
"createdAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-27T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"skip": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 获取单个应用详情 (Get App Details)
|
|
||||||
|
|
||||||
- **GET** `/apps/:id`
|
|
||||||
|
|
||||||
获取单个应用详情。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `id` (integer, required): 应用ID
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3000/api/apps/1" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test App",
|
|
||||||
"description": "An application for testing.",
|
|
||||||
"developerName": "Test Developer",
|
|
||||||
"permissionPrefix": "testapp",
|
|
||||||
"specialPermissions": [],
|
|
||||||
"permissionKey": [],
|
|
||||||
"version": "1.0.0",
|
|
||||||
"createdAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-27T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 为设备授权或升级应用 (Install or Upgrade App for Device)
|
|
||||||
|
|
||||||
- **POST** `/apps/:id/install/:deviceUuid`
|
|
||||||
|
|
||||||
为设备授权应用。如果应用已安装,则会将其升级到最新版本。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `id` (integer, required): 应用ID
|
|
||||||
- `deviceUuid` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3000/api/apps/1/install/your-device-uuid" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "a-unique-token-for-this-installation",
|
|
||||||
"appId": 1,
|
|
||||||
"permissionPrefix": "testapp",
|
|
||||||
"permissionKey": [],
|
|
||||||
"version": "1.1.0",
|
|
||||||
"authorizedAt": "2023-10-27T11:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 卸载设备上的应用 (Uninstall App from Device)
|
|
||||||
|
|
||||||
- **DELETE** `/apps/:id/uninstall/:deviceUuid`
|
|
||||||
|
|
||||||
卸载设备上已安装的应用。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `id` (integer, required): 应用ID
|
|
||||||
- `deviceUuid` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3000/api/apps/1/uninstall/your-device-uuid" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "应用卸载成功",
|
|
||||||
"appId": 1,
|
|
||||||
"uninstalledAt": "2023-10-27T12:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 获取设备已安装的应用列表 (Get Installed Apps on Device)
|
|
||||||
|
|
||||||
- **GET** `/devices/:deviceUuid/apps`
|
|
||||||
|
|
||||||
获取指定设备上已安装的所有应用列表。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `deviceUuid` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
- `limit` (integer, optional, default: 20): 每页数量
|
|
||||||
- `skip` (integer, optional, default: 0): 跳过数量
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3000/api/devices/your-device-uuid/apps?limit=10" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"installs": [
|
|
||||||
{
|
|
||||||
"id": 1,
|
|
||||||
"appId": 1,
|
|
||||||
"token": "a-unique-token-for-this-installation",
|
|
||||||
"permissionPrefix": "testapp",
|
|
||||||
"specialPermissions": [],
|
|
||||||
"permissionKey": [],
|
|
||||||
"version": "1.0.0",
|
|
||||||
"installedAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"app": {
|
|
||||||
"id": 1,
|
|
||||||
"name": "Test App",
|
|
||||||
"description": "An application for testing.",
|
|
||||||
"developerName": "Test Developer",
|
|
||||||
"permissionPrefix": "testapp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total": 1,
|
|
||||||
"limit": 10,
|
|
||||||
"skip": 0
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 获取所有带有 permissionKey 的应用列表 (Get Apps with Permission Key)
|
|
||||||
|
|
||||||
- **GET** `/apps/with-permission-key`
|
|
||||||
|
|
||||||
获取所有设置了 `permissionKey` 的应用列表。
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3000/api/apps/with-permission-key" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"apps": [
|
|
||||||
{
|
|
||||||
"id": 2,
|
|
||||||
"name": "App With Keys",
|
|
||||||
"description": "An application that uses permission keys.",
|
|
||||||
"developerName": "Key Developer",
|
|
||||||
"permissionPrefix": "keyapp",
|
|
||||||
"specialPermissions": [],
|
|
||||||
"permissionKey": ["read:data", "write:data"],
|
|
||||||
"version": "1.0.0",
|
|
||||||
"createdAt": "2023-10-26T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-26T10:00:00.000Z"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
# 设备授权流程 - 前端接口文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
类似 Device Authorization Grant 的授权流程,允许应用通过设备代码获取用户的访问令牌。
|
|
||||||
|
|
||||||
## 前端相关接口
|
|
||||||
|
|
||||||
### 1. 绑定令牌到设备代码
|
|
||||||
|
|
||||||
将用户的访问令牌绑定到应用提供的设备代码。
|
|
||||||
|
|
||||||
**接口地址:** `POST /auth/device/bind`
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
```
|
|
||||||
Content-Type: application/json
|
|
||||||
X-Site-Key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"device_code": "1234-ABCD",
|
|
||||||
"token": "user-access-token-string"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**参数说明:**
|
|
||||||
- `device_code` (必填): 应用提供给用户的设备授权码,格式如 `1234-ABCD`
|
|
||||||
- `token` (必填): 用户在系统中已有的有效访问令牌
|
|
||||||
|
|
||||||
**成功响应:** `200 OK`
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"message": "令牌已成功绑定到设备代码"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应:**
|
|
||||||
|
|
||||||
400 Bad Request - 参数错误
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "请提供 device_code 和 token"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
400 Bad Request - 无效的令牌
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "无效的令牌"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
400 Bad Request - 设备代码不存在或已过期
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "设备代码不存在或已过期"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 查询设备代码状态(可选,用于调试)
|
|
||||||
|
|
||||||
查询设备代码的当前状态,不会删除或修改数据。
|
|
||||||
|
|
||||||
**接口地址:** `GET /auth/device/status`
|
|
||||||
|
|
||||||
**请求头:**
|
|
||||||
```
|
|
||||||
X-Site-Key: your-site-key
|
|
||||||
```
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
- `device_code` (必填): 设备授权码
|
|
||||||
|
|
||||||
**请求示例:**
|
|
||||||
```
|
|
||||||
GET /auth/device/status?device_code=1234-ABCD
|
|
||||||
```
|
|
||||||
|
|
||||||
**成功响应:** `200 OK`
|
|
||||||
|
|
||||||
设备代码存在:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"device_code": "1234-ABCD",
|
|
||||||
"exists": true,
|
|
||||||
"has_token": false,
|
|
||||||
"expires_in": 850,
|
|
||||||
"created_at": 1234567890000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
设备代码不存在或已过期:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"device_code": "1234-ABCD",
|
|
||||||
"exists": false,
|
|
||||||
"message": "设备代码不存在或已过期"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**字段说明:**
|
|
||||||
- `exists`: 设备代码是否存在且有效
|
|
||||||
- `has_token`: 是否已绑定令牌
|
|
||||||
- `expires_in`: 剩余有效时间(秒)
|
|
||||||
- `created_at`: 创建时间戳(毫秒)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 使用流程
|
|
||||||
|
|
||||||
1. **应用端**生成设备代码并展示给用户
|
|
||||||
2. **用户**在前端页面输入设备代码
|
|
||||||
3. **前端**调用 `/auth/device/bind` 接口,将用户的 token 绑定到设备代码
|
|
||||||
4. **应用端**轮询获取到令牌,完成授权
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
- 设备代码有效期为 15 分钟
|
|
||||||
- 令牌必须是系统中已存在的有效令牌
|
|
||||||
- 设备代码格式固定为 `XXXX-XXXX` (4位数字-4位字母/数字)
|
|
||||||
- 令牌获取后会从服务器内存中删除,只能获取一次
|
|
||||||
- 如果需要站点密钥,需在请求头中添加 `X-Site-Key`
|
|
||||||
224
docs/kv-token.md
224
docs/kv-token.md
@ -1,224 +0,0 @@
|
|||||||
# KV 存储 Token API
|
|
||||||
|
|
||||||
本文档描述了基于令牌的 KV 存储 API。这些 API 端点使用应用程序安装令牌进行身份验证,而不是直接使用设备 UUID。
|
|
||||||
|
|
||||||
## 身份验证
|
|
||||||
|
|
||||||
所有请求都需要提供一个有效的应用程序安装令牌。令牌可以通过以下方式之一提供:
|
|
||||||
|
|
||||||
1. **Authorization Header**:
|
|
||||||
```
|
|
||||||
Authorization: Bearer YOUR_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Query Parameter**:
|
|
||||||
```
|
|
||||||
?token=YOUR_TOKEN
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Request Body**:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "YOUR_TOKEN"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## API 端点
|
|
||||||
|
|
||||||
### 列出键名
|
|
||||||
|
|
||||||
获取命名空间下的所有键名(不包括值)。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /kv/token/_keys
|
|
||||||
```
|
|
||||||
|
|
||||||
查询参数:
|
|
||||||
- `sortBy`: 排序字段(默认:'key')
|
|
||||||
- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc')
|
|
||||||
- `limit`: 每页记录数(默认:100)
|
|
||||||
- `skip`: 跳过的记录数(默认:0)
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keys": ["key1", "key2", "key3"],
|
|
||||||
"total_rows": 3,
|
|
||||||
"current_page": {
|
|
||||||
"limit": 100,
|
|
||||||
"skip": 0,
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 列出所有键值对
|
|
||||||
|
|
||||||
获取命名空间下的所有键值对及其元数据。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /kv/token/
|
|
||||||
```
|
|
||||||
|
|
||||||
查询参数:
|
|
||||||
- `sortBy`: 排序字段(默认:'key')
|
|
||||||
- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc')
|
|
||||||
- `limit`: 每页记录数(默认:100)
|
|
||||||
- `skip`: 跳过的记录数(默认:0)
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"key": "key1",
|
|
||||||
"value": { "data": "value1" },
|
|
||||||
"createdAt": "2024-01-01T00:00:00Z",
|
|
||||||
"updatedAt": "2024-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_rows": 1
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取单个键值
|
|
||||||
|
|
||||||
获取特定键的值。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /kv/token/:key
|
|
||||||
```
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": "value1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取键的元数据
|
|
||||||
|
|
||||||
获取特定键的元数据信息。
|
|
||||||
|
|
||||||
```http
|
|
||||||
GET /kv/token/:key/metadata
|
|
||||||
```
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key": "key1",
|
|
||||||
"createdAt": "2024-01-01T00:00:00Z",
|
|
||||||
"updatedAt": "2024-01-01T00:00:00Z",
|
|
||||||
"creatorIp": "127.0.0.1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 批量导入
|
|
||||||
|
|
||||||
批量导入多个键值对。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /kv/token/_batchimport
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key1": { "data": "value1" },
|
|
||||||
"key2": { "data": "value2" }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"namespace": "device-uuid",
|
|
||||||
"total": 2,
|
|
||||||
"successful": 2,
|
|
||||||
"failed": 0,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "key1",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "key2",
|
|
||||||
"created": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 创建或更新键值
|
|
||||||
|
|
||||||
创建新的键值对或更新现有的键值对。
|
|
||||||
|
|
||||||
```http
|
|
||||||
POST /kv/token/:key
|
|
||||||
```
|
|
||||||
|
|
||||||
请求体示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"data": "value1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
响应示例:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"namespace": "device-uuid",
|
|
||||||
"key": "key1",
|
|
||||||
"created": true,
|
|
||||||
"updatedAt": "2024-01-01T00:00:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 删除命名空间
|
|
||||||
|
|
||||||
删除整个命名空间及其所有键值对。
|
|
||||||
|
|
||||||
```http
|
|
||||||
DELETE /kv/token/
|
|
||||||
```
|
|
||||||
|
|
||||||
成功时返回 204 No Content。
|
|
||||||
|
|
||||||
### 删除键值对
|
|
||||||
|
|
||||||
删除特定的键值对。
|
|
||||||
|
|
||||||
```http
|
|
||||||
DELETE /kv/token/:key
|
|
||||||
```
|
|
||||||
|
|
||||||
成功时返回 204 No Content。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
所有错误响应都遵循以下格式:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"statusCode": 400,
|
|
||||||
"message": "错误描述"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
常见错误代码:
|
|
||||||
- 400: 请求参数错误
|
|
||||||
- 401: 未提供令牌或令牌无效
|
|
||||||
- 403: 权限不足
|
|
||||||
- 404: 资源不存在
|
|
||||||
- 429: 请求过于频繁
|
|
||||||
- 500: 服务器内部错误
|
|
||||||
|
|
||||||
## 权限
|
|
||||||
|
|
||||||
API 使用以下权限系统:
|
|
||||||
- `appReadAuthMiddleware`: 用于读取操作
|
|
||||||
- `appWriteAuthMiddleware`: 用于写入操作
|
|
||||||
- `appListAuthMiddleware`: 用于列表操作
|
|
||||||
|
|
||||||
这些权限基于应用程序的安装记录中的 `permissionPrefix` 和 `permissionKey` 字段进行验证。
|
|
||||||
614
docs/kv.md
614
docs/kv.md
@ -1,614 +0,0 @@
|
|||||||
# KV Store API
|
|
||||||
|
|
||||||
## 1. 获取设备信息 (Get Device Info)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/_info`
|
|
||||||
|
|
||||||
获取指定命名空间(设备)的详细信息。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/_info" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-read-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"uuid": "your-device-uuid",
|
|
||||||
"name": "My Device",
|
|
||||||
"accessType": "PROTECTED",
|
|
||||||
"hasPassword": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 检查设备状态 (Check Device Status)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/_check`
|
|
||||||
|
|
||||||
检查设备是否存在及基本信息。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/_check" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"uuid": "your-device-uuid",
|
|
||||||
"name": "My Device",
|
|
||||||
"accessType": "PROTECTED",
|
|
||||||
"hasPassword": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 校验设备密码 (Check Device Password)
|
|
||||||
|
|
||||||
- **POST** `/:namespace/_checkpassword`
|
|
||||||
|
|
||||||
校验设备密码是否正确。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"password": "your-device-password"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3030/kv/your-device-uuid/_checkpassword" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"password": "your-device-password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "success",
|
|
||||||
"uuid": "your-device-uuid",
|
|
||||||
"name": "My Device",
|
|
||||||
"accessType": "PROTECTED",
|
|
||||||
"hasPassword": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 获取密码提示 (Get Password Hint)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/_hint`
|
|
||||||
|
|
||||||
获取设备的密码提示。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/_hint" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"passwordHint": "My favorite pet's name"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 更新密码提示 (Update Password Hint)
|
|
||||||
|
|
||||||
- **PUT** `/:namespace/_hint`
|
|
||||||
|
|
||||||
更新设备的密码提示。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"hint": "New password hint"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PUT "http://localhost:3030/kv/your-device-uuid/_hint" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"hint": "New password hint"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "密码提示已更新",
|
|
||||||
"passwordHint": "New password hint"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. 更新设备信息 (Update Device Info)
|
|
||||||
|
|
||||||
- **PUT** `/:namespace/_info`
|
|
||||||
|
|
||||||
更新设备名称或访问类型。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "New Device Name",
|
|
||||||
"accessType": "PRIVATE"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X PUT "http://localhost:3030/kv/your-device-uuid/_info" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"name": "New Device Name", "accessType": "PRIVATE"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"uuid": "your-device-uuid",
|
|
||||||
"name": "New Device Name",
|
|
||||||
"accessType": "PRIVATE",
|
|
||||||
"hasPassword": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. 移除设备密码 (Remove Device Password)
|
|
||||||
|
|
||||||
- **DELETE** `/:namespace/_password`
|
|
||||||
|
|
||||||
移除设备的密码。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"password": "current-password"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3030/kv/your-device-uuid/_password" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"password": "current-password"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"message": "密码已成功移除"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 8. 获取键名列表 (List Keys)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/_keys`
|
|
||||||
|
|
||||||
获取指定命名空间下的键名列表(不包含值)。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
- `sortBy` (string, optional, default: `key`): 排序字段
|
|
||||||
- `sortDir` (string, optional, default: `asc`): 排序方向
|
|
||||||
- `limit` (integer, optional, default: 100): 每页数量
|
|
||||||
- `skip` (integer, optional, default: 0): 跳过数量
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <read-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/_keys?limit=50&sortDir=desc" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-read-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"keys": [
|
|
||||||
"key3",
|
|
||||||
"key2",
|
|
||||||
"key1"
|
|
||||||
],
|
|
||||||
"total_rows": 3,
|
|
||||||
"current_page": {
|
|
||||||
"limit": 50,
|
|
||||||
"skip": 0,
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 9. 获取所有键值对 (List All Key-Value Pairs)
|
|
||||||
|
|
||||||
- **GET** `/:namespace`
|
|
||||||
|
|
||||||
获取指定命名空间下的所有键值对及元数据。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**查询参数:**
|
|
||||||
|
|
||||||
- `sortBy` (string, optional, default: `key`): 排序字段
|
|
||||||
- `sortDir` (string, optional, default: `asc`): 排序方向
|
|
||||||
- `limit` (integer, optional, default: 100): 每页数量
|
|
||||||
- `skip` (integer, optional, default: 0): 跳过数量
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <read-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid?limit=1" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-read-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"items": [
|
|
||||||
{
|
|
||||||
"key": "key1",
|
|
||||||
"value": {"data": "some value"},
|
|
||||||
"createdAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"creatorIp": "::1"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"total_rows": 1,
|
|
||||||
"load_more": "/api/kv/your-device-uuid?sortBy=key&sortDir=asc&limit=1&skip=1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 10. 获取单个键值 (Get Value by Key)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/:key`
|
|
||||||
|
|
||||||
通过键名获取单个键值。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
- `key` (string, required): 键名
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <read-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-read-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"some_data": "value",
|
|
||||||
"nested": {
|
|
||||||
"is_supported": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. 获取键的元数据 (Get Key Metadata)
|
|
||||||
|
|
||||||
- **GET** `/:namespace/:key/metadata`
|
|
||||||
|
|
||||||
获取单个键的元数据。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
- `key` (string, required): 键名
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <read-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/your-device-uuid/my-key/metadata" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-read-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key": "my-key",
|
|
||||||
"createdAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"updatedAt": "2023-10-27T10:00:00.000Z",
|
|
||||||
"creatorIp": "::1"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 12. 批量导入键值对 (Batch Import Key-Value Pairs)
|
|
||||||
|
|
||||||
- **POST** `/:namespace/_batchimport`
|
|
||||||
|
|
||||||
批量导入多个键值对。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key1": {"data": "value1"},
|
|
||||||
"key2": {"data": "value2"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3030/kv/your-device-uuid/_batchimport" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"key1": {"data": "value1"}, "key2": {"data": "value2"}}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"namespace": "your-device-uuid",
|
|
||||||
"total": 2,
|
|
||||||
"successful": 2,
|
|
||||||
"failed": 0,
|
|
||||||
"results": [
|
|
||||||
{
|
|
||||||
"key": "key1",
|
|
||||||
"created": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "key2",
|
|
||||||
"created": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 13. 创建或更新键值对 (Create/Update Key-Value Pair)
|
|
||||||
|
|
||||||
- **POST** `/:namespace/:key`
|
|
||||||
|
|
||||||
创建或更新单个键值对。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
- `key` (string, required): 键名
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**请求体:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"new_data": "is here"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST "http://localhost:3030/kv/your-device-uuid/my-key" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"new_data": "is here"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"namespace": "your-device-uuid",
|
|
||||||
"key": "my-key",
|
|
||||||
"created": false,
|
|
||||||
"updatedAt": "2023-10-27T11:00:00.000Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 14. 删除命名空间 (Delete Namespace)
|
|
||||||
|
|
||||||
- **DELETE** `/:namespace`
|
|
||||||
|
|
||||||
删除整个命名空间及其所有数据。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3030/kv/your-device-uuid" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应 (204 No Content):**
|
|
||||||
|
|
||||||
无响应体。
|
|
||||||
|
|
||||||
## 15. 删除键 (Delete Key)
|
|
||||||
|
|
||||||
- **DELETE** `/:namespace/:key`
|
|
||||||
|
|
||||||
删除单个键值对。
|
|
||||||
|
|
||||||
**路径参数:**
|
|
||||||
|
|
||||||
- `namespace` (string, required): 设备UUID
|
|
||||||
- `key` (string, required): 键名
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
- `Authorization` (string, required): `Bearer <write-token>`
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X DELETE "http://localhost:3030/kv/your-device-uuid/my-key" \
|
|
||||||
-H "X-Site-Key: your-site-key" \
|
|
||||||
-H "Authorization: Bearer your-write-token"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应 (204 No Content):**
|
|
||||||
|
|
||||||
无响应体。
|
|
||||||
|
|
||||||
## 16. 生成 UUID (Generate UUID)
|
|
||||||
|
|
||||||
- **GET** `/uuid`
|
|
||||||
|
|
||||||
生成一个新的 UUID,可用作命名空间。
|
|
||||||
|
|
||||||
**Headers:**
|
|
||||||
|
|
||||||
- `X-Site-Key` (string, required): 站点密钥
|
|
||||||
|
|
||||||
**Curl 示例:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:3030/kv/uuid" \
|
|
||||||
-H "X-Site-Key: your-site-key"
|
|
||||||
```
|
|
||||||
|
|
||||||
**响应示例 (200 OK):**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"namespace": "a-newly-generated-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,479 +0,0 @@
|
|||||||
# 中间件系统文档
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本项目使用中间件系统来处理设备信息获取、权限验证和Token认证。所有与UUID相关的操作都通过统一的中间件处理。
|
|
||||||
|
|
||||||
## 中间件架构
|
|
||||||
|
|
||||||
### 1. 设备信息中间件 (`deviceMiddleware`)
|
|
||||||
|
|
||||||
**文件位置**: `middleware/device.js`
|
|
||||||
|
|
||||||
**功能**: 统一处理设备UUID,自动获取或创建设备
|
|
||||||
|
|
||||||
**使用场景**:
|
|
||||||
- 所有需要设备信息的接口
|
|
||||||
- 不需要密码验证的读操作
|
|
||||||
- 需要在后续中间件中访问设备信息的场景
|
|
||||||
|
|
||||||
**工作流程**:
|
|
||||||
1. 从 `req.params.deviceUuid`、`req.params.namespace` 或 `req.body.deviceUuid` 获取UUID
|
|
||||||
2. 在数据库中查找设备
|
|
||||||
3. 如果设备不存在,自动创建新设备
|
|
||||||
4. 将设备信息存储到 `res.locals.device`
|
|
||||||
|
|
||||||
**代码示例**:
|
|
||||||
```javascript
|
|
||||||
import { deviceMiddleware } from './middleware/device.js';
|
|
||||||
|
|
||||||
// 基本用法
|
|
||||||
router.get('/device/:deviceUuid/info', deviceMiddleware, (req, res) => {
|
|
||||||
// 设备信息可从 res.locals.device 访问
|
|
||||||
res.json(res.locals.device);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 从body获取UUID
|
|
||||||
router.post('/device/create', deviceMiddleware, (req, res) => {
|
|
||||||
// req.body.deviceUuid 会被自动处理
|
|
||||||
res.json({ message: '设备已创建', device: res.locals.device });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**数据访问**:
|
|
||||||
```javascript
|
|
||||||
const device = res.locals.device;
|
|
||||||
// device: {
|
|
||||||
// id: 1,
|
|
||||||
// uuid: 'device-uuid-123',
|
|
||||||
// name: 'My Device',
|
|
||||||
// password: 'hashed-password',
|
|
||||||
// passwordHint: '提示信息',
|
|
||||||
// accountId: null,
|
|
||||||
// createdAt: Date,
|
|
||||||
// updatedAt: Date
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. 写权限验证中间件 (`requireWriteAuth`)
|
|
||||||
|
|
||||||
**文件位置**: `middleware/tokenAuth.js`
|
|
||||||
|
|
||||||
**功能**: 验证设备密码,控制写权限
|
|
||||||
|
|
||||||
**依赖**: 必须在 `deviceMiddleware` 之后使用
|
|
||||||
|
|
||||||
**使用场景**:
|
|
||||||
- 所有需要修改数据的操作(POST、PUT、DELETE)
|
|
||||||
- 需要验证设备密码的操作
|
|
||||||
|
|
||||||
**工作流程**:
|
|
||||||
1. 从 `res.locals.device` 获取设备信息
|
|
||||||
2. 如果设备没有设置密码,直接允许操作
|
|
||||||
3. 如果设备设置了密码:
|
|
||||||
- 从 `req.body.password` 或 `req.query.password` 获取密码
|
|
||||||
- 验证密码是否正确
|
|
||||||
- 密码正确:继续执行
|
|
||||||
- 密码错误或未提供:返回 401 错误
|
|
||||||
|
|
||||||
**代码示例**:
|
|
||||||
```javascript
|
|
||||||
import { deviceMiddleware } from './middleware/device.js';
|
|
||||||
import { requireWriteAuth } from './middleware/tokenAuth.js';
|
|
||||||
|
|
||||||
// 写操作需要密码验证
|
|
||||||
router.post('/device/:deviceUuid/data',
|
|
||||||
deviceMiddleware, // 第一步:获取设备信息
|
|
||||||
requireWriteAuth, // 第二步:验证写权限
|
|
||||||
(req, res) => {
|
|
||||||
// 验证通过,执行写操作
|
|
||||||
res.json({ message: '数据已更新' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// 读操作不需要密码
|
|
||||||
router.get('/device/:deviceUuid/data',
|
|
||||||
deviceMiddleware, // 只需要设备信息
|
|
||||||
(req, res) => {
|
|
||||||
res.json({ data: 'some data' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**密码提供方式**:
|
|
||||||
```javascript
|
|
||||||
// 方式1: 通过请求体
|
|
||||||
fetch('/device/uuid-123/data', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
password: 'device-password',
|
|
||||||
data: 'new value'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
// 方式2: 通过查询参数
|
|
||||||
fetch('/device/uuid-123/data?password=device-password', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ data: 'new value' })
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**错误响应**:
|
|
||||||
```json
|
|
||||||
// 需要密码但未提供
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "此操作需要密码",
|
|
||||||
"passwordHint": "提示信息"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 密码错误
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "密码错误"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Token认证中间件 (`tokenAuth`)
|
|
||||||
|
|
||||||
**文件位置**: `middleware/tokenAuth.js`
|
|
||||||
|
|
||||||
**功能**: 基于应用安装Token进行认证
|
|
||||||
|
|
||||||
**使用场景**:
|
|
||||||
- 应用访问KV数据
|
|
||||||
- 需要应用级别认证的接口
|
|
||||||
- 不依赖设备UUID的操作
|
|
||||||
|
|
||||||
**工作流程**:
|
|
||||||
1. 从 Header、Query 或 Body 中获取 token
|
|
||||||
2. 在数据库中查找对应的应用安装记录
|
|
||||||
3. 验证 token 是否有效
|
|
||||||
4. 将应用、设备信息存储到 `res.locals`
|
|
||||||
|
|
||||||
**Token提供方式**:
|
|
||||||
1. **Authorization Header** (推荐):
|
|
||||||
```javascript
|
|
||||||
headers: {
|
|
||||||
'Authorization': 'Bearer <token>'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Query参数**:
|
|
||||||
```javascript
|
|
||||||
?token=<token>
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Request Body**:
|
|
||||||
```javascript
|
|
||||||
{
|
|
||||||
"token": "<token>",
|
|
||||||
"data": "..."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**代码示例**:
|
|
||||||
```javascript
|
|
||||||
import { tokenAuth } from './middleware/tokenAuth.js';
|
|
||||||
|
|
||||||
// Token认证的接口
|
|
||||||
router.get('/kv/:key', tokenAuth, (req, res) => {
|
|
||||||
// 可访问:
|
|
||||||
// - res.locals.appInstall (应用安装记录)
|
|
||||||
// - res.locals.app (应用信息)
|
|
||||||
// - res.locals.device (设备信息)
|
|
||||||
// - res.locals.deviceId (设备ID)
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
key: req.params.key,
|
|
||||||
device: res.locals.device.uuid,
|
|
||||||
app: res.locals.app.name
|
|
||||||
});
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**数据访问**:
|
|
||||||
```javascript
|
|
||||||
const appInstall = res.locals.appInstall;
|
|
||||||
// appInstall: {
|
|
||||||
// id: 'cuid',
|
|
||||||
// deviceId: 1,
|
|
||||||
// appId: 1,
|
|
||||||
// token: 'unique-token',
|
|
||||||
// note: '备注',
|
|
||||||
// installedAt: Date,
|
|
||||||
// updatedAt: Date,
|
|
||||||
// app: { ... },
|
|
||||||
// device: { ... }
|
|
||||||
// }
|
|
||||||
|
|
||||||
const app = res.locals.app;
|
|
||||||
// app: { id, name, description, developerName, ... }
|
|
||||||
|
|
||||||
const device = res.locals.device;
|
|
||||||
// device: { id, uuid, name, password, ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 中间件组合使用
|
|
||||||
|
|
||||||
### 场景1: 基于UUID的读操作(无需密码)
|
|
||||||
```javascript
|
|
||||||
router.get('/device/:deviceUuid/data',
|
|
||||||
deviceMiddleware,
|
|
||||||
(req, res) => {
|
|
||||||
const device = res.locals.device;
|
|
||||||
res.json({ device, data: '...' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 场景2: 基于UUID的写操作(需要密码)
|
|
||||||
```javascript
|
|
||||||
router.post('/device/:deviceUuid/data',
|
|
||||||
deviceMiddleware, // 获取设备信息
|
|
||||||
requireWriteAuth, // 验证密码
|
|
||||||
(req, res) => {
|
|
||||||
// 执行写操作
|
|
||||||
res.json({ message: '成功' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 场景3: 基于Token的操作
|
|
||||||
```javascript
|
|
||||||
router.get('/kv/:key',
|
|
||||||
tokenAuth, // Token认证,自动获取设备信息
|
|
||||||
(req, res) => {
|
|
||||||
const device = res.locals.device;
|
|
||||||
const app = res.locals.app;
|
|
||||||
res.json({ device, app, data: '...' });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 场景4: 批量路由保护
|
|
||||||
```javascript
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 所有该路由下的接口都需要设备信息
|
|
||||||
router.use(deviceMiddleware);
|
|
||||||
|
|
||||||
// 具体接口
|
|
||||||
router.get('/info', (req, res) => {
|
|
||||||
res.json(res.locals.device);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/update', requireWriteAuth, (req, res) => {
|
|
||||||
res.json({ message: '更新成功' });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 最佳实践
|
|
||||||
|
|
||||||
### 1. 中间件顺序很重要
|
|
||||||
```javascript
|
|
||||||
// ✅ 正确:先获取设备信息,再验证权限
|
|
||||||
router.post('/data', deviceMiddleware, requireWriteAuth, handler);
|
|
||||||
|
|
||||||
// ❌ 错误:requireWriteAuth 依赖 deviceMiddleware
|
|
||||||
router.post('/data', requireWriteAuth, deviceMiddleware, handler);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 选择合适的认证方式
|
|
||||||
```javascript
|
|
||||||
// 用户直接操作设备 → 使用 deviceMiddleware + requireWriteAuth
|
|
||||||
router.post('/device/:deviceUuid/config', deviceMiddleware, requireWriteAuth, handler);
|
|
||||||
|
|
||||||
// 应用代表用户操作 → 使用 tokenAuth
|
|
||||||
router.post('/kv/:key', tokenAuth, handler);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 读操作不需要密码
|
|
||||||
```javascript
|
|
||||||
// ✅ 读操作只需要设备信息
|
|
||||||
router.get('/device/:deviceUuid/data', deviceMiddleware, handler);
|
|
||||||
|
|
||||||
// ❌ 读操作不需要密码验证
|
|
||||||
router.get('/device/:deviceUuid/data', deviceMiddleware, requireWriteAuth, handler);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 错误处理
|
|
||||||
```javascript
|
|
||||||
router.post('/data', deviceMiddleware, requireWriteAuth,
|
|
||||||
async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
// 业务逻辑
|
|
||||||
const device = res.locals.device;
|
|
||||||
// ...
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
next(error); // 传递给全局错误处理器
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 密码提示信息
|
|
||||||
```javascript
|
|
||||||
// 设置设备时提供密码提示
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { uuid: deviceUuid },
|
|
||||||
data: {
|
|
||||||
password: hashedPassword,
|
|
||||||
passwordHint: '您的生日(8位数字)' // 提供友好的提示
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q1: 为什么设备不存在时会自动创建?
|
|
||||||
**A**: 这是为了简化客户端逻辑。客户端只需要生成UUID并使用,无需先调用创建接口。首次访问时会自动创建设备记录。
|
|
||||||
|
|
||||||
### Q2: 读操作为什么不需要密码?
|
|
||||||
**A**: 根据项目需求,只有写操作需要密码保护。读操作允许任何知道UUID的人访问。如果需要保护读操作,可以在路由中添加 `requireWriteAuth` 中间件。
|
|
||||||
|
|
||||||
### Q3: deviceMiddleware 和 tokenAuth 有什么区别?
|
|
||||||
**A**:
|
|
||||||
- `deviceMiddleware`: 基于UUID获取设备信息,适合用户直接操作
|
|
||||||
- `tokenAuth`: 基于应用Token认证,适合应用代表用户操作,包含应用级别的权限控制
|
|
||||||
|
|
||||||
### Q4: 如何撤销某个设备的访问权限?
|
|
||||||
**A**:
|
|
||||||
1. 基于UUID的访问:修改设备密码
|
|
||||||
2. 基于Token的访问:删除对应的 `AppInstall` 记录
|
|
||||||
|
|
||||||
### Q5: 密码错误但操作不需要密码是否可以继续?
|
|
||||||
**A**: 不可以。`requireWriteAuth` 中间件会检查:
|
|
||||||
- 如果设备没有密码 → 直接通过
|
|
||||||
- 如果设备有密码但未提供 → 拒绝
|
|
||||||
- 如果设备有密码但错误 → 拒绝
|
|
||||||
|
|
||||||
如果操作不需要密码,不要使用 `requireWriteAuth` 中间件。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 迁移指南
|
|
||||||
|
|
||||||
### 从旧的认证系统迁移
|
|
||||||
|
|
||||||
**旧代码**:
|
|
||||||
```javascript
|
|
||||||
router.post('/kv/:namespace/:key', authMiddleware, handler);
|
|
||||||
```
|
|
||||||
|
|
||||||
**新代码**:
|
|
||||||
```javascript
|
|
||||||
// 选项1: 使用 deviceMiddleware (如果通过URL传递UUID)
|
|
||||||
router.post('/device/:deviceUuid/kv/:key',
|
|
||||||
deviceMiddleware,
|
|
||||||
requireWriteAuth,
|
|
||||||
handler
|
|
||||||
);
|
|
||||||
|
|
||||||
// 选项2: 使用 tokenAuth (推荐,更安全)
|
|
||||||
router.post('/kv/:key', tokenAuth, handler);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 客户端更新
|
|
||||||
|
|
||||||
**旧方式**:
|
|
||||||
```javascript
|
|
||||||
// UUID + 密码
|
|
||||||
fetch('/kv/device-uuid/mykey', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-namespace-password': 'password'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ data: 'value' })
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**新方式(选项1 - UUID)**:
|
|
||||||
```javascript
|
|
||||||
fetch('/device/device-uuid/kv/mykey', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
password: 'password',
|
|
||||||
data: 'value'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**新方式(选项2 - Token,推荐)**:
|
|
||||||
```javascript
|
|
||||||
// 先获取token
|
|
||||||
const authResponse = await fetch('/apps/1/authorize', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
deviceUuid: 'device-uuid',
|
|
||||||
password: 'password'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
const { token } = await authResponse.json();
|
|
||||||
|
|
||||||
// 使用token操作
|
|
||||||
fetch('/kv/mykey', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ data: 'value' })
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 技术细节
|
|
||||||
|
|
||||||
### 密码存储
|
|
||||||
密码使用 `bcrypt` 进行哈希处理,存储在 `Device.password` 字段。
|
|
||||||
|
|
||||||
**加密函数** (`utils/crypto.js`):
|
|
||||||
```javascript
|
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
|
|
||||||
export async function hashDevicePassword(password) {
|
|
||||||
return await bcrypt.hash(password, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyDevicePassword(password, hash) {
|
|
||||||
return await bcrypt.compare(password, hash);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 性能优化
|
|
||||||
- 使用整数ID (`deviceId`) 作为外键,查询效率高于字符串UUID
|
|
||||||
- 设备信息查询结果缓存在 `res.locals`,避免重复查询
|
|
||||||
- 密码验证使用 bcrypt 的异步方法,不阻塞事件循环
|
|
||||||
|
|
||||||
### 安全考虑
|
|
||||||
1. 密码使用 bcrypt 加密存储
|
|
||||||
2. Token 使用 `cuid` 生成,具有高随机性
|
|
||||||
3. 支持密码提示功能,不暴露实际密码
|
|
||||||
4. 写操作强制密码验证(如果设置了密码)
|
|
||||||
5. 所有中间件使用 `errors.catchAsync` 包装,统一错误处理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 参考
|
|
||||||
|
|
||||||
- [API重构文档](./API_REFACTOR.md)
|
|
||||||
- [Token认证示例](./token-auth-examples.md)
|
|
||||||
- [KV存储文档](./kv.md)
|
|
||||||
- [应用管理文档](./apps.md)
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
# Token认证系统使用示例
|
|
||||||
|
|
||||||
本文档展示了如何使用重构后的基于Token的认证系统。
|
|
||||||
|
|
||||||
## 1. 基本Token认证
|
|
||||||
|
|
||||||
### 路由配置示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
import express from 'express';
|
|
||||||
import {
|
|
||||||
tokenOnlyAuthMiddleware,
|
|
||||||
tokenOnlyReadAuthMiddleware,
|
|
||||||
tokenOnlyWriteAuthMiddleware
|
|
||||||
} from './middleware/auth.js';
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
// 需要完整认证的接口
|
|
||||||
router.use('/secure', tokenOnlyAuthMiddleware);
|
|
||||||
router.get('/secure/profile', (req, res) => {
|
|
||||||
// res.locals.device, res.locals.appInstall, res.locals.app 已可用
|
|
||||||
res.json({
|
|
||||||
device: res.locals.device,
|
|
||||||
app: res.locals.app
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 只读接口
|
|
||||||
router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => {
|
|
||||||
// 处理读取逻辑
|
|
||||||
res.json({ key: req.params.key, value: 'some-value' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// 写入接口
|
|
||||||
router.post('/data/:key', tokenOnlyWriteAuthMiddleware, (req, res) => {
|
|
||||||
// 处理写入逻辑
|
|
||||||
res.json({ success: true, key: req.params.key });
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. 客户端请求示例
|
|
||||||
|
|
||||||
### 通过HTTP Header传递Token
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 使用fetch
|
|
||||||
fetch('/api/secure/profile', {
|
|
||||||
headers: {
|
|
||||||
'x-app-token': 'your-app-token-here',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => console.log(data));
|
|
||||||
|
|
||||||
// 使用axios
|
|
||||||
axios.get('/api/secure/profile', {
|
|
||||||
headers: {
|
|
||||||
'x-app-token': 'your-app-token-here'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通过查询参数传递Token
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// GET请求
|
|
||||||
fetch('/api/data/mykey?apptoken=your-app-token-here')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => console.log(data));
|
|
||||||
```
|
|
||||||
|
|
||||||
### 通过请求体传递Token
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// POST请求
|
|
||||||
fetch('/api/data/mykey', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
apptoken: 'your-app-token-here',
|
|
||||||
value: 'new-value'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 错误处理
|
|
||||||
|
|
||||||
### 常见错误响应
|
|
||||||
|
|
||||||
```json
|
|
||||||
// 缺少Token
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "缺少应用访问令牌,请提供有效的token"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 无效Token
|
|
||||||
{
|
|
||||||
"statusCode": 401,
|
|
||||||
"message": "无效的应用访问令牌"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限不足
|
|
||||||
{
|
|
||||||
"statusCode": 403,
|
|
||||||
"message": "应用令牌无权访问此命名空间"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 客户端错误处理示例
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
async function apiRequest(url, options = {}) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
...options,
|
|
||||||
headers: {
|
|
||||||
'x-app-token': 'your-app-token-here',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...options.headers
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(`API错误 ${error.statusCode}: ${error.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('API请求失败:', error.message);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用示例
|
|
||||||
try {
|
|
||||||
const data = await apiRequest('/api/secure/profile');
|
|
||||||
console.log('用户数据:', data);
|
|
||||||
} catch (error) {
|
|
||||||
// 处理认证错误
|
|
||||||
if (error.message.includes('401')) {
|
|
||||||
// 重新获取token或跳转到登录页
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 迁移指南
|
|
||||||
|
|
||||||
### 从UUID认证迁移到Token认证
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 旧的UUID认证方式(已弃用)
|
|
||||||
router.get('/data/:namespace/:key', authMiddleware, (req, res) => {
|
|
||||||
// 使用req.params.namespace作为设备标识
|
|
||||||
});
|
|
||||||
|
|
||||||
// 新的Token认证方式(推荐)
|
|
||||||
router.get('/data/:key', tokenOnlyReadAuthMiddleware, (req, res) => {
|
|
||||||
// 设备信息通过token自动获取,存储在res.locals.device中
|
|
||||||
const deviceUuid = res.locals.device.uuid;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### 客户端迁移
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 旧方式:使用UUID和密码
|
|
||||||
fetch('/api/data/device-uuid-123/mykey', {
|
|
||||||
headers: {
|
|
||||||
'x-namespace-password': 'device-password'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 新方式:使用Token
|
|
||||||
fetch('/api/data/mykey', {
|
|
||||||
headers: {
|
|
||||||
'x-app-token': 'app-token-from-installation'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. 最佳实践
|
|
||||||
|
|
||||||
1. **优先使用Token认证**:新项目应该直接使用`tokenOnlyAuthMiddleware`等纯Token认证中间件
|
|
||||||
|
|
||||||
2. **安全存储Token**:在客户端安全存储应用Token,避免在URL中暴露
|
|
||||||
|
|
||||||
3. **错误处理**:实现完善的错误处理机制,特别是认证失败的情况
|
|
||||||
|
|
||||||
4. **Token刷新**:实现Token过期和刷新机制(如果需要)
|
|
||||||
|
|
||||||
5. **日志记录**:记录认证相关的操作日志,便于调试和安全审计
|
|
||||||
|
|
||||||
## 6. 权限前缀系统
|
|
||||||
|
|
||||||
Token认证系统支持基于前缀的权限控制:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// 应用只能访问以特定前缀开头的键
|
|
||||||
// 例如:app.permissionPrefix = "myapp"
|
|
||||||
// 则只能访问 "myapp.config", "myapp.data" 等键
|
|
||||||
|
|
||||||
// 使用appReadAuthMiddleware自动进行前缀检查
|
|
||||||
router.get('/kv/:key', appReadAuthMiddleware, (req, res) => {
|
|
||||||
// 自动检查req.params.key是否符合权限前缀
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
这个系统提供了更安全、更灵活的认证机制,建议所有新项目都采用Token认证方式。
|
|
||||||
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>KV 服务授权管理</title>
|
<title>Classworks KV</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"lucide-vue-next": "^0.544.0",
|
"lucide-vue-next": "^0.544.0",
|
||||||
"marked": "^16.3.0",
|
"marked": "^16.3.0",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"reka-ui": "^2.5.1",
|
"reka-ui": "^2.5.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
17
kv-admin/pnpm-lock.yaml
generated
17
kv-admin/pnpm-lock.yaml
generated
@ -41,6 +41,9 @@ importers:
|
|||||||
marked:
|
marked:
|
||||||
specifier: ^16.3.0
|
specifier: ^16.3.0
|
||||||
version: 16.3.0
|
version: 16.3.0
|
||||||
|
pinia:
|
||||||
|
specifier: ^3.0.3
|
||||||
|
version: 3.0.3(vue@3.5.22)
|
||||||
radix-vue:
|
radix-vue:
|
||||||
specifier: ^1.9.17
|
specifier: ^1.9.17
|
||||||
version: 1.9.17(vue@3.5.22)
|
version: 1.9.17(vue@3.5.22)
|
||||||
@ -1573,6 +1576,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
pinia@3.0.3:
|
||||||
|
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
|
||||||
|
peerDependencies:
|
||||||
|
typescript: '>=4.4.4'
|
||||||
|
vue: ^2.7.0 || ^3.5.11
|
||||||
|
peerDependenciesMeta:
|
||||||
|
typescript:
|
||||||
|
optional: true
|
||||||
|
|
||||||
pkg-types@1.3.1:
|
pkg-types@1.3.1:
|
||||||
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
|
||||||
|
|
||||||
@ -3460,6 +3472,11 @@ snapshots:
|
|||||||
|
|
||||||
picomatch@4.0.3: {}
|
picomatch@4.0.3: {}
|
||||||
|
|
||||||
|
pinia@3.0.3(vue@3.5.22):
|
||||||
|
dependencies:
|
||||||
|
'@vue/devtools-api': 7.7.7
|
||||||
|
vue: 3.5.22
|
||||||
|
|
||||||
pkg-types@1.3.1:
|
pkg-types@1.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
confbox: 0.1.8
|
confbox: 0.1.8
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterView } from 'vue-router'
|
import { RouterView } from 'vue-router'
|
||||||
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
import 'vue-sonner/style.css'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<Toaster class="pointer-events-auto" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -61,7 +61,7 @@ const renderedReadme = computed(() => {
|
|||||||
// 获取应用信息
|
// 获取应用信息
|
||||||
const fetchApp = async () => {
|
const fetchApp = async () => {
|
||||||
try {
|
try {
|
||||||
app.value = await axios.get(`/apps/${props.appId}`);
|
app.value = await axios.get(`/apps/info/${props.appId}`);
|
||||||
|
|
||||||
if (app.value.repositoryUrl) {
|
if (app.value.repositoryUrl) {
|
||||||
await fetchReadme();
|
await fetchReadme();
|
||||||
|
|||||||
400
kv-admin/src/components/DeviceRegisterDialog.vue
Normal file
400
kv-admin/src/components/DeviceRegisterDialog.vue
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
<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>
|
||||||
136
kv-admin/src/components/EditDeviceNameDialog.vue
Normal file
136
kv-admin/src/components/EditDeviceNameDialog.vue
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
<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>
|
||||||
220
kv-admin/src/components/LoginDialog.vue
Normal file
220
kv-admin/src/components/LoginDialog.vue
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
<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
|
||||||
|
|
||||||
|
// 检查localStorage中是否有token(降级方案)
|
||||||
|
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>
|
||||||
262
kv-admin/src/components/PasswordInput.vue
Normal file
262
kv-admin/src/components/PasswordInput.vue
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
<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>
|
||||||
274
kv-admin/src/components/ResetDevicePasswordDialog.vue
Normal file
274
kv-admin/src/components/ResetDevicePasswordDialog.vue
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
<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>
|
||||||
17
kv-admin/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
17
kv-admin/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
<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>
|
||||||
23
kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
23
kv-admin/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<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>
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
<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>
|
||||||
9
kv-admin/src/components/ui/alert-dialog/index.js
Normal file
9
kv-admin/src/components/ui/alert-dialog/index.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
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";
|
||||||
46
kv-admin/src/components/ui/checkbox/Checkbox.vue
Normal file
46
kv-admin/src/components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<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>
|
||||||
1
kv-admin/src/components/ui/checkbox/index.js
Normal file
1
kv-admin/src/components/ui/checkbox/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Checkbox } from "./Checkbox.vue";
|
||||||
22
kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue
Normal file
22
kv-admin/src/components/ui/dropdown-menu/DropdownItem.vue
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<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>
|
||||||
62
kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
62
kv-admin/src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<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>
|
||||||
28
kv-admin/src/components/ui/separator/Separator.vue
Normal file
28
kv-admin/src/components/ui/separator/Separator.vue
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
1
kv-admin/src/components/ui/separator/index.js
Normal file
1
kv-admin/src/components/ui/separator/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Separator } from "./Separator.vue";
|
||||||
39
kv-admin/src/components/ui/sonner/Sonner.vue
Normal file
39
kv-admin/src/components/ui/sonner/Sonner.vue
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<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>
|
||||||
1
kv-admin/src/components/ui/sonner/index.js
Normal file
1
kv-admin/src/components/ui/sonner/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Toaster } from "./Sonner.vue";
|
||||||
31
kv-admin/src/components/ui/tabs/Tabs.vue
Normal file
31
kv-admin/src/components/ui/tabs/Tabs.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<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>
|
||||||
25
kv-admin/src/components/ui/tabs/TabsContent.vue
Normal file
25
kv-admin/src/components/ui/tabs/TabsContent.vue
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<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>
|
||||||
29
kv-admin/src/components/ui/tabs/TabsList.vue
Normal file
29
kv-admin/src/components/ui/tabs/TabsList.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<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>
|
||||||
32
kv-admin/src/components/ui/tabs/TabsTrigger.vue
Normal file
32
kv-admin/src/components/ui/tabs/TabsTrigger.vue
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<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>
|
||||||
4
kv-admin/src/components/ui/tabs/index.js
Normal file
4
kv-admin/src/components/ui/tabs/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
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";
|
||||||
126
kv-admin/src/composables/useOAuthCallback.js
Normal file
126
kv-admin/src/composables/useOAuthCallback.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -31,6 +31,23 @@ class ApiClient {
|
|||||||
return response.json()
|
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
|
// 应用相关 API
|
||||||
async getApps(params = {}) {
|
async getApps(params = {}) {
|
||||||
const query = new URLSearchParams(params).toString()
|
const query = new URLSearchParams(params).toString()
|
||||||
@ -38,53 +55,177 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getApp(appId) {
|
async getApp(appId) {
|
||||||
return this.fetch(`/apps/${appId}`)
|
return this.fetch(`/apps/info/${appId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAppInstallations(appId, params = {}) {
|
async getAppInstallations(appId, deviceUuid, params = {}) {
|
||||||
const query = new URLSearchParams(params).toString()
|
const query = new URLSearchParams(params).toString()
|
||||||
return this.fetch(`/apps/${appId}/installations${query ? `?${query}` : ''}`)
|
return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, {
|
||||||
}
|
headers: {
|
||||||
|
'x-device-uuid': deviceUuid,
|
||||||
// 授权相关 API
|
},
|
||||||
async authorizeApp(appId, data) {
|
|
||||||
return this.fetch(`/apps/${appId}/authorize`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token 管理 API
|
// Token 管理 API
|
||||||
async getDeviceTokens(deviceUuid) {
|
async getDeviceTokens(deviceUuid, options = {}) {
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/tokens`)
|
const params = new URLSearchParams({
|
||||||
|
uuid: deviceUuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.fetch(`/apps/tokens?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeToken(token) {
|
async revokeToken(targetToken, authOptions = {}) {
|
||||||
return this.fetch(`/apps/tokens/${token}`, {
|
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',
|
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
|
// 设备密码管理 API
|
||||||
async setDevicePassword(deviceUuid, data) {
|
async setDevicePassword(deviceUuid, data, token = null) {
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/password`, {
|
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',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
headers,
|
||||||
})
|
});
|
||||||
|
} else {
|
||||||
|
// 使用POST初次设置密码
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('newPassword', newPassword);
|
||||||
|
if (passwordHint !== undefined) {
|
||||||
|
params.set('passwordHint', passwordHint);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDevicePassword(deviceUuid, password) {
|
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/password`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: JSON.stringify({ password }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyDevicePassword(deviceUuid, password) {
|
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/password/verify`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ password }),
|
});
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 设备授权相关 API
|
||||||
@ -98,6 +239,295 @@ class ApiClient {
|
|||||||
async getDeviceCodeStatus(deviceCode) {
|
async getDeviceCodeStatus(deviceCode) {
|
||||||
return this.fetch(`/auth/device/status?device_code=${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)
|
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)
|
||||||
|
|||||||
@ -7,16 +7,64 @@ export function generateUUID() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备 UUID 管理
|
// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
|
||||||
export const deviceStore = {
|
export const deviceStore = {
|
||||||
// 获取当前设备 UUID
|
// 存储键名
|
||||||
|
STORAGE_KEY: 'device_uuid',
|
||||||
|
BACKUP_KEY: 'device_uuid_backup',
|
||||||
|
SESSION_KEY: 'device_uuid_session',
|
||||||
|
|
||||||
|
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
||||||
getDeviceUuid() {
|
getDeviceUuid() {
|
||||||
return localStorage.getItem('device_uuid')
|
// 1. 首先从 localStorage 获取
|
||||||
|
let uuid = localStorage.getItem(this.STORAGE_KEY)
|
||||||
|
|
||||||
|
// 2. 如果没有,尝试从备份位置获取
|
||||||
|
if (!uuid) {
|
||||||
|
uuid = localStorage.getItem(this.BACKUP_KEY)
|
||||||
|
if (uuid) {
|
||||||
|
// 恢复到主存储位置
|
||||||
|
this.setDeviceUuid(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 如果还没有,尝试从 sessionStorage 获取
|
||||||
|
if (!uuid) {
|
||||||
|
uuid = sessionStorage.getItem(this.SESSION_KEY)
|
||||||
|
if (uuid) {
|
||||||
|
// 恢复到主存储位置
|
||||||
|
this.setDeviceUuid(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 如果还没有,尝试从 cookie 获取(如果有的话)
|
||||||
|
if (!uuid) {
|
||||||
|
uuid = this.getFromCookie()
|
||||||
|
if (uuid) {
|
||||||
|
// 恢复到所有存储位置
|
||||||
|
this.setDeviceUuid(uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uuid
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置设备 UUID
|
// 设置设备 UUID(同时存储到多个位置)
|
||||||
setDeviceUuid(uuid) {
|
setDeviceUuid(uuid) {
|
||||||
localStorage.setItem('device_uuid', uuid)
|
// 1. 存储到 localStorage 主位置
|
||||||
|
localStorage.setItem(this.STORAGE_KEY, uuid)
|
||||||
|
|
||||||
|
// 2. 存储到备份位置
|
||||||
|
localStorage.setItem(this.BACKUP_KEY, uuid)
|
||||||
|
|
||||||
|
// 3. 存储到 sessionStorage
|
||||||
|
sessionStorage.setItem(this.SESSION_KEY, uuid)
|
||||||
|
|
||||||
|
// 4. 存储到 cookie(设置较长的过期时间)
|
||||||
|
this.saveToCookie(uuid)
|
||||||
|
|
||||||
|
// 5. 尝试存储到 IndexedDB(异步)
|
||||||
|
this.saveToIndexedDB(uuid)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 生成并保存新的设备 UUID
|
// 生成并保存新的设备 UUID
|
||||||
@ -31,26 +79,113 @@ export const deviceStore = {
|
|||||||
let uuid = this.getDeviceUuid()
|
let uuid = this.getDeviceUuid()
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
uuid = this.generateAndSave()
|
uuid = this.generateAndSave()
|
||||||
|
} else {
|
||||||
|
// 确保UUID被保存到所有位置
|
||||||
|
this.setDeviceUuid(uuid)
|
||||||
}
|
}
|
||||||
return uuid
|
return uuid
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清除设备 UUID
|
// 清除设备 UUID(从所有存储位置清除)
|
||||||
clear() {
|
clear() {
|
||||||
localStorage.removeItem('device_uuid')
|
localStorage.removeItem(this.STORAGE_KEY)
|
||||||
localStorage.removeItem('device_password')
|
localStorage.removeItem(this.BACKUP_KEY)
|
||||||
|
sessionStorage.removeItem(this.SESSION_KEY)
|
||||||
|
this.clearCookie()
|
||||||
|
this.clearIndexedDB()
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设备密码管理
|
// Cookie 操作
|
||||||
hasPassword() {
|
saveToCookie(uuid) {
|
||||||
return localStorage.getItem('device_password') === 'true'
|
try {
|
||||||
|
const expires = new Date()
|
||||||
|
expires.setFullYear(expires.getFullYear() + 10) // 10年过期
|
||||||
|
document.cookie = `device_uuid=${uuid}; expires=${expires.toUTCString()}; path=/; SameSite=Strict`
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to save UUID to cookie:', e)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
setHasPassword(hasPassword) {
|
getFromCookie() {
|
||||||
if (hasPassword) {
|
try {
|
||||||
localStorage.setItem('device_password', 'true')
|
const match = document.cookie.match(/(?:^|; )device_uuid=([^;]*)/)
|
||||||
} else {
|
return match ? match[1] : null
|
||||||
localStorage.removeItem('device_password')
|
} catch (e) {
|
||||||
|
console.log('Failed to get UUID from cookie:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearCookie() {
|
||||||
|
try {
|
||||||
|
document.cookie = 'device_uuid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to clear UUID cookie:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// IndexedDB 操作(异步,作为额外的备份)
|
||||||
|
async saveToIndexedDB(uuid) {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB()
|
||||||
|
const transaction = db.transaction(['device'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('device')
|
||||||
|
await store.put({ id: 'uuid', value: uuid })
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to save UUID to IndexedDB:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFromIndexedDB() {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB()
|
||||||
|
const transaction = db.transaction(['device'], 'readonly')
|
||||||
|
const store = transaction.objectStore('device')
|
||||||
|
const result = await store.get('uuid')
|
||||||
|
return result?.value || null
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to get UUID from IndexedDB:', e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearIndexedDB() {
|
||||||
|
try {
|
||||||
|
const db = await this.openDB()
|
||||||
|
const transaction = db.transaction(['device'], 'readwrite')
|
||||||
|
const store = transaction.objectStore('device')
|
||||||
|
await store.delete('uuid')
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Failed to clear UUID from IndexedDB:', e)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
openDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('ClassworksKV', 1)
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error)
|
||||||
|
request.onsuccess = () => resolve(request.result)
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result
|
||||||
|
if (!db.objectStoreNames.contains('device')) {
|
||||||
|
db.createObjectStore('device', { keyPath: 'id' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
// 尝试从 IndexedDB 恢复 UUID(在初始化时调用)
|
||||||
|
async tryRestoreFromIndexedDB() {
|
||||||
|
const uuid = await this.getFromIndexedDB()
|
||||||
|
if (uuid && !this.getDeviceUuid()) {
|
||||||
|
this.setDeviceUuid(uuid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在页面加载时尝试从 IndexedDB 恢复
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
deviceStore.tryRestoreFromIndexedDB()
|
||||||
|
}
|
||||||
|
|||||||
@ -1,11 +1,26 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
import { routes } from 'vue-router/auto-routes'
|
import { routes } from 'vue-router/auto-routes'
|
||||||
import { tokenStore } from './lib/tokenStore'
|
import { tokenStore } from './lib/tokenStore'
|
||||||
|
import { deviceStore } from './lib/deviceStore'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
// 检查 URL 参数中的 uuid 并设置到本地存储
|
||||||
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
|
const urlUuid = urlParams.get('uuid')
|
||||||
|
if (urlUuid) {
|
||||||
|
deviceStore.setDeviceUuid(urlUuid)
|
||||||
|
// 清除 URL 中的 uuid 参数
|
||||||
|
urlParams.delete('uuid')
|
||||||
|
const newUrl = urlParams.toString()
|
||||||
|
? `${window.location.pathname}?${urlParams.toString()}`
|
||||||
|
: window.location.pathname
|
||||||
|
window.history.replaceState({}, '', newUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
@ -24,4 +39,7 @@ router.beforeEach((to, _from, next) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
const app = createApp(App)
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@ -3,16 +3,21 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { apiClient } from '@/lib/api'
|
import { apiClient } from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import { deviceStore } from '@/lib/deviceStore'
|
||||||
|
import { useAccountStore } from '@/stores/account'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle } from 'lucide-vue-next'
|
import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, Check } from 'lucide-vue-next'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
import AppCard from '@/components/AppCard.vue'
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue'
|
||||||
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const accountStore = useAccountStore()
|
||||||
|
|
||||||
// URL 参数
|
// URL 参数
|
||||||
const appId = ref(route.query.app_id || '')
|
const appId = ref(route.query.app_id || '')
|
||||||
@ -24,7 +29,14 @@ const callbackUrl = ref(route.query.callback_url || '')
|
|||||||
const step = ref('input') // 'input' | 'loading' | 'success' | 'error'
|
const step = ref('input') // 'input' | 'loading' | 'success' | 'error'
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const deviceUuid = ref('')
|
const deviceUuid = ref('')
|
||||||
const hasPassword = ref(false)
|
const deviceInfo = ref(null)
|
||||||
|
const deviceAccount = ref(null)
|
||||||
|
const showLoginDialog = ref(false)
|
||||||
|
const showDeviceList = ref(false)
|
||||||
|
const customDeviceUuid = ref('')
|
||||||
|
|
||||||
|
// 计算属性获取是否有密码
|
||||||
|
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const inputDeviceCode = ref('')
|
const inputDeviceCode = ref('')
|
||||||
@ -50,6 +62,73 @@ const loadAppInfo = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载设备账户信息
|
||||||
|
const loadDeviceAccount = async () => {
|
||||||
|
if (!deviceUuid.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getDeviceAccount(deviceUuid.value)
|
||||||
|
deviceAccount.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load device account:', error)
|
||||||
|
deviceAccount.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择设备UUID
|
||||||
|
const selectDevice = async (uuid) => {
|
||||||
|
deviceUuid.value = uuid
|
||||||
|
deviceStore.setUuid(uuid)
|
||||||
|
showDeviceList.value = false
|
||||||
|
customDeviceUuid.value = ''
|
||||||
|
|
||||||
|
// 重新加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
await loadDeviceAccount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用自定义UUID
|
||||||
|
const useCustomUuid = () => {
|
||||||
|
if (!customDeviceUuid.value) return
|
||||||
|
|
||||||
|
selectDevice(customDeviceUuid.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一键绑定当前设备
|
||||||
|
const bindCurrentDevice = async () => {
|
||||||
|
if (!accountStore.isAuthenticated || !deviceUuid.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await accountStore.bindDevice(deviceUuid.value)
|
||||||
|
await loadDeviceAccount()
|
||||||
|
toast.success('绑定成功', {
|
||||||
|
description: '设备已绑定到您的账户'
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('绑定失败', {
|
||||||
|
description: error.message || '无法绑定设备'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功回调
|
||||||
|
const handleLoginSuccess = async (token) => {
|
||||||
|
showLoginDialog.value = false
|
||||||
|
await accountStore.login(token)
|
||||||
|
await loadDeviceAccount()
|
||||||
|
|
||||||
|
// 如果当前设备未绑定,提示是否绑定
|
||||||
|
if (!deviceAccount.value) {
|
||||||
|
toast('登录成功', {
|
||||||
|
description: '您可以将当前设备绑定到账户',
|
||||||
|
action: {
|
||||||
|
label: '立即绑定',
|
||||||
|
onClick: bindCurrentDevice,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 授权应用并绑定到设备代码
|
// 授权应用并绑定到设备代码
|
||||||
const authorizeWithDeviceCode = async () => {
|
const authorizeWithDeviceCode = async () => {
|
||||||
if (!currentDeviceCode.value || !deviceUuid.value) return
|
if (!currentDeviceCode.value || !deviceUuid.value) return
|
||||||
@ -60,7 +139,6 @@ const authorizeWithDeviceCode = async () => {
|
|||||||
try {
|
try {
|
||||||
// 1. 授权应用并获取 token
|
// 1. 授权应用并获取 token
|
||||||
const authData = {
|
const authData = {
|
||||||
deviceUuid: deviceUuid.value,
|
|
||||||
note: authNote.value || '设备代码授权',
|
note: authNote.value || '设备代码授权',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,7 +146,7 @@ const authorizeWithDeviceCode = async () => {
|
|||||||
authData.password = authPassword.value
|
authData.password = authPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const authResult = await apiClient.authorizeApp(appId.value, authData)
|
const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData)
|
||||||
const token = authResult.token
|
const token = authResult.token
|
||||||
|
|
||||||
// 2. 绑定 token 到设备代码
|
// 2. 绑定 token 到设备代码
|
||||||
@ -90,7 +168,6 @@ const authorizeWithCallback = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const authData = {
|
const authData = {
|
||||||
deviceUuid: deviceUuid.value,
|
|
||||||
note: authNote.value || '回调授权',
|
note: authNote.value || '回调授权',
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -98,7 +175,7 @@ const authorizeWithCallback = async () => {
|
|||||||
authData.password = authPassword.value
|
authData.password = authPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const authResult = await apiClient.authorizeApp(appId.value, authData)
|
const authResult = await apiClient.authorizeApp(appId.value, deviceUuid.value, authData)
|
||||||
const token = authResult.token
|
const token = authResult.token
|
||||||
|
|
||||||
// 如果有回调 URL,跳转并携带 token
|
// 如果有回调 URL,跳转并携带 token
|
||||||
@ -136,10 +213,33 @@ const retry = () => {
|
|||||||
authPassword.value = ''
|
authPassword.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// 加载设备信息
|
||||||
|
const loadDeviceInfo = async () => {
|
||||||
|
try {
|
||||||
|
const info = await apiClient.getDeviceInfo(deviceUuid.value)
|
||||||
|
deviceInfo.value = info
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load device info:', error)
|
||||||
|
deviceInfo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
deviceUuid.value = deviceStore.getOrGenerate()
|
deviceUuid.value = deviceStore.getOrGenerate()
|
||||||
hasPassword.value = deviceStore.hasPassword()
|
|
||||||
loadAppInfo()
|
// 加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
|
// 加载设备账户信息
|
||||||
|
await loadDeviceAccount()
|
||||||
|
|
||||||
|
// 加载应用信息
|
||||||
|
await loadAppInfo()
|
||||||
|
|
||||||
|
// 如果已登录,加载设备列表
|
||||||
|
if (accountStore.isAuthenticated) {
|
||||||
|
await accountStore.loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是 devicecode 模式且已有设备代码,自动填充
|
// 如果是 devicecode 模式且已有设备代码,自动填充
|
||||||
if (isDeviceCodeMode.value && deviceCode.value) {
|
if (isDeviceCodeMode.value && deviceCode.value) {
|
||||||
@ -179,7 +279,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 设备信息 -->
|
<!-- 设备信息 -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<Label class="text-sm text-muted-foreground">设备 UUID</Label>
|
<Label class="text-sm text-muted-foreground">设备 UUID</Label>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- 当前设备UUID显示 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="text-xs font-mono bg-muted px-3 py-2 rounded flex-1 truncate">
|
<code class="text-xs font-mono bg-muted px-3 py-2 rounded flex-1 truncate">
|
||||||
{{ deviceUuid }}
|
{{ deviceUuid }}
|
||||||
@ -189,6 +296,23 @@ onMounted(() => {
|
|||||||
已保护
|
已保护
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 设备绑定状态 -->
|
||||||
|
<div v-if="deviceAccount" class="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
|
<User class="h-3 w-3" />
|
||||||
|
已绑定至: {{ deviceAccount.name }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="accountStore.isAuthenticated && !showDeviceList" class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
@click="bindCurrentDevice"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="text-xs"
|
||||||
|
>
|
||||||
|
<Plus class="h-3 w-3 mr-1" />
|
||||||
|
绑定到我的账户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 模式标识 -->
|
<!-- 模式标识 -->
|
||||||
@ -231,14 +355,17 @@ onMounted(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 密码输入 -->
|
<!-- 密码输入(使用统一组件) -->
|
||||||
<div v-if="hasPassword" class="space-y-2">
|
<div v-if="hasPassword">
|
||||||
<Label for="password">设备密码</Label>
|
<PasswordInput
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
v-model="authPassword"
|
v-model="authPassword"
|
||||||
type="text"
|
label="设备密码"
|
||||||
placeholder="输入设备密码以确认授权"
|
placeholder="输入设备密码以确认授权"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
required
|
||||||
|
:error="step === 'error' && errorMessage.includes('密码') ? '密码错误' : ''"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -337,5 +464,8 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- 登录弹框 -->
|
||||||
|
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,422 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import { ref, onMounted, computed } from 'vue'
|
|
||||||
import { apiClient } from '@/lib/api'
|
|
||||||
import { tokenStore } from '@/lib/tokenStore'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
||||||
import { Loader2, Plus, Trash2, Eye, ArrowLeft, RefreshCw, Edit, Home } from 'lucide-vue-next'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const token = ref('')
|
|
||||||
const appName = ref('')
|
|
||||||
const items = ref([])
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isRefreshing = ref(false)
|
|
||||||
const totalRows = ref(0)
|
|
||||||
const currentPage = ref(0)
|
|
||||||
const pageSize = ref(20)
|
|
||||||
|
|
||||||
// Dialog states
|
|
||||||
const showCreateDialog = ref(false)
|
|
||||||
const showViewDialog = ref(false)
|
|
||||||
const showDeleteDialog = ref(false)
|
|
||||||
const showEditDialog = ref(false)
|
|
||||||
|
|
||||||
// Form data
|
|
||||||
const newKey = ref('')
|
|
||||||
const newValue = ref('{}')
|
|
||||||
const selectedItem = ref(null)
|
|
||||||
const editValue = ref('')
|
|
||||||
|
|
||||||
// Search and filter
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const sortBy = ref('key')
|
|
||||||
const sortDir = ref('asc')
|
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
|
||||||
if (!searchQuery.value) return items.value
|
|
||||||
return items.value.filter(item =>
|
|
||||||
item.key.toLowerCase().includes(searchQuery.value.toLowerCase())
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadItems = async () => {
|
|
||||||
if (!token.value) return
|
|
||||||
|
|
||||||
isLoading.value = true
|
|
||||||
try {
|
|
||||||
const response = await apiClient.listKVItems(token.value, {
|
|
||||||
sortBy: sortBy.value,
|
|
||||||
sortDir: sortDir.value,
|
|
||||||
limit: pageSize.value,
|
|
||||||
skip: currentPage.value * pageSize.value,
|
|
||||||
})
|
|
||||||
items.value = response.items
|
|
||||||
totalRows.value = response.total_rows
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load items:', error)
|
|
||||||
if (error instanceof Error && error.message.includes('401')) {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshItems = async () => {
|
|
||||||
isRefreshing.value = true
|
|
||||||
await loadItems()
|
|
||||||
isRefreshing.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const createItem = async () => {
|
|
||||||
if (!newKey.value || !newValue.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(newValue.value)
|
|
||||||
await apiClient.setKVItem(token.value, newKey.value, value)
|
|
||||||
await loadItems()
|
|
||||||
showCreateDialog.value = false
|
|
||||||
newKey.value = ''
|
|
||||||
newValue.value = '{}'
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to create item:', error)
|
|
||||||
alert('创建失败:' + (error instanceof Error ? error.message : '未知错误'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const viewItem = async (item) => {
|
|
||||||
selectedItem.value = item
|
|
||||||
showViewDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const editItem = (item) => {
|
|
||||||
selectedItem.value = item
|
|
||||||
editValue.value = JSON.stringify(item.value, null, 2)
|
|
||||||
showEditDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateItem = async () => {
|
|
||||||
if (!selectedItem.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const value = JSON.parse(editValue.value)
|
|
||||||
await apiClient.setKVItem(token.value, selectedItem.value.key, value)
|
|
||||||
await loadItems()
|
|
||||||
showEditDialog.value = false
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update item:', error)
|
|
||||||
alert('更新失败:' + (error instanceof Error ? error.message : '未知错误'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmDelete = (item) => {
|
|
||||||
selectedItem.value = item
|
|
||||||
showDeleteDialog.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteItem = async () => {
|
|
||||||
if (!selectedItem.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiClient.deleteKVItem(token.value, selectedItem.value.key)
|
|
||||||
await loadItems()
|
|
||||||
showDeleteDialog.value = false
|
|
||||||
selectedItem.value = null
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete item:', error)
|
|
||||||
alert('删除失败:' + (error instanceof Error ? error.message : '未知错误'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const goHome = () => {
|
|
||||||
window.location.href = '/'
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextPage = () => {
|
|
||||||
if ((currentPage.value + 1) * pageSize.value < totalRows.value) {
|
|
||||||
currentPage.value++
|
|
||||||
loadItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevPage = () => {
|
|
||||||
if (currentPage.value > 0) {
|
|
||||||
currentPage.value--
|
|
||||||
loadItems()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
|
||||||
return new Date(dateString).toLocaleString('zh-CN')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
const activeToken = tokenStore.getActiveToken()
|
|
||||||
if (!activeToken) {
|
|
||||||
window.location.href = '/'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token.value = activeToken.token
|
|
||||||
appName.value = activeToken.appName || '未命名应用'
|
|
||||||
loadItems()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-screen bg-background">
|
|
||||||
<!-- Header -->
|
|
||||||
<header class="border-b bg-background">
|
|
||||||
<div class="container mx-auto px-6 py-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" @click="goHome">
|
|
||||||
<Home class="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<h1 class="text-xl font-bold">{{ appName }} - 数据管理</h1>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ totalRows }} 条键值对</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<Button variant="ghost" size="icon" @click="refreshItems" :disabled="isRefreshing">
|
|
||||||
<RefreshCw :class="{ 'animate-spin': isRefreshing }" class="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<Button @click="showCreateDialog = true">
|
|
||||||
<Plus class="mr-2 h-4 w-4" />
|
|
||||||
新建
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main class="container mx-auto py-8 px-6">
|
|
||||||
<!-- Search and Filters -->
|
|
||||||
<div class="flex gap-4 mb-6">
|
|
||||||
<div class="flex-1">
|
|
||||||
<Input
|
|
||||||
v-model="searchQuery"
|
|
||||||
placeholder="搜索键名..."
|
|
||||||
class="max-w-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Select v-model="sortBy" @update:model-value="loadItems">
|
|
||||||
<SelectTrigger class="w-[180px]">
|
|
||||||
<SelectValue placeholder="排序方式" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="key">按键名</SelectItem>
|
|
||||||
<SelectItem value="createdAt">按创建时间</SelectItem>
|
|
||||||
<SelectItem value="updatedAt">按更新时间</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select v-model="sortDir" @update:model-value="loadItems">
|
|
||||||
<SelectTrigger class="w-[120px]">
|
|
||||||
<SelectValue placeholder="排序" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="asc">升序</SelectItem>
|
|
||||||
<SelectItem value="desc">降序</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div v-if="isLoading" class="flex items-center justify-center py-16">
|
|
||||||
<Loader2 class="h-8 w-8 animate-spin text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Table -->
|
|
||||||
<div v-else-if="filteredItems.length > 0" class="rounded-lg border">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead class="w-[30%]">键名</TableHead>
|
|
||||||
<TableHead class="w-[20%]">创建时间</TableHead>
|
|
||||||
<TableHead class="w-[20%]">更新时间</TableHead>
|
|
||||||
<TableHead class="w-[15%]">创建者 IP</TableHead>
|
|
||||||
<TableHead class="w-[15%] text-right">操作</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
<TableRow v-for="item in filteredItems" :key="item.key">
|
|
||||||
<TableCell class="font-mono font-medium">{{ item.key }}</TableCell>
|
|
||||||
<TableCell class="text-sm text-muted-foreground">
|
|
||||||
{{ formatDate(item.createdAt) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-sm text-muted-foreground">
|
|
||||||
{{ formatDate(item.updatedAt) }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-sm text-muted-foreground">
|
|
||||||
{{ item.creatorIp }}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell class="text-right">
|
|
||||||
<div class="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" size="icon" @click="viewItem(item)">
|
|
||||||
<Eye class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" @click="editItem(item)">
|
|
||||||
<Edit class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" @click="confirmDelete(item)">
|
|
||||||
<Trash2 class="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div v-else class="flex flex-col items-center justify-center py-16 space-y-3">
|
|
||||||
<p class="text-muted-foreground">暂无数据</p>
|
|
||||||
<Button variant="link" @click="showCreateDialog = true">
|
|
||||||
创建第一个键值对
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<div v-if="!isLoading && totalRows > pageSize" class="flex items-center justify-between mt-6 px-4 py-4 border-t">
|
|
||||||
<p class="text-sm text-muted-foreground">
|
|
||||||
显示 {{ currentPage * pageSize + 1 }} - {{ Math.min((currentPage + 1) * pageSize, totalRows) }} / {{ totalRows }}
|
|
||||||
</p>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="prevPage"
|
|
||||||
:disabled="currentPage === 0"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
@click="nextPage"
|
|
||||||
:disabled="(currentPage + 1) * pageSize >= totalRows"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<!-- Create Dialog -->
|
|
||||||
<Dialog v-model:open="showCreateDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader class="space-y-2">
|
|
||||||
<DialogTitle>新建键值对</DialogTitle>
|
|
||||||
<DialogDescription>创建一个新的键值对</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div class="space-y-4 py-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="new-key">键名</Label>
|
|
||||||
<Input id="new-key" v-model="newKey" placeholder="例如:user:123" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="new-value">值 (JSON)</Label>
|
|
||||||
<textarea
|
|
||||||
id="new-value"
|
|
||||||
v-model="newValue"
|
|
||||||
class="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
|
||||||
placeholder='{"name": "John", "age": 30}'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<Button variant="outline" @click="showCreateDialog = false">取消</Button>
|
|
||||||
<Button @click="createItem">创建</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- View Dialog -->
|
|
||||||
<Dialog v-model:open="showViewDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader class="space-y-2">
|
|
||||||
<DialogTitle>查看键值对</DialogTitle>
|
|
||||||
<DialogDescription>{{ selectedItem?.key }}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div v-if="selectedItem" class="space-y-4 py-4">
|
|
||||||
<div class="rounded-lg bg-muted p-4">
|
|
||||||
<pre class="text-sm overflow-auto max-h-[400px]">{{ JSON.stringify(selectedItem.value, null, 2) }}</pre>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="text-muted-foreground">设备 ID:</span>
|
|
||||||
<p class="font-mono text-xs break-all">{{ selectedItem.deviceId }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="text-muted-foreground">创建者 IP:</span>
|
|
||||||
<p>{{ selectedItem.creatorIp }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="text-muted-foreground">创建时间:</span>
|
|
||||||
<p>{{ formatDate(selectedItem.createdAt) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<span class="text-muted-foreground">更新时间:</span>
|
|
||||||
<p>{{ formatDate(selectedItem.updatedAt) }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button @click="showViewDialog = false">关闭</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- Edit Dialog -->
|
|
||||||
<Dialog v-model:open="showEditDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader class="space-y-2">
|
|
||||||
<DialogTitle>编辑键值对</DialogTitle>
|
|
||||||
<DialogDescription>{{ selectedItem?.key }}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div class="space-y-4 py-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="edit-value">值 (JSON)</Label>
|
|
||||||
<textarea
|
|
||||||
id="edit-value"
|
|
||||||
v-model="editValue"
|
|
||||||
class="flex min-h-[200px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter class="gap-2">
|
|
||||||
<Button variant="outline" @click="showEditDialog = false">取消</Button>
|
|
||||||
<Button @click="updateItem">保存</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<!-- Delete Dialog -->
|
|
||||||
<Dialog v-model:open="showDeleteDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader class="space-y-2">
|
|
||||||
<DialogTitle>确认删除</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
确定要删除键名为 <strong>{{ selectedItem?.key }}</strong> 的记录吗?此操作无法撤销。
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter class="gap-2 mt-4">
|
|
||||||
<Button variant="outline" @click="showDeleteDialog = false">取消</Button>
|
|
||||||
<Button variant="destructive" @click="deleteItem">删除</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
276
kv-admin/src/pages/device-management.vue
Normal file
276
kv-admin/src/pages/device-management.vue
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useAccountStore } from '@/stores/account'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { apiClient } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Smartphone,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Lock,
|
||||||
|
User,
|
||||||
|
ArrowLeft
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
||||||
|
import ResetDevicePasswordDialog from '@/components/ResetDevicePasswordDialog.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const accountStore = useAccountStore()
|
||||||
|
|
||||||
|
const devices = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const showEditNameDialog = ref(false)
|
||||||
|
const showResetPasswordDialog = ref(false)
|
||||||
|
const showDeleteDialog = ref(false)
|
||||||
|
const currentDevice = ref(null)
|
||||||
|
|
||||||
|
// 加载设备列表
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!accountStore.isAuthenticated) {
|
||||||
|
router.push('/')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getAccountDevices(accountStore.token)
|
||||||
|
devices.value = response.data || []
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('加载设备列表失败:' + error.message)
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开解绑确认对话框
|
||||||
|
const confirmUnbind = (device) => {
|
||||||
|
currentDevice.value = device
|
||||||
|
showDeleteDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑设备
|
||||||
|
const unbindDevice = async () => {
|
||||||
|
if (!currentDevice.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.unbindDeviceFromAccount(accountStore.token, currentDevice.value.uuid)
|
||||||
|
toast.success('设备已解绑')
|
||||||
|
showDeleteDialog.value = false
|
||||||
|
currentDevice.value = null
|
||||||
|
await loadDevices()
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('解绑失败:' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑设备名称
|
||||||
|
const editDeviceName = (device) => {
|
||||||
|
currentDevice.value = device
|
||||||
|
showEditNameDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置设备密码
|
||||||
|
const resetPassword = (device) => {
|
||||||
|
currentDevice.value = device
|
||||||
|
showResetPasswordDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备名称更新成功
|
||||||
|
const handleDeviceNameUpdated = async () => {
|
||||||
|
await loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码重置成功
|
||||||
|
const handlePasswordReset = async () => {
|
||||||
|
toast.success('密码重置成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
return new Date(dateString).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadDevices()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10">
|
||||||
|
<div class="container mx-auto px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
@click="router.push('/')"
|
||||||
|
>
|
||||||
|
<ArrowLeft class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold">设备管理</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">管理您账户下的所有设备</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" class="px-3 py-1">
|
||||||
|
<User class="h-3 w-3 mr-1.5" />
|
||||||
|
{{ accountStore.userName }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
@click="loadDevices"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="isLoading" class="text-center py-12">
|
||||||
|
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||||
|
<p class="mt-4 text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<Card v-else-if="devices.length === 0" class="border-dashed">
|
||||||
|
<CardContent class="flex flex-col items-center justify-center py-12">
|
||||||
|
<Smartphone class="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||||
|
<p class="text-lg font-medium text-muted-foreground mb-2">暂无绑定设备</p>
|
||||||
|
<p class="text-sm text-muted-foreground mb-4">您可以在主页面注册并绑定新设备</p>
|
||||||
|
<Button @click="router.push('/')" variant="outline">
|
||||||
|
返回主页
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 设备列表 -->
|
||||||
|
<div v-else>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
共 {{ devices.length }} 个设备
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Card
|
||||||
|
v-for="device in devices"
|
||||||
|
:key="device.uuid"
|
||||||
|
class="hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<CardHeader class="pb-3">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<CardTitle class="text-lg">
|
||||||
|
{{ device.name || '未命名设备' }}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription class="mt-1">
|
||||||
|
<code class="text-xs">{{ device.uuid }}</code>
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Smartphone class="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
创建时间: {{ formatDate(device.createdAt) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="editDeviceName(device)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<Edit class="h-3 w-3 mr-1" />
|
||||||
|
重命名
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="resetPassword(device)"
|
||||||
|
class="flex-1"
|
||||||
|
>
|
||||||
|
<Lock class="h-3 w-3 mr-1" />
|
||||||
|
重置密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
@click="confirmUnbind(device)"
|
||||||
|
class="w-full"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3 w-3 mr-1" />
|
||||||
|
解绑设备
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑设备名称弹框 -->
|
||||||
|
<EditDeviceNameDialog
|
||||||
|
v-if="currentDevice"
|
||||||
|
v-model="showEditNameDialog"
|
||||||
|
:device-uuid="currentDevice.uuid"
|
||||||
|
:current-name="currentDevice.name || ''"
|
||||||
|
:has-password="false"
|
||||||
|
@success="handleDeviceNameUpdated"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 重置密码弹框 -->
|
||||||
|
<ResetDevicePasswordDialog
|
||||||
|
v-if="currentDevice"
|
||||||
|
v-model="showResetPasswordDialog"
|
||||||
|
:device-uuid="currentDevice.uuid"
|
||||||
|
:device-name="currentDevice.name || ''"
|
||||||
|
@success="handlePasswordReset"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 解绑确认对话框 -->
|
||||||
|
<AlertDialog v-model:open="showDeleteDialog">
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认解绑设备</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要解绑设备 "{{ currentDevice?.name || currentDevice?.uuid }}" 吗?
|
||||||
|
此操作无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction @click="unbindDevice">
|
||||||
|
确认解绑
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -2,48 +2,69 @@
|
|||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import { apiClient } from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import { deviceStore } from '@/lib/deviceStore'
|
||||||
|
import { useAccountStore } from '@/stores/account'
|
||||||
|
import { useOAuthCallback } from '@/composables/useOAuthCallback'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Package, Clock } from 'lucide-vue-next'
|
import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Package, Clock, AlertCircle, Lock, Info, User, LogOut, Layers, ChevronDown } from 'lucide-vue-next'
|
||||||
|
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
||||||
|
import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
import AppCard from '@/components/AppCard.vue'
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue'
|
||||||
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
|
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
|
||||||
|
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
||||||
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
const deviceUuid = ref('')
|
const deviceUuid = ref('')
|
||||||
const tokens = ref([])
|
const tokens = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const copied = ref(null)
|
const copied = ref(null)
|
||||||
|
const deviceInfo = ref(null) // 存储设备信息
|
||||||
|
const deviceAccount = ref(null) // 设备账户信息
|
||||||
|
const accountStore = useAccountStore()
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
const showAuthorizeDialog = ref(false)
|
const showAuthorizeDialog = ref(false)
|
||||||
const showRevokeDialog = ref(false)
|
const showRevokeDialog = ref(false)
|
||||||
const showUuidDialog = ref(false)
|
const showRegisterDialog = ref(false)
|
||||||
const showPasswordDialog = ref(false)
|
const showPasswordDialog = ref(false)
|
||||||
|
const showLoginDialog = ref(false)
|
||||||
|
const showEditNameDialog = ref(false)
|
||||||
|
const showUserMenu = ref(false)
|
||||||
|
const deviceRequired = ref(false) // 标记是否必须注册设备
|
||||||
const selectedToken = ref(null)
|
const selectedToken = ref(null)
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
const appIdToAuthorize = ref('')
|
const appIdToAuthorize = ref('')
|
||||||
const authPassword = ref('')
|
const authPassword = ref('')
|
||||||
const authNote = ref('')
|
const authNote = ref('')
|
||||||
const newUuid = ref('')
|
|
||||||
const devicePassword = ref('')
|
const devicePassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
|
const passwordHint = ref('')
|
||||||
|
const revokePassword = ref('') // 撤销授权时的密码
|
||||||
|
|
||||||
const hasPassword = ref(false)
|
// 使用OAuth回调处理
|
||||||
|
const { handleOAuthCallback } = useOAuthCallback()
|
||||||
|
|
||||||
|
// 使用计算属性来获取是否有密码
|
||||||
|
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||||
|
|
||||||
// Group tokens by appId
|
// Group tokens by appId
|
||||||
const groupedByApp = computed(() => {
|
const groupedByApp = computed(() => {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
tokens.value.forEach(token => {
|
tokens.value.forEach(token => {
|
||||||
const appId = token.app.id
|
const appId = token.appId
|
||||||
if (!groups[appId]) {
|
if (!groups[appId]) {
|
||||||
groups[appId] = {
|
groups[appId] = {
|
||||||
appId: appId,
|
appId: appId,
|
||||||
appName: token.app.name || appId,
|
appName: token.appName || appId,
|
||||||
description: token.app.description || '',
|
description: token.appDescription || '',
|
||||||
tokens: []
|
tokens: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -52,6 +73,34 @@ const groupedByApp = computed(() => {
|
|||||||
return Object.values(groups)
|
return Object.values(groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 加载设备信息
|
||||||
|
const loadDeviceInfo = async () => {
|
||||||
|
try {
|
||||||
|
const info = await apiClient.getDeviceInfo(deviceUuid.value)
|
||||||
|
deviceInfo.value = info
|
||||||
|
// 如果有密码提示,设置它
|
||||||
|
if (info.passwordHint) {
|
||||||
|
passwordHint.value = info.passwordHint
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load device info:', error)
|
||||||
|
// 设备不存在时,deviceInfo为null,hasPassword会返回false
|
||||||
|
deviceInfo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载密码提示
|
||||||
|
const loadPasswordHint = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.getPasswordHint(deviceUuid.value)
|
||||||
|
if (data.hint) {
|
||||||
|
passwordHint.value = data.hint
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load password hint')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadTokens = async () => {
|
const loadTokens = async () => {
|
||||||
if (!deviceUuid.value) return
|
if (!deviceUuid.value) return
|
||||||
|
|
||||||
@ -73,24 +122,34 @@ const authorizeApp = async () => {
|
|||||||
if (!appIdToAuthorize.value) return
|
if (!appIdToAuthorize.value) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = {
|
const options = {
|
||||||
deviceUuid: deviceUuid.value,
|
|
||||||
note: authNote.value || '授权访问',
|
note: authNote.value || '授权访问',
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPassword.value && authPassword.value) {
|
if (hasPassword.value && authPassword.value) {
|
||||||
data.password = authPassword.value
|
options.password = authPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.authorizeApp(appIdToAuthorize.value, data)
|
if (accountStore.isAuthenticated) {
|
||||||
|
options.token = accountStore.token
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用授权接口
|
||||||
|
await apiClient.authorizeApp(
|
||||||
|
appIdToAuthorize.value,
|
||||||
|
deviceUuid.value,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
|
||||||
showAuthorizeDialog.value = false
|
showAuthorizeDialog.value = false
|
||||||
appIdToAuthorize.value = ''
|
appIdToAuthorize.value = ''
|
||||||
authPassword.value = ''
|
authPassword.value = ''
|
||||||
authNote.value = ''
|
authNote.value = ''
|
||||||
|
|
||||||
await loadTokens()
|
await loadTokens()
|
||||||
|
toast.success('授权成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('授权失败:' + error.message)
|
toast.error('授权失败:' + error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,13 +161,27 @@ const confirmRevoke = (token) => {
|
|||||||
const revokeToken = async () => {
|
const revokeToken = async () => {
|
||||||
if (!selectedToken.value) return
|
if (!selectedToken.value) return
|
||||||
|
|
||||||
|
// 如果没有登录账户且设备有密码,检查是否输入了密码
|
||||||
|
if (!accountStore.isAuthenticated && hasPassword.value && !revokePassword.value) {
|
||||||
|
alert('请输入设备密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.revokeToken(selectedToken.value.token)
|
// 使用安装记录ID撤销授权
|
||||||
|
await apiClient.revokeDeviceToken(
|
||||||
|
deviceUuid.value,
|
||||||
|
selectedToken.value.id,
|
||||||
|
accountStore.isAuthenticated ? null : revokePassword.value,
|
||||||
|
accountStore.isAuthenticated ? accountStore.token : null
|
||||||
|
)
|
||||||
showRevokeDialog.value = false
|
showRevokeDialog.value = false
|
||||||
selectedToken.value = null
|
selectedToken.value = null
|
||||||
|
revokePassword.value = ''
|
||||||
await loadTokens()
|
await loadTokens()
|
||||||
|
toast.success('撤销成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('撤销失败:' + error.message)
|
toast.error('撤销失败:' + error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,14 +198,10 @@ const copyToClipboard = async (text, id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const updateUuid = () => {
|
const updateUuid = () => {
|
||||||
if (newUuid.value.trim()) {
|
showRegisterDialog.value = false
|
||||||
deviceStore.setDeviceUuid(newUuid.value.trim())
|
|
||||||
} else {
|
|
||||||
deviceStore.generateAndSave()
|
|
||||||
}
|
|
||||||
deviceUuid.value = deviceStore.getDeviceUuid()
|
deviceUuid.value = deviceStore.getDeviceUuid()
|
||||||
showUuidDialog.value = false
|
loadDeviceInfo()
|
||||||
newUuid.value = ''
|
loadDeviceAccount()
|
||||||
loadTokens()
|
loadTokens()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,18 +213,25 @@ const setPassword = async () => {
|
|||||||
newPassword: newPassword.value,
|
newPassword: newPassword.value,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPassword.value) {
|
if (hasPassword.value && !accountStore.isAuthenticated) {
|
||||||
data.currentPassword = currentPassword.value
|
data.currentPassword = currentPassword.value
|
||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.setDevicePassword(deviceUuid.value, data)
|
await apiClient.setDevicePassword(
|
||||||
deviceStore.setHasPassword(true)
|
deviceUuid.value,
|
||||||
hasPassword.value = true
|
data,
|
||||||
|
accountStore.isAuthenticated ? accountStore.token : null
|
||||||
|
)
|
||||||
|
|
||||||
|
// 重新加载设备信息以更新hasPassword状态
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
showPasswordDialog.value = false
|
showPasswordDialog.value = false
|
||||||
newPassword.value = ''
|
newPassword.value = ''
|
||||||
currentPassword.value = ''
|
currentPassword.value = ''
|
||||||
|
toast.success('密码设置成功')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('设置密码失败:' + error.message)
|
toast.error('设置密码失败:' + error.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,55 +239,323 @@ const formatDate = (dateString) => {
|
|||||||
return new Date(dateString).toLocaleString('zh-CN')
|
return new Date(dateString).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
// 加载设备账户信息
|
||||||
deviceUuid.value = deviceStore.getOrGenerate()
|
const loadDeviceAccount = async () => {
|
||||||
hasPassword.value = deviceStore.hasPassword()
|
if (!deviceUuid.value) return
|
||||||
loadTokens()
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getDeviceAccount(deviceUuid.value)
|
||||||
|
deviceAccount.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load device account:', error)
|
||||||
|
deviceAccount.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录成功回调
|
||||||
|
const handleLoginSuccess = async (token) => {
|
||||||
|
showLoginDialog.value = false
|
||||||
|
await accountStore.login(token)
|
||||||
|
await loadDeviceAccount()
|
||||||
|
|
||||||
|
// 如果当前设备未绑定,提示是否绑定
|
||||||
|
if (!deviceAccount.value) {
|
||||||
|
toast('登录成功', {
|
||||||
|
description: '您可以将当前设备绑定到账户'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录完成后,根据需要决定是否继续显示设备弹框
|
||||||
|
|
||||||
|
// 如果设备已经注册,即使是必需模式也不再显示设备弹框
|
||||||
|
if (deviceUuid.value) {
|
||||||
|
deviceRequired.value = false
|
||||||
|
} else if (deviceRequired.value) {
|
||||||
|
// 如果仍然需要设备,再次显示设备管理弹框
|
||||||
|
showRegisterDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
accountStore.logout()
|
||||||
|
deviceAccount.value = null
|
||||||
|
toast('已退出登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定当前设备到账户
|
||||||
|
const bindCurrentDevice = async () => {
|
||||||
|
if (!accountStore.isAuthenticated) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
showLoginDialog.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
|
||||||
|
await loadDeviceInfo()
|
||||||
|
toast.success('设备已绑定到您的账户')
|
||||||
|
} catch (error) {
|
||||||
|
// 如果设备不存在,先注册再绑定
|
||||||
|
if (error.message.includes('设备不存在')) {
|
||||||
|
try {
|
||||||
|
await apiClient.registerDevice(
|
||||||
|
deviceUuid.value,
|
||||||
|
deviceInfo.value?.deviceName || null,
|
||||||
|
accountStore.token
|
||||||
|
)
|
||||||
|
await apiClient.bindDeviceToAccount(accountStore.token, deviceUuid.value)
|
||||||
|
await loadDeviceInfo()
|
||||||
|
toast.success('设备已注册并绑定到您的账户')
|
||||||
|
} catch (retryError) {
|
||||||
|
toast.error('绑定失败:' + retryError.message)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('绑定失败:' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑当前设备
|
||||||
|
const unbindCurrentDevice = async () => {
|
||||||
|
if (!accountStore.isAuthenticated) {
|
||||||
|
toast.error('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.unbindDeviceFromAccount(accountStore.token, deviceUuid.value)
|
||||||
|
await loadDeviceInfo()
|
||||||
|
toast.success('设备已解绑')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('解绑失败:' + error.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新设备名称成功回调
|
||||||
|
const handleDeviceNameUpdated = async (newName) => {
|
||||||
|
await loadDeviceInfo()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 检查是否存在设备UUID
|
||||||
|
const existingUuid = deviceStore.getDeviceUuid()
|
||||||
|
if (!existingUuid) {
|
||||||
|
deviceRequired.value = true
|
||||||
|
// 如果没有设备UUID,显示设备管理弹框
|
||||||
|
showRegisterDialog.value = true
|
||||||
|
} else {
|
||||||
|
deviceUuid.value = existingUuid
|
||||||
|
|
||||||
|
// 先加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
|
// 加载设备账户信息
|
||||||
|
await loadDeviceAccount()
|
||||||
|
|
||||||
|
// 如果有密码但密码提示不存在,单独加载密码提示
|
||||||
|
if (hasPassword.value && !passwordHint.value) {
|
||||||
|
await loadPasswordHint()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载tokens
|
||||||
|
await loadTokens()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 p-4 md:p-8">
|
<div class="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
|
||||||
<div class="max-w-7xl mx-auto space-y-6">
|
<!-- Header -->
|
||||||
<Card class="border-2 shadow-lg">
|
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10">
|
||||||
<CardHeader>
|
<div class="container mx-auto px-6 py-4">
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<CardTitle class="text-2xl font-bold flex items-center gap-2">
|
<div class="rounded-lg bg-gradient-to-br from-primary to-primary/80 p-2.5 shadow-lg">
|
||||||
<Shield class="h-6 w-6 text-primary" />
|
<Shield class="h-6 w-6 text-primary-foreground" />
|
||||||
设备授权管理
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription class="mt-2">
|
|
||||||
管理您的设备 UUID 和应用授权令牌
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div>
|
||||||
<Button variant="outline" size="sm" @click="showPasswordDialog = true">
|
<h1 class="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text">
|
||||||
<Key class="h-4 w-4 mr-2" />
|
Classworks KV
|
||||||
{{ hasPassword ? '修改密码' : '设置密码' }}
|
</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">云原生键值数据库</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 账户状态 -->
|
||||||
|
<template v-if="accountStore.isAuthenticated">
|
||||||
|
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
||||||
|
<template #trigger="{ toggle, open }">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="flex items-center gap-2"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="accountStore.userAvatar"
|
||||||
|
:src="accountStore.userAvatar"
|
||||||
|
:alt="accountStore.userName"
|
||||||
|
class="w-5 h-5 rounded-full"
|
||||||
|
>
|
||||||
|
<User v-else class="h-4 w-4" />
|
||||||
|
<span class="hidden sm:inline">{{ accountStore.userName }}</span>
|
||||||
|
<ChevronDown class="h-3.5 w-3.5" :class="{ 'rotate-180': open }" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" @click="showUuidDialog = true">
|
</template>
|
||||||
<RefreshCw class="h-4 w-4 mr-2" />
|
|
||||||
更换 UUID
|
<DropdownItem @click="$router.push('/device-management')">
|
||||||
|
<Layers class="h-4 w-4" />
|
||||||
|
设备管理
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem @click="$router.push('/password-manager')">
|
||||||
|
<Settings class="h-4 w-4" />
|
||||||
|
高级设置
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem @click="handleLogout" class="text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300">
|
||||||
|
<LogOut class="h-4 w-4" />
|
||||||
|
退出登录
|
||||||
|
</DropdownItem>
|
||||||
|
</DropdownMenu>
|
||||||
|
</template>
|
||||||
|
<Button
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="showLoginDialog = true"
|
||||||
|
>
|
||||||
|
<User class="h-4 w-4 mr-2" />
|
||||||
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
@click="loadTokens"
|
||||||
|
:disabled="isLoading"
|
||||||
|
>
|
||||||
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
||||||
|
<!-- Device Info Card -->
|
||||||
|
<Card class="mb-8 border-2 shadow-xl bg-gradient-to-br from-card to-card/95">
|
||||||
|
<CardHeader class="pb-4">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-primary/10 p-2">
|
||||||
|
<Key class="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CardTitle class="text-lg">
|
||||||
|
{{ deviceInfo?.name || '设备标识' }}
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="h-6 w-6"
|
||||||
|
@click="showEditNameDialog = true"
|
||||||
|
title="编辑设备名称"
|
||||||
|
>
|
||||||
|
<Settings class="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<CardDescription>您的唯一设备标识符</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
class="px-3 py-1"
|
||||||
|
:class="hasPassword ? 'border-green-500 text-green-700 dark:text-green-400' : 'border-yellow-500 text-yellow-700 dark:text-yellow-400'"
|
||||||
|
>
|
||||||
|
<Lock v-if="hasPassword" class="h-3 w-3 mr-1.5" />
|
||||||
|
<AlertCircle v-else class="h-3 w-3 mr-1.5" />
|
||||||
|
{{ hasPassword ? '已设密码保护' : '未设密码' }}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<!-- 设备账户绑定状态 -->
|
||||||
|
<Badge v-if="deviceInfo?.account" variant="secondary" class="px-3 py-1">
|
||||||
|
<User class="h-3 w-3 mr-1.5" />
|
||||||
|
{{ deviceInfo.account.name }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
v-else-if="accountStore.isAuthenticated"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="bindCurrentDevice"
|
||||||
|
>
|
||||||
|
<User class="h-4 w-4 mr-2" />
|
||||||
|
绑定到账户
|
||||||
|
</Button><Button
|
||||||
|
@click="$router.push('/password-manager')"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
><Settings class="h-4 w-4" />
|
||||||
|
高级设置</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
|
<div class="space-y-4">
|
||||||
<Label class="text-sm font-medium whitespace-nowrap">设备 UUID:</Label>
|
<!-- UUID Display -->
|
||||||
<code class="flex-1 text-sm font-mono bg-background px-3 py-2 rounded border">
|
<div class="relative group">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-lg blur-xl group-hover:blur-2xl transition-all duration-300 opacity-50" />
|
||||||
|
<div class="relative flex items-center gap-2 p-4 bg-gradient-to-r from-muted/80 to-muted/60 rounded-lg border">
|
||||||
|
<code class="flex-1 text-sm font-mono tracking-wider select-all">
|
||||||
{{ deviceUuid }}
|
{{ deviceUuid }}
|
||||||
</code>
|
</code>
|
||||||
|
<div class="flex gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="icon"
|
||||||
|
class="h-8 w-8"
|
||||||
@click="copyToClipboard(deviceUuid, 'uuid')"
|
@click="copyToClipboard(deviceUuid, 'uuid')"
|
||||||
|
title="复制设备标识"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="copied === 'uuid'" class="h-4 w-4 text-green-500" />
|
<CheckCircle2 v-if="copied === 'uuid'" class="h-4 w-4 text-green-500 animate-in zoom-in-50" />
|
||||||
<Copy v-else class="h-4 w-4" />
|
<Copy v-else class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats -->
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary">{{ groupedByApp.length }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">应用数</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary">{{ tokens.length }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground">令牌数</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||||
|
<div class="text-2xl font-bold text-primary">
|
||||||
|
{{ hasPassword ? '安全' : '未设置' }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-muted-foreground">安全状态</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Hint (if exists) -->
|
||||||
|
<div v-if="hasPassword && passwordHint" class="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<Info class="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">密码提示</p>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">{{ passwordHint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@ -332,14 +676,19 @@ onMounted(() => {
|
|||||||
placeholder="为此授权添加备注"
|
placeholder="为此授权添加备注"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="hasPassword" class="space-y-2">
|
<div v-if="hasPassword">
|
||||||
<Label for="password">设备密码</Label>
|
<PasswordInput
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
v-model="authPassword"
|
v-model="authPassword"
|
||||||
type="text"
|
label="设备密码"
|
||||||
placeholder="输入设备密码"
|
placeholder="输入设备密码"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
:required="!accountStore.isAuthenticated"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
||||||
|
已登录绑定账户,无需输入密码
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
@ -359,20 +708,47 @@ onMounted(() => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>撤销授权</DialogTitle>
|
<DialogTitle>撤销授权</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
确定要撤销此令牌的授权吗?此操作无法撤销。
|
确定要撤销此令牌的授权吗?此操作无法撤销。{{selectedToken}}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div v-if="selectedToken" class="py-4">
|
<div v-if="selectedToken" class="py-4 space-y-4">
|
||||||
<div class="p-4 bg-muted rounded-lg space-y-2">
|
<div class="p-4 bg-muted rounded-lg space-y-2">
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">应用: </span>
|
<span class="font-medium">应用: </span>
|
||||||
{{ selectedToken.app.name }}
|
{{ selectedToken.appName }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<span class="font-medium">令牌: </span>
|
<span class="font-medium">令牌: </span>
|
||||||
<code class="text-xs">{{ selectedToken.token.slice(0, 16) }}...</code>
|
<code class="text-xs">{{ selectedToken.token.slice(0, 16) }}...</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 如果没有登录账户且设备有密码,显示密码输入框 -->
|
||||||
|
<div v-if="!accountStore.isAuthenticated && hasPassword" class="space-y-2">
|
||||||
|
<Label for="revoke-password">设备密码</Label>
|
||||||
|
<PasswordInput
|
||||||
|
id="revoke-password"
|
||||||
|
v-model="revokePassword"
|
||||||
|
placeholder="请输入设备密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 显示当前认证状态 -->
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
<div v-if="accountStore.isAuthenticated" class="flex items-center gap-2 text-green-600">
|
||||||
|
<CheckCircle2 class="h-4 w-4" />
|
||||||
|
已登录账户,无需输入密码
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!hasPassword" class="flex items-center gap-2 text-blue-600">
|
||||||
|
<Info class="h-4 w-4" />
|
||||||
|
设备未设置密码
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex items-center gap-2 text-orange-600">
|
||||||
|
<Lock class="h-4 w-4" />
|
||||||
|
需要验证设备密码
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="showRevokeDialog = false">
|
<Button variant="outline" @click="showRevokeDialog = false">
|
||||||
@ -386,39 +762,6 @@ onMounted(() => {
|
|||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
<Dialog v-model:open="showUuidDialog">
|
|
||||||
<DialogContent>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>更换设备 UUID</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
输入新的 UUID 或留空以生成随机 UUID
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div class="space-y-4 py-4">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="newUuid">新 UUID(可选)</Label>
|
|
||||||
<Input
|
|
||||||
id="newUuid"
|
|
||||||
v-model="newUuid"
|
|
||||||
placeholder="留空自动生成"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="text-sm text-muted-foreground bg-amber-500/10 border border-amber-500/20 rounded-lg p-3">
|
|
||||||
<strong>警告:</strong> 更换 UUID 后,所有现有授权将失效
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" @click="showUuidDialog = false">
|
|
||||||
取消
|
|
||||||
</Button>
|
|
||||||
<Button @click="updateUuid">
|
|
||||||
确认更换
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
|
||||||
<Dialog v-model:open="showPasswordDialog">
|
<Dialog v-model:open="showPasswordDialog">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -428,22 +771,35 @@ onMounted(() => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-4 py-4">
|
||||||
<div v-if="hasPassword" class="space-y-2">
|
<div v-if="hasPassword && !accountStore.isAuthenticated">
|
||||||
<Label for="currentPassword">当前密码</Label>
|
<PasswordInput
|
||||||
<Input
|
|
||||||
id="currentPassword"
|
|
||||||
v-model="currentPassword"
|
v-model="currentPassword"
|
||||||
type="password"
|
label="当前密码"
|
||||||
placeholder="输入当前密码"
|
placeholder="输入当前密码"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div v-if="accountStore.isAuthenticated && hasPassword" class="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||||
<Label for="newPassword">新密码</Label>
|
<div class="flex items-start gap-2">
|
||||||
<Input
|
<Info class="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
id="newPassword"
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100">账户已登录</p>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">您已登录绑定的账户,无需输入当前密码</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
v-model="newPassword"
|
v-model="newPassword"
|
||||||
type="password"
|
label="新密码"
|
||||||
placeholder="输入新密码"
|
placeholder="输入新密码"
|
||||||
|
:show-hint="false"
|
||||||
|
:show-strength="true"
|
||||||
|
:min-length="8"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -458,5 +814,30 @@ onMounted(() => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 登录弹框 -->
|
||||||
|
<LoginDialog
|
||||||
|
v-model="showLoginDialog"
|
||||||
|
:on-success="handleLoginSuccess"
|
||||||
|
@update:modelValue="val => {
|
||||||
|
if (!val && deviceRequired.value) {
|
||||||
|
showRegisterDialog.value = true
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
/> <!-- 设备注册弹框 -->
|
||||||
|
<DeviceRegisterDialog
|
||||||
|
v-model="showRegisterDialog"
|
||||||
|
@confirm="updateUuid"
|
||||||
|
:required="deviceRequired"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 设备名称编辑弹框 -->
|
||||||
|
<EditDeviceNameDialog
|
||||||
|
v-model="showEditNameDialog"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:current-name="deviceInfo?.deviceName || ''"
|
||||||
|
:has-password="hasPassword"
|
||||||
|
@success="handleDeviceNameUpdated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
615
kv-admin/src/pages/kv-manager.vue
Normal file
615
kv-admin/src/pages/kv-manager.vue
Normal file
@ -0,0 +1,615 @@
|
|||||||
|
<template>
|
||||||
|
<div class="kv-manager-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>键值管理器</h1>
|
||||||
|
<p class="subtitle">使用 Token 访问和管理键值对数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token 输入区 -->
|
||||||
|
<div v-if="!isTokenSet" class="token-input-section">
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
v-model="tokenInput"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入应用 Token"
|
||||||
|
@keypress.enter="handleSetToken"
|
||||||
|
class="token-input"
|
||||||
|
/>
|
||||||
|
<button @click="handleSetToken" :disabled="!tokenInput.trim()" class="btn-primary">
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="tokenError" class="error-msg">{{ tokenError }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 键值列表区 -->
|
||||||
|
<div v-else class="kv-list-section">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-group">
|
||||||
|
<input
|
||||||
|
v-model="searchPattern"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索键名(支持通配符 *)"
|
||||||
|
@keypress.enter="loadKeys"
|
||||||
|
class="search-input"
|
||||||
|
/>
|
||||||
|
<button @click="loadKeys" class="btn-secondary">搜索</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button @click="showAddDialog = true" class="btn-primary">添加键值</button>
|
||||||
|
<button @click="handleLogout" class="btn-secondary">退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="loading" class="loading">加载中...</div>
|
||||||
|
|
||||||
|
<!-- 错误提示 -->
|
||||||
|
<div v-if="error" class="error-banner">{{ error }}</div>
|
||||||
|
|
||||||
|
<!-- 键值对表格 -->
|
||||||
|
<div v-if="!loading && keys.length > 0" class="table-container">
|
||||||
|
<table class="kv-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>键名</th>
|
||||||
|
<th>值</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="key in paginatedKeys" :key="key">
|
||||||
|
<td class="key-cell">{{ key }}</td>
|
||||||
|
<td class="value-cell">
|
||||||
|
<div v-if="loadingValues[key]" class="value-loading">加载中...</div>
|
||||||
|
<div v-else-if="values[key] !== undefined" class="value-content">
|
||||||
|
<pre>{{ formatValue(values[key]) }}</pre>
|
||||||
|
</div>
|
||||||
|
<button v-else @click="loadValue(key)" class="btn-link">查看</button>
|
||||||
|
</td>
|
||||||
|
<td class="actions-cell">
|
||||||
|
<button @click="editKey(key)" class="btn-edit">编辑</button>
|
||||||
|
<button @click="deleteKey(key)" class="btn-delete">删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 分页控件 -->
|
||||||
|
<div class="pagination">
|
||||||
|
<button
|
||||||
|
@click="currentPage--"
|
||||||
|
:disabled="currentPage === 1"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span class="page-info">
|
||||||
|
第 {{ currentPage }} / {{ totalPages }} 页(共 {{ keys.length }} 条)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="currentPage++"
|
||||||
|
:disabled="currentPage === totalPages"
|
||||||
|
class="btn-secondary"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="!loading && keys.length === 0" class="empty-state">
|
||||||
|
<p>暂无键值对数据</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加/编辑对话框 -->
|
||||||
|
<div v-if="showAddDialog || editingKey" class="dialog-overlay" @click.self="closeDialog">
|
||||||
|
<div class="dialog">
|
||||||
|
<h2>{{ editingKey ? '编辑键值' : '添加键值' }}</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>键名</label>
|
||||||
|
<input
|
||||||
|
v-model="dialogKey"
|
||||||
|
type="text"
|
||||||
|
:disabled="!!editingKey"
|
||||||
|
placeholder="请输入键名"
|
||||||
|
class="form-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>值(JSON 格式)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="dialogValue"
|
||||||
|
rows="8"
|
||||||
|
placeholder='例如: "字符串" 或 {"key": "value"}'
|
||||||
|
class="form-textarea"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<p v-if="dialogError" class="error-msg">{{ dialogError }}</p>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button @click="closeDialog" class="btn-secondary">取消</button>
|
||||||
|
<button @click="saveKeyValue" class="btn-primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { apiClient } from '../lib/api'
|
||||||
|
|
||||||
|
const tokenInput = ref('')
|
||||||
|
const isTokenSet = ref(false)
|
||||||
|
const currentToken = ref('')
|
||||||
|
const tokenError = ref('')
|
||||||
|
|
||||||
|
const searchPattern = ref('*')
|
||||||
|
const keys = ref([])
|
||||||
|
const values = ref({})
|
||||||
|
const loadingValues = ref({})
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
|
||||||
|
const showAddDialog = ref(false)
|
||||||
|
const editingKey = ref('')
|
||||||
|
const dialogKey = ref('')
|
||||||
|
const dialogValue = ref('')
|
||||||
|
const dialogError = ref('')
|
||||||
|
|
||||||
|
const totalPages = computed(() => Math.ceil(keys.value.length / pageSize.value))
|
||||||
|
|
||||||
|
const paginatedKeys = computed(() => {
|
||||||
|
const start = (currentPage.value - 1) * pageSize.value
|
||||||
|
const end = start + pageSize.value
|
||||||
|
return keys.value.slice(start, end)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSetToken = async () => {
|
||||||
|
tokenError.value = ''
|
||||||
|
const token = tokenInput.value.trim()
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
tokenError.value = '请输入 Token'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 token 是否有效
|
||||||
|
try {
|
||||||
|
await apiClient.getKVKeys(token, '*')
|
||||||
|
currentToken.value = token
|
||||||
|
isTokenSet.value = true
|
||||||
|
loadKeys()
|
||||||
|
} catch (err) {
|
||||||
|
tokenError.value = '无效的 Token 或无权限访问'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadKeys = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getKVKeys(currentToken.value, searchPattern.value)
|
||||||
|
keys.value = response.keys || []
|
||||||
|
currentPage.value = 1
|
||||||
|
values.value = {}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err.message || '加载键名失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadValue = async (key) => {
|
||||||
|
loadingValues.value[key] = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getKVItem(currentToken.value, key)
|
||||||
|
values.value[key] = response.value
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `加载键 "${key}" 的值失败: ${err.message}`
|
||||||
|
} finally {
|
||||||
|
delete loadingValues.value[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatValue = (value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return JSON.stringify(value, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editKey = async (key) => {
|
||||||
|
editingKey.value = key
|
||||||
|
dialogKey.value = key
|
||||||
|
dialogError.value = ''
|
||||||
|
|
||||||
|
// 加载值
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getKVItem(currentToken.value, key)
|
||||||
|
dialogValue.value = formatValue(response.value)
|
||||||
|
} catch (err) {
|
||||||
|
dialogError.value = '加载值失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveKeyValue = async () => {
|
||||||
|
dialogError.value = ''
|
||||||
|
|
||||||
|
const key = dialogKey.value.trim()
|
||||||
|
const valueStr = dialogValue.value.trim()
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
dialogError.value = '请输入键名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueStr) {
|
||||||
|
dialogError.value = '请输入值'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 JSON
|
||||||
|
let value
|
||||||
|
try {
|
||||||
|
value = JSON.parse(valueStr)
|
||||||
|
} catch (err) {
|
||||||
|
dialogError.value = '值必须是有效的 JSON 格式'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.setKVItem(currentToken.value, key, value)
|
||||||
|
closeDialog()
|
||||||
|
loadKeys()
|
||||||
|
} catch (err) {
|
||||||
|
dialogError.value = err.message || '保存失败'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteKey = async (key) => {
|
||||||
|
if (!confirm(`确定要删除键 "${key}" 吗?`)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.deleteKVItem(currentToken.value, key)
|
||||||
|
loadKeys()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = `删除键 "${key}" 失败: ${err.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
showAddDialog.value = false
|
||||||
|
editingKey.value = ''
|
||||||
|
dialogKey.value = ''
|
||||||
|
dialogValue.value = ''
|
||||||
|
dialogError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (confirm('确定要退出吗?')) {
|
||||||
|
isTokenSet.value = false
|
||||||
|
currentToken.value = ''
|
||||||
|
tokenInput.value = ''
|
||||||
|
keys.value = []
|
||||||
|
values.value = {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kv-manager-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input-section {
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-list-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: none;
|
||||||
|
color: #007bff;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-msg {
|
||||||
|
color: #dc3545;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-table th,
|
||||||
|
.kv-table td {
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-cell {
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-content pre {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.value-loading {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-cell {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
min-width: 500px;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog h2 {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-textarea {
|
||||||
|
font-family: monospace;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
591
kv-admin/src/pages/password-manager.vue
Normal file
591
kv-admin/src/pages/password-manager.vue
Normal file
@ -0,0 +1,591 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { apiClient } from '@/lib/api'
|
||||||
|
import { deviceStore } from '@/lib/deviceStore'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import PasswordInput from '@/components/PasswordInput.vue'
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
Key,
|
||||||
|
Trash2,
|
||||||
|
Edit,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
ArrowLeft,
|
||||||
|
Lock,
|
||||||
|
Unlock,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
HelpCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const deviceUuid = ref('')
|
||||||
|
const deviceInfo = ref(null)
|
||||||
|
|
||||||
|
// 计算属性获取是否有密码
|
||||||
|
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||||
|
const passwordHint = ref('')
|
||||||
|
|
||||||
|
// Dialog states
|
||||||
|
const showChangePasswordDialog = ref(false)
|
||||||
|
const showDeletePasswordDialog = ref(false)
|
||||||
|
const showHintDialog = ref(false)
|
||||||
|
const showResetDeviceDialog = ref(false)
|
||||||
|
|
||||||
|
// Form data
|
||||||
|
const currentPassword = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const deleteConfirmPassword = ref('')
|
||||||
|
const newHint = ref('')
|
||||||
|
const hintPassword = ref('')
|
||||||
|
|
||||||
|
// UI states
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const successMessage = ref('')
|
||||||
|
const errorMessage = ref('')
|
||||||
|
|
||||||
|
// 加载设备信息
|
||||||
|
const loadDeviceInfo = async () => {
|
||||||
|
try {
|
||||||
|
const info = await apiClient.getDeviceInfo(deviceUuid.value)
|
||||||
|
deviceInfo.value = info
|
||||||
|
if (info.passwordHint) {
|
||||||
|
passwordHint.value = info.passwordHint
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load device info:', error)
|
||||||
|
deviceInfo.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载密码提示
|
||||||
|
const loadPasswordHint = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.getPasswordHint(deviceUuid.value)
|
||||||
|
if (data.hint) {
|
||||||
|
passwordHint.value = data.hint
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to load password hint')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改密码
|
||||||
|
const changePassword = async () => {
|
||||||
|
if (!newPassword.value || !currentPassword.value) {
|
||||||
|
errorMessage.value = '请填写所有必填字段'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = '新密码与确认密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value === currentPassword.value) {
|
||||||
|
errorMessage.value = '新密码不能与当前密码相同'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.setDevicePassword(deviceUuid.value, {
|
||||||
|
currentPassword: currentPassword.value,
|
||||||
|
newPassword: newPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
|
successMessage.value = '密码修改成功!'
|
||||||
|
showChangePasswordDialog.value = false
|
||||||
|
currentPassword.value = ''
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || '密码修改失败'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除密码
|
||||||
|
const deletePassword = async () => {
|
||||||
|
if (!deleteConfirmPassword.value) {
|
||||||
|
errorMessage.value = '请输入当前密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.deleteDevicePassword(deviceUuid.value, deleteConfirmPassword.value)
|
||||||
|
|
||||||
|
// 重新加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
|
successMessage.value = '密码已删除!'
|
||||||
|
showDeletePasswordDialog.value = false
|
||||||
|
deleteConfirmPassword.value = ''
|
||||||
|
passwordHint.value = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || '密码删除失败'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置密码提示
|
||||||
|
const setPasswordHint = async () => {
|
||||||
|
if (!hintPassword.value) {
|
||||||
|
errorMessage.value = '请输入当前密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiClient.setPasswordHint(deviceUuid.value, newHint.value, hintPassword.value)
|
||||||
|
|
||||||
|
passwordHint.value = newHint.value
|
||||||
|
successMessage.value = '密码提示已更新!'
|
||||||
|
showHintDialog.value = false
|
||||||
|
newHint.value = ''
|
||||||
|
hintPassword.value = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || '设置密码提示失败'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置新密码(无密码时)
|
||||||
|
const setNewPassword = async () => {
|
||||||
|
if (!newPassword.value) {
|
||||||
|
errorMessage.value = '请输入新密码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.value !== confirmPassword.value) {
|
||||||
|
errorMessage.value = '密码与确认密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiClient.setDevicePassword(deviceUuid.value, {
|
||||||
|
newPassword: newPassword.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
|
successMessage.value = '密码设置成功!'
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmPassword.value = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
} catch (error) {
|
||||||
|
errorMessage.value = error.message || '密码设置失败'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回首页
|
||||||
|
const goBack = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备重置成功后的回调
|
||||||
|
const handleDeviceReset = () => {
|
||||||
|
deviceUuid.value = deviceStore.getDeviceUuid()
|
||||||
|
loadDeviceInfo()
|
||||||
|
successMessage.value = '设备已重置!'
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
successMessage.value = ''
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
deviceUuid.value = deviceStore.getOrGenerate()
|
||||||
|
|
||||||
|
// 加载设备信息
|
||||||
|
await loadDeviceInfo()
|
||||||
|
|
||||||
|
// 如果有密码但密码提示不存在,单独加载密码提示
|
||||||
|
if (hasPassword.value && !passwordHint.value) {
|
||||||
|
await loadPasswordHint()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
|
<div class="container mx-auto px-6 py-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="icon" @click="goBack">
|
||||||
|
<ArrowLeft class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold">高级设置</h1>
|
||||||
|
<p class="text-sm text-muted-foreground">设备管理与安全设置</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container mx-auto px-6 py-8 max-w-4xl">
|
||||||
|
<!-- Success/Error Messages -->
|
||||||
|
<div v-if="successMessage" class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
|
||||||
|
<CheckCircle2 class="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||||
|
<span class="text-green-800 dark:text-green-200">{{ successMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMessage" class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 p-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||||
|
<span class="text-red-800 dark:text-red-200">{{ errorMessage }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Status Card -->
|
||||||
|
<Card class="mb-6 border-2">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="rounded-lg bg-primary/10 p-2">
|
||||||
|
<Shield class="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>密码状态</CardTitle>
|
||||||
|
<CardDescription>当前设备的密码保护状态</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="hasPassword ? 'default' : 'secondary'" class="text-sm">
|
||||||
|
<Lock v-if="hasPassword" class="h-3 w-3 mr-1" />
|
||||||
|
<Unlock v-else class="h-3 w-3 mr-1" />
|
||||||
|
{{ hasPassword ? '已设置密码' : '未设置密码' }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Device UUID -->
|
||||||
|
<div class="p-4 rounded-lg bg-muted/50">
|
||||||
|
<Label class="text-xs text-muted-foreground">设备 UUID</Label>
|
||||||
|
<code class="block mt-1 text-sm font-mono break-all">{{ deviceUuid }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Password Hint -->
|
||||||
|
<div v-if="hasPassword && passwordHint" class="p-4 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<HelpCircle class="h-5 w-5 text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-medium text-blue-900 dark:text-blue-100 mb-1">密码提示</p>
|
||||||
|
<p class="text-sm text-blue-700 dark:text-blue-300">{{ passwordHint }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
|
<Button
|
||||||
|
v-if="!hasPassword"
|
||||||
|
@click="showChangePasswordDialog = true"
|
||||||
|
class="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Key class="h-4 w-4 mr-2" />
|
||||||
|
设置密码
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="hasPassword"
|
||||||
|
@click="showChangePasswordDialog = true"
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Edit class="h-4 w-4 mr-2" />
|
||||||
|
修改密码
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="hasPassword"
|
||||||
|
@click="showHintDialog = true"
|
||||||
|
variant="outline"
|
||||||
|
class="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Info class="h-4 w-4 mr-2" />
|
||||||
|
{{ passwordHint ? '修改提示' : '设置提示' }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
v-if="hasPassword"
|
||||||
|
@click="showDeletePasswordDialog = true"
|
||||||
|
variant="destructive"
|
||||||
|
class="flex-1 sm:flex-none"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 mr-2" />
|
||||||
|
删除密码
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 设备管理部分 -->
|
||||||
|
<h2 class="text-xl font-semibold mt-12 mb-4">设备管理</h2>
|
||||||
|
|
||||||
|
<!-- 设备重置卡片 -->
|
||||||
|
<Card class="mb-6 border-red-200 dark:border-red-900">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-lg flex items-center gap-2">
|
||||||
|
<Smartphone class="h-5 w-5 text-red-500" />
|
||||||
|
重置设备
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
重置或换新设备标识。此操作无法撤销,您将失去当前设备的所有授权。
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-red-900 dark:text-red-100">警告:此操作不可逆</p>
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300 mt-1">
|
||||||
|
重置设备后,您将获得全新的设备标识,现有的所有授权将被撤销,无法恢复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
class="w-full flex items-center justify-center gap-2"
|
||||||
|
@click="showResetDeviceDialog = true"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4" />
|
||||||
|
重置设备
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Change/Set Password Dialog -->
|
||||||
|
<Dialog v-model:open="showChangePasswordDialog">
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ hasPassword ? '修改密码' : '设置密码' }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{{ hasPassword ? '请输入当前密码和新密码' : '为您的设备设置一个安全的密码' }}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<!-- Current Password (only when changing) -->
|
||||||
|
<div v-if="hasPassword">
|
||||||
|
<PasswordInput
|
||||||
|
v-model="currentPassword"
|
||||||
|
label="当前密码"
|
||||||
|
placeholder="输入当前密码"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New Password -->
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="newPassword"
|
||||||
|
label="新密码"
|
||||||
|
placeholder="输入新密码"
|
||||||
|
:show-hint="false"
|
||||||
|
:show-strength="true"
|
||||||
|
:min-length="8"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirm Password -->
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="confirmPassword"
|
||||||
|
label="确认新密码"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
:show-hint="false"
|
||||||
|
:show-strength="false"
|
||||||
|
:confirm-password="newPassword"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showChangePasswordDialog = false" :disabled="isLoading">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="hasPassword ? changePassword() : setNewPassword()"
|
||||||
|
:disabled="isLoading || !newPassword || newPassword !== confirmPassword || (hasPassword && !currentPassword)"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '处理中...' : (hasPassword ? '修改密码' : '设置密码') }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Password Dialog -->
|
||||||
|
<Dialog v-model:open="showDeletePasswordDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>删除密码</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
删除密码后,您的设备将不再受密码保护。此操作无法撤销。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div class="p-4 rounded-lg bg-yellow-50 dark:bg-yellow-950/20 border border-yellow-200 dark:border-yellow-900">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||||
|
<div class="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p class="font-medium mb-1">警告</p>
|
||||||
|
<p>删除密码后,任何拥有您设备 UUID 的人都可以管理您的授权应用。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="deleteConfirmPassword"
|
||||||
|
label="输入当前密码以确认"
|
||||||
|
placeholder="输入当前密码"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showDeletePasswordDialog = false" :disabled="isLoading">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
@click="deletePassword"
|
||||||
|
:disabled="isLoading || !deleteConfirmPassword"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '删除中...' : '确认删除' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Set/Update Hint Dialog -->
|
||||||
|
<Dialog v-model:open="showHintDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{{ passwordHint ? '修改密码提示' : '设置密码提示' }}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
密码提示可以帮助您在忘记密码时回忆起密码
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div class="space-y-4 py-4">
|
||||||
|
<div v-if="passwordHint" class="p-3 rounded-lg bg-muted">
|
||||||
|
<Label class="text-xs text-muted-foreground">当前提示</Label>
|
||||||
|
<p class="text-sm mt-1">{{ passwordHint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="new-hint">新的密码提示</Label>
|
||||||
|
<Input
|
||||||
|
id="new-hint"
|
||||||
|
v-model="newHint"
|
||||||
|
placeholder="例如:我的宠物名字加生日"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
提示不应包含密码本身,而是能帮助您回忆密码的信息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<PasswordInput
|
||||||
|
v-model="hintPassword"
|
||||||
|
label="当前密码"
|
||||||
|
placeholder="输入当前密码以确认"
|
||||||
|
:device-uuid="deviceUuid"
|
||||||
|
:show-hint="true"
|
||||||
|
:show-strength="false"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showHintDialog = false" :disabled="isLoading">
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
@click="setPasswordHint"
|
||||||
|
:disabled="isLoading || !hintPassword"
|
||||||
|
>
|
||||||
|
{{ isLoading ? '保存中...' : '保存提示' }}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 设备重置弹框 -->
|
||||||
|
<DeviceRegisterDialog
|
||||||
|
v-model="showResetDeviceDialog"
|
||||||
|
@confirm="handleDeviceReset"
|
||||||
|
@update:modelValue="val => showResetDeviceDialog = val"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
116
kv-admin/src/stores/account.js
Normal file
116
kv-admin/src/stores/account.js
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { apiClient } from '@/lib/api'
|
||||||
|
|
||||||
|
export const useAccountStore = defineStore('account', () => {
|
||||||
|
// 状态
|
||||||
|
const token = ref(localStorage.getItem('auth_token') || null)
|
||||||
|
const profile = ref(null)
|
||||||
|
const devices = ref([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
const userName = computed(() => profile.value?.name || '')
|
||||||
|
const userAvatar = computed(() => profile.value?.avatarUrl || '')
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const setToken = (newToken) => {
|
||||||
|
token.value = newToken
|
||||||
|
if (newToken) {
|
||||||
|
localStorage.setItem('auth_token', newToken)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_provider')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getAccountProfile(token.value)
|
||||||
|
profile.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile:', error)
|
||||||
|
// Token可能无效,清除
|
||||||
|
if (error.message.includes('401')) {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getAccountDevices(token.value)
|
||||||
|
devices.value = response.data || []
|
||||||
|
return devices.value
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load devices:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindDevice = async (deviceUuid) => {
|
||||||
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
|
const response = await apiClient.bindDevice(token.value, deviceUuid)
|
||||||
|
// 重新加载设备列表
|
||||||
|
await loadDevices()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindDevice = async (deviceUuid) => {
|
||||||
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
|
const response = await apiClient.unbindDevice(token.value, deviceUuid)
|
||||||
|
// 重新加载设备列表
|
||||||
|
await loadDevices()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (authToken) => {
|
||||||
|
setToken(authToken)
|
||||||
|
await loadProfile()
|
||||||
|
await loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
profile.value = null
|
||||||
|
devices.value = []
|
||||||
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_provider')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化时加载用户信息
|
||||||
|
if (token.value) {
|
||||||
|
loadProfile()
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
token,
|
||||||
|
profile,
|
||||||
|
devices,
|
||||||
|
loading,
|
||||||
|
// 计算属性
|
||||||
|
isAuthenticated,
|
||||||
|
userName,
|
||||||
|
userAvatar,
|
||||||
|
// 方法
|
||||||
|
setToken,
|
||||||
|
loadProfile,
|
||||||
|
loadDevices,
|
||||||
|
bindDevice,
|
||||||
|
unbindDevice,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
}
|
||||||
|
})
|
||||||
@ -1,521 +0,0 @@
|
|||||||
/**
|
|
||||||
* Token认证中间件系统
|
|
||||||
*
|
|
||||||
* 本系统完全基于Token进行认证,不再支持UUID+密码的认证方式。
|
|
||||||
*
|
|
||||||
* ## 推荐使用的认证中间件:
|
|
||||||
*
|
|
||||||
* ### 1. 纯Token认证中间件(推荐)
|
|
||||||
* - `tokenOnlyAuthMiddleware`: 完整的Token认证,要求设备匹配
|
|
||||||
* - `tokenOnlyReadAuthMiddleware`: Token读取权限认证
|
|
||||||
* - `tokenOnlyWriteAuthMiddleware`: Token写入权限认证
|
|
||||||
* - `appTokenAuthMiddleware`: 应用Token认证,不要求设备匹配
|
|
||||||
*
|
|
||||||
* ### 2. 应用权限认证中间件
|
|
||||||
* - `appReadAuthMiddleware`: 应用读取权限(Token + 权限前缀检查)
|
|
||||||
* - `appWriteAuthMiddleware`: 应用写入权限(Token + 权限前缀检查)
|
|
||||||
* - `appListAuthMiddleware`: 应用列表权限(Token + 键过滤)
|
|
||||||
*
|
|
||||||
* ## Token获取方式:
|
|
||||||
* Token可通过以下三种方式提供:
|
|
||||||
* 1. HTTP Header: `x-app-token: your-token`
|
|
||||||
* 2. 查询参数: `?apptoken=your-token`
|
|
||||||
* 3. 请求体: `{\"apptoken\": \"your-token\"}`
|
|
||||||
*
|
|
||||||
* ## 认证成功响应:
|
|
||||||
* 认证成功后,中间件会在 `res.locals` 中设置:
|
|
||||||
* - `device`: 设备信息
|
|
||||||
* - `appInstall`: 应用安装信息
|
|
||||||
* - `app`: 应用信息
|
|
||||||
* - `filterKeys`: 键过滤函数(仅限应用权限中间件)
|
|
||||||
*
|
|
||||||
* ## 认证失败响应:
|
|
||||||
* - 401: Token无效或不存在
|
|
||||||
* - 403: 权限不足或设备不匹配
|
|
||||||
* - 404: 设备或应用不存在
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import errors from "../utils/errors.js";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
// 全局可读键列表
|
|
||||||
const GLOBAL_READABLE_KEYS = [
|
|
||||||
"_info",
|
|
||||||
"_check",
|
|
||||||
"_hint",
|
|
||||||
"_keys",
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查站点密钥
|
|
||||||
*/
|
|
||||||
export const checkSiteKey = (req, res, next) => {
|
|
||||||
const siteKey = req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
|
|
||||||
const expectedSiteKey = process.env.SITE_KEY;
|
|
||||||
|
|
||||||
if (expectedSiteKey && siteKey !== expectedSiteKey) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的站点密钥",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过Token获取设备信息
|
|
||||||
* @param {string} token - 应用安装Token
|
|
||||||
* @returns {Promise<Object|null>} 设备信息或null
|
|
||||||
*/
|
|
||||||
export const getDeviceByToken = async (token) => {
|
|
||||||
if (!token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
|
||||||
where: { token },
|
|
||||||
include: {
|
|
||||||
device: true,
|
|
||||||
app: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return appInstall;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("获取设备信息时出错:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从请求中提取Token
|
|
||||||
* @param {Object} req - Express请求对象
|
|
||||||
* @returns {string|null} Token或null
|
|
||||||
*/
|
|
||||||
const extractToken = (req) => {
|
|
||||||
// 优先级:Header > Query > Body
|
|
||||||
return (
|
|
||||||
req.headers["x-app-token"] ||
|
|
||||||
req.query.apptoken ||
|
|
||||||
req.body?.apptoken ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备信息中间件(仅检查设备存在性,不进行认证)
|
|
||||||
*/
|
|
||||||
export const deviceInfoMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const { deviceUuid,namespace } = req.params;
|
|
||||||
|
|
||||||
if (!deviceUuid&&!namespace) {
|
|
||||||
return res.status(400).json({
|
|
||||||
statusCode: 400,
|
|
||||||
message: "缺少命名空间参数",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找设备
|
|
||||||
const device = await prisma.device.findUnique({
|
|
||||||
where: { uuid: deviceUuid||namespace },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return res.status(404).json({
|
|
||||||
statusCode: 404,
|
|
||||||
message: "设备不存在",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.device = device;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("设备信息中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 纯Token认证中间件(推荐使用)
|
|
||||||
* 要求Token存在且对应的设备与请求的命名空间匹配
|
|
||||||
*/
|
|
||||||
export const tokenOnlyAuthMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
const { namespace } = req.params;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "缺少认证Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await getDeviceByToken(token);
|
|
||||||
if (!appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证设备匹配
|
|
||||||
if (namespace && appInstall.device.uuid !== namespace) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: "Token对应的设备与请求的命名空间不匹配",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Token认证中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 纯Token读取认证中间件
|
|
||||||
*/
|
|
||||||
export const tokenOnlyReadAuthMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
const { namespace } = req.params;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "缺少认证Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await getDeviceByToken(token);
|
|
||||||
if (!appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证设备匹配
|
|
||||||
if (namespace && appInstall.device.uuid !== namespace) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: "Token对应的设备与请求的命名空间不匹配",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查读取权限
|
|
||||||
if (!appInstall.permissions?.read) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: "无读取权限",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Token读取认证中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 纯Token写入认证中间件
|
|
||||||
*/
|
|
||||||
export const tokenOnlyWriteAuthMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
const { namespace } = req.params;
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "缺少认证Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await getDeviceByToken(token);
|
|
||||||
if (!appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证设备匹配
|
|
||||||
if (namespace && appInstall.device.uuid !== namespace) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: "Token对应的设备与请求的命名空间不匹配",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查写入权限
|
|
||||||
if (!appInstall.permissions?.write) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: "无写入权限",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Token写入认证中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用Token认证中间件
|
|
||||||
* 不要求设备匹配,适用于应用级别的操作
|
|
||||||
*/
|
|
||||||
export const appTokenAuthMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "缺少应用Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await getDeviceByToken(token);
|
|
||||||
if (!appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的应用Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("应用Token认证中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用权限前缀检查中间件
|
|
||||||
*/
|
|
||||||
export const appPrefixAuthMiddleware = (req, res, next) => {
|
|
||||||
const { key } = req.params;
|
|
||||||
const app = res.locals.app;
|
|
||||||
const appInstall = res.locals.appInstall;
|
|
||||||
|
|
||||||
if (!app || !appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "未认证的应用",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否为全局可读键
|
|
||||||
if (GLOBAL_READABLE_KEYS.includes(key)) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查权限前缀
|
|
||||||
const permissionPrefix = app.permissionPrefix;
|
|
||||||
if (!key.startsWith(permissionPrefix + ".")) {
|
|
||||||
// 检查特殊权限
|
|
||||||
const specialPermissions = appInstall.specialPermissions || [];
|
|
||||||
const hasSpecialPermission = specialPermissions.some(permission =>
|
|
||||||
key.startsWith(permission + ".") || key === permission
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasSpecialPermission) {
|
|
||||||
return res.status(403).json({
|
|
||||||
statusCode: 403,
|
|
||||||
message: `无权限访问键 '${key}'。需要权限前缀 '${permissionPrefix}.' 或特殊权限。`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用读取权限中间件
|
|
||||||
* 结合Token认证和权限前缀检查
|
|
||||||
*/
|
|
||||||
export const appReadAuthMiddleware = async (req, res, next) => {
|
|
||||||
// 先进行Token认证
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
tokenOnlyReadAuthMiddleware(req, res, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
}).catch(() => {
|
|
||||||
return; // 错误已经在tokenOnlyReadAuthMiddleware中处理
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果Token认证失败,直接返回
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进行权限前缀检查
|
|
||||||
appPrefixAuthMiddleware(req, res, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用写入权限中间件
|
|
||||||
* 结合Token认证和权限前缀检查
|
|
||||||
*/
|
|
||||||
export const appWriteAuthMiddleware = async (req, res, next) => {
|
|
||||||
// 先进行Token认证
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
tokenOnlyWriteAuthMiddleware(req, res, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
}).catch(() => {
|
|
||||||
return; // 错误已经在tokenOnlyWriteAuthMiddleware中处理
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果Token认证失败,直接返回
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 进行权限前缀检查
|
|
||||||
appPrefixAuthMiddleware(req, res, next);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 应用列表权限中间件
|
|
||||||
* 用于过滤键列表,只显示应用有权限访问的键
|
|
||||||
*/
|
|
||||||
export const appListAuthMiddleware = async (req, res, next) => {
|
|
||||||
// 先进行Token认证
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
tokenOnlyReadAuthMiddleware(req, res, (err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve();
|
|
||||||
});
|
|
||||||
}).catch(() => {
|
|
||||||
return; // 错误已经在tokenOnlyReadAuthMiddleware中处理
|
|
||||||
});
|
|
||||||
|
|
||||||
// 如果Token认证失败,直接返回
|
|
||||||
if (res.headersSent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = res.locals.app;
|
|
||||||
const appInstall = res.locals.appInstall;
|
|
||||||
|
|
||||||
if (app && appInstall) {
|
|
||||||
// 设置键过滤函数
|
|
||||||
res.locals.filterKeys = (keys) => {
|
|
||||||
const permissionPrefix = app.permissionPrefix;
|
|
||||||
const specialPermissions = appInstall.specialPermissions || [];
|
|
||||||
|
|
||||||
return keys.filter(key => {
|
|
||||||
// 全局可读键
|
|
||||||
if (GLOBAL_READABLE_KEYS.includes(key)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限前缀匹配
|
|
||||||
if (key.startsWith(permissionPrefix + ".")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 特殊权限匹配
|
|
||||||
return specialPermissions.some(permission =>
|
|
||||||
key.startsWith(permission + ".") || key === permission
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token认证中间件,并将设备UUID注入为命名空间
|
|
||||||
*
|
|
||||||
* 这个中间件专门用于处理那些URL中不包含 `:namespace` 参数的路由。
|
|
||||||
* 它会从Token中解析出设备信息,然后将设备的UUID(即命名空间)
|
|
||||||
* 注入到 `req.params.namespace` 中。
|
|
||||||
*
|
|
||||||
* 这使得后续的中间件(如权限检查中间件)和路由处理器可以统一
|
|
||||||
* 从 `req.params.namespace` 获取命名空间,而无需关心它最初是
|
|
||||||
* 来自URL还是来自Token。
|
|
||||||
*
|
|
||||||
* 认证成功后,除了注入 `req.params.namespace`,还会在 `res.locals` 中设置:
|
|
||||||
* - `device`: 设备信息
|
|
||||||
* - `appInstall`: 应用安装信息
|
|
||||||
* - `app`: 应用信息
|
|
||||||
*/
|
|
||||||
export const tokenAuthMiddleware = async (req, res, next) => {
|
|
||||||
try {
|
|
||||||
const token = extractToken(req);
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "缺少认证Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await getDeviceByToken(token);
|
|
||||||
if (!appInstall) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "无效的Token",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 核心逻辑:将设备UUID注入req.params.namespace
|
|
||||||
req.params.namespace = appInstall.device.uuid;
|
|
||||||
|
|
||||||
// 存储认证信息以供后续使用
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Token认证与命名空间注入中间件错误:", error);
|
|
||||||
return res.status(500).json({
|
|
||||||
statusCode: 500,
|
|
||||||
message: "服务器内部错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -92,6 +92,8 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
|
|||||||
* 从req.body.password获取密码
|
* 从req.body.password获取密码
|
||||||
* 如果设备有密码但未提供或密码错误,则返回401错误
|
* 如果设备有密码但未提供或密码错误,则返回401错误
|
||||||
*
|
*
|
||||||
|
* 特殊规则:如果设备绑定了账户,且req.account存在且匹配,则跳过密码验证
|
||||||
|
*
|
||||||
* 使用方式:
|
* 使用方式:
|
||||||
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
|
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
|
||||||
*/
|
*/
|
||||||
@ -103,6 +105,11 @@ export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
|
|||||||
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
return next(errors.createError(500, "设备信息未加载,请先使用deviceMiddleware"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
|
||||||
|
if (device.accountId && req.account && req.account.id === device.accountId) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
// 如果设备有密码,验证密码
|
// 如果设备有密码,验证密码
|
||||||
if (device.password) {
|
if (device.password) {
|
||||||
if (!password) {
|
if (!password) {
|
||||||
|
|||||||
54
middleware/jwt-auth.js
Normal file
54
middleware/jwt-auth.js
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* 纯账户JWT认证中间件
|
||||||
|
*
|
||||||
|
* 只验证账户JWT是否正确,不需要设备上下文
|
||||||
|
* 适用于只需要账户验证的接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { verifyToken } from "../utils/jwt.js";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 纯JWT认证中间件
|
||||||
|
* 只验证Bearer token并将账户信息存储到res.locals
|
||||||
|
*/
|
||||||
|
export const jwtAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||||
|
return next(errors.createError(401, "需要提供有效的JWT token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
// 验证JWT token
|
||||||
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
|
// 从数据库获取账户信息
|
||||||
|
const account = await prisma.account.findUnique({
|
||||||
|
where: { id: decoded.accountId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return next(errors.createError(401, "账户不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将账户信息存储到res.locals
|
||||||
|
res.locals.account = account;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'JsonWebTokenError') {
|
||||||
|
return next(errors.createError(401, "无效的JWT token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.name === 'TokenExpiredError') {
|
||||||
|
return next(errors.createError(401, "JWT token已过期"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(errors.createError(500, "认证过程出错"));
|
||||||
|
}
|
||||||
|
};
|
||||||
66
middleware/kvTokenAuth.js
Normal file
66
middleware/kvTokenAuth.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* KV接口专用Token认证中间件
|
||||||
|
*
|
||||||
|
* 仅验证app token,设置设备和应用信息到res.locals
|
||||||
|
* 适用于所有KV相关的接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KV Token认证中间件
|
||||||
|
* 从请求中提取token(支持多种方式),验证后将设备和应用信息注入到res.locals
|
||||||
|
*/
|
||||||
|
export const kvTokenAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// 从多种途径获取token
|
||||||
|
const token = extractToken(req);
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return next(errors.createError(401, "需要提供有效的token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找token对应的应用安装信息
|
||||||
|
const appInstall = await prisma.appInstall.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: {
|
||||||
|
app: true,
|
||||||
|
device: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!appInstall) {
|
||||||
|
return next(errors.createError(401, "无效的token"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将信息存储到res.locals供后续使用
|
||||||
|
res.locals.device = appInstall.device;
|
||||||
|
res.locals.app = appInstall.app;
|
||||||
|
res.locals.appInstall = appInstall;
|
||||||
|
res.locals.deviceId = appInstall.device.id;
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取token
|
||||||
|
* 支持的方式:
|
||||||
|
* 1. Header: x-app-token
|
||||||
|
* 2. Query: token 或 apptoken
|
||||||
|
* 3. Body: token 或 apptoken
|
||||||
|
*/
|
||||||
|
function extractToken(req) {
|
||||||
|
return (
|
||||||
|
req.headers["x-app-token"] ||
|
||||||
|
req.query.token ||
|
||||||
|
req.query.apptoken ||
|
||||||
|
(req.body && req.body.token) ||
|
||||||
|
(req.body && req.body.apptoken)
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -45,7 +45,7 @@ export const globalLimiter = rateLimit({
|
|||||||
// API限速器
|
// API限速器
|
||||||
export const apiLimiter = rateLimit({
|
export const apiLimiter = rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1分钟
|
windowMs: 1 * 60 * 1000, // 1分钟
|
||||||
limit: 50, // 每个IP在windowMs时间内最多允许50个请求
|
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
|
||||||
standardHeaders: "draft-7",
|
standardHeaders: "draft-7",
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
message: "API请求过于频繁,请稍后再试",
|
message: "API请求过于频繁,请稍后再试",
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { verifyDevicePassword } from "../utils/crypto.js";
|
|
||||||
import errors from "../utils/errors.js";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Token认证中间件
|
|
||||||
*
|
|
||||||
* 从请求中提取token,验证后将设备信息注入到res.locals
|
|
||||||
* 同时将deviceId注入到req.params,以便后续路由使用
|
|
||||||
*
|
|
||||||
* Token可通过以下方式提供:
|
|
||||||
* 1. Authorization header: Bearer <token>
|
|
||||||
* 2. Query参数: ?token=<token>
|
|
||||||
* 3. Body: {"token": "<token>"}
|
|
||||||
*/
|
|
||||||
export const tokenAuth = errors.catchAsync(async (req, res, next) => {
|
|
||||||
let token;
|
|
||||||
|
|
||||||
// 尝试从 headers, query, body 中获取 token
|
|
||||||
if (req.headers.authorization && req.headers.authorization.startsWith("Bearer ")) {
|
|
||||||
token = req.headers.authorization.split(" ")[1];
|
|
||||||
} else if (req.query.token) {
|
|
||||||
token = req.query.token;
|
|
||||||
} else if (req.body.token) {
|
|
||||||
token = req.body.token;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return next(errors.createError(401, "未提供身份验证令牌"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const appInstall = await prisma.appInstall.findUnique({
|
|
||||||
where: { token },
|
|
||||||
include: {
|
|
||||||
app: true,
|
|
||||||
device: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!appInstall) {
|
|
||||||
return next(errors.createError(401, "无效的身份验证令牌"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将认证信息存储到res.locals
|
|
||||||
res.locals.appInstall = appInstall;
|
|
||||||
res.locals.app = appInstall.app;
|
|
||||||
res.locals.device = appInstall.device;
|
|
||||||
res.locals.deviceId = appInstall.device.id;
|
|
||||||
|
|
||||||
// 将deviceId注入到req.params(向后兼容,某些路由可能需要namespace参数)
|
|
||||||
req.params.namespace = appInstall.device.uuid;
|
|
||||||
req.params.deviceId = appInstall.device.id;
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写权限验证中间件
|
|
||||||
*
|
|
||||||
* 依赖于deviceMiddleware,必须在其后使用
|
|
||||||
* 验证设备密码和写权限
|
|
||||||
*
|
|
||||||
* 逻辑:
|
|
||||||
* 1. 如果设备没有设置密码,直接允许写入
|
|
||||||
* 2. 如果设备设置了密码:
|
|
||||||
* - 验证提供的密码是否正确
|
|
||||||
* - 密码正确则允许写入
|
|
||||||
* - 密码错误或未提供则拒绝写入
|
|
||||||
*
|
|
||||||
* 使用方式:
|
|
||||||
* router.post('/path', deviceMiddleware, requireWriteAuth, handler)
|
|
||||||
* router.put('/path', deviceMiddleware, requireWriteAuth, handler)
|
|
||||||
* router.delete('/path', deviceMiddleware, requireWriteAuth, handler)
|
|
||||||
*/
|
|
||||||
export const requireWriteAuth = errors.catchAsync(async (req, res, next) => {
|
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
if (!device) {
|
|
||||||
return next(errors.createError(500, "设备信息未加载,请确保使用了deviceMiddleware"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果设备没有设置密码,直接通过
|
|
||||||
if (!device.password) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备有密码,需要验证
|
|
||||||
const providedPassword = req.body.password || req.query.password;
|
|
||||||
|
|
||||||
if (!providedPassword) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "此操作需要密码",
|
|
||||||
passwordHint: device.passwordHint,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证密码
|
|
||||||
const isValid = await verifyDevicePassword(providedPassword, device.password);
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
return res.status(401).json({
|
|
||||||
statusCode: 401,
|
|
||||||
message: "密码错误",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 密码正确,继续
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
131
middleware/uuidAuth.js
Normal file
131
middleware/uuidAuth.js
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* UUID+密码/JWT混合认证中间件
|
||||||
|
*
|
||||||
|
* 1. 必须提供UUID,读取设备信息并存储到res.locals
|
||||||
|
* 2. 验证密码或账户JWT(二选一)
|
||||||
|
* 3. 适用于需要设备上下文的接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
import { verifyToken as verifyAccountJWT } from "../utils/jwt.js";
|
||||||
|
import { verifyDevicePassword } from "../utils/crypto.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID+密码/JWT混合认证中间件
|
||||||
|
*/
|
||||||
|
export const uuidAuth = async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// 1. 获取UUID(必需)
|
||||||
|
const uuid = extractUuid(req);
|
||||||
|
if (!uuid) {
|
||||||
|
return next(errors.createError(400, "需要提供设备UUID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 查找设备并存储到locals
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储设备信息到locals
|
||||||
|
res.locals.device = device;
|
||||||
|
res.locals.deviceId = device.id;
|
||||||
|
|
||||||
|
// 3. 验证密码或JWT(二选一)
|
||||||
|
const password = extractPassword(req);
|
||||||
|
const jwt = extractJWT(req);
|
||||||
|
|
||||||
|
if (jwt) {
|
||||||
|
// 验证账户JWT
|
||||||
|
try {
|
||||||
|
const accountPayload = await verifyAccountJWT(jwt);
|
||||||
|
const account = await prisma.account.findUnique({
|
||||||
|
where: { id: accountPayload.accountId },
|
||||||
|
include: {
|
||||||
|
devices: {
|
||||||
|
where: { uuid },
|
||||||
|
select: { id: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return next(errors.createError(401, "账户不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设备是否绑定到此账户
|
||||||
|
if (account.devices.length === 0) {
|
||||||
|
return next(errors.createError(403, "设备未绑定到此账户"));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.locals.account = account;
|
||||||
|
res.locals.isAccountOwner = true; // 标记为账户拥有者
|
||||||
|
return next();
|
||||||
|
} catch (error) {
|
||||||
|
return next(errors.createError(401, "无效的JWT token"));
|
||||||
|
}
|
||||||
|
} else if (password) {
|
||||||
|
// 验证设备密码
|
||||||
|
if (!device.password) {
|
||||||
|
return next(); // 如果设备未设置密码,允许无密码访问
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyDevicePassword(password, device.password);
|
||||||
|
if (!isValid) {
|
||||||
|
return next(errors.createError(401, "密码错误"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
// 如果设备未设置密码,允许无密码访问
|
||||||
|
if (!device.password) {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
return next(errors.createError(401, "需要提供密码或JWT token"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取UUID
|
||||||
|
*/
|
||||||
|
function extractUuid(req) {
|
||||||
|
return (
|
||||||
|
req.headers["x-device-uuid"] ||
|
||||||
|
req.query.uuid ||
|
||||||
|
req.params.uuid ||
|
||||||
|
req.params.deviceUuid ||
|
||||||
|
(req.body && req.body.uuid) ||
|
||||||
|
(req.body && req.body.deviceUuid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取密码
|
||||||
|
*/
|
||||||
|
function extractPassword(req) {
|
||||||
|
return (
|
||||||
|
req.headers["x-device-password"] ||
|
||||||
|
req.query.password ||
|
||||||
|
req.query.currentPassword
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求中提取JWT
|
||||||
|
*/
|
||||||
|
function extractJWT(req) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (authHeader && authHeader.startsWith("Bearer ")) {
|
||||||
|
return authHeader.substring(7);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@ -30,6 +30,7 @@
|
|||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"http-errors": "~2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "~1.10.0",
|
"morgan": "~1.10.0",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^11.1.0"
|
||||||
},
|
},
|
||||||
|
|||||||
91
pnpm-lock.yaml
generated
91
pnpm-lock.yaml
generated
@ -65,6 +65,9 @@ importers:
|
|||||||
js-base64:
|
js-base64:
|
||||||
specifier: ^3.7.7
|
specifier: ^3.7.7
|
||||||
version: 3.7.7
|
version: 3.7.7
|
||||||
|
jsonwebtoken:
|
||||||
|
specifier: ^9.0.2
|
||||||
|
version: 9.0.2
|
||||||
morgan:
|
morgan:
|
||||||
specifier: ~1.10.0
|
specifier: ~1.10.0
|
||||||
version: 1.10.0
|
version: 1.10.0
|
||||||
@ -1377,6 +1380,9 @@ packages:
|
|||||||
brace-expansion@2.0.1:
|
brace-expansion@2.0.1:
|
||||||
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1535,6 +1541,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@ -1794,6 +1803,16 @@ packages:
|
|||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@ -1865,6 +1884,27 @@ packages:
|
|||||||
lodash.camelcase@4.3.0:
|
lodash.camelcase@4.3.0:
|
||||||
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
long@5.3.2:
|
long@5.3.2:
|
||||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||||
|
|
||||||
@ -2176,6 +2216,11 @@ packages:
|
|||||||
scule@1.3.0:
|
scule@1.3.0:
|
||||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||||
|
|
||||||
|
semver@7.7.2:
|
||||||
|
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
send@1.2.0:
|
send@1.2.0:
|
||||||
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@ -3834,6 +3879,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
balanced-match: 1.0.2
|
balanced-match: 1.0.2
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
c12@3.1.0:
|
c12@3.1.0:
|
||||||
@ -3966,6 +4013,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
effect@3.16.12:
|
effect@3.16.12:
|
||||||
@ -4261,6 +4312,30 @@ snapshots:
|
|||||||
|
|
||||||
json5@2.2.3: {}
|
json5@2.2.3: {}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.2:
|
||||||
|
dependencies:
|
||||||
|
jws: 3.2.2
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.7.2
|
||||||
|
|
||||||
|
jwa@1.4.2:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@3.2.2:
|
||||||
|
dependencies:
|
||||||
|
jwa: 1.4.2
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
lightningcss-darwin-arm64@1.30.1:
|
lightningcss-darwin-arm64@1.30.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -4314,6 +4389,20 @@ snapshots:
|
|||||||
|
|
||||||
lodash.camelcase@4.3.0: {}
|
lodash.camelcase@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
long@5.3.2: {}
|
long@5.3.2: {}
|
||||||
|
|
||||||
lucide-react@0.544.0(react@19.2.0):
|
lucide-react@0.544.0(react@19.2.0):
|
||||||
@ -4646,6 +4735,8 @@ snapshots:
|
|||||||
|
|
||||||
scule@1.3.0: {}
|
scule@1.3.0: {}
|
||||||
|
|
||||||
|
semver@7.7.2: {}
|
||||||
|
|
||||||
send@1.2.0:
|
send@1.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.1
|
debug: 4.4.1
|
||||||
|
|||||||
21
prisma/migrations/20251002074755_update/migration.sql
Normal file
21
prisma/migrations/20251002074755_update/migration.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `Account` (
|
||||||
|
`id` VARCHAR(191) NOT NULL,
|
||||||
|
`provider` VARCHAR(191) NOT NULL,
|
||||||
|
`providerId` VARCHAR(191) NOT NULL,
|
||||||
|
`email` VARCHAR(191) NULL,
|
||||||
|
`name` VARCHAR(191) NULL,
|
||||||
|
`avatarUrl` VARCHAR(191) NULL,
|
||||||
|
`providerData` JSON NULL,
|
||||||
|
`accessToken` VARCHAR(191) NOT NULL,
|
||||||
|
`refreshToken` VARCHAR(191) NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `Account_accessToken_key`(`accessToken`),
|
||||||
|
UNIQUE INDEX `Account_provider_providerId_key`(`provider`, `providerId`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `Device` ADD CONSTRAINT `Device_accountId_fkey` FOREIGN KEY (`accountId`) REFERENCES `Account`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -21,17 +21,37 @@ model KVStore {
|
|||||||
@@id([deviceId, key])
|
@@id([deviceId, key])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
provider String // OAuth提供者 (例如: google, github, gitlab等)
|
||||||
|
providerId String // 提供者返回的用户唯一ID
|
||||||
|
email String? // 用户邮箱
|
||||||
|
name String? // 用户名称
|
||||||
|
avatarUrl String? // 用户头像URL
|
||||||
|
providerData Json? // OAuth提供者返回的完整信息
|
||||||
|
accessToken String @unique // 账户访问令牌
|
||||||
|
refreshToken String? // OAuth refresh token (如果提供者支持)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// 关联的设备
|
||||||
|
devices Device[]
|
||||||
|
|
||||||
|
@@unique([provider, providerId]) // 确保同一提供者的用户ID唯一
|
||||||
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
uuid String @unique // 设备的唯一标识符
|
uuid String @unique // 设备的唯一标识符
|
||||||
name String?
|
name String?
|
||||||
accountId String? // 关联的社区账户ID,暂不添加相关代码
|
accountId String? // 关联的账户ID
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
password String?
|
password String?
|
||||||
passwordHint String?
|
passwordHint String?
|
||||||
|
|
||||||
// 关联的应用安装记录
|
// 关联关系
|
||||||
|
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
|
||||||
appInstalls AppInstall[]
|
appInstalls AppInstall[]
|
||||||
kvStore KVStore[] // 设备相关的KV存储
|
kvStore KVStore[] // 设备相关的KV存储
|
||||||
}
|
}
|
||||||
|
|||||||
166
public/auth-error.html
Normal file
166
public/auth-error.html
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录失败</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: shake 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fee2e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: #6b7280;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-text {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="error-icon">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>登录失败</h1>
|
||||||
|
|
||||||
|
<div class="error-message">
|
||||||
|
<div id="errorMsg">认证过程中出现错误</div>
|
||||||
|
<div class="error-code" id="errorCode"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="javascript:history.back()" class="retry-btn">返回重试</a>
|
||||||
|
<button class="close-btn" onclick="window.close()">关闭窗口</button>
|
||||||
|
|
||||||
|
<div class="help-text">
|
||||||
|
如果问题持续存在,请检查:<br>
|
||||||
|
• OAuth应用配置是否正确<br>
|
||||||
|
• 回调URL是否已添加到OAuth应用中<br>
|
||||||
|
• 环境变量是否配置正确
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 从URL获取错误信息
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const error = params.get('error');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errorMessages = {
|
||||||
|
'invalid_state': 'State验证失败,可能存在CSRF攻击',
|
||||||
|
'access_denied': '用户拒绝了授权请求',
|
||||||
|
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMsg = errorMessages[error] || '未知错误';
|
||||||
|
document.getElementById('errorMsg').textContent = errorMsg;
|
||||||
|
document.getElementById('errorCode').textContent = `错误代码: ${error}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
254
public/auth-success.html
Normal file
254
public/auth-success.html
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>登录成功</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
background: #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: scaleIn 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon svg {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
stroke: white;
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scaleIn {
|
||||||
|
from {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #1f2937;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-container {
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-label {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token {
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: #4338ca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auto-close {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.countdown {
|
||||||
|
color: #4f46e5;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="success-icon">
|
||||||
|
<svg fill="none" viewBox="0 0 24 24">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>登录成功</h1>
|
||||||
|
<p class="provider" id="provider">OAuth Provider</p>
|
||||||
|
|
||||||
|
<div class="token-container">
|
||||||
|
<div class="token-label">访问令牌</div>
|
||||||
|
<div class="token" id="token">加载中...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="copy-btn" id="copyBtn" onclick="copyToken()">复制令牌</button>
|
||||||
|
|
||||||
|
<div class="auto-close">
|
||||||
|
窗口将在 <span class="countdown" id="countdown">10</span> 秒后自动关闭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 从URL获取参数
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const token = params.get('token');
|
||||||
|
const provider = params.get('provider');
|
||||||
|
|
||||||
|
// 显示信息
|
||||||
|
if (token) {
|
||||||
|
document.getElementById('token').textContent = token;
|
||||||
|
|
||||||
|
// 保存到localStorage(前端应用可以读取)
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
localStorage.setItem('auth_provider', provider);
|
||||||
|
|
||||||
|
// 触发storage事件,通知其他窗口
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', {
|
||||||
|
key: 'auth_token',
|
||||||
|
newValue: token,
|
||||||
|
url: window.location.href
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
document.getElementById('token').textContent = '未获取到令牌';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider) {
|
||||||
|
const providerNames = {
|
||||||
|
'github': 'GitHub',
|
||||||
|
'zerocat': 'ZeroCat'
|
||||||
|
};
|
||||||
|
document.getElementById('provider').textContent = `通过 ${providerNames[provider] || provider} 登录`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复制令牌
|
||||||
|
function copyToken() {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
const btn = document.getElementById('copyBtn');
|
||||||
|
btn.textContent = '已复制';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = '复制令牌';
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(() => {
|
||||||
|
// 降级方案
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = token;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.opacity = '0';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
const btn = document.getElementById('copyBtn');
|
||||||
|
btn.textContent = '已复制';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = '复制令牌';
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 倒计时关闭
|
||||||
|
let countdown = 10;
|
||||||
|
const countdownEl = document.getElementById('countdown');
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
countdown--;
|
||||||
|
countdownEl.textContent = countdown;
|
||||||
|
|
||||||
|
if (countdown <= 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
|
||||||
|
// 尝试关闭窗口
|
||||||
|
window.close();
|
||||||
|
|
||||||
|
// 如果无法关闭(比如不是通过脚本打开的),显示提示
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!window.closed) {
|
||||||
|
countdownEl.parentElement.innerHTML = '您可以关闭此窗口了';
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// 如果用户有任何交互,停止自动关闭
|
||||||
|
document.addEventListener('click', () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
document.querySelector('.auto-close').style.display = 'none';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
548
routes/accounts.js
Normal file
548
routes/accounts.js
Normal file
@ -0,0 +1,548 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { oauthProviders, getCallbackURL, generateState } from "../config/oauth.js";
|
||||||
|
import { generateAccountToken, verifyToken } from "../utils/jwt.js";
|
||||||
|
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 存储OAuth state,防止CSRF攻击(生产环境应使用Redis等)
|
||||||
|
const oauthStates = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成安全的访问令牌
|
||||||
|
*/
|
||||||
|
function generateAccessToken() {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取支持的OAuth提供者列表
|
||||||
|
* GET /accounts/oauth/providers
|
||||||
|
*/
|
||||||
|
router.get("/oauth/providers", (req, res) => {
|
||||||
|
const providers = [];
|
||||||
|
|
||||||
|
for (const [key, config] of Object.entries(oauthProviders)) {
|
||||||
|
// 只返回已配置的提供者
|
||||||
|
if (config.clientId && config.clientSecret) {
|
||||||
|
providers.push({
|
||||||
|
id: key,
|
||||||
|
name: config.name,
|
||||||
|
icon: config.icon,
|
||||||
|
color: config.color,
|
||||||
|
description: config.description,
|
||||||
|
authUrl: `/accounts/oauth/${key}`, // 前端用于发起认证的URL
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: providers,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发起OAuth认证
|
||||||
|
* GET /accounts/oauth/:provider
|
||||||
|
*
|
||||||
|
* Query参数:
|
||||||
|
* - redirect_uri: 前端回调地址(可选)
|
||||||
|
*/
|
||||||
|
router.get("/oauth/:provider", (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { redirect_uri } = req.query;
|
||||||
|
|
||||||
|
const providerConfig = oauthProviders[provider];
|
||||||
|
if (!providerConfig) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: `不支持的OAuth提供者: ${provider}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!providerConfig.clientId || !providerConfig.clientSecret) {
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: `OAuth提供者 ${provider} 未配置`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成state参数
|
||||||
|
const state = generateState();
|
||||||
|
|
||||||
|
// 保存state和redirect_uri(5分钟过期)
|
||||||
|
oauthStates.set(state, {
|
||||||
|
provider,
|
||||||
|
redirect_uri,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 清理过期的state(超过5分钟)
|
||||||
|
for (const [key, value] of oauthStates.entries()) {
|
||||||
|
if (Date.now() - value.timestamp > 5 * 60 * 1000) {
|
||||||
|
oauthStates.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建授权URL
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: providerConfig.clientId,
|
||||||
|
redirect_uri: getCallbackURL(provider),
|
||||||
|
scope: providerConfig.scope,
|
||||||
|
state: state,
|
||||||
|
response_type: "code",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Google需要额外的参数
|
||||||
|
if (provider === "google") {
|
||||||
|
params.append("access_type", "offline");
|
||||||
|
params.append("prompt", "consent");
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUrl = `${providerConfig.authorizationURL}?${params.toString()}`;
|
||||||
|
|
||||||
|
// 重定向到OAuth提供者
|
||||||
|
res.redirect(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth回调处理
|
||||||
|
* GET /accounts/oauth/:provider/callback
|
||||||
|
*/
|
||||||
|
router.get("/oauth/:provider/callback", async (req, res) => {
|
||||||
|
const { provider } = req.params;
|
||||||
|
const { code, state, error } = req.query;
|
||||||
|
|
||||||
|
// 如果OAuth提供者返回错误
|
||||||
|
if (error) {
|
||||||
|
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const errorUrl = new URL(frontendBaseUrl);
|
||||||
|
errorUrl.searchParams.append("error", error);
|
||||||
|
errorUrl.searchParams.append("provider", provider);
|
||||||
|
errorUrl.searchParams.append("success", "false");
|
||||||
|
return res.redirect(errorUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证state
|
||||||
|
const stateData = oauthStates.get(state);
|
||||||
|
if (!stateData || stateData.provider !== provider) {
|
||||||
|
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const errorUrl = new URL(frontendBaseUrl);
|
||||||
|
errorUrl.searchParams.append("error", "invalid_state");
|
||||||
|
errorUrl.searchParams.append("provider", provider);
|
||||||
|
errorUrl.searchParams.append("success", "false");
|
||||||
|
return res.redirect(errorUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除已使用的state
|
||||||
|
oauthStates.delete(state);
|
||||||
|
|
||||||
|
const providerConfig = oauthProviders[provider];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 使用授权码换取访问令牌
|
||||||
|
const tokenResponse = await fetch(providerConfig.tokenURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
client_id: providerConfig.clientId,
|
||||||
|
client_secret: providerConfig.clientSecret,
|
||||||
|
code: code,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
redirect_uri: getCallbackURL(provider),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tokenData = await tokenResponse.json();
|
||||||
|
|
||||||
|
if (!tokenData.access_token) {
|
||||||
|
throw new Error("获取访问令牌失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使用访问令牌获取用户信息
|
||||||
|
const userResponse = await fetch(providerConfig.userInfoURL, {
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${tokenData.access_token}`,
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const userData = await userResponse.json();
|
||||||
|
|
||||||
|
// 3. 标准化用户数据(不同提供者返回的字段不同)
|
||||||
|
let normalizedUser = {};
|
||||||
|
|
||||||
|
if (provider === "github") {
|
||||||
|
normalizedUser = {
|
||||||
|
providerId: String(userData.id),
|
||||||
|
email: userData.email,
|
||||||
|
name: userData.name || userData.login,
|
||||||
|
avatarUrl: userData.avatar_url,
|
||||||
|
};
|
||||||
|
} else if (provider === "zerocat") {
|
||||||
|
normalizedUser = {
|
||||||
|
providerId: userData.openid,
|
||||||
|
email: userData.email_verified ? userData.email : null,
|
||||||
|
name: userData.nickname || userData.username,
|
||||||
|
avatarUrl: userData.avatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 查找或创建账户
|
||||||
|
let account = await prisma.account.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_providerId: {
|
||||||
|
provider,
|
||||||
|
providerId: normalizedUser.providerId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
// 更新账户信息
|
||||||
|
account = await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: {
|
||||||
|
email: normalizedUser.email || account.email,
|
||||||
|
name: normalizedUser.name || account.name,
|
||||||
|
avatarUrl: normalizedUser.avatarUrl || account.avatarUrl,
|
||||||
|
providerData: userData,
|
||||||
|
refreshToken: tokenData.refresh_token || account.refreshToken,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 创建新账户
|
||||||
|
const accessToken = generateAccessToken();
|
||||||
|
account = await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
provider,
|
||||||
|
providerId: normalizedUser.providerId,
|
||||||
|
email: normalizedUser.email,
|
||||||
|
name: normalizedUser.name,
|
||||||
|
avatarUrl: normalizedUser.avatarUrl,
|
||||||
|
providerData: userData,
|
||||||
|
accessToken,
|
||||||
|
refreshToken: tokenData.refresh_token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 生成JWT token
|
||||||
|
const jwtToken = generateAccountToken(account);
|
||||||
|
|
||||||
|
// 6. 重定向到前端根路径,携带JWT token
|
||||||
|
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const callbackUrl = new URL(frontendBaseUrl);
|
||||||
|
callbackUrl.searchParams.append("token", jwtToken);
|
||||||
|
callbackUrl.searchParams.append("provider", provider);
|
||||||
|
callbackUrl.searchParams.append("success", "true");
|
||||||
|
|
||||||
|
res.redirect(callbackUrl.toString());
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`OAuth回调处理失败 [${provider}]:`, error);
|
||||||
|
|
||||||
|
// 重定向到前端根路径,携带错误信息
|
||||||
|
const frontendBaseUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const errorUrl = new URL(frontendBaseUrl);
|
||||||
|
errorUrl.searchParams.append("error", error.message);
|
||||||
|
errorUrl.searchParams.append("provider", provider);
|
||||||
|
errorUrl.searchParams.append("success", "false");
|
||||||
|
|
||||||
|
res.redirect(errorUrl.toString());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户信息
|
||||||
|
* GET /api/accounts/profile
|
||||||
|
*
|
||||||
|
* Headers:
|
||||||
|
* Authorization: Bearer <JWT Token>
|
||||||
|
*/
|
||||||
|
router.get("/profile", jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const accountContext = res.locals.account;
|
||||||
|
|
||||||
|
const account = await prisma.account.findUnique({
|
||||||
|
where: { id: accountContext.id },
|
||||||
|
include: {
|
||||||
|
devices: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: account.id,
|
||||||
|
provider: account.provider,
|
||||||
|
email: account.email,
|
||||||
|
name: account.name,
|
||||||
|
avatarUrl: account.avatarUrl,
|
||||||
|
devices: account.devices,
|
||||||
|
createdAt: account.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定设备到账户
|
||||||
|
* POST /api/accounts/devices/bind
|
||||||
|
*
|
||||||
|
* Headers:
|
||||||
|
* Authorization: Bearer <JWT Token>
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* uuid: string // 设备UUID
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post("/devices/bind", jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const accountContext = res.locals.account;
|
||||||
|
const { uuid } = req.body;
|
||||||
|
|
||||||
|
if (!uuid) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "缺少设备UUID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找设备
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "设备不存在 #1",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查设备是否已绑定其他账户
|
||||||
|
if (device.accountId && device.accountId !== accountContext.id) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "设备已绑定其他账户",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定设备到账户
|
||||||
|
const updatedDevice = await prisma.device.update({
|
||||||
|
where: { uuid },
|
||||||
|
data: {
|
||||||
|
accountId: accountContext.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "设备绑定成功",
|
||||||
|
data: {
|
||||||
|
deviceId: updatedDevice.id,
|
||||||
|
uuid: updatedDevice.uuid,
|
||||||
|
name: updatedDevice.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑设备
|
||||||
|
* POST /api/accounts/devices/unbind
|
||||||
|
*
|
||||||
|
* Headers:
|
||||||
|
* Authorization: Bearer <JWT Token>
|
||||||
|
*
|
||||||
|
* Body:
|
||||||
|
* {
|
||||||
|
* uuid: string // 设备UUID(单个解绑)
|
||||||
|
* uuids: string[] // 设备UUID数组(批量解绑)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
router.post("/devices/unbind", jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const accountContext = res.locals.account;
|
||||||
|
const { uuid, uuids } = req.body;
|
||||||
|
|
||||||
|
// 支持单个解绑或批量解绑
|
||||||
|
const uuidsToUnbind = uuids || (uuid ? [uuid] : []);
|
||||||
|
|
||||||
|
if (uuidsToUnbind.length === 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "请提供要解绑的设备UUID",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找所有设备并验证所有权
|
||||||
|
const devices = await prisma.device.findMany({
|
||||||
|
where: {
|
||||||
|
uuid: { in: uuidsToUnbind },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查是否有不存在的设备
|
||||||
|
if (devices.length !== uuidsToUnbind.length) {
|
||||||
|
const foundUuids = devices.map(d => d.uuid);
|
||||||
|
const notFoundUuids = uuidsToUnbind.filter(u => !foundUuids.includes(u));
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `以下设备不存在: ${notFoundUuids.join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查所有权
|
||||||
|
const unauthorizedDevices = devices.filter(d => d.accountId !== accountContext.id);
|
||||||
|
if (unauthorizedDevices.length > 0) {
|
||||||
|
return res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: `您没有权限解绑以下设备: ${unauthorizedDevices.map(d => d.uuid).join(', ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量解绑设备
|
||||||
|
await prisma.device.updateMany({
|
||||||
|
where: {
|
||||||
|
uuid: { in: uuidsToUnbind },
|
||||||
|
accountId: accountContext.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
accountId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: uuidsToUnbind.length === 1 ? "设备解绑成功" : `成功解绑 ${uuidsToUnbind.length} 个设备`,
|
||||||
|
unboundCount: uuidsToUnbind.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取账户绑定的设备列表
|
||||||
|
* GET /api/accounts/devices
|
||||||
|
*
|
||||||
|
* Headers:
|
||||||
|
* Authorization: Bearer <JWT Token>
|
||||||
|
*/
|
||||||
|
router.get("/devices", jwtAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const accountContext = res.locals.account;
|
||||||
|
// 获取账户的设备列表
|
||||||
|
const account = await prisma.account.findUnique({
|
||||||
|
where: { id: accountContext.id },
|
||||||
|
include: {
|
||||||
|
devices: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
uuid: true,
|
||||||
|
name: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
appInstalls: {
|
||||||
|
include: {
|
||||||
|
app: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
iconHash: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: account.devices,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据设备UUID获取账户公开信息
|
||||||
|
* GET /accounts/device/:uuid/account
|
||||||
|
*
|
||||||
|
* 无需认证,返回公开信息
|
||||||
|
*/
|
||||||
|
router.get("/device/:uuid/account", async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
|
||||||
|
// 查找设备及其关联的账户
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
provider: true,
|
||||||
|
name: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "设备不存在 #2",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device.account) {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: null, // 设备未绑定账户
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: device.account.id,
|
||||||
|
provider: device.account.provider,
|
||||||
|
name: device.account.name,
|
||||||
|
avatarUrl: device.account.avatarUrl,
|
||||||
|
bindTime: device.updatedAt, // 绑定时间
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
507
routes/apps.js
507
routes/apps.js
@ -1,156 +1,176 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
import {
|
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||||
deviceMiddleware,
|
import { jwtAuth } from "../middleware/jwt-auth.js";
|
||||||
passwordMiddleware,
|
|
||||||
deviceInfoMiddleware,
|
|
||||||
} from "../middleware/device.js";
|
|
||||||
import { checkSiteKey } from "../middleware/auth.js";
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
router.use(checkSiteKey);
|
/**
|
||||||
|
* GET /apps/devices/:uuid/apps
|
||||||
|
* 获取设备安装的应用列表 (公开接口,无需认证)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/devices/:uuid/apps",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
|
||||||
|
// 查找设备
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const installations = await prisma.appInstall.findMany({
|
||||||
|
where: { deviceId: device.id },
|
||||||
|
include: { app: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const apps = installations.map(install => ({
|
||||||
|
id: install.app.id,
|
||||||
|
name: install.app.name,
|
||||||
|
description: install.app.description,
|
||||||
|
token: install.token,
|
||||||
|
installedAt: install.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
apps,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /apps/devices/:uuid/install/:appId
|
||||||
|
* 为设备安装应用 (需要UUID认证)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/devices/:uuid/install/:appId",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const device = res.locals.device;
|
||||||
|
const { appId } = req.params;
|
||||||
|
const { note } = req.body;
|
||||||
|
|
||||||
|
// 检查应用是否存在
|
||||||
|
const app = await prisma.app.findUnique({
|
||||||
|
where: { id: parseInt(appId) },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!app) {
|
||||||
|
return next(errors.createError(404, "应用不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 生成token
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
// 创建安装记录
|
||||||
|
const installation = await prisma.appInstall.create({
|
||||||
|
data: {
|
||||||
|
deviceId: device.id,
|
||||||
|
appId: app.id,
|
||||||
|
token,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
id: installation.id,
|
||||||
|
appId: app.id,
|
||||||
|
appName: app.name,
|
||||||
|
token: installation.token,
|
||||||
|
note: installation.note,
|
||||||
|
installedAt: installation.createdAt,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /apps/devices/:uuid/uninstall/:installId
|
||||||
|
* 卸载设备应用 (需要UUID认证)
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/devices/:uuid/uninstall/:installId",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const device = res.locals.device;
|
||||||
|
const { installId } = req.params;
|
||||||
|
|
||||||
|
const installation = await prisma.appInstall.findUnique({
|
||||||
|
where: { id: installId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!installation) {
|
||||||
|
return next(errors.createError(404, "应用未安装"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保安装记录属于当前设备
|
||||||
|
if (installation.deviceId !== device.id) {
|
||||||
|
return next(errors.createError(403, "无权操作此安装记录"));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.appInstall.delete({
|
||||||
|
where: { id: installation.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(204).end();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /apps
|
* GET /apps
|
||||||
* 获取应用列表
|
* 获取所有可用应用列表
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/",
|
"/",
|
||||||
errors.catchAsync(async (req, res) => {
|
errors.catchAsync(async (req, res) => {
|
||||||
const { limit = 20, skip = 0, search } = req.query;
|
const apps = await prisma.app.findMany({
|
||||||
|
select: {
|
||||||
const where = search
|
id: true,
|
||||||
? {
|
name: true,
|
||||||
OR: [
|
description: true,
|
||||||
{ name: { contains: search } },
|
createdAt: true,
|
||||||
{ description: { contains: search } },
|
|
||||||
{ developerName: { contains: search } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
: {};
|
|
||||||
|
|
||||||
const [apps, total] = await Promise.all([
|
|
||||||
prisma.app.findMany({
|
|
||||||
where,
|
|
||||||
take: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
orderBy: { createdAt: "desc" },
|
|
||||||
}),
|
|
||||||
prisma.app.count({ where }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
apps,
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /apps/:id
|
|
||||||
* 获取单个应用详情
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/:id",
|
|
||||||
errors.catchAsync(async (req, res) => {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
const app = await prisma.app.findUnique({
|
|
||||||
where: { id: parseInt(id) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
return res.status(404).json({
|
|
||||||
statusCode: 404,
|
|
||||||
message: "应用不存在",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(app);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /apps/:id/authorize
|
|
||||||
* 为应用授权获取token
|
|
||||||
*
|
|
||||||
* 使用统一的设备中间件:
|
|
||||||
* 1. deviceMiddleware - 自动获取或创建设备
|
|
||||||
* 2. passwordMiddleware - 验证密码(如果设备有密码)
|
|
||||||
*
|
|
||||||
* 请求体:
|
|
||||||
* {
|
|
||||||
* "deviceUuid": "设备UUID",
|
|
||||||
* "password": "设备密码(如果设备有密码则必须提供)",
|
|
||||||
* "note": "备注信息" // 可选
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/:id/authorize",
|
|
||||||
deviceMiddleware,
|
|
||||||
passwordMiddleware,
|
|
||||||
errors.catchAsync(async (req, res) => {
|
|
||||||
const { id: appId } = req.params;
|
|
||||||
const { note } = req.body;
|
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
// 检查应用是否存在
|
|
||||||
const app = await prisma.app.findUnique({
|
|
||||||
where: { id: Number(appId) },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!app) {
|
|
||||||
return res.status(404).json({
|
|
||||||
statusCode: 404,
|
|
||||||
message: "应用不存在",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成token
|
|
||||||
const randomBytes = crypto.randomBytes(32);
|
|
||||||
const tokenData = `${appId}-${device.uuid}-${Date.now()}-${randomBytes.toString('hex')}`;
|
|
||||||
const token = crypto.createHash("sha256").update(tokenData).digest("hex");
|
|
||||||
|
|
||||||
// 创建应用安装记录
|
|
||||||
const appInstall = await prisma.appInstall.create({
|
|
||||||
data: {
|
|
||||||
deviceId: device.id,
|
|
||||||
appId: Number(appId),
|
|
||||||
token,
|
|
||||||
note: note || "授权访问",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(200).json({
|
return res.json({
|
||||||
token: appInstall.token,
|
success: true,
|
||||||
appId: Number(appId),
|
apps,
|
||||||
appName: app.name,
|
|
||||||
deviceUuid: device.uuid,
|
|
||||||
deviceName: device.name,
|
|
||||||
note: appInstall.note,
|
|
||||||
authorizedAt: appInstall.installedAt,
|
|
||||||
|
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /apps/devices/:deviceUuid/tokens
|
* GET /apps/tokens
|
||||||
* 获取设备上的所有授权token
|
* 获取设备的token列表 (需要设备UUID)
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
"/devices/:deviceUuid/tokens",
|
"/tokens",
|
||||||
deviceInfoMiddleware,
|
errors.catchAsync(async (req, res, next) => {
|
||||||
errors.catchAsync(async (req, res) => {
|
const { uuid } = req.query;
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
|
if (!uuid) {
|
||||||
|
return next(errors.createError(400, "需要提供设备UUID"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找设备
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取该设备的所有应用安装记录(即token)
|
||||||
const installations = await prisma.appInstall.findMany({
|
const installations = await prisma.appInstall.findMany({
|
||||||
where: { deviceId: device.id },
|
where: { deviceId: device.id },
|
||||||
include: {
|
include: {
|
||||||
@ -159,236 +179,39 @@ router.get(
|
|||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
description: true,
|
description: true,
|
||||||
developerName: true,
|
|
||||||
iconHash: true,
|
|
||||||
repositoryUrl: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: { installedAt: "desc" },
|
orderBy: { installedAt: 'desc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
const tokens = installations.map(install => ({
|
||||||
deviceUuid: device.uuid,
|
id: install.id, // 安装记录ID
|
||||||
deviceName: device.name,
|
|
||||||
tokens: installations.map(install => ({
|
|
||||||
id: install.id,
|
|
||||||
token: install.token,
|
token: install.token,
|
||||||
app: install.app,
|
appId: install.app.id,
|
||||||
note: install.note,
|
appName: install.app.name,
|
||||||
|
appDescription: install.app.description,
|
||||||
installedAt: install.installedAt,
|
installedAt: install.installedAt,
|
||||||
updatedAt: install.updatedAt,
|
|
||||||
createdAt: install.createdAt,
|
|
||||||
repositoryUrl: install.app.repositoryUrl,
|
|
||||||
})),
|
|
||||||
total: installations.length,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /apps/tokens/:token
|
|
||||||
* 撤销特定token
|
|
||||||
*/
|
|
||||||
router.delete(
|
|
||||||
"/tokens/:token",
|
|
||||||
errors.catchAsync(async (req, res) => {
|
|
||||||
const { token } = req.params;
|
|
||||||
|
|
||||||
const result = await prisma.appInstall.deleteMany({
|
|
||||||
where: { token },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.count === 0) {
|
|
||||||
return res.status(404).json({
|
|
||||||
statusCode: 404,
|
|
||||||
message: "Token不存在",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(204).end();
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /apps/:id/installations
|
|
||||||
* 获取应用的所有安装记录
|
|
||||||
*/
|
|
||||||
router.get(
|
|
||||||
"/:id/installations",
|
|
||||||
errors.catchAsync(async (req, res) => {
|
|
||||||
const { id: appId } = req.params;
|
|
||||||
const { limit = 20, skip = 0 } = req.query;
|
|
||||||
|
|
||||||
const [installations, total] = await Promise.all([
|
|
||||||
prisma.appInstall.findMany({
|
|
||||||
where: { appId: Number(appId) },
|
|
||||||
include: {
|
|
||||||
device: {
|
|
||||||
select: {
|
|
||||||
uuid: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
take: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
orderBy: { installedAt: "desc" },
|
|
||||||
}),
|
|
||||||
prisma.appInstall.count({ where: { appId: Number(appId) } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
appId: Number(appId),
|
|
||||||
installations: installations.map(install => ({
|
|
||||||
id: install.id,
|
|
||||||
token: install.token,
|
|
||||||
device: install.device,
|
|
||||||
note: install.note,
|
note: install.note,
|
||||||
installedAt: install.installedAt,
|
}));
|
||||||
updatedAt: install.updatedAt,
|
|
||||||
})),
|
|
||||||
total,
|
|
||||||
limit: parseInt(limit),
|
|
||||||
skip: parseInt(skip),
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
return res.json({
|
||||||
* PUT /apps/devices/:deviceUuid/password
|
|
||||||
* 设置或更新设备密码
|
|
||||||
*
|
|
||||||
* Request Body:
|
|
||||||
* {
|
|
||||||
* "newPassword": "新密码",
|
|
||||||
* "passwordHint": "密码提示(可选)",
|
|
||||||
* "currentPassword": "当前密码(如果已设置密码则必须提供)"
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.put(
|
|
||||||
"/devices/:deviceUuid/password",
|
|
||||||
deviceInfoMiddleware,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const { newPassword, passwordHint, currentPassword } = req.body;
|
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
if (!newPassword) {
|
|
||||||
return next(errors.createError(400, "请提供新密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果设备已有密码,必须先验证当前密码
|
|
||||||
if (device.password) {
|
|
||||||
if (!currentPassword) {
|
|
||||||
return next(errors.createError(401, "设备已设置密码,请提供当前密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValid = await verifyDevicePassword(currentPassword, device.password);
|
|
||||||
if (!isValid) {
|
|
||||||
return next(errors.createError(401, "当前密码错误"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 哈希新密码
|
|
||||||
const hashedPassword = await hashPassword(newPassword);
|
|
||||||
|
|
||||||
// 更新设备密码
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: {
|
|
||||||
password: hashedPassword,
|
|
||||||
passwordHint: passwordHint || device.passwordHint,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
success: true,
|
||||||
message: device.password ? "密码已更新" : "密码已设置",
|
tokens,
|
||||||
deviceUuid: device.uuid,
|
deviceUuid: uuid,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
router.get("/info/:appid",
|
||||||
/**
|
|
||||||
* DELETE /apps/devices/:deviceUuid/password
|
|
||||||
* 删除设备密码
|
|
||||||
*
|
|
||||||
* Request Body:
|
|
||||||
* {
|
|
||||||
* "password": "当前密码(必须)"
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.delete(
|
|
||||||
"/devices/:deviceUuid/password",
|
|
||||||
deviceInfoMiddleware,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const { password } = req.body;
|
const { appid } = req.params;
|
||||||
const device = res.locals.device;
|
const app = await prisma.app.findUnique({
|
||||||
|
where: { id: parseInt(appid) },
|
||||||
if (!device.password) {
|
|
||||||
return next(errors.createError(400, "设备未设置密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
return next(errors.createError(401, "请提供当前密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证密码
|
|
||||||
const isValid = await verifyDevicePassword(password, device.password);
|
|
||||||
if (!isValid) {
|
|
||||||
return next(errors.createError(401, "密码错误"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除密码
|
|
||||||
await prisma.device.update({
|
|
||||||
where: { id: device.id },
|
|
||||||
data: {
|
|
||||||
password: null,
|
|
||||||
passwordHint: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "密码已删除",
|
|
||||||
deviceUuid: device.uuid,
|
|
||||||
});
|
});
|
||||||
|
if (!app) {
|
||||||
|
return next(errors.createError(404, "应用不存在"));
|
||||||
|
}
|
||||||
|
return res.json(app);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /apps/devices/:deviceUuid/password/verify
|
|
||||||
* 验证设备密码
|
|
||||||
*
|
|
||||||
* Request Body:
|
|
||||||
* {
|
|
||||||
* "password": "待验证的密码"
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
router.post(
|
|
||||||
"/devices/:deviceUuid/password/verify",
|
|
||||||
deviceInfoMiddleware,
|
|
||||||
errors.catchAsync(async (req, res, next) => {
|
|
||||||
const { password } = req.body;
|
|
||||||
const device = res.locals.device;
|
|
||||||
|
|
||||||
if (!device.password) {
|
|
||||||
return next(errors.createError(400, "设备未设置密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
return next(errors.createError(400, "请提供密码"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证密码
|
|
||||||
const isValid = await verifyDevicePassword(password, device.password);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
valid: isValid,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@ -1,14 +1,12 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import deviceCodeStore from "../utils/deviceCodeStore.js";
|
import deviceCodeStore from "../utils/deviceCodeStore.js";
|
||||||
import { checkSiteKey } from "../middleware/auth.js";
|
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
// 应用站点密钥验证
|
|
||||||
router.use(checkSiteKey);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /device/code
|
* POST /device/code
|
||||||
|
|||||||
327
routes/device.js
Normal file
327
routes/device.js
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
const router = Router();
|
||||||
|
import { uuidAuth } from "../middleware/uuidAuth.js";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import errors from "../utils/errors.js";
|
||||||
|
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /devices
|
||||||
|
* 注册新设备
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid, deviceName } = req.body;
|
||||||
|
|
||||||
|
if (!uuid) {
|
||||||
|
return next(errors.createError(400, "设备UUID是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deviceName) {
|
||||||
|
return next(errors.createError(400, "设备名称是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查UUID是否已存在
|
||||||
|
const existingDevice = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingDevice) {
|
||||||
|
return next(errors.createError(409, "设备UUID已存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建设备
|
||||||
|
const device = await prisma.device.create({
|
||||||
|
data: {
|
||||||
|
uuid,
|
||||||
|
name: deviceName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
id: device.id,
|
||||||
|
uuid: device.uuid,
|
||||||
|
name: device.name,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /devices/:uuid
|
||||||
|
* 获取设备信息 (公开接口,无需认证)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:uuid",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
|
||||||
|
// 查找设备,包含绑定的账户信息
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
include: {
|
||||||
|
account: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
id: device.id,
|
||||||
|
uuid: device.uuid,
|
||||||
|
name: device.name,
|
||||||
|
hasPassword: !!device.password,
|
||||||
|
passwordHint: device.passwordHint,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
account: device.account ? {
|
||||||
|
id: device.account.id,
|
||||||
|
name: device.account.name,
|
||||||
|
email: device.account.email,
|
||||||
|
avatarUrl: device.account.avatarUrl,
|
||||||
|
} : null,
|
||||||
|
isBoundToAccount: !!device.account,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);/**
|
||||||
|
* PUT /devices/:uuid/name
|
||||||
|
* 设置设备名称 (需要UUID认证)
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:uuid/name",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
const device = res.locals.device;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return next(errors.createError(400, "设备名称是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDevice = await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { name },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
id: updatedDevice.id,
|
||||||
|
uuid: updatedDevice.uuid,
|
||||||
|
name: updatedDevice.name,
|
||||||
|
hasPassword: !!updatedDevice.password,
|
||||||
|
passwordHint: updatedDevice.passwordHint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /devices/:uuid/password
|
||||||
|
* 初次设置设备密码 (无需认证,仅当设备未设置密码时)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:uuid/password",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
const newPassword = req.query.newPassword || req.body.newPassword;
|
||||||
|
const passwordHint = req.query.passwordHint || req.body.passwordHint;
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
return next(errors.createError(400, "新密码是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查找设备
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有在设备未设置密码时才允许无认证设置
|
||||||
|
if (device.password) {
|
||||||
|
return next(errors.createError(403, "设备已设置密码,请使用修改密码接口"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: {
|
||||||
|
password: hashedPassword,
|
||||||
|
passwordHint: passwordHint || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "密码设置成功",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /devices/:uuid/password
|
||||||
|
* 修改设备密码 (需要UUID认证和当前密码验证,账户拥有者除外)
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:uuid/password",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const currentPassword = req.query.currentPassword;
|
||||||
|
const newPassword = req.query.newPassword || req.body.newPassword;
|
||||||
|
const passwordHint = req.query.passwordHint || req.body.passwordHint;
|
||||||
|
const device = res.locals.device;
|
||||||
|
const isAccountOwner = res.locals.isAccountOwner;
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
return next(errors.createError(400, "新密码是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是账户拥有者,无需验证当前密码
|
||||||
|
if (!isAccountOwner) {
|
||||||
|
if (!device.password) {
|
||||||
|
return next(errors.createError(400, "设备未设置密码,请使用设置密码接口"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentPassword) {
|
||||||
|
return next(errors.createError(400, "当前密码是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证当前密码
|
||||||
|
const isCurrentPasswordValid = await verifyDevicePassword(currentPassword, device.password);
|
||||||
|
if (!isCurrentPasswordValid) {
|
||||||
|
return next(errors.createError(401, "当前密码错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedNewPassword = await hashPassword(newPassword);
|
||||||
|
|
||||||
|
await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: {
|
||||||
|
password: hashedNewPassword,
|
||||||
|
passwordHint: passwordHint !== undefined ? passwordHint : device.passwordHint,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "密码修改成功",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /devices/:uuid/password-hint
|
||||||
|
* 设置密码提示 (需要UUID认证)
|
||||||
|
*/
|
||||||
|
router.put(
|
||||||
|
"/:uuid/password-hint",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { passwordHint } = req.body;
|
||||||
|
const device = res.locals.device;
|
||||||
|
|
||||||
|
await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: { passwordHint: passwordHint || null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "密码提示设置成功",
|
||||||
|
passwordHint: passwordHint || null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /devices/:uuid/password-hint
|
||||||
|
* 获取设备密码提示 (无需认证)
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/:uuid/password-hint",
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const { uuid } = req.params;
|
||||||
|
|
||||||
|
const device = await prisma.device.findUnique({
|
||||||
|
where: { uuid },
|
||||||
|
select: {
|
||||||
|
passwordHint: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return next(errors.createError(404, "设备不存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
passwordHint: device.passwordHint || null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /devices/:uuid/password
|
||||||
|
* 删除设备密码 (需要UUID认证和密码验证,账户拥有者除外)
|
||||||
|
*/
|
||||||
|
router.delete(
|
||||||
|
"/:uuid/password",
|
||||||
|
uuidAuth,
|
||||||
|
errors.catchAsync(async (req, res, next) => {
|
||||||
|
const password = req.query.password;
|
||||||
|
const device = res.locals.device;
|
||||||
|
const isAccountOwner = res.locals.isAccountOwner;
|
||||||
|
|
||||||
|
if (!device.password) {
|
||||||
|
return next(errors.createError(400, "设备未设置密码"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是账户拥有者,需要验证密码
|
||||||
|
if (!isAccountOwner) {
|
||||||
|
if (!password) {
|
||||||
|
return next(errors.createError(400, "密码是必需的"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
const isPasswordValid = await verifyDevicePassword(password, device.password);
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
return next(errors.createError(401, "密码错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.device.update({
|
||||||
|
where: { id: device.id },
|
||||||
|
data: {
|
||||||
|
password: null,
|
||||||
|
passwordHint: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: "密码删除成功",
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@ -1,13 +1,11 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
const router = Router();
|
const router = Router();
|
||||||
import kvStore from "../utils/kvStore.js";
|
import kvStore from "../utils/kvStore.js";
|
||||||
import { tokenAuth } from "../middleware/tokenAuth.js";
|
import { kvTokenAuth } from "../middleware/kvTokenAuth.js";
|
||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { checkSiteKey } from "../middleware/auth.js";
|
|
||||||
|
|
||||||
// 应用站点密钥验证和token认证
|
// 使用KV专用token认证
|
||||||
router.use(checkSiteKey);
|
router.use(kvTokenAuth);
|
||||||
router.use(tokenAuth);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /_keys
|
* GET /_keys
|
||||||
|
|||||||
@ -1,69 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: classworks_mysql
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-classworks}
|
|
||||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-classworks}
|
|
||||||
MYSQL_USER: ${MYSQL_USER:-classworks}
|
|
||||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-classworks}
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "3306:3306"
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
- ./mysql/conf.d:/etc/mysql/conf.d:ro
|
|
||||||
- ./mysql/initdb.d:/docker-entrypoint-initdb.d:ro
|
|
||||||
command:
|
|
||||||
- --character-set-server=utf8mb4
|
|
||||||
- --collation-server=utf8mb4_unicode_ci
|
|
||||||
- --default-authentication-plugin=mysql_native_password
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u$$MYSQL_USER", "-p$$MYSQL_PASSWORD"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- classworks_net
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: classworks_postgres
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-classworks}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-classworks}
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-classworks}
|
|
||||||
TZ: Asia/Shanghai
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
- ./postgres/initdb.d:/docker-entrypoint-initdb.d:ro
|
|
||||||
command:
|
|
||||||
- "postgres"
|
|
||||||
- "-c"
|
|
||||||
- "max_connections=100"
|
|
||||||
- "-c"
|
|
||||||
- "shared_buffers=128MB"
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
networks:
|
|
||||||
- classworks_net
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
mysql_data:
|
|
||||||
name: classworks_mysql_data
|
|
||||||
postgres_data:
|
|
||||||
name: classworks_postgres_data
|
|
||||||
|
|
||||||
networks:
|
|
||||||
classworks_net:
|
|
||||||
name: classworks_network
|
|
||||||
driver: bridge
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
[mysqld]
|
|
||||||
# 字符集设置
|
|
||||||
character-set-server=utf8mb4
|
|
||||||
collation-server=utf8mb4_unicode_ci
|
|
||||||
|
|
||||||
# 连接设置
|
|
||||||
max_connections=100
|
|
||||||
max_allowed_packet=64M
|
|
||||||
|
|
||||||
# InnoDB设置
|
|
||||||
innodb_buffer_pool_size=256M
|
|
||||||
innodb_log_file_size=64M
|
|
||||||
innodb_flush_log_at_trx_commit=2
|
|
||||||
innodb_flush_method=O_DIRECT
|
|
||||||
|
|
||||||
# 优化设置
|
|
||||||
query_cache_type=1
|
|
||||||
query_cache_size=32M
|
|
||||||
sort_buffer_size=4M
|
|
||||||
read_buffer_size=2M
|
|
||||||
read_rnd_buffer_size=4M
|
|
||||||
join_buffer_size=2M
|
|
||||||
|
|
||||||
# 日志设置
|
|
||||||
slow_query_log=1
|
|
||||||
slow_query_log_file=/var/log/mysql/slow.log
|
|
||||||
long_query_time=2
|
|
||||||
|
|
||||||
[client]
|
|
||||||
default-character-set=utf8mb4
|
|
||||||
|
|
||||||
[mysql]
|
|
||||||
default-character-set=utf8mb4
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
-- 设置时区
|
|
||||||
SET GLOBAL time_zone = '+8:00';
|
|
||||||
SET time_zone = '+8:00';
|
|
||||||
|
|
||||||
-- 创建数据库(如果不存在)
|
|
||||||
CREATE DATABASE IF NOT EXISTS classworks
|
|
||||||
CHARACTER SET utf8mb4
|
|
||||||
COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
-- 设置权限
|
|
||||||
GRANT ALL PRIVILEGES ON classworks.* TO 'classworks'@'%';
|
|
||||||
FLUSH PRIVILEGES;
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
-- 创建扩展
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
|
||||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
|
|
||||||
|
|
||||||
-- 设置时区
|
|
||||||
SET timezone = 'Asia/Shanghai';
|
|
||||||
|
|
||||||
-- 设置默认权限
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO classworks;
|
|
||||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO classworks;
|
|
||||||
40
utils/jwt.js
Normal file
40
utils/jwt.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
// JWT密钥 - 生产环境应该从环境变量读取
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this-in-production';
|
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; // 默认7天过期
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签发JWT token
|
||||||
|
* @param {Object} payload - 要编码的数据
|
||||||
|
* @returns {string} JWT token
|
||||||
|
*/
|
||||||
|
export function signToken(payload) {
|
||||||
|
return jwt.sign(payload, JWT_SECRET, {
|
||||||
|
expiresIn: JWT_EXPIRES_IN,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证JWT token
|
||||||
|
* @param {string} token - JWT token
|
||||||
|
* @returns {Object} 解码后的payload
|
||||||
|
*/
|
||||||
|
export function verifyToken(token) {
|
||||||
|
return jwt.verify(token, JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为账户生成JWT token
|
||||||
|
* @param {Object} account - 账户对象
|
||||||
|
* @returns {string} JWT token
|
||||||
|
*/
|
||||||
|
export function generateAccountToken(account) {
|
||||||
|
return signToken({
|
||||||
|
accountId: account.id,
|
||||||
|
provider: account.provider,
|
||||||
|
email: account.email,
|
||||||
|
name: account.name,
|
||||||
|
avatarUrl: account.avatarUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user