mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-10-21 17:53:11 +00:00
更新到一半
This commit is contained in:
parent
aea47eba7d
commit
521522c1d2
15
.claude/settings.local.json
Normal file
15
.claude/settings.local.json
Normal 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
21
app.js
@ -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
98
cli/README.md
Normal 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
233
cli/get-token.js
Normal 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
671
docs/API_CURL_EXAMPLES.md
Normal 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
226
docs/API_REFACTOR.md
Normal 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
|
600
docs/FRONTEND_MIGRATION_GUIDE.md
Normal file
600
docs/FRONTEND_MIGRATION_GUIDE.md
Normal 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
257
docs/apps.md
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
131
docs/device-auth-frontend.md
Normal file
131
docs/device-auth-frontend.md
Normal 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
224
docs/kv-token.md
Normal 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
614
docs/kv.md
Normal 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
479
docs/middleware.md
Normal 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
214
docs/token-auth-examples.md
Normal 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认证方式。
|
9
kv-admin/.claude/settings.local.json
Normal file
9
kv-admin/.claude/settings.local.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//d/Classworks/ClassworksServer/prisma/**)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
8
kv-admin/.env.example
Normal file
8
kv-admin/.env.example
Normal 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
24
kv-admin/.gitignore
vendored
Normal 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
1
kv-admin/.npmrc
Normal file
@ -0,0 +1 @@
|
||||
node-linker=hoisted
|
3
kv-admin/.vscode/extensions.json
vendored
Normal file
3
kv-admin/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
281
kv-admin/README.md
Normal file
281
kv-admin/README.md
Normal 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
20
kv-admin/components.json
Normal 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
13
kv-admin/index.html
Normal 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
9
kv-admin/jsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
42
kv-admin/package.json
Normal file
42
kv-admin/package.json
Normal 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
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
1
kv-admin/public/vite.svg
Normal 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
7
kv-admin/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
1
kv-admin/src/assets/vue.svg
Normal file
1
kv-admin/src/assets/vue.svg
Normal 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 |
296
kv-admin/src/components/AppCard.vue
Normal file
296
kv-admin/src/components/AppCard.vue
Normal 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>
|
41
kv-admin/src/components/HelloWorld.vue
Normal file
41
kv-admin/src/components/HelloWorld.vue
Normal 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>
|
25
kv-admin/src/components/ui/badge/Badge.vue
Normal file
25
kv-admin/src/components/ui/badge/Badge.vue
Normal 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>
|
24
kv-admin/src/components/ui/badge/index.js
Normal file
24
kv-admin/src/components/ui/badge/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
24
kv-admin/src/components/ui/button/Button.vue
Normal file
24
kv-admin/src/components/ui/button/Button.vue
Normal 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>
|
34
kv-admin/src/components/ui/button/index.js
Normal file
34
kv-admin/src/components/ui/button/index.js
Normal 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",
|
||||
},
|
||||
},
|
||||
);
|
21
kv-admin/src/components/ui/card/Card.vue
Normal file
21
kv-admin/src/components/ui/card/Card.vue
Normal 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>
|
21
kv-admin/src/components/ui/card/CardAction.vue
Normal file
21
kv-admin/src/components/ui/card/CardAction.vue
Normal 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>
|
13
kv-admin/src/components/ui/card/CardContent.vue
Normal file
13
kv-admin/src/components/ui/card/CardContent.vue
Normal 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>
|
16
kv-admin/src/components/ui/card/CardDescription.vue
Normal file
16
kv-admin/src/components/ui/card/CardDescription.vue
Normal 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>
|
16
kv-admin/src/components/ui/card/CardFooter.vue
Normal file
16
kv-admin/src/components/ui/card/CardFooter.vue
Normal 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>
|
21
kv-admin/src/components/ui/card/CardHeader.vue
Normal file
21
kv-admin/src/components/ui/card/CardHeader.vue
Normal 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>
|
16
kv-admin/src/components/ui/card/CardTitle.vue
Normal file
16
kv-admin/src/components/ui/card/CardTitle.vue
Normal 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>
|
7
kv-admin/src/components/ui/card/index.js
Normal file
7
kv-admin/src/components/ui/card/index.js
Normal 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";
|
18
kv-admin/src/components/ui/dialog/Dialog.vue
Normal file
18
kv-admin/src/components/ui/dialog/Dialog.vue
Normal 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>
|
14
kv-admin/src/components/ui/dialog/DialogClose.vue
Normal file
14
kv-admin/src/components/ui/dialog/DialogClose.vue
Normal 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>
|
57
kv-admin/src/components/ui/dialog/DialogContent.vue
Normal file
57
kv-admin/src/components/ui/dialog/DialogContent.vue
Normal 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>
|
25
kv-admin/src/components/ui/dialog/DialogDescription.vue
Normal file
25
kv-admin/src/components/ui/dialog/DialogDescription.vue
Normal 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>
|
18
kv-admin/src/components/ui/dialog/DialogFooter.vue
Normal file
18
kv-admin/src/components/ui/dialog/DialogFooter.vue
Normal 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>
|
16
kv-admin/src/components/ui/dialog/DialogHeader.vue
Normal file
16
kv-admin/src/components/ui/dialog/DialogHeader.vue
Normal 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>
|
29
kv-admin/src/components/ui/dialog/DialogOverlay.vue
Normal file
29
kv-admin/src/components/ui/dialog/DialogOverlay.vue
Normal 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>
|
71
kv-admin/src/components/ui/dialog/DialogScrollContent.vue
Normal file
71
kv-admin/src/components/ui/dialog/DialogScrollContent.vue
Normal 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>
|
25
kv-admin/src/components/ui/dialog/DialogTitle.vue
Normal file
25
kv-admin/src/components/ui/dialog/DialogTitle.vue
Normal 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>
|
14
kv-admin/src/components/ui/dialog/DialogTrigger.vue
Normal file
14
kv-admin/src/components/ui/dialog/DialogTrigger.vue
Normal 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>
|
10
kv-admin/src/components/ui/dialog/index.js
Normal file
10
kv-admin/src/components/ui/dialog/index.js
Normal 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";
|
32
kv-admin/src/components/ui/input/Input.vue
Normal file
32
kv-admin/src/components/ui/input/Input.vue
Normal 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>
|
1
kv-admin/src/components/ui/input/index.js
Normal file
1
kv-admin/src/components/ui/input/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue";
|
29
kv-admin/src/components/ui/label/Label.vue
Normal file
29
kv-admin/src/components/ui/label/Label.vue
Normal 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>
|
1
kv-admin/src/components/ui/label/index.js
Normal file
1
kv-admin/src/components/ui/label/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue";
|
26
kv-admin/src/components/ui/select/Select.vue
Normal file
26
kv-admin/src/components/ui/select/Select.vue
Normal 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>
|
81
kv-admin/src/components/ui/select/SelectContent.vue
Normal file
81
kv-admin/src/components/ui/select/SelectContent.vue
Normal 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>
|
14
kv-admin/src/components/ui/select/SelectGroup.vue
Normal file
14
kv-admin/src/components/ui/select/SelectGroup.vue
Normal 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>
|
47
kv-admin/src/components/ui/select/SelectItem.vue
Normal file
47
kv-admin/src/components/ui/select/SelectItem.vue
Normal 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>
|
14
kv-admin/src/components/ui/select/SelectItemText.vue
Normal file
14
kv-admin/src/components/ui/select/SelectItemText.vue
Normal 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>
|
20
kv-admin/src/components/ui/select/SelectLabel.vue
Normal file
20
kv-admin/src/components/ui/select/SelectLabel.vue
Normal 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>
|
30
kv-admin/src/components/ui/select/SelectScrollDownButton.vue
Normal file
30
kv-admin/src/components/ui/select/SelectScrollDownButton.vue
Normal 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>
|
30
kv-admin/src/components/ui/select/SelectScrollUpButton.vue
Normal file
30
kv-admin/src/components/ui/select/SelectScrollUpButton.vue
Normal 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>
|
21
kv-admin/src/components/ui/select/SelectSeparator.vue
Normal file
21
kv-admin/src/components/ui/select/SelectSeparator.vue
Normal 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>
|
37
kv-admin/src/components/ui/select/SelectTrigger.vue
Normal file
37
kv-admin/src/components/ui/select/SelectTrigger.vue
Normal 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>
|
15
kv-admin/src/components/ui/select/SelectValue.vue
Normal file
15
kv-admin/src/components/ui/select/SelectValue.vue
Normal 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>
|
11
kv-admin/src/components/ui/select/index.js
Normal file
11
kv-admin/src/components/ui/select/index.js
Normal 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";
|
18
kv-admin/src/components/ui/table/Table.vue
Normal file
18
kv-admin/src/components/ui/table/Table.vue
Normal 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>
|
16
kv-admin/src/components/ui/table/TableBody.vue
Normal file
16
kv-admin/src/components/ui/table/TableBody.vue
Normal 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>
|
16
kv-admin/src/components/ui/table/TableCaption.vue
Normal file
16
kv-admin/src/components/ui/table/TableCaption.vue
Normal 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>
|
21
kv-admin/src/components/ui/table/TableCell.vue
Normal file
21
kv-admin/src/components/ui/table/TableCell.vue
Normal 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>
|
31
kv-admin/src/components/ui/table/TableEmpty.vue
Normal file
31
kv-admin/src/components/ui/table/TableEmpty.vue
Normal 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>
|
18
kv-admin/src/components/ui/table/TableFooter.vue
Normal file
18
kv-admin/src/components/ui/table/TableFooter.vue
Normal 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>
|
21
kv-admin/src/components/ui/table/TableHead.vue
Normal file
21
kv-admin/src/components/ui/table/TableHead.vue
Normal 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>
|
13
kv-admin/src/components/ui/table/TableHeader.vue
Normal file
13
kv-admin/src/components/ui/table/TableHeader.vue
Normal 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>
|
21
kv-admin/src/components/ui/table/TableRow.vue
Normal file
21
kv-admin/src/components/ui/table/TableRow.vue
Normal 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>
|
9
kv-admin/src/components/ui/table/index.js
Normal file
9
kv-admin/src/components/ui/table/index.js
Normal 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";
|
7
kv-admin/src/components/ui/table/utils.js
Normal file
7
kv-admin/src/components/ui/table/utils.js
Normal 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
103
kv-admin/src/lib/api.js
Normal 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
37
kv-admin/src/lib/axios.js
Normal 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
|
56
kv-admin/src/lib/deviceStore.js
Normal file
56
kv-admin/src/lib/deviceStore.js
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
66
kv-admin/src/lib/tokenStore.js
Normal file
66
kv-admin/src/lib/tokenStore.js
Normal 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)
|
||||
}
|
||||
}
|
6
kv-admin/src/lib/utils.js
Normal file
6
kv-admin/src/lib/utils.js
Normal 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
27
kv-admin/src/main.js
Normal 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')
|
341
kv-admin/src/pages/authorize.vue
Normal file
341
kv-admin/src/pages/authorize.vue
Normal 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>
|
422
kv-admin/src/pages/dashboard.vue
Normal file
422
kv-admin/src/pages/dashboard.vue
Normal 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>
|
462
kv-admin/src/pages/index.vue
Normal file
462
kv-admin/src/pages/index.vue
Normal 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
124
kv-admin/src/style.css
Normal 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
26
kv-admin/vite.config.js
Normal 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)),
|
||||
},
|
||||
},
|
||||
})
|
@ -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
119
middleware/device.js
Normal 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.deviceUuid、req.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();
|
||||
});
|
@ -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
112
middleware/tokenAuth.js
Normal 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();
|
||||
});
|
@ -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
2220
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
@ -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")
|
||||
);
|
@ -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"
|
@ -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
Loading…
x
Reference in New Issue
Block a user