1
1
mirror of https://github.com/ZeroCatDev/ClassworksKV.git synced 2025-10-21 17:53:11 +00:00

继续一大堆功能实现

This commit is contained in:
SunWuyuan 2025-10-03 21:22:18 +08:00
parent 521522c1d2
commit 7b1e224f70
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
81 changed files with 7342 additions and 5141 deletions

18
.env.oauth.example Normal file
View 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
View File

@ -17,7 +17,9 @@ import {
import kvRouter from "./routes/kv-token.js";
import appsRouter from "./routes/apps.js";
import deviceRouter from "./routes/device.js";
import deviceAuthRouter from "./routes/device-auth.js";
import accountsRouter from "./routes/accounts.js";
var app = express();
@ -85,12 +87,18 @@ app.get("/check", apiLimiter, (req, res) => {
// Mount the Apps router with API rate limiting
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 (更宽松的限速)
app.use("/kv", tokenBasedRateLimiter, kvRouter);
// Mount the Device Authorization router with API rate limiting
app.use("/auth", apiLimiter, deviceAuthRouter);
// Mount the Accounts router with API rate limiting
app.use("/accounts", apiLimiter, accountsRouter);
// 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => {
const notFoundError = errors.createError(404, `找不到路径: ${req.path}`);

View File

@ -1,22 +1,33 @@
# 设备授权流程 - CLI 工具
命令行工具,用于通过设备授权流程获取访问令牌。
命令行工具,用于通过设备授权流程获取访问令牌。支持两种授权模式:
- **设备代码模式** (`get-token.js`) - 用户手动输入设备代码完成授权
- **回调模式** (`get-token-callback.js`) - 通过浏览器回调自动完成授权
## 使用方法
### 基本使用
### 1. 设备代码模式推荐用于无GUI环境
```bash
node cli/get-token.js
```
### 配置环境变量
### 2. 回调模式(推荐用于桌面环境)
```bash
node cli/get-token-callback.js
```
### 环境变量配置
两种模式都支持以下环境变量:
```bash
# 设置API服务器地址默认: http://localhost:3030
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
# 设置应用ID默认: 1
@ -25,8 +36,13 @@ export APP_ID=1
# 设置站点密钥(如果需要)
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
@ -38,15 +54,28 @@ chmod +x cli/get-token.js
## 工作流程
### 设备代码模式 (`get-token.js`)
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
2. **显示授权链接** - 在终端显示完整的授权URL包含设备代码
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
4. **获取令牌** - 工具自动轮询并获取令牌
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
```
### 回调模式输出
```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
- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: https://classworks.xiaomo.tech/authorize
可以通过修改相应文件中的 `CONFIG` 对象或设置环境变量来调整:
- `baseUrl` / `API_BASE_URL` - API 服务器地址(默认: `http://localhost:3030`
- `authPageUrl` / `AUTH_PAGE_URL` - Classworks 授权页面地址(默认: `http://localhost:5173/authorize`
- `appId` / `APP_ID` - 应用ID默认: 1
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
### 设备代码模式专用配置
- `pollInterval` - 轮询间隔默认3秒
- `maxPolls` - 最大轮询次数默认100次
### 回调模式专用配置
- `callbackPort` / `CALLBACK_PORT` - 回调服务器端口(默认: 8080
- `timeout` / `TIMEOUT` - 授权超时时间(秒,默认: 300
- `callbackPath` - 回调路径(默认: /callback
## 错误处理
### 设备代码模式
- 如果设备代码过期,会显示错误并退出
- 如果轮询超时默认5分钟会显示超时错误
- 如果无法连接到服务器,会显示连接错误
### 回调模式
- 如果回调端口被占用,会提示更换端口
- 如果授权超时,会显示超时错误并提示延长超时时间
- 如果状态参数不匹配会拒绝授权防止CSRF攻击
- 如果无法连接到服务器,会显示连接错误
## 选择模式建议
- **设备代码模式** - 适用于无GUI环境、服务器环境、或无法启动本地服务器的场景
- **回调模式** - 适用于桌面环境、开发环境、或希望更流畅授权体验的场景

422
cli/get-token-callback.js Normal file
View 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
View 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);
}

View File

@ -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
```

View File

@ -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

View File

@ -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推荐
---

View File

@ -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"
}
]
}
```

View File

@ -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`

View File

@ -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` 字段进行验证。

View File

@ -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"
}
```

View File

@ -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)

View File

@ -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认证方式。

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KV 服务授权管理</title>
<title>Classworks KV</title>
</head>
<body>
<div id="app"></div>

View File

@ -20,6 +20,7 @@
"lucide-react": "^0.544.0",
"lucide-vue-next": "^0.544.0",
"marked": "^16.3.0",
"pinia": "^3.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.5.1",
"tailwind-merge": "^3.3.1",

View File

@ -41,6 +41,9 @@ importers:
marked:
specifier: ^16.3.0
version: 16.3.0
pinia:
specifier: ^3.0.3
version: 3.0.3(vue@3.5.22)
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.22)
@ -1573,6 +1576,15 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
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:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@ -3460,6 +3472,11 @@ snapshots:
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:
dependencies:
confbox: 0.1.8

View File

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

View File

@ -61,7 +61,7 @@ const renderedReadme = computed(() => {
//
const fetchApp = async () => {
try {
app.value = await axios.get(`/apps/${props.appId}`);
app.value = await axios.get(`/apps/info/${props.appId}`);
if (app.value.repositoryUrl) {
await fetchReadme();

View 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>

View 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>

View 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
// localStoragetoken
const token = localStorage.getItem('auth_token')
const authProvider = localStorage.getItem('auth_provider')
if (token) {
toast.success('登录成功', {
description: `已通过 ${authProvider} 登录`
})
//
if (props.onSuccess) {
props.onSuccess(token)
}
}
}
} catch (error) {
//
}
}, 500)
// 30
const timeoutId = setTimeout(() => {
clearInterval(checkInterval)
window.removeEventListener('message', handleMessage)
if (authWindow && !authWindow.closed) {
authWindow.close()
}
if (isAuthenticating.value) {
isAuthenticating.value = false
toast.error('登录超时', {
description: '请重试'
})
}
}, 30000)
}
onMounted(() => {
loadProviders()
})
</script>

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View 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>

View File

@ -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>

View 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";

View 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>

View File

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

View 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>

View 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>

View 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>

View File

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

View 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>

View File

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

View 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>

View 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>

View 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>

View 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>

View 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";

View 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
}
}

View File

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

View File

@ -7,16 +7,64 @@ export function generateUUID() {
})
}
// 设备 UUID 管理
// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
export const deviceStore = {
// 获取当前设备 UUID
// 存储键名
STORAGE_KEY: 'device_uuid',
BACKUP_KEY: 'device_uuid_backup',
SESSION_KEY: 'device_uuid_session',
// 获取当前设备 UUID从多个存储位置尝试读取
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) {
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
@ -31,26 +79,113 @@ export const deviceStore = {
let uuid = this.getDeviceUuid()
if (!uuid) {
uuid = this.generateAndSave()
} else {
// 确保UUID被保存到所有位置
this.setDeviceUuid(uuid)
}
return uuid
},
// 清除设备 UUID
// 清除设备 UUID(从所有存储位置清除)
clear() {
localStorage.removeItem('device_uuid')
localStorage.removeItem('device_password')
localStorage.removeItem(this.STORAGE_KEY)
localStorage.removeItem(this.BACKUP_KEY)
sessionStorage.removeItem(this.SESSION_KEY)
this.clearCookie()
this.clearIndexedDB()
},
// 设备密码管理
hasPassword() {
return localStorage.getItem('device_password') === 'true'
// Cookie 操作
saveToCookie(uuid) {
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) {
if (hasPassword) {
localStorage.setItem('device_password', 'true')
} else {
localStorage.removeItem('device_password')
getFromCookie() {
try {
const match = document.cookie.match(/(?:^|; )device_uuid=([^;]*)/)
return match ? match[1] : null
} 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()
}

View File

@ -1,11 +1,26 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { createPinia } from 'pinia'
import { routes } from 'vue-router/auto-routes'
import { tokenStore } from './lib/tokenStore'
import { deviceStore } from './lib/deviceStore'
import './style.css'
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({
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')

View File

@ -3,16 +3,21 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { apiClient } from '@/lib/api'
import { deviceStore } from '@/lib/deviceStore'
import { useAccountStore } from '@/stores/account'
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 { 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 PasswordInput from '@/components/PasswordInput.vue'
import LoginDialog from '@/components/LoginDialog.vue'
import { toast } from 'vue-sonner'
const route = useRoute()
const router = useRouter()
const accountStore = useAccountStore()
// URL
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 errorMessage = 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('')
@ -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 () => {
if (!currentDeviceCode.value || !deviceUuid.value) return
@ -60,7 +139,6 @@ const authorizeWithDeviceCode = async () => {
try {
// 1. token
const authData = {
deviceUuid: deviceUuid.value,
note: authNote.value || '设备代码授权',
}
@ -68,7 +146,7 @@ const authorizeWithDeviceCode = async () => {
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
// 2. token
@ -90,7 +168,6 @@ const authorizeWithCallback = async () => {
try {
const authData = {
deviceUuid: deviceUuid.value,
note: authNote.value || '回调授权',
}
@ -98,7 +175,7 @@ const authorizeWithCallback = async () => {
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
// URL token
@ -136,10 +213,33 @@ const retry = () => {
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()
hasPassword.value = deviceStore.hasPassword()
loadAppInfo()
//
await loadDeviceInfo()
//
await loadDeviceAccount()
//
await loadAppInfo()
//
if (accountStore.isAuthenticated) {
await accountStore.loadDevices()
}
// devicecode
if (isDeviceCodeMode.value && deviceCode.value) {
@ -179,8 +279,15 @@ onMounted(() => {
<!-- 设备信息 -->
<div class="space-y-3">
<Label class="text-sm text-muted-foreground">设备 UUID</Label>
<div class="flex items-center gap-2">
<div class="flex items-center justify-between">
<Label class="text-sm text-muted-foreground">设备 UUID</Label>
</div>
<!-- 当前设备UUID显示 -->
<div class="flex items-center gap-2">
<code class="text-xs font-mono bg-muted px-3 py-2 rounded flex-1 truncate">
{{ deviceUuid }}
</code>
@ -189,6 +296,23 @@ onMounted(() => {
已保护
</Badge>
</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>
<!-- 模式标识 -->
@ -231,14 +355,17 @@ onMounted(() => {
/>
</div>
<!-- 密码输入 -->
<div v-if="hasPassword" class="space-y-2">
<Label for="password">设备密码</Label>
<Input
id="password"
<!-- 密码输入使用统一组件 -->
<div v-if="hasPassword">
<PasswordInput
v-model="authPassword"
type="text"
label="设备密码"
placeholder="输入设备密码以确认授权"
:device-uuid="deviceUuid"
:show-hint="true"
:show-strength="false"
required
:error="step === 'error' && errorMessage.includes('密码') ? '密码错误' : ''"
/>
</div>
@ -337,5 +464,8 @@ onMounted(() => {
</div>
</CardContent>
</Card>
<!-- 登录弹框 -->
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" />
</div>
</template>

View File

@ -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>

View 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>

View File

@ -2,48 +2,69 @@
import { ref, computed, onMounted } from 'vue'
import { apiClient } from '@/lib/api'
import { deviceStore } from '@/lib/deviceStore'
import { useAccountStore } from '@/stores/account'
import { useOAuthCallback } from '@/composables/useOAuthCallback'
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 { Badge } from '@/components/ui/badge'
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 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 tokens = ref([])
const isLoading = ref(false)
const copied = ref(null)
const deviceInfo = ref(null) //
const deviceAccount = ref(null) //
const accountStore = useAccountStore()
// Dialogs
const showAuthorizeDialog = ref(false)
const showRevokeDialog = ref(false)
const showUuidDialog = ref(false)
const showRegisterDialog = 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)
// Form data
const appIdToAuthorize = ref('')
const authPassword = ref('')
const authNote = ref('')
const newUuid = ref('')
const devicePassword = ref('')
const newPassword = 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
const groupedByApp = computed(() => {
const groups = {}
tokens.value.forEach(token => {
const appId = token.app.id
const appId = token.appId
if (!groups[appId]) {
groups[appId] = {
appId: appId,
appName: token.app.name || appId,
description: token.app.description || '',
appName: token.appName || appId,
description: token.appDescription || '',
tokens: []
}
}
@ -52,6 +73,34 @@ const groupedByApp = computed(() => {
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)
// deviceInfonullhasPasswordfalse
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 () => {
if (!deviceUuid.value) return
@ -73,24 +122,34 @@ const authorizeApp = async () => {
if (!appIdToAuthorize.value) return
try {
const data = {
deviceUuid: deviceUuid.value,
const options = {
note: authNote.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
appIdToAuthorize.value = ''
authPassword.value = ''
authNote.value = ''
await loadTokens()
toast.success('授权成功')
} catch (error) {
alert('授权失败:' + error.message)
toast.error('授权失败:' + error.message)
}
}
@ -102,13 +161,27 @@ const confirmRevoke = (token) => {
const revokeToken = async () => {
if (!selectedToken.value) return
//
if (!accountStore.isAuthenticated && hasPassword.value && !revokePassword.value) {
alert('请输入设备密码')
return
}
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
selectedToken.value = null
revokePassword.value = ''
await loadTokens()
toast.success('撤销成功')
} catch (error) {
alert('撤销失败:' + error.message)
toast.error('撤销失败:' + error.message)
}
}
@ -125,14 +198,10 @@ const copyToClipboard = async (text, id) => {
}
const updateUuid = () => {
if (newUuid.value.trim()) {
deviceStore.setDeviceUuid(newUuid.value.trim())
} else {
deviceStore.generateAndSave()
}
showRegisterDialog.value = false
deviceUuid.value = deviceStore.getDeviceUuid()
showUuidDialog.value = false
newUuid.value = ''
loadDeviceInfo()
loadDeviceAccount()
loadTokens()
}
@ -144,18 +213,25 @@ const setPassword = async () => {
newPassword: newPassword.value,
}
if (hasPassword.value) {
if (hasPassword.value && !accountStore.isAuthenticated) {
data.currentPassword = currentPassword.value
}
await apiClient.setDevicePassword(deviceUuid.value, data)
deviceStore.setHasPassword(true)
hasPassword.value = true
await apiClient.setDevicePassword(
deviceUuid.value,
data,
accountStore.isAuthenticated ? accountStore.token : null
)
// hasPassword
await loadDeviceInfo()
showPasswordDialog.value = false
newPassword.value = ''
currentPassword.value = ''
toast.success('密码设置成功')
} catch (error) {
alert('设置密码失败:' + error.message)
toast.error('设置密码失败:' + error.message)
}
}
@ -163,54 +239,322 @@ const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('zh-CN')
}
onMounted(() => {
deviceUuid.value = deviceStore.getOrGenerate()
hasPassword.value = deviceStore.hasPassword()
loadTokens()
//
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
}
}
//
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>
<template>
<div class="min-h-screen bg-gradient-to-br from-background via-background to-muted/20 p-4 md:p-8">
<div class="max-w-7xl mx-auto space-y-6">
<Card class="border-2 shadow-lg">
<CardHeader>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<CardTitle class="text-2xl font-bold flex items-center gap-2">
<Shield class="h-6 w-6 text-primary" />
设备授权管理
</CardTitle>
<CardDescription class="mt-2">
管理您的设备 UUID 和应用授权令牌
</CardDescription>
<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-3">
<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-foreground" />
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" @click="showPasswordDialog = true">
<Key class="h-4 w-4 mr-2" />
{{ hasPassword ? '修改密码' : '设置密码' }}
</Button>
<Button variant="outline" size="sm" @click="showUuidDialog = true">
<RefreshCw class="h-4 w-4 mr-2" />
更换 UUID
</Button>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-foreground to-foreground/70 bg-clip-text">
Classworks KV
</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>
</template>
<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
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>
</CardHeader>
<CardContent>
<div class="flex items-center gap-2 p-4 bg-muted/50 rounded-lg">
<Label class="text-sm font-medium whitespace-nowrap">设备 UUID:</Label>
<code class="flex-1 text-sm font-mono bg-background px-3 py-2 rounded border">
{{ deviceUuid }}
</code>
<Button
variant="ghost"
size="sm"
@click="copyToClipboard(deviceUuid, 'uuid')"
>
<CheckCircle2 v-if="copied === 'uuid'" class="h-4 w-4 text-green-500" />
<Copy v-else class="h-4 w-4" />
</Button>
<div class="space-y-4">
<!-- UUID Display -->
<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 }}
</code>
<div class="flex gap-1">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
@click="copyToClipboard(deviceUuid, 'uuid')"
title="复制设备标识"
>
<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" />
</Button>
</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>
</Card>
@ -332,14 +676,19 @@ onMounted(() => {
placeholder="为此授权添加备注"
/>
</div>
<div v-if="hasPassword" class="space-y-2">
<Label for="password">设备密码</Label>
<Input
id="password"
<div v-if="hasPassword">
<PasswordInput
v-model="authPassword"
type="text"
label="设备密码"
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>
<DialogFooter>
@ -359,20 +708,47 @@ onMounted(() => {
<DialogHeader>
<DialogTitle>撤销授权</DialogTitle>
<DialogDescription>
确定要撤销此令牌的授权吗此操作无法撤销
确定要撤销此令牌的授权吗此操作无法撤销{{selectedToken}}
</DialogDescription>
</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="text-sm">
<span class="font-medium">应用: </span>
{{ selectedToken.app.name }}
{{ selectedToken.appName }}
</div>
<div class="text-sm">
<span class="font-medium">令牌: </span>
<code class="text-xs">{{ selectedToken.token.slice(0, 16) }}...</code>
</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>
<DialogFooter>
<Button variant="outline" @click="showRevokeDialog = false">
@ -386,39 +762,6 @@ onMounted(() => {
</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">
<DialogContent>
<DialogHeader>
@ -428,22 +771,35 @@ onMounted(() => {
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div v-if="hasPassword" class="space-y-2">
<Label for="currentPassword">当前密码</Label>
<Input
id="currentPassword"
<div v-if="hasPassword && !accountStore.isAuthenticated">
<PasswordInput
v-model="currentPassword"
type="password"
label="当前密码"
placeholder="输入当前密码"
:device-uuid="deviceUuid"
:show-hint="true"
:show-strength="false"
required
/>
</div>
<div class="space-y-2">
<Label for="newPassword">新密码</Label>
<Input
id="newPassword"
<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">
<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">账户已登录</p>
<p class="text-sm text-blue-700 dark:text-blue-300">您已登录绑定的账户无需输入当前密码</p>
</div>
</div>
</div>
<div>
<PasswordInput
v-model="newPassword"
type="password"
label="新密码"
placeholder="输入新密码"
:show-hint="false"
:show-strength="true"
:min-length="8"
required
/>
</div>
</div>
@ -458,5 +814,30 @@ onMounted(() => {
</DialogContent>
</Dialog>
</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>
</template>

View 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>

View 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>

View 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,
}
})

View File

@ -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: "服务器内部错误",
});
}
};

View File

@ -92,6 +92,8 @@ export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) =>
* 从req.body.password获取密码
* 如果设备有密码但未提供或密码错误则返回401错误
*
* 特殊规则如果设备绑定了账户且req.account存在且匹配则跳过密码验证
*
* 使用方式
* 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"));
}
// 如果设备绑定了账户,且请求中有账户信息且匹配,则跳过密码验证
if (device.accountId && req.account && req.account.id === device.accountId) {
return next();
}
// 如果设备有密码,验证密码
if (device.password) {
if (!password) {

54
middleware/jwt-auth.js Normal file
View 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
View 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)
);
}

View File

@ -45,7 +45,7 @@ export const globalLimiter = rateLimit({
// API限速器
export const apiLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 50, // 每个IP在windowMs时间内最多允许50个请求
limit: 100, // 每个IP在windowMs时间内最多允许100个请求
standardHeaders: "draft-7",
legacyHeaders: false,
message: "API请求过于频繁请稍后再试",

View File

@ -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
View 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;
}

View File

@ -30,6 +30,7 @@
"express-rate-limit": "^7.5.0",
"http-errors": "~2.0.0",
"js-base64": "^3.7.7",
"jsonwebtoken": "^9.0.2",
"morgan": "~1.10.0",
"uuid": "^11.1.0"
},

91
pnpm-lock.yaml generated
View File

@ -65,6 +65,9 @@ importers:
js-base64:
specifier: ^3.7.7
version: 3.7.7
jsonwebtoken:
specifier: ^9.0.2
version: 9.0.2
morgan:
specifier: ~1.10.0
version: 1.10.0
@ -1377,6 +1380,9 @@ packages:
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -1535,6 +1541,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -1794,6 +1803,16 @@ packages:
engines: {node: '>=6'}
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:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
@ -1865,6 +1884,27 @@ packages:
lodash.camelcase@4.3.0:
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:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
@ -2176,6 +2216,11 @@ packages:
scule@1.3.0:
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:
resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==}
engines: {node: '>= 18'}
@ -3834,6 +3879,8 @@ snapshots:
dependencies:
balanced-match: 1.0.2
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {}
c12@3.1.0:
@ -3966,6 +4013,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
effect@3.16.12:
@ -4261,6 +4312,30 @@ snapshots:
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:
optional: true
@ -4314,6 +4389,20 @@ snapshots:
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: {}
lucide-react@0.544.0(react@19.2.0):
@ -4646,6 +4735,8 @@ snapshots:
scule@1.3.0: {}
semver@7.7.2: {}
send@1.2.0:
dependencies:
debug: 4.4.1

View 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;

View File

@ -21,17 +21,37 @@ model KVStore {
@@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 {
id Int @id @default(autoincrement())
uuid String @unique // 设备的唯一标识符
name String?
accountId String? // 关联的社区账户ID暂不添加相关代码
accountId String? // 关联的账户ID
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
password String?
passwordHint String?
// 关联的应用安装记录
// 关联关系
account Account? @relation(fields: [accountId], references: [id], onDelete: SetNull)
appInstalls AppInstall[]
kvStore KVStore[] // 设备相关的KV存储
}

166
public/auth-error.html Normal file
View 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
View 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
View 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_uri5分钟过期
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;

View File

@ -1,156 +1,176 @@
import { Router } from "express";
const router = Router();
import {
deviceMiddleware,
passwordMiddleware,
deviceInfoMiddleware,
} from "../middleware/device.js";
import { checkSiteKey } from "../middleware/auth.js";
import { uuidAuth } from "../middleware/uuidAuth.js";
import { jwtAuth } from "../middleware/jwt-auth.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();
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
* 获取应用列表
* 获取所有可用应用列表
*/
router.get(
"/",
errors.catchAsync(async (req, res) => {
const { limit = 20, skip = 0, search } = req.query;
const where = search
? {
OR: [
{ name: { contains: search } },
{ 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 || "授权访问",
const apps = await prisma.app.findMany({
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
});
res.status(200).json({
token: appInstall.token,
appId: Number(appId),
appName: app.name,
deviceUuid: device.uuid,
deviceName: device.name,
note: appInstall.note,
authorizedAt: appInstall.installedAt,
return res.json({
success: true,
apps,
});
})
);
/**
* GET /apps/devices/:deviceUuid/tokens
* 获取设备所有授权token
* GET /apps/tokens
* 获取设备的token列表 (需要设备UUID)
*/
router.get(
"/devices/:deviceUuid/tokens",
deviceInfoMiddleware,
errors.catchAsync(async (req, res) => {
const device = res.locals.device;
"/tokens",
errors.catchAsync(async (req, res, next) => {
const { uuid } = req.query;
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({
where: { deviceId: device.id },
include: {
@ -159,236 +179,39 @@ router.get(
id: true,
name: true,
description: true,
developerName: true,
iconHash: true,
repositoryUrl: true,
createdAt: true,
updatedAt: true,
},
},
},
orderBy: { installedAt: "desc" },
orderBy: { installedAt: 'desc' },
});
res.json({
deviceUuid: device.uuid,
deviceName: device.name,
tokens: installations.map(install => ({
id: install.id,
token: install.token,
app: install.app,
note: install.note,
installedAt: install.installedAt,
updatedAt: install.updatedAt,
createdAt: install.createdAt,
repositoryUrl: install.app.repositoryUrl,
})),
total: installations.length,
});
})
);
const tokens = installations.map(install => ({
id: install.id, // 安装记录ID
token: install.token,
appId: install.app.id,
appName: install.app.name,
appDescription: install.app.description,
installedAt: install.installedAt,
note: install.note,
}));
/**
* 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,
installedAt: install.installedAt,
updatedAt: install.updatedAt,
})),
total,
limit: parseInt(limit),
skip: parseInt(skip),
});
})
);
/**
* 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({
return res.json({
success: true,
message: device.password ? "密码已更新" : "密码已设置",
deviceUuid: device.uuid,
tokens,
deviceUuid: uuid,
});
})
);
/**
* DELETE /apps/devices/:deviceUuid/password
* 删除设备密码
*
* Request Body:
* {
* "password": "当前密码(必须)"
* }
*/
router.delete(
"/devices/:deviceUuid/password",
deviceInfoMiddleware,
router.get("/info/:appid",
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(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,
const { appid } = req.params;
const app = await prisma.app.findUnique({
where: { id: parseInt(appid) },
});
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;

View File

@ -1,14 +1,12 @@
import { Router } from "express";
import deviceCodeStore from "../utils/deviceCodeStore.js";
import { checkSiteKey } from "../middleware/auth.js";
import errors from "../utils/errors.js";
import { PrismaClient } from "@prisma/client";
const router = Router();
const prisma = new PrismaClient();
// 应用站点密钥验证
router.use(checkSiteKey);
/**
* POST /device/code

327
routes/device.js Normal file
View 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;

View File

@ -1,13 +1,11 @@
import { Router } from "express";
const router = Router();
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 { checkSiteKey } from "../middleware/auth.js";
// 应用站点密钥验证和token认证
router.use(checkSiteKey);
router.use(tokenAuth);
// 使用KV专用token认证
router.use(kvTokenAuth);
/**
* GET /_keys

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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
View 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,
});
}