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-02 12:07:50 +08:00
parent aea47eba7d
commit 521522c1d2
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
118 changed files with 15581 additions and 1102 deletions

View File

@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(mkdir:*)",
"Bash(pnpm create:*)",
"Bash(pnpm install:*)",
"Bash(pnpm add:*)",
"Bash(pnpm dlx:*)",
"WebFetch(domain:www.shadcn-vue.com)",
"Bash(pnpm build)"
],
"deny": [],
"ask": []
}
}

21
app.js
View File

@ -8,14 +8,16 @@ import logger from "morgan";
import bodyParser from "body-parser";
import errorHandler from "./middleware/errorHandler.js";
import errors from "./utils/errors.js";
import { initReadme, getReadmeValue } from "./utils/siteinfo.js";
import {
globalLimiter,
apiLimiter,
methodBasedRateLimiter,
tokenBasedRateLimiter,
} from "./middleware/rateLimiter.js";
import kvRouter from "./routes/kv.js";
import kvRouter from "./routes/kv-token.js";
import appsRouter from "./routes/apps.js";
import deviceAuthRouter from "./routes/device-auth.js";
var app = express();
@ -33,9 +35,6 @@ app.disable("x-powered-by");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// 初始化 readme
initReadme();
// 应用全局限速
app.use(globalLimiter);
@ -73,7 +72,7 @@ app.use((req, res, next) => {
next();
});
app.get("/", (req, res) => {
res.render("index.ejs", { readmeValue: getReadmeValue() });
res.render("index.ejs");
});
app.get("/check", apiLimiter, (req, res) => {
res.json({
@ -83,8 +82,14 @@ app.get("/check", apiLimiter, (req, res) => {
});
});
// Mount the KV store router with method-based rate limiting
app.use("/", methodBasedRateLimiter, kvRouter);
// Mount the Apps router with API rate limiting
app.use("/apps", apiLimiter, appsRouter);
// 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);
// 兜底404路由 - 处理所有未匹配的路由
app.use((req, res, next) => {

98
cli/README.md Normal file
View File

@ -0,0 +1,98 @@
# 设备授权流程 - CLI 工具
命令行工具,用于通过设备授权流程获取访问令牌。
## 使用方法
### 基本使用
```bash
node cli/get-token.js
```
### 配置环境变量
```bash
# 设置API服务器地址默认: http://localhost:3030
export API_BASE_URL=https://your-api-server.com
# 设置授权页面地址(默认: https://classworks.xiaomo.tech/authorize
export AUTH_PAGE_URL=https://your-classworks-frontend.com/authorize
# 设置应用ID默认: 1
export APP_ID=1
# 设置站点密钥(如果需要)
export SITE_KEY=your-site-key
# 运行工具
node cli/get-token.js
```
### 使其可执行Linux/Mac
```bash
chmod +x cli/get-token.js
./cli/get-token.js
```
## 工作流程
1. **生成设备代码** - 工具会自动调用 API 生成形如 `1234-ABCD` 的授权码
2. **显示授权链接** - 在终端显示完整的授权URL包含设备代码
3. **等待授权** - 用户点击链接或在授权页面手动输入设备代码完成授权
4. **获取令牌** - 工具自动轮询并获取令牌
5. **保存令牌** - 令牌会保存到 `~/.classworks/token.txt`
## 输出示例
```
设备授权流程 - 令牌获取工具
✓ 设备授权码生成成功!
============================================================
请访问以下地址完成授权:
https://classworks.xiaomo.tech/authorize?app_id=1&mode=devicecode&devicecode=1234-ABCD
设备授权码: 1234-ABCD
============================================================
授权码有效期: 15 分钟
API服务器: http://localhost:3030
请在浏览器中打开上述地址,或在授权页面手动输入设备代码
等待授权中...
等待授权... (1/100)
等待授权... (2/100)
==================================================
✓ 授权成功!令牌获取完成
==================================================
您的访问令牌:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
✓ 令牌已保存到: /home/user/.classworks/token.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
- `appId` / `APP_ID` - 应用ID默认: 1
- `siteKey` / `SITE_KEY` - 站点密钥(如果需要)
- `pollInterval` - 轮询间隔默认3秒
- `maxPolls` - 最大轮询次数默认100次
## 错误处理
- 如果设备代码过期,会显示错误并退出
- 如果轮询超时默认5分钟会显示超时错误
- 如果无法连接到服务器,会显示连接错误

233
cli/get-token.js Normal file
View File

@ -0,0 +1,233 @@
#!/usr/bin/env node
/**
* 设备授权流程 - 命令行工具
*
* 用于演示设备授权流程获取访问令牌
*
* 使用方法
* node cli/get-token.js
* 或配置为可执行chmod +x cli/get-token.js && ./cli/get-token.js
*/
import readline from 'readline';
// 配置
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',
// 轮询间隔(秒)
pollInterval: 3,
// 最大轮询次数
maxPolls: 100,
};
// 颜色输出
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 url = `${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(url, {
...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;
}
}
// 生成设备代码
async function generateDeviceCode() {
logInfo('正在生成设备授权码...');
const data = await request('/auth/device/code', {
method: 'POST',
});
return data;
}
// 轮询获取令牌
async function pollForToken(deviceCode) {
let polls = 0;
return new Promise((resolve, reject) => {
const poll = async () => {
polls++;
if (polls > CONFIG.maxPolls) {
reject(new Error('轮询超时,请重试'));
return;
}
try {
const data = await request(`/auth/device/token?device_code=${deviceCode}`);
if (data.status === 'success') {
resolve(data.token);
} else if (data.status === 'expired') {
reject(new Error('设备代码已过期'));
} else if (data.status === 'pending') {
// 继续轮询
log(`等待授权... (${polls}/${CONFIG.maxPolls})`, colors.dim);
setTimeout(poll, CONFIG.pollInterval * 1000);
}
} catch (error) {
reject(error);
}
};
// 开始轮询
poll();
});
}
// 显示设备代码和授权链接
function displayDeviceCode(deviceCode, expiresIn) {
console.log('\n' + '='.repeat(60));
log(` 请访问以下地址完成授权:`, colors.bright);
console.log('');
// 构建授权URL
const authUrl = `${CONFIG.authPageUrl}?app_id=${CONFIG.appId}&mode=devicecode&devicecode=${deviceCode}`;
log(` ${authUrl}`, colors.cyan + colors.bright);
console.log('');
log(` 设备授权码: ${deviceCode}`, colors.green + colors.bright);
console.log('='.repeat(60));
logInfo(`授权码有效期: ${Math.floor(expiresIn / 60)} 分钟`);
logInfo(`API服务器: ${CONFIG.baseUrl}`);
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.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. 生成设备代码
const { device_code, expires_in } = await generateDeviceCode();
logSuccess('设备授权码生成成功!');
// 2. 显示设备代码和授权链接
displayDeviceCode(device_code, expires_in);
// 3. 提示用户授权
logInfo('请在浏览器中打开上述地址,或在授权页面手动输入设备代码');
logInfo('等待授权中...\n');
// 4. 轮询获取令牌
const token = await pollForToken(device_code);
// 5. 显示令牌
console.log('\n' + '='.repeat(50));
logSuccess('授权成功!令牌获取完成');
console.log('='.repeat(50));
console.log('\n' + colors.bright + '您的访问令牌:' + colors.reset);
log(token, colors.green);
console.log('');
// 6. 保存令牌
await saveToken(token);
// 7. 使用示例
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}`);
console.log('');
process.exit(1);
}
}
// 运行
main();

671
docs/API_CURL_EXAMPLES.md Normal file
View File

@ -0,0 +1,671 @@
# 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
```

226
docs/API_REFACTOR.md Normal file
View File

@ -0,0 +1,226 @@
# 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

@ -0,0 +1,600 @@
# 前端迁移指南
## 概述
本文档描述了后端中间件系统的重构,以及前端需要如何适配这些变化。核心变化是统一了设备信息获取和权限验证流程。
---
## 核心变化
### 1. 统一的设备中间件系统
后端现在使用统一的中间件处理所有与设备UUID相关的操作
- **`deviceMiddleware`**: 自动获取或创建设备,设备不存在时自动创建
- **`requireWriteAuth`**: 验证写权限,检查设备密码
- **`tokenAuth`**: Token认证用于应用访问
### 2. 设备自动创建
**重要变化**: 当使用一个新的UUID访问API时后端会自动创建该设备无需手动调用创建设备接口。
### 3. 权限模型
- **读操作**: 永远不需要密码
- **写操作**: 如果设备设置了密码则需要验证,否则直接允许
---
## 场景1: 基于UUID的直接访问
适用于:用户直接操作设备数据(设备配置、设备管理等)
### 读操作(无需密码)
**请求方式**: `GET /device/:deviceUuid/*`
**特点**:
- 设备不存在时自动创建
- 无需提供密码
- 任何知道UUID的人都可以读取
**请求示例**:
```http
GET /device/550e8400-e29b-41d4-a716-446655440000/info
Headers:
x-site-key: your-site-key
```
**成功响应** (200):
```json
{
"id": 1,
"uuid": "550e8400-e29b-41d4-a716-446655440000",
"name": null,
"password": null,
"passwordHint": null,
"accountId": null,
"createdAt": "2025-01-30T10:00:00.000Z",
"updatedAt": "2025-01-30T10:00:00.000Z"
}
```
### 写操作(需要密码验证)
**请求方式**: `POST|PUT|DELETE /device/:deviceUuid/*`
**特点**:
- 设备不存在时自动创建
- 如果设备设置了密码,必须提供正确密码
- 如果设备没有密码,直接允许写入
#### 密码提供方式
**方式1: 通过请求体(推荐)**
```http
POST /device/550e8400-e29b-41d4-a716-446655440000/config
Headers:
Content-Type: application/json
x-site-key: your-site-key
Body:
{
"password": "device-password",
"data": {
"theme": "dark",
"language": "zh-CN"
}
}
```
**方式2: 通过查询参数**
```http
POST /device/550e8400-e29b-41d4-a716-446655440000/config?password=device-password
Headers:
Content-Type: application/json
x-site-key: your-site-key
Body:
{
"data": {
"theme": "dark",
"language": "zh-CN"
}
}
```
**成功响应** (200):
```json
{
"message": "数据已更新",
"updatedAt": "2025-01-30T10:05:00.000Z"
}
```
**错误响应 - 需要密码** (401):
```json
{
"statusCode": 401,
"message": "此操作需要密码",
"passwordHint": "您的生日8位数字"
}
```
**错误响应 - 密码错误** (401):
```json
{
"statusCode": 401,
"message": "密码错误"
}
```
---
## 场景2: 基于Token的应用访问
适用于应用访问KV存储数据
### 步骤1: 获取Token
**请求方式**: `POST /apps/:appId/authorize`
**请求示例**:
```http
POST /apps/1/authorize
Headers:
Content-Type: application/json
x-site-key: your-site-key
Body:
{
"deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
"password": "device-password",
"note": "我的应用授权"
}
```
**说明**:
- `deviceUuid`: 必填设备UUID
- `password`: 如果设备有密码则必填
- `note`: 可选,授权备注
**成功响应** (200):
```json
{
"token": "clxxx123456789abcdefg",
"appId": 1,
"appName": "我的应用",
"deviceUuid": "550e8400-e29b-41d4-a716-446655440000",
"deviceName": null,
"note": "我的应用授权",
"authorizedAt": "2025-01-30T10:00:00.000Z"
}
```
**错误响应 - 需要密码** (401):
```json
{
"statusCode": 401,
"message": "此操作需要密码",
"passwordHint": "您的生日8位数字"
}
```
### 步骤2: 使用Token访问KV存储
#### Token提供方式
**方式1: Authorization Header推荐**
```http
Headers:
Authorization: Bearer clxxx123456789abcdefg
```
**方式2: Query参数**
```http
?token=clxxx123456789abcdefg
```
**方式3: Request Body**
```json
{
"token": "clxxx123456789abcdefg",
...
}
```
---
### KV API端点
| 方法 | 端点 | 说明 |
|------|------|------|
| GET | `/kv` | 列出所有键(含元数据) |
| GET | `/kv/_keys` | 列出所有键名(仅键名) |
| GET | `/kv/:key` | 获取键值 |
| GET | `/kv/:key/metadata` | 获取键元数据 |
| POST | `/kv/:key` | 创建/更新键值 |
| POST | `/kv/_batchimport` | 批量导入 |
| DELETE | `/kv/:key` | 删除键值 |
---
### GET /kv - 列出所有键(含元数据)
**请求示例**:
```http
GET /kv?sortBy=key&sortDir=asc&limit=10&skip=0
Headers:
Authorization: Bearer clxxx123456789abcdefg
x-site-key: your-site-key
```
**查询参数**:
- `sortBy`: 排序字段key/createdAt/updatedAt默认 key
- `sortDir`: 排序方向asc/desc默认 asc
- `limit`: 每页数量,默认 100
- `skip`: 跳过数量,默认 0
**成功响应** (200):
```json
{
"items": [
{
"deviceId": 1,
"key": "config",
"metadata": {
"creatorIp": "192.168.1.1",
"createdAt": "2025-01-30T10:00:00.000Z",
"updatedAt": "2025-01-30T10:00:00.000Z"
}
},
{
"deviceId": 1,
"key": "user.name",
"metadata": {
"creatorIp": "192.168.1.1",
"createdAt": "2025-01-30T10:01:00.000Z",
"updatedAt": "2025-01-30T10:01:00.000Z"
}
}
],
"total_rows": 25,
"load_more": "/kv?sortBy=key&sortDir=asc&limit=10&skip=10"
}
```
---
### GET /kv/_keys - 列出所有键名
**请求示例**:
```http
GET /kv/_keys?limit=50&skip=0
Headers:
Authorization: Bearer clxxx123456789abcdefg
x-site-key: your-site-key
```
**成功响应** (200):
```json
{
"keys": ["config", "user.name", "user.theme", "app.settings"],
"total_rows": 4,
"current_page": {
"limit": 50,
"skip": 0,
"count": 4
}
}
```
---
### GET /kv/:key - 获取键值
**请求示例**:
```http
GET /kv/config
Headers:
Authorization: Bearer clxxx123456789abcdefg
x-site-key: your-site-key
```
**成功响应** (200):
```json
{
"theme": "dark",
"language": "zh-CN",
"fontSize": 14
}
```
**错误响应 - 键不存在** (404):
```json
{
"statusCode": 404,
"message": "未找到键名为 'config' 的记录"
}
```
---
### GET /kv/:key/metadata - 获取键元数据
**请求示例**:
```http
GET /kv/config/metadata
Headers:
Authorization: Bearer clxxx123456789abcdefg
x-site-key: your-site-key
```
**成功响应** (200):
```json
{
"deviceId": 1,
"key": "config",
"metadata": {
"creatorIp": "192.168.1.1",
"createdAt": "2025-01-30T10:00:00.000Z",
"updatedAt": "2025-01-30T10:05:00.000Z"
}
}
```
---
### POST /kv/:key - 创建/更新键值
**请求示例**:
```http
POST /kv/config
Headers:
Authorization: Bearer clxxx123456789abcdefg
Content-Type: application/json
x-site-key: your-site-key
Body:
{
"theme": "dark",
"language": "zh-CN",
"fontSize": 14
}
```
**成功响应** (200):
```json
{
"deviceId": 1,
"key": "config",
"created": false,
"updatedAt": "2025-01-30T10:10:00.000Z"
}
```
**说明**:
- `created`: true表示新建false表示更新
**错误响应 - 空值** (400):
```json
{
"statusCode": 400,
"message": "请提供有效的JSON值"
}
```
---
### POST /kv/_batchimport - 批量导入
**请求示例**:
```http
POST /kv/_batchimport
Headers:
Authorization: Bearer clxxx123456789abcdefg
Content-Type: application/json
x-site-key: your-site-key
Body:
{
"config": {
"theme": "dark",
"language": "zh-CN"
},
"user.name": {
"firstName": "John",
"lastName": "Doe"
},
"app.settings": {
"notifications": true
}
}
```
**成功响应** (200):
```json
{
"deviceId": 1,
"total": 3,
"successful": 3,
"failed": 0,
"results": [
{
"key": "config",
"created": false
},
{
"key": "user.name",
"created": true
},
{
"key": "app.settings",
"created": true
}
]
}
```
**部分失败响应** (200):
```json
{
"deviceId": 1,
"total": 3,
"successful": 2,
"failed": 1,
"results": [
{
"key": "config",
"created": false
},
{
"key": "user.name",
"created": true
}
],
"errors": [
{
"key": "app.settings",
"error": "Invalid value"
}
]
}
```
---
### DELETE /kv/:key - 删除键值
**请求示例**:
```http
DELETE /kv/config
Headers:
Authorization: Bearer clxxx123456789abcdefg
x-site-key: your-site-key
```
**成功响应** (204):
```
无响应体
```
**错误响应 - 键不存在** (404):
```json
{
"statusCode": 404,
"message": "未找到键名为 'config' 的记录"
}
```
---
## 错误码参考
| 状态码 | 说明 | 场景 |
|--------|------|------|
| 200 | 成功 | 操作成功 |
| 204 | 成功(无内容) | 删除成功 |
| 400 | 请求错误 | 参数缺失或格式错误 |
| 401 | 未授权 | 需要密码、密码错误、Token无效 |
| 403 | 禁止访问 | 权限不足 |
| 404 | 未找到 | 资源不存在 |
| 500 | 服务器错误 | 服务器内部错误 |
---
## 401错误详解
### 需要密码
```json
{
"statusCode": 401,
"message": "此操作需要密码",
"passwordHint": "您的生日8位数字"
}
```
**处理方式**: 提示用户输入密码,使用 `passwordHint` 作为提示信息
### 密码错误
```json
{
"statusCode": 401,
"message": "密码错误"
}
```
**处理方式**: 提示用户密码错误,允许重试
### Token无效
```json
{
"statusCode": 401,
"message": "未提供身份验证令牌"
}
```
```json
{
"statusCode": 401,
"message": "无效的身份验证令牌"
}
```
**处理方式**: 清除本地Token引导用户重新授权
---
## 迁移检查清单
### Phase 1: 基础适配
- [ ] 移除手动创建设备的逻辑(设备会自动创建)
- [ ] 更新密码提供方式从header改为body/query
- [ ] 实现统一的错误处理
- [ ] 更新API端点路径
### Phase 2: Token集成
- [ ] 实现应用授权流程POST /apps/:appId/authorize
- [ ] 集成Token到KV操作
- [ ] 实现Token存储和管理localStorage
- [ ] 处理Token过期/无效场景
### Phase 3: 优化
- [ ] 封装统一的API客户端
- [ ] 实现请求重试机制
- [ ] 添加Loading状态管理
- [ ] 优化错误提示用户体验
### Phase 4: 测试
- [ ] 测试设备自动创建
- [ ] 测试密码验证流程(需要密码、密码错误、密码正确)
- [ ] 测试Token授权流程
- [ ] 测试各种错误场景404、401、400等
---
## 关键注意事项
### 1. 设备自动创建
- ✅ 无需手动创建设备,首次访问自动创建
- ✅ 简化前端流程减少API调用
- ⚠️ 确保UUID使用正确的格式建议使用uuidv4
### 2. 密码处理
- ✅ 读操作永远不需要密码
- ✅ 写操作只在设备设置了密码时才需要
- ⚠️ 密码通过body或query提供不要放在header中
- ⚠️ 注意区分"需要密码"和"密码错误"两种情况
### 3. Token管理
- ✅ Token一次获取可重复使用
- ✅ Token与设备和应用绑定
- ⚠️ Token需要安全存储localStorage/sessionStorage
- ⚠️ Token失效时需要重新授权
### 4. Header要求
- 所有请求必须携带 `x-site-key` header
- Token认证使用 `Authorization: Bearer <token>` header推荐
---

257
docs/apps.md Normal file
View File

@ -0,0 +1,257 @@
# 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

@ -0,0 +1,131 @@
# 设备授权流程 - 前端接口文档
## 概述
类似 Device Authorization Grant 的授权流程,允许应用通过设备代码获取用户的访问令牌。
## 前端相关接口
### 1. 绑定令牌到设备代码
将用户的访问令牌绑定到应用提供的设备代码。
**接口地址:** `POST /auth/device/bind`
**请求头:**
```
Content-Type: application/json
X-Site-Key: your-site-key
```
**请求体:**
```json
{
"device_code": "1234-ABCD",
"token": "user-access-token-string"
}
```
**参数说明:**
- `device_code` (必填): 应用提供给用户的设备授权码,格式如 `1234-ABCD`
- `token` (必填): 用户在系统中已有的有效访问令牌
**成功响应:** `200 OK`
```json
{
"success": true,
"message": "令牌已成功绑定到设备代码"
}
```
**错误响应:**
400 Bad Request - 参数错误
```json
{
"statusCode": 400,
"message": "请提供 device_code 和 token"
}
```
400 Bad Request - 无效的令牌
```json
{
"statusCode": 400,
"message": "无效的令牌"
}
```
400 Bad Request - 设备代码不存在或已过期
```json
{
"statusCode": 400,
"message": "设备代码不存在或已过期"
}
```
---
### 2. 查询设备代码状态(可选,用于调试)
查询设备代码的当前状态,不会删除或修改数据。
**接口地址:** `GET /auth/device/status`
**请求头:**
```
X-Site-Key: your-site-key
```
**查询参数:**
- `device_code` (必填): 设备授权码
**请求示例:**
```
GET /auth/device/status?device_code=1234-ABCD
```
**成功响应:** `200 OK`
设备代码存在:
```json
{
"device_code": "1234-ABCD",
"exists": true,
"has_token": false,
"expires_in": 850,
"created_at": 1234567890000
}
```
设备代码不存在或已过期:
```json
{
"device_code": "1234-ABCD",
"exists": false,
"message": "设备代码不存在或已过期"
}
```
**字段说明:**
- `exists`: 设备代码是否存在且有效
- `has_token`: 是否已绑定令牌
- `expires_in`: 剩余有效时间(秒)
- `created_at`: 创建时间戳(毫秒)
---
## 使用流程
1. **应用端**生成设备代码并展示给用户
2. **用户**在前端页面输入设备代码
3. **前端**调用 `/auth/device/bind` 接口,将用户的 token 绑定到设备代码
4. **应用端**轮询获取到令牌,完成授权
## 注意事项
- 设备代码有效期为 15 分钟
- 令牌必须是系统中已存在的有效令牌
- 设备代码格式固定为 `XXXX-XXXX` (4位数字-4位字母/数字)
- 令牌获取后会从服务器内存中删除,只能获取一次
- 如果需要站点密钥,需在请求头中添加 `X-Site-Key`

224
docs/kv-token.md Normal file
View File

@ -0,0 +1,224 @@
# KV 存储 Token API
本文档描述了基于令牌的 KV 存储 API。这些 API 端点使用应用程序安装令牌进行身份验证,而不是直接使用设备 UUID。
## 身份验证
所有请求都需要提供一个有效的应用程序安装令牌。令牌可以通过以下方式之一提供:
1. **Authorization Header**:
```
Authorization: Bearer YOUR_TOKEN
```
2. **Query Parameter**:
```
?token=YOUR_TOKEN
```
3. **Request Body**:
```json
{
"token": "YOUR_TOKEN"
}
```
## API 端点
### 列出键名
获取命名空间下的所有键名(不包括值)。
```http
GET /kv/token/_keys
```
查询参数:
- `sortBy`: 排序字段(默认:'key'
- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc'
- `limit`: 每页记录数默认100
- `skip`: 跳过的记录数默认0
响应示例:
```json
{
"keys": ["key1", "key2", "key3"],
"total_rows": 3,
"current_page": {
"limit": 100,
"skip": 0,
"count": 3
}
}
```
### 列出所有键值对
获取命名空间下的所有键值对及其元数据。
```http
GET /kv/token/
```
查询参数:
- `sortBy`: 排序字段(默认:'key'
- `sortDir`: 排序方向('asc' 或 'desc',默认:'asc'
- `limit`: 每页记录数默认100
- `skip`: 跳过的记录数默认0
响应示例:
```json
{
"items": [
{
"key": "key1",
"value": { "data": "value1" },
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
],
"total_rows": 1
}
```
### 获取单个键值
获取特定键的值。
```http
GET /kv/token/:key
```
响应示例:
```json
{
"data": "value1"
}
```
### 获取键的元数据
获取特定键的元数据信息。
```http
GET /kv/token/:key/metadata
```
响应示例:
```json
{
"key": "key1",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"creatorIp": "127.0.0.1"
}
```
### 批量导入
批量导入多个键值对。
```http
POST /kv/token/_batchimport
```
请求体示例:
```json
{
"key1": { "data": "value1" },
"key2": { "data": "value2" }
}
```
响应示例:
```json
{
"namespace": "device-uuid",
"total": 2,
"successful": 2,
"failed": 0,
"results": [
{
"key": "key1",
"created": true
},
{
"key": "key2",
"created": true
}
]
}
```
### 创建或更新键值
创建新的键值对或更新现有的键值对。
```http
POST /kv/token/:key
```
请求体示例:
```json
{
"data": "value1"
}
```
响应示例:
```json
{
"namespace": "device-uuid",
"key": "key1",
"created": true,
"updatedAt": "2024-01-01T00:00:00Z"
}
```
### 删除命名空间
删除整个命名空间及其所有键值对。
```http
DELETE /kv/token/
```
成功时返回 204 No Content。
### 删除键值对
删除特定的键值对。
```http
DELETE /kv/token/:key
```
成功时返回 204 No Content。
## 错误处理
所有错误响应都遵循以下格式:
```json
{
"statusCode": 400,
"message": "错误描述"
}
```
常见错误代码:
- 400: 请求参数错误
- 401: 未提供令牌或令牌无效
- 403: 权限不足
- 404: 资源不存在
- 429: 请求过于频繁
- 500: 服务器内部错误
## 权限
API 使用以下权限系统:
- `appReadAuthMiddleware`: 用于读取操作
- `appWriteAuthMiddleware`: 用于写入操作
- `appListAuthMiddleware`: 用于列表操作
这些权限基于应用程序的安装记录中的 `permissionPrefix``permissionKey` 字段进行验证。

614
docs/kv.md Normal file
View File

@ -0,0 +1,614 @@
# 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"
}
```

479
docs/middleware.md Normal file
View File

@ -0,0 +1,479 @@
# 中间件系统文档
## 概述
本项目使用中间件系统来处理设备信息获取、权限验证和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)

214
docs/token-auth-examples.md Normal file
View File

@ -0,0 +1,214 @@
# 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

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

8
kv-admin/.env.example Normal file
View File

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

24
kv-admin/.gitignore vendored Normal file
View File

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

1
kv-admin/.npmrc Normal file
View File

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

3
kv-admin/.vscode/extensions.json vendored Normal file
View File

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

281
kv-admin/README.md Normal file
View File

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

20
kv-admin/components.json Normal file
View File

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

13
kv-admin/index.html Normal file
View File

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

9
kv-admin/jsconfig.json Normal file
View File

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

42
kv-admin/package.json Normal file
View File

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

3898
kv-admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
kv-admin/public/vite.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

7
kv-admin/src/App.vue Normal file
View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>

View File

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

After

Width:  |  Height:  |  Size: 496 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

103
kv-admin/src/lib/api.js Normal file
View File

@ -0,0 +1,103 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
class ApiClient {
constructor(baseUrl, siteKey) {
this.baseUrl = baseUrl
this.siteKey = siteKey
}
async fetch(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
'x-site-key': this.siteKey,
...options.headers,
}
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers,
})
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'Unknown error' }))
throw new Error(error.message || `HTTP ${response.status}`)
}
if (response.status === 204) {
return {}
}
return response.json()
}
// 应用相关 API
async getApps(params = {}) {
const query = new URLSearchParams(params).toString()
return this.fetch(`/apps${query ? `?${query}` : ''}`)
}
async getApp(appId) {
return this.fetch(`/apps/${appId}`)
}
async getAppInstallations(appId, 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),
})
}
// Token 管理 API
async getDeviceTokens(deviceUuid) {
return this.fetch(`/apps/devices/${deviceUuid}/tokens`)
}
async revokeToken(token) {
return this.fetch(`/apps/tokens/${token}`, {
method: 'DELETE',
})
}
// 设备密码管理 API
async setDevicePassword(deviceUuid, data) {
return this.fetch(`/apps/devices/${deviceUuid}/password`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteDevicePassword(deviceUuid, password) {
return this.fetch(`/apps/devices/${deviceUuid}/password`, {
method: 'DELETE',
body: JSON.stringify({ password }),
})
}
async verifyDevicePassword(deviceUuid, password) {
return this.fetch(`/apps/devices/${deviceUuid}/password/verify`, {
method: 'POST',
body: JSON.stringify({ password }),
})
}
// 设备授权相关 API
async bindDeviceCode(deviceCode, token) {
return this.fetch('/auth/device/bind', {
method: 'POST',
body: JSON.stringify({ device_code: deviceCode, token }),
})
}
async getDeviceCodeStatus(deviceCode) {
return this.fetch(`/auth/device/status?device_code=${deviceCode}`)
}
}
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)

37
kv-admin/src/lib/axios.js Normal file
View File

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

View File

@ -0,0 +1,56 @@
// 生成 UUID v4
export function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// 设备 UUID 管理
export const deviceStore = {
// 获取当前设备 UUID
getDeviceUuid() {
return localStorage.getItem('device_uuid')
},
// 设置设备 UUID
setDeviceUuid(uuid) {
localStorage.setItem('device_uuid', uuid)
},
// 生成并保存新的设备 UUID
generateAndSave() {
const uuid = generateUUID()
this.setDeviceUuid(uuid)
return uuid
},
// 获取或生成设备 UUID
getOrGenerate() {
let uuid = this.getDeviceUuid()
if (!uuid) {
uuid = this.generateAndSave()
}
return uuid
},
// 清除设备 UUID
clear() {
localStorage.removeItem('device_uuid')
localStorage.removeItem('device_password')
},
// 设备密码管理
hasPassword() {
return localStorage.getItem('device_password') === 'true'
},
setHasPassword(hasPassword) {
if (hasPassword) {
localStorage.setItem('device_password', 'true')
} else {
localStorage.removeItem('device_password')
}
}
}

View File

@ -0,0 +1,66 @@
// 生成随机设备码
export function generateDeviceCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const segments = []
for (let i = 0; i < 4; i++) {
let segment = ''
for (let j = 0; j < 4; j++) {
segment += chars[Math.floor(Math.random() * chars.length)]
}
segments.push(segment)
}
return segments.join('-')
}
// Token 管理
export const tokenStore = {
// 获取所有 token
getTokens() {
const tokens = localStorage.getItem('kv_tokens')
return tokens ? JSON.parse(tokens) : []
},
// 添加 token
addToken(token, appName = '') {
const tokens = this.getTokens()
const newToken = {
id: Date.now().toString(),
token,
appName,
deviceCode: generateDeviceCode(),
createdAt: new Date().toISOString(),
lastUsed: new Date().toISOString()
}
tokens.push(newToken)
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
return newToken
},
// 删除 token
removeToken(id) {
const tokens = this.getTokens().filter(t => t.id !== id)
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
},
// 更新 token
updateToken(id, updates) {
const tokens = this.getTokens().map(t =>
t.id === id ? { ...t, ...updates } : t
)
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
},
// 获取当前活跃的 token
getActiveToken() {
const activeId = localStorage.getItem('kv_active_token')
if (!activeId) return null
return this.getTokens().find(t => t.id === activeId)
},
// 设置活跃 token
setActiveToken(id) {
localStorage.setItem('kv_active_token', id)
}
}

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}

27
kv-admin/src/main.js Normal file
View File

@ -0,0 +1,27 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import { routes } from 'vue-router/auto-routes'
import { tokenStore } from './lib/tokenStore'
import './style.css'
import App from './App.vue'
const router = createRouter({
history: createWebHistory(),
routes,
})
// Navigation guard for authentication
router.beforeEach((to, _from, next) => {
const requiresAuth = to.meta?.requiresAuth
const activeToken = tokenStore.getActiveToken()
if (requiresAuth && !activeToken) {
next({ path: '/' })
} else {
next()
}
})
createApp(App).use(router).mount('#app')

View File

@ -0,0 +1,341 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, 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 { Badge } from '@/components/ui/badge'
import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle } from 'lucide-vue-next'
import AppCard from '@/components/AppCard.vue'
const route = useRoute()
const router = useRouter()
// URL
const appId = ref(route.query.app_id || '')
const mode = ref(route.query.mode || 'callback') // 'callback' | 'devicecode'
const deviceCode = ref(route.query.devicecode || '')
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 inputDeviceCode = ref('')
const authPassword = ref('')
const authNote = ref('')
//
const appInfo = ref(null)
//
const isDeviceCodeMode = computed(() => mode.value === 'devicecode')
const currentDeviceCode = computed(() => deviceCode.value || inputDeviceCode.value)
//
const loadAppInfo = async () => {
if (!appId.value) return
try {
const data = await apiClient.getApp(appId.value)
appInfo.value = data
} catch (error) {
console.error('Failed to load app info:', error)
}
}
//
const authorizeWithDeviceCode = async () => {
if (!currentDeviceCode.value || !deviceUuid.value) return
step.value = 'loading'
errorMessage.value = ''
try {
// 1. token
const authData = {
deviceUuid: deviceUuid.value,
note: authNote.value || '设备代码授权',
}
if (hasPassword.value && authPassword.value) {
authData.password = authPassword.value
}
const authResult = await apiClient.authorizeApp(appId.value, authData)
const token = authResult.token
// 2. token
await apiClient.bindDeviceCode(currentDeviceCode.value, token)
step.value = 'success'
} catch (error) {
step.value = 'error'
errorMessage.value = error.message || '授权失败'
}
}
//
const authorizeWithCallback = async () => {
if (!deviceUuid.value) return
step.value = 'loading'
errorMessage.value = ''
try {
const authData = {
deviceUuid: deviceUuid.value,
note: authNote.value || '回调授权',
}
if (hasPassword.value && authPassword.value) {
authData.password = authPassword.value
}
const authResult = await apiClient.authorizeApp(appId.value, authData)
const token = authResult.token
// URL token
if (callbackUrl.value) {
const url = new URL(callbackUrl.value)
url.searchParams.set('token', token)
window.location.href = url.toString()
} else {
step.value = 'success'
}
} catch (error) {
step.value = 'error'
errorMessage.value = error.message || '授权失败'
}
}
//
const handleSubmit = async () => {
if (isDeviceCodeMode.value) {
await authorizeWithDeviceCode()
} else {
await authorizeWithCallback()
}
}
//
const goHome = () => {
router.push('/')
}
//
const retry = () => {
step.value = 'input'
errorMessage.value = ''
authPassword.value = ''
}
onMounted(() => {
deviceUuid.value = deviceStore.getOrGenerate()
hasPassword.value = deviceStore.hasPassword()
loadAppInfo()
// devicecode
if (isDeviceCodeMode.value && deviceCode.value) {
inputDeviceCode.value = deviceCode.value
}
})
</script>
<template>
<div class="min-h-screen bg-background flex items-center justify-center p-6">
<Card class="w-full max-w-md">
<!-- 头部 -->
<CardHeader class="space-y-4">
<div class="flex items-center justify-center">
<div class="rounded-full bg-primary/10 p-3">
<Key class="h-8 w-8 text-primary" />
</div>
</div>
<div class="space-y-2 text-center">
<CardTitle class="text-2xl">应用授权</CardTitle>
<CardDescription>
<template v-if="appInfo">
授权 <span class="font-semibold">{{ appInfo.name }}</span> 访问您的 KV 存储
</template>
<template v-else>
授权应用访问您的 KV 存储
</template>
</CardDescription>
</div>
</CardHeader>
<CardContent class="space-y-6">
<!-- 应用信息 -->
<div v-if="appInfo">
<AppCard :app-id="parseInt(appId)" class="mb-4" />
</div>
<!-- 设备信息 -->
<div class="space-y-3">
<Label class="text-sm text-muted-foreground">设备 UUID</Label>
<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>
<Badge v-if="hasPassword" variant="secondary" class="shrink-0">
<Shield class="h-3 w-3 mr-1" />
已保护
</Badge>
</div>
</div>
<!-- 模式标识 -->
<div class="flex items-center gap-2">
<Badge :variant="isDeviceCodeMode ? 'default' : 'secondary'">
{{ isDeviceCodeMode ? '设备代码模式' : '回调模式' }}
</Badge>
</div>
<!-- 输入表单状态 -->
<div v-if="step === 'input'" class="space-y-4">
<!-- 设备代码输入仅设备代码模式且无预填充时显示 -->
<div v-if="isDeviceCodeMode && !deviceCode" class="space-y-2">
<Label for="device-code">设备代码</Label>
<Input
id="device-code"
v-model="inputDeviceCode"
placeholder="例如1234-ABCD"
class="font-mono"
/>
</div>
<!-- 设备代码显示已预填充 -->
<div v-else-if="isDeviceCodeMode && deviceCode" class="space-y-2">
<Label class="text-sm text-muted-foreground">设备代码</Label>
<div class="rounded-lg bg-primary/5 border-2 border-primary/20 p-6">
<div class="text-center font-mono text-2xl font-bold tracking-wider text-primary">
{{ deviceCode }}
</div>
</div>
</div>
<!-- 备注 -->
<div class="space-y-2">
<Label for="note">备注可选</Label>
<Input
id="note"
v-model="authNote"
placeholder="例如CLI 工具访问"
/>
</div>
<!-- 密码输入 -->
<div v-if="hasPassword" class="space-y-2">
<Label for="password">设备密码</Label>
<Input
id="password"
v-model="authPassword"
type="text"
placeholder="输入设备密码以确认授权"
/>
</div>
<!-- 授权按钮 -->
<div class="space-y-3 pt-2">
<Button
@click="handleSubmit"
class="w-full"
size="lg"
:disabled="(isDeviceCodeMode && !currentDeviceCode) || (hasPassword && !authPassword)"
>
<Key class="mr-2 h-4 w-4" />
确认授权
</Button>
<!-- 返回首页 -->
<Button @click="goHome" variant="ghost" class="w-full">
返回管理页面
</Button>
</div>
</div>
<!-- 加载状态 -->
<div v-else-if="step === 'loading'" class="py-8">
<div class="flex flex-col items-center justify-center space-y-4">
<Loader2 class="h-12 w-12 animate-spin text-primary" />
<div class="text-center space-y-1">
<div class="font-medium">正在授权...</div>
<div class="text-sm text-muted-foreground">请稍候</div>
</div>
</div>
</div>
<!-- 成功状态 -->
<div v-else-if="step === 'success'" class="space-y-4">
<div class="flex flex-col items-center justify-center py-8 space-y-4">
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-500" />
</div>
<div class="text-center space-y-2">
<div class="text-lg font-semibold">授权成功</div>
<div class="text-sm text-muted-foreground">
<template v-if="isDeviceCodeMode">
设备代码已绑定您可以继续使用 CLI 工具
</template>
<template v-else>
应用已成功授权
</template>
</div>
</div>
</div>
<Button @click="goHome" class="w-full" size="lg">
返回管理页面
</Button>
</div>
<!-- 错误状态 -->
<div v-else-if="step === 'error'" class="space-y-4">
<div class="flex flex-col items-center justify-center py-8 space-y-4">
<div class="rounded-full bg-red-100 dark:bg-red-900/20 p-4">
<XCircle class="h-12 w-12 text-red-600 dark:text-red-500" />
</div>
<div class="text-center space-y-2">
<div class="text-lg font-semibold">授权失败</div>
<div class="text-sm text-muted-foreground">{{ errorMessage }}</div>
</div>
</div>
<div class="space-y-2">
<Button @click="retry" class="w-full" size="lg">
重试
</Button>
<Button @click="goHome" variant="ghost" class="w-full">
返回管理页面
</Button>
</div>
</div>
<!-- 提示信息 -->
<div v-if="step === 'input'" class="rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 p-4">
<div class="flex gap-3">
<AlertCircle class="h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div class="space-y-1.5 text-sm">
<div class="font-medium text-blue-900 dark:text-blue-100">授权说明</div>
<div class="text-blue-700 dark:text-blue-300 leading-relaxed">
<template v-if="isDeviceCodeMode">
点击"确认授权"应用将获得访问您 KV 存储的权限CLI 工具将自动完成授权流程
</template>
<template v-else>
点击"确认授权"应用将获得访问您 KV 存储的权限
</template>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,422 @@
<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,462 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
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 { 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 AppCard from '@/components/AppCard.vue'
const deviceUuid = ref('')
const tokens = ref([])
const isLoading = ref(false)
const copied = ref(null)
// Dialogs
const showAuthorizeDialog = ref(false)
const showRevokeDialog = ref(false)
const showUuidDialog = ref(false)
const showPasswordDialog = 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 hasPassword = ref(false)
// Group tokens by appId
const groupedByApp = computed(() => {
const groups = {}
tokens.value.forEach(token => {
const appId = token.app.id
if (!groups[appId]) {
groups[appId] = {
appId: appId,
appName: token.app.name || appId,
description: token.app.description || '',
tokens: []
}
}
groups[appId].tokens.push(token)
})
return Object.values(groups)
})
const loadTokens = async () => {
if (!deviceUuid.value) return
isLoading.value = true
try {
const response = await apiClient.getDeviceTokens(deviceUuid.value)
tokens.value = response.tokens || []
} catch (error) {
console.error('Failed to load tokens:', error)
if (error.message.includes('设备不存在')) {
tokens.value = []
}
} finally {
isLoading.value = false
}
}
const authorizeApp = async () => {
if (!appIdToAuthorize.value) return
try {
const data = {
deviceUuid: deviceUuid.value,
note: authNote.value || '授权访问',
}
if (hasPassword.value && authPassword.value) {
data.password = authPassword.value
}
await apiClient.authorizeApp(appIdToAuthorize.value, data)
showAuthorizeDialog.value = false
appIdToAuthorize.value = ''
authPassword.value = ''
authNote.value = ''
await loadTokens()
} catch (error) {
alert('授权失败:' + error.message)
}
}
const confirmRevoke = (token) => {
selectedToken.value = token
showRevokeDialog.value = true
}
const revokeToken = async () => {
if (!selectedToken.value) return
try {
await apiClient.revokeToken(selectedToken.value.token)
showRevokeDialog.value = false
selectedToken.value = null
await loadTokens()
} catch (error) {
alert('撤销失败:' + error.message)
}
}
const copyToClipboard = async (text, id) => {
try {
await navigator.clipboard.writeText(text)
copied.value = id
setTimeout(() => {
copied.value = null
}, 2000)
} catch (error) {
console.error('Failed to copy:', error)
}
}
const updateUuid = () => {
if (newUuid.value.trim()) {
deviceStore.setDeviceUuid(newUuid.value.trim())
} else {
deviceStore.generateAndSave()
}
deviceUuid.value = deviceStore.getDeviceUuid()
showUuidDialog.value = false
newUuid.value = ''
loadTokens()
}
const setPassword = async () => {
if (!newPassword.value) return
try {
const data = {
newPassword: newPassword.value,
}
if (hasPassword.value) {
data.currentPassword = currentPassword.value
}
await apiClient.setDevicePassword(deviceUuid.value, data)
deviceStore.setHasPassword(true)
hasPassword.value = true
showPasswordDialog.value = false
newPassword.value = ''
currentPassword.value = ''
} catch (error) {
alert('设置密码失败:' + error.message)
}
}
const formatDate = (dateString) => {
return new Date(dateString).toLocaleString('zh-CN')
}
onMounted(() => {
deviceUuid.value = deviceStore.getOrGenerate()
hasPassword.value = deviceStore.hasPassword()
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>
<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>
</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>
</CardContent>
</Card>
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold">已授权应用</h2>
<Button @click="showAuthorizeDialog = true" class="gap-2">
<Plus class="h-4 w-4" />
授权新应用
</Button>
</div>
<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="groupedByApp.length === 0" class="border-dashed">
<CardContent class="flex flex-col items-center justify-center py-12">
<Package 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="showAuthorizeDialog = true" variant="outline">
<Plus class="h-4 w-4 mr-2" />
授权应用
</Button>
</CardContent>
</Card>
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div
v-for="app in groupedByApp"
:key="app.appId"
class="space-y-4"
>
<AppCard :app-id="app.appId" />
<Card class="border-dashed">
<CardContent class="p-4 space-y-3">
<div
v-for="(token, index) in app.tokens"
:key="token.token"
class="p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors"
>
<div class="space-y-2">
<div class="flex items-center gap-2">
<Key class="h-3 w-3 text-muted-foreground flex-shrink-0" />
<code class="text-xs font-mono flex-1 truncate">
{{ token.token }}
</code>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 p-0"
@click="copyToClipboard(token.token, token.token)"
>
<CheckCircle2 v-if="copied === token.token" class="h-3 w-3 text-green-500" />
<Copy v-else class="h-3 w-3" />
</Button>
</div>
<div v-if="token.note" class="text-xs text-muted-foreground pl-5">
{{ token.note }}
</div>
<div class="flex items-center justify-between pl-5">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<Clock class="h-3 w-3" />
{{ formatDate(token.installedAt) }}
</div>
<Button
variant="ghost"
size="sm"
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
@click="confirmRevoke(token)"
>
<Trash2 class="h-3 w-3 mr-1" />
撤销
</Button>
</div>
</div>
<div
v-if="index < app.tokens.length - 1"
class="mt-3 border-t border-border/50"
/>
</div>
</CardContent>
</Card>
</div>
</div>
<Dialog v-model:open="showAuthorizeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>授权新应用</DialogTitle>
<DialogDescription>
为应用生成新的访问令牌
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="appId">应用 ID</Label>
<Input
id="appId"
v-model="appIdToAuthorize"
placeholder="输入应用 ID"
/>
</div>
<div class="space-y-2">
<Label for="note">备注可选</Label>
<Input
id="note"
v-model="authNote"
placeholder="为此授权添加备注"
/>
</div>
<div v-if="hasPassword" class="space-y-2">
<Label for="password">设备密码</Label>
<Input
id="password"
v-model="authPassword"
type="text"
placeholder="输入设备密码"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showAuthorizeDialog = false">
取消
</Button>
<Button @click="authorizeApp">
授权
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="showRevokeDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>撤销授权</DialogTitle>
<DialogDescription>
确定要撤销此令牌的授权吗此操作无法撤销
</DialogDescription>
</DialogHeader>
<div v-if="selectedToken" class="py-4">
<div class="p-4 bg-muted rounded-lg space-y-2">
<div class="text-sm">
<span class="font-medium">应用: </span>
{{ selectedToken.app.name }}
</div>
<div class="text-sm">
<span class="font-medium">令牌: </span>
<code class="text-xs">{{ selectedToken.token.slice(0, 16) }}...</code>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showRevokeDialog = false">
取消
</Button>
<Button variant="destructive" @click="revokeToken">
确认撤销
</Button>
</DialogFooter>
</DialogContent>
</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>
<DialogTitle>{{ hasPassword ? '修改密码' : '设置密码' }}</DialogTitle>
<DialogDescription>
{{ hasPassword ? '输入当前密码和新密码' : '为设备设置密码以增强安全性' }}
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-4">
<div v-if="hasPassword" class="space-y-2">
<Label for="currentPassword">当前密码</Label>
<Input
id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="输入当前密码"
/>
</div>
<div class="space-y-2">
<Label for="newPassword">新密码</Label>
<Input
id="newPassword"
v-model="newPassword"
type="password"
placeholder="输入新密码"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showPasswordDialog = false">
取消
</Button>
<Button @click="setPassword">
确认
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</template>

124
kv-admin/src/style.css Normal file
View File

@ -0,0 +1,124 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

26
kv-admin/vite.config.js Normal file
View File

@ -0,0 +1,26 @@
import path from "path"
import { fileURLToPath, URL } from 'node:url'
import tailwindcss from "@tailwindcss/vite"
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import VueRouter from 'unplugin-vue-router/vite'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
VueRouter({
routesFolder: 'src/pages',
dts: false,
}),
vue(),
tailwindcss(),
vueDevTools(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL('./src', import.meta.url)),
},
},
})

View File

@ -1,235 +1,519 @@
import { siteKey } from "../utils/config.js";
/**
* 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 { DecodeAndVerifyPassword, verifySiteKey } from "../utils/crypto.js";
import errors from "../utils/errors.js";
const prisma = new PrismaClient();
export const ACCESS_TYPES = {
PUBLIC: "PUBLIC",
PROTECTED: "PROTECTED",
PRIVATE: "PRIVATE",
};
// 全局可读键列表
const GLOBAL_READABLE_KEYS = [
"_info",
"_check",
"_hint",
"_keys",
];
/**
* 检查站点密钥
*/
export const checkSiteKey = (req, res, next) => {
if (!siteKey) {
return next();
}
const siteKey = req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
const expectedSiteKey = process.env.SITE_KEY;
const providedKey =
req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
if (!verifySiteKey(providedKey, siteKey)) {
if (expectedSiteKey && siteKey !== expectedSiteKey) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
message: "无效的站点密钥",
});
}
next();
};
async function getOrCreateDevice(uuid, className) {
try {
let device = await prisma.device.findUnique({
where: { uuid },
});
if (!device) {
try {
device = await prisma.device.create({
data: {
uuid,
name: className || null,
accessType: ACCESS_TYPES.PUBLIC,
},
});
} catch (error) {
if (error.code === "P2002") {
device = await prisma.device.findUnique({
where: { uuid },
});
} else {
throw error;
}
}
}
if (
device &&
!device.password &&
device.accessType !== ACCESS_TYPES.PUBLIC
) {
device = await prisma.device.update({
where: { uuid },
data: { accessType: ACCESS_TYPES.PUBLIC },
});
}
return device;
} catch (error) {
console.error("Error in getOrCreateDevice:", error);
throw error;
/**
* 通过Token获取设备信息
* @param {string} token - 应用安装Token
* @returns {Promise<Object|null>} 设备信息或null
*/
export const getDeviceByToken = async (token) => {
if (!token) {
return null;
}
}
export const deviceInfoMiddleware = async (req, res, next) => {
const { namespace } = req.params;
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
res.locals.device = device;
next();
} catch (error) {
console.error("Auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const authMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace, req.body?.className);
res.locals.device = device;
if (device.password && password !== device.password) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const readAuthMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (
[ACCESS_TYPES.PUBLIC, ACCESS_TYPES.PROTECTED].includes(device.accessType)
) {
return next();
}
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Read auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const writeAuthMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (device.accessType === ACCESS_TYPES.PUBLIC) {
return next();
}
if (
!device.password ||
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
next();
} catch (error) {
console.error("Write auth middleware error:", error);
res.status(500).json({
statusCode: 500,
message: "服务器内部错误",
});
}
};
export const removePasswordMiddleware = async (req, res, next) => {
const { namespace } = req.params;
const password =
req.headers["x-namespace-password"] ||
req.query.password ||
req.body?.password;
const providedKey =
req.headers["x-site-key"] || req.query.sitekey || req.body?.sitekey;
try {
const device = await getOrCreateDevice(namespace);
res.locals.device = device;
if (!verifySiteKey(providedKey, siteKey)) {
return res.status(401).json({
statusCode: 401,
message: "此服务器已开启站点密钥验证,请提供有效的站点密钥",
});
}
if (
device.password &&
!(await DecodeAndVerifyPassword(password, device.password))
) {
return res.status(401).json({
statusCode: 401,
message: "设备密码验证失败",
});
}
await prisma.device.update({
where: { uuid: device.uuid },
data: {
password: null,
accessType: ACCESS_TYPES.PUBLIC,
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("Remove password middleware error:", error);
res.status(500).json({
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: "服务器内部错误",
});

119
middleware/device.js Normal file
View File

@ -0,0 +1,119 @@
/**
* 设备管理中间件
*
* 提供统一的设备UUID处理逻辑
* 1. deviceMiddleware - 自动获取或创建设备将设备信息存储到res.locals.device
* 2. deviceInfoMiddleware - 仅获取设备信息不创建新设备
* 3. passwordMiddleware - 验证设备密码
*/
import { PrismaClient } from "@prisma/client";
import errors from "../utils/errors.js";
import { verifyDevicePassword } from "../utils/crypto.js";
const prisma = new PrismaClient();
/**
* 设备中间件 - 统一处理设备UUID
*
* 从req.params.deviceUuidreq.params.namespace或req.body.deviceUuid获取UUID
* 如果设备不存在则自动创建
* 将设备信息存储到res.locals.device
*
* 使用方式
* router.post('/path', deviceMiddleware, handler)
* router.get('/path/:deviceUuid', deviceMiddleware, handler)
*/
export const deviceMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid || req.params.namespace || req.body.deviceUuid;
if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID"));
}
// 查找或创建设备
let device = await prisma.device.findUnique({
where: { uuid: deviceUuid },
});
if (!device) {
// 设备不存在,自动创建
device = await prisma.device.create({
data: {
uuid: deviceUuid,
name: null,
password: null,
passwordHint: null,
accountId: null,
},
});
}
// 将设备信息存储到res.locals
res.locals.device = device;
next();
});
/**
* 设备信息中间件 - 仅获取设备信息不创建新设备
*
* 从req.params.deviceUuid获取UUID
* 如果设备不存在则返回404错误
* 将设备信息存储到res.locals.device
*
* 使用方式
* router.get('/path/:deviceUuid', deviceInfoMiddleware, handler)
*/
export const deviceInfoMiddleware = errors.catchAsync(async (req, res, next) => {
const deviceUuid = req.params.deviceUuid || req.params.namespace;
if (!deviceUuid) {
return next(errors.createError(400, "缺少设备UUID"));
}
// 查找设备
const device = await prisma.device.findUnique({
where: { uuid: deviceUuid },
});
if (!device) {
return next(errors.createError(404, "设备不存在"));
}
// 将设备信息存储到res.locals
res.locals.device = device;
next();
});
/**
* 密码验证中间件 - 验证设备密码
*
* 前置条件必须先使用deviceMiddleware或deviceInfoMiddleware
* 从req.body.password获取密码
* 如果设备有密码但未提供或密码错误则返回401错误
*
* 使用方式
* router.post('/path', deviceMiddleware, passwordMiddleware, handler)
*/
export const passwordMiddleware = errors.catchAsync(async (req, res, next) => {
const device = res.locals.device;
const { password } = req.body;
if (!device) {
return next(errors.createError(500, "设备信息未加载请先使用deviceMiddleware"));
}
// 如果设备有密码,验证密码
if (device.password) {
if (!password) {
return next(errors.createError(401, "设备需要密码"));
}
const isValid = await verifyDevicePassword(password, device.password);
if (!isValid) {
return next(errors.createError(401, "密码错误"));
}
}
next();
});

View File

@ -11,6 +11,25 @@ export const getClientIp = (req) => {
);
};
// 从请求中提取Token的函数
const extractToken = (req) => {
return (
req.headers["x-app-token"] ||
req.query.apptoken ||
req.body?.apptoken ||
null
);
};
// 获取限速键优先使用token没有token则使用IP
export const getRateLimitKey = (req) => {
const token = extractToken(req);
if (token) {
return `token:${token}`;
}
return `ip:${getClientIp(req)}`;
};
// 配置全局限速中间件
export const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15分钟
@ -83,6 +102,56 @@ export const batchLimiter = rateLimit({
skipFailedRequests: false,
});
// === Token 专用限速器(更宽松的限制) ===
// Token 读操作限速器
export const tokenReadLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 1024, // 每个token在1分钟内最多1024次读操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "读操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 写操作限速器
export const tokenWriteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 512, // 每个token在1分钟内最多512次写操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "写操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 删除操作限速器
export const tokenDeleteLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 256, // 每个token在1分钟内最多256次删除操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "删除操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// Token 批量操作限速器
export const tokenBatchLimiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1分钟
limit: 128, // 每个token在1分钟内最多128次批量操作
standardHeaders: "draft-7",
legacyHeaders: false,
message: "批量操作请求过于频繁,请稍后再试",
keyGenerator: getRateLimitKey,
skipSuccessfulRequests: false,
skipFailedRequests: false,
});
// 创建一个路由处理中间件根据HTTP方法应用不同的限速器
export const methodBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
@ -105,3 +174,26 @@ export const methodBasedRateLimiter = (req, res, next) => {
// 其他方法使用API限速
return apiLimiter(req, res, next);
};
// Token 专用路由中间件根据HTTP方法应用不同的Token限速器
export const tokenBasedRateLimiter = (req, res, next) => {
// 检查是否是批量导入路由
if (req.method === "POST" && (req.path.endsWith("/_batchimport") || req.path.endsWith("/batch-import"))) {
return tokenBatchLimiter(req, res, next);
} else if (req.method === "GET") {
// 读操作使用Token读限速
return tokenReadLimiter(req, res, next);
} else if (
req.method === "POST" ||
req.method === "PUT" ||
req.method === "PATCH"
) {
// 写操作使用Token写限速
return tokenWriteLimiter(req, res, next);
} else if (req.method === "DELETE") {
// 删除操作使用Token删除限速
return tokenDeleteLimiter(req, res, next);
}
// 其他方法使用Token读限速
return tokenReadLimiter(req, res, next);
};

112
middleware/tokenAuth.js Normal file
View File

@ -0,0 +1,112 @@
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();
});

View File

@ -5,9 +5,9 @@
"scripts": {
"start": "node ./bin/www",
"prisma": "prisma generate",
"prisma:pull": "prisma db pull",
"dev": "NODE_ENV=development nodemon node .bin/www",
"migrate": "node ./scripts/batchMigrate.js"
"migrate": "node ./scripts/batchMigrate.js",
"get-token": "node ./cli/get-token.js"
},
"type": "module",
"dependencies": {
@ -17,7 +17,7 @@
"@opentelemetry/sdk-node": "^0.201.1",
"@opentelemetry/sdk-trace-base": "^2.0.1",
"@opentelemetry/semantic-conventions": "^1.34.0",
"@prisma/client": "6.8.2",
"@prisma/client": "6.16.2",
"axios": "^1.9.0",
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
@ -34,6 +34,6 @@
"uuid": "^11.1.0"
},
"devDependencies": {
"prisma": "6.8.2"
"prisma": "6.16.2"
}
}

2220
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,36 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "mysql"
url = env("DATABASE_URL")
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
}
model KVStore {
namespace String
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
}
model Device {
uuid String @id
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -1,27 +0,0 @@
-- CreateEnum
CREATE TYPE "AccessType" AS ENUM ('PUBLIC', 'PROTECTED', 'PRIVATE');
-- CreateTable
CREATE TABLE "KVStore" (
"namespace" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"creatorIp" TEXT DEFAULT '',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KVStore_pkey" PRIMARY KEY ("namespace","key")
);
-- CreateTable
CREATE TABLE "Device" (
"uuid" TEXT NOT NULL,
"password" TEXT,
"passwordHint" TEXT,
"name" TEXT,
"accessType" "AccessType" NOT NULL DEFAULT 'PUBLIC',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Device_pkey" PRIMARY KEY ("uuid")
);

View File

@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -1,36 +0,0 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum AccessType {
PUBLIC // No password required for read/write
PROTECTED // No password for read, password for write
PRIVATE // Password required for read/write
}
model KVStore {
namespace String
key String
value Json
creatorIp String? @default("")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([namespace, key])
}
model Device {
uuid String @id
password String?
passwordHint String?
name String?
accessType AccessType @default(PUBLIC)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

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