Update
This commit is contained in:
parent
85c4bb9a26
commit
077a2ddc35
|
|
@ -0,0 +1,42 @@
|
|||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import User from '../models/user.js'
|
||||
|
||||
export default class AuthController {
|
||||
// Đăng ký
|
||||
async register({ request, response }: HttpContext) {
|
||||
const data = request.only(['email', 'password', 'full_name'])
|
||||
const user = await User.create(data)
|
||||
return response.json({ message: 'User created', user })
|
||||
}
|
||||
|
||||
// Đăng nhập
|
||||
async login({ request, auth, response }: HttpContext) {
|
||||
const { email, password } = request.only(['email', 'password'])
|
||||
const user = await User.query().where('email', email).first()
|
||||
|
||||
if (!user) {
|
||||
return response.status(401).json({ message: 'Invalid email or password' })
|
||||
}
|
||||
|
||||
try {
|
||||
// So sánh password
|
||||
if (user.password !== password) {
|
||||
return response.status(401).json({ message: 'Invalid email or password' })
|
||||
}
|
||||
|
||||
// ✅ Nếu dùng token thủ công:
|
||||
const token = Math.random().toString(36).substring(2) // hoặc JWT nếu bạn cài auth
|
||||
return response.json({
|
||||
message: 'Login successful',
|
||||
user: { id: user.id, email: user.email, token },
|
||||
})
|
||||
} catch {
|
||||
return response.status(401).json({ message: 'Invalid credentials' })
|
||||
}
|
||||
}
|
||||
|
||||
// Đăng xuất
|
||||
async logout({ auth, response }: HttpContext) {
|
||||
return response.json({ message: 'Logged out successfully' })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import User from '#models/user'
|
||||
import type { HttpContext } from '@adonisjs/core/http'
|
||||
import hash from '@adonisjs/core/services/hash'
|
||||
|
||||
export default class UsersController {
|
||||
async index({ request, response }: HttpContext) {
|
||||
|
|
@ -37,13 +36,10 @@ export default class UsersController {
|
|||
})
|
||||
}
|
||||
|
||||
// Hash the password before saving
|
||||
const hashedPassword = await hash.make(data.password)
|
||||
|
||||
const user = await User.create({
|
||||
fullName: data.full_name,
|
||||
email: data.email,
|
||||
password: hashedPassword,
|
||||
password: data.password,
|
||||
})
|
||||
|
||||
return response.created({
|
||||
|
|
@ -73,11 +69,6 @@ export default class UsersController {
|
|||
}
|
||||
}
|
||||
|
||||
// Hash the password if it is provided
|
||||
if (data.password) {
|
||||
data.password = await hash.make(data.password)
|
||||
}
|
||||
|
||||
user.merge(data)
|
||||
await user.save()
|
||||
|
||||
|
|
|
|||
|
|
@ -22,4 +22,4 @@ export default class AuthMiddleware {
|
|||
await ctx.auth.authenticateUsing(options.guards, { loginRoute: this.redirectTo })
|
||||
return next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,16 +12,16 @@ export default class Line extends BaseModel {
|
|||
declare port: number
|
||||
|
||||
@column()
|
||||
declare line_number: number
|
||||
declare lineNumber: number
|
||||
|
||||
@column()
|
||||
declare line_clear: number
|
||||
declare lineClear: number
|
||||
|
||||
@column()
|
||||
declare stationId: number
|
||||
|
||||
@column()
|
||||
declare apc_name: number
|
||||
declare apcName: string
|
||||
|
||||
@column()
|
||||
declare outlet: number
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import net from 'node:net'
|
||||
import { cleanData } from '../ultils/helper.js'
|
||||
|
||||
interface LineConfig {
|
||||
id: number
|
||||
|
|
@ -7,6 +8,8 @@ interface LineConfig {
|
|||
ip: string
|
||||
stationId: number
|
||||
apcName?: string
|
||||
output: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export default class LineConnection {
|
||||
|
|
@ -20,12 +23,19 @@ export default class LineConnection {
|
|||
this.client = new net.Socket()
|
||||
}
|
||||
|
||||
connect() {
|
||||
connect(timeoutMs = 5000) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const { ip, port, lineNumber, id, stationId } = this.config
|
||||
let resolvedOrRejected = false
|
||||
// Set timeout
|
||||
this.client.setTimeout(timeoutMs)
|
||||
|
||||
this.client.connect(port, ip, () => {
|
||||
if (resolvedOrRejected) return
|
||||
resolvedOrRejected = true
|
||||
|
||||
console.log(`✅ Connected to line ${lineNumber} (${ip}:${port})`)
|
||||
this.config.status = 'connected'
|
||||
this.socketIO.emit('line_connected', {
|
||||
stationId,
|
||||
lineId: id,
|
||||
|
|
@ -36,8 +46,19 @@ export default class LineConnection {
|
|||
})
|
||||
|
||||
this.client.on('data', (data) => {
|
||||
const message = data.toString().trim()
|
||||
console.log(`📨 [${this.config.apcName}] ${message}`)
|
||||
let message = data.toString()
|
||||
// let output = cleanData(message)
|
||||
// console.log(`📨 [${this.config.port}] ${message}`)
|
||||
// Handle netOutput with backspace support
|
||||
for (const char of message) {
|
||||
if (char === '\x7F' || char === '\x08') {
|
||||
this.config.output = this.config.output.slice(0, -1)
|
||||
// message = message.slice(0, -1)
|
||||
} else {
|
||||
this.config.output += cleanData(char)
|
||||
}
|
||||
}
|
||||
this.config.output = this.config.output.slice(-15000)
|
||||
this.socketIO.emit('line_output', {
|
||||
stationId,
|
||||
lineId: id,
|
||||
|
|
@ -46,7 +67,10 @@ export default class LineConnection {
|
|||
})
|
||||
|
||||
this.client.on('error', (err) => {
|
||||
if (resolvedOrRejected) return
|
||||
resolvedOrRejected = true
|
||||
console.error(`❌ Error line ${lineNumber}:`, err.message)
|
||||
this.config.output += err.message
|
||||
this.socketIO.emit('line_error', {
|
||||
stationId,
|
||||
lineId: id,
|
||||
|
|
@ -57,12 +81,23 @@ export default class LineConnection {
|
|||
|
||||
this.client.on('close', () => {
|
||||
console.log(`🔌 Line ${lineNumber} disconnected`)
|
||||
this.config.status = 'disconnected'
|
||||
this.socketIO.emit('line_disconnected', {
|
||||
stationId,
|
||||
lineId: id,
|
||||
lineNumber,
|
||||
status: 'disconnected',
|
||||
})
|
||||
})
|
||||
|
||||
this.client.on('timeout', () => {
|
||||
if (resolvedOrRejected) return
|
||||
resolvedOrRejected = true
|
||||
|
||||
console.log(`⏳ Connection timeout line ${lineNumber}`)
|
||||
this.client.destroy()
|
||||
reject(new Error('Connection timeout'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -71,13 +106,26 @@ export default class LineConnection {
|
|||
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
|
||||
return
|
||||
}
|
||||
console.log(`➡️ [${this.config.apcName}] SEND:`, cmd)
|
||||
// console.log(`➡️ [${this.config.apcName}] SEND:`, cmd)
|
||||
this.client.write(`${cmd}\r\n`)
|
||||
}
|
||||
|
||||
writeCommand(cmd: string) {
|
||||
if (this.client.destroyed) {
|
||||
console.log(`⚠️ Cannot send, line ${this.config.lineNumber} is closed`)
|
||||
return
|
||||
}
|
||||
this.client.write(`${cmd}`)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
try {
|
||||
this.client.destroy()
|
||||
this.config.status = 'disconnected'
|
||||
this.socketIO.emit('line_disconnected', {
|
||||
...this.config,
|
||||
status: 'disconnected',
|
||||
})
|
||||
console.log(`🔻 Closed connection to line ${this.config.lineNumber}`)
|
||||
} catch (e) {
|
||||
console.error('Error closing line:', e)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
/**
|
||||
* Function to clean up unwanted characters from the output data.
|
||||
* @param {string} data - The raw data to be cleaned.
|
||||
* @returns {string} - The cleaned data.
|
||||
*/
|
||||
export const cleanData = (data) => {
|
||||
return data
|
||||
.replace(/--More--\s*BS\s*BS\s*BS\s*BS\s*BS\s*BS/g, '')
|
||||
.replace(/\s*--More--\s*/g, '')
|
||||
.replace(/\x1b\[[0-9;]*m/g, '') // Remove ANSI escape codes
|
||||
.replace(/\x08/g, '')
|
||||
.replace(/[^\x20-\x7E\r\n]/g, '') // Remove non-printable characters
|
||||
// .replace(/\r\n/g, '\n')
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"version": "0.0.0",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@adonisjs/auth": "^9.4.0",
|
||||
"@adonisjs/auth": "^9.5.1",
|
||||
"@adonisjs/core": "^6.18.0",
|
||||
"@adonisjs/cors": "^2.2.1",
|
||||
"@adonisjs/lucid": "^21.6.1",
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@
|
|||
"typescript": "~5.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@adonisjs/auth": "^9.4.0",
|
||||
"@adonisjs/auth": "^9.5.1",
|
||||
"@adonisjs/core": "^6.18.0",
|
||||
"@adonisjs/cors": "^2.2.1",
|
||||
"@adonisjs/lucid": "^21.6.1",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import LineConnection from '../app/services/line_connection.js'
|
|||
import { ApplicationService } from '@adonisjs/core/types'
|
||||
import env from '#start/env'
|
||||
import { CustomServer, CustomSocket } from '../app/ultils/types.js'
|
||||
import Line from '#models/line'
|
||||
|
||||
interface Station {
|
||||
id: number
|
||||
|
|
@ -77,24 +78,37 @@ export class WebSocketIo {
|
|||
console.log('Socket connected:', socket.id)
|
||||
socket.connectionTime = new Date()
|
||||
|
||||
const lineConnectionArray: LineConnection[] = Array.from(this.lineMap.values())
|
||||
io.to(socket.id).emit(
|
||||
'init',
|
||||
lineConnectionArray.map((el) => el.config)
|
||||
)
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`🔴 FE disconnected: ${socket.id}`)
|
||||
console.log(`FE disconnected: ${socket.id}`)
|
||||
})
|
||||
|
||||
// FE gửi yêu cầu connect lines
|
||||
socket.on('connect_lines', async (stationData: Station) => {
|
||||
console.log('📡 Yêu cầu connect station:', stationData.name)
|
||||
await this.connectStation(socket, stationData)
|
||||
socket.on('connect_lines', async (data) => {
|
||||
const { stationData, linesData } = data
|
||||
await this.connectLine(io, linesData, stationData)
|
||||
})
|
||||
|
||||
// FE gửi command đến line cụ thể
|
||||
socket.on('send_command', (data) => {
|
||||
const { lineId, command } = data
|
||||
const line = this.lineMap.get(lineId)
|
||||
if (line) {
|
||||
line.sendCommand(command)
|
||||
} else {
|
||||
socket.emit('line_error', { lineId, error: 'Line not connected' })
|
||||
socket.on('write_command_line_from_web', (data) => {
|
||||
const { lineIds, stationId, command } = data
|
||||
for (const lineId of lineIds) {
|
||||
const line = this.lineMap.get(lineId)
|
||||
if (line) {
|
||||
this.setTimeoutConnect(lineId, line)
|
||||
line.writeCommand(command)
|
||||
} else {
|
||||
io.emit('line_disconnected', {
|
||||
stationId,
|
||||
lineId,
|
||||
status: 'disconnected',
|
||||
})
|
||||
io.emit('line_error', { lineId, error: 'Line not connected\r\n' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -111,22 +125,30 @@ export class WebSocketIo {
|
|||
return io
|
||||
}
|
||||
|
||||
private async connectStation(socket, station: Station) {
|
||||
this.stationMap.set(station.id, station)
|
||||
for (const line of station.lines) {
|
||||
const lineConn = new LineConnection(
|
||||
{
|
||||
id: line.id,
|
||||
port: line.port,
|
||||
ip: station.ip,
|
||||
lineNumber: line.lineNumber,
|
||||
stationId: station.id,
|
||||
apcName: line.apcName,
|
||||
},
|
||||
this.io
|
||||
)
|
||||
await lineConn.connect()
|
||||
this.lineMap.set(line.id, lineConn)
|
||||
private async connectLine(socket: any, lines: Line[], station: Station) {
|
||||
try {
|
||||
this.stationMap.set(station.id, station)
|
||||
for (const line of lines) {
|
||||
const lineConn = new LineConnection(
|
||||
{
|
||||
id: line.id,
|
||||
port: line.port,
|
||||
ip: station.ip,
|
||||
lineNumber: line.lineNumber,
|
||||
stationId: station.id,
|
||||
apcName: line.apcName,
|
||||
output: '',
|
||||
status: '',
|
||||
},
|
||||
socket
|
||||
)
|
||||
await lineConn.connect()
|
||||
lineConn.writeCommand('\r\n\r\n')
|
||||
this.lineMap.set(line.id, lineConn)
|
||||
this.setTimeoutConnect(line.id, lineConn)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -145,4 +167,17 @@ export class WebSocketIo {
|
|||
this.stationMap.delete(stationId)
|
||||
console.log(`🔻 Station ${station.name} disconnected`)
|
||||
}
|
||||
|
||||
private setTimeoutConnect = (lineId: number, lineConn: LineConnection) => {
|
||||
if (this.intervalMap[`${lineId}`]) {
|
||||
clearInterval(this.intervalMap[`${lineId}`])
|
||||
delete this.intervalMap[`${lineId}`]
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
lineConn.disconnect()
|
||||
this.lineMap.delete(lineId)
|
||||
}, 120000)
|
||||
|
||||
this.intervalMap[`${lineId}`] = interval
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,3 +63,9 @@ router
|
|||
router.post('delete', '#controllers/scenarios_controller.delete')
|
||||
})
|
||||
.prefix('api/scenarios')
|
||||
|
||||
router
|
||||
.group(() => {
|
||||
router.post('/login', '#controllers/auth_controller.login')
|
||||
})
|
||||
.prefix('api/auth')
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>frontend</title>
|
||||
<title>Automation Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,12 @@
|
|||
"@mantine/dates": "^8.3.5",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"axios": "^1.12.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"socket.io-client": "^4.8.1",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
|
@ -1913,6 +1915,22 @@
|
|||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
|
||||
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
|
@ -4280,6 +4298,13 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
|
||||
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@
|
|||
"@mantine/dates": "^8.3.5",
|
||||
"@mantine/notifications": "^8.3.5",
|
||||
"@tabler/icons-react": "^3.35.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"axios": "^1.12.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"socket.io-client": "^4.8.1"
|
||||
"socket.io-client": "^4.8.1",
|
||||
"xterm": "^5.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
|
|
|||
|
|
@ -21,14 +21,16 @@ import type { TLine, TStation } from "./untils/types";
|
|||
import axios from "axios";
|
||||
import CardLine from "./components/CardLine";
|
||||
import { IconEdit, IconSettingsPlus } from "@tabler/icons-react";
|
||||
import { SocketProvider, useSocket } from "./context/SocketContext";
|
||||
|
||||
const apiUrl = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
/**
|
||||
* Main Component
|
||||
*/
|
||||
export default function App() {
|
||||
export function App() {
|
||||
document.title = "Automation Test";
|
||||
const { socket } = useSocket();
|
||||
const [stations, setStations] = useState<TStation[]>([]);
|
||||
const [selectedLines, setSelectedLines] = useState<TLine[]>([]);
|
||||
const [activeTab, setActiveTab] = useState("0");
|
||||
|
|
@ -62,95 +64,162 @@ export default function App() {
|
|||
getStation();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MantineProvider>
|
||||
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}>
|
||||
{/* Tabs (Top Bar) */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(id) => setActiveTab(id?.toString() || "0")}
|
||||
variant="none"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List ref={setRootRef} className={classes.list}>
|
||||
{stations.map((station) => (
|
||||
<Tabs.Tab
|
||||
ref={setControlRef(station.id.toString())}
|
||||
className={classes.tab}
|
||||
key={station.id}
|
||||
value={station.id.toString()}
|
||||
>
|
||||
{station.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
useEffect(() => {
|
||||
if (!socket || !stations?.length) return;
|
||||
|
||||
<FloatingIndicator
|
||||
target={activeTab ? controlsRefs[activeTab] : null}
|
||||
parent={rootRef}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
<Flex gap={"sm"}>
|
||||
<ActionIcon title="Add Station" variant="outline" color="green">
|
||||
<IconSettingsPlus />
|
||||
</ActionIcon>
|
||||
const updateStatus = (data: any) => {
|
||||
const line = getLine(data.lineId, data.stationId);
|
||||
if (line) {
|
||||
updateValueLineStation(line, "status", data.status);
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("line_connected", updateStatus);
|
||||
socket.on("line_disconnected", updateStatus);
|
||||
socket?.on("init", (data) => {
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((value) => {
|
||||
updateStatus(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ✅ cleanup on unmount or when socket changes
|
||||
return () => {
|
||||
socket.off("line_connected");
|
||||
socket.off("line_disconnected");
|
||||
socket.off("line_disconnected");
|
||||
};
|
||||
}, [socket, stations]);
|
||||
|
||||
const updateValueLineStation = (
|
||||
currentLine: TLine,
|
||||
field: string,
|
||||
value: any
|
||||
) => {
|
||||
setStations((el) =>
|
||||
el?.map((station: TStation) =>
|
||||
station.id.toString() === activeTab
|
||||
? {
|
||||
...station,
|
||||
lines: (station?.lines || [])?.map((lineItem: TLine) => {
|
||||
if (lineItem.id === currentLine.id) {
|
||||
return {
|
||||
...lineItem,
|
||||
[field]: value,
|
||||
};
|
||||
}
|
||||
return lineItem;
|
||||
}),
|
||||
}
|
||||
: station
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getLine = (lineId: number, stationId: number) => {
|
||||
const station = stations?.find((sta) => sta.id === stationId);
|
||||
if (station) {
|
||||
const line = station.lines?.find((li) => li.id === lineId);
|
||||
return line;
|
||||
} else return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Container py="xl" w={"100%"} style={{ maxWidth: "100%" }}>
|
||||
{/* Tabs (Top Bar) */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(id) => setActiveTab(id?.toString() || "0")}
|
||||
variant="none"
|
||||
keepMounted={false}
|
||||
>
|
||||
<Tabs.List ref={setRootRef} className={classes.list}>
|
||||
{stations.map((station) => (
|
||||
<Tabs.Tab
|
||||
ref={setControlRef(station.id.toString())}
|
||||
className={classes.tab}
|
||||
key={station.id}
|
||||
value={station.id.toString()}
|
||||
>
|
||||
{station.name}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
|
||||
<FloatingIndicator
|
||||
target={activeTab ? controlsRefs[activeTab] : null}
|
||||
parent={rootRef}
|
||||
className={classes.indicator}
|
||||
/>
|
||||
<Flex gap={"sm"}>
|
||||
<ActionIcon title="Add Station" variant="outline" color="green">
|
||||
<IconSettingsPlus />
|
||||
</ActionIcon>
|
||||
{Number(activeTab) && (
|
||||
<ActionIcon title="Edit Station" variant="outline">
|
||||
<IconEdit />
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
</Tabs.List>
|
||||
)}
|
||||
</Flex>
|
||||
</Tabs.List>
|
||||
|
||||
{stations.map((station) => (
|
||||
<Tabs.Panel
|
||||
className={classes.content}
|
||||
key={station.id}
|
||||
value={station.id.toString()}
|
||||
pt="md"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col
|
||||
span={10}
|
||||
style={{
|
||||
boxShadow: showBottomShadow
|
||||
? "inset 0 -12px 10px -10px rgba(0, 0, 0, 0.2)"
|
||||
: "none",
|
||||
borderRadius: 8,
|
||||
{stations.map((station) => (
|
||||
<Tabs.Panel
|
||||
className={classes.content}
|
||||
key={station.id}
|
||||
value={station.id.toString()}
|
||||
pt="md"
|
||||
>
|
||||
<Grid>
|
||||
<Grid.Col
|
||||
span={11}
|
||||
style={{
|
||||
boxShadow: showBottomShadow
|
||||
? "inset 0 -12px 10px -10px rgba(0, 0, 0, 0.2)"
|
||||
: "none",
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<ScrollArea
|
||||
h={"84vh"}
|
||||
onScrollPositionChange={({ y }) => {
|
||||
const el = document.querySelector(
|
||||
".mantine-ScrollArea-viewport"
|
||||
);
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollHeight - el.clientHeight;
|
||||
setShowBottomShadow(y < maxScroll - 2);
|
||||
}}
|
||||
>
|
||||
<ScrollArea
|
||||
h={"84vh"}
|
||||
onScrollPositionChange={({ y }) => {
|
||||
const el = document.querySelector(
|
||||
".mantine-ScrollArea-viewport"
|
||||
);
|
||||
if (!el) return;
|
||||
const maxScroll = el.scrollHeight - el.clientHeight;
|
||||
setShowBottomShadow(y < maxScroll - 2);
|
||||
}}
|
||||
>
|
||||
{station.lines.length > 0 ? (
|
||||
<Flex wrap="wrap" gap="sm" justify={"center"}>
|
||||
{[
|
||||
...station.lines,
|
||||
...station.lines,
|
||||
...station.lines,
|
||||
].map((line) => (
|
||||
<CardLine
|
||||
line={line}
|
||||
selectedLines={selectedLines}
|
||||
setSelectedLines={setSelectedLines}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" mt="lg">
|
||||
No lines configured
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Grid.Col>
|
||||
<Grid.Col
|
||||
span={2}
|
||||
style={{ backgroundColor: "#f1f1f1", borderRadius: 8 }}
|
||||
{station.lines.length > 0 ? (
|
||||
<Flex wrap="wrap" gap="sm" justify={"center"}>
|
||||
{station.lines.map((line, i) => (
|
||||
<CardLine
|
||||
key={i}
|
||||
socket={socket}
|
||||
stationItem={station}
|
||||
line={line}
|
||||
selectedLines={selectedLines}
|
||||
setSelectedLines={setSelectedLines}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Text ta="center" c="dimmed" mt="lg">
|
||||
No lines configured
|
||||
</Text>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Grid.Col>
|
||||
<Grid.Col
|
||||
span={1}
|
||||
style={{ backgroundColor: "#f1f1f1", borderRadius: 8 }}
|
||||
>
|
||||
<Flex
|
||||
direction={"column"}
|
||||
align={"center"}
|
||||
gap={"xs"}
|
||||
wrap={"wrap"}
|
||||
>
|
||||
<Button
|
||||
variant="filled"
|
||||
|
|
@ -165,12 +234,42 @@ export default function App() {
|
|||
? "Select All"
|
||||
: "Deselect All"}
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Container>
|
||||
<Button
|
||||
disabled={
|
||||
selectedLines.filter((el) => el.status !== "connected")
|
||||
.length === 0
|
||||
}
|
||||
variant="outline"
|
||||
style={{ height: "30px", width: "120px" }}
|
||||
onClick={() => {
|
||||
const lines = selectedLines.filter(
|
||||
(el) => el.status !== "connected"
|
||||
);
|
||||
socket?.emit("connect_lines", {
|
||||
stationData: station,
|
||||
linesData: lines,
|
||||
});
|
||||
setSelectedLines([]);
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
</Flex>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Main() {
|
||||
return (
|
||||
<MantineProvider>
|
||||
<SocketProvider>
|
||||
<App />
|
||||
</SocketProvider>
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,70 @@
|
|||
.wrapper {
|
||||
min-height: rem(100vh);
|
||||
background-size: cover;
|
||||
background-image: url(https://images.unsplash.com/photo-1484242857719-4b9144542727?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1280&q=80);
|
||||
}
|
||||
|
||||
.form {
|
||||
border-right: rem(1px) solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-7));
|
||||
min-height: rem(100vh);
|
||||
max-width: rem(500px);
|
||||
padding-top: rem(80px);
|
||||
@media (max-width: var(--mantine-breakpoint-sm)) {
|
||||
max-width: 100%;
|
||||
};
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: light-dark(var(--mantine-color-black), var(--mantine-color-white));
|
||||
font-family:
|
||||
Greycliff CF,
|
||||
var(--mantine-font-family);
|
||||
}
|
||||
|
||||
.google-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #4285f4;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
margin: 15px auto;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.google-btn:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.google-btn:active {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.title::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 2px;
|
||||
background-color: #007bff; /* blue accent */
|
||||
margin: 0.1rem auto 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { useState } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { RootState } from '@/rtk/store'
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
PasswordInput,
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core'
|
||||
import { notifications } from '@mantine/notifications'
|
||||
|
||||
import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput'
|
||||
import { requirementsPassword } from '@/rtk/helpers/variables'
|
||||
import { passwordRegex } from '@/utils/formRegexs'
|
||||
|
||||
import { post } from '@/rtk/helpers/apiService'
|
||||
import { changePassword } from '@/api/Auth'
|
||||
|
||||
type TChangePassword = {
|
||||
opened: boolean
|
||||
setOpened: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
function ChangePassword({ opened, setOpened }: TChangePassword) {
|
||||
const { user } = useSelector((state: RootState) => state.auth)
|
||||
|
||||
const [countSpam, setCountSpam] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
current_password: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
setLoading(true)
|
||||
if (countSpam > 5) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: 'Password error more than 5 times. Logout after 3s',
|
||||
color: 'red',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
localStorage.clear()
|
||||
window.location.reload()
|
||||
}, 3000)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await post(changePassword, {
|
||||
email: user!.email,
|
||||
current_password: formData.current_password,
|
||||
password: formData.password,
|
||||
confirm_password: formData.confirm_password,
|
||||
})
|
||||
|
||||
if (res.status) {
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: res.message,
|
||||
color: 'green',
|
||||
})
|
||||
|
||||
setOpened(false)
|
||||
setFormData({
|
||||
current_password: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
return
|
||||
}
|
||||
setCountSpam(countSpam + 1)
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isFormValid =
|
||||
formData.current_password &&
|
||||
formData.password &&
|
||||
passwordRegex.test(formData.password) &&
|
||||
formData.password === formData.confirm_password
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
setOpened(false)
|
||||
setFormData({
|
||||
current_password: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
}}
|
||||
title={
|
||||
<Text fw={700} fz={'1.2rem'}>
|
||||
Change password
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box p="sm">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleChangePassword()
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
label="E-mail"
|
||||
value={user!.email}
|
||||
disabled
|
||||
mb="md"
|
||||
required
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Current password"
|
||||
required
|
||||
placeholder="Current password"
|
||||
maxLength={32}
|
||||
value={formData.current_password}
|
||||
onChange={(e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
current_password: e.target.value,
|
||||
})
|
||||
}}
|
||||
mb="md"
|
||||
error={
|
||||
formData.current_password.length < 8 &&
|
||||
formData.current_password !== '' &&
|
||||
'Length 8 characters or more'
|
||||
}
|
||||
/>
|
||||
|
||||
<PasswordRequirementInput
|
||||
requirements={requirementsPassword}
|
||||
value={formData}
|
||||
setValue={setFormData}
|
||||
label="New password"
|
||||
placeholder="New password"
|
||||
name="password"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
required
|
||||
placeholder="Confirm password"
|
||||
maxLength={32}
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
confirm_password: e.target.value,
|
||||
})
|
||||
}
|
||||
error={
|
||||
formData.password !== formData.confirm_password &&
|
||||
formData.confirm_password !== '' &&
|
||||
'Password do not match'
|
||||
}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
style={{ float: 'right' }}
|
||||
mb={'lg'}
|
||||
mt={'lg'}
|
||||
loading={loading}
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
Change Password
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChangePassword
|
||||
|
|
@ -0,0 +1,181 @@
|
|||
import { useGoogleLogin } from '@react-oauth/google'
|
||||
import { emailRegex } from '@/utils/formRegexs'
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { AppDispatch, RootState } from '@/rtk/store'
|
||||
import {
|
||||
loginAsync,
|
||||
loginERPAsync,
|
||||
loginWithGoogleAsync,
|
||||
} from '@/rtk/slices/authSlice'
|
||||
|
||||
import { Box, Button, PasswordInput, TextInput } from '@mantine/core'
|
||||
import { useForm } from '@mantine/form'
|
||||
|
||||
import classes from './AuthenticationImage.module.css'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import ImgERP from '../../lib/images/erp.jpg'
|
||||
import { useState } from 'react'
|
||||
|
||||
type TLogin = {
|
||||
email: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate()
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const { status } = useSelector((state: RootState) => state.auth)
|
||||
const [isLoginERP, setIsLoginERP] = useState(false)
|
||||
|
||||
const formLogin = useForm<TLogin>({
|
||||
initialValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
validate: (values) => ({
|
||||
email:
|
||||
values.email === ''
|
||||
? 'Email is required'
|
||||
: isLoginERP
|
||||
? null
|
||||
: emailRegex.test(values.email)
|
||||
? null
|
||||
: 'Invalid email',
|
||||
|
||||
password: values.password === '' ? 'Password is required' : null,
|
||||
}),
|
||||
})
|
||||
|
||||
const handleLogin = async (values: TLogin) => {
|
||||
if (isLoginERP) {
|
||||
const payload = {
|
||||
userEmail: values.email,
|
||||
password: values.password,
|
||||
}
|
||||
const resultAction = await dispatch(loginERPAsync(payload))
|
||||
|
||||
if (loginERPAsync.fulfilled.match(resultAction)) {
|
||||
// set interval to wait for localStorage to be set
|
||||
window.location.href = '/dashboard'
|
||||
}
|
||||
} else {
|
||||
const resultAction = await dispatch(loginAsync(values))
|
||||
|
||||
if (loginAsync.fulfilled.match(resultAction)) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoginGG = useGoogleLogin({
|
||||
onSuccess: async (codeResponse) => {
|
||||
const accessToken = codeResponse.access_token
|
||||
const resultAction = await dispatch(loginWithGoogleAsync(accessToken))
|
||||
|
||||
if (loginWithGoogleAsync.fulfilled.match(resultAction)) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
},
|
||||
onError: (error) => console.log('Login Failed:', error),
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
}}
|
||||
onSubmit={formLogin.onSubmit(handleLogin)}
|
||||
>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<h3 className={classes.title}>
|
||||
{isLoginERP ? 'Login with ERP account' : 'Login with ATC account'}
|
||||
</h3>
|
||||
</div>
|
||||
<TextInput
|
||||
label={isLoginERP ? 'Username/email:' : 'Email address'}
|
||||
placeholder="hello@gmail.com"
|
||||
value={formLogin.values.email}
|
||||
error={formLogin.errors.email}
|
||||
onChange={(e) => {
|
||||
formLogin.setFieldValue('email', e.target.value!)
|
||||
}}
|
||||
required
|
||||
size="md"
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Your password"
|
||||
value={formLogin.values.password}
|
||||
error={formLogin.errors.password}
|
||||
onChange={(e) => {
|
||||
formLogin.setFieldValue('password', e.target.value!)
|
||||
}}
|
||||
required
|
||||
mt="md"
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
mt="xl"
|
||||
size="md"
|
||||
type="submit"
|
||||
loading={status === 'loading'}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
|
||||
{!isLoginERP ? (
|
||||
<Box ta={'center'}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="#228be6"
|
||||
radius={'5px'}
|
||||
className={classes['google-btn']}
|
||||
onClick={() => setIsLoginERP(true)}
|
||||
>
|
||||
<img
|
||||
src={ImgERP}
|
||||
alt="ERP logo"
|
||||
className={classes['google-icon']}
|
||||
/>
|
||||
Sign in with ERP
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Box ta={'center'}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="#228be6"
|
||||
radius={'5px'}
|
||||
className={classes['google-btn']}
|
||||
onClick={() => setIsLoginERP(false)}
|
||||
>
|
||||
Sign in normally
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box ta={'center'}>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="#228be6"
|
||||
radius={'5px'}
|
||||
onClick={() => handleLoginGG()}
|
||||
className={classes['google-btn']}
|
||||
>
|
||||
<img
|
||||
src="https://upload.wikimedia.org/wikipedia/commons/thumb/c/c1/Google_%22G%22_logo.svg/480px-Google_%22G%22_logo.svg.png"
|
||||
alt="Google logo"
|
||||
className={classes['google-icon']}
|
||||
/>
|
||||
Sign in with Google
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { AppDispatch, RootState } from '@/rtk/store'
|
||||
import { registerAsync } from '@/rtk/slices/authSlice'
|
||||
|
||||
import PasswordRequirementInput from '../PasswordRequirementInput/PasswordRequirementInput'
|
||||
import { emailRegex, passwordRegex } from '@/utils/formRegexs'
|
||||
import { requirementsPassword } from '@/rtk/helpers/variables'
|
||||
|
||||
import { Box, Button, PasswordInput, TextInput } from '@mantine/core'
|
||||
|
||||
type TRegister = {
|
||||
email: string
|
||||
password: string
|
||||
confirm_password: string
|
||||
full_name: string
|
||||
}
|
||||
|
||||
function Register() {
|
||||
const dispatch = useDispatch<AppDispatch>()
|
||||
const { status } = useSelector((state: RootState) => state.auth)
|
||||
|
||||
const [formRegister, setFormRegister] = useState<TRegister>({
|
||||
email: '',
|
||||
full_name: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
|
||||
const handleRegister = async () => {
|
||||
// Dispatch action registerAsync với dữ liệu form và đợi kết quả
|
||||
const resultAction = await dispatch(registerAsync(formRegister))
|
||||
|
||||
// Kiểm tra nếu action thành công
|
||||
if (registerAsync.fulfilled.match(resultAction)) {
|
||||
// Tải lại trang web
|
||||
// window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleRegister()
|
||||
}}
|
||||
>
|
||||
<TextInput
|
||||
label="Email address"
|
||||
placeholder="hello@gmail.com"
|
||||
value={formRegister.email}
|
||||
error={
|
||||
emailRegex.test(formRegister.email) || formRegister.email === ''
|
||||
? null
|
||||
: 'Invalid email'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFormRegister({ ...formRegister, email: e.target.value })
|
||||
}}
|
||||
required
|
||||
size="md"
|
||||
mb="md"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
mb="md"
|
||||
label="Full name"
|
||||
placeholder="Bill Gates"
|
||||
value={formRegister.full_name}
|
||||
onChange={(e) => {
|
||||
setFormRegister({ ...formRegister, full_name: e.target.value })
|
||||
}}
|
||||
required
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<PasswordRequirementInput
|
||||
requirements={requirementsPassword}
|
||||
value={formRegister}
|
||||
setValue={setFormRegister}
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
name="password"
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
mt="md"
|
||||
label="Confirm password"
|
||||
placeholder="Your password"
|
||||
value={formRegister.confirm_password}
|
||||
error={
|
||||
formRegister.confirm_password === formRegister.password ||
|
||||
formRegister.confirm_password === ''
|
||||
? null
|
||||
: 'Password do not match'
|
||||
}
|
||||
onChange={(e) => {
|
||||
setFormRegister({ ...formRegister, confirm_password: e.target.value })
|
||||
}}
|
||||
required
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<Box ta={'center'}>
|
||||
<Button
|
||||
type="submit"
|
||||
m="15px auto"
|
||||
fullWidth
|
||||
size="md"
|
||||
loading={status === 'loading'}
|
||||
disabled={
|
||||
formRegister.password !== '' &&
|
||||
passwordRegex.test(formRegister.password) &&
|
||||
formRegister.password === formRegister.confirm_password
|
||||
? false
|
||||
: true
|
||||
}
|
||||
>
|
||||
Register
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default Register
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
.resetForm {
|
||||
background-color: light-dark(#ffffffdb, var(--mantine-color-dark-5));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
|
@ -1,15 +1,23 @@
|
|||
import { Card, Text, Box, Flex } from "@mantine/core";
|
||||
import type { TLine } from "../untils/types";
|
||||
import type { TLine, TStation } from "../untils/types";
|
||||
import classes from "./Component.module.css";
|
||||
import TerminalCLI from "./TerminalXTerm";
|
||||
import type { Socket } from "socket.io-client";
|
||||
import { IconCircleCheckFilled } from "@tabler/icons-react";
|
||||
import { memo } from "react";
|
||||
|
||||
const CardLine = ({
|
||||
line,
|
||||
selectedLines,
|
||||
setSelectedLines,
|
||||
socket,
|
||||
stationItem,
|
||||
}: {
|
||||
line: TLine;
|
||||
selectedLines: TLine[];
|
||||
setSelectedLines: (lines: React.SetStateAction<TLine[]>) => void;
|
||||
socket: Socket | null;
|
||||
stationItem: TStation;
|
||||
}) => {
|
||||
return (
|
||||
<Card
|
||||
|
|
@ -31,24 +39,50 @@ const CardLine = ({
|
|||
else setSelectedLines((pre) => [...pre, line]);
|
||||
}}
|
||||
>
|
||||
<Flex justify={"space-between"}>
|
||||
<Box>
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
Line {line.lineNumber} - {line.port}
|
||||
</Text>
|
||||
</div>
|
||||
<Text className={classes.info_line}>PID: WS-C3560CG-8PC-S</Text>
|
||||
<Flex
|
||||
justify={"space-between"}
|
||||
direction={"column"}
|
||||
// gap={"md"}
|
||||
align={"center"}
|
||||
>
|
||||
<div>
|
||||
<Text fw={600} style={{ display: "flex", gap: "4px" }}>
|
||||
Line {line.lineNumber} - {line.port}{" "}
|
||||
{line.status === "connected" && (
|
||||
<IconCircleCheckFilled color="green" />
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
{/* <Text className={classes.info_line}>PID: WS-C3560CG-8PC-S</Text>
|
||||
<div className={classes.info_line}>SN: FGL2240307M</div>
|
||||
<div className={classes.info_line}>VID: V01</div>
|
||||
</Box>
|
||||
<div className={classes.info_line}>VID: V01</div> */}
|
||||
<Box
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{ backgroundColor: "black", height: "130px", width: "220px" }}
|
||||
></Box>
|
||||
style={{ height: "175px", width: "300px" }}
|
||||
>
|
||||
<TerminalCLI
|
||||
cliOpened={true}
|
||||
socket={socket}
|
||||
content={line.netOutput ?? ""}
|
||||
line_id={Number(line?.id)}
|
||||
station_id={Number(stationItem.id)}
|
||||
isDisabled={false}
|
||||
line_status={line?.status || ""}
|
||||
fontSize={11}
|
||||
miniSize={true}
|
||||
customStyle={{
|
||||
maxHeight: "175px",
|
||||
height: "175px",
|
||||
fontSize: "7px",
|
||||
padding: "0px",
|
||||
paddingBottom: "0px",
|
||||
}}
|
||||
onDoubleClick={() => {}}
|
||||
/>
|
||||
</Box>
|
||||
</Flex>
|
||||
{/* <Flex justify={"flex-end"}>
|
||||
<Button variant="filled" style={{ height: "30px", width: "70px" }}>
|
||||
|
|
@ -59,4 +93,4 @@ const CardLine = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default CardLine;
|
||||
export default memo(CardLine);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.card_line {
|
||||
width: 400px;
|
||||
height: 150px;
|
||||
width: 320px;
|
||||
height: 220px;
|
||||
padding: 8px;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,236 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "xterm";
|
||||
import "xterm/css/xterm.css";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { SOCKET_EVENTS } from "../untils/constanst";
|
||||
import type { Socket } from "socket.io-client";
|
||||
|
||||
interface TerminalCLIProps {
|
||||
socket: Socket | null;
|
||||
content?: string;
|
||||
line_id: number;
|
||||
line_status: string;
|
||||
station_id: number;
|
||||
cliOpened: boolean;
|
||||
isDisabled?: boolean;
|
||||
customStyle?: {
|
||||
fontSize?: string;
|
||||
maxHeight?: string;
|
||||
height?: string;
|
||||
padding?: string;
|
||||
paddingBottom?: string;
|
||||
paddingLeft?: string;
|
||||
};
|
||||
onDoubleClick?: () => void;
|
||||
fontSize?: number;
|
||||
miniSize?: boolean;
|
||||
}
|
||||
|
||||
const TerminalCLI: React.FC<TerminalCLIProps> = ({
|
||||
socket,
|
||||
content = "",
|
||||
line_id,
|
||||
station_id,
|
||||
cliOpened = false,
|
||||
isDisabled = false,
|
||||
line_status = "",
|
||||
customStyle = {},
|
||||
onDoubleClick = () => {},
|
||||
fontSize = 14,
|
||||
miniSize = false,
|
||||
}) => {
|
||||
const xtermRef = useRef<HTMLDivElement>(null);
|
||||
const terminal = useRef<Terminal>(null);
|
||||
const fitRef = useRef<FitAddon>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cliOpened || fitRef.current) return;
|
||||
|
||||
terminal.current = new Terminal({
|
||||
disableStdin: isDisabled,
|
||||
cursorBlink: true,
|
||||
convertEol: true,
|
||||
fontFamily: "Consolas, 'Courier New', monospace",
|
||||
fontSize: fontSize,
|
||||
theme: {
|
||||
background: "#000000",
|
||||
foreground: "#41ee4a",
|
||||
cursor: "#ffffff",
|
||||
},
|
||||
// rows: 24,
|
||||
// cols: 80,
|
||||
});
|
||||
const fitAddon = new FitAddon();
|
||||
fitRef.current = fitAddon;
|
||||
if (!miniSize) terminal.current.focus();
|
||||
terminal.current.loadAddon(fitAddon);
|
||||
terminal.current.open(xtermRef.current!);
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 500);
|
||||
|
||||
// Gửi input từ người dùng lên server
|
||||
terminal.current.onData((data) => {
|
||||
socket?.emit(SOCKET_EVENTS.CLI.WRITE_COMMAND_FROM_WEB, {
|
||||
lineIds: [line_id],
|
||||
stationId: station_id,
|
||||
command: data,
|
||||
});
|
||||
});
|
||||
|
||||
terminal.current.onSelectionChange(() => {
|
||||
const selectedText = terminal?.current?.getSelection();
|
||||
if (selectedText) {
|
||||
navigator.clipboard
|
||||
.writeText(selectedText)
|
||||
.then(() => console.log("Copied to clipboard"))
|
||||
.catch((err) => console.error("Clipboard copy failed", err));
|
||||
}
|
||||
});
|
||||
|
||||
terminal.current.attachCustomKeyEventHandler(
|
||||
(e: KeyboardEvent): boolean => {
|
||||
// Handle Ctrl+V (Paste)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === "v") return false;
|
||||
// Handle Esc
|
||||
if (e.key === "Escape") return false;
|
||||
|
||||
return true; // allow all other keys through
|
||||
}
|
||||
);
|
||||
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
terminal?.current?.paste(text);
|
||||
} catch (err) {
|
||||
console.error("Clipboard read failed:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (xtermRef.current)
|
||||
xtermRef.current?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
return () => {
|
||||
xtermRef?.current?.removeEventListener("contextmenu", handleContextMenu);
|
||||
};
|
||||
}, [cliOpened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cliOpened) {
|
||||
if (terminal.current)
|
||||
setTimeout(() => terminal.current?.write(content), 200);
|
||||
|
||||
if (fitRef.current) setTimeout(() => fitRef.current?.fit(), 500);
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
// Nhận output từ thiết bị và ghi vào terminal
|
||||
socket?.on("line_output", (data) => {
|
||||
if (data?.lineId === line_id && terminal.current) {
|
||||
terminal.current?.write(data.data);
|
||||
terminal.current?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on("line_error", (data) => {
|
||||
if (data?.lineId === line_id && terminal.current) {
|
||||
terminal.current?.write(data.error);
|
||||
}
|
||||
});
|
||||
|
||||
socket?.on("init", (data) => {
|
||||
if (Array.isArray(data)) {
|
||||
data.forEach((value) => {
|
||||
if (value?.id === line_id && terminal.current) {
|
||||
terminal.current?.write(value.output);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket?.off("init");
|
||||
socket?.off("line_error");
|
||||
socket?.off("line_output");
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cliOpened) {
|
||||
setTimeout(() => {
|
||||
setLoading(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
if (!cliOpened && terminal?.current) {
|
||||
// console.log('Dispose terminal CLI')
|
||||
terminal?.current.clear();
|
||||
terminal?.current.dispose();
|
||||
terminal.current = null;
|
||||
setLoading(true);
|
||||
}
|
||||
}, [cliOpened]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (terminal.current) {
|
||||
terminal.current?.write(content);
|
||||
if (!miniSize && !isDisabled) terminal.current?.focus();
|
||||
terminal.current.scrollToBottom();
|
||||
}
|
||||
if (fitRef.current) fitRef.current?.fit();
|
||||
setLoading(true);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (terminal.current) {
|
||||
terminal.current.options.disableStdin = isDisabled;
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setLoading(true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
backgroundColor: "black",
|
||||
paddingBottom: customStyle.paddingBottom ?? "10px",
|
||||
minHeight: customStyle.maxHeight ?? "60vh",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={xtermRef}
|
||||
style={{
|
||||
width: "100%",
|
||||
paddingLeft: customStyle.paddingLeft ?? "10px",
|
||||
paddingBottom: customStyle.paddingBottom ?? "10px",
|
||||
fontSize: customStyle.fontSize ?? "9px",
|
||||
maxHeight: customStyle.maxHeight ?? "60vh",
|
||||
height: customStyle.height ?? "60vh",
|
||||
padding: customStyle.padding ?? "4px",
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onDoubleClick();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalCLI;
|
||||
Loading…
Reference in New Issue