update docs

This commit is contained in:
2026-07-22 14:11:18 +07:00
parent a836645728
commit 166276d19b
14 changed files with 1308 additions and 17 deletions

View File

@@ -7,6 +7,9 @@ DOCKER_NETWORK=robot-installer-net
WEB_SERVER_UPLOADS_DIR=./uploads
MAX_UPLOAD_BYTES=1073741824
AGENT_MAX_UPLOAD_BYTES=1073741824
DOCUMENT_MAX_UPLOAD_BYTES=52428800
DOCUMENT_MAX_CONTENT_BYTES=2097152
DOCUMENT_MAX_CONTENT_CHARS=500000
SQLSERVER_HOST=172.20.235.176
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=RobotInstaller

View File

@@ -18,7 +18,7 @@ COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
COPY docker-entrypoint.sh ./docker-entrypoint.sh
RUN mkdir -p uploads/packages/agent \
RUN mkdir -p uploads/packages/agent uploads/documents \
&& chown -R node:node uploads \
&& chmod +x docker-entrypoint.sh

View File

@@ -9,6 +9,7 @@ IF OBJECT_ID(N'dbo.ApplicationPackages', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.PackageVersions', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Applications', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Packages', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Documents', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.EmailConfirmationTokens', N'U') IS NOT NULL
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
BEGIN
@@ -54,6 +55,37 @@ CREATE TABLE dbo.EmailConfirmationTokens
);
GO
CREATE TABLE dbo.Documents
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
Title NVARCHAR(200) NOT NULL,
Category NVARCHAR(50) NOT NULL
CONSTRAINT DF_Documents_Category DEFAULT N'other',
Summary NVARCHAR(1000) NULL,
Content NVARCHAR(MAX) NULL,
FilePath NVARCHAR(1000) NULL,
OriginalFileName NVARCHAR(260) NULL,
MimeType NVARCHAR(200) NULL,
FileSizeBytes BIGINT NULL,
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
CONSTRAINT FK_Documents_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
CONSTRAINT CK_Documents_Category CHECK (
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
),
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
CONSTRAINT CK_Documents_HasReadableContent CHECK (
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
)
);
GO
CREATE TABLE dbo.Packages
(
Id UNIQUEIDENTIFIER NOT NULL
@@ -164,6 +196,13 @@ CREATE INDEX IX_EmailConfirmationTokens_UserId
ON dbo.EmailConfirmationTokens(UserId);
GO
CREATE INDEX IX_Documents_Category_UpdatedAt
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
CREATE INDEX IX_Documents_CreatedByUserId
ON dbo.Documents(CreatedByUserId);
GO
CREATE UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);

View File

@@ -0,0 +1,67 @@
USE [RobotInstaller];
GO
SET ANSI_NULLS ON;
SET QUOTED_IDENTIFIER ON;
GO
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Documents
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
Title NVARCHAR(200) NOT NULL,
Category NVARCHAR(50) NOT NULL
CONSTRAINT DF_Documents_Category DEFAULT N'other',
Summary NVARCHAR(1000) NULL,
Content NVARCHAR(MAX) NULL,
FilePath NVARCHAR(1000) NULL,
OriginalFileName NVARCHAR(260) NULL,
MimeType NVARCHAR(200) NULL,
FileSizeBytes BIGINT NULL,
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
CONSTRAINT FK_Documents_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
CONSTRAINT CK_Documents_Category CHECK (
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
),
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
CONSTRAINT CK_Documents_HasReadableContent CHECK (
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
)
);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_Documents_Category_UpdatedAt'
AND object_id = OBJECT_ID(N'dbo.Documents')
)
BEGIN
CREATE INDEX IX_Documents_Category_UpdatedAt
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
END;
GO
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_Documents_CreatedByUserId'
AND object_id = OBJECT_ID(N'dbo.Documents')
)
BEGIN
CREATE INDEX IX_Documents_CreatedByUserId
ON dbo.Documents(CreatedByUserId);
END;
GO
PRINT N'RobotInstaller documents schema is ready.';
GO

View File

