@bomon/nestjs-cct-explorer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cct-explorer.controller.d.ts +24 -0
- package/dist/cct-explorer.controller.js +161 -0
- package/dist/cct-explorer.module.d.ts +5 -0
- package/dist/cct-explorer.module.js +38 -0
- package/dist/device-gateway.interface.d.ts +15 -0
- package/dist/device-gateway.interface.js +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/dist/path-utils.d.ts +12 -0
- package/dist/path-utils.js +24 -0
- package/dist/public/browser.html +806 -0
- package/dist/tokens.d.ts +14 -0
- package/dist/tokens.js +5 -0
- package/package.json +44 -0
- package/readme.md +49 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Response } from 'express';
|
|
2
|
+
import { DeviceGateway } from './device-gateway.interface';
|
|
3
|
+
import { CctExplorerModuleOptions } from './tokens';
|
|
4
|
+
export declare class CctExplorerController {
|
|
5
|
+
private readonly gateway;
|
|
6
|
+
private readonly options;
|
|
7
|
+
constructor(gateway: DeviceGateway, options: CctExplorerModuleOptions);
|
|
8
|
+
renderBrowser(res: Response): Promise<void>;
|
|
9
|
+
getDevices(): Promise<{
|
|
10
|
+
devices: import("./device-gateway.interface").DeviceInfo[];
|
|
11
|
+
}>;
|
|
12
|
+
readDir(device: string, virtualPath?: string): Promise<{
|
|
13
|
+
entries: import("./device-gateway.interface").DirEntry[];
|
|
14
|
+
}>;
|
|
15
|
+
readFile(device: string, virtualPath: string | undefined, res: Response): Promise<void>;
|
|
16
|
+
deleteFile(device: string, virtualPath?: string): Promise<{
|
|
17
|
+
ok: boolean;
|
|
18
|
+
message: string;
|
|
19
|
+
}>;
|
|
20
|
+
writeFile(device: string, virtualPath: string | undefined, content: string): Promise<{
|
|
21
|
+
ok: boolean;
|
|
22
|
+
message: string;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var __metadata = (this && this.__metadata) || function (k, v) {
|
|
9
|
+
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
10
|
+
};
|
|
11
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
12
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
13
|
+
};
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.CctExplorerController = void 0;
|
|
16
|
+
const common_1 = require("@nestjs/common");
|
|
17
|
+
const node_path_1 = require("node:path");
|
|
18
|
+
const tokens_1 = require("./tokens");
|
|
19
|
+
const path_utils_1 = require("./path-utils");
|
|
20
|
+
let CctExplorerController = class CctExplorerController {
|
|
21
|
+
constructor(gateway, options) {
|
|
22
|
+
this.gateway = gateway;
|
|
23
|
+
this.options = options;
|
|
24
|
+
}
|
|
25
|
+
async renderBrowser(res) {
|
|
26
|
+
const filePath = (0, node_path_1.join)(__dirname, 'public', 'browser.html');
|
|
27
|
+
res.sendFile(filePath);
|
|
28
|
+
}
|
|
29
|
+
async getDevices() {
|
|
30
|
+
const devices = await this.gateway.listDevices();
|
|
31
|
+
return { devices };
|
|
32
|
+
}
|
|
33
|
+
async readDir(device, virtualPath = '') {
|
|
34
|
+
if (!device || !virtualPath) {
|
|
35
|
+
throw new common_1.HttpException({ code: '400', message: '缺少 device 或 path' }, common_1.HttpStatus.BAD_REQUEST);
|
|
36
|
+
}
|
|
37
|
+
const parsed = (0, path_utils_1.parseVirtualPath)(device, virtualPath);
|
|
38
|
+
if ('error' in parsed) {
|
|
39
|
+
throw new common_1.HttpException({ code: String(parsed.code), message: parsed.error }, parsed.code);
|
|
40
|
+
}
|
|
41
|
+
const entries = await this.gateway.readDir(device, parsed.devicePath);
|
|
42
|
+
return { entries };
|
|
43
|
+
}
|
|
44
|
+
async readFile(device, virtualPath = '', res) {
|
|
45
|
+
if (!device || !virtualPath) {
|
|
46
|
+
throw new common_1.HttpException({ code: '400', message: '缺少 device 或 path' }, common_1.HttpStatus.BAD_REQUEST);
|
|
47
|
+
}
|
|
48
|
+
const parsed = (0, path_utils_1.parseVirtualPath)(device, virtualPath);
|
|
49
|
+
if ('error' in parsed) {
|
|
50
|
+
throw new common_1.HttpException({ code: String(parsed.code), message: parsed.error }, parsed.code);
|
|
51
|
+
}
|
|
52
|
+
let buf;
|
|
53
|
+
try {
|
|
54
|
+
buf = await this.gateway.readFile(device, parsed.devicePath);
|
|
55
|
+
}
|
|
56
|
+
catch (e) {
|
|
57
|
+
const err = e;
|
|
58
|
+
if (err?.message === 'file_not_found') {
|
|
59
|
+
throw new common_1.HttpException({ code: '404', message: '文件不存在' }, common_1.HttpStatus.NOT_FOUND);
|
|
60
|
+
}
|
|
61
|
+
throw new common_1.HttpException({ code: '500', message: err?.message || '读取文件失败' }, common_1.HttpStatus.INTERNAL_SERVER_ERROR);
|
|
62
|
+
}
|
|
63
|
+
const filename = virtualPath.split('/').pop() || 'file';
|
|
64
|
+
const ext = filename.substring(filename.lastIndexOf('.')).toLowerCase();
|
|
65
|
+
const mime = {
|
|
66
|
+
'.js': 'application/javascript',
|
|
67
|
+
'.mjs': 'application/javascript',
|
|
68
|
+
'.ts': 'application/typescript',
|
|
69
|
+
'.json': 'application/json',
|
|
70
|
+
'.html': 'text/html',
|
|
71
|
+
'.txt': 'text/plain',
|
|
72
|
+
'.png': 'image/png',
|
|
73
|
+
'.jpg': 'image/jpeg',
|
|
74
|
+
'.jpeg': 'image/jpeg',
|
|
75
|
+
'.webp': 'image/webp',
|
|
76
|
+
'.gif': 'image/gif',
|
|
77
|
+
};
|
|
78
|
+
res.setHeader('Content-Type', mime[ext] || 'application/octet-stream');
|
|
79
|
+
res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
|
|
80
|
+
res.send(buf);
|
|
81
|
+
}
|
|
82
|
+
async deleteFile(device, virtualPath = '') {
|
|
83
|
+
if (!device || !virtualPath) {
|
|
84
|
+
throw new common_1.HttpException({ code: '400', message: '缺少 device 或 path' }, common_1.HttpStatus.BAD_REQUEST);
|
|
85
|
+
}
|
|
86
|
+
const parsed = (0, path_utils_1.parseVirtualPath)(device, virtualPath);
|
|
87
|
+
if ('error' in parsed) {
|
|
88
|
+
throw new common_1.HttpException({ code: String(parsed.code), message: parsed.error }, parsed.code);
|
|
89
|
+
}
|
|
90
|
+
await this.gateway.removeFile(device, parsed.devicePath);
|
|
91
|
+
return { ok: true, message: 'deleted' };
|
|
92
|
+
}
|
|
93
|
+
async writeFile(device, virtualPath = '', content) {
|
|
94
|
+
if (!this.gateway.writeFile) {
|
|
95
|
+
throw new common_1.HttpException({ code: '400', message: '当前网关未实现写文件能力' }, common_1.HttpStatus.BAD_REQUEST);
|
|
96
|
+
}
|
|
97
|
+
if (!device || !virtualPath) {
|
|
98
|
+
throw new common_1.HttpException({ code: '400', message: '缺少 device 或 path' }, common_1.HttpStatus.BAD_REQUEST);
|
|
99
|
+
}
|
|
100
|
+
const parsed = (0, path_utils_1.parseVirtualPath)(device, virtualPath);
|
|
101
|
+
if ('error' in parsed) {
|
|
102
|
+
throw new common_1.HttpException({ code: String(parsed.code), message: parsed.error }, parsed.code);
|
|
103
|
+
}
|
|
104
|
+
await this.gateway.writeFile(device, parsed.devicePath, content ?? '');
|
|
105
|
+
return { ok: true, message: 'saved' };
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
exports.CctExplorerController = CctExplorerController;
|
|
109
|
+
__decorate([
|
|
110
|
+
(0, common_1.Get)('browser'),
|
|
111
|
+
__param(0, (0, common_1.Res)()),
|
|
112
|
+
__metadata("design:type", Function),
|
|
113
|
+
__metadata("design:paramtypes", [Object]),
|
|
114
|
+
__metadata("design:returntype", Promise)
|
|
115
|
+
], CctExplorerController.prototype, "renderBrowser", null);
|
|
116
|
+
__decorate([
|
|
117
|
+
(0, common_1.Get)('api/devices'),
|
|
118
|
+
__metadata("design:type", Function),
|
|
119
|
+
__metadata("design:paramtypes", []),
|
|
120
|
+
__metadata("design:returntype", Promise)
|
|
121
|
+
], CctExplorerController.prototype, "getDevices", null);
|
|
122
|
+
__decorate([
|
|
123
|
+
(0, common_1.Get)('api/fs/dir'),
|
|
124
|
+
__param(0, (0, common_1.Query)('device')),
|
|
125
|
+
__param(1, (0, common_1.Query)('path')),
|
|
126
|
+
__metadata("design:type", Function),
|
|
127
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
128
|
+
__metadata("design:returntype", Promise)
|
|
129
|
+
], CctExplorerController.prototype, "readDir", null);
|
|
130
|
+
__decorate([
|
|
131
|
+
(0, common_1.Get)('api/fs/file'),
|
|
132
|
+
__param(0, (0, common_1.Query)('device')),
|
|
133
|
+
__param(1, (0, common_1.Query)('path')),
|
|
134
|
+
__param(2, (0, common_1.Res)()),
|
|
135
|
+
__metadata("design:type", Function),
|
|
136
|
+
__metadata("design:paramtypes", [String, Object, Object]),
|
|
137
|
+
__metadata("design:returntype", Promise)
|
|
138
|
+
], CctExplorerController.prototype, "readFile", null);
|
|
139
|
+
__decorate([
|
|
140
|
+
(0, common_1.Delete)('api/fs/file'),
|
|
141
|
+
__param(0, (0, common_1.Query)('device')),
|
|
142
|
+
__param(1, (0, common_1.Query)('path')),
|
|
143
|
+
__metadata("design:type", Function),
|
|
144
|
+
__metadata("design:paramtypes", [String, Object]),
|
|
145
|
+
__metadata("design:returntype", Promise)
|
|
146
|
+
], CctExplorerController.prototype, "deleteFile", null);
|
|
147
|
+
__decorate([
|
|
148
|
+
(0, common_1.Post)('api/fs/file'),
|
|
149
|
+
__param(0, (0, common_1.Body)('device')),
|
|
150
|
+
__param(1, (0, common_1.Body)('path')),
|
|
151
|
+
__param(2, (0, common_1.Body)('content')),
|
|
152
|
+
__metadata("design:type", Function),
|
|
153
|
+
__metadata("design:paramtypes", [String, Object, String]),
|
|
154
|
+
__metadata("design:returntype", Promise)
|
|
155
|
+
], CctExplorerController.prototype, "writeFile", null);
|
|
156
|
+
exports.CctExplorerController = CctExplorerController = __decorate([
|
|
157
|
+
(0, common_1.Controller)(),
|
|
158
|
+
__param(0, (0, common_1.Inject)(tokens_1.DEVICE_GATEWAY_TOKEN)),
|
|
159
|
+
__param(1, (0, common_1.Inject)(tokens_1.CCT_EXPLORER_OPTIONS)),
|
|
160
|
+
__metadata("design:paramtypes", [Object, Object])
|
|
161
|
+
], CctExplorerController);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
3
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
5
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
6
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
7
|
+
};
|
|
8
|
+
var CctExplorerModule_1;
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.CctExplorerModule = void 0;
|
|
11
|
+
const common_1 = require("@nestjs/common");
|
|
12
|
+
const cct_explorer_controller_1 = require("./cct-explorer.controller");
|
|
13
|
+
const tokens_1 = require("./tokens");
|
|
14
|
+
let CctExplorerModule = CctExplorerModule_1 = class CctExplorerModule {
|
|
15
|
+
static forRoot(options) {
|
|
16
|
+
const { deviceGatewayClass, ...rest } = options;
|
|
17
|
+
return {
|
|
18
|
+
module: CctExplorerModule_1,
|
|
19
|
+
controllers: [cct_explorer_controller_1.CctExplorerController],
|
|
20
|
+
providers: [
|
|
21
|
+
{
|
|
22
|
+
provide: tokens_1.CCT_EXPLORER_OPTIONS,
|
|
23
|
+
useValue: rest,
|
|
24
|
+
},
|
|
25
|
+
deviceGatewayClass,
|
|
26
|
+
{
|
|
27
|
+
provide: tokens_1.DEVICE_GATEWAY_TOKEN,
|
|
28
|
+
useExisting: deviceGatewayClass,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
exports: [tokens_1.CCT_EXPLORER_OPTIONS, tokens_1.DEVICE_GATEWAY_TOKEN],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
exports.CctExplorerModule = CctExplorerModule;
|
|
36
|
+
exports.CctExplorerModule = CctExplorerModule = CctExplorerModule_1 = __decorate([
|
|
37
|
+
(0, common_1.Module)({})
|
|
38
|
+
], CctExplorerModule);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface DeviceInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
connectedAt?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface DirEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
type: 'file' | 'dir';
|
|
8
|
+
}
|
|
9
|
+
export interface DeviceGateway {
|
|
10
|
+
listDevices(): Promise<DeviceInfo[]>;
|
|
11
|
+
readDir(device: string, devicePath: string): Promise<DirEntry[]>;
|
|
12
|
+
readFile(device: string, devicePath: string): Promise<Buffer>;
|
|
13
|
+
removeFile(device: string, devicePath: string): Promise<void>;
|
|
14
|
+
writeFile?(device: string, devicePath: string, content: Buffer | string): Promise<void>;
|
|
15
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./device-gateway.interface"), exports);
|
|
18
|
+
__exportStar(require("./tokens"), exports);
|
|
19
|
+
__exportStar(require("./path-utils"), exports);
|
|
20
|
+
__exportStar(require("./cct-explorer.module"), exports);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const ALLOWED_PREFIXES: readonly ["local_scripts", "screenshots"];
|
|
2
|
+
export interface ParseResult {
|
|
3
|
+
devicePath: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ParseError {
|
|
6
|
+
error: string;
|
|
7
|
+
code: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* 校验并解析虚拟路径:/{device}/local_scripts/xxx -> local_scripts/xxx
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseVirtualPath(device: string, virtualPath: string): ParseResult | ParseError;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ALLOWED_PREFIXES = void 0;
|
|
4
|
+
exports.parseVirtualPath = parseVirtualPath;
|
|
5
|
+
exports.ALLOWED_PREFIXES = ['local_scripts', 'screenshots'];
|
|
6
|
+
/**
|
|
7
|
+
* 校验并解析虚拟路径:/{device}/local_scripts/xxx -> local_scripts/xxx
|
|
8
|
+
*/
|
|
9
|
+
function parseVirtualPath(device, virtualPath) {
|
|
10
|
+
const normalized = virtualPath.replace(/^\/+/, '').replace(/\\/g, '/');
|
|
11
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
12
|
+
if (parts.length < 2 || parts[0] !== device) {
|
|
13
|
+
return {
|
|
14
|
+
error: 'path 须为 /{device_name}/local_scripts/... 或 /{device_name}/screenshots/...',
|
|
15
|
+
code: 400,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
const second = parts[1];
|
|
19
|
+
if (!exports.ALLOWED_PREFIXES.includes(second)) {
|
|
20
|
+
return { error: '仅允许 local_scripts 或 screenshots 目录', code: 400 };
|
|
21
|
+
}
|
|
22
|
+
const devicePath = parts.slice(1).join('/');
|
|
23
|
+
return { devicePath };
|
|
24
|
+
}
|
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
|
|
4
|
+
<head>
|
|
5
|
+
<meta charset="UTF-8" />
|
|
6
|
+
<title>设备资源浏览器 - cdp-client-tool</title>
|
|
7
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
color-scheme: dark light;
|
|
11
|
+
--bg: #0f172a;
|
|
12
|
+
--bg-elevated: #020617;
|
|
13
|
+
--bg-soft: #111827;
|
|
14
|
+
--border-subtle: #1f2937;
|
|
15
|
+
--accent: #38bdf8;
|
|
16
|
+
--accent-soft: rgba(56, 189, 248, 0.1);
|
|
17
|
+
--accent-strong: rgba(56, 189, 248, 0.2);
|
|
18
|
+
--text: #e5e7eb;
|
|
19
|
+
--text-muted: #9ca3af;
|
|
20
|
+
--danger: #f97373;
|
|
21
|
+
--danger-soft: rgba(248, 113, 113, 0.12);
|
|
22
|
+
--radius-lg: 10px;
|
|
23
|
+
--radius-md: 8px;
|
|
24
|
+
--radius-sm: 6px;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
* {
|
|
28
|
+
box-sizing: border-box;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
body {
|
|
32
|
+
margin: 0;
|
|
33
|
+
padding: 0;
|
|
34
|
+
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text",
|
|
35
|
+
"Segoe UI", Roboto, sans-serif;
|
|
36
|
+
background: radial-gradient(circle at top left, #1e293b 0, #020617 45%);
|
|
37
|
+
color: var(--text);
|
|
38
|
+
height: 100vh;
|
|
39
|
+
display: flex;
|
|
40
|
+
align-items: stretch;
|
|
41
|
+
justify-content: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.app-shell {
|
|
45
|
+
display: flex;
|
|
46
|
+
width: 100%;
|
|
47
|
+
max-width: 1320px;
|
|
48
|
+
margin: 16px;
|
|
49
|
+
border-radius: 18px;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
52
|
+
background: radial-gradient(circle at top, #020617 0, #020617 40%, #020617 90%);
|
|
53
|
+
box-shadow:
|
|
54
|
+
0 22px 45px rgba(15, 23, 42, 0.9),
|
|
55
|
+
0 0 0 1px rgba(15, 23, 42, 0.9),
|
|
56
|
+
0 0 0 1px rgba(15, 23, 42, 0.9) inset;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.sidebar {
|
|
60
|
+
width: 260px;
|
|
61
|
+
border-right: 1px solid rgba(31, 41, 55, 0.9);
|
|
62
|
+
background: radial-gradient(circle at top left, #020617 0, #020617 55%, #020617 100%);
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.sidebar-header {
|
|
68
|
+
padding: 16px 18px 10px;
|
|
69
|
+
border-bottom: 1px solid rgba(31, 41, 55, 0.7);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.sidebar-title {
|
|
73
|
+
font-size: 14px;
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
letter-spacing: 0.02em;
|
|
76
|
+
color: #e5e7eb;
|
|
77
|
+
display: flex;
|
|
78
|
+
align-items: center;
|
|
79
|
+
gap: 8px;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.sidebar-title-badge {
|
|
83
|
+
font-size: 10px;
|
|
84
|
+
padding: 2px 7px;
|
|
85
|
+
border-radius: 999px;
|
|
86
|
+
border: 1px solid rgba(148, 163, 184, 0.45);
|
|
87
|
+
color: var(--text-muted);
|
|
88
|
+
text-transform: uppercase;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.sidebar-sub {
|
|
92
|
+
margin-top: 6px;
|
|
93
|
+
font-size: 12px;
|
|
94
|
+
color: var(--text-muted);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.devices-header {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
justify-content: space-between;
|
|
101
|
+
padding: 10px 16px 8px;
|
|
102
|
+
font-size: 11px;
|
|
103
|
+
color: var(--text-muted);
|
|
104
|
+
text-transform: uppercase;
|
|
105
|
+
letter-spacing: 0.08em;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.devices-refresh-btn {
|
|
109
|
+
border: none;
|
|
110
|
+
background: transparent;
|
|
111
|
+
color: var(--text-muted);
|
|
112
|
+
cursor: pointer;
|
|
113
|
+
display: inline-flex;
|
|
114
|
+
align-items: center;
|
|
115
|
+
gap: 4px;
|
|
116
|
+
font-size: 11px;
|
|
117
|
+
padding: 2px 4px;
|
|
118
|
+
border-radius: 999px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.devices-refresh-btn:hover {
|
|
122
|
+
background: rgba(15, 23, 42, 0.9);
|
|
123
|
+
color: var(--text);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.device-list {
|
|
127
|
+
flex: 1;
|
|
128
|
+
overflow: auto;
|
|
129
|
+
padding: 4px 8px 10px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.device-item {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
border-radius: 10px;
|
|
136
|
+
padding: 8px 10px;
|
|
137
|
+
cursor: pointer;
|
|
138
|
+
margin: 2px 0;
|
|
139
|
+
gap: 8px;
|
|
140
|
+
transition: background 0.15s ease, transform 0.08s ease, box-shadow 0.15s ease;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.device-item:hover {
|
|
144
|
+
background: rgba(15, 23, 42, 0.9);
|
|
145
|
+
transform: translateY(-0.5px);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.device-item.active {
|
|
149
|
+
background: linear-gradient(90deg, var(--accent-soft), rgba(15, 23, 42, 0.9));
|
|
150
|
+
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.3);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.device-dot {
|
|
154
|
+
width: 8px;
|
|
155
|
+
height: 8px;
|
|
156
|
+
border-radius: 999px;
|
|
157
|
+
background: #22c55e;
|
|
158
|
+
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.15);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.device-name {
|
|
162
|
+
font-size: 13px;
|
|
163
|
+
font-weight: 500;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.device-meta {
|
|
167
|
+
font-size: 11px;
|
|
168
|
+
color: var(--text-muted);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.sidebar-footer {
|
|
172
|
+
padding: 8px 14px 12px;
|
|
173
|
+
border-top: 1px solid rgba(31, 41, 55, 0.9);
|
|
174
|
+
font-size: 11px;
|
|
175
|
+
color: var(--text-muted);
|
|
176
|
+
display: flex;
|
|
177
|
+
justify-content: space-between;
|
|
178
|
+
align-items: center;
|
|
179
|
+
gap: 8px;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.sidebar-footer span {
|
|
183
|
+
white-space: nowrap;
|
|
184
|
+
overflow: hidden;
|
|
185
|
+
text-overflow: ellipsis;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.pill {
|
|
189
|
+
padding: 2px 8px;
|
|
190
|
+
border-radius: 999px;
|
|
191
|
+
border: 1px solid rgba(148, 163, 184, 0.55);
|
|
192
|
+
font-size: 10px;
|
|
193
|
+
text-transform: uppercase;
|
|
194
|
+
letter-spacing: 0.08em;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* Main */
|
|
198
|
+
.main {
|
|
199
|
+
flex: 1;
|
|
200
|
+
display: flex;
|
|
201
|
+
flex-direction: column;
|
|
202
|
+
background: radial-gradient(circle at top, #020617 0, #020617 45%, #020617 100%);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.main-header {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: space-between;
|
|
209
|
+
padding: 14px 18px 10px;
|
|
210
|
+
border-bottom: 1px solid rgba(31, 41, 55, 0.7);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.breadcrumbs {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
gap: 6px;
|
|
217
|
+
font-size: 12px;
|
|
218
|
+
color: var(--text-muted);
|
|
219
|
+
flex-wrap: wrap;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
.breadcrumb-part {
|
|
223
|
+
cursor: pointer;
|
|
224
|
+
padding: 2px 7px;
|
|
225
|
+
border-radius: 999px;
|
|
226
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.breadcrumb-part:hover {
|
|
230
|
+
background: rgba(15, 23, 42, 0.9);
|
|
231
|
+
color: var(--text);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.breadcrumb-part.current {
|
|
235
|
+
background: var(--accent-soft);
|
|
236
|
+
color: var(--accent);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.breadcrumb-sep {
|
|
240
|
+
opacity: 0.4;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.main-actions {
|
|
244
|
+
display: flex;
|
|
245
|
+
gap: 8px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.btn {
|
|
249
|
+
border-radius: 999px;
|
|
250
|
+
border: 1px solid rgba(148, 163, 184, 0.5);
|
|
251
|
+
background: #020617;
|
|
252
|
+
color: var(--text-muted);
|
|
253
|
+
font-size: 11px;
|
|
254
|
+
padding: 5px 10px;
|
|
255
|
+
display: inline-flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
gap: 6px;
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.btn-primary {
|
|
262
|
+
border-color: rgba(56, 189, 248, 0.7);
|
|
263
|
+
background: linear-gradient(90deg, rgba(56, 189, 248, 0.18), rgba(56, 189, 248, 0.06));
|
|
264
|
+
color: var(--accent);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.btn:hover {
|
|
268
|
+
background: rgba(15, 23, 42, 0.9);
|
|
269
|
+
color: var(--text);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.btn-primary:hover {
|
|
273
|
+
background: linear-gradient(90deg, rgba(56, 189, 248, 0.28), rgba(56, 189, 248, 0.12));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.main-body {
|
|
277
|
+
flex: 1;
|
|
278
|
+
display: flex;
|
|
279
|
+
flex-direction: column;
|
|
280
|
+
padding: 10px 16px 14px;
|
|
281
|
+
gap: 10px;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.panel {
|
|
285
|
+
border-radius: var(--radius-lg);
|
|
286
|
+
border: 1px solid rgba(31, 41, 55, 0.9);
|
|
287
|
+
background: radial-gradient(circle at top, #020617 0, #020617 60%, #020617 100%);
|
|
288
|
+
padding: 10px 12px;
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-direction: column;
|
|
291
|
+
min-height: 0;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.panel-header {
|
|
295
|
+
display: flex;
|
|
296
|
+
align-items: center;
|
|
297
|
+
justify-content: space-between;
|
|
298
|
+
margin-bottom: 6px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.panel-title {
|
|
302
|
+
font-size: 12px;
|
|
303
|
+
color: var(--text-muted);
|
|
304
|
+
text-transform: uppercase;
|
|
305
|
+
letter-spacing: 0.08em;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.panel-sub {
|
|
309
|
+
font-size: 12px;
|
|
310
|
+
color: var(--text-muted);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.file-table-wrapper {
|
|
314
|
+
flex: 1;
|
|
315
|
+
overflow: auto;
|
|
316
|
+
border-radius: var(--radius-md);
|
|
317
|
+
border: 1px solid rgba(31, 41, 55, 0.95);
|
|
318
|
+
background: rgba(15, 23, 42, 0.96);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
table {
|
|
322
|
+
width: 100%;
|
|
323
|
+
border-collapse: collapse;
|
|
324
|
+
font-size: 12px;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
thead {
|
|
328
|
+
position: sticky;
|
|
329
|
+
top: 0;
|
|
330
|
+
background: rgba(15, 23, 42, 0.98);
|
|
331
|
+
z-index: 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
th,
|
|
335
|
+
td {
|
|
336
|
+
padding: 7px 10px;
|
|
337
|
+
text-align: left;
|
|
338
|
+
border-bottom: 1px solid rgba(31, 41, 55, 0.9);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
th {
|
|
342
|
+
font-size: 11px;
|
|
343
|
+
text-transform: uppercase;
|
|
344
|
+
letter-spacing: 0.06em;
|
|
345
|
+
color: var(--text-muted);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
tbody tr {
|
|
349
|
+
cursor: default;
|
|
350
|
+
transition: background 0.12s ease;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
tbody tr:hover {
|
|
354
|
+
background: rgba(17, 24, 39, 0.86);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.row-folder {
|
|
358
|
+
cursor: pointer;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.file-name-cell {
|
|
362
|
+
display: flex;
|
|
363
|
+
align-items: center;
|
|
364
|
+
gap: 8px;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.file-icon {
|
|
368
|
+
width: 16px;
|
|
369
|
+
height: 16px;
|
|
370
|
+
border-radius: 4px;
|
|
371
|
+
display: inline-flex;
|
|
372
|
+
align-items: center;
|
|
373
|
+
justify-content: center;
|
|
374
|
+
font-size: 11px;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.file-icon.folder {
|
|
378
|
+
background: rgba(56, 189, 248, 0.12);
|
|
379
|
+
color: var(--accent);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.file-icon.file {
|
|
383
|
+
background: rgba(59, 130, 246, 0.18);
|
|
384
|
+
color: #93c5fd;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
.file-icon.image {
|
|
388
|
+
background: rgba(16, 185, 129, 0.18);
|
|
389
|
+
color: #6ee7b7;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
.file-icon.script {
|
|
393
|
+
background: rgba(251, 191, 36, 0.18);
|
|
394
|
+
color: #facc15;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
.file-meta {
|
|
398
|
+
font-size: 11px;
|
|
399
|
+
color: var(--text-muted);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.file-actions {
|
|
403
|
+
display: flex;
|
|
404
|
+
gap: 6px;
|
|
405
|
+
justify-content: flex-end;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.link-icon {
|
|
409
|
+
border: none;
|
|
410
|
+
background: transparent;
|
|
411
|
+
color: var(--text-muted);
|
|
412
|
+
padding: 2px 4px;
|
|
413
|
+
cursor: pointer;
|
|
414
|
+
border-radius: 999px;
|
|
415
|
+
display: inline-flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
font-size: 11px;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.link-icon:hover {
|
|
421
|
+
background: rgba(15, 23, 42, 0.9);
|
|
422
|
+
color: var(--text);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
.link-icon.danger:hover {
|
|
426
|
+
background: var(--danger-soft);
|
|
427
|
+
color: var(--danger);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.status-bar {
|
|
431
|
+
font-size: 11px;
|
|
432
|
+
color: var(--text-muted);
|
|
433
|
+
padding: 2px 2px 0;
|
|
434
|
+
display: flex;
|
|
435
|
+
justify-content: space-between;
|
|
436
|
+
align-items: center;
|
|
437
|
+
gap: 8px;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.status-badge {
|
|
441
|
+
padding: 2px 8px;
|
|
442
|
+
border-radius: 999px;
|
|
443
|
+
background: rgba(15, 23, 42, 0.98);
|
|
444
|
+
border: 1px solid rgba(31, 41, 55, 0.9);
|
|
445
|
+
display: inline-flex;
|
|
446
|
+
align-items: center;
|
|
447
|
+
gap: 6px;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
.status-dot {
|
|
451
|
+
width: 6px;
|
|
452
|
+
height: 6px;
|
|
453
|
+
border-radius: 999px;
|
|
454
|
+
background: #22c55e;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.status-dot.warn {
|
|
458
|
+
background: #f97316;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
.status-dot.err {
|
|
462
|
+
background: #ef4444;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
.status-text {
|
|
466
|
+
max-width: 280px;
|
|
467
|
+
white-space: nowrap;
|
|
468
|
+
overflow: hidden;
|
|
469
|
+
text-overflow: ellipsis;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
.badge-soft {
|
|
473
|
+
padding: 2px 6px;
|
|
474
|
+
border-radius: 999px;
|
|
475
|
+
background: rgba(15, 23, 42, 0.9);
|
|
476
|
+
border: 1px solid rgba(31, 41, 55, 0.9);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
@media (max-width: 960px) {
|
|
480
|
+
.app-shell {
|
|
481
|
+
flex-direction: column;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.sidebar {
|
|
485
|
+
width: 100%;
|
|
486
|
+
border-right: none;
|
|
487
|
+
border-bottom: 1px solid rgba(31, 41, 55, 0.9);
|
|
488
|
+
max-height: 220px;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
</style>
|
|
492
|
+
</head>
|
|
493
|
+
|
|
494
|
+
<body>
|
|
495
|
+
<div class="app-shell">
|
|
496
|
+
<aside class="sidebar">
|
|
497
|
+
<div class="sidebar-header">
|
|
498
|
+
<div class="sidebar-title">
|
|
499
|
+
设备资源浏览器
|
|
500
|
+
<span class="sidebar-title-badge">CDP CLIENT</span>
|
|
501
|
+
</div>
|
|
502
|
+
<div class="sidebar-sub">
|
|
503
|
+
通过 socket.io 网关浏览各设备脚本与截图
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
|
|
507
|
+
<div class="devices-header">
|
|
508
|
+
<span>Devices</span>
|
|
509
|
+
<button class="devices-refresh-btn" id="btn-refresh-devices">
|
|
510
|
+
⟳ 刷新
|
|
511
|
+
</button>
|
|
512
|
+
</div>
|
|
513
|
+
|
|
514
|
+
<div class="device-list" id="device-list">
|
|
515
|
+
<!-- 动态插入设备列表 -->
|
|
516
|
+
</div>
|
|
517
|
+
|
|
518
|
+
<div class="sidebar-footer">
|
|
519
|
+
<span id="selected-device-label">未选择设备</span>
|
|
520
|
+
<span class="pill">SOCKET.IO</span>
|
|
521
|
+
</div>
|
|
522
|
+
</aside>
|
|
523
|
+
|
|
524
|
+
<main class="main">
|
|
525
|
+
<div class="main-header">
|
|
526
|
+
<div class="breadcrumbs" id="breadcrumbs">
|
|
527
|
+
<span style="font-size:12px;color:var(--text-muted)">请选择左侧设备</span>
|
|
528
|
+
</div>
|
|
529
|
+
<div class="main-actions">
|
|
530
|
+
<button class="btn" id="btn-root-local">local_scripts</button>
|
|
531
|
+
<button class="btn" id="btn-root-screenshots">screenshots</button>
|
|
532
|
+
<button class="btn-primary btn" id="btn-refresh-dir">刷新目录</button>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<div class="main-body">
|
|
537
|
+
<section class="panel" style="flex: 1 1 auto; min-height: 0;">
|
|
538
|
+
<div class="panel-header">
|
|
539
|
+
<div class="panel-title">当前目录</div>
|
|
540
|
+
<div class="panel-sub" id="panel-subtitle"></div>
|
|
541
|
+
</div>
|
|
542
|
+
<div class="file-table-wrapper">
|
|
543
|
+
<table>
|
|
544
|
+
<thead>
|
|
545
|
+
<tr>
|
|
546
|
+
<th style="width: 40%;">名称</th>
|
|
547
|
+
<th style="width: 20%;">类型</th>
|
|
548
|
+
<th style="width: 20%;">所属设备</th>
|
|
549
|
+
<th style="width: 20%; text-align: right;">操作</th>
|
|
550
|
+
</tr>
|
|
551
|
+
</thead>
|
|
552
|
+
<tbody id="file-tbody">
|
|
553
|
+
<!-- 动态插入行 -->
|
|
554
|
+
</tbody>
|
|
555
|
+
</table>
|
|
556
|
+
</div>
|
|
557
|
+
</section>
|
|
558
|
+
|
|
559
|
+
<div class="status-bar">
|
|
560
|
+
<div class="status-badge">
|
|
561
|
+
<span class="status-dot" id="status-dot"></span>
|
|
562
|
+
<span style="font-size:11px;">状态</span>
|
|
563
|
+
<span class="status-text" id="status-text">已加载界面,等待选择设备…</span>
|
|
564
|
+
</div>
|
|
565
|
+
<div style="display:flex;gap:6px;align-items:center;">
|
|
566
|
+
<span class="badge-soft">GET api/devices</span>
|
|
567
|
+
<span class="badge-soft">GET api/fs/dir</span>
|
|
568
|
+
<span class="badge-soft">GET api/fs/file</span>
|
|
569
|
+
<span class="badge-soft">DELETE api/fs/file</span>
|
|
570
|
+
</div>
|
|
571
|
+
</div>
|
|
572
|
+
</div>
|
|
573
|
+
</main>
|
|
574
|
+
</div>
|
|
575
|
+
|
|
576
|
+
<script>
|
|
577
|
+
(function () {
|
|
578
|
+
const deviceListEl = document.getElementById('device-list');
|
|
579
|
+
const selectedDeviceLabelEl = document.getElementById('selected-device-label');
|
|
580
|
+
const breadcrumbsEl = document.getElementById('breadcrumbs');
|
|
581
|
+
const fileTbodyEl = document.getElementById('file-tbody');
|
|
582
|
+
const panelSubtitleEl = document.getElementById('panel-subtitle');
|
|
583
|
+
const statusTextEl = document.getElementById('status-text');
|
|
584
|
+
const statusDotEl = document.getElementById('status-dot');
|
|
585
|
+
|
|
586
|
+
const btnRefreshDevices = document.getElementById('btn-refresh-devices');
|
|
587
|
+
const btnRootLocal = document.getElementById('btn-root-local');
|
|
588
|
+
const btnRootScreenshots = document.getElementById('btn-root-screenshots');
|
|
589
|
+
const btnRefreshDir = document.getElementById('btn-refresh-dir');
|
|
590
|
+
|
|
591
|
+
const state = {
|
|
592
|
+
devices: [],
|
|
593
|
+
currentDevice: null,
|
|
594
|
+
currentPath: null,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
function setStatus(text, level) {
|
|
598
|
+
level = level || 'info';
|
|
599
|
+
statusTextEl.textContent = text;
|
|
600
|
+
statusDotEl.classList.remove('warn', 'err');
|
|
601
|
+
if (level === 'warn') statusDotEl.classList.add('warn');
|
|
602
|
+
else if (level === 'error') statusDotEl.classList.add('err');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function joinPath(base, name) {
|
|
606
|
+
if (!base.endsWith('/')) base += '/';
|
|
607
|
+
return base + name.replace(/^\/+/, '');
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function buildFileIcon(name, type) {
|
|
611
|
+
if (type === 'dir') return { cls: 'folder', label: '📁' };
|
|
612
|
+
const lower = name.toLowerCase();
|
|
613
|
+
if (/\.(png|jpg|jpeg|webp)$/.test(lower)) return { cls: 'image', label: '🖼' };
|
|
614
|
+
if (/\.(js|mjs|ts)$/.test(lower)) return { cls: 'script', label: '</>' };
|
|
615
|
+
return { cls: 'file', label: '•' };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function renderDevices() {
|
|
619
|
+
deviceListEl.innerHTML = '';
|
|
620
|
+
if (!state.devices.length) {
|
|
621
|
+
const empty = document.createElement('div');
|
|
622
|
+
empty.style.fontSize = '12px';
|
|
623
|
+
empty.style.color = 'var(--text-muted)';
|
|
624
|
+
empty.style.padding = '8px 10px';
|
|
625
|
+
empty.textContent = '暂无在线设备';
|
|
626
|
+
deviceListEl.appendChild(empty);
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
state.devices.forEach((d) => {
|
|
630
|
+
const item = document.createElement('div');
|
|
631
|
+
item.className = 'device-item' + (state.currentDevice === d.name ? ' active' : '');
|
|
632
|
+
item.innerHTML = '<span class="device-dot"></span><div style="flex:1;min-width:0;"><div class="device-name">' + d.name + '</div><div class="device-meta">' + (d.connectedAt ? new Date(d.connectedAt).toLocaleString() : '已连接') + '</div></div>';
|
|
633
|
+
item.addEventListener('click', () => {
|
|
634
|
+
state.currentDevice = d.name;
|
|
635
|
+
state.currentPath = null;
|
|
636
|
+
selectedDeviceLabelEl.textContent = '当前设备:' + d.name;
|
|
637
|
+
renderDevices();
|
|
638
|
+
renderRootForDevice();
|
|
639
|
+
setStatus('已选择设备 ' + d.name + ',请选择目录。');
|
|
640
|
+
});
|
|
641
|
+
deviceListEl.appendChild(item);
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function renderRootForDevice() {
|
|
646
|
+
const device = state.currentDevice;
|
|
647
|
+
if (!device) {
|
|
648
|
+
breadcrumbsEl.innerHTML = '<span style="font-size:12px;color:var(--text-muted)">请选择左侧设备</span>';
|
|
649
|
+
fileTbodyEl.innerHTML = '';
|
|
650
|
+
panelSubtitleEl.textContent = '';
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
state.currentPath = null;
|
|
654
|
+
breadcrumbsEl.innerHTML = '';
|
|
655
|
+
const rootCrumb = document.createElement('span');
|
|
656
|
+
rootCrumb.className = 'breadcrumb-part current';
|
|
657
|
+
rootCrumb.textContent = '/' + device;
|
|
658
|
+
breadcrumbsEl.appendChild(rootCrumb);
|
|
659
|
+
panelSubtitleEl.textContent = '根目录:/' + device + ',选择 local_scripts 或 screenshots';
|
|
660
|
+
fileTbodyEl.innerHTML = '';
|
|
661
|
+
[{ name: 'local_scripts', type: 'dir', hint: '脚本文件' }, { name: 'screenshots', type: 'dir', hint: '截图文件' }].forEach((entry) => {
|
|
662
|
+
const tr = document.createElement('tr');
|
|
663
|
+
tr.className = 'row-folder';
|
|
664
|
+
tr.innerHTML = '<td><div class="file-name-cell"><span class="file-icon folder">📁</span><div><div>' + entry.name + '</div><div class="file-meta">' + entry.hint + '</div></div></div></td><td>目录</td><td>' + device + '</td><td></td>';
|
|
665
|
+
tr.addEventListener('click', () => openDir('/' + device + '/' + entry.name));
|
|
666
|
+
fileTbodyEl.appendChild(tr);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function renderBreadcrumbs(path) {
|
|
671
|
+
const device = state.currentDevice;
|
|
672
|
+
breadcrumbsEl.innerHTML = '';
|
|
673
|
+
if (!device) return;
|
|
674
|
+
const parts = path.replace(/^\/+/, '').split('/');
|
|
675
|
+
const segments = [];
|
|
676
|
+
for (let i = 0; i < parts.length; i++) {
|
|
677
|
+
const seg = parts[i];
|
|
678
|
+
if (!seg) continue;
|
|
679
|
+
const prev = segments[segments.length - 1] || '';
|
|
680
|
+
segments.push((prev ? prev + '/' + seg : seg).replace(/^\/+/, ''));
|
|
681
|
+
}
|
|
682
|
+
segments.forEach((seg, idx) => {
|
|
683
|
+
const name = seg.split('/').pop();
|
|
684
|
+
if (!name) return;
|
|
685
|
+
if (idx > 0) {
|
|
686
|
+
const sep = document.createElement('span');
|
|
687
|
+
sep.className = 'breadcrumb-sep';
|
|
688
|
+
sep.textContent = '>';
|
|
689
|
+
breadcrumbsEl.appendChild(sep);
|
|
690
|
+
}
|
|
691
|
+
const span = document.createElement('span');
|
|
692
|
+
span.className = 'breadcrumb-part' + (idx === segments.length - 1 ? ' current' : '');
|
|
693
|
+
span.textContent = '/' + name;
|
|
694
|
+
span.addEventListener('click', () => { if (idx === 0) renderRootForDevice(); else openDir('/' + seg); });
|
|
695
|
+
breadcrumbsEl.appendChild(span);
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
async function fetchJSON(url) {
|
|
700
|
+
const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
|
|
701
|
+
if (!res.ok) {
|
|
702
|
+
let msg = res.status + ' ' + res.statusText;
|
|
703
|
+
try { const data = await res.json(); if (data && data.message) msg = data.message; } catch (_) {}
|
|
704
|
+
throw new Error(msg);
|
|
705
|
+
}
|
|
706
|
+
return res.json();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async function loadDevices() {
|
|
710
|
+
try {
|
|
711
|
+
setStatus('正在加载设备列表…');
|
|
712
|
+
const data = await fetchJSON('api/devices');
|
|
713
|
+
state.devices = data.devices || [];
|
|
714
|
+
renderDevices();
|
|
715
|
+
if (!state.currentDevice && state.devices.length) {
|
|
716
|
+
state.currentDevice = state.devices[0].name;
|
|
717
|
+
selectedDeviceLabelEl.textContent = '当前设备:' + state.currentDevice;
|
|
718
|
+
renderDevices();
|
|
719
|
+
renderRootForDevice();
|
|
720
|
+
setStatus('已加载设备列表,当前设备:' + state.currentDevice);
|
|
721
|
+
} else if (!state.devices.length) {
|
|
722
|
+
selectedDeviceLabelEl.textContent = '未选择设备';
|
|
723
|
+
fileTbodyEl.innerHTML = '';
|
|
724
|
+
breadcrumbsEl.innerHTML = '<span style="font-size:12px;color:var(--text-muted)">暂无在线设备</span>';
|
|
725
|
+
setStatus('暂无在线设备。', 'warn');
|
|
726
|
+
} else {
|
|
727
|
+
setStatus('已刷新设备列表。');
|
|
728
|
+
}
|
|
729
|
+
} catch (err) {
|
|
730
|
+
setStatus('加载设备列表失败:' + err.message, 'error');
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
async function openDir(path) {
|
|
735
|
+
const device = state.currentDevice;
|
|
736
|
+
if (!device) { setStatus('请先选择设备。', 'warn'); return; }
|
|
737
|
+
state.currentPath = path;
|
|
738
|
+
try {
|
|
739
|
+
panelSubtitleEl.textContent = '目录:' + path;
|
|
740
|
+
setStatus('正在读取目录 ' + path + ' …');
|
|
741
|
+
const params = new URLSearchParams({ device: device, path: path });
|
|
742
|
+
const data = await fetchJSON('api/fs/dir?' + params.toString());
|
|
743
|
+
const entries = data.entries || [];
|
|
744
|
+
renderBreadcrumbs(path);
|
|
745
|
+
fileTbodyEl.innerHTML = '';
|
|
746
|
+
if (!entries.length) {
|
|
747
|
+
const tr = document.createElement('tr');
|
|
748
|
+
tr.innerHTML = '<td colspan="4" style="font-size:12px;color:var(--text-muted);padding:12px 10px;">当前目录下没有文件或子目录。</td>';
|
|
749
|
+
fileTbodyEl.appendChild(tr);
|
|
750
|
+
} else {
|
|
751
|
+
entries.forEach((e) => {
|
|
752
|
+
const tr = document.createElement('tr');
|
|
753
|
+
const icon = buildFileIcon(e.name, e.type);
|
|
754
|
+
tr.innerHTML = '<td><div class="file-name-cell"><span class="file-icon ' + icon.cls + '">' + icon.label + '</span><div><div>' + e.name + '</div><div class="file-meta">' + (e.type === 'dir' ? '目录' : '文件') + '</div></div></div></td><td>' + (e.type === 'dir' ? '目录' : '文件') + '</td><td>' + device + '</td><td><div class="file-actions">' + (e.type === 'file' ? '<button class="link-icon" data-action="view">查看</button><button class="link-icon danger" data-action="delete">删除</button>' : '') + '</div></td>';
|
|
755
|
+
if (e.type === 'dir') {
|
|
756
|
+
tr.classList.add('row-folder');
|
|
757
|
+
tr.addEventListener('click', () => openDir(joinPath(path, e.name)));
|
|
758
|
+
} else {
|
|
759
|
+
const actionsCell = tr.querySelector('.file-actions');
|
|
760
|
+
if (actionsCell) {
|
|
761
|
+
actionsCell.addEventListener('click', function (ev) {
|
|
762
|
+
ev.stopPropagation();
|
|
763
|
+
const target = ev.target;
|
|
764
|
+
const filePath = joinPath(path, e.name);
|
|
765
|
+
if (target.matches('[data-action="view"]')) window.open('api/fs/file?' + new URLSearchParams({ device, path: filePath }).toString(), '_blank');
|
|
766
|
+
else if (target.matches('[data-action="delete"]')) { if (confirm('确定要删除文件 ' + e.name + ' 吗?此操作不可恢复。')) deleteFile(device, filePath); }
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
fileTbodyEl.appendChild(tr);
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
setStatus('目录加载完成:' + path);
|
|
774
|
+
} catch (err) {
|
|
775
|
+
setStatus('读取目录失败:' + err.message, 'error');
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async function deleteFile(device, path) {
|
|
780
|
+
try {
|
|
781
|
+
setStatus('正在删除文件 ' + path + ' …');
|
|
782
|
+
const params = new URLSearchParams({ device, path });
|
|
783
|
+
const res = await fetch('api/fs/file?' + params.toString(), { method: 'DELETE' });
|
|
784
|
+
if (!res.ok) {
|
|
785
|
+
let msg = res.status + ' ' + res.statusText;
|
|
786
|
+
try { const data = await res.json(); if (data && data.message) msg = data.message; } catch (_) {}
|
|
787
|
+
throw new Error(msg);
|
|
788
|
+
}
|
|
789
|
+
setStatus('文件已删除:' + path);
|
|
790
|
+
if (state.currentPath) openDir(state.currentPath);
|
|
791
|
+
} catch (err) {
|
|
792
|
+
setStatus('删除文件失败:' + err.message, 'error');
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
btnRefreshDevices.addEventListener('click', loadDevices);
|
|
797
|
+
btnRootLocal.addEventListener('click', () => { if (state.currentDevice) openDir('/' + state.currentDevice + '/local_scripts'); else setStatus('请先选择设备。', 'warn'); });
|
|
798
|
+
btnRootScreenshots.addEventListener('click', () => { if (state.currentDevice) openDir('/' + state.currentDevice + '/screenshots'); else setStatus('请先选择设备。', 'warn'); });
|
|
799
|
+
btnRefreshDir.addEventListener('click', () => { if (state.currentPath) openDir(state.currentPath); else if (state.currentDevice) renderRootForDevice(); else setStatus('请先选择设备。', 'warn'); });
|
|
800
|
+
|
|
801
|
+
loadDevices();
|
|
802
|
+
})();
|
|
803
|
+
</script>
|
|
804
|
+
</body>
|
|
805
|
+
|
|
806
|
+
</html>
|
package/dist/tokens.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DeviceGateway } from './device-gateway.interface';
|
|
2
|
+
export declare const DEVICE_GATEWAY_TOKEN: unique symbol;
|
|
3
|
+
export interface CctExplorerModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* 实现 DeviceGateway 的类,由宿主应用提供,会在本模块内注册以便 Controller 注入
|
|
6
|
+
*/
|
|
7
|
+
deviceGatewayClass: new (...args: any[]) => DeviceGateway;
|
|
8
|
+
/**
|
|
9
|
+
* HTTP 路径前缀,例如 'cct' 则接口为 /cct/api/devices、/cct/browser
|
|
10
|
+
* 空字符串表示挂载在根路径
|
|
11
|
+
*/
|
|
12
|
+
pathPrefix?: string;
|
|
13
|
+
}
|
|
14
|
+
export declare const CCT_EXPLORER_OPTIONS: unique symbol;
|
package/dist/tokens.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CCT_EXPLORER_OPTIONS = exports.DEVICE_GATEWAY_TOKEN = void 0;
|
|
4
|
+
exports.DEVICE_GATEWAY_TOKEN = Symbol('DEVICE_GATEWAY_TOKEN');
|
|
5
|
+
exports.CCT_EXPLORER_OPTIONS = Symbol('CCT_EXPLORER_OPTIONS');
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bomon/nestjs-cct-explorer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "NestJS 模块:封装 CDP client 的设备资源浏览器 HTTP 接口与 /browser 页面",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"require": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc && node -e \"require('fs').cpSync('src/public', 'dist/public', { recursive: true })\""
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"readme.md"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"nestjs",
|
|
23
|
+
"cdp",
|
|
24
|
+
"puppeteer",
|
|
25
|
+
"socket.io",
|
|
26
|
+
"file-browser"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@nestjs/common": "^9.0.0 || ^10.0.0",
|
|
35
|
+
"@nestjs/core": "^9.0.0 || ^10.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"typescript": "^5.9.0",
|
|
39
|
+
"@types/node": "^20.0.0",
|
|
40
|
+
"@types/express": "^4.17.0",
|
|
41
|
+
"@nestjs/common": "^10.0.0",
|
|
42
|
+
"@nestjs/core": "^10.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @bomon/nestjs-cct-explorer
|
|
2
|
+
|
|
3
|
+
NestJS 模块:为 CDP Client 网关提供设备资源浏览器 HTTP 接口与 `/browser` 页面。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @bomon/nestjs-cct-explorer
|
|
9
|
+
# 或
|
|
10
|
+
npm i @bomon/nestjs-cct-explorer
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 使用
|
|
14
|
+
|
|
15
|
+
1. 在你的网关项目中实现 `DeviceGateway` 接口(例如基于 Socket.IO 转发到设备)。
|
|
16
|
+
2. 在根模块中通过 `forRoot` 传入你的网关类(需实现 `DeviceGateway`):
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
import { Module } from '@nestjs/common';
|
|
20
|
+
import { CctExplorerModule } from '@bomon/nestjs-cct-explorer';
|
|
21
|
+
import { SocketIoDeviceGateway } from './socket-io-device.gateway'; // 你的实现
|
|
22
|
+
|
|
23
|
+
@Module({
|
|
24
|
+
imports: [
|
|
25
|
+
CctExplorerModule.forRoot({
|
|
26
|
+
deviceGatewayClass: SocketIoDeviceGateway, // 必填
|
|
27
|
+
pathPrefix: '', // 可选,例如 'cct' 时需配合挂载路径
|
|
28
|
+
}),
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
export class AppModule {}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
3. 若使用全局前缀(如 `setGlobalPrefix('cct')`),则访问 `http://localhost:3000/cct/browser`,页面内 API 使用相对路径,会自动请求 `/cct/api/*`。
|
|
35
|
+
|
|
36
|
+
## 路由
|
|
37
|
+
|
|
38
|
+
| 路径 | 方法 | 说明 |
|
|
39
|
+
|------|------|------|
|
|
40
|
+
| `/browser` | GET | 资源浏览器单页 |
|
|
41
|
+
| `/api/devices` | GET | 设备列表 |
|
|
42
|
+
| `/api/fs/dir` | GET | 读目录 |
|
|
43
|
+
| `/api/fs/file` | GET | 读文件 |
|
|
44
|
+
| `/api/fs/file` | DELETE | 删文件 |
|
|
45
|
+
| `/api/fs/file` | POST | 写文件(需网关实现 writeFile) |
|
|
46
|
+
|
|
47
|
+
## 依赖
|
|
48
|
+
|
|
49
|
+
需实现并注入 `DeviceGateway`(见包内 `device-gateway.interface.ts`)。
|