feat: 实现Redis集成协议并重构项目控制台

refactor(backend): 重构后端服务以支持Redis协议
feat(backend): 添加Redis客户端和服务模块
feat(backend): 实现命令和日志路由处理Redis交互
refactor(frontend): 重构前端状态管理和组件结构
feat(frontend): 实现基于Redis的日志和命令功能
docs: 添加Redis集成协议文档
chore: 更新ESLint配置和依赖
This commit is contained in:
2026-01-12 19:55:51 +08:00
parent 95a4613965
commit 19e65d78dc
29 changed files with 1061 additions and 349 deletions

16
.env.example Normal file
View File

@@ -0,0 +1,16 @@
# Example environment variables for Web_BLS_ProjectConsole
# Copy this file to `.env` and fill values before running locally
# Redis connection
REDIS_HOST=localhost # Redis host (default: localhost)
REDIS_PORT=6379 # Redis port (default: 6379)
REDIS_PASSWORD= # Redis password (leave empty if not set)
REDIS_DB=0 # Redis database number (default: 0)
REDIS_CONNECT_TIMEOUT_MS=2000 # Connection timeout in ms (default: 2000)
# Application
# Optional: port for backend server. Current default in code is 3001 but you can override it here.
PORT=3001
# Node environment (development|production)
NODE_ENV=development

View File