@@ -28,6 +28,7 @@ Không lưu mật khẩu thật vào file cấu hình. Khi chạy local, tạo f
| `dbo.PackageVersions` | Các version của từng package |
| `dbo.Applications` | App được đóng gói từ nhiều package |
| `dbo.ApplicationPackages` | Liên kết app-package, có thể chọn version cụ thể |
| `dbo.Documents` | Nội dung tài liệu và metadata file đính kèm |
| `dbo.Notifications` | Thông báo riêng cho từng user và thông báo hệ thống do Admin đăng |
## Ràng buộc quan trọng
@@ -65,13 +66,14 @@ sqlcmd -S 172.20.235.176 -U sa -b -i .\database\01_create_database.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\02_schema.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\03_views.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\04_notifications.sql
sqlcmd -S 172.20.235.176 -U sa -d RobotInstaller -b -i .\database\05_documents.sql
```
Chạy các lệnh trên từ thư mục `web-server`.
Khi dùng `sqlcmd` để seed/test dữ liệu, thêm `-I` hoặc bật `SET QUOTED_IDENTIFIER ON` vì schema có filtered index cho ràng buộc một latest version trên mỗi package.
`04_notifications.sql` migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy file này; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại.
`04_notifications.sql` `05_documents.sql` là các migration chỉ bổ sung bảng/index và có thể chạy lặp lại. Với database đang hoạt động, chỉ chạy các migration còn thiếu; không chạy lại `02_schema.sql` vì script schema gốc chủ động dừng khi phát hiện bảng đã tồn tại. Web server cũng tự bảo đảm bảng `Documents` tồn tại khi chức năng tài liệu được truy cập.
## Luồng dữ liệu đề xuất

View File

@@ -1,7 +1,7 @@
#!/bin/sh
set -e
mkdir -p /app/uploads/packages/agent
mkdir -p /app/uploads/packages/agent /app/uploads/documents
chown -R node:node /app/uploads
exec su-exec node "$@"

View File

@@ -1724,12 +1724,155 @@ tbody tr:hover td.action-col {
min-width: 980px;
}
.documents-table {
min-width: 980px;
}
.document-title-cell {
max-width: 360px;
min-width: 240px;
}
.document-title-cell .table-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-file-name {
color: var(--text-primary);
display: block;
font-size: 12px;
font-weight: 600;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-detail-grid {
display: grid;
flex: 1;
gap: 16px;
grid-template-columns: 320px minmax(0, 1fr);
min-height: 0;
}
.document-meta-panel,
.document-reader-panel {
min-width: 0;
}
.document-reader {
display: flex;
flex: 1;
flex-direction: column;
gap: 20px;
min-height: 0;
overflow: auto;
padding: 20px;
}
.document-copy {
color: var(--text-primary);
font-family: "Montserrat Variable", Montserrat, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.75;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.document-preview-block {
border-top: 1px solid var(--border-subtle);
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 16px;
}
.document-section-heading {
align-items: center;
color: var(--text-secondary);
display: flex;
font-size: 12px;
gap: 6px;
}
.document-section-heading .material-symbols-outlined {
color: var(--brand-primary);
font-size: 18px;
}
.document-file-preview {
background: var(--neutral-50);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
min-height: 540px;
width: 100%;
}
.document-image-preview {
align-self: center;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
height: auto;
max-height: 70vh;
max-width: 100%;
object-fit: contain;
}
.document-attachment-state {
align-items: center;
background: var(--surface-raised);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
display: grid;
gap: 14px;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 16px;
}
.document-attachment-state > .material-symbols-outlined {
color: var(--brand-primary);
font-size: 34px;
}
.document-attachment-state strong,
.document-attachment-state p {
display: block;
overflow-wrap: anywhere;
}
.document-attachment-state p {
color: var(--text-secondary);
font-size: 12px;
margin-top: 3px;
}
.document-remove-option {
align-items: flex-start;
background: var(--semantic-warning-bg);
border: 1px solid #f3d28c;
border-radius: var(--radius-md);
color: var(--semantic-warning);
display: flex;
font-size: 12px;
gap: 8px;
padding: 10px 12px;
}
.document-remove-option input {
flex: 0 0 auto;
margin-top: 3px;
}
@media (max-width: 1100px) {
.dashboard-grid,
.detail-grid,
.builder-layout,
.agent-layout,
.users-layout {
.users-layout,
.document-detail-grid {
grid-template-columns: 1fr;
}
@@ -1740,9 +1883,14 @@ tbody tr:hover td.action-col {
.detail-grid,
.builder-layout,
.agent-layout,
.users-layout {
.users-layout,
.document-detail-grid {
overflow: auto;
}
.document-reader-panel {
min-height: 620px;
}
}
@media (max-width: 900px) {
@@ -1833,6 +1981,16 @@ tbody tr:hover td.action-col {
gap: 8px;
}
.document-attachment-state {
align-items: flex-start;
grid-template-columns: auto minmax(0, 1fr);
}
.document-attachment-state .btn {
grid-column: 1 / -1;
width: 100%;
}
.modal-backdrop {
align-items: flex-end;
}

View File

@@ -15,6 +15,7 @@
database_off: 'database_warning_20_regular.svg',
delete: 'delete_20_regular.svg',
deployed_code: 'cube_24_regular.svg',
description: 'document_24_regular.svg',
download: 'arrow_download_20_regular.svg',
draft: 'document_24_regular.svg',
edit: 'edit_20_regular.svg',
@@ -22,6 +23,7 @@
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
group: 'people_24_regular.svg',
inventory_2: 'box_24_regular.svg',
library_books: 'library_24_regular.svg',
link_off: 'link_dismiss_20_regular.svg',
login: 'arrow_enter_20_regular.svg',
logout: 'sign_out_20_regular.svg',
@@ -30,6 +32,7 @@
menu: 'navigation_20_regular.svg',
notifications: 'alert_20_regular.svg',
notifications_none: 'alert_off_20_regular.svg',
note_add: 'document_add_24_regular.svg',
person_add: 'person_add_20_regular.svg',
precision_manufacturing: 'bot_24_filled.svg',
save: 'save_20_regular.svg',
@@ -451,7 +454,8 @@
createdAt: row.dataset.userCreatedAt || '',
updatedAt: row.dataset.userUpdatedAt || '',
packageCount: row.dataset.userPackageCount || '0',
applicationCount: row.dataset.userApplicationCount || '0'
applicationCount: row.dataset.userApplicationCount || '0',
documentCount: row.dataset.userDocumentCount || '0'
};
}
@@ -561,7 +565,10 @@
setText('[data-user-detail="status"]', user.status);
setText('[data-user-detail="createdAt"]', user.createdAt);
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
setText('[data-user-detail="ownedData"]', `${user.packageCount} packages, ${user.applicationCount} apps`);
setText(
'[data-user-detail="ownedData"]',
`${user.packageCount} packages, ${user.applicationCount} apps, ${user.documentCount} tài liệu`
);
openModal('userDetailModal');
}

View File

@@ -8,6 +8,7 @@ const path = require('path');
const express = require('express');
const multer = require('multer');
const repository = require('./src/repository');
const { normalizeDocumentFileName } = require('./src/document-file-name');
const notificationRepository = require('./src/notification-repository');
const mailer = require('./src/mailer');
const { closePool, getPool } = require('./src/db');
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
const app = express();
const port = Number(process.env.PORT || 3000);
const uploadDir = path.join(__dirname, 'uploads', 'packages');
const documentUploadDir = path.join(__dirname, 'uploads', 'documents');
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
const agentDebianPackageName = 'local-installer-agent';
const authCookieName = 'robot_installer_session';
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
const documentCategories = Object.freeze([
{ id: 'introduction', label: 'Giới thiệu' },
{ id: 'guide', label: 'Hướng dẫn' },
{ id: 'user-guide', label: 'Hướng dẫn sử dụng' },
{ id: 'technical', label: 'Tài liệu kỹ thuật' },
{ id: 'policy', label: 'Quy trình / chính sách' },
{ id: 'other', label: 'Khác' }
]);
const documentCategoryIds = new Set(documentCategories.map((category) => category.id));
const allowedDocumentExtensions = new Set([
'.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md',
'.png', '.jpg', '.jpeg', '.webp',
'.ppt', '.pptx', '.xls', '.xlsx'
]);
const documentTextExtensions = new Set(['.txt', '.md']);
const documentImageExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp']);
const documentMaxContentChars = Number(process.env.DOCUMENT_MAX_CONTENT_CHARS || 500000);
const agentVersionCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base'
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
}));
fs.mkdirSync(uploadDir, { recursive: true });
fs.mkdirSync(documentUploadDir, { recursive: true });
fs.mkdirSync(agentPackageDir, { recursive: true });
const navItems = [
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
{ id: 'documents', label: 'Tài liệu', href: '/documents', icon: 'library_books' },
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
@@ -123,6 +144,24 @@ const upload = multer({
}
});
const documentStorage = multer.diskStorage({
destination: documentUploadDir,
filename(req, file, callback) {
const normalizedName = normalizeDocumentFileName(file.originalname)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9._-]/g, '-')
.replace(/-+/g, '-')
.toLowerCase();
const extension = path.extname(normalizedName).slice(0, 20);
const baseName = normalizedName.slice(0, normalizedName.length - extension.length).slice(0, 180) || 'document';
const safeName = `${baseName}${extension}`;
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
callback(null, `${suffix}-${safeName}`);
}
});
const agentUpload = multer({
storage: agentStorage,
limits: {
@@ -131,6 +170,15 @@ const agentUpload = multer({
}
});
const documentUpload = multer({
storage: documentStorage,
limits: {
...multipartLimits,
fieldSize: Number(process.env.DOCUMENT_MAX_CONTENT_BYTES || 2 * 1024 * 1024),
fileSize: Number(process.env.DOCUMENT_MAX_UPLOAD_BYTES || 50 * 1024 * 1024)
}
});
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
@@ -227,6 +275,9 @@ function helpers() {
if (type === 'docker') return 'badge-info';
if (type === 'apt') return 'badge-warning';
return 'badge-primary';
},
documentCategoryLabel(categoryId) {
return documentCategories.find((category) => category.id === categoryId)?.label || 'Khác';
}
};
}
@@ -1037,6 +1088,128 @@ async function getArtifactFromUpload(file) {
};
}
function normalizeDocumentCategory(value) {
const category = String(value || '').trim().toLowerCase();
return documentCategoryIds.has(category) ? category : '';
}
function getDocumentUploadValidationMessage(file) {
if (!file) return '';
const originalFileName = normalizeDocumentFileName(file.originalname);
if (originalFileName.length > 260) {
return 'Tên file tài liệu không được vượt quá 260 ký tự.';
}
const extension = path.extname(originalFileName).toLowerCase();
if (!allowedDocumentExtensions.has(extension)) {
return 'Định dạng tài liệu chưa được hỗ trợ. Hãy dùng PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint hoặc Excel.';
}
return '';
}
function getDocumentArtifact(file) {
if (!file) {
return {
filePath: null,
originalFileName: null,
mimeType: null,
fileSizeBytes: null
};
}
return {
filePath: `/uploads/documents/${file.filename}`,
originalFileName: normalizeDocumentFileName(file.originalname),
mimeType: file.mimetype || 'application/octet-stream',
fileSizeBytes: file.size
};
}
function getLocalDocumentFilePath(filePath) {
const storedPath = String(filePath || '').trim();
const prefix = '/uploads/documents/';
if (!storedPath.startsWith(prefix)) return null;
let relativePath;
try {
relativePath = decodeURIComponent(storedPath.slice(prefix.length));
} catch {
return null;
}
if (!relativePath || relativePath.includes('\0')) return null;
const documentRoot = path.resolve(documentUploadDir);
const localPath = path.resolve(documentRoot, relativePath);
const pathDelta = path.relative(documentRoot, localPath);
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
return null;
}
return localPath;
}
async function removeStoredDocumentFile(filePath) {
const localPath = getLocalDocumentFilePath(filePath);
if (!localPath) return;
try {
await fsp.unlink(localPath);
} catch (error) {
if (error.code !== 'ENOENT') {
console.warn(`Cannot remove document file ${localPath}:`, error.message);
}
}
}
function getDocumentPreviewKind(document) {
if (!document?.filePath) return '';
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
if (extension === '.pdf') return 'pdf';
if (documentImageExtensions.has(extension)) return 'image';
if (documentTextExtensions.has(extension)) return 'text';
return '';
}
function getDocumentResponseMimeType(document) {
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
const knownTypes = {
'.pdf': 'application/pdf',
'.txt': 'text/plain; charset=utf-8',
'.md': 'text/plain; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
};
return knownTypes[extension] || document.mimeType || 'application/octet-stream';
}
function setDocumentContentDisposition(res, disposition, fileName) {
const originalName = String(fileName || 'document')
.replace(/[\r\n]/g, '')
.slice(0, 260);
const asciiName = originalName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^\x20-\x7E]/g, '_')
.replace(/["\\;]/g, '_') || 'document';
const encodedName = encodeURIComponent(originalName)
.replace(/['()]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
res.setHeader(
'Content-Disposition',
`${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
);
}
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
@@ -1568,7 +1741,7 @@ exit 1
});
app.use(requireAuthenticated);
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
app.use('/uploads/packages', express.static(uploadDir));
app.get('/api/notifications', asyncRoute(async (req, res) => {
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
@@ -2309,6 +2482,228 @@ app.get('/applications/:id', asyncRoute(async (req, res) => {
);
}));
app.get('/documents', asyncRoute(async (req, res) => {
const [pageData, documents] = await Promise.all([
repository.getPageData(req.currentUser),
repository.listDocuments()
]);
res.render(
'documents',
viewModel(req, 'documents', 'Tài liệu', pageData, { documents, documentCategories })
);
}));
app.post('/documents', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
try {
const title = String(req.body.title || '').trim();
const category = normalizeDocumentCategory(req.body.category);
const summary = String(req.body.summary || '').trim();
const content = String(req.body.content || '').trim();
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
if (!title || title.length > 200) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
return;
}
if (!category) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
return;
}
if (summary.length > 1000 || content.length > documentMaxContentChars) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
return;
}
if (fileValidationMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', fileValidationMessage);
return;
}
if (!content && !req.file) {
redirectWithNotice(res, '/documents', 'warning', 'Hãy nhập nội dung hoặc đính kèm ít nhất một file tài liệu.');
return;
}
const artifact = getDocumentArtifact(req.file);
await repository.createDocument({
title,
category,
summary,
content,
...artifact,
createdByUserId: req.currentUser.id
});
redirectWithNotice(res, '/documents', 'success', 'Đã lưu tài liệu mới.');
} catch (error) {
await removeUploadedFile(req.file);
throw error;
}
}));
app.get('/documents/:id/file', asyncRoute(async (req, res) => {
const document = await repository.getDocumentById(req.params.id);
const localPath = document ? getLocalDocumentFilePath(document.filePath) : null;
if (!document || !localPath) {
res.status(404).type('text/plain').send('Không tìm thấy file tài liệu.');
return;
}
try {
await fsp.access(localPath, fs.constants.R_OK);
} catch (error) {
if (error.code === 'ENOENT') {
res.status(404).type('text/plain').send('File tài liệu không còn tồn tại trên máy chủ.');
return;
}
throw error;
}
const previewKind = getDocumentPreviewKind(document);
const shouldDownload = req.query.download === '1' || !previewKind;
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Type', getDocumentResponseMimeType(document));
setDocumentContentDisposition(
res,
shouldDownload ? 'attachment' : 'inline',
document.originalFileName || path.basename(localPath)
);
res.sendFile(localPath);
}));
app.post('/documents/:id/edit', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
const documentId = String(req.params.id || '').trim();
try {
const existingDocument = await repository.getDocumentById(documentId);
if (!existingDocument) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
return;
}
const title = String(req.body.title || '').trim();
const category = normalizeDocumentCategory(req.body.category);
const summary = String(req.body.summary || '').trim();
const content = String(req.body.content || '').trim();
const removeAttachment = req.body.removeAttachment === '1';
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
const willHaveAttachment = Boolean(req.file) || (Boolean(existingDocument.filePath) && !removeAttachment);
if (!title || title.length > 200) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
return;
}
if (!category) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
return;
}
if (summary.length > 1000 || content.length > documentMaxContentChars) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
return;
}
if (fileValidationMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/documents/${documentId}`, 'warning', fileValidationMessage);
return;
}
if (!content && !willHaveAttachment) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tài liệu phải có nội dung hoặc file đính kèm.');
return;
}
const artifact = getDocumentArtifact(req.file);
const replaceAttachment = Boolean(req.file) || removeAttachment;
const result = await repository.updateDocument({
documentId,
title,
category,
summary,
content,
replaceAttachment,
...artifact
});
if (!result) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
return;
}
if (
result.attachmentChanged
&& result.previousFilePath
&& result.previousFilePath !== result.currentFilePath
) {
await removeStoredDocumentFile(result.previousFilePath);
}
redirectWithNotice(res, `/documents/${documentId}`, 'success', 'Đã cập nhật tài liệu.');
} catch (error) {
await removeUploadedFile(req.file);
throw error;
}
}));
app.post('/documents/:id/delete', asyncRoute(async (req, res) => {
const documentId = String(req.params.id || '').trim();
if (!isUuid(documentId)) {
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần xóa.');
return;
}
const deletedDocument = await repository.deleteDocument(documentId);
if (deletedDocument?.filePath) {
await removeStoredDocumentFile(deletedDocument.filePath);
}
redirectWithNotice(
res,
'/documents',
deletedDocument ? 'success' : 'warning',
deletedDocument ? 'Đã xóa tài liệu và file đính kèm.' : 'Không tìm thấy tài liệu cần xóa.'
);
}));
app.get('/documents/:id', asyncRoute(async (req, res) => {
const [pageData, document] = await Promise.all([
repository.getPageData(req.currentUser),
repository.getDocumentById(req.params.id)
]);
if (!document) {
res.status(404).render('not-found', viewModel(req, 'documents', 'Không tìm thấy', pageData));
return;
}
res.render(
'document-detail',
viewModel(req, 'documents', document.title, pageData, {
document,
documentCategories,
documentPreviewKind: getDocumentPreviewKind(document)
})
);
}));
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
const [pageData, users] = await Promise.all([
repository.getPageData(req.currentUser),
@@ -2447,7 +2842,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
);
} catch (error) {
if (error.code === 'USER_HAS_OWNED_DATA') {
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package hoặc application. Hãy khóa tài khoản nếu cần.');
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package, application hoặc tài liệu. Hãy khóa tài khoản nếu cần.');
return;
}
@@ -2472,7 +2867,9 @@ app.use(async (error, req, res, next) => {
LIMIT_PART_COUNT: 'Biểu mẫu upload có quá nhiều thành phần.',
LIMIT_UNEXPECTED_FILE: 'Trường file upload không hợp lệ.'
};
const returnPath = req.path.startsWith('/agent/') ? '/agent' : '/packages';
const returnPath = req.path.startsWith('/agent/')
? '/agent'
: (req.path.startsWith('/documents') ? '/documents' : '/packages');
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
redirectWithNotice(

View File

@@ -0,0 +1,23 @@
function normalizeDocumentFileName(value) {
const fileName = String(value || '');
if (!fileName || !/[\u00C2-\u00C5\u00E1\u00E2]/.test(fileName)) return fileName;
const codePoints = Array.from(fileName, (character) => character.codePointAt(0));
if (codePoints.some((codePoint) => codePoint > 0xff)) return fileName;
const legacyBytes = Buffer.from(codePoints);
const decodedName = legacyBytes.toString('utf8');
if (
decodedName.includes('\uFFFD')
|| !Buffer.from(decodedName, 'utf8').equals(legacyBytes)
) {
return fileName;
}
return decodedName;
}
module.exports = {
normalizeDocumentFileName
};

View File

@@ -1,5 +1,6 @@
const crypto = require('crypto');
const { sql, getPool } = require('./db');
const { normalizeDocumentFileName } = require('./document-file-name');
const PASSWORD_HASH_PREFIX = 'pbkdf2';
const PASSWORD_HASH_ITERATIONS = 120000;
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
let emailConfirmationSchemaPromise;
let applicationOpenUrlSchemaPromise;
let packageTypeSchemaPromise;
let documentSchemaPromise;
function padDatePart(value) {
return String(value).padStart(2, '0');
@@ -167,7 +169,8 @@ function mapUserRow(row) {
createdAt: formatDate(row.CreatedAt),
updatedAt: formatDate(row.UpdatedAt),
packageCount: Number(row.PackageCount || 0),
applicationCount: Number(row.ApplicationCount || 0)
applicationCount: Number(row.ApplicationCount || 0),
documentCount: Number(row.DocumentCount || 0)
};
}
@@ -190,7 +193,7 @@ function duplicateApplicationError() {
}
function userHasOwnedDataError() {
const error = new Error('User owns packages or applications.');
const error = new Error('User owns packages, applications, or documents.');
error.code = 'USER_HAS_OWNED_DATA';
return error;
}
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
return packageTypeSchemaPromise;
}
async function ensureDocumentSchema() {
if (!documentSchemaPromise) {
documentSchemaPromise = getPool().then((pool) => pool.request().query(`
IF OBJECT_ID(N'dbo.Documents', N'U') IS NULL
BEGIN
CREATE TABLE dbo.Documents
(
Id UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Documents PRIMARY KEY CLUSTERED
CONSTRAINT DF_Documents_Id DEFAULT NEWSEQUENTIALID(),
Title NVARCHAR(200) NOT NULL,
Category NVARCHAR(50) NOT NULL
CONSTRAINT DF_Documents_Category DEFAULT N'other',
Summary NVARCHAR(1000) NULL,
Content NVARCHAR(MAX) NULL,
FilePath NVARCHAR(1000) NULL,
OriginalFileName NVARCHAR(260) NULL,
MimeType NVARCHAR(200) NULL,
FileSizeBytes BIGINT NULL,
CreatedByUserId UNIQUEIDENTIFIER NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
CONSTRAINT DF_Documents_CreatedAt DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIME2(3) NULL,
CONSTRAINT FK_Documents_CreatedByUser
FOREIGN KEY (CreatedByUserId) REFERENCES dbo.Users(Id),
CONSTRAINT CK_Documents_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0),
CONSTRAINT CK_Documents_Category CHECK (
Category IN (N'introduction', N'guide', N'user-guide', N'technical', N'policy', N'other')
),
CONSTRAINT CK_Documents_FileSizeBytes CHECK (FileSizeBytes IS NULL OR FileSizeBytes >= 0),
CONSTRAINT CK_Documents_HasReadableContent CHECK (
NULLIF(LTRIM(RTRIM(Content)), N'') IS NOT NULL OR FilePath IS NOT NULL
)
);
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_Documents_Category_UpdatedAt'
AND object_id = OBJECT_ID(N'dbo.Documents')
)
BEGIN
CREATE INDEX IX_Documents_Category_UpdatedAt
ON dbo.Documents(Category, UpdatedAt DESC, CreatedAt DESC);
END;
IF NOT EXISTS (
SELECT 1
FROM sys.indexes
WHERE name = N'IX_Documents_CreatedByUserId'
AND object_id = OBJECT_ID(N'dbo.Documents')
)
BEGIN
CREATE INDEX IX_Documents_CreatedByUserId
ON dbo.Documents(CreatedByUserId);
END;
`));
}
return documentSchemaPromise;
}
function normalizePackageStatus(isActive) {
return isActive ? 'Active' : 'Archived';
}
@@ -438,6 +504,28 @@ function mapApplicationPackageRow(row) {
};
}
function mapDocumentRow(row) {
if (!row) return null;
return {
id: String(row.Id),
title: row.Title,
category: row.Category,
summary: row.Summary || '',
content: row.Content || '',
hasContent: row.HasContent === undefined ? Boolean(row.Content) : Boolean(row.HasContent),
filePath: row.FilePath || '',
originalFileName: normalizeDocumentFileName(row.OriginalFileName || ''),
mimeType: row.MimeType || '',
fileSizeBytes: Number(row.FileSizeBytes || 0),
fileSize: formatFileSize(row.FileSizeBytes),
createdByUserId: row.CreatedByUserId ? String(row.CreatedByUserId) : '',
createdBy: row.CreatedByUsername || '',
createdAt: formatDate(row.CreatedAt),
updatedAt: formatDate(row.UpdatedAt || row.CreatedAt)
};
}
function isLoopbackHost(hostname) {
const host = String(hostname || '').toLowerCase();
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
@@ -494,23 +582,27 @@ async function getUserById(id) {
}
async function getUserOwnershipCounts(userId) {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request()
.input('UserId', sql.UniqueIdentifier, userId)
.query(`
SELECT
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount;
(SELECT COUNT_BIG(*) FROM dbo.Applications WHERE CreatedByUserId = @UserId) AS ApplicationCount,
(SELECT COUNT_BIG(*) FROM dbo.Documents WHERE CreatedByUserId = @UserId) AS DocumentCount;
`);
const row = result.recordset[0];
return {
packageCount: Number(row.PackageCount || 0),
applicationCount: Number(row.ApplicationCount || 0)
applicationCount: Number(row.ApplicationCount || 0),
documentCount: Number(row.DocumentCount || 0)
};
}
async function listUsers() {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request().query(`
SELECT
@@ -523,7 +615,8 @@ async function listUsers() {
u.CreatedAt,
u.UpdatedAt,
package_count.PackageCount,
application_count.ApplicationCount
application_count.ApplicationCount,
document_count.DocumentCount
FROM dbo.Users AS u
OUTER APPLY (
SELECT COUNT_BIG(*) AS PackageCount
@@ -535,6 +628,11 @@ async function listUsers() {
FROM dbo.Applications AS a
WHERE a.CreatedByUserId = u.Id
) AS application_count
OUTER APPLY (
SELECT COUNT_BIG(*) AS DocumentCount
FROM dbo.Documents AS d
WHERE d.CreatedByUserId = u.Id
) AS document_count
ORDER BY u.CreatedAt DESC, u.Username ASC;
`);
@@ -865,7 +963,7 @@ async function updateUser(input) {
async function deleteUser(userId) {
const counts = await getUserOwnershipCounts(userId);
if (counts.packageCount > 0 || counts.applicationCount > 0) {
if (counts.packageCount > 0 || counts.applicationCount > 0 || counts.documentCount > 0) {
throw userHasOwnedDataError();
}
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
return result.recordset.length > 0;
}
async function listDocuments() {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request().query(`
SELECT
d.Id,
d.Title,
d.Category,
d.Summary,
CASE WHEN NULLIF(LTRIM(RTRIM(d.Content)), N'') IS NULL THEN 0 ELSE 1 END AS HasContent,
d.FilePath,
d.OriginalFileName,
d.MimeType,
d.FileSizeBytes,
d.CreatedByUserId,
d.CreatedAt,
d.UpdatedAt,
u.Username AS CreatedByUsername
FROM dbo.Documents AS d
INNER JOIN dbo.Users AS u
ON u.Id = d.CreatedByUserId
ORDER BY COALESCE(d.UpdatedAt, d.CreatedAt) DESC, d.Title ASC;
`);
return result.recordset.map(mapDocumentRow);
}
async function getDocumentById(documentId) {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.NVarChar(100), String(documentId || '').trim())
.query(`
SELECT TOP (1)
d.Id,
d.Title,
d.Category,
d.Summary,
d.Content,
d.FilePath,
d.OriginalFileName,
d.MimeType,
d.FileSizeBytes,
d.CreatedByUserId,
d.CreatedAt,
d.UpdatedAt,
u.Username AS CreatedByUsername
FROM dbo.Documents AS d
INNER JOIN dbo.Users AS u
ON u.Id = d.CreatedByUserId
WHERE CONVERT(NVARCHAR(36), d.Id) = @Id;
`);
return mapDocumentRow(result.recordset[0]);
}
async function createDocument(input) {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request()
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
.input('Category', sql.NVarChar(50), input.category)
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
.input('CreatedByUserId', sql.UniqueIdentifier, input.createdByUserId)
.query(`
INSERT dbo.Documents
(
Title,
Category,
Summary,
Content,
FilePath,
OriginalFileName,
MimeType,
FileSizeBytes,
CreatedByUserId
)
OUTPUT inserted.Id
VALUES
(
@Title,
@Category,
@Summary,
@Content,
@FilePath,
@OriginalFileName,
@MimeType,
@FileSizeBytes,
@CreatedByUserId
);
`);
return String(result.recordset[0].Id);
}
async function updateDocument(input) {
await ensureDocumentSchema();
const pool = await getPool();
const replaceAttachment = Boolean(input.replaceAttachment);
const result = await pool.request()
.input('Id', sql.UniqueIdentifier, input.documentId)
.input('Title', sql.NVarChar(200), String(input.title || '').trim())
.input('Category', sql.NVarChar(50), input.category)
.input('Summary', sql.NVarChar(1000), String(input.summary || '').trim() || null)
.input('Content', sql.NVarChar(sql.MAX), String(input.content || '').trim() || null)
.input('ReplaceAttachment', sql.Bit, replaceAttachment ? 1 : 0)
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
.input('OriginalFileName', sql.NVarChar(260), input.originalFileName || null)
.input('MimeType', sql.NVarChar(200), input.mimeType || null)
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes ?? null)
.query(`
UPDATE dbo.Documents
SET Title = @Title,
Category = @Category,
Summary = @Summary,
Content = @Content,
FilePath = CASE WHEN @ReplaceAttachment = 1 THEN @FilePath ELSE FilePath END,
OriginalFileName = CASE WHEN @ReplaceAttachment = 1 THEN @OriginalFileName ELSE OriginalFileName END,
MimeType = CASE WHEN @ReplaceAttachment = 1 THEN @MimeType ELSE MimeType END,
FileSizeBytes = CASE WHEN @ReplaceAttachment = 1 THEN @FileSizeBytes ELSE FileSizeBytes END,
UpdatedAt = SYSUTCDATETIME()
OUTPUT
inserted.Id,
deleted.FilePath AS PreviousFilePath,
inserted.FilePath AS CurrentFilePath
WHERE Id = @Id;
`);
const row = result.recordset[0];
if (!row) return null;
return {
id: String(row.Id),
previousFilePath: row.PreviousFilePath || '',
currentFilePath: row.CurrentFilePath || '',
attachmentChanged: replaceAttachment
};
}
async function deleteDocument(documentId) {
await ensureDocumentSchema();
const pool = await getPool();
const result = await pool.request()
.input('Id', sql.UniqueIdentifier, documentId)
.query(`
DELETE FROM dbo.Documents
OUTPUT
deleted.Id,
deleted.FilePath,
deleted.OriginalFileName
WHERE Id = @Id;
`);
const row = result.recordset[0];
if (!row) return null;
return {
id: String(row.Id),
filePath: row.FilePath || '',
originalFileName: row.OriginalFileName || ''
};
}
async function getPageData(currentUser) {
const [stats, packageRows, applications, activity] = await Promise.all([
getStats(),
@@ -1635,5 +1901,10 @@ module.exports = {
updateApplication,
updateApplicationStatus,
deleteApplication,
removeApplicationPackage
removeApplicationPackage,
listDocuments,
getDocumentById,
createDocument,
updateDocument,
deleteDocument
};

View File

@@ -0,0 +1,161 @@
<%- include('partials/page-start') %>
<section class="page document-page">
<div class="page-header">
<div>
<div class="breadcrumb"><a href="/documents">Tài liệu</a><span>/</span><span><%= helpers.documentCategoryLabel(document.category) %></span></div>
<h1><%= document.title %></h1>
<p><%= document.summary || 'Tài liệu nội bộ Robot Installer.' %></p>
</div>
<div class="page-actions">
<% if (document.filePath) { %>
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">
<span class="material-symbols-outlined">download</span>
Tải file
</a>
<% } %>
<button class="btn btn-primary" type="button" data-modal-open="editDocumentModal">
<span class="material-symbols-outlined">edit</span>
Chỉnh sửa
</button>
<form method="post" action="/documents/<%= document.id %>/delete" data-confirm-submit="Xóa tài liệu <%= document.title %> và file đính kèm?">
<button class="btn btn-danger" type="submit">
<span class="material-symbols-outlined">delete</span>
Xóa
</button>
</form>
</div>
</div>
<div class="document-detail-grid">
<section class="panel document-meta-panel">
<div class="panel-header">
<div>
<h2>Thông tin tài liệu</h2>
<p>Thông tin phân loại và tệp lưu trữ.</p>
</div>
</div>
<dl class="detail-list">
<div><dt>Nhóm</dt><dd><span class="badge badge-info"><%= helpers.documentCategoryLabel(document.category) %></span></dd></div>
<div><dt>Người tạo</dt><dd><%= document.createdBy %></dd></div>
<div><dt>Ngày tạo</dt><dd><%= document.createdAt %></dd></div>
<div><dt>Cập nhật</dt><dd><%= document.updatedAt %></dd></div>
<div><dt>File</dt><dd><%= document.originalFileName || 'Không có' %></dd></div>
<div><dt>Dung lượng</dt><dd><%= document.fileSize || '-' %></dd></div>
</dl>
</section>
<section class="panel document-reader-panel">
<div class="panel-header">
<div>
<h2>Nội dung</h2>
<p>Đọc nội dung và xem trước file được trình duyệt hỗ trợ.</p>
</div>
<% if (document.filePath) { %>
<a class="text-link" href="/documents/<%= document.id %>/file" target="_blank" rel="noopener">Mở file</a>
<% } %>
</div>
<div class="document-reader">
<% if (document.content) { %>
<article class="document-copy"><%= document.content %></article>
<% } %>
<% if (document.filePath && documentPreviewKind) { %>
<section class="document-preview-block">
<div class="document-section-heading">
<span class="material-symbols-outlined">attach_file</span>
<strong>Xem trước: <%= document.originalFileName %></strong>
</div>
<% if (documentPreviewKind === 'image') { %>
<img class="document-image-preview" src="/documents/<%= document.id %>/file" alt="<%= document.title %>">
<% } else { %>
<iframe
class="document-file-preview"
src="/documents/<%= document.id %>/file"
title="Xem trước <%= document.originalFileName %>"
<% if (documentPreviewKind === 'text') { %>sandbox<% } %>
></iframe>
<% } %>
</section>
<% } else if (document.filePath) { %>
<div class="document-attachment-state">
<span class="material-symbols-outlined">description</span>
<div>
<strong><%= document.originalFileName %></strong>
<p>Trình duyệt không xem trực tiếp định dạng này. Hãy tải file để mở bằng ứng dụng phù hợp.</p>
</div>
<a class="btn btn-secondary" href="/documents/<%= document.id %>/file?download=1">Tải file</a>
</div>
<% } %>
<% if (!document.content && !document.filePath) { %>
<div class="table-empty">Tài liệu chưa có nội dung.</div>
<% } %>
</div>
</section>
</div>
</section>
<div id="editDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="editDocumentModalTitle">
<div class="modal-content wide">
<div class="modal-header">
<div>
<h3 id="editDocumentModalTitle">Chỉnh sửa tài liệu</h3>
<p>Cập nhật nội dung hoặc thay thế file đính kèm hiện tại.</p>
</div>
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" method="post" action="/documents/<%= document.id %>/edit" enctype="multipart/form-data">
<div class="form-stack">
<div class="form-grid">
<label class="form-field">
<span>Tiêu đề</span>
<input type="text" name="title" maxlength="200" required value="<%= document.title %>">
</label>
<label class="form-field">
<span>Nhóm tài liệu</span>
<select name="category" required>
<% documentCategories.forEach((category) => { %>
<option value="<%= category.id %>" <%= document.category === category.id ? 'selected' : '' %>><%= category.label %></option>
<% }) %>
</select>
</label>
</div>
<label class="form-field">
<span>Mô tả ngắn</span>
<textarea name="summary" rows="3" maxlength="1000"><%= document.summary %></textarea>
</label>
<label class="form-field">
<span>Nội dung đọc trực tiếp</span>
<textarea name="content" rows="8" maxlength="500000"><%= document.content %></textarea>
</label>
<label class="form-field">
<span>Thay file đính kèm</span>
<input
type="file"
name="documentFile"
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
>
<small>Để trống nếu muốn giữ file hiện tại. File mới sẽ thay thế file cũ.</small>
</label>
<% if (document.filePath) { %>
<label class="document-remove-option">
<input type="checkbox" name="removeAttachment" value="1">
<span>Xóa file hiện tại: <strong><%= document.originalFileName %></strong></span>
</label>
<% } %>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit">
<span class="material-symbols-outlined">save</span>
Lưu thay đổi
</button>
</div>
</form>
</div>
</div>
<%- include('partials/page-end') %>

View File

@@ -0,0 +1,161 @@
<%- include('partials/page-start') %>
<section class="page">
<div class="page-header">
<div>
<h1>Tài liệu</h1>
<p>Lưu trữ và tra cứu tài liệu giới thiệu, hướng dẫn sử dụng, quy trình và tài liệu kỹ thuật.</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" type="button" data-modal-open="createDocumentModal">
<span class="material-symbols-outlined">note_add</span>
Thêm tài liệu
</button>
</div>
</div>
<div class="page-filters">
<label class="filter-field">
<span>Nhóm tài liệu</span>
<select data-filter-select data-filter-column="category" data-filter-table="documentsTable">
<option value="">Tất cả</option>
<% documentCategories.forEach((category) => { %>
<option value="<%= category.id %>"><%= category.label %></option>
<% }) %>
</select>
</label>
<label class="filter-field wide">
<span>Tìm kiếm</span>
<input type="search" placeholder="Tìm theo tiêu đề, mô tả, tác giả..." data-table-search="documentsTable">
</label>
</div>
<section class="table-panel">
<div class="table-wrap">
<table id="documentsTable" class="data-table documents-table">
<thead>
<tr>
<th>Tài liệu</th>
<th>Nhóm</th>
<th>File đính kèm</th>
<th>Cập nhật</th>
<th>Người tạo</th>
<th class="action-col">Thao tác</th>
</tr>
</thead>
<tbody>
<% if (documents.length === 0) { %>
<tr>
<td colspan="6" class="table-empty">Chưa có tài liệu. Bấm Thêm tài liệu để tạo nội dung đầu tiên.</td>
</tr>
<% } %>
<% documents.forEach((item) => { %>
<tr
data-search="<%= `${item.title} ${item.summary} ${item.createdBy} ${helpers.documentCategoryLabel(item.category)}`.toLowerCase() %>"
data-category="<%= item.category %>"
>
<td class="document-title-cell">
<a class="table-title" href="/documents/<%= item.id %>"><%= item.title %></a>
<span class="table-subtitle"><%= item.summary || (item.hasContent ? 'Có nội dung đọc trực tiếp' : 'Tài liệu đính kèm') %></span>
</td>
<td><span class="badge badge-info"><%= helpers.documentCategoryLabel(item.category) %></span></td>
<td>
<% if (item.filePath) { %>
<span class="document-file-name" title="<%= item.originalFileName %>"><%= item.originalFileName %></span>
<span class="table-subtitle"><%= item.fileSize %></span>
<% } else { %>
<span class="table-subtitle">Không có file</span>
<% } %>
</td>
<td><%= item.updatedAt %></td>
<td><%= item.createdBy %></td>
<td class="action-col">
<div class="action-group">
<a class="icon-button subtle" href="/documents/<%= item.id %>" title="Đọc tài liệu" aria-label="Đọc tài liệu <%= item.title %>">
<span class="material-symbols-outlined">visibility</span>
</a>
<% if (item.filePath) { %>
<a class="icon-button subtle" href="/documents/<%= item.id %>/file?download=1" title="Tải file" aria-label="Tải file <%= item.title %>">
<span class="material-symbols-outlined">download</span>
</a>
<% } %>
<form method="post" action="/documents/<%= item.id %>/delete" data-confirm-submit="Xóa tài liệu <%= item.title %> và file đính kèm?">
<button class="icon-button danger" type="submit" title="Xóa tài liệu" aria-label="Xóa tài liệu <%= item.title %>">
<span class="material-symbols-outlined">delete</span>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<div class="page-pager">
<span>Hiển thị <%= documents.length %> tài liệu</span>
<div>
<button type="button" disabled>Trước</button>
<span>Trang 1 / 1</span>
<button type="button" disabled>Sau</button>
</div>
</div>
</section>
</section>
<div id="createDocumentModal" class="modal-backdrop" role="dialog" aria-modal="true" aria-labelledby="createDocumentModalTitle">
<div class="modal-content wide">
<div class="modal-header">
<div>
<h3 id="createDocumentModalTitle">Thêm tài liệu</h3>
<p>Nhập nội dung để đọc trực tiếp, đính kèm file, hoặc sử dụng cả hai.</p>
</div>
<button class="icon-button subtle" type="button" data-modal-close aria-label="Đóng">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form class="modal-form" method="post" action="/documents" enctype="multipart/form-data">
<div class="form-stack">
<div class="form-grid">
<label class="form-field">
<span>Tiêu đề</span>
<input type="text" name="title" maxlength="200" required placeholder="Ví dụ: Hướng dẫn cài đặt Robot">
</label>
<label class="form-field">
<span>Nhóm tài liệu</span>
<select name="category" required>
<% documentCategories.forEach((category) => { %>
<option value="<%= category.id %>"><%= category.label %></option>
<% }) %>
</select>
</label>
</div>
<label class="form-field">
<span>Mô tả ngắn</span>
<textarea name="summary" rows="3" maxlength="1000" placeholder="Nội dung chính và đối tượng sử dụng tài liệu này."></textarea>
</label>
<label class="form-field">
<span>Nội dung đọc trực tiếp</span>
<textarea name="content" rows="8" maxlength="500000" placeholder="Nhập nội dung hướng dẫn tại đây. Xuống dòng và khoảng trắng sẽ được giữ nguyên khi hiển thị."></textarea>
</label>
<label class="form-field">
<span>File đính kèm</span>
<input
type="file"
name="documentFile"
accept=".pdf,.doc,.docx,.odt,.rtf,.txt,.md,.png,.jpg,.jpeg,.webp,.ppt,.pptx,.xls,.xlsx"
>
<small>Hỗ trợ PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint và Excel; tối đa 50 MB mặc định.</small>
</label>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" type="button" data-modal-close>Hủy</button>
<button class="btn btn-primary" type="submit">
<span class="material-symbols-outlined">save</span>
Lưu tài liệu
</button>
</div>
</form>
</div>
</div>
<%- include('partials/page-end') %>

View File

@@ -114,6 +114,7 @@
data-user-updated-at="<%= user.updatedAt %>"
data-user-package-count="<%= user.packageCount %>"
data-user-application-count="<%= user.applicationCount %>"
data-user-document-count="<%= user.documentCount %>"
>
<td>
<span class="table-title"><%= user.name %></span>
@@ -126,6 +127,7 @@
<td>
<span class="table-subtitle"><%= user.packageCount %> packages</span>
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
<span class="table-subtitle"><%= user.documentCount %> tài liệu</span>
</td>
<td>
<% if (user.id === currentUser.id) { %>