Compare commits
No commits in common. "main" and "v0.4.1.0" have entirely different histories.
3
.gitignore
vendored
3
.gitignore
vendored
@ -40,9 +40,6 @@ Desktop.ini
|
|||||||
|
|
||||||
# Project Specific - Server
|
# Project Specific - Server
|
||||||
server/.env
|
server/.env
|
||||||
server/.env.backup*
|
|
||||||
server/*.tmp
|
|
||||||
server/backups/
|
|
||||||
server/uploads/*
|
server/uploads/*
|
||||||
!server/uploads/.gitkeep
|
!server/uploads/.gitkeep
|
||||||
server/server.zip
|
server/server.zip
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
# ==============================================
|
|
||||||
# [Common Settings]
|
|
||||||
# ==============================================
|
|
||||||
DB_HOST=sokuree.com
|
|
||||||
DB_USER=choibk
|
|
||||||
DB_PASSWORD=^Ocean1472bk
|
|
||||||
PORT=3005
|
|
||||||
|
|
||||||
# ==============================================
|
|
||||||
# [Development Environment] - Local Windows
|
|
||||||
# ==============================================
|
|
||||||
# 로컬 개발용 DB (분리됨: sokuree_platform_dev)
|
|
||||||
DB_NAME=sokuree_platform_dev
|
|
||||||
DB_PORT=3307
|
|
||||||
# Windows 환경 호환성 (tcp는 권한 오류 발생 가능)
|
|
||||||
CCTV_TRANSPORT_OVERRIDE=auto
|
|
||||||
|
|
||||||
# ==============================================
|
|
||||||
# [Production Environment] - Synology NAS
|
|
||||||
# ==============================================
|
|
||||||
# DB_NAME=sokuree_platform_prod
|
|
||||||
# DB_PORT=3307
|
|
||||||
|
|
||||||
@ -35,13 +35,8 @@
|
|||||||
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
||||||
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
||||||
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.2.0`
|
#### 🏷️ Tag: `v0.4.2.0`
|
||||||
- [ ] **라이선스 관리 오류 수정**
|
|
||||||
- [ ] **시스템 관리 기본설정 오류 수정**
|
|
||||||
- [ ] **시스템 업데이트 오류 수정**
|
|
||||||
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.3.0`
|
|
||||||
- [ ] **소모품 관리**
|
- [ ] **소모품 관리**
|
||||||
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"version": "0.4.0.1",
|
"version": "0.4.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"version": "0.4.0.1",
|
"version": "0.4.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.2.7",
|
"version": "0.4.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -51,9 +51,6 @@ const sessionStoreOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionStore = new MySQLStore(sessionStoreOptions);
|
const sessionStore = new MySQLStore(sessionStoreOptions);
|
||||||
sessionStore.on('error', (err) => {
|
|
||||||
console.error('Session Store Error:', err);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors({
|
app.use(cors({
|
||||||
@ -441,20 +438,14 @@ const initTables = async () => {
|
|||||||
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
||||||
if (existingModules.length === 0) {
|
if (existingModules.length === 0) {
|
||||||
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||||
await db.query(insert, ['asset', '자산 관리', false, null]);
|
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
||||||
await db.query(insert, ['production', '생산 관리', false, null]);
|
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||||
await db.query(insert, ['cctv', 'CCTV', false, null]);
|
await db.query(insert, ['cctv', 'CCTV', true, 'dev']);
|
||||||
} else {
|
} else {
|
||||||
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
|
// One-time update: Rename 'monitoring' code to 'cctv' and ensure it's active
|
||||||
// Use subquery or check if cctv exists to avoid ER_DUP_ENTRY
|
await db.query("UPDATE system_modules SET code = 'cctv', is_active = 1 WHERE code = 'monitoring'");
|
||||||
const [cctvExists] = await db.query("SELECT 1 FROM system_modules WHERE code = 'cctv'");
|
// Also ensure 'cctv' is active if it already exists but was inactive due to renaming glitches
|
||||||
if (cctvExists.length > 0) {
|
await db.query("UPDATE system_modules SET is_active = 1 WHERE code = 'cctv'");
|
||||||
// If cctv already exists, just remove monitoring if it's there
|
|
||||||
await db.query("DELETE FROM system_modules WHERE code = 'monitoring'");
|
|
||||||
} else {
|
|
||||||
// If cctv doesn't exist, try renaming monitoring
|
|
||||||
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
@ -467,20 +458,24 @@ initTables();
|
|||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
// Dynamic version check (Light-weight)
|
// Force Korean time (UTC+9) for the timestamp
|
||||||
const kstOffset = 9 * 60 * 60 * 1000;
|
const kstOffset = 9 * 60 * 60 * 1000;
|
||||||
const kstDate = new Date(Date.now() + kstOffset);
|
const kstDate = new Date(Date.now() + kstOffset);
|
||||||
|
|
||||||
let version = packageJson.version;
|
// Dynamic Version detection: Prioritize local Git Tag to match the "Update" rule
|
||||||
|
let version = '0.0.0.0';
|
||||||
try {
|
try {
|
||||||
const { execSync } = require('child_process');
|
const { execSync } = require('child_process');
|
||||||
// Check git tag in parent directory (Project root)
|
// Get the latest tag on the current commit
|
||||||
version = execSync('git describe --tags --abbrev=0', {
|
version = execSync('git describe --tags --abbrev=0', { cwd: path.join(__dirname, '..') }).toString().trim().replace(/^v/, '');
|
||||||
cwd: path.join(__dirname, '..'),
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
|
||||||
}).toString().trim().replace(/^v/, '');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Safe fallback to package.json
|
// Fallback to root package.json if git fails
|
||||||
|
try {
|
||||||
|
const rootPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8'));
|
||||||
|
version = rootPkg.version;
|
||||||
|
} catch (err) {
|
||||||
|
version = packageJson.version; // Fallback to server/package.json
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.0.1",
|
"version": "0.4.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.0.1",
|
"version": "0.4.0.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.2.7",
|
"version": "0.4.0.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -153,20 +153,6 @@ router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
|
|
||||||
let conn;
|
let conn;
|
||||||
try {
|
try {
|
||||||
// 1. Try to connect without specifying database first to see if credentials/host are OK
|
|
||||||
try {
|
|
||||||
const basicConn = await mysql.createConnection({
|
|
||||||
host, user, password, port: parseInt(port) || 3306, connectTimeout: 3000
|
|
||||||
});
|
|
||||||
await basicConn.end();
|
|
||||||
} catch (basicErr) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
error: `서버 접속 실패: 계정 정보나 호스트/포트를 확인하세요. (${basicErr.message})`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Try to connect with database
|
|
||||||
conn = await mysql.createConnection({
|
conn = await mysql.createConnection({
|
||||||
host,
|
host,
|
||||||
user,
|
user,
|
||||||
@ -178,13 +164,7 @@ router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
await conn.query('SELECT 1');
|
await conn.query('SELECT 1');
|
||||||
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
|
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
let msg = err.message;
|
res.status(400).json({ success: false, error: err.message });
|
||||||
if (err.code === 'ER_BAD_DB_ERROR') {
|
|
||||||
msg = `데이터베이스 '${database}'가 존재하지 않습니다. MariaDB에서 스키마를 먼저 생성해 주세요.`;
|
|
||||||
} else if (err.code === 'ER_ACCESS_DENIED_ERROR') {
|
|
||||||
msg = '사용자 계정 또는 비밀번호가 일치하지 않거나, 해당 DB에 대한 접근 권한이 없습니다.';
|
|
||||||
}
|
|
||||||
res.status(400).json({ success: false, error: msg });
|
|
||||||
} finally {
|
} finally {
|
||||||
if (conn) await conn.end();
|
if (conn) await conn.end();
|
||||||
}
|
}
|
||||||
@ -298,8 +278,7 @@ router.get('/modules', isAuthenticated, async (req, res) => {
|
|||||||
|
|
||||||
// Get stored subscriber ID
|
// Get stored subscriber ID
|
||||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||||
// Ensure we return null or empty string if not found, DO NOT use any hardcoded fallback
|
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
|
||||||
const serverSubscriberId = (subRows.length > 0 && subRows[0].setting_value) ? subRows[0].setting_value : '';
|
|
||||||
|
|
||||||
defaults.forEach(code => {
|
defaults.forEach(code => {
|
||||||
const found = rows.find(r => r.code === code);
|
const found = rows.find(r => r.code === code);
|
||||||
@ -343,10 +322,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Module match
|
// 2. Check Module match
|
||||||
// Allow legacy 'monitoring' licenses to activate 'cctv' module
|
if (result.module !== code) {
|
||||||
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
|
|
||||||
|
|
||||||
if (!isMatch) {
|
|
||||||
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -366,7 +342,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (result.subscriberId !== serverSubscriberId) {
|
if (result.subscriberId !== serverSubscriberId) {
|
||||||
return res.status(400).json({
|
return res.status(403).json({
|
||||||
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -489,36 +465,33 @@ const getGiteaAuth = async () => {
|
|||||||
// 5. Get Version Info (Current, Remote & History from Tags)
|
// 5. Get Version Info (Current, Remote & History from Tags)
|
||||||
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Local version detection (Dynamic & Robust)
|
// Dynamic Version detection: Prioritize local Git Tag
|
||||||
const projectRoot = path.join(__dirname, '../..');
|
|
||||||
let currentVersion = '0.0.0.0';
|
let currentVersion = '0.0.0.0';
|
||||||
try {
|
try {
|
||||||
const { execSync } = require('child_process');
|
currentVersion = execSync('git describe --tags --abbrev=0', { cwd: path.join(__dirname, '../..') }).toString().trim().replace(/^v/, '');
|
||||||
currentVersion = execSync('git describe --tags --abbrev=0', {
|
|
||||||
cwd: projectRoot,
|
|
||||||
stdio: ['ignore', 'pipe', 'ignore']
|
|
||||||
}).toString().trim().replace(/^v/, '');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
const packageJsonPath = path.resolve(__dirname, '../../package.json');
|
||||||
currentVersion = rootPkg.version;
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||||
|
currentVersion = packageJson.version;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
currentVersion = '0.4.2.7';
|
currentVersion = '0.4.0.0'; // Ultimate fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare git fetch command
|
// Prepare git fetch command with auth if available
|
||||||
const auth = await getGiteaAuth();
|
const auth = await getGiteaAuth();
|
||||||
let fetchCmd = 'git fetch --tags';
|
let fetchCmd = 'git fetch --tags --force';
|
||||||
|
|
||||||
if (auth.user && auth.pass && auth.url && auth.url.includes('https://')) {
|
if (auth.user && auth.pass) {
|
||||||
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||||
fetchCmd = `git fetch ${authenticatedUrl} --tags --force --prune`;
|
// Use explicit refspec to ensure local tags are updated from the remote URL
|
||||||
} else if (auth.url) {
|
fetchCmd = `git fetch ${authenticatedUrl} +refs/tags/*:refs/tags/* --force --prune --prune-tags`;
|
||||||
fetchCmd = `git fetch ${auth.url} --tags --force --prune`;
|
} else {
|
||||||
|
fetchCmd = `git fetch ${auth.url} +refs/tags/*:refs/tags/* --force --prune --prune-tags`;
|
||||||
}
|
}
|
||||||
|
|
||||||
exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
|
exec(fetchCmd, (err, stdout, stderr) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('Git fetch failed:', err);
|
console.error('Git fetch failed:', err);
|
||||||
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
|
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
|
||||||
@ -535,7 +508,7 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
|||||||
const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)';
|
const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)';
|
||||||
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=50`;
|
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=50`;
|
||||||
|
|
||||||
exec(historyCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
|
exec(historyCmd, (err, stdout, stderr) => {
|
||||||
const lines = stdout ? stdout.trim().split('\n') : [];
|
const lines = stdout ? stdout.trim().split('\n') : [];
|
||||||
const allTags = lines.map(line => {
|
const allTags = lines.map(line => {
|
||||||
const [tag, subject, body, date] = line.split('|');
|
const [tag, subject, body, date] = line.split('|');
|
||||||
@ -622,7 +595,7 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
|
|||||||
|
|
||||||
// Build auth URL for git commands
|
// Build auth URL for git commands
|
||||||
let remoteUrl = auth.url;
|
let remoteUrl = auth.url;
|
||||||
if (auth.user && auth.pass && auth.url.includes('https://')) {
|
if (auth.user && auth.pass) {
|
||||||
remoteUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
remoteUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -641,33 +614,18 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
|
|||||||
scriptContent = `
|
scriptContent = `
|
||||||
@echo off
|
@echo off
|
||||||
echo [Update] Starting update to ${targetTag}...
|
echo [Update] Starting update to ${targetTag}...
|
||||||
|
if not exist "${backupDir}" mkdir "${backupDir}"
|
||||||
|
|
||||||
REM Ensure backup directory
|
echo [Update] Backing up Uploads & Config...
|
||||||
set BACKUP_PATH=${backupDir}
|
tar -czvf "${backupDir}/backup_images_${timestamp}.tar.gz" server/uploads/
|
||||||
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
|
if exist "server\\.env" copy "server\\.env" "${backupDir}\\env_backup_${timestamp}"
|
||||||
if not exist "%BACKUP_PATH%" (
|
|
||||||
echo [Warning] Global backup failed, using local backup.
|
|
||||||
set BACKUP_PATH=.\\server\\backups
|
|
||||||
if not exist ".\\server\\backups" mkdir ".\\server\\backups"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Backing up Config...
|
|
||||||
if exist "server\\.env" (
|
|
||||||
copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}"
|
|
||||||
copy /Y "server\\.env" "server\\.env.tmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Syncing Source Code...
|
echo [Update] Syncing Source Code...
|
||||||
git fetch "${remoteUrl}" --tags --force --prune
|
git fetch "${remoteUrl}" +refs/tags/*:refs/tags/* --force --prune --prune-tags
|
||||||
git checkout -f ${targetTag}
|
git checkout -f ${targetTag}
|
||||||
|
|
||||||
echo [Update] Restoring Config...
|
echo [Update] Restoring Config...
|
||||||
if exist "server\\.env.tmp" (
|
if exist "${backupDir}\\env_backup_${timestamp}" copy /Y "${backupDir}\\env_backup_${timestamp}" "server\\.env"
|
||||||
copy /Y "server\\.env.tmp" "server\\.env"
|
|
||||||
del "server\\.env.tmp"
|
|
||||||
) else if exist "%BACKUP_PATH%\\.env.backup.${timestamp}" (
|
|
||||||
copy /Y "%BACKUP_PATH%\\.env.backup.${timestamp}" "server\\.env"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Installing & Building...
|
echo [Update] Installing & Building...
|
||||||
call npm install
|
call npm install
|
||||||
@ -675,7 +633,8 @@ call npm run build
|
|||||||
cd server
|
cd server
|
||||||
call npm install
|
call npm install
|
||||||
|
|
||||||
echo [Update] Done.
|
echo [Update] Restarting Server...
|
||||||
|
echo "Please restart your dev server manually if needed."
|
||||||
`;
|
`;
|
||||||
} else {
|
} else {
|
||||||
// Linux/Synology Script
|
// Linux/Synology Script
|
||||||
@ -683,59 +642,22 @@ echo [Update] Done.
|
|||||||
scriptContent = `#!/bin/bash
|
scriptContent = `#!/bin/bash
|
||||||
exec > >(tee -a update.log) 2>&1
|
exec > >(tee -a update.log) 2>&1
|
||||||
echo "[Update] Starting update to ${targetTag}..."
|
echo "[Update] Starting update to ${targetTag}..."
|
||||||
|
mkdir -p ${backupDir}
|
||||||
# Ensure backup directory
|
|
||||||
BACKUP_DIR="${backupDir}"
|
|
||||||
mkdir -p "$BACKUP_DIR" || {
|
|
||||||
echo "[Warning] Global backup failed, using local backup."
|
|
||||||
BACKUP_DIR="./server/backups"
|
|
||||||
mkdir -p "$BACKUP_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "[Update] Backing up Database..."
|
echo "[Update] Backing up Database..."
|
||||||
if [ -f "${dumpTool}" ]; then
|
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > ${backupDir}/backup_db_${timestamp}.sql || echo "DB Backup Failed, continuing..."
|
||||||
echo "[Info] MySQL dump tool found. Attempting database backup..."
|
|
||||||
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > "$BACKUP_DIR/backup_db_${timestamp}.sql" 2>/dev/null
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "[Info] Database backup successful."
|
|
||||||
else
|
|
||||||
echo "[Warning] Database backup failed, continuing..."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[Warning] MySQL dump tool not found. Skipping DB backup."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[Update] Backing up Config..."
|
echo "[Update] Backing up Uploads & Config..."
|
||||||
if [ -f "server/.env" ]; then
|
tar -czvf ${backupDir}/backup_images_${timestamp}.tar.gz server/uploads/
|
||||||
echo "[Info] Backing up 'server/.env' to '$BACKUP_DIR/.env.backup.${timestamp}' and 'server/.env.tmp'."
|
cp server/.env ${backupDir}/.env.backup.${timestamp}
|
||||||
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
|
|
||||||
cp "server/.env" "server/.env.tmp"
|
|
||||||
else
|
|
||||||
echo "[Warning] 'server/.env' not found. Skipping config backup."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[Update] Syncing Source Code..."
|
echo "[Update] Syncing Source Code..."
|
||||||
git remote set-url origin "${remoteUrl}"
|
git remote set-url origin "${remoteUrl}"
|
||||||
git fetch origin --tags --force --prune
|
git fetch origin +refs/tags/*:refs/tags/* --force --prune --prune-tags
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[Error] Git fetch failed. Exiting update."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[Info] Git fetch successful."
|
|
||||||
git checkout -f ${targetTag}
|
git checkout -f ${targetTag}
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[Error] Git checkout to ${targetTag} failed. Exiting update."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[Info] Git checkout to ${targetTag} successful."
|
|
||||||
|
|
||||||
echo "[Update] Restoring Config..."
|
echo "[Update] Restoring Config..."
|
||||||
if [ -f "server/.env.tmp" ]; then
|
cp ${backupDir}/.env.backup.${timestamp} server/.env
|
||||||
cp "server/.env.tmp" "server/.env"
|
|
||||||
rm "server/.env.tmp"
|
|
||||||
elif [ -f "$BACKUP_DIR/.env.backup.${timestamp}" ]; then
|
|
||||||
cp "$BACKUP_DIR/.env.backup.${timestamp}" "server/.env"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[Update] Installing & Building..."
|
echo "[Update] Installing & Building..."
|
||||||
npm install
|
npm install
|
||||||
|
|||||||
@ -61,8 +61,6 @@ export function LoginPage() {
|
|||||||
placeholder="아이디를 입력하세요"
|
placeholder="아이디를 입력하세요"
|
||||||
required
|
required
|
||||||
autoComplete="one-time-code"
|
autoComplete="one-time-code"
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -79,8 +77,6 @@ export function LoginPage() {
|
|||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -151,11 +151,7 @@ export function BasicSettingsPage() {
|
|||||||
setTestResult({ success: true, message: res.data.message });
|
setTestResult({ success: true, message: res.data.message });
|
||||||
setIsDbVerified(true);
|
setIsDbVerified(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const detailedError = error.response?.data?.error || error.message || '알 수 없는 오류가 발생했습니다.';
|
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
|
||||||
setTestResult({
|
|
||||||
success: false,
|
|
||||||
message: `접속 실패: ${detailedError}`
|
|
||||||
});
|
|
||||||
setIsDbVerified(false);
|
setIsDbVerified(false);
|
||||||
} finally {
|
} finally {
|
||||||
setTesting(false);
|
setTesting(false);
|
||||||
@ -333,7 +329,7 @@ export function BasicSettingsPage() {
|
|||||||
|
|
||||||
{/* Section 2.5: Gitea Repository Update Settings */}
|
{/* Section 2.5: Gitea Repository Update Settings */}
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50 flex justify-between items-center">
|
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
||||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||||
<RefreshCcw size={20} className="text-emerald-600" />
|
<RefreshCcw size={20} className="text-emerald-600" />
|
||||||
시스템 업데이트 저장소 설정 (Gitea)
|
시스템 업데이트 저장소 설정 (Gitea)
|
||||||
@ -456,14 +452,7 @@ export function BasicSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-sm font-medium text-slate-700">비밀번호</label>
|
<label className="text-sm font-medium text-slate-700">비밀번호</label>
|
||||||
<Input
|
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
value={settings.db_config.password}
|
|
||||||
onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -539,9 +528,6 @@ export function BasicSettingsPage() {
|
|||||||
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
value={verifyPassword}
|
value={verifyPassword}
|
||||||
onChange={e => setVerifyPassword(e.target.value)}
|
onChange={e => setVerifyPassword(e.target.value)}
|
||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
|
|||||||
@ -242,10 +242,6 @@ export function UserManagementPage() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
||||||
<Input
|
<Input
|
||||||
name="user_id_new"
|
|
||||||
autoComplete="off"
|
|
||||||
readOnly={!isEditing}
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
value={formData.id}
|
value={formData.id}
|
||||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||||
disabled={isEditing}
|
disabled={isEditing}
|
||||||
@ -259,10 +255,6 @@ export function UserManagementPage() {
|
|||||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
name="user_password_new"
|
|
||||||
autoComplete="new-password"
|
|
||||||
readOnly={!isEditing}
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export function LicensePage() {
|
|||||||
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
||||||
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
||||||
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
||||||
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscriber Configuration State
|
// Subscriber Configuration State
|
||||||
@ -21,13 +21,6 @@ export function LicensePage() {
|
|||||||
const [inputSubscriberId, setInputSubscriberId] = useState('');
|
const [inputSubscriberId, setInputSubscriberId] = useState('');
|
||||||
const [isConfigSaving, setIsConfigSaving] = useState(false);
|
const [isConfigSaving, setIsConfigSaving] = useState(false);
|
||||||
|
|
||||||
// Supervisor Verification State
|
|
||||||
const [isConfigUnlocked, setIsConfigUnlocked] = useState(false);
|
|
||||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
|
||||||
const [verifyPassword, setVerifyPassword] = useState('');
|
|
||||||
const [verifying, setVerifying] = useState(false);
|
|
||||||
const [verifyError, setVerifyError] = useState('');
|
|
||||||
|
|
||||||
// Initial Load for Subscriber ID
|
// Initial Load for Subscriber ID
|
||||||
useState(() => {
|
useState(() => {
|
||||||
fetchSubscriberId();
|
fetchSubscriberId();
|
||||||
@ -93,30 +86,7 @@ export function LicensePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ... Generator logic omitted for brevity as it's dev-only ...
|
||||||
const handleOpenVerify = () => {
|
|
||||||
setVerifyError('');
|
|
||||||
setVerifyPassword('');
|
|
||||||
setShowVerifyModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVerifySupervisor = async (e?: React.FormEvent) => {
|
|
||||||
if (e) e.preventDefault();
|
|
||||||
setVerifying(true);
|
|
||||||
setVerifyError('');
|
|
||||||
try {
|
|
||||||
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
|
|
||||||
if (res.data.success) {
|
|
||||||
setIsConfigUnlocked(true);
|
|
||||||
setShowVerifyModal(false);
|
|
||||||
setVerifyPassword('');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setVerifyError(error.response?.data?.message || '인증 실패');
|
|
||||||
} finally {
|
|
||||||
setVerifying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -136,61 +106,37 @@ export function LicensePage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8 overflow-hidden">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
<Terminal size={20} className="text-slate-600" />
|
||||||
<Terminal size={20} className="text-slate-600" />
|
서버 환경 설정
|
||||||
서버 환경 설정
|
</h3>
|
||||||
</h3>
|
<div className="max-w-xl">
|
||||||
{!isConfigUnlocked && (
|
<div className="flex items-end gap-4">
|
||||||
<button
|
<div className="flex-1">
|
||||||
onClick={handleOpenVerify}
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
className="text-xs bg-slate-200 hover:bg-slate-300 px-3 py-1 rounded font-bold text-slate-700 flex items-center gap-1 transition"
|
서버 구독자 ID (Server Subscriber ID)
|
||||||
>
|
</label>
|
||||||
<Shield size={12} /> 조회 / 변경
|
<input
|
||||||
</button>
|
type="text"
|
||||||
)}
|
className="w-full border border-slate-300 rounded px-3 py-2"
|
||||||
{isConfigUnlocked && (
|
placeholder="예: SAMSUNG, DEMO_USER_01"
|
||||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded font-bold flex items-center gap-1">
|
value={inputSubscriberId}
|
||||||
<Shield size={12} /> 최고관리자 권한
|
onChange={(e) => setInputSubscriberId(e.target.value)}
|
||||||
</span>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isConfigUnlocked ? (
|
|
||||||
<div className="max-w-xl animate-in fade-in duration-300">
|
|
||||||
<div className="flex items-end gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
서버 구독자 ID (Server Subscriber ID)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full border border-slate-300 rounded px-3 py-2"
|
|
||||||
placeholder="예: SAMSUNG, DEMO_USER_01"
|
|
||||||
value={inputSubscriberId}
|
|
||||||
onChange={(e) => setInputSubscriberId(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveConfig}
|
|
||||||
disabled={isConfigSaving}
|
|
||||||
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px] whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-2">
|
<button
|
||||||
* 주의: 구독자 ID를 변경하면 기존에 활성화된 모든 모듈의 라이선스 효력이 중지될 수 있습니다.<br />
|
onClick={handleSaveConfig}
|
||||||
* 반드시 라이선스 키에 포함된 ID와 일치하게 설정하십시오.
|
disabled={isConfigSaving}
|
||||||
</p>
|
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px]"
|
||||||
|
>
|
||||||
|
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
<div className="p-8 text-center bg-slate-100 rounded border border-slate-200 border-dashed">
|
* 라이선스 키에 포함된 ID와 일치해야 활성화됩니다.
|
||||||
<p className="text-slate-500 text-sm mb-2">서버 환경 설정은 시스템의 핵심 식별자입니다.</p>
|
</p>
|
||||||
<p className="text-slate-400 text-xs">보안을 위해 <strong>최고관리자(Supervisor)</strong> 인증 후 변경할 수 있습니다.</p>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
@ -314,50 +260,6 @@ export function LicensePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Supervisor Verification Modal */}
|
|
||||||
{showVerifyModal && (
|
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[60] animate-in fade-in duration-200">
|
|
||||||
<div className="bg-white rounded-lg shadow-2xl w-full max-w-sm overflow-hidden border border-slate-200">
|
|
||||||
<div className="bg-slate-800 p-4 text-white">
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
<Shield size={20} /> 최고관리자 인증
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-300 text-xs mt-1">서버 핵심 설정을 변경하려면 인증이 필요합니다.</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleVerifySupervisor} className="p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">비밀번호</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
className="w-full border border-slate-300 rounded px-3 py-2 focus:ring-2 focus:ring-slate-500 outline-none"
|
|
||||||
placeholder="최고관리자 비밀번호 입력"
|
|
||||||
value={verifyPassword}
|
|
||||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
{verifyError && <p className="text-red-500 text-xs mt-2 font-bold">{verifyError}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setShowVerifyModal(false); setVerifyPassword(''); }}
|
|
||||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded text-sm font-medium"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={verifying}
|
|
||||||
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-900 text-sm font-bold"
|
|
||||||
>
|
|
||||||
{verifying ? '인증 중...' : '확인'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
|
|
||||||
@echo off
|
|
||||||
echo [Update] Starting update to v0.4.2.6...
|
|
||||||
|
|
||||||
REM Ensure backup directory
|
|
||||||
set BACKUP_PATH=./backup
|
|
||||||
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
|
|
||||||
if not exist "%BACKUP_PATH%" (
|
|
||||||
echo [Warning] Global backup failed, using local backup.
|
|
||||||
set BACKUP_PATH=.\server\backups
|
|
||||||
if not exist ".\server\backups" mkdir ".\server\backups"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Backing up Config...
|
|
||||||
if exist "server\.env" (
|
|
||||||
copy /Y "server\.env" "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22"
|
|
||||||
copy /Y "server\.env" "server\.env.tmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Syncing Source Code...
|
|
||||||
git fetch "https://gitea.qideun.com/SOKUREE/smart_ims.git" --tags --force --prune
|
|
||||||
git checkout -f v0.4.2.6
|
|
||||||
|
|
||||||
echo [Update] Restoring Config...
|
|
||||||
if exist "server\.env.tmp" (
|
|
||||||
copy /Y "server\.env.tmp" "server\.env"
|
|
||||||
del "server\.env.tmp"
|
|
||||||
) else if exist "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" (
|
|
||||||
copy /Y "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" "server\.env"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Installing & Building...
|
|
||||||
call npm install
|
|
||||||
call npm run build
|
|
||||||
cd server
|
|
||||||
call npm install
|
|
||||||
|
|
||||||
echo [Update] Done.
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user