@@ -1,29 +1,26 @@
export default { /** @type {import('eslint').Linter.Config} */
module.exports = {
ignorePatterns: ['dist/**'],
env: { env: {
browser: true, browser: true,
es2021: true, es2021: true,
node: true node: true
}, },
extends: [ extends: ['eslint:recommended', 'plugin:vue/vue3-recommended'],
'eslint:recommended',
'plugin:vue/vue3-recommended'
],
parserOptions: { parserOptions: {
ecmaVersion: 'latest', ecmaVersion: 'latest',
sourceType: 'module' sourceType: 'module'
}, },
plugins: [ plugins: ['vue'],
'vue'
],
rules: { rules: {
'vue/multi-word-component-names': 'off', 'vue/multi-word-component-names': 'off',
'vue/no-unused-vars': 'warn', 'vue/no-unused-vars': 'warn',
'vue/no-unused-components': 'warn', 'vue/no-unused-components': 'warn',
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn', 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
'indent': ['error', 2], indent: ['error', 2],
'quotes': ['error', 'single'], quotes: ['error', 'single'],
'semi': ['error', 'always'], semi: ['error', 'always'],
'no-trailing-spaces': 'error', 'no-trailing-spaces': 'error',
'comma-dangle': ['error', 'always-multiline'], 'comma-dangle': ['error', 'always-multiline'],
'object-curly-spacing': ['error', 'always'], 'object-curly-spacing': ['error', 'always'],
@@ -33,8 +30,8 @@ export default {
{ {
files: ['*.vue'], files: ['*.vue'],
rules: { rules: {
'indent': 'off' indent: 'off'
} }
} }
] ]
} };

View File

@@ -0,0 +1,110 @@
# Redis 对接协议(供外部项目 AI 生成代码使用)
本文档定义“外部项目 ↔ BLS Project Console”之间通过 Redis 交互的 **Key 命名、数据类型、写入方式、读取方式与数据格式**
> 约束:每个需要关联本控制台的外部项目,必须在本项目使用的同一个 Redis 实例中:
> - 写入 2 个 Key状态 + 控制台信息)
> - 读取 1 个 Key控制指令队列
## 1. 命名约定
令:
- `projectName`:外部项目名称(建议只用字母数字下划线 `A-Za-z0-9_`;如使用中文也可,但需保证统一且 UTF-8
固定后缀:
- 状态:`${projectName}_项目状态`
- 控制台:`${projectName}_项目控制台`
- 控制:`${projectName}_控制`
示例projectName = `订单系统`
- `订单系统_项目状态`
- `订单系统_项目控制台`
- `订单系统_控制`
## 2. 外部项目需要写入的 2 个 Key
### 2.1 `${projectName}_项目状态`
- Redis 数据类型:**STRING**
- 写入方式:`SET ${projectName}_项目状态 <status>`
- value状态枚举必须为以下之一
- `在线`
- `离线`
- `故障`
- `报错`
建议(非强制):
- 定期刷新(如 5~30 秒)
- 可设置 TTL 防止僵尸在线(如 `SET key value EX 60`
### 2.2 `${projectName}_项目控制台`
- Redis 数据类型:**LIST**(作为项目向控制台追加的“消息队列/日志队列”)
- 写入方式(推荐 FIFO`RPUSH ${projectName}_项目控制台 <json>`
value推荐格式一条 JSON 字符串,表示“错误/调试信息”或日志记录。
推荐 JSON Schema字段尽量保持稳定便于控制台解析
```json
{
"timestamp": "2026-01-12T12:34:56.789Z",
"level": "info",
"message": "连接成功",
"metadata": {
"module": "redis",
"host": "127.0.0.1"
}
}
```
字段说明:
- `timestamp`ISO-8601 时间字符串
- `level`:建议取值 `info|warn|error|debug`(小写)
- `message`:日志文本
- `metadata`:可选对象(附加信息)
## 3. 外部项目需要读取的 1 个 Key控制指令
### 3.1 `${projectName}_控制`
- Redis 数据类型:**LIST**(控制台向目标项目下发的“命令队列”)
- 队列模式(必须):**Redis List 队列**(生产 `RPUSH`,消费 `BLPOP`;必要时配合 `LTRIM`
- 控制台写入方式FIFO`RPUSH ${targetProjectName}_控制 <json>`
- 外部项目读取方式(阻塞消费,推荐):`BLPOP ${projectName}_控制 0`
> 说明:`BLPOP` 本身会原子性地“取出并移除”队列头元素,通常不需要额外 `LTRIM`。
> 如果你的运行环境/客户端库无法可靠使用 `BLPOP`,或你希望采用“读取 + 修剪LTRIM”的显式确认方式可使用兼容模式
> 1) `LRANGE ${projectName}_控制 0 0` 读取 1 条
> 2) 处理成功后执行 `LTRIM ${projectName}_控制 1 -1` 修剪已消费元素
value**JSON 对象**(在 Redis 中以 JSON 字符串形式存储)。
推荐 JSON Schema
```json
{
"id": "cmd-1700000000000-abc123",
"timestamp": "2026-01-12T12:35:00.000Z",
"source": "BLS Project Console",
"command": "reload",
"args": {
"force": true
}
}
```
字段说明:
- `id`:命令唯一 ID用于追踪
- `timestamp`:命令下发时间
- `source`:固定可写 `BLS Project Console`
- `command`:命令名称或命令文本
- `args`:可选对象参数
## 4. 兼容与错误处理建议
- JSON 解析失败:外部项目应记录错误,并丢弃该条消息(避免死循环阻塞消费)。
- 消息过长:建议控制单条消息大小(例如 < 64KB
- 字符编码:统一 UTF-8。
## 5. 与本项目代码的对应关系(实现中)
后端通过 `/api/commands` 将命令写入 `${targetProjectName}_控制`;通过 `/api/logs` 读取 `${projectName}_项目控制台`(以仓库当前实现为准)。

View File

@@ -0,0 +1,13 @@
# Change: Update Redis Integration Protocol
## Why
需要为“BLS Project Console ↔ 其他业务项目”的 Redis 交互约定一个稳定、可机器生成的协议,明确每个接入项目必须写入的状态与控制台信息,以及必须读取的控制指令队列。
## What Changes
- 统一 Redis Key 命名规则:每个项目写 2 个 key、读 1 个 key
- 明确每个 key 的 Redis 数据类型STRING/LIST与 value 格式(枚举值/JSON
- 对齐 logging / command / redis-connection 三个 capability 的 requirements以便实现端可依据 spec 开发)
## Impact
- Affected specs: specs/redis-connection/spec.md, specs/logging/spec.md, specs/command/spec.md
- Affected code (planned): src/backend/routes/, src/backend/services/, src/frontend/components/

View File

@@ -0,0 +1,15 @@
## MODIFIED Requirements
### Requirement: Command Sending to Redis
The system SHALL send commands to a per-target Redis key.
#### Scenario: Console enqueues a command for a target project
- **WHEN** the user sends a command from the console
- **THEN** the backend SHALL append a JSON message to Redis LIST key `${targetProjectName}_控制`
- **AND** the JSON message SHALL represent the command payload (an object)
#### Scenario: Target project consumes a command
- **WHEN** a target project listens for commands
- **THEN** it SHALL consume messages from `${projectName}_控制` as JSON objects
- **AND** it SHOULD use Redis LIST queue semantics (producer `RPUSH`, consumer `BLPOP`)
- **AND** if `BLPOP` is not available, it MAY use a `LRANGE` + `LTRIM` compatibility pattern

View File

@@ -0,0 +1,14 @@
## MODIFIED Requirements
### Requirement: Log Reading from Redis
The system SHALL read log records from per-project Redis keys.
#### Scenario: External project writes console logs
- **WHEN** an external project emits debug/error information
- **THEN** it SHALL append entries to a Redis LIST key named `${projectName}_项目控制台`
- **AND** each entry SHALL be a JSON string representing a log record
#### Scenario: Server reads project console logs
- **WHEN** the server is configured to show a given project
- **THEN** it SHALL read entries from `${projectName}_项目控制台`
- **AND** it SHALL present them in the console UI with timestamp, level and message

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: Per-Project Status Key
The system SHALL standardize a per-project Redis status key for connected projects.
#### Scenario: External project writes status
- **WHEN** an external project integrates with this console
- **THEN** it SHALL write a Redis STRING key named `${projectName}_项目状态`
- **AND** the value SHALL be one of: `在线`, `离线`, `故障`, `报错`

View File

@@ -0,0 +1,13 @@
## 1. Documentation
- [x] 1.1 Add Redis integration protocol doc for external projects
- [ ] 1.2 Link doc location from README (optional)
## 2. Backend
- [x] 2.1 Add Redis client config + connection helper
- [x] 2.2 Implement command enqueue: write `${targetProjectName}_控制` LIST with JSON payload
- [x] 2.3 Implement log fetch/stream: read `${projectName}_项目控制台` LIST (and status `${projectName}_项目状态` STRING when needed)
## 3. Frontend
- [x] 3.1 Wire selected project name into Console (targetProjectName)
- [x] 3.2 Replace simulated command send with API call to backend
- [x] 3.3 Replace simulated logs with backend-provided logs (polling or SSE)

View File

@@ -62,6 +62,7 @@ BLS Project Console是一个前后端分离的Node.js项目用于从Redis队
- **可靠性**: Redis连接需要具备重连机制确保系统稳定运行 - **可靠性**: Redis连接需要具备重连机制确保系统稳定运行
- **安全性**: API接口需要适当的访问控制 - **安全性**: API接口需要适当的访问控制
- **可扩展性**: 系统设计应支持未来功能扩展 - **可扩展性**: 系统设计应支持未来功能扩展
- **开发约束**: 一旦遇到 lint/build/tooling 失败(例如 ESLint 配置错误),必须优先修复并恢复可用的开发工作流,再继续功能开发
## External Dependencies ## External Dependencies
- **Redis**: 用于存储日志记录和控制台指令的消息队列服务 - **Redis**: 用于存储日志记录和控制台指令的消息队列服务

View File

@@ -9,7 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"start": "node src/backend/server.js", "start": "node src/backend/server.js",
"start:dev": "nodemon src/backend/server.js", "start:dev": "nodemon src/backend/server.js",
"lint": "eslint . --ext .js,.vue", "lint": "eslint . --ext .js,.vue --config .eslintrc.cjs",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"dependencies": { "dependencies": {

14
page.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BLS Project Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/main.js"></script>
</body>
</html>

View File

@@ -1,22 +1,64 @@
import express from 'express' import express from 'express';
const router = express.Router()
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import { projectControlKey } from '../services/redisKeys.js';
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
// 发送指令 // 发送指令
router.post('/', (req, res) => { router.post('/', async (req, res) => {
const { command } = req.body const { targetProjectName, command, args } = req.body;
if (!command) { if (!isNonEmptyString(targetProjectName)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: '指令内容不能为空' message: 'targetProjectName 不能为空',
}) });
} }
// 这里将实现发送指令到Redis队列的逻辑
res.status(200).json({
success: true,
message: '指令已发送到Redis队列'
})
})
export default router if (!isNonEmptyString(command)) {
return res.status(400).json({
success: false,
message: '指令内容不能为空',
});
}
const commandId = `cmd-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const payload = {
id: commandId,
timestamp: new Date().toISOString(),
source: 'BLS Project Console',
command: command.trim(),
args: typeof args === 'object' && args !== null ? args : undefined,
};
try {
const redis = req.app?.locals?.redis || (await getRedisClient());
if (!redis?.isReady) {
return res.status(503).json({
success: false,
message: 'Redis 未就绪',
});
}
const key = projectControlKey(targetProjectName.trim());
await redis.rPush(key, JSON.stringify(payload));
return res.status(200).json({
success: true,
message: '指令已写入目标项目控制队列',
commandId,
});
} catch (err) {
console.error('Failed to enqueue command', err);
return res.status(500).json({
success: false,
message: '写入Redis失败',
});
}
});
export default router;

View File

@@ -1,12 +1,79 @@
import express from 'express' import express from 'express';
const router = express.Router()
const router = express.Router();
import { getRedisClient } from '../services/redisClient.js';
import { projectConsoleKey, projectStatusKey } from '../services/redisKeys.js';
function parsePositiveInt(value, defaultValue) {
const num = Number.parseInt(String(value), 10);
if (!Number.isFinite(num) || num <= 0) return defaultValue;
return num;
}
// 获取日志列表 // 获取日志列表
router.get('/', (req, res) => { router.get('/', async (req, res) => {
// 这里将实现从Redis读取日志的逻辑 const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
res.status(200).json({ const limit = parsePositiveInt(req.query.limit, 200);
logs: []
})
})
export default router if (!projectName) {
return res.status(200).json({
logs: [],
projectStatus: null,
});
}
try {
const redis = req.app?.locals?.redis || (await getRedisClient());
if (!redis?.isReady) {
return res.status(503).json({
logs: [],
projectStatus: null,
message: 'Redis 未就绪',
});
}
const key = projectConsoleKey(projectName);
const list = await redis.lRange(key, -limit, -1);
const logs = list
.map((raw, idx) => {
try {
const parsed = JSON.parse(raw);
const timestamp = parsed.timestamp || new Date().toISOString();
const level = (parsed.level || 'info').toString().toLowerCase();
const message = parsed.message != null ? String(parsed.message) : '';
return {
id: parsed.id || `log-${timestamp}-${idx}`,
timestamp,
level,
message,
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
};
} catch {
return {
id: `log-${Date.now()}-${idx}`,
timestamp: new Date().toISOString(),
level: 'info',
message: raw,
};
}
});
const status = await redis.get(projectStatusKey(projectName));
return res.status(200).json({
logs,
projectStatus: status || null,
});
} catch (err) {
console.error('Failed to read logs', err);
return res.status(500).json({
logs: [],
projectStatus: null,
message: '读取Redis失败',
});
}
});
export default router;

View File

@@ -1,25 +1,46 @@
import express from 'express' import express from 'express';
import cors from 'cors' import cors from 'cors';
import logRoutes from './routes/logs.js' import logRoutes from './routes/logs.js';
import commandRoutes from './routes/commands.js' import commandRoutes from './routes/commands.js';
import { getRedisClient } from './services/redisClient.js';
const app = express() const app = express();
const PORT = 3001 const PORT = 3001;
// 中间件 // 中间件
app.use(cors()) app.use(cors());
app.use(express.json()) app.use(express.json());
// 路由 // 路由
app.use('/api/logs', logRoutes) app.use('/api/logs', logRoutes);
app.use('/api/commands', commandRoutes) app.use('/api/commands', commandRoutes);
// 健康检查 // 健康检查
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.status(200).json({ status: 'ok' }) res.status(200).json({ status: 'ok' });
}) });
// 启动服务器 // 启动服务器
app.listen(PORT, () => { const server = app.listen(PORT, async () => {
console.log(`Server running on port ${PORT}`) console.log(`Server running on port ${PORT}`);
})
try {
const redis = await getRedisClient();
app.locals.redis = redis;
console.log('[redis] client attached to app.locals');
} catch (err) {
console.error('[redis] failed to connect on startup', err);
}
});
process.on('SIGINT', async () => {
try {
if (app.locals.redis) {
await app.locals.redis.quit();
}
} catch {
// ignore
} finally {
server.close(() => process.exit(0));
}
});

View File

@@ -0,0 +1,88 @@
import { createClient } from 'redis';
let client = null;
let connectPromise = null;
let lastErrorLogAt = 0;
const ERROR_LOG_THROTTLE_MS = 60_000;
function parseIntOrDefault(value, defaultValue) {
const num = Number.parseInt(value, 10);
return Number.isFinite(num) ? num : defaultValue;
}
export async function getRedisClient() {
if (client) return client;
const host = process.env.REDIS_HOST || 'localhost';
const port = parseIntOrDefault(process.env.REDIS_PORT, 6379);
const password = process.env.REDIS_PASSWORD || undefined;
const db = parseIntOrDefault(process.env.REDIS_DB, 0);
const url = `redis://${host}:${port}`;
client = createClient({
url,
password,
database: db,
socket: {
connectTimeout: parseIntOrDefault(process.env.REDIS_CONNECT_TIMEOUT_MS, 2000),
reconnectStrategy: (retries) => {
// exponential-ish backoff with cap
return Math.min(1000 * Math.max(1, retries), 30_000);
},
},
});
client.on('error', (err) => {
const now = Date.now();
if (now - lastErrorLogAt >= ERROR_LOG_THROTTLE_MS) {
lastErrorLogAt = now;
const code = err && typeof err === 'object' && 'code' in err ? String(err.code) : 'UNKNOWN';
const message = err && typeof err === 'object' && 'message' in err ? String(err.message) : String(err);
const summary = `${code}: ${message.split('\n')[0]}`;
console.error('[redis] error', summary);
}
});
client.on('connect', () => {
console.log('[redis] connect');
});
client.on('ready', () => {
console.log('[redis] ready');
});
client.on('end', () => {
console.log('[redis] end');
});
connectPromise = client.connect().catch(() => {
// allow retries via next call
connectPromise = null;
return null;
});
return client;
}
export async function ensureRedisReady(options = {}) {
const { timeoutMs = 2000 } = options;
const redis = await getRedisClient();
if (redis.isReady) return true;
if (!connectPromise) {
connectPromise = redis.connect().catch(() => {
connectPromise = null;
return null;
});
}
try {
await Promise.race([
connectPromise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Redis connect timeout')), timeoutMs)),
]);
} catch {
// ignore
}
return redis.isReady;
}

View File

@@ -0,0 +1,11 @@
export function projectStatusKey(projectName) {
return `${projectName}_项目状态`;
}
export function projectConsoleKey(projectName) {
return `${projectName}_项目控制台`;
}
export function projectControlKey(projectName) {
return `${projectName}_控制`;
}

View File

@@ -1,20 +1,43 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<header class="app-header"> <header class="app-header">
<button class="menu-toggle" @click="toggleSidebar" v-if="isMobile"> <button
<span class="menu-icon"></span> v-if="isMobile"
class="menu-toggle"
@click="toggleSidebar"
>
<span class="menu-icon" />
</button> </button>
<h1>BLS Project Console</h1> <h1>BLS Project Console</h1>
<div
class="service-status"
:class="{ 'status-ok': serviceStatus === 'ok', 'status-error': serviceStatus === 'error' }"
>
{{ serviceStatusText }}
</div>
</header> </header>
<div class="app-content"> <div class="app-content">
<!-- 左侧选择区域 --> <!-- 左侧选择区域 -->
<aside class="sidebar" :class="{ 'sidebar-closed': !sidebarOpen && isMobile }"> <aside
class="sidebar"
:class="{ 'sidebar-closed': !sidebarOpen && isMobile }"
>
<router-view name="sidebar" /> <router-view name="sidebar" />
</aside> </aside>
<!-- 右侧调试区域 --> <!-- 右侧调试区域 -->
<main class="main-content"> <main class="main-content">
<router-view name="main" /> <div
v-if="serviceStatus === 'error'"
class="service-error-message"
>
<h3>服务不可用</h3>
<p>无法连接到后端服务请检查服务是否正常运行</p>
</div>
<router-view
v-else
name="main"
/>
</main> </main>
</div> </div>
</div> </div>
@@ -25,6 +48,9 @@ import { ref, onMounted, onUnmounted } from 'vue';
const sidebarOpen = ref(true); const sidebarOpen = ref(true);
const isMobile = ref(false); const isMobile = ref(false);
const serviceStatus = ref('checking');
const serviceStatusText = ref('检查服务状态...');
let checkInterval = null;
// 检测窗口大小变化 // 检测窗口大小变化
const checkMobile = () => { const checkMobile = () => {
@@ -41,14 +67,63 @@ const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value; sidebarOpen.value = !sidebarOpen.value;
}; };
// 检查服务健康状态
const checkServiceHealth = async () => {
try {
console.log('=== 开始检查服务健康状态 ===');
// 测试不同的请求方式
const response = await fetch('http://localhost:3000/api/health', {
method: 'GET',
credentials: 'omit',
referrerPolicy: 'no-referrer',
});
console.log('响应状态码:', response.status);
console.log('响应头:', Object.fromEntries(response.headers));
const responseText = await response.text();
console.log('响应文本:', responseText);
if (response.ok) {
serviceStatus.value = 'ok';
serviceStatusText.value = '服务正常';
console.log('=== 服务健康状态检查通过 ===');
} else {
serviceStatus.value = 'error';
serviceStatusText.value = `服务异常 (${response.status})`;
console.log('=== 服务健康状态检查失败: 状态码异常 ===');
}
} catch (error) {
console.error('=== 服务健康状态检查异常 ===');
console.error('错误类型:', error.name);
console.error('错误消息:', error.message);
console.error('错误堆栈:', error.stack);
serviceStatus.value = 'error';
serviceStatusText.value = '服务不可用: ' + error.message;
}
};
// 监听窗口大小变化 // 监听窗口大小变化
onMounted(() => { onMounted(() => {
checkMobile(); checkMobile();
window.addEventListener('resize', checkMobile); window.addEventListener('resize', checkMobile);
// 初始检查服务状态
checkServiceHealth();
// 定时检查服务状态每5秒
checkInterval = setInterval(checkServiceHealth, 5000);
}); });
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('resize', checkMobile); window.removeEventListener('resize', checkMobile);
// 清除定时检查
if (checkInterval) {
clearInterval(checkInterval);
}
}); });
</script> </script>
@@ -134,6 +209,48 @@ body {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
flex: 1;
}
/* 服务状态指示器 */
.service-status {
font-size: 0.9rem;
padding: 0.3rem 0.8rem;
border-radius: 12px;
font-weight: 500;
margin-right: 1rem;
display: inline-block;
}
.service-status.status-ok {
background-color: #e8f5e8;
color: #2e7d32;
}
.service-status.status-error {
background-color: #ffebee;
color: #c62828;
}
/* 服务错误消息 */
.service-error-message {
background-color: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 8px;
padding: 2rem;
text-align: center;
margin: 2rem;
color: #c62828;
}
.service-error-message h3 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.service-error-message p {
font-size: 1rem;
opacity: 0.8;
} }
.app-content { .app-content {

View File

@@ -6,65 +6,118 @@
<!-- 日志级别筛选 --> <!-- 日志级别筛选 -->
<div class="log-level-filter"> <div class="log-level-filter">
<label class="filter-label">日志级别:</label> <label class="filter-label">日志级别:</label>
<select v-model="selectedLogLevel" class="filter-select"> <select
<option value="all">全部</option> v-model="selectedLogLevel"
<option value="info">信息</option> class="filter-select"
<option value="warn">警告</option> >
<option value="error">错误</option> <option value="all">
<option value="debug">调试</option> 全部
</option>
<option value="info">
信息
</option>
<option value="warn">
警告
</option>
<option value="error">
错误
</option>
<option value="debug">
调试
</option>
</select> </select>
</div> </div>
<!-- 自动滚动开关 --> <!-- 自动滚动开关 -->
<div class="auto-scroll-toggle"> <div class="auto-scroll-toggle">
<label class="toggle-label"> <label class="toggle-label">
<input type="checkbox" v-model="autoScroll" class="toggle-checkbox"> <input
v-model="autoScroll"
type="checkbox"
class="toggle-checkbox"
>
<span class="toggle-text">自动滚动</span> <span class="toggle-text">自动滚动</span>
</label> </label>
</div> </div>
<!-- 日志清理按钮 --> <!-- 日志清理按钮 -->
<button class="clear-logs-btn" @click="clearLogs"> <button
class="clear-logs-btn"
@click="clearLogs"
>
清空日志 清空日志
</button> </button>
<!-- 日志数量显示 --> <!-- 日志数量显示 -->
<div class="log-count"> <div class="log-count">
{{ filteredLogs.length }} / {{ logs.length }} 条日志 {{ filteredLogs.length }} / {{ mergedLogs.length }} 条日志
</div> </div>
</div> </div>
</div> </div>
<!-- 日志显示区域 --> <!-- 日志显示区域 -->
<div class="logs-container" ref="logsContainer"> <div
<div class="log-table-wrapper" ref="logTableWrapper" @scroll="handleScroll"> ref="logsContainer"
class="logs-container"
>
<div
ref="logTableWrapper"
class="log-table-wrapper"
@scroll="handleScroll"
>
<table class="log-table"> <table class="log-table">
<tbody> <tbody>
<tr v-for="log in filteredLogs" :key="log.id" :class="`log-item log-level-${log.level}`"> <tr
v-for="log in filteredLogs"
:key="log.id"
:class="`log-item log-level-${log.level}`"
>
<td class="log-meta"> <td class="log-meta">
<div class="log-timestamp">{{ formatTimestamp(log.timestamp) }}</div> <div class="log-timestamp">
<div class="log-level-badge" :class="`level-${log.level}`"> {{ formatTimestamp(log.timestamp) }}
</div>
<div
class="log-level-badge"
:class="`level-${log.level}`"
>
{{ log.level.toUpperCase() }} {{ log.level.toUpperCase() }}
</div> </div>
</td> </td>
<td class="log-message">{{ log.message }}</td> <td class="log-message">
{{ log.message }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
<div class="empty-logs" v-if="filteredLogs.length === 0"> <div
v-if="filteredLogs.length === 0"
class="empty-logs"
>
<p>暂无日志记录</p> <p>暂无日志记录</p>
</div> </div>
</div> </div>
<!-- 命令输入区域 --> <!-- 命令输入区域 -->
<div class="command-input-container"> <div class="command-input-container">
<div class="command-prompt">$</div> <div class="command-prompt">
<input type="text" v-model="commandInput" class="command-input" placeholder="输入命令..." @keydown.enter="sendCommand" $
ref="commandInputRef" autocomplete="off"> </div>
<button class="send-command-btn" @click="sendCommand"> <input
ref="commandInputRef"
v-model="commandInput"
type="text"
class="command-input"
placeholder="输入命令..."
autocomplete="off"
@keydown.enter="sendCommand"
>
<button
class="send-command-btn"
@click="sendCommand"
>
发送 发送
</button> </button>
</div> </div>
@@ -72,13 +125,16 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch, onMounted } from 'vue'; import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
import axios from 'axios';
import { selectedProjectName } from '../store/projectStore.js';
// 控制台配置 // 控制台配置
const MAX_LOGS = 1000; const MAX_LOGS = 1000;
// 响应式状态 // 响应式状态
const logs = ref([]); const remoteLogs = ref([]);
const uiLogs = ref([]);
const commandInput = ref(''); const commandInput = ref('');
const selectedLogLevel = ref('all'); const selectedLogLevel = ref('all');
const autoScroll = ref(true); const autoScroll = ref(true);
@@ -87,62 +143,64 @@ const logTableWrapper = ref(null);
const commandInputRef = ref(null); const commandInputRef = ref(null);
const isAtBottom = ref(true); const isAtBottom = ref(true);
let pollTimer = null;
const mergedLogs = computed(() => {
const combined = [ ...remoteLogs.value, ...uiLogs.value ]
.filter(Boolean)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
if (combined.length <= MAX_LOGS) return combined;
return combined.slice(combined.length - MAX_LOGS);
});
// 计算过滤后的日志 // 计算过滤后的日志
const filteredLogs = computed(() => { const filteredLogs = computed(() => {
if (selectedLogLevel.value === 'all') { if (selectedLogLevel.value === 'all') {
return logs.value; return mergedLogs.value;
} }
return logs.value.filter(log => log.level === selectedLogLevel.value); return mergedLogs.value.filter(log => log.level === selectedLogLevel.value);
}); });
// 模拟发送命令 function scrollTableToBottom() {
const sendCommand = () => { if (!logTableWrapper.value) return;
setTimeout(() => {
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 60);
}
// 发送命令:写入 Redis LIST `${targetProjectName}_控制`(后端负责 JSON 封装)
const sendCommand = async () => {
if (!commandInput.value.trim()) return; if (!commandInput.value.trim()) return;
const content = commandInput.value.trim(); const content = commandInput.value.trim();
const target = selectedProjectName.value;
// 记录命令日志 if (!target) {
addLog({ addLog({ level: 'error', message: '请先在左侧选择一个目标项目' });
level: 'info', return;
message: `$ ${content}`
});
// 发送后立即强制滚动到表格底部,确保输入框和最新日志可见
if (logTableWrapper.value) {
setTimeout(() => {
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 60);
} }
// 模拟命令执行结果 addLog({ level: 'info', message: `$ ${content}` });
setTimeout(() => { scrollTableToBottom();
try {
const resp = await axios.post('/api/commands', {
targetProjectName: target,
command: content,
});
const commandId = resp?.data?.commandId;
addLog({ addLog({
level: 'info', level: 'info',
message: `执行命令: ${content}` message: `已发送到 ${target}_控制${commandId ? ` (id=${commandId})` : ''}`,
}); });
} catch (err) {
// 模拟不同类型的日志输出 const msg = err?.response?.data?.message || err?.message || '发送失败';
const logTypes = ['info', 'warn', 'error', 'debug']; addLog({ level: 'error', message: `发送到 ${target}_控制 失败: ${msg}` });
const randomLogType = logTypes[Math.floor(Math.random() * logTypes.length)]; } finally {
addLog({ commandInput.value = '';
level: randomLogType, if (commandInputRef.value) commandInputRef.value.focus();
message: `命令执行${randomLogType === 'error' ? '失败' : '成功'}: 这是一条${randomLogType}日志` scrollTableToBottom();
});
// 响应完成后再次确保滚动到表格底部
if (logTableWrapper.value) {
setTimeout(() => {
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 60);
}
}, 500);
// 清空命令输入
commandInput.value = '';
// 保持输入框焦点
if (commandInputRef.value) {
commandInputRef.value.focus();
} }
}; };
@@ -151,17 +209,11 @@ const addLog = (logData) => {
const newLog = { const newLog = {
id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...logData ...logData,
}; };
// 添加新日志 // UI 内部日志(不会被远端轮询覆盖)
logs.value.push(newLog); uiLogs.value.push(newLog);
// 检查日志数量是否超过上限
if (logs.value.length > MAX_LOGS) {
// 删除最旧的日志
logs.value.splice(0, logs.value.length - MAX_LOGS);
}
// 自动滚动到底部(如果启用了自动滚动且用户在底部) // 自动滚动到底部(如果启用了自动滚动且用户在底部)
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) { if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
@@ -173,7 +225,8 @@ const addLog = (logData) => {
// 清空日志 // 清空日志
const clearLogs = () => { const clearLogs = () => {
logs.value = []; remoteLogs.value = [];
uiLogs.value = [];
}; };
// 格式化时间戳 // 格式化时间戳
@@ -184,7 +237,7 @@ const formatTimestamp = (timestamp) => {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit', second: '2-digit',
fractionalSecondDigits: 3 fractionalSecondDigits: 3,
}); });
}; };
@@ -200,29 +253,61 @@ const handleScroll = () => {
// 监听过滤后的日志变化,自动滚动(如果启用) // 监听过滤后的日志变化,自动滚动(如果启用)
watch(filteredLogs, () => { watch(filteredLogs, () => {
if (autoScroll.value && isAtBottom.value && logsContainer.value) { if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
setTimeout(() => { setTimeout(() => {
logsContainer.value.scrollTop = logsContainer.value.scrollHeight; logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
}, 50); }, 50);
} }
}, { deep: true }); }, { deep: true });
// 初始化模拟日志 async function fetchRemoteLogs() {
onMounted(() => { const projectName = selectedProjectName.value;
// 添加一些初始日志 if (!projectName) {
for (let i = 0; i < 10; i++) { remoteLogs.value = [];
const logTypes = ['info', 'warn', 'error', 'debug']; return;
const randomLogType = logTypes[Math.floor(Math.random() * logTypes.length)];
addLog({
level: randomLogType,
message: `初始化日志 ${i + 1}: 这是一条${randomLogType}日志,用于测试控制台功能。`
});
} }
// 保持命令输入框焦点 try {
if (commandInputRef.value) { const resp = await axios.get('/api/logs', {
commandInputRef.value.focus(); params: {
projectName,
limit: Math.min(500, MAX_LOGS),
},
});
const list = Array.isArray(resp?.data?.logs) ? resp.data.logs : [];
remoteLogs.value = list
.map((item, idx) => ({
id: item.id || `remote-${item.timestamp || Date.now()}-${idx}`,
timestamp: item.timestamp || new Date().toISOString(),
level: (item.level || 'info').toString().toLowerCase(),
message: item.message || '',
metadata: item.metadata,
}))
.slice(-MAX_LOGS);
} catch (err) {
const msg = err?.response?.data?.message || err?.message || '读取失败';
addLog({ level: 'error', message: `读取 ${projectName}_项目控制台 失败: ${msg}` });
} }
}
onMounted(async () => {
await fetchRemoteLogs();
pollTimer = setInterval(fetchRemoteLogs, 1200);
if (commandInputRef.value) commandInputRef.value.focus();
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
watch(selectedProjectName, async () => {
await fetchRemoteLogs();
scrollTableToBottom();
}); });
</script> </script>
@@ -389,9 +474,9 @@ onMounted(() => {
vertical-align: top; vertical-align: top;
padding: 0.1rem 0.2rem 0.1rem 0; padding: 0.1rem 0.2rem 0.1rem 0;
white-space: nowrap; white-space: nowrap;
width: 120px; width: 100px;
min-width: 120px; min-width: 100px;
max-width: 120px; max-width: 100px;
} }
.log-timestamp { .log-timestamp {

View File

@@ -4,105 +4,147 @@
<div class="debug-header"> <div class="debug-header">
<h2>调试区域</h2> <h2>调试区域</h2>
<div class="debug-controls"> <div class="debug-controls">
<button class="export-btn" @click="exportDebugInfo"> <button
class="export-btn"
@click="exportDebugInfo"
>
导出调试信息 导出调试信息
</button> </button>
<button class="refresh-btn" @click="refreshDebugInfo"> <button
class="refresh-btn"
@click="refreshDebugInfo"
>
刷新数据 刷新数据
</button> </button>
</div> </div>
</div> </div>
<!-- 筛选条件区域 --> <!-- 筛选条件区域 -->
<div class="filters-container"> <div class="filters-container">
<div class="filter-group"> <div class="filter-group">
<div class="filter-item"> <div class="filter-item">
<label class="filter-label">项目名称</label> <label class="filter-label">项目名称</label>
<select v-model="selectedProjectId" class="filter-select"> <select
<option value="all">全部项目</option> v-model="selectedProjectId"
<option class="filter-select"
v-for="project in projects" >
:key="project.id" <option value="all">
全部项目
</option>
<option
v-for="project in projects"
:key="project.id"
:value="project.id" :value="project.id"
> >
{{ project.name }} {{ project.name }}
</option> </option>
</select> </select>
</div> </div>
<div class="filter-item"> <div class="filter-item">
<label class="filter-label">调试类型</label> <label class="filter-label">调试类型</label>
<select v-model="selectedDebugType" class="filter-select"> <select
<option value="all">全部类型</option> v-model="selectedDebugType"
<option value="api">API请求</option> class="filter-select"
<option value="database">数据库操作</option> >
<option value="cache">缓存操作</option> <option value="all">
<option value="error">错误信息</option> 全部类型
<option value="performance">性能监控</option> </option>
<option value="api">
API请求
</option>
<option value="database">
数据库操作
</option>
<option value="cache">
缓存操作
</option>
<option value="error">
错误信息
</option>
<option value="performance">
性能监控
</option>
</select> </select>
</div> </div>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<div class="filter-item"> <div class="filter-item">
<label class="filter-label">时间范围</label> <label class="filter-label">时间范围</label>
<div class="date-range"> <div class="date-range">
<input <input
type="datetime-local" v-model="startTime"
v-model="startTime" type="datetime-local"
class="date-input" class="date-input"
> >
<span class="date-separator"></span> <span class="date-separator"></span>
<input <input
type="datetime-local" v-model="endTime"
v-model="endTime" type="datetime-local"
class="date-input" class="date-input"
> >
</div> </div>
</div> </div>
<div class="filter-actions"> <div class="filter-actions">
<button class="apply-filters-btn" @click="applyFilters"> <button
class="apply-filters-btn"
@click="applyFilters"
>
应用筛选 应用筛选
</button> </button>
<button class="reset-filters-btn" @click="resetFilters"> <button
class="reset-filters-btn"
@click="resetFilters"
>
重置筛选 重置筛选
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- 调试信息展示区域 --> <!-- 调试信息展示区域 -->
<div class="debug-content"> <div class="debug-content">
<!-- 调试信息列表 --> <!-- 调试信息列表 -->
<div class="debug-list"> <div class="debug-list">
<div <div
class="debug-item" v-for="item in filteredDebugInfo"
v-for="item in filteredDebugInfo"
:key="item.id" :key="item.id"
class="debug-item"
:class="`debug-type-${item.type}`" :class="`debug-type-${item.type}`"
> >
<div class="debug-item-header"> <div class="debug-item-header">
<div class="debug-type-badge" :class="`type-${item.type}`"> <div
class="debug-type-badge"
:class="`type-${item.type}`"
>
{{ getTypeText(item.type) }} {{ getTypeText(item.type) }}
</div> </div>
<div class="debug-timestamp">{{ formatTimestamp(item.timestamp) }}</div> <div class="debug-timestamp">
{{ formatTimestamp(item.timestamp) }}
</div>
<div class="debug-project"> <div class="debug-project">
{{ getProjectName(item.projectId) }} {{ getProjectName(item.projectId) }}
</div> </div>
</div> </div>
<div class="debug-item-content"> <div class="debug-item-content">
<div class="debug-content-main">{{ item.content }}</div> <div class="debug-content-main">
{{ item.content }}
</div>
<!-- 调试元数据 --> <!-- 调试元数据 -->
<div class="debug-metadata" v-if="Object.keys(item.metadata).length > 0"> <div
v-if="Object.keys(item.metadata).length > 0"
class="debug-metadata"
>
<h4>元数据</h4> <h4>元数据</h4>
<div class="metadata-list"> <div class="metadata-list">
<div <div
class="metadata-item" v-for="(value, key) in item.metadata"
v-for="(value, key) in item.metadata"
:key="key" :key="key"
class="metadata-item"
> >
<span class="metadata-key">{{ key }}:</span> <span class="metadata-key">{{ key }}:</span>
<span class="metadata-value">{{ JSON.stringify(value) }}</span> <span class="metadata-value">{{ JSON.stringify(value) }}</span>
@@ -110,58 +152,64 @@
</div> </div>
</div> </div>
</div> </div>
<!-- 展开/折叠按钮 --> <!-- 展开/折叠按钮 -->
<div <div
class="expand-btn" class="expand-btn"
@click="toggleExpand(item.id)"
:class="{ 'expanded': expandedItems.includes(item.id) }" :class="{ 'expanded': expandedItems.includes(item.id) }"
@click="toggleExpand(item.id)"
> >
<span class="expand-icon"> <span class="expand-icon">
{{ expandedItems.includes(item.id) ? '▼' : '▶' }} {{ expandedItems.includes(item.id) ? '▼' : '▶' }}
</span> </span>
</div> </div>
</div> </div>
<!-- 空状态 --> <!-- 空状态 -->
<div class="empty-state" v-if="filteredDebugInfo.length === 0"> <div
v-if="filteredDebugInfo.length === 0"
class="empty-state"
>
<p>没有找到匹配的调试信息</p> <p>没有找到匹配的调试信息</p>
</div> </div>
</div> </div>
</div> </div>
<!-- 分页控件 --> <!-- 分页控件 -->
<div class="pagination" v-if="totalPages > 1"> <div
<button v-if="totalPages > 1"
class="page-btn" class="pagination"
@click="currentPage = 1" >
<button
class="page-btn"
:disabled="currentPage === 1" :disabled="currentPage === 1"
@click="currentPage = 1"
> >
首页 首页
</button> </button>
<button <button
class="page-btn" class="page-btn"
@click="currentPage--"
:disabled="currentPage === 1" :disabled="currentPage === 1"
@click="currentPage--"
> >
上一页 上一页
</button> </button>
<div class="page-info"> <div class="page-info">
{{ currentPage }} / {{ totalPages }} {{ currentPage }} / {{ totalPages }}
</div> </div>
<button <button
class="page-btn" class="page-btn"
@click="currentPage++"
:disabled="currentPage === totalPages" :disabled="currentPage === totalPages"
@click="currentPage++"
> >
下一页 下一页
</button> </button>
<button <button
class="page-btn" class="page-btn"
@click="currentPage = totalPages"
:disabled="currentPage === totalPages" :disabled="currentPage === totalPages"
@click="currentPage = totalPages"
> >
末页 末页
</button> </button>
@@ -179,7 +227,7 @@ const projects = ref([
{ id: 'proj-3', name: '订单处理系统', type: 'backend' }, { id: 'proj-3', name: '订单处理系统', type: 'backend' },
{ id: 'proj-4', name: '移动端应用', type: 'frontend' }, { id: 'proj-4', name: '移动端应用', type: 'frontend' },
{ id: 'proj-5', name: 'API网关', type: 'backend' }, { id: 'proj-5', name: 'API网关', type: 'backend' },
{ id: 'proj-6', name: '管理后台', type: 'frontend' } { id: 'proj-6', name: '管理后台', type: 'frontend' },
]); ]);
// 筛选条件 // 筛选条件
@@ -198,26 +246,26 @@ const debugInfo = ref([]);
// 初始化模拟数据 // 初始化模拟数据
const initDebugData = () => { const initDebugData = () => {
const types = ['api', 'database', 'cache', 'error', 'performance']; const types = [ 'api', 'database', 'cache', 'error', 'performance' ];
const newDebugInfo = []; const newDebugInfo = [];
// 生成50条模拟数据 // 生成50条模拟数据
for (let i = 0; i < 50; i++) { for (let i = 0; i < 50; i++) {
const type = types[Math.floor(Math.random() * types.length)]; const type = types[Math.floor(Math.random() * types.length)];
const projectId = projects.value[Math.floor(Math.random() * projects.value.length)].id; const projectId = projects.value[Math.floor(Math.random() * projects.value.length)].id;
const timestamp = new Date(Date.now() - Math.random() * 3600000 * 24).toISOString(); const timestamp = new Date(Date.now() - Math.random() * 3600000 * 24).toISOString();
// 根据类型生成不同的内容 // 根据类型生成不同的内容
let content = ''; let content = '';
let metadata = {}; let metadata = {};
switch (type) { switch (type) {
case 'api': case 'api':
content = `API请求: GET /api/users/${i + 1}`; content = `API请求: GET /api/users/${i + 1}`;
metadata = { metadata = {
status: Math.random() > 0.1 ? 200 : 404, status: Math.random() > 0.1 ? 200 : 404,
responseTime: Math.floor(Math.random() * 500) + 50, responseTime: Math.floor(Math.random() * 500) + 50,
ip: `192.168.1.${Math.floor(Math.random() * 255)}` ip: `192.168.1.${Math.floor(Math.random() * 255)}`,
}; };
break; break;
case 'database': case 'database':
@@ -225,7 +273,7 @@ const initDebugData = () => {
metadata = { metadata = {
queryTime: Math.floor(Math.random() * 100) + 10, queryTime: Math.floor(Math.random() * 100) + 10,
rowsAffected: Math.floor(Math.random() * 10) + 1, rowsAffected: Math.floor(Math.random() * 10) + 1,
table: 'users' table: 'users',
}; };
break; break;
case 'cache': case 'cache':
@@ -233,19 +281,19 @@ const initDebugData = () => {
metadata = { metadata = {
cacheKey: `user:${i + 1}`, cacheKey: `user:${i + 1}`,
cacheType: Math.random() > 0.5 ? 'redis' : 'memcached', cacheType: Math.random() > 0.5 ? 'redis' : 'memcached',
ttl: 3600 ttl: 3600,
}; };
break; break;
case 'error': case 'error':
content = `错误信息: Error: Cannot read property 'name' of undefined`; content = '错误信息: Error: Cannot read property \'name\' of undefined';
metadata = { metadata = {
errorType: 'TypeError', errorType: 'TypeError',
stackTrace: [ stackTrace: [
'at Object.<anonymous> (/app/src/index.js:10:20)', 'at Object.<anonymous> (/app/src/index.js:10:20)',
'at Module._compile (internal/modules/cjs/loader.js:1085:14)', 'at Module._compile (internal/modules/cjs/loader.js:1085:14)',
'at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)' 'at Object.Module._extensions..js (internal/modules/cjs/loader.js:1114:10)',
], ],
severity: 'high' severity: 'high',
}; };
break; break;
case 'performance': case 'performance':
@@ -254,82 +302,82 @@ const initDebugData = () => {
metric: 'pageLoadTime', metric: 'pageLoadTime',
value: Math.floor(Math.random() * 2000) + 500, value: Math.floor(Math.random() * 2000) + 500,
threshold: 1500, threshold: 1500,
status: Math.random() > 0.3 ? 'warning' : 'ok' status: Math.random() > 0.3 ? 'warning' : 'ok',
}; };
break; break;
} }
newDebugInfo.push({ newDebugInfo.push({
id: `debug-${Date.now()}-${i}`, id: `debug-${Date.now()}-${i}`,
projectId, projectId,
timestamp, timestamp,
type, type,
content, content,
metadata metadata,
}); });
} }
// 按时间降序排序 // 按时间降序排序
debugInfo.value = newDebugInfo.sort((a, b) => debugInfo.value = newDebugInfo.sort((a, b) =>
new Date(b.timestamp) - new Date(a.timestamp) new Date(b.timestamp) - new Date(a.timestamp),
); );
}; };
// 计算过滤后的调试信息 // 计算过滤后的调试信息
const filteredDebugInfo = computed(() => { const filteredDebugInfo = computed(() => {
let result = [...debugInfo.value]; let result = [ ...debugInfo.value ];
// 按项目筛选 // 按项目筛选
if (selectedProjectId.value !== 'all') { if (selectedProjectId.value !== 'all') {
result = result.filter(item => item.projectId === selectedProjectId.value); result = result.filter(item => item.projectId === selectedProjectId.value);
} }
// 按调试类型筛选 // 按调试类型筛选
if (selectedDebugType.value !== 'all') { if (selectedDebugType.value !== 'all') {
result = result.filter(item => item.type === selectedDebugType.value); result = result.filter(item => item.type === selectedDebugType.value);
} }
// 按时间范围筛选 // 按时间范围筛选
if (startTime.value) { if (startTime.value) {
const startDate = new Date(startTime.value); const startDate = new Date(startTime.value);
result = result.filter(item => new Date(item.timestamp) >= startDate); result = result.filter(item => new Date(item.timestamp) >= startDate);
} }
if (endTime.value) { if (endTime.value) {
const endDate = new Date(endTime.value); const endDate = new Date(endTime.value);
result = result.filter(item => new Date(item.timestamp) <= endDate); result = result.filter(item => new Date(item.timestamp) <= endDate);
} }
// 分页处理 // 分页处理
const startIndex = (currentPage.value - 1) * pageSize.value; const startIndex = (currentPage.value - 1) * pageSize.value;
const endIndex = startIndex + pageSize.value; const endIndex = startIndex + pageSize.value;
return result.slice(startIndex, endIndex); return result.slice(startIndex, endIndex);
}); });
// 计算总页数 // 计算总页数
const totalPages = computed(() => { const totalPages = computed(() => {
let result = [...debugInfo.value]; let result = [ ...debugInfo.value ];
// 应用相同的过滤条件来计算总条数 // 应用相同的过滤条件来计算总条数
if (selectedProjectId.value !== 'all') { if (selectedProjectId.value !== 'all') {
result = result.filter(item => item.projectId === selectedProjectId.value); result = result.filter(item => item.projectId === selectedProjectId.value);
} }
if (selectedDebugType.value !== 'all') { if (selectedDebugType.value !== 'all') {
result = result.filter(item => item.type === selectedDebugType.value); result = result.filter(item => item.type === selectedDebugType.value);
} }
if (startTime.value) { if (startTime.value) {
const startDate = new Date(startTime.value); const startDate = new Date(startTime.value);
result = result.filter(item => new Date(item.timestamp) >= startDate); result = result.filter(item => new Date(item.timestamp) >= startDate);
} }
if (endTime.value) { if (endTime.value) {
const endDate = new Date(endTime.value); const endDate = new Date(endTime.value);
result = result.filter(item => new Date(item.timestamp) <= endDate); result = result.filter(item => new Date(item.timestamp) <= endDate);
} }
return Math.ceil(result.length / pageSize.value); return Math.ceil(result.length / pageSize.value);
}); });
@@ -366,13 +414,13 @@ const exportDebugInfo = () => {
selectedProjectId: selectedProjectId.value, selectedProjectId: selectedProjectId.value,
selectedDebugType: selectedDebugType.value, selectedDebugType: selectedDebugType.value,
startTime: startTime.value, startTime: startTime.value,
endTime: endTime.value endTime: endTime.value,
}, },
debugInfo: filteredDebugInfo.value debugInfo: filteredDebugInfo.value,
}; };
// 创建下载链接 // 创建下载链接
const blob = new Blob([JSON.stringify(dataToExport, null, 2)], { type: 'application/json' }); const blob = new Blob([ JSON.stringify(dataToExport, null, 2) ], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -406,7 +454,7 @@ const getTypeText = (type) => {
database: '数据库操作', database: '数据库操作',
cache: '缓存操作', cache: '缓存操作',
error: '错误信息', error: '错误信息',
performance: '性能监控' performance: '性能监控',
}; };
return typeMap[type] || type; return typeMap[type] || type;
}; };
@@ -420,7 +468,7 @@ const formatTimestamp = (timestamp) => {
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
second: '2-digit' second: '2-digit',
}); });
}; };
@@ -841,49 +889,49 @@ onMounted(() => {
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
} }
.filter-item { .filter-item {
max-width: 100%; max-width: 100%;
min-width: auto; min-width: auto;
} }
.filter-group:last-child { .filter-group:last-child {
align-items: stretch; align-items: stretch;
} }
.filter-actions { .filter-actions {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.debug-controls { .debug-controls {
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
} }
.export-btn, .export-btn,
.refresh-btn { .refresh-btn {
width: 100%; width: 100%;
} }
.debug-item-header { .debug-item-header {
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
gap: 0.5rem; gap: 0.5rem;
} }
.expand-btn { .expand-btn {
position: static; position: static;
align-self: flex-end; align-self: flex-end;
margin-top: -0.5rem; margin-top: -0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.pagination { .pagination {
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.5rem; gap: 0.5rem;
} }
.page-btn { .page-btn {
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
font-size: 0.8rem; font-size: 0.8rem;

View File

@@ -4,23 +4,22 @@
<div class="selector-header"> <div class="selector-header">
<h2>项目选择</h2> <h2>项目选择</h2>
</div> </div>
<!-- 项目列表 --> <!-- 项目列表 -->
<div class="project-list"> <div class="project-list">
<div <div v-for="project in projects" :key="project.id" class="project-item"
v-for="project in projects" :class="{ 'project-selected': selectedProjectId === project.id }" @click="selectProject(project)">
:key="project.id"
class="project-item"
:class="{ 'project-selected': selectedProjectId === project.id }"
@click="selectProject(project)"
>
<!-- 项目状态指示器 --> <!-- 项目状态指示器 -->
<div class="project-status" :class="`status-${project.status}`"></div> <div class="project-status" :class="`status-${project.status}`" />
<!-- 项目信息 --> <!-- 项目信息 -->
<div class="project-info"> <div class="project-info">
<div class="project-name">{{ project.name }}</div> <div class="project-name">
<div class="project-description">{{ project.description }}</div> {{ project.name }}
</div>
<div class="project-description">
{{ project.description }}
</div>
<div class="project-meta"> <div class="project-meta">
<span class="project-type" :class="`type-${project.type}`"> <span class="project-type" :class="`type-${project.type}`">
{{ project.type === 'backend' ? '后端' : '前端' }} {{ project.type === 'backend' ? '后端' : '前端' }}
@@ -28,9 +27,9 @@
<span class="project-status-text">{{ getStatusText(project.status) }}</span> <span class="project-status-text">{{ getStatusText(project.status) }}</span>
</div> </div>
</div> </div>
<!-- 选择指示器 --> <!-- 选择指示器 -->
<div class="project-select-indicator" v-if="selectedProjectId === project.id"> <div v-if="selectedProjectId === project.id" class="project-select-indicator">
<span class="select-icon"></span> <span class="select-icon"></span>
</div> </div>
</div> </div>
@@ -42,11 +41,11 @@
import { ref } from 'vue'; import { ref } from 'vue';
// 定义props和emits // 定义props和emits
const props = defineProps({ defineProps({
selectedProjectId: { selectedProjectId: {
type: String, type: String,
default: null default: null,
} },
}); });
const emit = defineEmits(['project-selected']); const emit = defineEmits(['project-selected']);
@@ -58,43 +57,43 @@ const projects = ref([
name: '用户管理系统', name: '用户管理系统',
type: 'backend', type: 'backend',
description: '基于Node.js的用户管理后端服务', description: '基于Node.js的用户管理后端服务',
status: 'running' status: 'running',
}, },
{ {
id: 'proj-2', id: 'proj-2',
name: '数据可视化平台', name: '数据可视化平台',
type: 'frontend', type: 'frontend',
description: '基于Vue.js的数据可视化前端应用', description: '基于Vue.js的数据可视化前端应用',
status: 'running' status: 'running',
}, },
{ {
id: 'proj-3', id: 'proj-3',
name: '订单处理系统', name: '订单处理系统',
type: 'backend', type: 'backend',
description: '高性能订单处理后端服务', description: '高性能订单处理后端服务',
status: 'stopped' status: 'stopped',
}, },
{ {
id: 'proj-4', id: 'proj-4',
name: '移动端应用', name: '移动端应用',
type: 'frontend', type: 'frontend',
description: '基于React Native的移动端应用', description: '基于React Native的移动端应用',
status: 'error' status: 'error',
}, },
{ {
id: 'proj-5', id: 'proj-5',
name: 'API网关', name: 'API网关',
type: 'backend', type: 'backend',
description: '微服务架构的API网关', description: '微服务架构的API网关',
status: 'running' status: 'running',
}, },
{ {
id: 'proj-6', id: 'proj-6',
name: '管理后台', name: '管理后台',
type: 'frontend', type: 'frontend',
description: '基于Vue 3的管理后台系统', description: '基于Vue 3的管理后台系统',
status: 'running' status: 'running',
} },
]); ]);
// 选择项目 // 选择项目
@@ -107,7 +106,7 @@ const getStatusText = (status) => {
const statusMap = { const statusMap = {
running: '运行中', running: '运行中',
stopped: '已停止', stopped: '已停止',
error: '错误' error: '错误',
}; };
return statusMap[status] || status; return statusMap[status] || status;
}; };

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue' import { createApp } from 'vue';
import App from './App.vue' import App from './App.vue';
import router from './router' import router from './router';
const app = createApp(App) const app = createApp(App);
app.use(router) app.use(router);
app.mount('#app') app.mount('#app');

View File

@@ -1,6 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router';
import MainView from '../views/MainView.vue' import MainView from '../views/MainView.vue';
import SidebarView from '../views/SidebarView.vue' import SidebarView from '../views/SidebarView.vue';
const routes = [ const routes = [
{ {
@@ -8,14 +8,14 @@ const routes = [
name: 'main', name: 'main',
components: { components: {
sidebar: SidebarView, sidebar: SidebarView,
main: MainView main: MainView,
} },
} },
] ];
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes routes,
}) });
export default router export default router;

