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:
16
.env.example
Normal file
16
.env.example
Normal 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
|
||||
@@ -1,29 +1,26 @@
|
||||
export default {
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
ignorePatterns: ['dist/**'],
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true
|
||||
},
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:vue/vue3-recommended'
|
||||
],
|
||||
extends: ['eslint:recommended', 'plugin:vue/vue3-recommended'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module'
|
||||
},
|
||||
plugins: [
|
||||
'vue'
|
||||
],
|
||||
plugins: ['vue'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-unused-vars': 'warn',
|
||||
'vue/no-unused-components': 'warn',
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'warn',
|
||||
'indent': ['error', 2],
|
||||
'quotes': ['error', 'single'],
|
||||
'semi': ['error', 'always'],
|
||||
indent: ['error', 2],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'always'],
|
||||
'no-trailing-spaces': 'error',
|
||||
'comma-dangle': ['error', 'always-multiline'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
@@ -33,8 +30,8 @@ export default {
|
||||
{
|
||||
files: ['*.vue'],
|
||||
rules: {
|
||||
'indent': 'off'
|
||||
indent: 'off'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
110
docs/redis-integration-protocol.md
Normal file
110
docs/redis-integration-protocol.md
Normal 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}_项目控制台`(以仓库当前实现为准)。
|
||||
13
openspec/changes/update-redis-protocol/proposal.md
Normal file
13
openspec/changes/update-redis-protocol/proposal.md
Normal 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/
|
||||
15
openspec/changes/update-redis-protocol/specs/command/spec.md
Normal file
15
openspec/changes/update-redis-protocol/specs/command/spec.md
Normal 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
|
||||
14
openspec/changes/update-redis-protocol/specs/logging/spec.md
Normal file
14
openspec/changes/update-redis-protocol/specs/logging/spec.md
Normal 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
|
||||
@@ -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: `在线`, `离线`, `故障`, `报错`
|
||||
13
openspec/changes/update-redis-protocol/tasks.md
Normal file
13
openspec/changes/update-redis-protocol/tasks.md
Normal 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)
|
||||
@@ -62,6 +62,7 @@ BLS Project Console是一个前后端分离的Node.js项目,用于从Redis队
|
||||
- **可靠性**: Redis连接需要具备重连机制,确保系统稳定运行
|
||||
- **安全性**: API接口需要适当的访问控制
|
||||
- **可扩展性**: 系统设计应支持未来功能扩展
|
||||
- **开发约束**: 一旦遇到 lint/build/tooling 失败(例如 ESLint 配置错误),必须优先修复并恢复可用的开发工作流,再继续功能开发
|
||||
|
||||
## External Dependencies
|
||||
- **Redis**: 用于存储日志记录和控制台指令的消息队列服务
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"preview": "vite preview",
|
||||
"start": "node 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 ."
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
14
page.html
Normal file
14
page.html
Normal 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>
|
||||
@@ -1,22 +1,64 @@
|
||||
import express from 'express'
|
||||
const router = express.Router()
|
||||
import express from 'express';
|
||||
|
||||
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) => {
|
||||
const { command } = req.body
|
||||
router.post('/', async (req, res) => {
|
||||
const { targetProjectName, command, args } = req.body;
|
||||
|
||||
if (!command) {
|
||||
if (!isNonEmptyString(targetProjectName)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '指令内容不能为空'
|
||||
})
|
||||
message: 'targetProjectName 不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
// 这里将实现发送指令到Redis队列的逻辑
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: '指令已发送到Redis队列'
|
||||
})
|
||||
})
|
||||
if (!isNonEmptyString(command)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '指令内容不能为空',
|
||||
});
|
||||
}
|
||||
|
||||
export default router
|
||||
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;
|
||||
@@ -1,12 +1,79 @@
|
||||
import express from 'express'
|
||||
const router = express.Router()
|
||||
import express from 'express';
|
||||
|
||||
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) => {
|
||||
// 这里将实现从Redis读取日志的逻辑
|
||||
res.status(200).json({
|
||||
logs: []
|
||||
})
|
||||
})
|
||||
router.get('/', async (req, res) => {
|
||||
const projectName = typeof req.query.projectName === 'string' ? req.query.projectName.trim() : '';
|
||||
const limit = parsePositiveInt(req.query.limit, 200);
|
||||
|
||||
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;
|
||||
@@ -1,25 +1,46 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import logRoutes from './routes/logs.js'
|
||||
import commandRoutes from './routes/commands.js'
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import logRoutes from './routes/logs.js';
|
||||
import commandRoutes from './routes/commands.js';
|
||||
import { getRedisClient } from './services/redisClient.js';
|
||||
|
||||
const app = express()
|
||||
const PORT = 3001
|
||||
const app = express();
|
||||
const PORT = 3001;
|
||||
|
||||
// 中间件
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 路由
|
||||
app.use('/api/logs', logRoutes)
|
||||
app.use('/api/commands', commandRoutes)
|
||||
app.use('/api/logs', logRoutes);
|
||||
app.use('/api/commands', commandRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' })
|
||||
})
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`)
|
||||
})
|
||||
const server = app.listen(PORT, async () => {
|
||||
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));
|
||||
}
|
||||
});
|
||||
88
src/backend/services/redisClient.js
Normal file
88
src/backend/services/redisClient.js
Normal 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;
|
||||
}
|
||||
11
src/backend/services/redisKeys.js
Normal file
11
src/backend/services/redisKeys.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export function projectStatusKey(projectName) {
|
||||
return `${projectName}_项目状态`;
|
||||
}
|
||||
|
||||
export function projectConsoleKey(projectName) {
|
||||
return `${projectName}_项目控制台`;
|
||||
}
|
||||
|
||||
export function projectControlKey(projectName) {
|
||||
return `${projectName}_控制`;
|
||||
}
|
||||
@@ -1,20 +1,43 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<header class="app-header">
|
||||
<button class="menu-toggle" @click="toggleSidebar" v-if="isMobile">
|
||||
<span class="menu-icon"></span>
|
||||
<button
|
||||
v-if="isMobile"
|
||||
class="menu-toggle"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<span class="menu-icon" />
|
||||
</button>
|
||||
<h1>BLS Project Console</h1>
|
||||
<div
|
||||
class="service-status"
|
||||
:class="{ 'status-ok': serviceStatus === 'ok', 'status-error': serviceStatus === 'error' }"
|
||||
>
|
||||
{{ serviceStatusText }}
|
||||
</div>
|
||||
</header>
|
||||
<div class="app-content">
|
||||
<!-- 左侧选择区域 -->
|
||||
<aside class="sidebar" :class="{ 'sidebar-closed': !sidebarOpen && isMobile }">
|
||||
<aside
|
||||
class="sidebar"
|
||||
:class="{ 'sidebar-closed': !sidebarOpen && isMobile }"
|
||||
>
|
||||
<router-view name="sidebar" />
|
||||
</aside>
|
||||
|
||||
<!-- 右侧调试区域 -->
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,6 +48,9 @@ import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const sidebarOpen = ref(true);
|
||||
const isMobile = ref(false);
|
||||
const serviceStatus = ref('checking');
|
||||
const serviceStatusText = ref('检查服务状态...');
|
||||
let checkInterval = null;
|
||||
|
||||
// 检测窗口大小变化
|
||||
const checkMobile = () => {
|
||||
@@ -41,14 +67,63 @@ const toggleSidebar = () => {
|
||||
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(() => {
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
|
||||
// 初始检查服务状态
|
||||
checkServiceHealth();
|
||||
|
||||
// 定时检查服务状态(每5秒)
|
||||
checkInterval = setInterval(checkServiceHealth, 5000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkMobile);
|
||||
|
||||
// 清除定时检查
|
||||
if (checkInterval) {
|
||||
clearInterval(checkInterval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -134,6 +209,48 @@ body {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
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 {
|
||||
|
||||
@@ -6,65 +6,118 @@
|
||||
<!-- 日志级别筛选 -->
|
||||
<div class="log-level-filter">
|
||||
<label class="filter-label">日志级别:</label>
|
||||
<select v-model="selectedLogLevel" class="filter-select">
|
||||
<option value="all">全部</option>
|
||||
<option value="info">信息</option>
|
||||
<option value="warn">警告</option>
|
||||
<option value="error">错误</option>
|
||||
<option value="debug">调试</option>
|
||||
<select
|
||||
v-model="selectedLogLevel"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="all">
|
||||
全部
|
||||
</option>
|
||||
<option value="info">
|
||||
信息
|
||||
</option>
|
||||
<option value="warn">
|
||||
警告
|
||||
</option>
|
||||
<option value="error">
|
||||
错误
|
||||
</option>
|
||||
<option value="debug">
|
||||
调试
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 自动滚动开关 -->
|
||||
<div class="auto-scroll-toggle">
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 日志清理按钮 -->
|
||||
<button class="clear-logs-btn" @click="clearLogs">
|
||||
<button
|
||||
class="clear-logs-btn"
|
||||
@click="clearLogs"
|
||||
>
|
||||
清空日志
|
||||
</button>
|
||||
|
||||
<!-- 日志数量显示 -->
|
||||
<div class="log-count">
|
||||
{{ filteredLogs.length }} / {{ logs.length }} 条日志
|
||||
{{ filteredLogs.length }} / {{ mergedLogs.length }} 条日志
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 日志显示区域 -->
|
||||
<div class="logs-container" ref="logsContainer">
|
||||
<div class="log-table-wrapper" ref="logTableWrapper" @scroll="handleScroll">
|
||||
<div
|
||||
ref="logsContainer"
|
||||
class="logs-container"
|
||||
>
|
||||
<div
|
||||
ref="logTableWrapper"
|
||||
class="log-table-wrapper"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<table class="log-table">
|
||||
<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">
|
||||
<div class="log-timestamp">{{ formatTimestamp(log.timestamp) }}</div>
|
||||
<div class="log-level-badge" :class="`level-${log.level}`">
|
||||
<div class="log-timestamp">
|
||||
{{ formatTimestamp(log.timestamp) }}
|
||||
</div>
|
||||
<div
|
||||
class="log-level-badge"
|
||||
:class="`level-${log.level}`"
|
||||
>
|
||||
{{ log.level.toUpperCase() }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="log-message">{{ log.message }}</td>
|
||||
<td class="log-message">
|
||||
{{ log.message }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="empty-logs" v-if="filteredLogs.length === 0">
|
||||
<div
|
||||
v-if="filteredLogs.length === 0"
|
||||
class="empty-logs"
|
||||
>
|
||||
<p>暂无日志记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 命令输入区域 -->
|
||||
<div class="command-input-container">
|
||||
<div class="command-prompt">$</div>
|
||||
<input type="text" v-model="commandInput" class="command-input" placeholder="输入命令..." @keydown.enter="sendCommand"
|
||||
ref="commandInputRef" autocomplete="off">
|
||||
<button class="send-command-btn" @click="sendCommand">
|
||||
<div class="command-prompt">
|
||||
$
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
@@ -72,13 +125,16 @@
|
||||
</template>
|
||||
|
||||
<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 logs = ref([]);
|
||||
const remoteLogs = ref([]);
|
||||
const uiLogs = ref([]);
|
||||
const commandInput = ref('');
|
||||
const selectedLogLevel = ref('all');
|
||||
const autoScroll = ref(true);
|
||||
@@ -87,62 +143,64 @@ const logTableWrapper = ref(null);
|
||||
const commandInputRef = ref(null);
|
||||
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(() => {
|
||||
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);
|
||||
});
|
||||
|
||||
// 模拟发送命令
|
||||
const sendCommand = () => {
|
||||
function scrollTableToBottom() {
|
||||
if (!logTableWrapper.value) return;
|
||||
setTimeout(() => {
|
||||
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
||||
}, 60);
|
||||
}
|
||||
|
||||
// 发送命令:写入 Redis LIST `${targetProjectName}_控制`(后端负责 JSON 封装)
|
||||
const sendCommand = async () => {
|
||||
if (!commandInput.value.trim()) return;
|
||||
|
||||
const content = commandInput.value.trim();
|
||||
const target = selectedProjectName.value;
|
||||
|
||||
// 记录命令日志
|
||||
addLog({
|
||||
level: 'info',
|
||||
message: `$ ${content}`
|
||||
});
|
||||
|
||||
// 发送后立即强制滚动到表格底部,确保输入框和最新日志可见
|
||||
if (logTableWrapper.value) {
|
||||
setTimeout(() => {
|
||||
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
||||
}, 60);
|
||||
if (!target) {
|
||||
addLog({ level: 'error', message: '请先在左侧选择一个目标项目' });
|
||||
return;
|
||||
}
|
||||
|
||||
// 模拟命令执行结果
|
||||
setTimeout(() => {
|
||||
addLog({ level: 'info', message: `$ ${content}` });
|
||||
scrollTableToBottom();
|
||||
|
||||
try {
|
||||
const resp = await axios.post('/api/commands', {
|
||||
targetProjectName: target,
|
||||
command: content,
|
||||
});
|
||||
|
||||
const commandId = resp?.data?.commandId;
|
||||
addLog({
|
||||
level: 'info',
|
||||
message: `执行命令: ${content}`
|
||||
message: `已发送到 ${target}_控制${commandId ? ` (id=${commandId})` : ''}`,
|
||||
});
|
||||
|
||||
// 模拟不同类型的日志输出
|
||||
const logTypes = ['info', 'warn', 'error', 'debug'];
|
||||
const randomLogType = logTypes[Math.floor(Math.random() * logTypes.length)];
|
||||
addLog({
|
||||
level: randomLogType,
|
||||
message: `命令执行${randomLogType === 'error' ? '失败' : '成功'}: 这是一条${randomLogType}日志`
|
||||
});
|
||||
|
||||
// 响应完成后再次确保滚动到表格底部
|
||||
if (logTableWrapper.value) {
|
||||
setTimeout(() => {
|
||||
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
||||
}, 60);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// 清空命令输入
|
||||
commandInput.value = '';
|
||||
|
||||
// 保持输入框焦点
|
||||
if (commandInputRef.value) {
|
||||
commandInputRef.value.focus();
|
||||
} catch (err) {
|
||||
const msg = err?.response?.data?.message || err?.message || '发送失败';
|
||||
addLog({ level: 'error', message: `发送到 ${target}_控制 失败: ${msg}` });
|
||||
} finally {
|
||||
commandInput.value = '';
|
||||
if (commandInputRef.value) commandInputRef.value.focus();
|
||||
scrollTableToBottom();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -151,17 +209,11 @@ const addLog = (logData) => {
|
||||
const newLog = {
|
||||
id: `log-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
...logData
|
||||
...logData,
|
||||
};
|
||||
|
||||
// 添加新日志
|
||||
logs.value.push(newLog);
|
||||
|
||||
// 检查日志数量是否超过上限
|
||||
if (logs.value.length > MAX_LOGS) {
|
||||
// 删除最旧的日志
|
||||
logs.value.splice(0, logs.value.length - MAX_LOGS);
|
||||
}
|
||||
// UI 内部日志(不会被远端轮询覆盖)
|
||||
uiLogs.value.push(newLog);
|
||||
|
||||
// 自动滚动到底部(如果启用了自动滚动且用户在底部)
|
||||
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
|
||||
@@ -173,7 +225,8 @@ const addLog = (logData) => {
|
||||
|
||||
// 清空日志
|
||||
const clearLogs = () => {
|
||||
logs.value = [];
|
||||
remoteLogs.value = [];
|
||||
uiLogs.value = [];
|
||||
};
|
||||
|
||||
// 格式化时间戳
|
||||
@@ -184,7 +237,7 @@ const formatTimestamp = (timestamp) => {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
fractionalSecondDigits: 3
|
||||
fractionalSecondDigits: 3,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -200,29 +253,61 @@ const handleScroll = () => {
|
||||
|
||||
// 监听过滤后的日志变化,自动滚动(如果启用)
|
||||
watch(filteredLogs, () => {
|
||||
if (autoScroll.value && isAtBottom.value && logsContainer.value) {
|
||||
if (autoScroll.value && isAtBottom.value && logTableWrapper.value) {
|
||||
setTimeout(() => {
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight;
|
||||
logTableWrapper.value.scrollTop = logTableWrapper.value.scrollHeight;
|
||||
}, 50);
|
||||
}
|
||||
}, { deep: true });
|
||||
|
||||
// 初始化模拟日志
|
||||
onMounted(() => {
|
||||
// 添加一些初始日志
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const logTypes = ['info', 'warn', 'error', 'debug'];
|
||||
const randomLogType = logTypes[Math.floor(Math.random() * logTypes.length)];
|
||||
addLog({
|
||||
level: randomLogType,
|
||||
message: `初始化日志 ${i + 1}: 这是一条${randomLogType}日志,用于测试控制台功能。`
|
||||
});
|
||||
async function fetchRemoteLogs() {
|
||||
const projectName = selectedProjectName.value;
|
||||
if (!projectName) {
|
||||
remoteLogs.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// 保持命令输入框焦点
|
||||
if (commandInputRef.value) {
|
||||
commandInputRef.value.focus();
|
||||
try {
|
||||
const resp = await axios.get('/api/logs', {
|
||||
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>
|
||||
|
||||
@@ -389,9 +474,9 @@ onMounted(() => {
|
||||
vertical-align: top;
|
||||
padding: 0.1rem 0.2rem 0.1rem 0;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
min-width: 120px;
|
||||
max-width: 120px;
|
||||
width: 100px;
|
||||
min-width: 100px;
|
||||
max-width: 100px;
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
<div class="debug-header">
|
||||
<h2>调试区域</h2>
|
||||
<div class="debug-controls">
|
||||
<button class="export-btn" @click="exportDebugInfo">
|
||||
<button
|
||||
class="export-btn"
|
||||
@click="exportDebugInfo"
|
||||
>
|
||||
导出调试信息
|
||||
</button>
|
||||
<button class="refresh-btn" @click="refreshDebugInfo">
|
||||
<button
|
||||
class="refresh-btn"
|
||||
@click="refreshDebugInfo"
|
||||
>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
@@ -18,8 +24,13 @@
|
||||
<div class="filter-group">
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">项目名称</label>
|
||||
<select v-model="selectedProjectId" class="filter-select">
|
||||
<option value="all">全部项目</option>
|
||||
<select
|
||||
v-model="selectedProjectId"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="all">
|
||||
全部项目
|
||||
</option>
|
||||
<option
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
@@ -32,13 +43,28 @@
|
||||
|
||||
<div class="filter-item">
|
||||
<label class="filter-label">调试类型</label>
|
||||
<select v-model="selectedDebugType" class="filter-select">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="api">API请求</option>
|
||||
<option value="database">数据库操作</option>
|
||||
<option value="cache">缓存操作</option>
|
||||
<option value="error">错误信息</option>
|
||||
<option value="performance">性能监控</option>
|
||||
<select
|
||||
v-model="selectedDebugType"
|
||||
class="filter-select"
|
||||
>
|
||||
<option value="all">
|
||||
全部类型
|
||||
</option>
|
||||
<option value="api">
|
||||
API请求
|
||||
</option>
|
||||
<option value="database">
|
||||
数据库操作
|
||||
</option>
|
||||
<option value="cache">
|
||||
缓存操作
|
||||
</option>
|
||||
<option value="error">
|
||||
错误信息
|
||||
</option>
|
||||
<option value="performance">
|
||||
性能监控
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,24 +74,30 @@
|
||||
<label class="filter-label">时间范围</label>
|
||||
<div class="date-range">
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="startTime"
|
||||
type="datetime-local"
|
||||
class="date-input"
|
||||
>
|
||||
<span class="date-separator">至</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
v-model="endTime"
|
||||
type="datetime-local"
|
||||
class="date-input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button class="apply-filters-btn" @click="applyFilters">
|
||||
<button
|
||||
class="apply-filters-btn"
|
||||
@click="applyFilters"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button class="reset-filters-btn" @click="resetFilters">
|
||||
<button
|
||||
class="reset-filters-btn"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
@@ -77,32 +109,42 @@
|
||||
<!-- 调试信息列表 -->
|
||||
<div class="debug-list">
|
||||
<div
|
||||
class="debug-item"
|
||||
v-for="item in filteredDebugInfo"
|
||||
:key="item.id"
|
||||
class="debug-item"
|
||||
:class="`debug-type-${item.type}`"
|
||||
>
|
||||
<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) }}
|
||||
</div>
|
||||
<div class="debug-timestamp">{{ formatTimestamp(item.timestamp) }}</div>
|
||||
<div class="debug-timestamp">
|
||||
{{ formatTimestamp(item.timestamp) }}
|
||||
</div>
|
||||
<div class="debug-project">
|
||||
{{ getProjectName(item.projectId) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div class="metadata-list">
|
||||
<div
|
||||
class="metadata-item"
|
||||
v-for="(value, key) in item.metadata"
|
||||
:key="key"
|
||||
class="metadata-item"
|
||||
>
|
||||
<span class="metadata-key">{{ key }}:</span>
|
||||
<span class="metadata-value">{{ JSON.stringify(value) }}</span>
|
||||
@@ -114,8 +156,8 @@
|
||||
<!-- 展开/折叠按钮 -->
|
||||
<div
|
||||
class="expand-btn"
|
||||
@click="toggleExpand(item.id)"
|
||||
:class="{ 'expanded': expandedItems.includes(item.id) }"
|
||||
@click="toggleExpand(item.id)"
|
||||
>
|
||||
<span class="expand-icon">
|
||||
{{ expandedItems.includes(item.id) ? '▼' : '▶' }}
|
||||
@@ -124,25 +166,31 @@
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="empty-state" v-if="filteredDebugInfo.length === 0">
|
||||
<div
|
||||
v-if="filteredDebugInfo.length === 0"
|
||||
class="empty-state"
|
||||
>
|
||||
<p>没有找到匹配的调试信息</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控件 -->
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<div
|
||||
v-if="totalPages > 1"
|
||||
class="pagination"
|
||||
>
|
||||
<button
|
||||
class="page-btn"
|
||||
@click="currentPage = 1"
|
||||
:disabled="currentPage === 1"
|
||||
@click="currentPage = 1"
|
||||
>
|
||||
首页
|
||||
</button>
|
||||
<button
|
||||
class="page-btn"
|
||||
@click="currentPage--"
|
||||
:disabled="currentPage === 1"
|
||||
@click="currentPage--"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
@@ -153,15 +201,15 @@
|
||||
|
||||
<button
|
||||
class="page-btn"
|
||||
@click="currentPage++"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="currentPage++"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
<button
|
||||
class="page-btn"
|
||||
@click="currentPage = totalPages"
|
||||
:disabled="currentPage === totalPages"
|
||||
@click="currentPage = totalPages"
|
||||
>
|
||||
末页
|
||||
</button>
|
||||
@@ -179,7 +227,7 @@ const projects = ref([
|
||||
{ id: 'proj-3', name: '订单处理系统', type: 'backend' },
|
||||
{ id: 'proj-4', name: '移动端应用', type: 'frontend' },
|
||||
{ id: 'proj-5', name: 'API网关', type: 'backend' },
|
||||
{ id: 'proj-6', name: '管理后台', type: 'frontend' }
|
||||
{ id: 'proj-6', name: '管理后台', type: 'frontend' },
|
||||
]);
|
||||
|
||||
// 筛选条件
|
||||
@@ -198,7 +246,7 @@ const debugInfo = ref([]);
|
||||
|
||||
// 初始化模拟数据
|
||||
const initDebugData = () => {
|
||||
const types = ['api', 'database', 'cache', 'error', 'performance'];
|
||||
const types = [ 'api', 'database', 'cache', 'error', 'performance' ];
|
||||
const newDebugInfo = [];
|
||||
|
||||
// 生成50条模拟数据
|
||||
@@ -217,7 +265,7 @@ const initDebugData = () => {
|
||||
metadata = {
|
||||
status: Math.random() > 0.1 ? 200 : 404,
|
||||
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;
|
||||
case 'database':
|
||||
@@ -225,7 +273,7 @@ const initDebugData = () => {
|
||||
metadata = {
|
||||
queryTime: Math.floor(Math.random() * 100) + 10,
|
||||
rowsAffected: Math.floor(Math.random() * 10) + 1,
|
||||
table: 'users'
|
||||
table: 'users',
|
||||
};
|
||||
break;
|
||||
case 'cache':
|
||||
@@ -233,19 +281,19 @@ const initDebugData = () => {
|
||||
metadata = {
|
||||
cacheKey: `user:${i + 1}`,
|
||||
cacheType: Math.random() > 0.5 ? 'redis' : 'memcached',
|
||||
ttl: 3600
|
||||
ttl: 3600,
|
||||
};
|
||||
break;
|
||||
case 'error':
|
||||
content = `错误信息: Error: Cannot read property 'name' of undefined`;
|
||||
content = '错误信息: Error: Cannot read property \'name\' of undefined';
|
||||
metadata = {
|
||||
errorType: 'TypeError',
|
||||
stackTrace: [
|
||||
'at Object.<anonymous> (/app/src/index.js:10:20)',
|
||||
'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;
|
||||
case 'performance':
|
||||
@@ -254,7 +302,7 @@ const initDebugData = () => {
|
||||
metric: 'pageLoadTime',
|
||||
value: Math.floor(Math.random() * 2000) + 500,
|
||||
threshold: 1500,
|
||||
status: Math.random() > 0.3 ? 'warning' : 'ok'
|
||||
status: Math.random() > 0.3 ? 'warning' : 'ok',
|
||||
};
|
||||
break;
|
||||
}
|
||||
@@ -265,19 +313,19 @@ const initDebugData = () => {
|
||||
timestamp,
|
||||
type,
|
||||
content,
|
||||
metadata
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
// 按时间降序排序
|
||||
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(() => {
|
||||
let result = [...debugInfo.value];
|
||||
let result = [ ...debugInfo.value ];
|
||||
|
||||
// 按项目筛选
|
||||
if (selectedProjectId.value !== 'all') {
|
||||
@@ -309,7 +357,7 @@ const filteredDebugInfo = computed(() => {
|
||||
|
||||
// 计算总页数
|
||||
const totalPages = computed(() => {
|
||||
let result = [...debugInfo.value];
|
||||
let result = [ ...debugInfo.value ];
|
||||
|
||||
// 应用相同的过滤条件来计算总条数
|
||||
if (selectedProjectId.value !== 'all') {
|
||||
@@ -366,13 +414,13 @@ const exportDebugInfo = () => {
|
||||
selectedProjectId: selectedProjectId.value,
|
||||
selectedDebugType: selectedDebugType.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 a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -406,7 +454,7 @@ const getTypeText = (type) => {
|
||||
database: '数据库操作',
|
||||
cache: '缓存操作',
|
||||
error: '错误信息',
|
||||
performance: '性能监控'
|
||||
performance: '性能监控',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
@@ -420,7 +468,7 @@ const formatTimestamp = (timestamp) => {
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
second: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -7,20 +7,19 @@
|
||||
|
||||
<!-- 项目列表 -->
|
||||
<div class="project-list">
|
||||
<div
|
||||
v-for="project in projects"
|
||||
:key="project.id"
|
||||
class="project-item"
|
||||
:class="{ 'project-selected': selectedProjectId === project.id }"
|
||||
@click="selectProject(project)"
|
||||
>
|
||||
<div v-for="project in projects" :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-name">{{ project.name }}</div>
|
||||
<div class="project-description">{{ project.description }}</div>
|
||||
<div class="project-name">
|
||||
{{ project.name }}
|
||||
</div>
|
||||
<div class="project-description">
|
||||
{{ project.description }}
|
||||
</div>
|
||||
<div class="project-meta">
|
||||
<span class="project-type" :class="`type-${project.type}`">
|
||||
{{ project.type === 'backend' ? '后端' : '前端' }}
|
||||
@@ -30,7 +29,7 @@
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -42,11 +41,11 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
// 定义props和emits
|
||||
const props = defineProps({
|
||||
defineProps({
|
||||
selectedProjectId: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['project-selected']);
|
||||
@@ -58,43 +57,43 @@ const projects = ref([
|
||||
name: '用户管理系统',
|
||||
type: 'backend',
|
||||
description: '基于Node.js的用户管理后端服务',
|
||||
status: 'running'
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
id: 'proj-2',
|
||||
name: '数据可视化平台',
|
||||
type: 'frontend',
|
||||
description: '基于Vue.js的数据可视化前端应用',
|
||||
status: 'running'
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
id: 'proj-3',
|
||||
name: '订单处理系统',
|
||||
type: 'backend',
|
||||
description: '高性能订单处理后端服务',
|
||||
status: 'stopped'
|
||||
status: 'stopped',
|
||||
},
|
||||
{
|
||||
id: 'proj-4',
|
||||
name: '移动端应用',
|
||||
type: 'frontend',
|
||||
description: '基于React Native的移动端应用',
|
||||
status: 'error'
|
||||
status: 'error',
|
||||
},
|
||||
{
|
||||
id: 'proj-5',
|
||||
name: 'API网关',
|
||||
type: 'backend',
|
||||
description: '微服务架构的API网关',
|
||||
status: 'running'
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
id: 'proj-6',
|
||||
name: '管理后台',
|
||||
type: 'frontend',
|
||||
description: '基于Vue 3的管理后台系统',
|
||||
status: 'running'
|
||||
}
|
||||
status: 'running',
|
||||
},
|
||||
]);
|
||||
|
||||
// 选择项目
|
||||
@@ -107,7 +106,7 @@ const getStatusText = (status) => {
|
||||
const statusMap = {
|
||||
running: '运行中',
|
||||
stopped: '已停止',
|
||||
error: '错误'
|
||||
error: '错误',
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { createApp } from 'vue';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import MainView from '../views/MainView.vue'
|
||||
import SidebarView from '../views/SidebarView.vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import MainView from '../views/MainView.vue';
|
||||
import SidebarView from '../views/SidebarView.vue';
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -8,14 +8,14 @@ const routes = [
|
||||
name: 'main',
|
||||
components: {
|
||||
sidebar: SidebarView,
|
||||
main: MainView
|
||||
}
|
||||
}
|
||||
]
|
||||
main: MainView,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
routes,
|
||||
});
|
||||
|
||||
export default router
|
||||
export default router;
|
||||
10
src/frontend/store/projectStore.js
Normal file
10
src/frontend/store/projectStore.js
Normal 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 || '';
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div class="command-view">
|
||||
<h2>发送指令</h2>
|
||||
<form @submit.prevent="sendCommand" class="command-form">
|
||||
<form
|
||||
class="command-form"
|
||||
@submit.prevent="sendCommand"
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="command">指令内容</label>
|
||||
<textarea
|
||||
@@ -10,16 +13,33 @@
|
||||
rows="5"
|
||||
placeholder="请输入要发送的指令..."
|
||||
required
|
||||
></textarea>
|
||||
/>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">发送指令</button>
|
||||
<button type="button" class="btn btn-secondary" @click="clearCommand">清空</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
发送指令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
@click="clearCommand"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="response" class="response-section">
|
||||
<div
|
||||
v-if="response"
|
||||
class="response-section"
|
||||
>
|
||||
<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 }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,24 +47,24 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
const command = ref('')
|
||||
const response = ref(null)
|
||||
const command = ref('');
|
||||
const response = ref(null);
|
||||
|
||||
const sendCommand = () => {
|
||||
// 这里将实现发送指令的逻辑
|
||||
response.value = {
|
||||
success: true,
|
||||
message: `指令已发送: ${command.value}`
|
||||
}
|
||||
command.value = ''
|
||||
}
|
||||
message: `指令已发送: ${command.value}`,
|
||||
};
|
||||
command.value = '';
|
||||
};
|
||||
|
||||
const clearCommand = () => {
|
||||
command.value = ''
|
||||
response.value = null
|
||||
}
|
||||
command.value = '';
|
||||
response.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -2,24 +2,33 @@
|
||||
<div class="log-view">
|
||||
<h2>日志记录</h2>
|
||||
<div class="log-container">
|
||||
<div v-if="logs.length === 0" class="empty-state">
|
||||
<div
|
||||
v-if="logs.length === 0"
|
||||
class="empty-state"
|
||||
>
|
||||
<p>暂无日志记录</p>
|
||||
</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">
|
||||
<span class="log-timestamp">{{ log.timestamp }}</span>
|
||||
<span class="log-level">{{ log.level }}</span>
|
||||
</div>
|
||||
<div class="log-message">{{ log.message }}</div>
|
||||
<div class="log-message">
|
||||
{{ log.message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ref } from 'vue';
|
||||
|
||||
const logs = ref([])
|
||||
const logs = ref([]);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,17 +8,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import Console from '../components/Console.vue';
|
||||
|
||||
// 选中的项目ID,用于在组件间共享状态
|
||||
const selectedProjectId = ref('all');
|
||||
|
||||
// 接收来自项目选择器的项目选择事件
|
||||
const handleProjectSelected = (project) => {
|
||||
selectedProjectId.value = project.id;
|
||||
console.log('选中项目:', project);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<template>
|
||||
<div class="sidebar-view">
|
||||
<ProjectSelector :selectedProjectId="selectedProjectId" @project-selected="handleProjectSelected" />
|
||||
<ProjectSelector
|
||||
:selected-project-id="selectedId"
|
||||
@project-selected="handleProjectSelected"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import ProjectSelector from '../components/ProjectSelector.vue';
|
||||
import { selectedProjectId, setSelectedProject } from '../store/projectStore.js';
|
||||
|
||||
// 选中的项目ID
|
||||
const selectedProjectId = ref('all');
|
||||
const selectedId = computed(() => selectedProjectId.value);
|
||||
|
||||
// 项目选择事件
|
||||
const handleProjectSelected = (project) => {
|
||||
selectedProjectId.value = project.id;
|
||||
setSelectedProject(project);
|
||||
console.log('选中项目:', project);
|
||||
// 这里可以通过事件总线或状态管理工具将选中的项目传递给其他组件
|
||||
};
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
plugins: [ vue() ],
|
||||
root: 'src/frontend',
|
||||
build: {
|
||||
outDir: '../../dist',
|
||||
emptyOutDir: true
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src/frontend')
|
||||
}
|
||||
}
|
||||
})
|
||||
'@': resolve(__dirname, 'src/frontend'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user