update docs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
67
web-server/database/05_documents.sql
Normal file
67
web-server/database/05_documents.sql
Normal 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
|
||||
@@ -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` là 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` và `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
|
||||
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
23
web-server/src/document-file-name.js
Normal file
23
web-server/src/document-file-name.js
Normal 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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
161
web-server/views/document-detail.ejs
Normal file
161
web-server/views/document-detail.ejs
Normal 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') %>
|
||||
161
web-server/views/documents.ejs
Normal file
161
web-server/views/documents.ejs
Normal 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') %>
|
||||
@@ -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) { %>
|
||||
|
||||
Reference in New Issue
Block a user