View File

@@ -0,0 +1,10 @@
import { ref } from 'vue';
export const selectedProjectId = ref('');
export const selectedProjectName = ref('');
export function setSelectedProject(project) {
if (!project) return;
selectedProjectId.value = project.id || '';
selectedProjectName.value = project.name || '';
}

View File

@@ -1,25 +1,45 @@
<template> <template>
<div class="command-view"> <div class="command-view">
<h2>发送指令</h2> <h2>发送指令</h2>
<form @submit.prevent="sendCommand" class="command-form"> <form
class="command-form"
@submit.prevent="sendCommand"
>
<div class="form-group"> <div class="form-group">
<label for="command">指令内容</label> <label for="command">指令内容</label>
<textarea <textarea
id="command" id="command"
v-model="command" v-model="command"
rows="5" rows="5"
placeholder="请输入要发送的指令..." placeholder="请输入要发送的指令..."
required required
></textarea> />
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">发送指令</button> <button
<button type="button" class="btn btn-secondary" @click="clearCommand">清空</button> type="submit"
class="btn btn-primary"
>
发送指令
</button>
<button
type="button"
class="btn btn-secondary"
@click="clearCommand"
>
清空
</button>
</div> </div>
</form> </form>
<div v-if="response" class="response-section"> <div
v-if="response"
class="response-section"
>
<h3>发送结果</h3> <h3>发送结果</h3>
<div class="response-content" :class="{ 'success': response.success, 'error': !response.success }"> <div
class="response-content"
:class="{ 'success': response.success, 'error': !response.success }"
>
{{ response.message }} {{ response.message }}
</div> </div>
</div> </div>
@@ -27,24 +47,24 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue';
const command = ref('') const command = ref('');
const response = ref(null) const response = ref(null);
const sendCommand = () => { const sendCommand = () => {
// 这里将实现发送指令的逻辑 // 这里将实现发送指令的逻辑
response.value = { response.value = {
success: true, success: true,
message: `指令已发送: ${command.value}` message: `指令已发送: ${command.value}`,
} };
command.value = '' command.value = '';
} };
const clearCommand = () => { const clearCommand = () => {
command.value = '' command.value = '';
response.value = null response.value = null;
} };
</script> </script>
<style scoped> <style scoped>

