3 Commits
36 changed files with 3392 additions and 1932 deletions
-6
View File
@@ -1,6 +0,0 @@
PORT=3000
PG_HOST=192.168.50.79
PG_PORT=5437
PG_USER=postgres
PG_PASSWORD=123
PG_DATABASE=an_omon_full_04052026
+6 -5
View File
@@ -1,5 +1,6 @@
node_modules/
*.log
.app.pid
uploads/issues/*
!uploads/issues/.gitkeep
web-giao-ban
web-giao-ban.exe
*.syso
*.rc
logs/
uploads/
+108 -31
View File
@@ -1,38 +1,115 @@
# Phần Mềm Quản Lý Giao Ban Hằng Ngày (DH-HIS Web)
# DH-HIS Quản Lý Giao Ban Hằng Ngày (Golang Standalone)
Ứng dụng Node.js + Express + PostgreSQL phục vụ công tác giao ban bệnh viện hằng ngày, hỗ trợ phân tích đối chiếu đa chu kỳ và tương tác nhập liệu ghi chú trực tiếp.
Ứng dụng độc lập (Single Binary Executable) viết bằng **Golang**, nhúng toàn bộ giao diện Web, cơ chế quản trị kết nối CSDL PostgreSQL bảo mật mã hóa AES-256, khay hệ thống (System Tray) cho Windows, và chức năng tự động kiểm tra/cập nhật phiên bản từ Gitea Package/Release.
## 1. Cấu trúc thư mục
```
dh-giaoban-web/
├── server.js # API Server Express (REST APIs cho Ngoại trú, Nội trú, Bảng 3 & 4)
├── db.js # Quản lý kết nối PostgreSQL Pool & chuẩn hóa văn bản RTF
├── .env # Cấu hình CSDL PostgreSQL DH37 (Host, Port, User, DB)
├── package.json # Dependencies: express, pg, cors, dotenv
└── public/
├── index.html # Giao diện Dashboard giao ban bệnh viện
├── app.js # Xử lý lọc kỳ, so sánh đa kỳ, vẽ biểu đồ Chart.js & auto-save ghi chú
└── styles.css # Định dạng giao diện & hỗ trợ in ấn (Print CSS)
```
---
## 2. Cách khởi động ứng dụng
## 🌟 1. Điểm nổi bật & Tính năng chính
1. **Chỉ 1 file duy nhất (Single Binary Executable)**:
- Toàn bộ giao diện HTML, CSS, JavaScript, icon được nhúng trực tiếp vào file nhị phân qua `//go:embed`.
- Không cần cài Node.js, không cần thư mục `node_modules` hay web server bên ngoài.
- Dung lượng nhẹ: ~7.3 MB trên Windows (`.exe`) và ~6.8 MB trên Linux.
2. **Khay hệ thống (System Tray)**:
- Chạy trên Windows có icon khay hệ thống, không hiện cửa sổ console đen (`-H windowsgui`).
- Menu gồm:
- 🌐 Mở Báo Cáo Giao Ban
- ⚙️ Quản Trị & Cấu Hình
- 📜 Xem Nhật Ký / Log App
- 🔄 Kiểm Tra Cập Nhật
- ❌ Thoát Ứng Dụng
- Chạy trên Linux Server tự động chuyển sang chế độ Daemon / Service nền ổn định.
3. **Trang Quản Trị & Cấu Hình Kết Nối (`/admin`)**:
- Tùy chỉnh Cổng Web (Port), đường dẫn quản trị.
- Cấu hình kết nối PostgreSQL: Host, Port, User, Password, CSDL mặc định.
- **Mã hóa AES-256-GCM**: Mật khẩu và Token được tự động mã hóa dạng `ENC(...)` khi lưu vào `config.json`, không lộ bản rõ.
- Nút **"Thử Kết Nối (Test Connection)"** kiểm tra thông suốt CSDL ngay trên web.
- Bảng quản lý danh sách CSDL các bệnh viện hiển thị trên dropdown.
4. **Nhật Ký Thời Gian Thực (Live Log Viewer)**:
- Ring buffer 500 dòng log gần nhất lưu trong bộ nhớ và ghi ra `logs/app.log`.
- Xem log trực tiếp trên trang quản trị có lọc theo cấp độ (INFO, WARN, ERROR, SQL) và tự động cuộn (Auto-scroll).
5. **Tự động kiểm tra & cập nhật phiên bản (Gitea Self-Update)**:
- Kết nối với Gitea Releases / Generic Package Registry qua Token cá nhân.
- Phát hiện phiên bản mới, xem ghi chú phát hành (*Release Notes*), tải và tự thay thế file binary đang chạy (*in-place binary replacement*).
---
## 🚀 2. Hướng dẫn biên dịch (Build)
Chạy script biên dịch có sẵn:
```bash
cd /home/othau/dh-coding/dh-giaoban-web
node server.js
./build.sh
```
Mở trình duyệt truy cập: **http://localhost:3000** (hoặc `http://<IP_MÁY>:3000`).
Kết quả sinh ra 2 file:
- `web-giao-ban`: File thực thi standalone cho Linux.
- `web-giao-ban.exe`: File thực thi standalone cho Windows (chỉ cần copy sang máy Windows là click đúp chạy).
## 3. Các tính năng chính
1. **Giao ban Ngoại trú**:
- Thống kê lượt khám BHYT, Thu phí, Chuyển viện theo các khoa (Khám bệnh, Ngoại, Nội, CSSKSS, Nhi, YDCT,...).
- Thống kê Bệnh án ngoại trú (Mới vs Tái khám) và chuyên khoa YDCT (Điện châm, VLTL, Sắc thuốc, Cấy chỉ).
- Tổng hợp số liệu Cận lâm sàng: Xét nghiệm (Huyết học, Sinh hóa, Nước tiểu, Miễn dịch, Đông máu) & CĐHA (Nội soi, Xquang, Siêu âm, CT, ECG).
- Top 10 chẩn đoán bệnh ngoại trú theo mã ICD-10.
- So sánh số liệu giữa 2 khoảng thời gian không liên tục (Ví dụ: Tháng 1 vs Tháng 3/10) và biểu diễn bằng biểu đồ cột.
---
2. **Giao ban Nội trú**:
- Tổng hợp lưu lượng bệnh nhân theo các khoa: Bệnh cũ (tồn đầu), Bệnh mới (nhập viện), Xuất viện (Ổn xin về, Nặng xin về, Trốn, Chuyển viện, Tử vong), Bệnh hiện còn, Bệnh theo dõi, Chăm sóc Cấp I.
- Biểu đồ cột phân tích lưu lượng và so sánh đa chu kỳ.
- **Bảng 3 (Danh sách bệnh nhân Chuyển viện)** & **Bảng 4 (Danh sách bệnh nhân Theo dõi & CS Cấp I)**:
- Hiển thị đầy đủ: Họ tên, Địa chỉ, Ngày VV/RV, Khoa, Chẩn đoán chính/phụ, Tóm tắt kết quả CLS.
- **Cột Ghi chú thông minh**: Các khoa tự nhập liệu trực tiếp trên lưới; hệ thống tự động lưu vào CSDL ngay khi chuyển ô.
## 💻 3. Hướng dẫn sử dụng trên Windows
1. Copy file `web-giao-ban.exe` vào thư mục bất kỳ trên máy Windows (Ví dụ: `D:\DH-HIS\GiaoBan\`).
2. Nhấp đúp vào `web-giao-ban.exe` để chạy.
3. Icon chữ thập y tế màu xanh sẽ xuất hiện ở **Khay hệ thống (System Tray)** góc dưới bên phải màn hình.
4. Nhấp chuột phải vào icon khay hệ thống:
- Bấm **"Mở Báo Cáo Giao Ban"** để mở trình duyệt xem số liệu.
- Bấm **"Quản Trị & Cấu Hình"** để đổi thông số IP máy chủ hoặc CSDL.
- Bấm **"Xem Nhật Ký / Log App"** để kiểm tra log hoạt động.
- Bấm **"Kiểm Tra Cập Nhật"** để cập nhật phần mềm mới từ Gitea.
- Bấm **"Thoát"** để dừng ứng dụng.
---
## 🐧 4. Quản lý dịch vụ trên Linux (Systemd Service)
- **Xem trạng thái**: `./status.sh` hoặc `sudo systemctl status web-giao-ban`
- **Khởi động**: `./start.sh` hoặc `sudo systemctl start web-giao-ban`
- **Dừng dịch vụ**: `./stop.sh` hoặc `sudo systemctl stop web-giao-ban`
- **Khởi động lại**: `./restart.sh` hoặc `sudo systemctl restart web-giao-ban`
---
## 🔒 5. Cấu trúc file cấu hình `config.json`
File `config.json` được tự động sinh lần đầu tiên và mã hóa bảo mật:
```json
{
"server": {
"port": 3000,
"admin_path": "/admin",
"admin_password": "ENC(...)"
},
"database": {
"host": "192.168.50.79",
"port": 5437,
"user": "postgres",
"password": "ENC(...)",
"default_db": "an_omon_full_04052026",
"databases": [
{ "id": "an_omon_full_04052026", "name": "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)" },
{ "id": "tuan_dbgovap_full", "name": "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)" },
{ "id": "binh_bvq12_062026052026042026032026022026", "name": "BV Quận 12 - TP.HCM (21 GB)" },
{ "id": "bvq12_697", "name": "BV Quận 12 - Bản 697 (17 GB)" },
{ "id": "an_dalieu_full_05082026", "name": "BV Da Liễu (31 GB)" }
]
},
"updater": {
"enabled": true,
"gitea_url": "https://i.dh-his.com",
"owner": "ongtrieuhau",
"repo": "web-giao-ban",
"token": "ENC(...)",
"current_version": "v1.0.0"
}
}
```
---
## 📦 6. Package & Release trên Gitea
- **Repository**: [https://i.dh-his.com/ongtrieuhau/web-giao-ban](https://i.dh-his.com/ongtrieuhau/web-giao-ban)
- **Generic Package**: [https://i.dh-his.com/ongtrieuhau/-/packages/generic/web-giao-ban/1.0.1](https://i.dh-his.com/ongtrieuhau/-/packages/generic/web-giao-ban/1.0.1)
- **Release Assets**: [https://i.dh-his.com/ongtrieuhau/web-giao-ban/releases/tag/v1.0.1](https://i.dh-his.com/ongtrieuhau/web-giao-ban/releases/tag/v1.0.1)
- `web-giao-ban.exe` (Windows x64)
- `web-giao-ban-linux-amd64` (Linux x64)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
echo "======================================================="
echo " Building DH-HIS Web Giao Ban (Go Standalone)"
echo "======================================================="
# 1. Build Linux binary
echo "[1/2] Building Linux standalone binary: web-giao-ban..."
go build -ldflags="-s -w" -o web-giao-ban .
chmod +x web-giao-ban
echo " -> OK: $(ls -lh web-giao-ban | awk '{print $9, $5}')"
# 2. Build Windows .exe binary
echo "[2/2] Building Windows standalone executable: web-giao-ban.exe..."
if [ -f "app.ico" ]; then
echo " * Embedding application icon into Windows PE resource..."
echo '1 ICON "app.ico"' > app.rc
x86_64-w64-mingw32-windres -i app.rc -O coff -o rsrc_windows_amd64.syso
fi
CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc GOOS=windows GOARCH=amd64 \
go build -ldflags="-s -w -H windowsgui" -o web-giao-ban.exe .
echo " -> OK: $(ls -lh web-giao-ban.exe | awk '{print $9, $5}')"
echo "======================================================="
echo " Build hoàn tất! Có thể copy web-giao-ban.exe sang Windows là chạy được ngay."
echo "======================================================="
+44
View File
@@ -0,0 +1,44 @@
{
"server": {
"port": 3001,
"admin_path": "/admin",
"admin_password": "ENC(myrgA7xnANjqBI6e9sR7KgSlRNknizKEfXQ/Ejbzzk1s)"
},
"database": {
"host": "192.168.50.79",
"port": 5437,
"user": "postgres",
"password": "ENC(ECwpzj4lHt7WdtbuQXPTveO/rhbvZnnolQh74DxlIw==)",
"default_db": "an_omon_full_04052026",
"databases": [
{
"id": "an_omon_full_04052026",
"name": "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)"
},
{
"id": "tuan_dbgovap_full",
"name": "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)"
},
{
"id": "binh_bvq12_062026052026042026032026022026",
"name": "BV Quận 12 - TP.HCM (21 GB)"
},
{
"id": "bvq12_697",
"name": "BV Quận 12 - Bản 697 (17 GB)"
},
{
"id": "an_dalieu_full_05082026",
"name": "BV Da Liễu (31 GB)"
}
]
},
"updater": {
"enabled": true,
"gitea_url": "https://i.dh-his.com",
"owner": "ongtrieuhau",
"repo": "web-giao-ban",
"token": "ENC(abS+Aa/rBa8ydyLgFvGmDrJ5Vc9KOVTFu52PX4rCcA+88edzq5J6yWu05DPTTHWZEw2wKz+8RmXbt+jTTexJi+zATOg=)",
"current_version": "v1.0.1"
}
}
+272
View File
@@ -0,0 +1,272 @@
package config
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"os"
"path/filepath"
"strings"
"sync"
)
var secretKey = sha256.Sum256([]byte("dh-his-giaoban-secret-key-salt-2026-v1"))
type DatabaseInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ServerConfig struct {
Port int `json:"port"`
AdminPath string `json:"admin_path"`
AdminPassword string `json:"admin_password"` // stored as ENC(...)
}
type DBConfig struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"` // stored as ENC(...)
DefaultDB string `json:"default_db"`
Databases []DatabaseInfo `json:"databases"`
}
type UpdaterConfig struct {
Enabled bool `json:"enabled"`
GiteaURL string `json:"gitea_url"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Token string `json:"token"` // stored as ENC(...)
CurrentVersion string `json:"current_version"`
}
type AppConfig struct {
Server ServerConfig `json:"server"`
Database DBConfig `json:"database"`
Updater UpdaterConfig `json:"updater"`
// runtime plaintext fields (not exported to JSON if needed)
DecryptedDBPassword string `json:"-"`
DecryptedUpdaterToken string `json:"-"`
DecryptedAdminPassword string `json:"-"`
}
var (
currentConfig *AppConfig
configMutex sync.RWMutex
configPath = "config.json"
)
func Encrypt(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(secretKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return "ENC(" + base64.StdEncoding.EncodeToString(ciphertext) + ")", nil
}
func Decrypt(encText string) (string, error) {
if encText == "" {
return "", nil
}
if !strings.HasPrefix(encText, "ENC(") || !strings.HasSuffix(encText, ")") {
// Already plaintext
return encText, nil
}
rawB64 := encText[4 : len(encText)-1]
data, err := base64.StdEncoding.DecodeString(rawB64)
if err != nil {
return "", err
}
block, err := aes.NewCipher(secretKey[:])
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext quá ngắn")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
func DefaultConfig() *AppConfig {
cfg := &AppConfig{
Server: ServerConfig{
Port: 3000,
AdminPath: "/admin",
AdminPassword: "admin",
},
Database: DBConfig{
Host: "192.168.50.79",
Port: 5437,
User: "postgres",
Password: "123",
DefaultDB: "an_omon_full_04052026",
Databases: []DatabaseInfo{
{ID: "an_omon_full_04052026", Name: "BVĐK Ô Môn - Cần Thơ (33 GB - Chuẩn khoa phòng)"},
{ID: "tuan_dbgovap_full", Name: "BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)"},
{ID: "binh_bvq12_062026052026042026032026022026", Name: "BV Quận 12 - TP.HCM (21 GB)"},
{ID: "bvq12_697", Name: "BV Quận 12 - Bản 697 (17 GB)"},
{ID: "an_dalieu_full_05082026", Name: "BV Da Liễu (31 GB)"},
},
},
Updater: UpdaterConfig{
Enabled: true,
GiteaURL: "https://i.dh-his.com",
Owner: "ongtrieuhau",
Repo: "web-giao-ban",
Token: "",
CurrentVersion: "v1.0.2",
},
}
return cfg
}
func LoadConfig(path string) (*AppConfig, error) {
configMutex.Lock()
defer configMutex.Unlock()
if path != "" {
configPath = path
}
if _, err := os.Stat(configPath); os.IsNotExist(err) {
cfg := DefaultConfig()
cfg.DecryptedAdminPassword = cfg.Server.AdminPassword
cfg.DecryptedDBPassword = cfg.Database.Password
cfg.DecryptedUpdaterToken = cfg.Updater.Token
// Encrypt for disk save
encAdmin, _ := Encrypt(cfg.Server.AdminPassword)
encDB, _ := Encrypt(cfg.Database.Password)
encTok, _ := Encrypt(cfg.Updater.Token)
cfg.Server.AdminPassword = encAdmin
cfg.Database.Password = encDB
cfg.Updater.Token = encTok
data, _ := json.MarshalIndent(cfg, "", " ")
_ = os.MkdirAll(filepath.Dir(configPath), 0755)
_ = os.WriteFile(configPath, data, 0644)
currentConfig = cfg
return cfg, nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
cfg := &AppConfig{}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
// Decrypt sensitive fields for runtime use
decAdmin, _ := Decrypt(cfg.Server.AdminPassword)
decDB, _ := Decrypt(cfg.Database.Password)
decTok, _ := Decrypt(cfg.Updater.Token)
cfg.DecryptedAdminPassword = decAdmin
cfg.DecryptedDBPassword = decDB
cfg.DecryptedUpdaterToken = decTok
// Auto-migrate legacy owner & repo
if cfg.Updater.Owner == "" || cfg.Updater.Owner == "hdhiswork" {
cfg.Updater.Owner = "ongtrieuhau"
}
if cfg.Updater.Repo == "" || cfg.Updater.Repo == "DUAN" {
cfg.Updater.Repo = "web-giao-ban"
}
currentConfig = cfg
return cfg, nil
}
func SaveConfig(cfg *AppConfig) error {
configMutex.Lock()
defer configMutex.Unlock()
clone := *cfg
// Ensure passwords/tokens are stored encrypted
if !strings.HasPrefix(clone.Server.AdminPassword, "ENC(") {
clone.DecryptedAdminPassword = clone.Server.AdminPassword
enc, _ := Encrypt(clone.Server.AdminPassword)
clone.Server.AdminPassword = enc
}
if !strings.HasPrefix(clone.Database.Password, "ENC(") {
clone.DecryptedDBPassword = clone.Database.Password
enc, _ := Encrypt(clone.Database.Password)
clone.Database.Password = enc
}
if !strings.HasPrefix(clone.Updater.Token, "ENC(") {
clone.DecryptedUpdaterToken = clone.Updater.Token
enc, _ := Encrypt(clone.Updater.Token)
clone.Updater.Token = enc
}
data, err := json.MarshalIndent(&clone, "", " ")
if err != nil {
return err
}
tmpPath := configPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return err
}
if err := os.Rename(tmpPath, configPath); err != nil {
return err
}
currentConfig = &clone
return nil
}
func Get() *AppConfig {
configMutex.RLock()
defer configMutex.RUnlock()
if currentConfig == nil {
configMutex.RUnlock()
cfg, _ := LoadConfig("config.json")
return cfg
}
return currentConfig
}
-57
View File
@@ -1,57 +0,0 @@
const { Pool } = require('pg');
require('dotenv').config();
const pools = {};
function getPool(dbName) {
const database = dbName || process.env.PG_DATABASE || 'an_omon_full_04052026';
if (!pools[database]) {
pools[database] = new Pool({
host: process.env.PG_HOST || '192.168.50.79',
port: parseInt(process.env.PG_PORT || '5437', 10),
user: process.env.PG_USER || 'postgres',
password: process.env.PG_PASSWORD || '123',
database: database,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
statement_timeout: 15000, // 15s timeout to prevent hanging queries
});
pools[database].on('error', (err) => {
console.error(`Unexpected error on idle client for db ${database}`, err);
});
}
return pools[database];
}
async function query(sql, params = [], dbName = null) {
const pool = getPool(dbName);
const start = Date.now();
const res = await pool.query(sql, params);
const duration = Date.now() - start;
// console.log(`[SQL ${duration}ms] executed on ${dbName || 'default'}`);
return res;
}
// Clean RTF string helper
function cleanRtf(text) {
if (!text) return '';
if (!text.includes('{\\rtf')) return text.trim();
// Strip RTF control codes
let clean = text
.replace(/\\par[d]?/g, '\n')
.replace(/\\u([0-9]{3,5})\?/g, (match, dec) => String.fromCharCode(dec))
.replace(/\\'([0-9a-fA-F]{2})/g, (match, hex) => String.fromCharCode(parseInt(hex, 16)))
.replace(/\{[^{}]*\}/g, '')
.replace(/\\[a-zA-Z0-9\-]+/g, '')
.replace(/[\{\}]/g, '')
.trim();
return clean;
}
module.exports = {
getPool,
query,
cleanRtf,
};
+156
View File
@@ -0,0 +1,156 @@
package db
import (
"database/sql"
"fmt"
"regexp"
"strings"
"sync"
"time"
_ "github.com/lib/pq"
"web-giao-ban-go/config"
"web-giao-ban-go/logger"
)
var (
poolMu sync.RWMutex
pools = make(map[string]*sql.DB)
)
func GetConnectionString(cfg *config.AppConfig, dbName string) string {
if dbName == "" {
dbName = cfg.Database.DefaultDB
}
pass := cfg.DecryptedDBPassword
if pass == "" {
pass, _ = config.Decrypt(cfg.Database.Password)
}
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=10 statement_timeout=15000",
cfg.Database.Host,
cfg.Database.Port,
cfg.Database.User,
pass,
dbName,
)
}
func GetDB(dbName string) (*sql.DB, error) {
cfg := config.Get()
if dbName == "" {
dbName = cfg.Database.DefaultDB
}
poolMu.RLock()
if conn, ok := pools[dbName]; ok {
poolMu.RUnlock()
return conn, nil
}
poolMu.RUnlock()
poolMu.Lock()
defer poolMu.Unlock()
// Double check
if conn, ok := pools[dbName]; ok {
return conn, nil
}
connStr := GetConnectionString(cfg, dbName)
conn, err := sql.Open("postgres", connStr)
if err != nil {
logger.Error("Lỗi kết nối PostgreSQL [%s]: %v", dbName, err)
return nil, err
}
conn.SetMaxOpenConns(15)
conn.SetMaxIdleConns(5)
conn.SetConnMaxLifetime(15 * time.Minute)
if err := conn.Ping(); err != nil {
logger.Error("Ping thất bại tới CSDL [%s]: %v", dbName, err)
return nil, err
}
pools[dbName] = conn
logger.Info("Khởi tạo connection pool tới CSDL: %s", dbName)
return conn, nil
}
func CloseAll() {
poolMu.Lock()
defer poolMu.Unlock()
for name, conn := range pools {
_ = conn.Close()
delete(pools, name)
}
}
func TestConnection(host string, port int, user, pass, dbName string) error {
connStr := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable connect_timeout=5",
host, port, user, pass, dbName)
testConn, err := sql.Open("postgres", connStr)
if err != nil {
return err
}
defer testConn.Close()
return testConn.Ping()
}
func EnsureTable(dbName string) {
conn, err := GetDB(dbName)
if err != nil {
return
}
safeName := regexp.MustCompile(`[^a-zA-Z0-9]`).ReplaceAllString(dbName, "_")
sqlCreate := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS current.giaoban_ghichu (
id SERIAL PRIMARY KEY,
maba VARCHAR(50) NOT NULL,
mabn VARCHAR(20) NOT NULL,
loai_danhsach VARCHAR(20) NOT NULL,
ghichu TEXT,
ngay_capnhat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unq_giaoban_item_%s UNIQUE(maba, loai_danhsach)
);
`, safeName)
_, _ = conn.Exec(sqlCreate)
}
func CleanRTF(s string) string {
if s == "" {
return ""
}
if !strings.HasPrefix(s, "{\\rtf") && !strings.Contains(s, "\\rtf") {
return s
}
cleaned := s
// Remove {\*...} blocks
re1 := regexp.MustCompile(`\{\\\*[^}]*\}`)
cleaned = re1.ReplaceAllString(cleaned, "")
// Remove general {...} groups with tags
re2 := regexp.MustCompile(`\{[^{}]*\}`)
cleaned = re2.ReplaceAllString(cleaned, "")
// Remove RTF control words (\b, \par, \fs20...)
re3 := regexp.MustCompile(`\\[a-zA-Z]+(-?\d+)? ?`)
cleaned = re3.ReplaceAllString(cleaned, " ")
// Remove stray brackets and backslashes
re4 := regexp.MustCompile(`[\\{}]`)
cleaned = re4.ReplaceAllString(cleaned, "")
// Collapse multiple spaces
re5 := regexp.MustCompile(`\s+`)
cleaned = strings.TrimSpace(re5.ReplaceAllString(cleaned, " "))
if cleaned == "" {
return "Có kết quả (Định dạng RTF)"
}
return cleaned
}
+23
View File
@@ -0,0 +1,23 @@
module web-giao-ban-go
go 1.22.2
require (
github.com/getlantern/systray v1.2.2
github.com/lib/pq v1.12.3
github.com/minio/selfupdate v0.6.0
)
require (
aead.dev/minisign v0.2.0 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
golang.org/x/sys v0.1.0 // indirect
)
+55
View File
@@ -0,0 +1,55 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
+218
View File
@@ -0,0 +1,218 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"web-giao-ban-go/config"
"web-giao-ban-go/db"
"web-giao-ban-go/logger"
"web-giao-ban-go/updater"
)
// GET /api/admin/config
func HandleGetAdminConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
cfg := config.Get()
// Mask sensitive fields
respCfg := *cfg
if respCfg.Server.AdminPassword != "" {
respCfg.Server.AdminPassword = "••••••••"
}
if respCfg.Database.Password != "" {
respCfg.Database.Password = "••••••••"
}
if respCfg.Updater.Token != "" {
respCfg.Updater.Token = "••••••••"
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": respCfg,
})
}
// POST /api/admin/config
func HandleSaveAdminConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var input config.AppConfig
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "JSON không hợp lệ: " + err.Error()})
return
}
current := config.Get()
// Handle Admin Password
if input.Server.AdminPassword == "" || input.Server.AdminPassword == "••••••••" {
input.Server.AdminPassword = current.Server.AdminPassword
input.DecryptedAdminPassword = current.DecryptedAdminPassword
} else {
input.DecryptedAdminPassword = input.Server.AdminPassword
enc, _ := config.Encrypt(input.Server.AdminPassword)
input.Server.AdminPassword = enc
}
// Handle DB Password
if input.Database.Password == "" || input.Database.Password == "••••••••" {
input.Database.Password = current.Database.Password
input.DecryptedDBPassword = current.DecryptedDBPassword
} else {
input.DecryptedDBPassword = input.Database.Password
enc, _ := config.Encrypt(input.Database.Password)
input.Database.Password = enc
}
// Handle Updater Token
if input.Updater.Token == "" || input.Updater.Token == "••••••••" {
input.Updater.Token = current.Updater.Token
input.DecryptedUpdaterToken = current.DecryptedUpdaterToken
} else {
input.DecryptedUpdaterToken = input.Updater.Token
enc, _ := config.Encrypt(input.Updater.Token)
input.Updater.Token = enc
}
if err := config.SaveConfig(&input); err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Lưu file thất bại: " + err.Error()})
return
}
// Reset DB pool to apply new connection parameters
db.CloseAll()
logger.Info("Đã lưu cấu hình mới và làm mới kết nối CSDL")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã lưu cấu hình và mã hóa mật khẩu thành công!",
})
}
// POST /api/admin/test-db
func HandleTestDBConnection(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
Host string `json:"host"`
Port int `json:"port"`
User string `json:"user"`
Password string `json:"password"`
Database string `json:"database"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"})
return
}
cfg := config.Get()
pass := body.Password
if pass == "" || pass == "••••••••" {
pass = cfg.DecryptedDBPassword
}
err := db.TestConnection(body.Host, body.Port, body.User, pass, body.Database)
if err != nil {
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Kết nối thất bại: " + err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Kết nối PostgreSQL thành công!",
})
}
// GET /api/admin/logs
func HandleGetLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
limitStr := r.URL.Query().Get("limit")
level := r.URL.Query().Get("level")
limit := 100
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 {
limit = l
}
entries := logger.Get().GetEntries(limit, strings.ToUpper(level))
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": entries,
})
}
// POST /api/admin/logs/clear
func HandleClearLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
logger.Get().Clear()
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã làm trống bộ nhớ log!",
})
}
// GET /api/update/check
func HandleCheckUpdate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
res, err := updater.CheckUpdate()
if err != nil {
logger.Error("Kiểm tra cập nhật lỗi: %v", err)
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": err.Error(),
})
return
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": res,
})
}
// POST /api/update/apply
func HandleApplyUpdate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
AssetURL string `json:"asset_url"`
Version string `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.AssetURL == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin URL bản cập nhật"})
return
}
err := updater.ApplyUpdate(body.AssetURL)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "Cập nhật thất bại: " + err.Error(),
})
return
}
// Update current version in config if provided
if body.Version != "" {
cfg := config.Get()
cfg.Updater.CurrentVersion = body.Version
_ = config.SaveConfig(cfg)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã cập nhật phiên bản mới thành công! Vui lòng khởi động lại phần mềm để áp dụng.",
})
}
+681
View File
@@ -0,0 +1,681 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
"web-giao-ban-go/config"
"web-giao-ban-go/db"
)
// Helper to generate partition filters: namkt IN (...) AND thangkt IN (...)
func GetThangNamFilter(fromStr, toStr, prefix string) string {
f, err1 := time.Parse("2006-01-02 15:04:05", fromStr)
t, err2 := time.Parse("2006-01-02 15:04:05", toStr)
if err1 != nil || err2 != nil {
// Try date only
f, err1 = time.Parse("2006-01-02", fromStr[:10])
t, err2 = time.Parse("2006-01-02", toStr[:10])
if err1 != nil || err2 != nil {
return ""
}
}
thangsMap := make(map[string]bool)
namsMap := make(map[string]bool)
curr := time.Date(f.Year(), f.Month(), 1, 0, 0, 0, 0, time.UTC)
end := time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, time.UTC)
count := 0
for !curr.After(end) && count < 24 {
thangsMap[fmt.Sprintf("'%02d'", curr.Month())] = true
namsMap[fmt.Sprintf("'%d'", curr.Year())] = true
curr = curr.AddDate(0, 1, 0)
count++
}
var thangsArr, namsArr []string
for k := range thangsMap {
thangsArr = append(thangsArr, k)
}
for k := range namsMap {
namsArr = append(namsArr, k)
}
if len(thangsArr) == 0 || len(namsArr) == 0 {
return ""
}
p := ""
if prefix != "" {
p = prefix + "."
}
return fmt.Sprintf(" AND %snamkt IN (%s) AND %sthangkt IN (%s) ",
p, strings.Join(namsArr, ","),
p, strings.Join(thangsArr, ","),
)
}
// GET /api/databases
func HandleGetDatabases(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": cfg.Database.Databases,
})
}
type OutpatientData struct {
Khoa []map[string]interface{} `json:"khoa"`
BANT map[string]interface{} `json:"bant"`
YDCT map[string]interface{} `json:"ydct"`
CLS []map[string]interface{} `json:"cls"`
Top10 []map[string]interface{} `json:"top10"`
TiemNgua int `json:"tiemngua"`
}
func queryOutpatient(database, fromDate, toDate string) (*OutpatientData, error) {
conn, err := db.GetDB(database)
if err != nil {
return nil, err
}
fnKb := GetThangNamFilter(fromDate, toDate, "kb")
fnLs := GetThangNamFilter(fromDate, toDate, "ls")
fnNone := GetThangNamFilter(fromDate, toDate, "")
out := &OutpatientData{
Khoa: make([]map[string]interface{}, 0),
BANT: map[string]interface{}{"moi": 0, "taikham": 0, "tong": 0},
YDCT: map[string]interface{}{"dien_cham": 0, "vltl": 0, "sac_thuoc": 0, "cay_chi": 0},
CLS: make([]map[string]interface{}, 0),
Top10: make([]map[string]interface{}, 0),
}
var wg sync.WaitGroup
var mu sync.Mutex
// 1. Khoa KCB
wg.Add(1)
go func() {
defer wg.Done()
sqlKhoa := fmt.Sprintf(`
SELECT
COALESCE(dv.madv, 'KHAC') AS madv,
COALESCE(dv.tendv, 'Khoa khác') AS tendv,
COUNT(kb.makb) AS tong_kham,
SUM(CASE WHEN dt.bhyt IN (1, 2) THEN 1 ELSE 0 END) AS bhyt,
SUM(CASE WHEN dt.bhyt IN (0, 4) THEN 1 ELSE 0 END) AS thuphi,
SUM(CASE WHEN kb.dakham = 6 THEN 1 ELSE 0 END) AS chuyenvien
FROM current.khambenh kb
LEFT JOIN current.psdangky dk ON kb.mabn = dk.mabn AND kb.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0
LEFT JOIN current.dmdoituong dt ON dk.madt = dt.madt
LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv
WHERE kb.ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(kb.xoa, 0) = 0
AND COALESCE(kb.dakham, 0) != 0
GROUP BY dv.madv, dv.tendv
ORDER BY tong_kham DESC;
`, fnKb)
rows, err := conn.Query(sqlKhoa, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var madv, tendv string
var tong, bhyt, thuphi, cv int
_ = rows.Scan(&madv, &tendv, &tong, &bhyt, &thuphi, &cv)
list = append(list, map[string]interface{}{
"madv": madv, "tendv": tendv, "tong_kham": tong,
"bhyt": bhyt, "thuphi": thuphi, "chuyenvien": cv,
})
}
mu.Lock()
out.Khoa = list
mu.Unlock()
}
}()
// 2. BANT
wg.Add(1)
go func() {
defer wg.Done()
sqlBant := `
SELECT
COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END), 0) AS moi,
COALESCE(SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END), 0) AS taikham,
COUNT(*) AS tong
FROM current.bnnoitru nt
JOIN current.psdangky dk ON nt.mabn = dk.mabn AND nt.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0
WHERE nt.namvien = 0 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayvv BETWEEN $1 AND $2;
`
var moi, taikham, tong int
err := conn.QueryRow(sqlBant, fromDate, toDate).Scan(&moi, &taikham, &tong)
if err == nil {
mu.Lock()
out.BANT = map[string]interface{}{"moi": moi, "taikham": taikham, "tong": tong}
mu.Unlock()
}
}()
// 3. YDCT
wg.Add(1)
go func() {
defer wg.Done()
sqlYdct := fmt.Sprintf(`
SELECT
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Điện châm%%' THEN ls.soluong ELSE 0 END), 0) AS dien_cham,
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Vật lý trị liệu%%' OR cls.tencls ILIKE '%%VLTL%%' OR cls.macls IN ('DI131','DI135') THEN ls.soluong ELSE 0 END), 0) AS vltl,
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Sắc thuốc%%' THEN ls.soluong ELSE 0 END), 0) AS sac_thuoc,
COALESCE(SUM(CASE WHEN cls.tencls ILIKE '%%Cấy chỉ%%' OR cls.tencls ILIKE '%%Chôn chỉ%%' THEN ls.soluong ELSE 0 END), 0) AS cay_chi
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
WHERE ls.ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(ls.xoa, 0) = 0;
`, fnLs)
var dc, vltl, st, cc float64
err := conn.QueryRow(sqlYdct, fromDate, toDate).Scan(&dc, &vltl, &st, &cc)
if err == nil {
mu.Lock()
out.YDCT = map[string]interface{}{
"dien_cham": int(dc), "vltl": int(vltl), "sac_thuoc": int(st), "cay_chi": int(cc),
}
mu.Unlock()
}
}()
// 4. CLS
wg.Add(1)
go func() {
defer wg.Done()
sqlCls := fmt.Sprintf(`
SELECT
cls.kho,
cls.maloai,
COALESCE(loaicls.tenloai, cls.maloai) AS tenloai,
COUNT(DISTINCT ls.makb) AS so_bn,
COUNT(*) AS so_luot
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.dmloaicls loaicls ON cls.maloai = loaicls.maloai
WHERE ls.ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(ls.xoa, 0) = 0
AND cls.kho IN ('XN', 'HA', 'CN')
GROUP BY cls.kho, cls.maloai, loaicls.tenloai
ORDER BY cls.kho, count(*) DESC;
`, fnLs)
rows, err := conn.Query(sqlCls, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var kho, maloai, tenloai string
var soBn, soLuot int
_ = rows.Scan(&kho, &maloai, &tenloai, &soBn, &soLuot)
list = append(list, map[string]interface{}{
"kho": kho, "maloai": maloai, "tenloai": tenloai, "so_bn": soBn, "so_luot": soLuot,
})
}
mu.Lock()
out.CLS = list
mu.Unlock()
}
}()
// 5. Top 10
wg.Add(1)
go func() {
defer wg.Done()
sqlTop10 := fmt.Sprintf(`
SELECT maicd, kqcdoan, COUNT(*) AS sl
FROM current.khambenh
WHERE ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(xoa, 0) = 0
AND maicd IS NOT NULL AND maicd != ''
GROUP BY maicd, kqcdoan
ORDER BY sl DESC
LIMIT 10;
`, fnNone)
rows, err := conn.Query(sqlTop10, fromDate, toDate)
if err == nil {
defer rows.Close()
var list []map[string]interface{}
for rows.Next() {
var maicd, kqcdoan string
var sl int
_ = rows.Scan(&maicd, &kqcdoan, &sl)
list = append(list, map[string]interface{}{
"maicd": maicd, "kqcdoan": kqcdoan, "sl": sl,
})
}
mu.Lock()
out.Top10 = list
mu.Unlock()
}
}()
// 6. Tiêm ngừa
wg.Add(1)
go func() {
defer wg.Done()
sqlTn := fmt.Sprintf(`
SELECT COUNT(kb.makb) AS sl
FROM current.khambenh kb
LEFT JOIN current.dmphong ph ON kb.maphong = ph.maphong
LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv
WHERE kb.ngaykcb BETWEEN $1 AND $2 %s
AND COALESCE(kb.xoa, 0) = 0
AND COALESCE(kb.dakham, 0) != 0
AND (
COALESCE(ph.loai_bc_giaoban, 0) = 2 OR
ph.maphong = 'TN' OR
dv.madv = 'PTN' OR
ph.tenphong ILIKE '%%Tiêm ngừa%%' OR
ph.tenphong ILIKE '%%Tiêm chủng%%'
);
`, fnKb)
var sl int
err := conn.QueryRow(sqlTn, fromDate, toDate).Scan(&sl)
if err == nil {
mu.Lock()
out.TiemNgua = sl
mu.Unlock()
}
}()
wg.Wait()
return out, nil
}
// GET /api/giao-ban-ngoai-tru
func HandleGiaoBanNgoaiTru(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
compareFrom := q.Get("compareFrom")
compareTo := q.Get("compareTo")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
curData, err := queryOutpatient(database, from, to)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
var compData *OutpatientData
if compareFrom != "" && compareTo != "" {
compData, _ = queryOutpatient(database, compareFrom, compareTo)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"current": curData,
"compare": compData,
"periodCurrent": map[string]string{
"from": from, "to": to,
},
"periodCompare": func() interface{} {
if compareFrom != "" && compareTo != "" {
return map[string]string{"from": compareFrom, "to": compareTo}
}
return nil
}(),
},
})
}
type InpatientData struct {
Khoa []map[string]interface{} `json:"khoa"`
}
func queryInpatient(database, fromDate, toDate string) (*InpatientData, error) {
conn, err := db.GetDB(database)
if err != nil {
return nil, err
}
sqlKhoa := `
SELECT
dv.madv,
dv.tendv,
SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu,
SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND nt.ravien = 1 AND COALESCE(nt.makq, '01') NOT IN ('04','05','06') THEN 1 ELSE 0 END) AS on_xin_ve,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.makq IN ('04','06') OR (nt.maxt = '09' AND nt.makq = '04')) THEN 1 ELSE 0 END) AS nang_xin_ve,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 2 OR nt.maxt = '08') THEN 1 ELSE 0 END) AS tron_vien,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') THEN 1 ELSE 0 END) AS chuyen_vien,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 3 OR nt.maxt = '06') THEN 1 ELSE 0 END) AS tu_vong,
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con,
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR dv.madv IN ('17','29')) THEN 1 ELSE 0 END) AS benh_theo_doi,
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI')) THEN 1 ELSE 0 END) AS chamsoc_cap1
FROM current.bnnoitru nt
JOIN current.dmdonvi dv ON nt.madv = dv.madv
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayvv >= ($1::date - INTERVAL '180 days')
GROUP BY dv.madv, dv.tendv
HAVING SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) > 0
ORDER BY benh_moi DESC;
`
rows, err := conn.Query(sqlKhoa, fromDate, toDate)
if err != nil {
return nil, err
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var madv, tendv string
var cu, moi, on, nang, tron, cv, tv, hiencon, td, cs1 int
_ = rows.Scan(&madv, &tendv, &cu, &moi, &on, &nang, &tron, &cv, &tv, &hiencon, &td, &cs1)
list = append(list, map[string]interface{}{
"madv": madv, "tendv": tendv,
"benh_cu": cu, "benh_moi": moi, "on_xin_ve": on, "nang_xin_ve": nang,
"tron_vien": tron, "chuyen_vien": cv, "tu_vong": tv, "benh_hien_con": hiencon,
"benh_theo_doi": td, "chamsoc_cap1": cs1,
})
}
return &InpatientData{Khoa: list}, nil
}
// GET /api/giao-ban-noi-tru
func HandleGiaoBanNoiTru(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
compareFrom := q.Get("compareFrom")
compareTo := q.Get("compareTo")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
db.EnsureTable(database)
curData, err := queryInpatient(database, from, to)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
var compData *InpatientData
if compareFrom != "" && compareTo != "" {
compData, _ = queryInpatient(database, compareFrom, compareTo)
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": map[string]interface{}{
"current": curData,
"compare": compData,
"periodCurrent": map[string]string{
"from": from, "to": to,
},
"periodCompare": func() interface{} {
if compareFrom != "" && compareTo != "" {
return map[string]string{"from": compareFrom, "to": compareTo}
}
return nil
}(),
},
})
}
// GET /api/danh-sach-chuyen-vien
func HandleDanhSachChuyenVien(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
madv := q.Get("madv")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
conn, err := db.GetDB(database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(database)
sqlWhere := `
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayrv BETWEEN $1 AND $2
AND (nt.ravien = 4 OR nt.maxt = '03')
`
args := []interface{}{from, to}
if madv != "" && madv != "ALL" {
args = append(args, madv)
sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args))
}
sqlStr := fmt.Sprintf(`
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
COALESCE(bn.diachi, '') AS diachi,
to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv,
to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv,
COALESCE(dv.tendv, nt.madv) AS tendv,
COALESCE(nt.kqcdoan, '') AS chandoan_chinh,
COALESCE(nt.kqcdoanp, '') AS chandoan_phu,
COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls,
COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu
FROM current.bnnoitru nt
JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn
LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv
LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'CHUYEN_VIEN'
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb
WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0
) cls_sub ON true
%s
ORDER BY nt.ngayrv DESC
LIMIT 100;
`, sqlWhere)
rows, err := conn.Query(sqlStr, args...)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, cls, ghichu string
_ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &cls, &ghichu)
list = append(list, map[string]interface{}{
"mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi,
"ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv,
"chandoan_chinh": cdChinh, "chandoan_phu": cdPhu,
"ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu,
})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// GET /api/danh-sach-theo-doi
func HandleDanhSachTheoDoi(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
q := r.URL.Query()
database := q.Get("database")
from := q.Get("from")
to := q.Get("to")
madv := q.Get("madv")
if from == "" {
from = "2026-03-01 00:00:00"
}
if to == "" {
to = "2026-03-31 23:59:59"
}
conn, err := db.GetDB(database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(database)
sqlWhere := `
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND (nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1))
AND (
COALESCE(nt.benhnang, 0) = 1 OR
COALESCE(nt.mabatheodoi, '') != '' OR
nt.chamsoc ILIKE '%cấp I%' OR
nt.chamsoc ILIKE '%cấp 1%' OR
nt.chamsoc IN ('1', 'CS I', 'CSI') OR
nt.madv IN ('17', '29')
)
`
args := []interface{}{from, to}
if madv != "" && madv != "ALL" {
args = append(args, madv)
sqlWhere += fmt.Sprintf(" AND nt.madv = $%d ", len(args))
}
sqlStr := fmt.Sprintf(`
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
COALESCE(bn.diachi, '') AS diachi,
to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv,
to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv,
COALESCE(dv.tendv, nt.madv) AS tendv,
COALESCE(nt.kqcdoan, '') AS chandoan_chinh,
COALESCE(nt.kqcdoanp, '') AS chandoan_phu,
COALESCE(nt.chamsoc, 'Theo dõi đặc biệt') AS chamsoc,
COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls,
COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu
FROM current.bnnoitru nt
JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn
LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv
LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'THEO_DOI'
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb
WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0
) cls_sub ON true
%s
ORDER BY nt.ngayvv DESC
LIMIT 100;
`, sqlWhere)
rows, err := conn.Query(sqlStr, args...)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
defer rows.Close()
list := make([]map[string]interface{}, 0)
for rows.Next() {
var mabn, maba, hoten, diachi, ngayvv, ngayrv, tendv, cdChinh, cdPhu, chamsoc, cls, ghichu string
_ = rows.Scan(&mabn, &maba, &hoten, &diachi, &ngayvv, &ngayrv, &tendv, &cdChinh, &cdPhu, &chamsoc, &cls, &ghichu)
list = append(list, map[string]interface{}{
"mabn": mabn, "maba": maba, "hoten": hoten, "diachi": diachi,
"ngayvv": ngayvv, "ngayrv": ngayrv, "tendv": tendv,
"chandoan_chinh": cdChinh, "chandoan_phu": cdPhu,
"chamsoc": chamsoc, "ketqua_cls": db.CleanRTF(cls), "ghichu": ghichu,
})
}
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// POST /api/cap-nhat-ghi-chu
func HandleCapNhatGhiChu(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var body struct {
Maba string `json:"maba"`
Mabn string `json:"mabn"`
Loai string `json:"loai"`
Ghichu string `json:"ghichu"`
Database string `json:"database"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Maba == "" || body.Loai == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Thiếu thông tin maba hoặc loai danh sách"})
return
}
conn, err := db.GetDB(body.Database)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
db.EnsureTable(body.Database)
sqlUpsert := `
INSERT INTO current.giaoban_ghichu (maba, mabn, loai_danhsach, ghichu, ngay_capnhat)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (maba, loai_danhsach)
DO UPDATE SET ghichu = EXCLUDED.ghichu, ngay_capnhat = NOW();
`
_, err = conn.Exec(sqlUpsert, body.Maba, body.Mabn, body.Loai, body.Ghichu)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": err.Error()})
return
}
// Try sync to bnnoitru.ghichu
_, _ = conn.Exec("UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;", body.Ghichu, body.Maba)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã lưu ghi chú thành công!",
})
}
+287
View File
@@ -0,0 +1,287 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"web-giao-ban-go/logger"
)
type AttachmentInfo struct {
Filename string `json:"filename"`
OriginalName string `json:"originalname"`
Size int64 `json:"size"`
MimeType string `json:"mimetype"`
URL string `json:"url"`
}
type Issue struct {
ID string `json:"id"`
Title string `json:"title"`
Category string `json:"category"`
Module string `json:"module"`
Priority string `json:"priority"`
Database string `json:"database"`
DatabaseName string `json:"databaseName"`
Period string `json:"period"`
Reporter string `json:"reporter"`
Department string `json:"department"`
Description string `json:"description"`
Status string `json:"status"` // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG
HandlerNotes string `json:"handlerNotes"`
ResolvedBy string `json:"resolvedBy"`
ResolvedAt *string `json:"resolvedAt"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Attachment *AttachmentInfo `json:"attachment,omitempty"`
}
var (
issuesMu sync.RWMutex
dataFile = "data/issues.json"
uploadsDir = "uploads/issues"
)
func EnsureDirs() {
_ = os.MkdirAll("data", 0755)
_ = os.MkdirAll(uploadsDir, 0755)
if _, err := os.Stat(dataFile); os.IsNotExist(err) {
_ = os.WriteFile(dataFile, []byte("[]"), 0644)
}
}
func getAllIssues() []Issue {
EnsureDirs()
issuesMu.RLock()
defer issuesMu.RUnlock()
raw, err := os.ReadFile(dataFile)
if err != nil {
return []Issue{}
}
var list []Issue
if err := json.Unmarshal(raw, &list); err != nil {
return []Issue{}
}
return list
}
func saveIssues(list []Issue) error {
EnsureDirs()
issuesMu.Lock()
defer issuesMu.Unlock()
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := dataFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return err
}
return os.Rename(tmp, dataFile)
}
func generateIssueID(all []Issue) string {
now := time.Now()
prefix := fmt.Sprintf("ISS-%s", now.Format("20060102"))
count := 1
for _, item := range all {
if strings.HasPrefix(item.ID, prefix) {
count++
}
}
return fmt.Sprintf("%s-%03d", prefix, count)
}
// GET /api/issues
func HandleGetIssues(w http.ResponseWriter, r *http.Request) {
list := getAllIssues()
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": list,
})
}
// POST /api/issues
func HandleCreateIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
// Limit 25MB
if err := r.ParseMultipartForm(25 << 20); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "File quá lớn (tối đa 25MB)"})
return
}
title := strings.TrimSpace(r.FormValue("title"))
if title == "" {
title = "Sự cố không có tiêu đề"
}
all := getAllIssues()
id := generateIssueID(all)
nowStr := time.Now().Format(time.RFC3339)
var attach *AttachmentInfo
file, header, err := r.FormFile("attachment")
if err == nil && file != nil {
defer file.Close()
ext := filepath.Ext(header.Filename)
cleanBase := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
return r
}
return '_'
}, strings.TrimSuffix(header.Filename, ext))
safeFilename := fmt.Sprintf("%s_%d%s", cleanBase, time.Now().UnixNano()/1e6, ext)
destPath := filepath.Join(uploadsDir, safeFilename)
dst, err := os.Create(destPath)
if err == nil {
defer dst.Close()
_, _ = io.Copy(dst, file)
attach = &AttachmentInfo{
Filename: safeFilename,
OriginalName: header.Filename,
Size: header.Size,
MimeType: header.Header.Get("Content-Type"),
URL: "/uploads/issues/" + safeFilename,
}
}
}
newIssue := Issue{
ID: id,
Title: title,
Category: r.FormValue("category"),
Module: r.FormValue("module"),
Priority: r.FormValue("priority"),
Database: r.FormValue("database"),
DatabaseName: r.FormValue("databaseName"),
Period: r.FormValue("period"),
Reporter: strings.TrimSpace(r.FormValue("reporter")),
Department: strings.TrimSpace(r.FormValue("department")),
Description: strings.TrimSpace(r.FormValue("description")),
Status: "CHO_XU_LY",
CreatedAt: nowStr,
UpdatedAt: nowStr,
Attachment: attach,
}
all = append([]Issue{newIssue}, all...)
_ = saveIssues(all)
logger.Info("Đã ghi nhận Issue mới: [%s] %s từ %s", id, title, newIssue.Reporter)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": newIssue,
"message": "Đã ghi nhận báo lỗi thành công!",
})
}
// PUT /api/issues/{id}
func HandleUpdateIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
pathParts := strings.Split(r.URL.Path, "/")
id := pathParts[len(pathParts)-1]
var body struct {
Status string `json:"status"`
ResolvedBy string `json:"resolvedBy"`
HandlerNotes string `json:"handlerNotes"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Dữ liệu không hợp lệ"})
return
}
all := getAllIssues()
found := false
var updated Issue
nowStr := time.Now().Format(time.RFC3339)
for i := range all {
if all[i].ID == id {
if body.Status != "" {
all[i].Status = body.Status
}
all[i].ResolvedBy = body.ResolvedBy
all[i].HandlerNotes = body.HandlerNotes
all[i].UpdatedAt = nowStr
if (body.Status == "DA_XU_LY" || body.Status == "DONG") && all[i].ResolvedAt == nil {
all[i].ResolvedAt = &nowStr
}
updated = all[i]
found = true
break
}
}
if !found {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"})
return
}
_ = saveIssues(all)
logger.Info("Cập nhật Issue [%s]: Trạng thái=%s, Kỹ thuật=%s", id, updated.Status, updated.ResolvedBy)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"data": updated,
"message": "Cập nhật issue thành công!",
})
}
// DELETE /api/issues/{id}
func HandleDeleteIssue(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
pathParts := strings.Split(r.URL.Path, "/")
id := pathParts[len(pathParts)-1]
all := getAllIssues()
filtered := make([]Issue, 0, len(all))
var removed *Issue
for _, item := range all {
if item.ID == id {
cpy := item
removed = &cpy
continue
}
filtered = append(filtered, item)
}
if removed == nil {
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"success": false, "message": "Không tìm thấy issue"})
return
}
if removed.Attachment != nil && removed.Attachment.Filename != "" {
_ = os.Remove(filepath.Join(uploadsDir, removed.Attachment.Filename))
}
_ = saveIssues(filtered)
logger.Info("Đã xóa Issue [%s]", id)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Đã xóa issue thành công!",
})
}
-147
View File
@@ -1,147 +0,0 @@
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, 'data');
const UPLOADS_DIR = path.join(__dirname, 'uploads', 'issues');
const ISSUES_FILE = path.join(DATA_DIR, 'issues.json');
function ensureDirs() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
if (!fs.existsSync(ISSUES_FILE)) {
fs.writeFileSync(ISSUES_FILE, JSON.stringify([], null, 2), 'utf-8');
}
}
function getAllIssues() {
ensureDirs();
try {
const raw = fs.readFileSync(ISSUES_FILE, 'utf-8');
const list = JSON.parse(raw);
return Array.isArray(list) ? list : [];
} catch (err) {
console.error('Error reading issues.json:', err);
return [];
}
}
function saveIssues(issues) {
ensureDirs();
const tmpFile = `${ISSUES_FILE}.tmp`;
fs.writeFileSync(tmpFile, JSON.stringify(issues, null, 2), 'utf-8');
fs.renameSync(tmpFile, ISSUES_FILE);
}
function generateIssueId() {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const prefix = `ISS-${y}${m}${d}`;
const all = getAllIssues();
const todayCount = all.filter(item => item.id && item.id.startsWith(prefix)).length + 1;
const seq = String(todayCount).padStart(3, '0');
return `${prefix}-${seq}`;
}
function createIssue(data, file) {
ensureDirs();
const all = getAllIssues();
const id = generateIssueId();
const now = new Date().toISOString();
let attachment = null;
if (file) {
attachment = {
filename: file.filename,
originalname: file.originalname,
size: file.size,
mimetype: file.mimetype,
url: `/uploads/issues/${file.filename}`,
};
}
const newIssue = {
id,
title: (data.title || 'Không có tiêu đề').trim(),
category: data.category || 'SAI_LECH_SO_LIEU', // SAI_LECH_SO_LIEU, LOI_GIAO_DIEN, LOI_KET_NOI, DE_XUAT, YEU_CAU_MOI, KHAC
module: data.module || 'CHUNG', // NGOAI_TRU, NOI_TRU, CHUYEN_VIEN, THEO_DOI, CHUNG
priority: data.priority || 'BINH_THUONG', // THAP, BINH_THUONG, CAO, KHAN_CAP
database: data.database || '',
databaseName: data.databaseName || data.database || '',
period: data.period || '',
reporter: (data.reporter || 'Cán bộ giao ban').trim(),
department: (data.department || '').trim(),
description: (data.description || '').trim(),
status: 'CHO_XU_LY', // CHO_XU_LY, DANG_XU_LY, DA_XU_LY, DONG
handlerNotes: '',
resolvedBy: '',
resolvedAt: null,
createdAt: now,
updatedAt: now,
attachment,
};
all.unshift(newIssue);
saveIssues(all);
return newIssue;
}
function updateIssue(id, updates) {
const all = getAllIssues();
const idx = all.findIndex(item => item.id === id);
if (idx === -1) {
throw new Error(`Không tìm thấy Issue với mã: ${id}`);
}
const now = new Date().toISOString();
const cur = all[idx];
const updated = {
...cur,
...updates,
updatedAt: now,
};
if (updates.status === 'DA_XU_LY' && cur.status !== 'DA_XU_LY') {
updated.resolvedAt = now;
}
all[idx] = updated;
saveIssues(all);
return updated;
}
function deleteIssue(id) {
const all = getAllIssues();
const idx = all.findIndex(item => item.id === id);
if (idx === -1) {
throw new Error(`Không tìm thấy Issue với mã: ${id}`);
}
const item = all[idx];
if (item.attachment && item.attachment.filename) {
const fPath = path.join(UPLOADS_DIR, item.attachment.filename);
if (fs.existsSync(fPath)) {
try { fs.unlinkSync(fPath); } catch (e) { /* ignore */ }
}
}
all.splice(idx, 1);
saveIssues(all);
return true;
}
module.exports = {
UPLOADS_DIR,
ensureDirs,
getAllIssues,
createIssue,
updateIssue,
deleteIssue,
};
+121
View File
@@ -0,0 +1,121 @@
package logger
import (
"fmt"
"io"
"os"
"path/filepath"
"sync"
"time"
)
type LogEntry struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
}
type RingLogger struct {
mu sync.RWMutex
entries []LogEntry
maxSize int
file *os.File
multiOut io.Writer
}
var globalLogger *RingLogger
var once sync.Once
func Init(logFilePath string, maxSize int) *RingLogger {
once.Do(func() {
if maxSize <= 0 {
maxSize = 500
}
dir := filepath.Dir(logFilePath)
if dir != "" && dir != "." {
_ = os.MkdirAll(dir, 0755)
}
f, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
var out io.Writer = os.Stdout
if err == nil {
out = io.MultiWriter(os.Stdout, f)
}
globalLogger = &RingLogger{
entries: make([]LogEntry, 0, maxSize),
maxSize: maxSize,
file: f,
multiOut: out,
}
})
return globalLogger
}
func Get() *RingLogger {
if globalLogger == nil {
return Init("logs/app.log", 500)
}
return globalLogger
}
func (l *RingLogger) Log(level, message string) {
ts := time.Now().Format("2006-01-02 15:04:05")
entry := LogEntry{
Timestamp: ts,
Level: level,
Message: message,
}
l.mu.Lock()
if len(l.entries) >= l.maxSize {
l.entries = l.entries[1:]
}
l.entries = append(l.entries, entry)
l.mu.Unlock()
line := fmt.Sprintf("[%s] [%s] %s\n", ts, level, message)
if l.multiOut != nil {
_, _ = l.multiOut.Write([]byte(line))
}
}
func (l *RingLogger) GetEntries(limit int, levelFilter string) []LogEntry {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]LogEntry, 0, len(l.entries))
for i := len(l.entries) - 1; i >= 0; i-- {
e := l.entries[i]
if levelFilter != "" && levelFilter != "ALL" && e.Level != levelFilter {
continue
}
result = append(result, e)
if limit > 0 && len(result) >= limit {
break
}
}
return result
}
func (l *RingLogger) Clear() {
l.mu.Lock()
defer l.mu.Unlock()
l.entries = make([]LogEntry, 0, l.maxSize)
}
func Info(format string, a ...interface{}) {
Get().Log("INFO", fmt.Sprintf(format, a...))
}
func Warn(format string, a ...interface{}) {
Get().Log("WARN", fmt.Sprintf(format, a...))
}
func Error(format string, a ...interface{}) {
Get().Log("ERROR", fmt.Sprintf(format, a...))
}
func SQL(format string, a ...interface{}) {
Get().Log("SQL", fmt.Sprintf(format, a...))
}
+228
View File
@@ -0,0 +1,228 @@
package main
import (
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"strings"
"time"
"web-giao-ban-go/config"
"web-giao-ban-go/handlers"
"web-giao-ban-go/logger"
"web-giao-ban-go/tray"
)
//go:embed public/*
var embeddedFiles embed.FS
func main() {
// Initialize logger
logger.Init("logs/app.log", 500)
logger.Info("=======================================================")
logger.Info("[DH-HIS] Phần Mềm Quản Lý Giao Ban Bệnh Viện (Golang)")
logger.Info("=======================================================")
// Load or create config
cfg, err := config.LoadConfig("config.json")
if err != nil {
logger.Error("Không thể đọc file config.json: %v", err)
} else {
logger.Info("Đã nạp file cấu hình thành công (Port: %d, Admin: %s)", cfg.Server.Port, cfg.Server.AdminPath)
}
handlers.EnsureDirs()
// Run system tray (Windows GUI) or signal listener (Linux daemon)
tray.Run(func() {
startWebServer()
})
}
func startWebServer() {
cfg := config.Get()
port := cfg.Server.Port
if port <= 0 {
port = 3000
}
mux := http.NewServeMux()
// Sub-filesystem for embedded public
pubFS, err := fs.Sub(embeddedFiles, "public")
if err != nil {
logger.Error("Lỗi đọc embedded filesystem: %v", err)
return
}
// Static file handler
staticHandler := func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
p = strings.TrimPrefix(p, "/web-giao-ban-go")
p = strings.TrimPrefix(p, "/web-giao-ban")
if p == "" {
p = "/"
}
cleanPath := strings.TrimPrefix(p, "/")
if cleanPath == "" {
cleanPath = "index.html"
}
// Disable caching for HTML to avoid browser redirect caching
if strings.HasSuffix(cleanPath, ".html") || cleanPath == "index.html" {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
}
// Try open in embedded fs
if f, err := pubFS.Open(cleanPath); err == nil {
defer f.Close()
if stat, err := f.Stat(); err == nil && !stat.IsDir() {
http.ServeContent(w, r, stat.Name(), stat.ModTime(), f.(io.ReadSeeker))
return
}
}
// Fallback to index.html for SPA routing
if f, err := pubFS.Open("index.html"); err == nil {
defer f.Close()
if stat, err := f.Stat(); err == nil {
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
http.ServeContent(w, r, "index.html", stat.ModTime(), f.(io.ReadSeeker))
return
}
}
http.NotFound(w, r)
}
// Serve Uploaded Files
_ = os.MkdirAll("uploads/issues", 0755)
uploadFileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir("uploads")))
uploadFileServerPrefixed := http.StripPrefix("/web-giao-ban/uploads/", http.FileServer(http.Dir("uploads")))
uploadFileServerPrefixedGo := http.StripPrefix("/web-giao-ban-go/uploads/", http.FileServer(http.Dir("uploads")))
mux.Handle("/uploads/", uploadFileServer)
mux.Handle("/web-giao-ban/uploads/", uploadFileServerPrefixed)
mux.Handle("/web-giao-ban-go/uploads/", uploadFileServerPrefixedGo)
// Admin page
adminPath := cfg.Server.AdminPath
if adminPath == "" {
adminPath = "/admin"
}
adminHandler := func(w http.ResponseWriter, r *http.Request) {
if f, err := pubFS.Open("admin.html"); err == nil {
defer f.Close()
if stat, err := f.Stat(); err == nil {
http.ServeContent(w, r, "admin.html", stat.ModTime(), f.(io.ReadSeeker))
return
}
}
http.NotFound(w, r)
}
mux.HandleFunc(adminPath, adminHandler)
mux.HandleFunc("/web-giao-ban"+adminPath, adminHandler)
mux.HandleFunc("/web-giao-ban-go"+adminPath, adminHandler)
// API Routing helper to mount on /api/..., /web-giao-ban/api/..., and /web-giao-ban-go/api/...
mountAPI := func(pattern string, handler func(http.ResponseWriter, *http.Request)) {
mux.HandleFunc(pattern, handler)
mux.HandleFunc("/web-giao-ban"+pattern, handler)
mux.HandleFunc("/web-giao-ban-go"+pattern, handler)
}
// Business APIs
mountAPI("/api/databases", handlers.HandleGetDatabases)
mountAPI("/api/giao-ban-ngoai-tru", handlers.HandleGiaoBanNgoaiTru)
mountAPI("/api/giao-ban-noi-tru", handlers.HandleGiaoBanNoiTru)
mountAPI("/api/danh-sach-chuyen-vien", handlers.HandleDanhSachChuyenVien)
mountAPI("/api/danh-sach-theo-doi", handlers.HandleDanhSachTheoDoi)
mountAPI("/api/cap-nhat-ghi-chu", handlers.HandleCapNhatGhiChu)
// Issues Management APIs
mountAPI("/api/issues", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handlers.HandleGetIssues(w, r)
case http.MethodPost:
handlers.HandleCreateIssue(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mountAPI("/api/issues/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodPut:
handlers.HandleUpdateIssue(w, r)
case http.MethodDelete:
handlers.HandleDeleteIssue(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Admin & Update APIs
mountAPI("/api/admin/config", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handlers.HandleGetAdminConfig(w, r)
case http.MethodPost:
handlers.HandleSaveAdminConfig(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
mountAPI("/api/admin/test-db", handlers.HandleTestDBConnection)
mountAPI("/api/admin/logs", handlers.HandleGetLogs)
mountAPI("/api/admin/logs/clear", handlers.HandleClearLogs)
mountAPI("/api/update/check", handlers.HandleCheckUpdate)
mountAPI("/api/update/apply", handlers.HandleApplyUpdate)
// Static website routes
mux.HandleFunc("/web-giao-ban-go", staticHandler)
mux.HandleFunc("/web-giao-ban-go/", staticHandler)
mux.HandleFunc("/web-giao-ban", staticHandler)
mux.HandleFunc("/web-giao-ban/", staticHandler)
mux.HandleFunc("/", staticHandler)
// Enable CORS & Logging Middleware
corsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Requested-With")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
t0 := time.Now()
mux.ServeHTTP(w, r)
if strings.HasPrefix(r.URL.Path, "/api/") || strings.HasPrefix(r.URL.Path, "/web-giao-ban/api/") || strings.HasPrefix(r.URL.Path, "/web-giao-ban-go/api/") {
logger.Info("%s %s in %v", r.Method, r.URL.Path, time.Since(t0))
}
})
addr := fmt.Sprintf("0.0.0.0:%d", port)
logger.Info("Web Server đang lắng nghe tại: http://localhost:%d", port)
logger.Info("Trang Giao Ban: http://localhost:%d/web-giao-ban-go (hoặc /web-giao-ban)", port)
logger.Info("Trang Quản Trị: http://localhost:%d/web-giao-ban-go%s", port, adminPath)
server := &http.Server{
Addr: addr,
Handler: corsHandler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
}
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("Lỗi khởi động web server: %v", err)
}
}
-1048
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
{
"name": "dh-giaoban-web",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^18.0.9",
"express": "^5.3.0",
"multer": "^2.5.0",
"pg": "^8.23.1"
}
}
+345
View File
@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DH-HIS | Quản Trị &amp; Cấu Hình Hệ Thống Giao Ban</title>
<link rel="icon" type="image/x-icon" href="favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="styles.css">
</head>
<body class="bg-slate-100 text-slate-800 font-sans antialiased min-h-screen flex flex-col">
<!-- TOP HEADER -->
<header class="bg-gradient-to-r from-slate-900 via-indigo-950 to-slate-900 text-white shadow-lg sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-3.5 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="bg-indigo-600/30 p-2 rounded-xl border border-indigo-400/30 text-cyan-300">
<i class="fa-solid fa-sliders text-2xl"></i>
</div>
<div>
<h1 class="text-lg font-bold tracking-tight uppercase flex items-center gap-2">
DH-HIS Giao Ban | Quản Trị Hệ Thống
<span class="text-xs bg-indigo-500/30 text-indigo-200 border border-indigo-400/30 px-2 py-0.5 rounded-full font-mono" id="appVersionBadge">v1.0.2</span>
</h1>
<p class="text-xs text-slate-400">Thiết lập kết nối CSDL, bảo mật mã hóa, kiểm tra cập nhật &amp; theo dõi nhật ký</p>
</div>
</div>
<div class="flex items-center gap-2">
<a href="index.html?v=1.0.2" class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-3 py-2 rounded-lg shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-hospital-user"></i>
<span>Mở Báo Cáo Giao Ban</span>
</a>
</div>
</div>
</header>
<!-- NAVIGATION TABS -->
<section class="max-w-7xl mx-auto px-4 sm:px-6 mt-4 w-full">
<div class="flex border-b border-slate-200 gap-2 flex-wrap">
<button class="admin-tab active px-5 py-2.5 text-sm font-bold border-b-2 border-indigo-600 text-indigo-600 flex items-center gap-2 transition-all" data-tab="tab-config">
<i class="fa-solid fa-database"></i>
<span>1. Cấu Hình Kết Nối &amp; CSDL</span>
</button>
<button class="admin-tab px-5 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-update">
<i class="fa-solid fa-cloud-arrow-down"></i>
<span>2. Cập Nhật Phiên Bản (Gitea)</span>
</button>
<button class="admin-tab px-5 py-2.5 text-sm font-bold border-b-2 border-transparent text-slate-500 hover:text-slate-800 flex items-center gap-2 transition-all" data-tab="tab-logs">
<i class="fa-solid fa-terminal"></i>
<span>3. Nhật Ký Hoạt Động (Live Logs)</span>
</button>
</div>
</section>
<!-- MAIN CONTAINER -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 py-6 flex-1 w-full">
<!-- ========================================== -->
<!-- TAB 1: CẤU HÌNH KẾT NỐI -->
<!-- ========================================== -->
<div id="tab-config" class="admin-content space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Server & PostgreSQL Config Form -->
<div class="lg:col-span-6 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-server text-indigo-600"></i>
<span>Cấu Hình Web Server &amp; CSDL PostgreSQL</span>
</h2>
<span class="text-[11px] bg-emerald-100 text-emerald-800 px-2 py-0.5 rounded font-bold">Mã hóa AES-256</span>
</div>
<form id="formConfig" class="p-5 space-y-4 text-xs">
<!-- Server Settings -->
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Cổng Web (Port):</label>
<input type="number" id="cfgPort" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-bold" value="3000">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Đường dẫn quản trị:</label>
<input type="text" id="cfgAdminPath" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="/admin">
</div>
</div>
<!-- PostgreSQL Settings -->
<div class="border-t border-slate-200 pt-3 space-y-3">
<div class="font-bold text-slate-800 text-xs flex items-center gap-1.5">
<i class="fa-solid fa-database text-blue-600"></i> Thông tin máy chủ PostgreSQL:
</div>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label class="block font-medium text-slate-600 mb-1">Địa chỉ IP / Host:</label>
<input type="text" id="cfgDbHost" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="192.168.50.79">
</div>
<div>
<label class="block font-medium text-slate-600 mb-1">Port:</label>
<input type="number" id="cfgDbPort" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="5437">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-medium text-slate-600 mb-1">Người dùng (User):</label>
<input type="text" id="cfgDbUser" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="postgres">
</div>
<div>
<label class="block font-medium text-slate-600 mb-1 flex items-center justify-between">
<span>Mật khẩu:</span>
<button type="button" id="btnToggleDbPass" class="text-[10px] text-indigo-600 hover:underline">Hiện</button>
</label>
<input type="password" id="cfgDbPassword" placeholder="•••••••• (Giữ nguyên hoặc nhập mới)" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium">
</div>
</div>
<div>
<label class="block font-medium text-slate-600 mb-1">CSDL mặc định (Default DB):</label>
<input type="text" id="cfgDbDefault" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="an_omon_full_04052026">
</div>
</div>
<!-- Test Connection Button -->
<div class="pt-2 flex items-center justify-between">
<button type="button" id="btnTestConnection" class="bg-slate-800 hover:bg-slate-900 text-white font-semibold py-2 px-3.5 rounded-lg flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-bolt text-amber-400"></i>
<span>Thử Kết Nối (Test Connection)</span>
</button>
<div id="testConnResult" class="text-xs font-semibold"></div>
</div>
<!-- Save Button -->
<div class="pt-4 border-t border-slate-200 flex justify-end">
<button type="submit" id="btnSaveConfig" class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2.5 px-6 rounded-lg shadow-md flex items-center gap-2 transition-all text-xs">
<i class="fa-solid fa-floppy-disk"></i>
<span>Lưu Cấu Hình &amp; Mã Hóa</span>
</button>
</div>
</form>
</div>
<!-- Databases List Management -->
<div class="lg:col-span-6 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex flex-col">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-list text-blue-600"></i>
<span>Danh Sách CSDL Bệnh Viện (Dropdown)</span>
</h2>
<button id="btnAddDbRow" class="bg-blue-50 text-blue-700 hover:bg-blue-100 border border-blue-200 px-2.5 py-1 rounded-lg text-xs font-bold flex items-center gap-1">
<i class="fa-solid fa-plus"></i> Thêm CSDL
</button>
</div>
<div class="p-4 flex-1 overflow-y-auto">
<table class="w-full text-left text-xs border-collapse">
<thead class="bg-slate-100 text-slate-700 font-semibold border-b border-slate-200">
<tr>
<th class="py-2 px-2.5 w-44">Tên CSDL (DB Name)</th>
<th class="py-2 px-2.5">Tên Hiển Thị (Label)</th>
<th class="py-2 px-2 w-16 text-center">Xóa</th>
</tr>
</thead>
<tbody id="bodyDbList" class="divide-y divide-slate-200">
<!-- Dynamically loaded -->
</tbody>
</table>
</div>
<div class="p-3 bg-amber-50/70 border-t border-amber-200 text-amber-800 text-[11px] leading-relaxed">
<i class="fa-solid fa-circle-info mr-1"></i> Các CSDL này sẽ hiển thị trong dropdown chọn CSDL ngoài trang báo cáo giao ban để người dùng chuyển đổi linh hoạt.
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 2: CẬP NHẬT PHIÊN BẢN (GITEA) -->
<!-- ========================================== -->
<div id="tab-update" class="admin-content hidden space-y-6">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6">
<!-- Gitea Config -->
<div class="lg:col-span-5 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-gear text-indigo-600"></i>
<span>Thiết Lập Gitea Package / Release</span>
</h2>
</div>
<form id="formGiteaConfig" class="p-5 space-y-3.5 text-xs">
<div>
<label class="block font-semibold text-slate-700 mb-1">Gitea URL:</label>
<input type="text" id="cfgGiteaUrl" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500 font-medium" value="https://i.dh-his.com">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block font-semibold text-slate-700 mb-1">Tổ chức / Owner:</label>
<input type="text" id="cfgGiteaOwner" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="ongtrieuhau">
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1">Repository:</label>
<input type="text" id="cfgGiteaRepo" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500" value="web-giao-ban">
</div>
</div>
<div>
<label class="block font-semibold text-slate-700 mb-1 flex items-center justify-between">
<span>Personal Access Token (Để tải bản private):</span>
<button type="button" id="btnToggleToken" class="text-[10px] text-indigo-600 hover:underline">Hiện</button>
</label>
<input type="password" id="cfgGiteaToken" placeholder="•••••••• (Giữ nguyên hoặc nhập token mới)" class="w-full rounded-lg border border-slate-300 px-3 py-2 bg-slate-50 font-mono outline-none focus:ring-2 focus:ring-indigo-500">
</div>
<div class="pt-2 flex justify-end">
<button type="submit" class="bg-slate-800 hover:bg-slate-900 text-white font-bold py-2 px-4 rounded-lg flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-check"></i>
<span>Lưu Cấu Hình Gitea</span>
</button>
</div>
</form>
</div>
<!-- Release Status & Action -->
<div class="lg:col-span-7 bg-white rounded-2xl shadow-sm border border-slate-200 overflow-hidden flex flex-col">
<div class="px-5 py-4 bg-slate-50 border-b border-slate-200 flex items-center justify-between">
<h2 class="text-sm font-bold text-slate-800 flex items-center gap-2">
<i class="fa-solid fa-code-compare text-blue-600"></i>
<span>Trạng Thái Phiên Bản &amp; Tự Động Cập Nhật</span>
</h2>
<button id="btnCheckUpdate" class="bg-indigo-600 hover:bg-indigo-700 text-white px-3.5 py-1.5 rounded-lg text-xs font-bold shadow-sm flex items-center gap-1.5 transition-all">
<i class="fa-solid fa-arrows-rotate"></i>
<span>Kiểm Tra Cập Nhật</span>
</button>
</div>
<div class="p-6 flex-1 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div class="p-4 rounded-xl border border-slate-200 bg-slate-50">
<div class="text-xs text-slate-500 font-semibold">Phiên Bản Đang Chạy:</div>
<div class="mt-1 text-2xl font-black text-slate-800" id="currentVerDisplay">v1.0.0</div>
</div>
<div class="p-4 rounded-xl border border-indigo-200 bg-indigo-50/40">
<div class="text-xs text-indigo-700 font-semibold">Phiên Bản Mới Nhất Trên Gitea:</div>
<div class="mt-1 text-2xl font-black text-indigo-700" id="latestVerDisplay">Đang kiểm tra...</div>
</div>
</div>
<div id="updateStatusBox" class="p-4 rounded-xl border border-slate-200 bg-white">
<div class="font-bold text-xs text-slate-700 mb-1" id="updateStatusTitle">Nội dung phiên bản:</div>
<div class="text-xs text-slate-600 whitespace-pre-wrap leading-relaxed max-h-48 overflow-y-auto" id="updateReleaseNotes">
Bấm nút "Kiểm Tra Cập Nhật" để truy vấn phiên bản mới nhất từ Gitea.
</div>
</div>
<!-- Action Button Apply Update -->
<div id="applyUpdateArea" class="hidden p-4 rounded-xl bg-emerald-50 border border-emerald-200 flex items-center justify-between gap-3">
<div>
<div class="font-bold text-xs text-emerald-900">Phát hiện bản cập nhật mới!</div>
<div class="text-[11px] text-emerald-700" id="updateAssetInfo">File: ...</div>
</div>
<button id="btnApplyUpdate" class="bg-emerald-600 hover:bg-emerald-700 text-white px-5 py-2.5 rounded-lg font-bold text-xs shadow-md flex items-center gap-2 transition-all">
<i class="fa-solid fa-download"></i>
<span>Tải &amp; Cập Nhật Tự Động</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========================================== -->
<!-- TAB 3: NHẬT KÝ HOẠT ĐỘNG (LIVE LOGS) -->
<!-- ========================================== -->
<div id="tab-logs" class="admin-content hidden space-y-4">
<!-- Toolbar Logs -->
<div class="bg-white p-3.5 rounded-2xl shadow-sm border border-slate-200 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-3 text-xs">
<span class="font-bold text-slate-700 flex items-center gap-1.5">
<i class="fa-solid fa-filter text-indigo-600"></i> Cấp độ log:
</span>
<select id="selectLogLevel" class="rounded-lg border border-slate-300 px-3 py-1.5 bg-slate-50 outline-none font-medium">
<option value="ALL">Tất cả (ALL)</option>
<option value="INFO">Thông tin (INFO)</option>
<option value="WARN">Cảnh báo (WARN)</option>
<option value="ERROR">Lỗi (ERROR)</option>
<option value="SQL">Truy vấn (SQL)</option>
</select>
<label class="inline-flex items-center gap-1.5 cursor-pointer text-slate-600 font-medium">
<input type="checkbox" id="chkAutoRefresh" class="rounded text-indigo-600" checked>
<span>Tự động cập nhật (3s)</span>
</label>
</div>
<div class="flex items-center gap-2">
<button id="btnRefreshLogs" class="bg-slate-100 hover:bg-slate-200 text-slate-700 text-xs font-semibold px-3 py-1.5 rounded-lg border border-slate-300 flex items-center gap-1">
<i class="fa-solid fa-rotate-right"></i> Làm mới
</button>
<button id="btnClearLogs" class="bg-rose-50 hover:bg-rose-100 text-rose-700 border border-rose-200 text-xs font-semibold px-3 py-1.5 rounded-lg flex items-center gap-1">
<i class="fa-solid fa-trash-can"></i> Xóa log
</button>
</div>
</div>
<!-- Log Terminal Box -->
<div class="bg-slate-950 rounded-2xl p-4 shadow-xl border border-slate-800 font-mono text-xs overflow-hidden flex flex-col h-[600px]">
<div class="flex items-center justify-between pb-2 border-b border-slate-800 text-slate-500 text-[11px]">
<span>TERMINAL LOG STREAM (Ring buffer 500 dòng)</span>
<span id="logCountBadge" class="text-slate-400">0 dòng</span>
</div>
<div id="logTerminal" class="flex-1 overflow-y-auto space-y-1 mt-2 text-slate-300 leading-relaxed pr-2">
<div class="text-slate-500 italic">Đang tải nhật ký...</div>
</div>
</div>
</div>
</main>
<!-- TOAST -->
<div id="toast" class="fixed bottom-4 right-4 z-50 bg-slate-900 text-white text-xs px-4 py-2.5 rounded-lg shadow-xl flex items-center gap-2 transform translate-y-20 opacity-0 transition-all duration-300">
<i class="fa-solid fa-circle-check text-emerald-400"></i>
<span id="toastMsg">Thông báo</span>
</div>
<script src="admin.js"></script>
</body>
</html>
+448
View File
@@ -0,0 +1,448 @@
// Admin & Configuration JavaScript
const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web-giao-ban-go' : (window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : '');
let autoRefreshTimer = null;
let currentUpdateAsset = null;
document.addEventListener('DOMContentLoaded', () => {
initTabs();
loadConfig();
initLogViewer();
initUpdateChecker();
// Handle URL hash tab
const hash = window.location.hash;
if (hash === '#tab-update') {
switchTab('tab-update');
checkUpdate();
} else if (hash === '#tab-logs') {
switchTab('tab-logs');
}
});
function showToast(msg) {
const toast = document.getElementById('toast');
const toastMsg = document.getElementById('toastMsg');
if (!toast || !toastMsg) return;
toastMsg.innerText = msg;
toast.classList.remove('translate-y-20', 'opacity-0');
toast.classList.add('translate-y-0', 'opacity-100');
setTimeout(() => {
toast.classList.remove('translate-y-0', 'opacity-100');
toast.classList.add('translate-y-20', 'opacity-0');
}, 2500);
}
function initTabs() {
document.querySelectorAll('.admin-tab').forEach(btn => {
btn.addEventListener('click', () => {
const target = btn.getAttribute('data-tab');
switchTab(target);
});
});
}
function switchTab(targetId) {
document.querySelectorAll('.admin-tab').forEach(b => {
const isTarget = b.getAttribute('data-tab') === targetId;
b.classList.toggle('active', isTarget);
b.classList.toggle('border-indigo-600', isTarget);
b.classList.toggle('text-indigo-600', isTarget);
b.classList.toggle('border-transparent', !isTarget);
b.classList.toggle('text-slate-500', !isTarget);
});
document.querySelectorAll('.admin-content').forEach(c => {
c.classList.toggle('hidden', c.id !== targetId);
});
if (targetId === 'tab-logs') {
fetchLogs();
}
}
// -------------------------------------------------------------------
// CONFIGURATION
// -------------------------------------------------------------------
async function loadConfig() {
try {
const res = await fetch(`${API_BASE}/api/admin/config`);
const json = await res.json();
if (json.success && json.data) {
const cfg = json.data;
// Server
document.getElementById('cfgPort').value = cfg.server.port || 3000;
document.getElementById('cfgAdminPath').value = cfg.server.admin_path || '/admin';
// Database
document.getElementById('cfgDbHost').value = cfg.database.host || '192.168.50.79';
document.getElementById('cfgDbPort').value = cfg.database.port || 5437;
document.getElementById('cfgDbUser').value = cfg.database.user || 'postgres';
document.getElementById('cfgDbPassword').value = '';
document.getElementById('cfgDbDefault').value = cfg.database.default_db || '';
// Databases Table
renderDbList(cfg.database.databases || []);
// Gitea Updater
document.getElementById('cfgGiteaUrl').value = cfg.updater.gitea_url || 'https://i.dh-his.com';
const owner = (cfg.updater.owner && cfg.updater.owner !== 'hdhiswork') ? cfg.updater.owner : 'ongtrieuhau';
const repo = (cfg.updater.repo && cfg.updater.repo !== 'DUAN') ? cfg.updater.repo : 'web-giao-ban';
document.getElementById('cfgGiteaOwner').value = owner;
document.getElementById('cfgGiteaRepo').value = repo;
document.getElementById('cfgGiteaToken').value = '';
const ver = cfg.updater.current_version || 'v1.0.0';
document.getElementById('appVersionBadge').innerText = ver;
document.getElementById('currentVerDisplay').innerText = ver;
}
} catch (err) {
console.error('Lỗi nạp cấu hình:', err);
}
}
function renderDbList(list) {
const tbody = document.getElementById('bodyDbList');
tbody.innerHTML = '';
list.forEach(db => {
addDbRow(db.id, db.name);
});
}
function addDbRow(id = '', name = '') {
const tbody = document.getElementById('bodyDbList');
const tr = document.createElement('tr');
tr.className = 'hover:bg-slate-50 transition-colors';
tr.innerHTML = `
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 font-mono text-xs db-row-id" value="${id}" placeholder="Tên CSDL trong Postgres">
</td>
<td class="py-2 px-2.5">
<input type="text" class="w-full rounded border border-slate-300 px-2 py-1 text-xs db-row-name" value="${name}" placeholder="Tên hiển thị dropdown">
</td>
<td class="py-2 px-2 text-center">
<button type="button" class="text-rose-500 hover:text-rose-700 p-1 text-xs btn-del-row" title="Xóa dòng">
<i class="fa-solid fa-trash-can"></i>
</button>
</td>
`;
tr.querySelector('.btn-del-row').addEventListener('click', () => tr.remove());
tbody.appendChild(tr);
}
document.getElementById('btnAddDbRow')?.addEventListener('click', () => addDbRow());
// Password Toggle
document.getElementById('btnToggleDbPass')?.addEventListener('click', () => {
const inp = document.getElementById('cfgDbPassword');
const btn = document.getElementById('btnToggleDbPass');
if (inp.type === 'password') {
inp.type = 'text';
btn.innerText = 'Ẩn';
} else {
inp.type = 'password';
btn.innerText = 'Hiện';
}
});
document.getElementById('btnToggleToken')?.addEventListener('click', () => {
const inp = document.getElementById('cfgGiteaToken');
const btn = document.getElementById('btnToggleToken');
if (inp.type === 'password') {
inp.type = 'text';
btn.innerText = 'Ẩn';
} else {
inp.type = 'password';
btn.innerText = 'Hiện';
}
});
// Test Connection
document.getElementById('btnTestConnection')?.addEventListener('click', async () => {
const resEl = document.getElementById('testConnResult');
resEl.innerHTML = '<span class="text-blue-600"><i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang thử kết nối...</span>';
const body = {
host: document.getElementById('cfgDbHost').value,
port: parseInt(document.getElementById('cfgDbPort').value, 10),
user: document.getElementById('cfgDbUser').value,
password: document.getElementById('cfgDbPassword').value,
database: document.getElementById('cfgDbDefault').value,
};
try {
const res = await fetch(`${API_BASE}/api/admin/test-db`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) {
resEl.innerHTML = '<span class="text-emerald-600"><i class="fa-solid fa-circle-check mr-1"></i> Kết nối thành công!</span>';
} else {
resEl.innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${json.message}</span>`;
}
} catch (err) {
resEl.innerHTML = `<span class="text-rose-600"><i class="fa-solid fa-circle-xmark mr-1"></i> ${err.message}</span>`;
}
});
// Save Config Form
document.getElementById('formConfig')?.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('btnSaveConfig');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang lưu...</span>';
const dbs = [];
document.querySelectorAll('#bodyDbList tr').forEach(tr => {
const id = tr.querySelector('.db-row-id').value.trim();
const name = tr.querySelector('.db-row-name').value.trim();
if (id) {
dbs.push({ id, name: name || id });
}
});
const body = {
server: {
port: parseInt(document.getElementById('cfgPort').value, 10),
admin_path: document.getElementById('cfgAdminPath').value.trim(),
},
database: {
host: document.getElementById('cfgDbHost').value.trim(),
port: parseInt(document.getElementById('cfgDbPort').value, 10),
user: document.getElementById('cfgDbUser').value.trim(),
password: document.getElementById('cfgDbPassword').value,
default_db: document.getElementById('cfgDbDefault').value.trim(),
databases: dbs,
},
updater: {
enabled: true,
gitea_url: document.getElementById('cfgGiteaUrl').value.trim(),
owner: document.getElementById('cfgGiteaOwner').value.trim(),
repo: document.getElementById('cfgGiteaRepo').value.trim(),
token: document.getElementById('cfgGiteaToken').value,
current_version: document.getElementById('currentVerDisplay').innerText,
}
};
try {
const res = await fetch(`${API_BASE}/api/admin/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) {
showToast('Đã lưu cấu hình & mã hóa thành công!');
loadConfig();
} else {
alert('Lỗi lưu cấu hình: ' + json.message);
}
} catch (err) {
alert('Lỗi kết nối server: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> <span>Lưu Cấu Hình &amp; Mã Hóa</span>';
}
});
// Save Gitea Config Form
document.getElementById('formGiteaConfig')?.addEventListener('submit', (e) => {
e.preventDefault();
document.getElementById('formConfig').dispatchEvent(new Event('submit'));
});
// -------------------------------------------------------------------
// LOG VIEWER
// -------------------------------------------------------------------
function initLogViewer() {
document.getElementById('btnRefreshLogs')?.addEventListener('click', fetchLogs);
document.getElementById('selectLogLevel')?.addEventListener('change', fetchLogs);
document.getElementById('btnClearLogs')?.addEventListener('click', clearLogs);
const chk = document.getElementById('chkAutoRefresh');
chk?.addEventListener('change', () => {
if (chk.checked) {
startAutoRefresh();
} else {
clearInterval(autoRefreshTimer);
}
});
startAutoRefresh();
}
function startAutoRefresh() {
clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(() => {
const logsTab = document.getElementById('tab-logs');
if (logsTab && !logsTab.classList.contains('hidden')) {
fetchLogs();
}
}, 3000);
}
async function fetchLogs() {
const level = document.getElementById('selectLogLevel')?.value || 'ALL';
const box = document.getElementById('logTerminal');
const countBadge = document.getElementById('logCountBadge');
try {
const res = await fetch(`${API_BASE}/api/admin/logs?limit=300&level=${level}`);
const json = await res.json();
if (json.success && Array.isArray(json.data)) {
countBadge.innerText = `${json.data.length} dòng`;
if (json.data.length === 0) {
box.innerHTML = '<div class="text-slate-500 italic">Không có dòng log nào.</div>';
return;
}
box.innerHTML = '';
json.data.forEach(entry => {
let colorClass = 'text-slate-300';
let badgeColor = 'bg-slate-800 text-slate-300';
switch (entry.level) {
case 'INFO':
colorClass = 'text-emerald-400';
badgeColor = 'bg-emerald-950 text-emerald-300 border border-emerald-800';
break;
case 'WARN':
colorClass = 'text-amber-400';
badgeColor = 'bg-amber-950 text-amber-300 border border-amber-800';
break;
case 'ERROR':
colorClass = 'text-rose-400 font-bold';
badgeColor = 'bg-rose-950 text-rose-300 border border-rose-800';
break;
case 'SQL':
colorClass = 'text-cyan-400';
badgeColor = 'bg-cyan-950 text-cyan-300 border border-cyan-800';
break;
}
const div = document.createElement('div');
div.className = 'py-0.5 border-b border-slate-900/60 flex items-start gap-2';
div.innerHTML = `
<span class="text-slate-500 text-[10px] whitespace-nowrap">${entry.timestamp}</span>
<span class="text-[10px] font-bold px-1.5 rounded ${badgeColor}">${entry.level}</span>
<span class="${colorClass} break-all flex-1">${escapeHtml(entry.message)}</span>
`;
box.appendChild(div);
});
}
} catch (err) {
console.error('Lỗi tải log:', err);
}
}
async function clearLogs() {
if (!confirm('Bạn có chắc muốn làm sạch toàn bộ bộ nhớ log không?')) return;
try {
await fetch(`${API_BASE}/api/admin/logs/clear`, { method: 'POST' });
fetchLogs();
showToast('Đã xóa bộ nhớ log!');
} catch (err) {
alert('Lỗi xóa log: ' + err.message);
}
}
// -------------------------------------------------------------------
// UPDATE CHECKER (GITEA)
// -------------------------------------------------------------------
function initUpdateChecker() {
document.getElementById('btnCheckUpdate')?.addEventListener('click', checkUpdate);
document.getElementById('btnApplyUpdate')?.addEventListener('click', applyUpdate);
}
async function checkUpdate() {
const btn = document.getElementById('btnCheckUpdate');
const latestDisplay = document.getElementById('latestVerDisplay');
const notesEl = document.getElementById('updateReleaseNotes');
const statusTitle = document.getElementById('updateStatusTitle');
const applyArea = document.getElementById('applyUpdateArea');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> <span>Đang kiểm tra...</span>';
try {
const res = await fetch(`${API_BASE}/api/update/check`);
const json = await res.json();
if (json.success && json.data) {
const u = json.data;
latestDisplay.innerText = u.latest_version || u.current_version;
if (u.has_update) {
statusTitle.innerHTML = `<span class="text-emerald-600 font-bold"><i class="fa-solid fa-bell"></i> Có bản cập nhật mới: ${u.release_title || u.latest_version}</span>`;
notesEl.innerText = u.release_notes || 'Không có mô tả chi tiết.';
if (u.asset_url) {
currentUpdateAsset = u;
applyArea.classList.remove('hidden');
document.getElementById('updateAssetInfo').innerText = `File: ${u.asset_name} (${(u.asset_size / (1024 * 1024)).toFixed(2)} MB)`;
} else {
applyArea.classList.add('hidden');
notesEl.innerText += '\n\n(Lưu ý: Chưa tìm thấy file nhị phân tương thích trong bản release).';
}
} else {
statusTitle.innerHTML = `<span class="text-slate-600 font-bold"><i class="fa-solid fa-circle-check text-emerald-500"></i> Bạn đang sử dụng phiên bản mới nhất (${u.current_version})</span>`;
notesEl.innerText = u.release_notes || 'Hệ thống đã cập nhật đầy đủ các tính năng mới nhất.';
applyArea.classList.add('hidden');
}
} else {
latestDisplay.innerText = 'Lỗi kết nối';
notesEl.innerText = json.message || 'Không thể kết nối đến máy chủ Gitea.';
}
} catch (err) {
latestDisplay.innerText = 'Lỗi';
notesEl.innerText = err.message;
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-arrows-rotate"></i> <span>Kiểm Tra Cập Nhật</span>';
}
}
async function applyUpdate() {
if (!currentUpdateAsset || !currentUpdateAsset.asset_url) return;
if (!confirm(`Bạn có chắc muốn tự động cập nhật lên phiên bản ${currentUpdateAsset.latest_version}?`)) return;
const btn = document.getElementById('btnApplyUpdate');
btn.disabled = true;
btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i> Đang tải và cài đặt...';
try {
const res = await fetch(`${API_BASE}/api/update/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_url: currentUpdateAsset.asset_url,
version: currentUpdateAsset.latest_version,
}),
});
const json = await res.json();
if (json.success) {
alert(json.message);
location.reload();
} else {
alert('Lỗi cập nhật: ' + json.message);
}
} catch (err) {
alert('Lỗi kết nối cập nhật: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="fa-solid fa-download"></i> <span>Tải &amp; Cập Nhật Tự Động</span>';
}
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
+1 -1
View File
@@ -1,5 +1,5 @@
// DH-HIS Quản Lý Giao Ban Web Client
const API_BASE = window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : '';
const API_BASE = window.location.pathname.startsWith('/web-giao-ban-go') ? '/web-giao-ban-go' : (window.location.pathname.startsWith('/web-giao-ban') ? '/web-giao-ban' : '');
let chartNgoaiTruInstance = null;
let chartNoiTruInstance = null;
let currentChartTypeNgoaiTru = 'khoa';
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+8
View File
@@ -34,6 +34,7 @@
</div>
</div>
<div class="flex items-center gap-2">
<!-- Database Selector -->
<div class="flex items-center gap-2 text-xs bg-slate-900/40 px-3 py-1.5 rounded-lg border border-white/10">
<label for="selectDb" class="text-sky-200 font-medium flex items-center gap-1 whitespace-nowrap">
@@ -47,6 +48,13 @@
<option value="an_dalieu_full_05082026">BV Da Liễu (31 GB)</option>
</select>
</div>
<!-- Admin Config Link -->
<a href="admin.html?v=1.0.2" class="text-xs bg-slate-900/40 hover:bg-slate-800 text-sky-200 hover:text-white px-2.5 py-2 rounded-lg border border-white/10 flex items-center gap-1.5 transition-all" title="Quản trị &amp; Cấu hình hệ thống">
<i class="fa-solid fa-sliders text-cyan-300"></i>
<span class="hidden sm:inline">Quản Trị</span>
</a>
</div>
</div>
</header>
+2 -3
View File
@@ -1,6 +1,5 @@
#!/usr/bin/env bash
# Script khởi động lại dịch vụ web-giao-ban
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
"$DIR/stop.sh"
sleep 1
"$DIR/start.sh"
-533
View File
@@ -1,533 +0,0 @@
const express = require('express');
const cors = require('cors');
const path = require('path');
const multer = require('multer');
const db = require('./db');
const issueService = require('./issueService');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
const router = express.Router();
router.use(express.static(path.join(__dirname, 'public')));
router.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Multer storage for issue attachments
const storage = multer.diskStorage({
destination: function (req, file, cb) {
issueService.ensureDirs();
cb(null, issueService.UPLOADS_DIR);
},
filename: function (req, file, cb) {
const ext = path.extname(file.originalname);
const safeBase = path.basename(file.originalname, ext).replace(/[^a-zA-Z0-9_-]/g, '_');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e6);
cb(null, `${safeBase}_${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage: storage,
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
});
// Ensure giaoban_ghichu table exists
async function ensureTable(dbName) {
try {
await db.query(`
CREATE TABLE IF NOT EXISTS current.giaoban_ghichu (
id SERIAL PRIMARY KEY,
maba VARCHAR(50) NOT NULL,
mabn VARCHAR(20) NOT NULL,
loai_danhsach VARCHAR(20) NOT NULL,
ghichu TEXT,
ngay_capnhat TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unq_giaoban_item_${dbName ? dbName.replace(/[^a-zA-Z0-9]/g, '_') : 'default'} UNIQUE(maba, loai_danhsach)
);
`, [], dbName);
} catch (err) {
// If permission or table exists, ignore
}
}
// 1. Danh sách database
router.get('/api/databases', async (req, res) => {
try {
const list = [
{ id: 'an_omon_full_04052026', name: 'BVĐK Ô Môn - Cần Thơ (33 GB - Đầy đủ khoa phòng chuẩn)' },
{ id: 'tuan_dbgovap_full', name: 'BVĐK Gò Vấp - TP.HCM (65 GB - Dữ liệu lớn)' },
{ id: 'binh_bvq12_062026052026042026032026022026', name: 'BV Quận 12 - TP.HCM (21 GB)' },
{ id: 'bvq12_697', name: 'BV Quận 12 - Bản 697 (17 GB)' },
{ id: 'an_dalieu_full_05082026', name: 'BV Da Liễu (31 GB)' },
];
res.json({ success: true, data: list });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
// Helper function to build namkt and thangkt partition filter
function getThangNamFilter(fromDateStr, toDateStr, prefix = '') {
try {
const f = new Date(fromDateStr);
const t = new Date(toDateStr);
if (isNaN(f.getTime()) || isNaN(t.getTime())) return '';
const thangs = new Set();
const nams = new Set();
let curr = new Date(f.getFullYear(), f.getMonth(), 1);
const end = new Date(t.getFullYear(), t.getMonth(), 1);
let count = 0;
while (curr <= end && count < 24) {
thangs.add(String(curr.getMonth() + 1).padStart(2, '0'));
nams.add(String(curr.getFullYear()));
curr.setMonth(curr.getMonth() + 1);
count++;
}
const p = prefix ? `${prefix}.` : '';
const thangsArr = Array.from(thangs).map(m => `'${m}'`).join(',');
const namsArr = Array.from(nams).map(y => `'${y}'`).join(',');
return ` AND ${p}namkt IN (${namsArr}) AND ${p}thangkt IN (${thangsArr}) `;
} catch (e) {
return '';
}
}
// Helper for Outpatient query
async function getOutpatientData(database, fromDate, toDate) {
const params = [fromDate, toDate];
const fnKb = getThangNamFilter(fromDate, toDate, 'kb');
const fnLs = getThangNamFilter(fromDate, toDate, 'ls');
const fnNone = getThangNamFilter(fromDate, toDate, '');
// A. Thống kê theo Khoa khám bệnh
const sqlKhoa = `
SELECT
COALESCE(dv.madv, 'KHAC') AS madv,
COALESCE(dv.tendv, 'Khoa khác') AS tendv,
COUNT(kb.makb) AS tong_kham,
SUM(CASE WHEN dt.bhyt IN (1, 2) THEN 1 ELSE 0 END) AS bhyt,
SUM(CASE WHEN dt.bhyt IN (0, 4) THEN 1 ELSE 0 END) AS thuphi,
SUM(CASE WHEN kb.dakham = 6 THEN 1 ELSE 0 END) AS chuyenvien
FROM current.khambenh kb
LEFT JOIN current.psdangky dk ON kb.mabn = dk.mabn AND kb.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0
LEFT JOIN current.dmdoituong dt ON dk.madt = dt.madt
LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv
WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb}
AND COALESCE(kb.xoa, 0) = 0
AND COALESCE(kb.dakham, 0) != 0
GROUP BY dv.madv, dv.tendv
ORDER BY tong_kham DESC;
`;
// B. Bệnh án ngoại trú (Mới vs Tái khám)
const sqlBANT = `
SELECT
SUM(CASE WHEN COALESCE(dk.taikham, 0) = 0 THEN 1 ELSE 0 END) AS moi,
SUM(CASE WHEN COALESCE(dk.taikham, 0) = 1 THEN 1 ELSE 0 END) AS taikham,
COUNT(*) AS tong
FROM current.bnnoitru nt
JOIN current.psdangky dk ON nt.mabn = dk.mabn AND nt.makb = dk.makb AND COALESCE(dk.xoa, 0) = 0
WHERE nt.namvien = 0 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayvv BETWEEN $1 AND $2;
`;
// C. Chuyên khoa YDCT
const sqlYDCT = `
SELECT
SUM(CASE WHEN cls.tencls ILIKE '%Điện châm%' THEN ls.soluong ELSE 0 END) AS dien_cham,
SUM(CASE WHEN cls.tencls ILIKE '%Vật lý trị liệu%' OR cls.tencls ILIKE '%VLTL%' OR cls.macls IN ('DI131','DI135') THEN ls.soluong ELSE 0 END) AS vltl,
SUM(CASE WHEN cls.tencls ILIKE '%Sắc thuốc%' THEN ls.soluong ELSE 0 END) AS sac_thuoc,
SUM(CASE WHEN cls.tencls ILIKE '%Cấy chỉ%' OR cls.tencls ILIKE '%Chôn chỉ%' THEN ls.soluong ELSE 0 END) AS cay_chi
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs}
AND COALESCE(ls.xoa, 0) = 0;
`;
// D. Cận lâm sàng
const sqlCLS = `
SELECT
cls.kho,
cls.maloai,
COALESCE(loaicls.tenloai, cls.maloai) AS tenloai,
COUNT(DISTINCT ls.makb) AS so_bn,
COUNT(*) AS so_luot
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.dmloaicls loaicls ON cls.maloai = loaicls.maloai
WHERE ls.ngaykcb BETWEEN $1 AND $2 ${fnLs}
AND COALESCE(ls.xoa, 0) = 0
AND cls.kho IN ('XN', 'HA', 'CN')
GROUP BY cls.kho, cls.maloai, loaicls.tenloai
ORDER BY cls.kho, count(*) DESC;
`;
// E. Top 10 chẩn đoán
const sqlTop10 = `
SELECT maicd, kqcdoan, COUNT(*) AS sl
FROM current.khambenh
WHERE ngaykcb BETWEEN $1 AND $2 ${fnNone}
AND COALESCE(xoa, 0) = 0
AND maicd IS NOT NULL AND maicd != ''
GROUP BY maicd, kqcdoan
ORDER BY sl DESC
LIMIT 10;
`;
// F. Tiêm ngừa
const sqlTiemNgua = `
SELECT COUNT(kb.makb) AS sl
FROM current.khambenh kb
LEFT JOIN current.dmphong ph ON kb.maphong = ph.maphong
LEFT JOIN current.dmdonvi dv ON kb.madv = dv.madv
WHERE kb.ngaykcb BETWEEN $1 AND $2 ${fnKb}
AND COALESCE(kb.xoa, 0) = 0
AND COALESCE(kb.dakham, 0) != 0
AND (
COALESCE(ph.loai_bc_giaoban, 0) = 2 OR
ph.maphong = 'TN' OR
dv.madv = 'PTN' OR
ph.tenphong ILIKE '%Tiêm ngừa%' OR
ph.tenphong ILIKE '%Tiêm chủng%'
);
`;
const [resKhoa, resBANT, resYDCT, resCLS, resTop10, resTiemNgua] = await Promise.all([
db.query(sqlKhoa, params, database),
db.query(sqlBANT, params, database),
db.query(sqlYDCT, params, database),
db.query(sqlCLS, params, database),
db.query(sqlTop10, params, database),
db.query(sqlTiemNgua, params, database),
]);
return {
khoa: resKhoa.rows,
bant: resBANT.rows[0] || { moi: 0, taikham: 0, tong: 0 },
ydct: resYDCT.rows[0] || { dien_cham: 0, vltl: 0, sac_thuoc: 0, cay_chi: 0 },
cls: resCLS.rows,
top10: resTop10.rows,
tiemngua: parseInt(resTiemNgua.rows[0]?.sl || 0, 10),
};
}
// 2. API Báo cáo Giao ban Ngoại trú
router.get('/api/giao-ban-ngoai-tru', async (req, res) => {
try {
const { database, from, to, compareFrom, compareTo } = req.query;
const dbName = database || process.env.PG_DATABASE;
const fromDate = from || '2026-03-01 00:00:00';
const toDate = to || '2026-03-31 23:59:59';
const currentData = await getOutpatientData(dbName, fromDate, toDate);
let compareData = null;
if (compareFrom && compareTo) {
compareData = await getOutpatientData(dbName, compareFrom, compareTo);
}
res.json({
success: true,
data: {
current: currentData,
compare: compareData,
periodCurrent: { from: fromDate, to: toDate },
periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null,
},
});
} catch (error) {
console.error('Error in /api/giao-ban-ngoai-tru:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Helper for Inpatient query
async function getInpatientData(database, fromDate, toDate) {
const params = [fromDate, toDate];
const sqlKhoa = `
SELECT
dv.madv,
dv.tendv,
-- 1. Bệnh cũ (tồn đầu kỳ)
SUM(CASE WHEN nt.ngayvv < $1 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) AS benh_cu,
-- 2. Bệnh mới (vào viện trong kỳ)
SUM(CASE WHEN nt.ngayvv BETWEEN $1 AND $2 THEN 1 ELSE 0 END) AS benh_moi,
-- 3. Xuất viện:
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND nt.ravien = 1 AND COALESCE(nt.makq, '01') NOT IN ('04','05','06') THEN 1 ELSE 0 END) AS on_xin_ve,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.makq IN ('04','06') OR (nt.maxt = '09' AND nt.makq = '04')) THEN 1 ELSE 0 END) AS nang_xin_ve,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 2 OR nt.maxt = '08') THEN 1 ELSE 0 END) AS tron_vien,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 4 OR nt.maxt = '03') THEN 1 ELSE 0 END) AS chuyen_vien,
SUM(CASE WHEN nt.ngayrv BETWEEN $1 AND $2 AND (nt.ravien = 3 OR nt.maxt = '06') THEN 1 ELSE 0 END) AS tu_vong,
-- 4. Bệnh hiện còn (tồn cuối kỳ)
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv > $2) THEN 1 ELSE 0 END) AS benh_hien_con,
-- 5. Bệnh theo dõi nặng
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (COALESCE(nt.benhnang, 0) = 1 OR COALESCE(nt.mabatheodoi, '') != '' OR dv.madv IN ('17','29')) THEN 1 ELSE 0 END) AS benh_theo_doi,
-- 6. Chăm sóc Cấp I
SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) AND (nt.chamsoc ILIKE '%cấp I%' OR nt.chamsoc ILIKE '%cấp 1%' OR nt.chamsoc IN ('1', 'CS I', 'CSI')) THEN 1 ELSE 0 END) AS chamsoc_cap1
FROM current.bnnoitru nt
JOIN current.dmdonvi dv ON nt.madv = dv.madv
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayvv >= ($1::date - INTERVAL '180 days')
GROUP BY dv.madv, dv.tendv
HAVING SUM(CASE WHEN nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1) THEN 1 ELSE 0 END) > 0
ORDER BY benh_moi DESC;
`;
const resKhoa = await db.query(sqlKhoa, params, database);
return {
khoa: resKhoa.rows,
};
}
// 3. API Báo cáo Giao ban Nội trú
router.get('/api/giao-ban-noi-tru', async (req, res) => {
try {
const { database, from, to, compareFrom, compareTo } = req.query;
const dbName = database || process.env.PG_DATABASE;
const fromDate = from || '2026-03-01 00:00:00';
const toDate = to || '2026-03-31 23:59:59';
await ensureTable(dbName);
const currentData = await getInpatientData(dbName, fromDate, toDate);
let compareData = null;
if (compareFrom && compareTo) {
compareData = await getInpatientData(dbName, compareFrom, compareTo);
}
res.json({
success: true,
data: {
current: currentData,
compare: compareData,
periodCurrent: { from: fromDate, to: toDate },
periodCompare: compareFrom && compareTo ? { from: compareFrom, to: compareTo } : null,
},
});
} catch (error) {
console.error('Error in /api/giao-ban-noi-tru:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// 4. API Bảng 3: Danh sách Bệnh nhân Chuyển viện
router.get('/api/danh-sach-chuyen-vien', async (req, res) => {
try {
const { database, from, to, madv } = req.query;
const dbName = database || process.env.PG_DATABASE;
const fromDate = from || '2026-03-01 00:00:00';
const toDate = to || '2026-03-31 23:59:59';
await ensureTable(dbName);
let sqlWhere = `
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND nt.ngayrv BETWEEN $1 AND $2
AND (nt.ravien = 4 OR nt.maxt = '03')
`;
const params = [fromDate, toDate];
if (madv && madv !== 'ALL') {
params.push(madv);
sqlWhere += ` AND nt.madv = $${params.length} `;
}
const sql = `
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
bn.diachi,
to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv,
to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv,
COALESCE(dv.tendv, nt.madv) AS tendv,
nt.kqcdoan AS chandoan_chinh,
nt.kqcdoanp AS chandoan_phu,
COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls,
COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu
FROM current.bnnoitru nt
JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn
LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv
LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'CHUYEN_VIEN'
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb
WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0
) cls_sub ON true
${sqlWhere}
ORDER BY nt.ngayrv DESC
LIMIT 100;
`;
const result = await db.query(sql, params, dbName);
const cleanedRows = result.rows.map(row => ({
...row,
ketqua_cls: db.cleanRtf(row.ketqua_cls)
}));
res.json({ success: true, data: cleanedRows });
} catch (error) {
console.error('Error in /api/danh-sach-chuyen-vien:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// 5. API Bảng 4: Danh sách Bệnh nhân Theo dõi & Chăm sóc Cấp I
router.get('/api/danh-sach-theo-doi', async (req, res) => {
try {
const { database, from, to, madv } = req.query;
const dbName = database || process.env.PG_DATABASE;
const fromDate = from || '2026-03-01 00:00:00';
const toDate = to || '2026-03-31 23:59:59';
await ensureTable(dbName);
let sqlWhere = `
WHERE nt.namvien = 1 AND COALESCE(nt.xoa, 0) = 0
AND (nt.ngayvv <= $2 AND (nt.ravien = 0 OR nt.ngayrv >= $1))
AND (
COALESCE(nt.benhnang, 0) = 1 OR
COALESCE(nt.mabatheodoi, '') != '' OR
nt.chamsoc ILIKE '%cấp I%' OR
nt.chamsoc ILIKE '%cấp 1%' OR
nt.chamsoc IN ('1', 'CS I', 'CSI') OR
nt.madv IN ('17', '29')
)
`;
const params = [fromDate, toDate];
if (madv && madv !== 'ALL') {
params.push(madv);
sqlWhere += ` AND nt.madv = $${params.length} `;
}
const sql = `
SELECT
nt.mabn,
nt.maba,
bn.holot || ' ' || bn.ten AS hoten,
bn.diachi,
to_char(nt.ngayvv, 'DD/MM/YYYY HH24:MI') AS ngayvv,
to_char(nt.ngayrv, 'DD/MM/YYYY HH24:MI') AS ngayrv,
COALESCE(dv.tendv, nt.madv) AS tendv,
nt.kqcdoan AS chandoan_chinh,
nt.kqcdoanp AS chandoan_phu,
COALESCE(nt.chamsoc, 'Theo dõi đặc biệt') AS chamsoc,
COALESCE(cls_sub.ketqua_cls, 'Chưa có kết quả') AS ketqua_cls,
COALESCE(gb.ghichu, nt.ghichu, '') AS ghichu
FROM current.bnnoitru nt
JOIN current.dmbenhnhan bn ON nt.mabn = bn.mabn
LEFT JOIN current.dmdonvi dv ON nt.madv = dv.madv
LEFT JOIN current.giaoban_ghichu gb ON nt.maba = gb.maba AND gb.loai_danhsach = 'THEO_DOI'
LEFT JOIN LATERAL (
SELECT string_agg(DISTINCT cls.tencls || COALESCE(': ' || NULLIF(ha.ketluan,''), ''), '; ') AS ketqua_cls
FROM current.chidinhcls ls
JOIN current.dmcls cls ON ls.macls = cls.macls
LEFT JOIN current.pskhamha ha ON ls.mabn = ha.mabn AND ls.macls = ha.macls AND ls.makb = ha.makb
WHERE ls.mabn = nt.mabn AND ls.makb = nt.makb AND COALESCE(ls.xoa, 0) = 0
) cls_sub ON true
${sqlWhere}
ORDER BY nt.ngayvv DESC
LIMIT 100;
`;
const result = await db.query(sql, params, dbName);
const cleanedRows = result.rows.map(row => ({
...row,
ketqua_cls: db.cleanRtf(row.ketqua_cls)
}));
res.json({ success: true, data: cleanedRows });
} catch (error) {
console.error('Error in /api/danh-sach-theo-doi:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// 6. API Cập nhật Ghi chú trực tiếp từ lưới (Bảng 3 & Bảng 4)
router.post('/api/cap-nhat-ghi-chu', async (req, res) => {
try {
const { maba, mabn, loai, ghichu, database } = req.body;
if (!maba || !loai) {
return res.status(400).json({ success: false, message: 'Thiếu maba hoặc loại danh sách' });
}
const dbName = database || process.env.PG_DATABASE;
await ensureTable(dbName);
const sqlUpsert = `
INSERT INTO current.giaoban_ghichu (maba, mabn, loai_danhsach, ghichu, ngay_capnhat)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (maba, loai_danhsach)
DO UPDATE SET ghichu = EXCLUDED.ghichu, ngay_capnhat = NOW();
`;
await db.query(sqlUpsert, [maba, mabn || '', loai, ghichu || ''], dbName);
// Đồng bộ vào bnnoitru.ghichu nếu cần
try {
await db.query(`UPDATE current.bnnoitru SET ghichu = $1 WHERE maba = $2;`, [ghichu || '', maba], dbName);
} catch (e) {
// ignore
}
res.json({ success: true, message: 'Đã lưu ghi chú thành công!' });
} catch (error) {
console.error('Error in /api/cap-nhat-ghi-chu:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// 7. API Quản lý Issue & Báo lỗi (lưu file và tổng hợp)
router.get('/api/issues', (req, res) => {
try {
const list = issueService.getAllIssues();
res.json({ success: true, data: list });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
});
router.post('/api/issues', upload.single('attachment'), (req, res) => {
try {
const issue = issueService.createIssue(req.body, req.file);
res.json({ success: true, data: issue, message: 'Đã ghi nhận báo lỗi thành công!' });
} catch (error) {
console.error('Error creating issue:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.put('/api/issues/:id', (req, res) => {
try {
const updated = issueService.updateIssue(req.params.id, req.body);
res.json({ success: true, data: updated, message: 'Cập nhật trạng thái issue thành công!' });
} catch (error) {
console.error('Error updating issue:', error);
res.status(500).json({ success: false, message: error.message });
}
});
router.delete('/api/issues/:id', (req, res) => {
try {
issueService.deleteIssue(req.params.id);
res.json({ success: true, message: 'Đã xóa issue thành công!' });
} catch (error) {
console.error('Error deleting issue:', error);
res.status(500).json({ success: false, message: error.message });
}
});
// Mount router on both /web-giao-ban and /
app.use('/web-giao-ban', router);
app.use('/', router);
app.listen(PORT, '0.0.0.0', () => {
console.log(`=======================================================`);
console.log(`[DH-HIS] Phần mềm Quản lý Giao ban hằng ngày (Node.js)`);
console.log(`Server is running at: http://localhost:${PORT}`);
console.log(`=======================================================`);
});
+23 -16
View File
@@ -1,21 +1,28 @@
#!/usr/bin/env bash
# Script khởi động dịch vụ web-giao-ban
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR"
if systemctl is-enabled web-giao-ban.service >/dev/null 2>&1; then
echo "Starting web-giao-ban via systemd..."
sudo systemctl start web-giao-ban
sleep 1
sudo systemctl status web-giao-ban --no-pager
else
PID_FILE="$DIR/.app.pid"
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "web-giao-ban is already running (PID: $(cat "$PID_FILE"))"
# Check if systemd service exists and active
if systemctl is-enabled --quiet web-giao-ban-go 2>/dev/null || systemctl is-active --quiet web-giao-ban-go 2>/dev/null; then
echo "Khởi động qua systemd service: web-giao-ban-go..."
sudo systemctl start web-giao-ban-go
sudo systemctl status web-giao-ban-go --no-pager
exit 0
fi
echo "Starting web-giao-ban in background..."
nohup /usr/bin/node server.js > app.log 2> error.log &
echo $! > "$PID_FILE"
echo "Started with PID: $!"
# Fallback to direct process
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
if [ -n "$PID" ]; then
echo "Ứng dụng Go đang chạy với PID: $PID"
exit 0
fi
echo "Đang khởi động web-giao-ban nền..."
nohup ./web-giao-ban > /dev/null 2>&1 &
sleep 1
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
if [ -n "$PID" ]; then
echo "Khởi động thành công! PID: $PID"
else
echo "Khởi động thất bại, kiểm tra logs/app.log"
fi
+9 -22
View File
@@ -1,27 +1,14 @@
#!/usr/bin/env bash
# Script kiểm tra trạng thái web-giao-ban
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$DIR"
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== TRẠNG THÁI DỊCH VỤ WEB-GIAO-BAN ==="
if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then
sudo systemctl status web-giao-ban --no-pager
else
PID_FILE="$DIR/.app.pid"
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "Status: RUNNING (PID: $(cat "$PID_FILE"))"
else
echo "Status: STOPPED"
fi
if systemctl list-unit-files | grep -q "web-giao-ban-go.service"; then
sudo systemctl status web-giao-ban-go --no-pager
exit 0
fi
echo ""
echo "=== KIỂM TRA KẾT NỐI PORT 3000 ==="
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/web-giao-ban/ || echo "ERR")
echo "HTTP Status: $HTTP_CODE (http://127.0.0.1:3000/web-giao-ban/)"
if [ "$HTTP_CODE" == "200" ]; then
echo "Dịch vụ web-giao-ban đang hoạt động tốt!"
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
if [ -n "$PID" ]; then
echo "● web-giao-ban (Go) is RUNNING (PID: $PID)"
else
echo "Cảnh báo: Không thể truy cập cổng 3000 hoặc mã trả về không phải 200."
echo "○ web-giao-ban (Go) is STOPPED"
fi
+13 -27
View File
@@ -1,32 +1,18 @@
#!/usr/bin/env bash
# Script dừng dịch vụ web-giao-ban
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
cd "$DIR"
#!/bin/bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if systemctl is-active web-giao-ban.service >/dev/null 2>&1; then
echo "Stopping web-giao-ban via systemd..."
sudo systemctl stop web-giao-ban
echo "Stopped."
else
PID_FILE="$DIR/.app.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
echo "Stopping web-giao-ban (PID: $PID)..."
kill "$PID" 2>/dev/null || true
sleep 1
if systemctl is-active --quiet web-giao-ban-go; then
echo "Dừng systemd service web-giao-ban-go..."
sudo systemctl stop web-giao-ban-go
exit 0
fi
rm -f "$PID_FILE"
echo "Stopped."
else
# Kill by port 3000 if running
PID=$(lsof -t -i:3000 2>/dev/null || fuser 3000/tcp 2>/dev/null)
PID=$(pgrep -f "$DIR/web-giao-ban" || true)
if [ -n "$PID" ]; then
echo "Killing process on port 3000 (PID: $PID)..."
kill -9 $PID 2>/dev/null || true
echo "Stopped."
echo "Đang dừng PID: $PID..."
kill $PID
sleep 1
echo "Đã dừng."
else
echo "web-giao-ban is not running."
fi
fi
echo "Ứng dụng không chạy."
fi
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+13
View File
@@ -0,0 +1,13 @@
package tray
import (
_ "embed"
)
//go:embed app.ico
var iconData []byte
// DefaultIconBytes returns the embedded ICO byte slice
func DefaultIconBytes() []byte {
return iconData
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+23
View File
@@ -0,0 +1,23 @@
//go:build !windows
package tray
import (
"os"
"os/signal"
"syscall"
"web-giao-ban-go/logger"
)
func Run(onServerReady func()) {
if onServerReady != nil {
go onServerReady()
}
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
sig := <-sigChan
logger.Info("Nhận tín hiệu dừng hệ thống (%v), đang thoát...", sig)
}
+71
View File
@@ -0,0 +1,71 @@
//go:build windows
package tray
import (
"fmt"
"os/exec"
"github.com/getlantern/systray"
"web-giao-ban-go/config"
"web-giao-ban-go/logger"
)
func openBrowser(url string) {
_ = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
}
func Run(onServerReady func()) {
systray.Run(func() {
cfg := config.Get()
baseURL := fmt.Sprintf("http://localhost:%d", cfg.Server.Port)
adminPath := cfg.Server.AdminPath
if adminPath == "" {
adminPath = "/admin"
}
systray.SetIcon(DefaultIconBytes())
systray.SetTitle("DH-HIS Giao Ban")
systray.SetTooltip(fmt.Sprintf("DH-HIS Quản Lý Giao Ban (Port %d)", cfg.Server.Port))
mTitle := systray.AddMenuItem(fmt.Sprintf("🏥 DH-HIS Giao Ban (%s)", cfg.Updater.CurrentVersion), "Phiên bản phần mềm")
mTitle.Disable()
systray.AddSeparator()
mOpenWeb := systray.AddMenuItem("📊 Mở Báo Cáo Giao Ban", "Mở giao diện báo cáo giao ban bệnh viện")
mOpenAdmin := systray.AddMenuItem("⚙️ Quản Trị Hệ Thống", "Thiết lập kết nối CSDL và tham số")
mOpenLogs := systray.AddMenuItem("📜 Xem Nhật Ký / Log App", "Xem trực tiếp log hoạt động của ứng dụng")
mCheckUpdate := systray.AddMenuItem("🔄 Kiểm Tra Bản Cập Nhật", "Kiểm tra phiên bản mới từ Gitea")
systray.AddSeparator()
mQuit := systray.AddMenuItem("❌ Thoát Ứng Dụng", "Dừng dịch vụ và thoát")
// Start web server in background
if onServerReady != nil {
go onServerReady()
}
// Handle tray menu events
go func() {
for {
select {
case <-mOpenWeb.ClickedCh:
openBrowser(baseURL + "/web-giao-ban-go")
case <-mOpenAdmin.ClickedCh:
openBrowser(baseURL + "/web-giao-ban-go" + adminPath)
case <-mOpenLogs.ClickedCh:
openBrowser(baseURL + "/web-giao-ban-go" + adminPath + "#tab-logs")
case <-mCheckUpdate.ClickedCh:
openBrowser(baseURL + "/web-giao-ban-go" + adminPath + "#tab-update")
case <-mQuit.ClickedCh:
logger.Info("Người dùng chọn Thoát từ khay hệ thống (System Tray)")
systray.Quit()
return
}
}
}()
}, func() {
logger.Info("System Tray đã đóng")
})
}
+190
View File
@@ -0,0 +1,190 @@
package updater
import (
"encoding/json"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"time"
"github.com/minio/selfupdate"
"web-giao-ban-go/config"
"web-giao-ban-go/logger"
)
type GiteaAsset struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
DownloadURL string `json:"browser_download_url"`
}
type GiteaRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
Assets []GiteaAsset `json:"assets"`
}
type CheckResult struct {
HasUpdate bool `json:"has_update"`
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
ReleaseTitle string `json:"release_title"`
ReleaseNotes string `json:"release_notes"`
AssetName string `json:"asset_name"`
AssetURL string `json:"asset_url"`
AssetSize int64 `json:"asset_size"`
}
func CheckUpdate() (*CheckResult, error) {
cfg := config.Get()
if !cfg.Updater.Enabled {
return &CheckResult{HasUpdate: false, CurrentVersion: cfg.Updater.CurrentVersion}, nil
}
baseURL := strings.TrimRight(cfg.Updater.GiteaURL, "/")
if baseURL == "" {
baseURL = "https://i.dh-his.com"
}
owner := cfg.Updater.Owner
if owner == "" || owner == "hdhiswork" {
owner = "ongtrieuhau"
}
repo := cfg.Updater.Repo
if repo == "" || repo == "DUAN" {
repo = "web-giao-ban"
}
apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/releases/latest", baseURL, owner, repo)
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, err
}
token := cfg.DecryptedUpdaterToken
if token == "" {
token, _ = config.Decrypt(cfg.Updater.Token)
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
req.Header.Set("Accept", "application/json")
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return &CheckResult{
HasUpdate: false,
CurrentVersion: cfg.Updater.CurrentVersion,
LatestVersion: cfg.Updater.CurrentVersion,
ReleaseNotes: "Chưa có bản phát hành nào trên Gitea",
}, nil
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Gitea API trả về mã lỗi %d: %s", resp.StatusCode, string(bodyBytes))
}
var rel GiteaRelease
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
return nil, err
}
curVer := cfg.Updater.CurrentVersion
hasNew := isNewerVersion(rel.TagName, curVer)
// Pick asset according to OS
var matchedAsset *GiteaAsset
for _, a := range rel.Assets {
lowerName := strings.ToLower(a.Name)
if runtime.GOOS == "windows" {
if strings.HasSuffix(lowerName, ".exe") {
matchedAsset = &a
break
}
} else {
if strings.Contains(lowerName, "linux") || (!strings.HasSuffix(lowerName, ".exe") && !strings.HasSuffix(lowerName, ".zip")) {
matchedAsset = &a
break
}
}
}
res := &CheckResult{
HasUpdate: hasNew,
CurrentVersion: curVer,
LatestVersion: rel.TagName,
ReleaseTitle: rel.Name,
ReleaseNotes: rel.Body,
}
if matchedAsset != nil {
res.AssetName = matchedAsset.Name
res.AssetURL = matchedAsset.DownloadURL
res.AssetSize = matchedAsset.Size
}
return res, nil
}
func isNewerVersion(latest, current string) bool {
latest = strings.TrimPrefix(strings.TrimSpace(latest), "v")
current = strings.TrimPrefix(strings.TrimSpace(current), "v")
if latest == "" {
return false
}
if current == "" {
return true
}
return latest != current
}
func ApplyUpdate(downloadURL string) error {
cfg := config.Get()
logger.Info("Bắt đầu tải bản cập nhật từ: %s", downloadURL)
req, err := http.NewRequest("GET", downloadURL, nil)
if err != nil {
return err
}
token := cfg.DecryptedUpdaterToken
if token == "" {
token, _ = config.Decrypt(cfg.Updater.Token)
}
if token != "" {
req.Header.Set("Authorization", "token "+token)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tải bản cập nhật thất bại với HTTP status %d", resp.StatusCode)
}
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
logger.Error("Self-update thất bại: %v", err)
return err
}
logger.Info("Đã cập nhật binary ứng dụng thành công! Vui lòng khởi động lại ứng dụng.")
return nil
}
View File