update docs
This commit is contained in:
@@ -7,6 +7,9 @@ DOCKER_NETWORK=robot-installer-net
|
|||||||
WEB_SERVER_UPLOADS_DIR=./uploads
|
WEB_SERVER_UPLOADS_DIR=./uploads
|
||||||
MAX_UPLOAD_BYTES=1073741824
|
MAX_UPLOAD_BYTES=1073741824
|
||||||
AGENT_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_HOST=172.20.235.176
|
||||||
SQLSERVER_PORT=1433
|
SQLSERVER_PORT=1433
|
||||||
SQLSERVER_DATABASE=RobotInstaller
|
SQLSERVER_DATABASE=RobotInstaller
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ COPY --from=dependencies /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
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 \
|
&& chown -R node:node uploads \
|
||||||
&& chmod +x docker-entrypoint.sh
|
&& 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.PackageVersions', N'U') IS NOT NULL
|
||||||
OR OBJECT_ID(N'dbo.Applications', 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.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.EmailConfirmationTokens', N'U') IS NOT NULL
|
||||||
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
|
OR OBJECT_ID(N'dbo.Users', N'U') IS NOT NULL
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -54,6 +55,37 @@ CREATE TABLE dbo.EmailConfirmationTokens
|
|||||||
);
|
);
|
||||||
GO
|
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
|
CREATE TABLE dbo.Packages
|
||||||
(
|
(
|
||||||
Id UNIQUEIDENTIFIER NOT NULL
|
Id UNIQUEIDENTIFIER NOT NULL
|
||||||
@@ -164,6 +196,13 @@ CREATE INDEX IX_EmailConfirmationTokens_UserId
|
|||||||
ON dbo.EmailConfirmationTokens(UserId);
|
ON dbo.EmailConfirmationTokens(UserId);
|
||||||
GO
|
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 UNIQUE INDEX UX_Packages_PackageCode ON dbo.Packages(PackageCode);
|
||||||
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
|
CREATE INDEX IX_Packages_CreatedByUserId ON dbo.Packages(CreatedByUserId);
|
||||||
CREATE INDEX IX_Packages_PackageType ON dbo.Packages(PackageType);
|
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.PackageVersions` | Các version của từng package |
|
||||||
| `dbo.Applications` | App được đóng gói từ nhiều 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.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 |
|
| `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
|
## 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\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\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\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`.
|
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.
|
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
|
## Luồng dữ liệu đề xuất
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
mkdir -p /app/uploads/packages/agent
|
mkdir -p /app/uploads/packages/agent /app/uploads/documents
|
||||||
chown -R node:node /app/uploads
|
chown -R node:node /app/uploads
|
||||||
|
|
||||||
exec su-exec node "$@"
|
exec su-exec node "$@"
|
||||||
|
|||||||
@@ -1724,12 +1724,155 @@ tbody tr:hover td.action-col {
|
|||||||
min-width: 980px;
|
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) {
|
@media (max-width: 1100px) {
|
||||||
.dashboard-grid,
|
.dashboard-grid,
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.builder-layout,
|
.builder-layout,
|
||||||
.agent-layout,
|
.agent-layout,
|
||||||
.users-layout {
|
.users-layout,
|
||||||
|
.document-detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1740,9 +1883,14 @@ tbody tr:hover td.action-col {
|
|||||||
.detail-grid,
|
.detail-grid,
|
||||||
.builder-layout,
|
.builder-layout,
|
||||||
.agent-layout,
|
.agent-layout,
|
||||||
.users-layout {
|
.users-layout,
|
||||||
|
.document-detail-grid {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-reader-panel {
|
||||||
|
min-height: 620px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
@@ -1833,6 +1981,16 @@ tbody tr:hover td.action-col {
|
|||||||
gap: 8px;
|
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 {
|
.modal-backdrop {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
database_off: 'database_warning_20_regular.svg',
|
database_off: 'database_warning_20_regular.svg',
|
||||||
delete: 'delete_20_regular.svg',
|
delete: 'delete_20_regular.svg',
|
||||||
deployed_code: 'cube_24_regular.svg',
|
deployed_code: 'cube_24_regular.svg',
|
||||||
|
description: 'document_24_regular.svg',
|
||||||
download: 'arrow_download_20_regular.svg',
|
download: 'arrow_download_20_regular.svg',
|
||||||
draft: 'document_24_regular.svg',
|
draft: 'document_24_regular.svg',
|
||||||
edit: 'edit_20_regular.svg',
|
edit: 'edit_20_regular.svg',
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
|
forward_to_inbox: 'mail_arrow_forward_20_regular.svg',
|
||||||
group: 'people_24_regular.svg',
|
group: 'people_24_regular.svg',
|
||||||
inventory_2: 'box_24_regular.svg',
|
inventory_2: 'box_24_regular.svg',
|
||||||
|
library_books: 'library_24_regular.svg',
|
||||||
link_off: 'link_dismiss_20_regular.svg',
|
link_off: 'link_dismiss_20_regular.svg',
|
||||||
login: 'arrow_enter_20_regular.svg',
|
login: 'arrow_enter_20_regular.svg',
|
||||||
logout: 'sign_out_20_regular.svg',
|
logout: 'sign_out_20_regular.svg',
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
menu: 'navigation_20_regular.svg',
|
menu: 'navigation_20_regular.svg',
|
||||||
notifications: 'alert_20_regular.svg',
|
notifications: 'alert_20_regular.svg',
|
||||||
notifications_none: 'alert_off_20_regular.svg',
|
notifications_none: 'alert_off_20_regular.svg',
|
||||||
|
note_add: 'document_add_24_regular.svg',
|
||||||
person_add: 'person_add_20_regular.svg',
|
person_add: 'person_add_20_regular.svg',
|
||||||
precision_manufacturing: 'bot_24_filled.svg',
|
precision_manufacturing: 'bot_24_filled.svg',
|
||||||
save: 'save_20_regular.svg',
|
save: 'save_20_regular.svg',
|
||||||
@@ -451,7 +454,8 @@
|
|||||||
createdAt: row.dataset.userCreatedAt || '',
|
createdAt: row.dataset.userCreatedAt || '',
|
||||||
updatedAt: row.dataset.userUpdatedAt || '',
|
updatedAt: row.dataset.userUpdatedAt || '',
|
||||||
packageCount: row.dataset.userPackageCount || '0',
|
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="status"]', user.status);
|
||||||
setText('[data-user-detail="createdAt"]', user.createdAt);
|
setText('[data-user-detail="createdAt"]', user.createdAt);
|
||||||
setText('[data-user-detail="updatedAt"]', user.updatedAt || 'Chưa cập nhật');
|
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');
|
openModal('userDetailModal');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const path = require('path');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const repository = require('./src/repository');
|
const repository = require('./src/repository');
|
||||||
|
const { normalizeDocumentFileName } = require('./src/document-file-name');
|
||||||
const notificationRepository = require('./src/notification-repository');
|
const notificationRepository = require('./src/notification-repository');
|
||||||
const mailer = require('./src/mailer');
|
const mailer = require('./src/mailer');
|
||||||
const { closePool, getPool } = require('./src/db');
|
const { closePool, getPool } = require('./src/db');
|
||||||
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
const uploadDir = path.join(__dirname, 'uploads', 'packages');
|
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 agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
|
||||||
const agentDebianPackageName = 'local-installer-agent';
|
const agentDebianPackageName = 'local-installer-agent';
|
||||||
const authCookieName = 'robot_installer_session';
|
const authCookieName = 'robot_installer_session';
|
||||||
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
|
|||||||
const installerVersionPattern = /^[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 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 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', {
|
const agentVersionCollator = new Intl.Collator('en', {
|
||||||
numeric: true,
|
numeric: true,
|
||||||
sensitivity: 'base'
|
sensitivity: 'base'
|
||||||
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
fs.mkdirSync(uploadDir, { recursive: true });
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
fs.mkdirSync(documentUploadDir, { recursive: true });
|
||||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
||||||
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
||||||
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
{ 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: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
||||||
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
||||||
{ id: 'users', label: 'Users', href: '/users', icon: 'group', 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({
|
const agentUpload = multer({
|
||||||
storage: agentStorage,
|
storage: agentStorage,
|
||||||
limits: {
|
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('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
@@ -227,6 +275,9 @@ function helpers() {
|
|||||||
if (type === 'docker') return 'badge-info';
|
if (type === 'docker') return 'badge-info';
|
||||||
if (type === 'apt') return 'badge-warning';
|
if (type === 'apt') return 'badge-warning';
|
||||||
return 'badge-primary';
|
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) {
|
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
|
||||||
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
||||||
|
|
||||||
@@ -1568,7 +1741,7 @@ exit 1
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(requireAuthenticated);
|
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) => {
|
app.get('/api/notifications', asyncRoute(async (req, res) => {
|
||||||
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
|
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) => {
|
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
|
||||||
const [pageData, users] = await Promise.all([
|
const [pageData, users] = await Promise.all([
|
||||||
repository.getPageData(req.currentUser),
|
repository.getPageData(req.currentUser),
|
||||||
@@ -2447,7 +2842,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
|
|||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === 'USER_HAS_OWNED_DATA') {
|
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;
|
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_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ệ.'
|
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);
|
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
|
||||||
redirectWithNotice(
|
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 crypto = require('crypto');
|
||||||
const { sql, getPool } = require('./db');
|
const { sql, getPool } = require('./db');
|
||||||
|
const { normalizeDocumentFileName } = require('./document-file-name');
|
||||||
|
|
||||||
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
const PASSWORD_HASH_PREFIX = 'pbkdf2';
|
||||||
const PASSWORD_HASH_ITERATIONS = 120000;
|
const PASSWORD_HASH_ITERATIONS = 120000;
|
||||||
@@ -11,6 +12,7 @@ const EMAIL_CONFIRMATION_EXPIRES_MS = Number(process.env.EMAIL_CONFIRMATION_EXPI
|
|||||||
let emailConfirmationSchemaPromise;
|
let emailConfirmationSchemaPromise;
|
||||||
let applicationOpenUrlSchemaPromise;
|
let applicationOpenUrlSchemaPromise;
|
||||||
let packageTypeSchemaPromise;
|
let packageTypeSchemaPromise;
|
||||||
|
let documentSchemaPromise;
|
||||||
|
|
||||||
function padDatePart(value) {
|
function padDatePart(value) {
|
||||||
return String(value).padStart(2, '0');
|
return String(value).padStart(2, '0');
|
||||||
@@ -167,7 +169,8 @@ function mapUserRow(row) {
|
|||||||
createdAt: formatDate(row.CreatedAt),
|
createdAt: formatDate(row.CreatedAt),
|
||||||
updatedAt: formatDate(row.UpdatedAt),
|
updatedAt: formatDate(row.UpdatedAt),
|
||||||
packageCount: Number(row.PackageCount || 0),
|
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() {
|
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';
|
error.code = 'USER_HAS_OWNED_DATA';
|
||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
@@ -358,6 +361,69 @@ async function ensurePackageTypeSchema() {
|
|||||||
return packageTypeSchemaPromise;
|
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) {
|
function normalizePackageStatus(isActive) {
|
||||||
return isActive ? 'Active' : 'Archived';
|
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) {
|
function isLoopbackHost(hostname) {
|
||||||
const host = String(hostname || '').toLowerCase();
|
const host = String(hostname || '').toLowerCase();
|
||||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||||
@@ -494,23 +582,27 @@ async function getUserById(id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getUserOwnershipCounts(userId) {
|
async function getUserOwnershipCounts(userId) {
|
||||||
|
await ensureDocumentSchema();
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const result = await pool.request()
|
const result = await pool.request()
|
||||||
.input('UserId', sql.UniqueIdentifier, userId)
|
.input('UserId', sql.UniqueIdentifier, userId)
|
||||||
.query(`
|
.query(`
|
||||||
SELECT
|
SELECT
|
||||||
(SELECT COUNT_BIG(*) FROM dbo.Packages WHERE CreatedByUserId = @UserId) AS PackageCount,
|
(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];
|
const row = result.recordset[0];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
packageCount: Number(row.PackageCount || 0),
|
packageCount: Number(row.PackageCount || 0),
|
||||||
applicationCount: Number(row.ApplicationCount || 0)
|
applicationCount: Number(row.ApplicationCount || 0),
|
||||||
|
documentCount: Number(row.DocumentCount || 0)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listUsers() {
|
async function listUsers() {
|
||||||
|
await ensureDocumentSchema();
|
||||||
const pool = await getPool();
|
const pool = await getPool();
|
||||||
const result = await pool.request().query(`
|
const result = await pool.request().query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -523,7 +615,8 @@ async function listUsers() {
|
|||||||
u.CreatedAt,
|
u.CreatedAt,
|
||||||
u.UpdatedAt,
|
u.UpdatedAt,
|
||||||
package_count.PackageCount,
|
package_count.PackageCount,
|
||||||
application_count.ApplicationCount
|
application_count.ApplicationCount,
|
||||||
|
document_count.DocumentCount
|
||||||
FROM dbo.Users AS u
|
FROM dbo.Users AS u
|
||||||
OUTER APPLY (
|
OUTER APPLY (
|
||||||
SELECT COUNT_BIG(*) AS PackageCount
|
SELECT COUNT_BIG(*) AS PackageCount
|
||||||
@@ -535,6 +628,11 @@ async function listUsers() {
|
|||||||
FROM dbo.Applications AS a
|
FROM dbo.Applications AS a
|
||||||
WHERE a.CreatedByUserId = u.Id
|
WHERE a.CreatedByUserId = u.Id
|
||||||
) AS application_count
|
) 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;
|
ORDER BY u.CreatedAt DESC, u.Username ASC;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -865,7 +963,7 @@ async function updateUser(input) {
|
|||||||
async function deleteUser(userId) {
|
async function deleteUser(userId) {
|
||||||
const counts = await getUserOwnershipCounts(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();
|
throw userHasOwnedDataError();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1588,6 +1686,174 @@ async function removeApplicationPackage(applicationId, packageId) {
|
|||||||
return result.recordset.length > 0;
|
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) {
|
async function getPageData(currentUser) {
|
||||||
const [stats, packageRows, applications, activity] = await Promise.all([
|
const [stats, packageRows, applications, activity] = await Promise.all([
|
||||||
getStats(),
|
getStats(),
|
||||||
@@ -1635,5 +1901,10 @@ module.exports = {
|
|||||||
updateApplication,
|
updateApplication,
|
||||||
updateApplicationStatus,
|
updateApplicationStatus,
|
||||||
deleteApplication,
|
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-updated-at="<%= user.updatedAt %>"
|
||||||
data-user-package-count="<%= user.packageCount %>"
|
data-user-package-count="<%= user.packageCount %>"
|
||||||
data-user-application-count="<%= user.applicationCount %>"
|
data-user-application-count="<%= user.applicationCount %>"
|
||||||
|
data-user-document-count="<%= user.documentCount %>"
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<span class="table-title"><%= user.name %></span>
|
<span class="table-title"><%= user.name %></span>
|
||||||
@@ -126,6 +127,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<span class="table-subtitle"><%= user.packageCount %> packages</span>
|
<span class="table-subtitle"><%= user.packageCount %> packages</span>
|
||||||
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
|
<span class="table-subtitle"><%= user.applicationCount %> apps</span>
|
||||||
|
<span class="table-subtitle"><%= user.documentCount %> tài liệu</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<% if (user.id === currentUser.id) { %>
|
<% if (user.id === currentUser.id) { %>
|
||||||
|
|||||||
Reference in New Issue
Block a user