View File

@@ -2,24 +2,33 @@
<div class="log-view"> <div class="log-view">
<h2>日志记录</h2> <h2>日志记录</h2>
<div class="log-container"> <div class="log-container">
<div v-if="logs.length === 0" class="empty-state"> <div
v-if="logs.length === 0"
class="empty-state"
>
<p>暂无日志记录</p> <p>暂无日志记录</p>
</div> </div>
<div v-for="(log, index) in logs" :key="index" class="log-item"> <div
v-for="(log, index) in logs"
:key="index"
class="log-item"
>
<div class="log-header"> <div class="log-header">
<span class="log-timestamp">{{ log.timestamp }}</span> <span class="log-timestamp">{{ log.timestamp }}</span>
<span class="log-level">{{ log.level }}</span> <span class="log-level">{{ log.level }}</span>
</div> </div>
<div class="log-message">{{ log.message }}</div> <div class="log-message">
{{ log.message }}
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue';
const logs = ref([]) const logs = ref([]);
</script> </script>
<style scoped> <style scoped>

View File

@@ -8,17 +8,7 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue';
import Console from '../components/Console.vue'; import Console from '../components/Console.vue';
// 选中的项目ID用于在组件间共享状态
const selectedProjectId = ref('all');
// 接收来自项目选择器的项目选择事件
const handleProjectSelected = (project) => {
selectedProjectId.value = project.id;
console.log('选中项目:', project);
};
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,19 +1,22 @@
<template> <template>
<div class="sidebar-view"> <div class="sidebar-view">
<ProjectSelector :selectedProjectId="selectedProjectId" @project-selected="handleProjectSelected" /> <ProjectSelector
:selected-project-id="selectedId"
@project-selected="handleProjectSelected"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'; import { computed } from 'vue';
import ProjectSelector from '../components/ProjectSelector.vue'; import ProjectSelector from '../components/ProjectSelector.vue';
import { selectedProjectId, setSelectedProject } from '../store/projectStore.js';
// 选中的项目ID const selectedId = computed(() => selectedProjectId.value);
const selectedProjectId = ref('all');
// 项目选择事件 // 项目选择事件
const handleProjectSelected = (project) => { const handleProjectSelected = (project) => {
selectedProjectId.value = project.id; setSelectedProject(project);
console.log('选中项目:', project); console.log('选中项目:', project);
// 这里可以通过事件总线或状态管理工具将选中的项目传递给其他组件 // 这里可以通过事件总线或状态管理工具将选中的项目传递给其他组件
}; };

View File

@@ -1,26 +1,26 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue';
import { resolve } from 'path' import { resolve } from 'path';
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [ vue() ],
root: 'src/frontend', root: 'src/frontend',
build: { build: {
outDir: '../../dist', outDir: '../../dist',
emptyOutDir: true emptyOutDir: true,
}, },
server: { server: {
port: 3000, port: 3000,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:3001',
changeOrigin: true changeOrigin: true,
} },
} },
}, },
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src/frontend') '@': resolve(__dirname, 'src/frontend'),
} },
} },
}) });

BIN
样品图.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB