From b9137204a0ddb1f0d3dd51e3fd3b86b4b3905f18 Mon Sep 17 00:00:00 2001
From: panda <1415243231@qq.com>
Date: Sun, 24 May 2026 20:09:42 +0800
Subject: [PATCH] fix: FilePreview fileType case + Tailwind v4 gradient
transparent bug
- FilePreview.vue: add normalizedFileType computed to handle backend
returning uppercase HTML/MD/PPTX (fixes preview/download buttons)
- FilePreview.vue: bg-gradient-to-r from-orange-500 -> bg-orange-500
(Tailwind v4 gradient + CSS variable = transparent)
- ReportCard.vue: bg-gradient-to-r -> bg-orange-600 for selected state
- Add .opencode/, node_modules/, dist/ to .gitignore
- Initial git setup for publish project
---
.dockerignore | 40 +
.gitignore | 69 +
Dockerfile | 15 +
Dockerfile.frontend | 17 +
docker-compose.yml | 44 +
docs/api.md | 403 ++
index.html | 29 +
mvnw | 332 ++
mvnw.cmd | 206 +
mvnwDebug | 35 +
mvnwDebug.cmd | 44 +
nginx.conf | 77 +
package-lock.json | 4522 +++++++++++++++++
package.json | 36 +
playwright.config.js | 33 +
pom.xml | 140 +
postcss.config.cjs | 1 +
postcss.config.js | 1 +
run-backend.bat | 3 +
server.js | 117 +
server.py | 95 +
src/App.vue | 16 +
src/components/FilePreview.vue | 184 +
src/components/Header.vue | 28 +
src/components/ProjectCard.vue | 115 +
src/components/ReportCard.vue | 135 +
src/components/Sidebar.vue | 104 +
src/composables/useApi.js | 144 +
src/main.js | 8 +
.../DailyReportDistributionApplication.java | 12 +
.../java/com/reportdist/config/WebConfig.java | 24 +
.../controller/ProjectController.java | 57 +
.../controller/ReportController.java | 135 +
.../com/reportdist/dto/ProjectRequest.java | 29 +
.../com/reportdist/dto/ProjectResponse.java | 80 +
.../com/reportdist/dto/ReportRequest.java | 30 +
.../com/reportdist/dto/ReportResponse.java | 72 +
.../java/com/reportdist/entity/Project.java | 53 +
.../java/com/reportdist/entity/Report.java | 79 +
.../exception/GlobalExceptionHandler.java | 65 +
.../repository/ProjectRepository.java | 9 +
.../repository/ReportRepository.java | 19 +
.../reportdist/service/PptxToPdfService.java | 143 +
.../reportdist/service/ProjectService.java | 122 +
.../com/reportdist/service/ReportService.java | 188 +
src/main/resources/application.yml | 46 +
src/pages/ProjectDetail.vue | 178 +
src/pages/ProjectList.vue | 215 +
src/router/index.js | 24 +
src/styles/main.css | 129 +
.../CompleteApiFlowIntegrationTest.java | 252 +
.../ErrorHandlingIntegrationTest.java | 369 ++
.../reportdist/FileUploadIntegrationTest.java | 460 ++
.../controller/ProjectControllerTest.java | 188 +
.../controller/ReportControllerTest.java | 314 ++
.../service/ProjectServiceTest.java | 276 +
.../reportdist/service/ReportServiceTest.java | 350 ++
src/test/resources/application.yml | 37 +
src/test/vue/components/FilePreview.test.js | 247 +
src/test/vue/components/ReportCard.test.js | 174 +
src/test/vue/composables/useApi.test.js | 161 +
src/test/vue/pages/ProjectDetail.test.js | 201 +
src/test/vue/pages/ProjectList.test.js | 215 +
start-local.bat | 65 +
stop-local.bat | 36 +
tailwind.config.js | 3 +
test-api.py | 11 +
test-backend-status.py | 26 +
test-upload.py | 39 +
tests/e2e/cover-image.spec.js | 131 +
tests/e2e/project.spec.js | 141 +
tests/e2e/report-view.spec.js | 121 +
tests/e2e/report.spec.js | 100 +
tests/e2e/responsive.spec.js | 119 +
uploads/.gitkeep | 1 +
vite.config.js | 30 +
vitest.config.js | 17 +
修复报告_20260524.html | 164 +
78 files changed, 12950 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 Dockerfile
create mode 100644 Dockerfile.frontend
create mode 100644 docker-compose.yml
create mode 100644 docs/api.md
create mode 100644 index.html
create mode 100644 mvnw
create mode 100644 mvnw.cmd
create mode 100644 mvnwDebug
create mode 100644 mvnwDebug.cmd
create mode 100644 nginx.conf
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 playwright.config.js
create mode 100644 pom.xml
create mode 100644 postcss.config.cjs
create mode 100644 postcss.config.js
create mode 100644 run-backend.bat
create mode 100644 server.js
create mode 100644 server.py
create mode 100644 src/App.vue
create mode 100644 src/components/FilePreview.vue
create mode 100644 src/components/Header.vue
create mode 100644 src/components/ProjectCard.vue
create mode 100644 src/components/ReportCard.vue
create mode 100644 src/components/Sidebar.vue
create mode 100644 src/composables/useApi.js
create mode 100644 src/main.js
create mode 100644 src/main/java/com/reportdist/DailyReportDistributionApplication.java
create mode 100644 src/main/java/com/reportdist/config/WebConfig.java
create mode 100644 src/main/java/com/reportdist/controller/ProjectController.java
create mode 100644 src/main/java/com/reportdist/controller/ReportController.java
create mode 100644 src/main/java/com/reportdist/dto/ProjectRequest.java
create mode 100644 src/main/java/com/reportdist/dto/ProjectResponse.java
create mode 100644 src/main/java/com/reportdist/dto/ReportRequest.java
create mode 100644 src/main/java/com/reportdist/dto/ReportResponse.java
create mode 100644 src/main/java/com/reportdist/entity/Project.java
create mode 100644 src/main/java/com/reportdist/entity/Report.java
create mode 100644 src/main/java/com/reportdist/exception/GlobalExceptionHandler.java
create mode 100644 src/main/java/com/reportdist/repository/ProjectRepository.java
create mode 100644 src/main/java/com/reportdist/repository/ReportRepository.java
create mode 100644 src/main/java/com/reportdist/service/PptxToPdfService.java
create mode 100644 src/main/java/com/reportdist/service/ProjectService.java
create mode 100644 src/main/java/com/reportdist/service/ReportService.java
create mode 100644 src/main/resources/application.yml
create mode 100644 src/pages/ProjectDetail.vue
create mode 100644 src/pages/ProjectList.vue
create mode 100644 src/router/index.js
create mode 100644 src/styles/main.css
create mode 100644 src/test/java/com/reportdist/CompleteApiFlowIntegrationTest.java
create mode 100644 src/test/java/com/reportdist/ErrorHandlingIntegrationTest.java
create mode 100644 src/test/java/com/reportdist/FileUploadIntegrationTest.java
create mode 100644 src/test/java/com/reportdist/controller/ProjectControllerTest.java
create mode 100644 src/test/java/com/reportdist/controller/ReportControllerTest.java
create mode 100644 src/test/java/com/reportdist/service/ProjectServiceTest.java
create mode 100644 src/test/java/com/reportdist/service/ReportServiceTest.java
create mode 100644 src/test/resources/application.yml
create mode 100644 src/test/vue/components/FilePreview.test.js
create mode 100644 src/test/vue/components/ReportCard.test.js
create mode 100644 src/test/vue/composables/useApi.test.js
create mode 100644 src/test/vue/pages/ProjectDetail.test.js
create mode 100644 src/test/vue/pages/ProjectList.test.js
create mode 100644 start-local.bat
create mode 100644 stop-local.bat
create mode 100644 tailwind.config.js
create mode 100644 test-api.py
create mode 100644 test-backend-status.py
create mode 100644 test-upload.py
create mode 100644 tests/e2e/cover-image.spec.js
create mode 100644 tests/e2e/project.spec.js
create mode 100644 tests/e2e/report-view.spec.js
create mode 100644 tests/e2e/report.spec.js
create mode 100644 tests/e2e/responsive.spec.js
create mode 100644 uploads/.gitkeep
create mode 100644 vite.config.js
create mode 100644 vitest.config.js
create mode 100644 修复报告_20260524.html
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..cc8dd05
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,40 @@
+# Exclude build artifacts and dependencies
+node_modules
+dist
+target
+
+# Exclude IDE and system files
+.git
+.mavis
+.mvn
+.opencode
+
+# Exclude test and dev files
+test-results
+test-uploads
+tests
+*.spec.js
+*.test.js
+
+# Exclude unnecessary config files (frontend is pre-built)
+*.bat
+*.ps1
+playwright.config.js
+vitest.config.js
+postcss.config.*
+tsconfig.json
+tsconfig.node.json
+vite.config.js
+tailwind.config.js
+package.json
+package-lock.json
+src
+
+# Exclude local files
+database.db
+*.zip
+*.log
+*.txt
+spawn-config.json
+team-plan.yaml
+worker1.json
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3bedb72
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,69 @@
+# Maven
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!.mvn/wrapper/maven-wrapper.properties
+.mvn
+*.jar
+!.pom.xml
+
+# IDE
+.idea
+*.iml
+*.iws
+.project
+.classpath
+.settings
+.vscode
+
+# Logs
+*.log
+logs
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Database
+*.db
+*.db-journal
+
+# Uploads
+uploads/*
+!uploads/.gitkeep
+
+# Environment
+.env
+*.env.local
+
+# Build outputs
+dist/
+dist_new/
+
+# Mavis
+.mavis/
+.mcp/
+
+# Opencode
+.opencode/
+
+# Dependencies
+node_modules/
+
+# Temp files
+tmp_*.cjs
+tmp_*.js
+backend-stderr.txt
+backend-stdout.txt
+spawn-config.json
+worker1.json
+team-plan.yaml
+test-args.json
+
+# Test
+test-results/
+test-uploads/
+
+# Other
+*.bak
+matrix-media-*.png
+maven-wrapper.zip
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..12e0a91
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,15 @@
+# Backend: Pre-built JAR
+FROM eclipse-temurin:26.0.1_8-jre-noble
+WORKDIR /app
+
+# Create uploads directory with write permissions
+RUN mkdir -p /app/uploads && chmod -R 777 /app/uploads
+
+# Copy pre-built JAR (v7 - renamed to break cache)
+COPY target/app-v7.jar app-v7.jar
+
+# Expose port
+EXPOSE 8080
+
+# Run the application
+ENTRYPOINT ["java", "-jar", "app-v7.jar"]
diff --git a/Dockerfile.frontend b/Dockerfile.frontend
new file mode 100644
index 0000000..7ed39f8
--- /dev/null
+++ b/Dockerfile.frontend
@@ -0,0 +1,17 @@
+# Frontend: Node.js static file server with API proxy
+FROM nfqlt/node20
+
+WORKDIR /app
+
+# Copy static files
+COPY dist/ /app/dist/
+
+# Copy server script
+COPY server.js /app/server.js
+
+# Create uploads directory
+RUN mkdir -p /app/uploads && chmod 777 /app/uploads
+
+EXPOSE 80
+
+CMD ["node", "/app/server.js"]
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..17ed9f2
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+services:
+ # Backend: Spring Boot JAR
+ backend:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: publish-backend
+ restart: unless-stopped
+ ports:
+ - "37821:8080"
+ environment:
+ - SPRING_PROFILES_ACTIVE=prod
+ - JAVA_OPTS=-Xms256m -Xmx512m
+ - UPLOAD_DIR=/app/uploads
+ volumes:
+ - uploads-data2:/app/uploads
+ networks:
+ - app-network
+
+ # Frontend: Python HTTP server with API proxy
+ frontend:
+ build:
+ context: .
+ dockerfile: Dockerfile.frontend
+ container_name: publish-frontend
+ restart: unless-stopped
+ ports:
+ - "41733:80"
+ environment:
+ - BACKEND_URL=http://publish-backend:8080
+ volumes:
+ - uploads-data2:/app/uploads
+ networks:
+ - app-network
+ depends_on:
+ - backend
+
+networks:
+ app-network:
+ driver: bridge
+
+volumes:
+ uploads-data2:
+ driver: local
\ No newline at end of file
diff --git a/docs/api.md b/docs/api.md
new file mode 100644
index 0000000..c0425ff
--- /dev/null
+++ b/docs/api.md
@@ -0,0 +1,403 @@
+# 日报分发平台 API 文档
+
+本文档面向人工阅读和 AI Agent 调用。接口均 REST 风格,JSON 格式(除文件下载外)。
+
+---
+
+## 基础信息
+
+**本地开发**: `http://localhost:37821`
+**Docker 部署**: `http://<服务器IP>:41733`(前端代理到后端)
+
+> Docker 部署时,前端(41733)会自动将 `/api/*` 和 `/uploads/*` 请求转发到后端,无需单独访问后端端口。
+
+**认证**: 当前接口均无需认证(开放接口)
+
+---
+
+## 1. 项目管理
+
+### 1.1 获取所有项目
+
+**请求**
+
+```
+GET /api/projects
+```
+
+**Python**
+```python
+import requests
+resp = requests.get("http://192.168.31.240:41733/api/projects")
+print(resp.json())
+```
+
+**curl**
+```bash
+curl http://192.168.31.240:41733/api/projects
+```
+
+**响应** `200 OK`
+```json
+[
+ {
+ "id": 1,
+ "name": "MiniMax 日报",
+ "description": "MiniMax 日报分发",
+ "coverImage": null,
+ "createdAt": "2026-05-24T11:23:28.734",
+ "reportCount": 1,
+ "todayNewReports": 1
+ }
+]
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | Long | 项目 ID |
+| name | String | 项目名称 |
+| description | String | 项目描述 |
+| coverImage | String? | 封面图片 URL,无则为 null |
+| createdAt | String | 创建时间(ISO 8601) |
+| reportCount | Integer | 该项目报告总数 |
+| todayNewReports | Integer | 今日新增报告数 |
+
+---
+
+### 1.2 创建项目
+
+**请求**
+
+```
+POST /api/projects
+Content-Type: application/json
+```
+
+**Body**
+```json
+{
+ "name": "项目名称",
+ "description": "项目描述"
+}
+```
+
+**Python**
+```python
+import requests
+resp = requests.post(
+ "http://192.168.31.240:41733/api/projects",
+ json={"name": "新项目", "description": "描述"}
+)
+print(resp.json())
+```
+
+**curl**
+```bash
+curl -X POST http://192.168.31.240:41733/api/projects \
+ -H "Content-Type: application/json" \
+ -d '{"name":"新项目","description":"描述"}'
+```
+
+**响应** `201 Created`
+```json
+{
+ "id": 2,
+ "name": "新项目",
+ "description": "描述",
+ "coverImage": null,
+ "createdAt": "2026-05-24T11:33:34.658",
+ "reportCount": 0,
+ "todayNewReports": 0
+}
+```
+
+---
+
+### 1.3 更新项目(含封面上传)
+
+**请求**
+
+```
+PUT /api/projects/{projectId}
+Content-Type: multipart/form-data
+```
+
+**表单字段**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| name | String | 否 | 项目新名称 |
+| description | String | 否 | 项目描述 |
+| coverImage | File | 否 | 封面图片,支持 jpg/png/webp/gif |
+
+**Python**
+```python
+import requests
+resp = requests.put(
+ "http://192.168.31.240:41733/api/projects/1",
+ data={"name": "新名称"},
+ files={"coverImage": open("cover.png", "rb")}
+)
+print(resp.json())
+```
+
+**curl**
+```bash
+curl -X PUT http://192.168.31.240:41733/api/projects/1 \
+ -F "name=新名称" \
+ -F "coverImage=@cover.png"
+```
+
+**响应** `200 OK` — 返回更新后的项目对象,字段同 1.1
+
+---
+
+### 1.4 删除项目
+
+**请求**
+
+```
+DELETE /api/projects/{projectId}
+```
+
+**Python**
+```python
+import requests
+requests.delete("http://192.168.31.240:41733/api/projects/1")
+```
+
+**curl**
+```bash
+curl -X DELETE http://192.168.31.240:41733/api/projects/1
+```
+
+**响应** `204 No Content`(成功无返回体)
+
+---
+
+## 2. 报告管理
+
+### 2.1 获取项目下的报告列表
+
+**请求**
+
+```
+GET /api/reports?projectId={projectId}
+```
+
+**Python**
+```python
+import requests
+resp = requests.get("http://192.168.31.240:41733/api/reports?projectId=1")
+print(resp.json())
+```
+
+**curl**
+```bash
+curl "http://192.168.31.240:41733/api/reports?projectId=1"
+```
+
+**响应** `200 OK`
+```json
+[
+ {
+ "id": 1,
+ "projectId": 1,
+ "fileName": "MiniMax_Daily_2026-05-23.html",
+ "fileType": "HTML",
+ "filePath": "/app/uploads/1/1779622289908_MiniMax_Daily_2026-05-23.html",
+ "uploadTime": "2026-05-24T11:31:29.919",
+ "fileContent": null
+ }
+]
+```
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| id | Long | 报告 ID |
+| projectId | Long | 所属项目 ID |
+| fileName | String | 文件名(含扩展名) |
+| fileType | String | 文件类型:`HTML` / `MD` / `PPTX` 等 |
+| filePath | String | 服务器存储路径 |
+| uploadTime | String | 上传时间(ISO 8601) |
+| fileContent | String? | 文件内容(列表中为 null,详情接口有值) |
+
+---
+
+### 2.2 上传报告文件
+
+**请求**
+
+```
+POST /api/reports
+Content-Type: multipart/form-data
+```
+
+**表单字段**
+
+| 参数 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| file | File | 是 | 报告文件本体 |
+| projectId | Long | 是 | 上传到哪个项目 ID |
+| fileType | String | 是 | 文件类型,取值:`HTML` / `MD` / `PPTX` |
+
+**文件类型对应关系**
+
+| fileType | 对应扩展名 | 说明 |
+|----------|-----------|------|
+| HTML | .html | 网页格式,推荐 |
+| MD | .md | Markdown 格式 |
+| PPTX | .pptx | PowerPoint 格式 |
+
+**Python**
+```python
+import requests
+resp = requests.post(
+ "http://192.168.31.240:41733/api/reports",
+ data={"projectId": 1, "fileType": "HTML"},
+ files={"file": ("日报.html", open("日报.html", "rb"), "text/html")}
+)
+print(resp.json())
+```
+
+**curl**
+```bash
+curl -X POST http://192.168.31.240:41733/api/reports \
+ -F "file=@日报.html" \
+ -F "projectId=1" \
+ -F "fileType=HTML"
+```
+
+> 注意:`requests.post(url, data={...}, files={...})` 中,`projectId` 和 `fileType` 放在 `data` 中,文件放在 `files` 中。错误示例:放在同一个 dict 中。
+
+**响应** `201 Created`
+```json
+{
+ "id": 1,
+ "projectId": 1,
+ "fileName": "MiniMax_Daily_2026-05-23.html",
+ "fileType": "HTML",
+ "filePath": "/app/uploads/1/1779622289908_MiniMax_Daily_2026-05-23.html",
+ "uploadTime": "2026-05-24T11:31:29.919",
+ "fileContent": null
+}
+```
+
+---
+
+### 2.3 获取报告详情(含文件内容)
+
+**请求**
+
+```
+GET /api/reports/{reportId}
+```
+
+**Python**
+```python
+import requests
+resp = requests.get("http://192.168.31.240:41733/api/reports/1")
+data = resp.json()
+print(data['fileContent']) # HTML/MD 文件的原文内容
+```
+
+**curl**
+```bash
+curl http://192.168.31.240:41733/api/reports/1
+```
+
+**响应** `200 OK`
+```json
+{
+ "id": 1,
+ "projectId": 1,
+ "fileName": "MiniMax_Daily_2026-05-23.html",
+ "fileType": "HTML",
+ "filePath": "/app/uploads/1/1779622289908_MiniMax_Daily_2026-05-23.html",
+ "uploadTime": "2026-05-24T11:31:29.919",
+ "fileContent": "..."
+}
+```
+
+---
+
+### 2.4 下载报告原始文件
+
+**请求**
+
+```
+GET /api/reports/{reportId}/download
+```
+
+**Python**
+```python
+import requests
+resp = requests.get("http://192.168.31.240:41733/api/reports/1/download")
+with open("report.html", "wb") as f:
+ f.write(resp.content)
+```
+
+**curl**
+```bash
+curl -o report.html http://192.168.31.240:41733/api/reports/1/download
+```
+
+**响应** `200 OK`,Body 为文件的原始字节流(`application/octet-stream`)
+
+---
+
+### 2.5 删除报告
+
+**请求**
+
+```
+DELETE /api/reports/{reportId}
+```
+
+**Python**
+```python
+import requests
+requests.delete("http://192.168.31.240:41733/api/reports/1")
+```
+
+**curl**
+```bash
+curl -X DELETE http://192.168.31.240:41733/api/reports/1
+```
+
+**响应** `204 No Content`
+
+---
+
+## 3. 健康检查
+
+**请求**
+
+```
+GET /actuator/health
+```
+
+**Python**
+```python
+import requests
+resp = requests.get("http://192.168.31.240:41733/actuator/health")
+print(resp.json()) # {"status": "UP"}
+```
+
+---
+
+## 4. 错误码
+
+| HTTP 状态 | 说明 |
+|-----------|------|
+| 200 | 成功 |
+| 201 | 创建成功 |
+| 204 | 删除成功(无返回体) |
+| 400 | 参数错误(如 fileType 不支持) |
+| 404 | 项目或报告不存在 |
+| 500 | 服务器错误 |
+
+常见 500 错误原因:
+- `MissingServletRequestParameterException`: 缺少必填参数(如上传时缺少 `projectId` 或 `fileType`)
+- `MultipartException`: 请求不是 multipart 格式(上传接口必须用 `multipart/form-data`)
+- `NoResourceFoundException`: 接口路径不存在(确认 URL 是否正确)
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a7df844
--- /dev/null
+++ b/index.html
@@ -0,0 +1,29 @@
+
+
+
+
+
+ 日报分发系统
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/mvnw b/mvnw
new file mode 100644
index 0000000..5e9618c
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,332 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Required ENV vars:
+# ------------------
+# JAVA_HOME - location of a JDK home dir
+#
+# Optional ENV vars
+# -----------------
+# MAVEN_OPTS - parameters passed to the Java VM when running Maven
+# e.g. to debug Maven itself, use
+# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+# MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+# ----------------------------------------------------------------------------
+
+if [ -z "$MAVEN_SKIP_RC" ]; then
+
+ if [ -f /usr/local/etc/mavenrc ]; then
+ . /usr/local/etc/mavenrc
+ fi
+
+ if [ -f /etc/mavenrc ]; then
+ . /etc/mavenrc
+ fi
+
+ if [ -f "$HOME/.mavenrc" ]; then
+ . "$HOME/.mavenrc"
+ fi
+
+fi
+
+# OS specific support. $var _must_ be set to either true or false.
+cygwin=false
+darwin=false
+mingw=false
+case "$(uname)" in
+CYGWIN*) cygwin=true ;;
+MINGW*) mingw=true ;;
+Darwin*)
+ darwin=true
+ # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home
+ # See https://developer.apple.com/library/mac/qa/qa1170/_index.html
+ if [ -z "$JAVA_HOME" ]; then
+ if [ -x "/usr/libexec/java_home" ]; then
+ JAVA_HOME="$(/usr/libexec/java_home)"
+ export JAVA_HOME
+ else
+ JAVA_HOME="/Library/Java/Home"
+ export JAVA_HOME
+ fi
+ fi
+ ;;
+esac
+
+if [ -z "$JAVA_HOME" ]; then
+ if [ -r /etc/gentoo-release ]; then
+ JAVA_HOME=$(java-config --jre-home)
+ fi
+fi
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched
+if $cygwin; then
+ [ -n "$JAVA_HOME" ] \
+ && JAVA_HOME=$(cygpath --unix "$JAVA_HOME")
+ [ -n "$CLASSPATH" ] \
+ && CLASSPATH=$(cygpath --path --unix "$CLASSPATH")
+fi
+
+# For Mingw, ensure paths are in UNIX format before anything is touched
+if $mingw; then
+ [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] \
+ && JAVA_HOME="$(
+ cd "$JAVA_HOME" || (
+ echo "cannot cd into $JAVA_HOME." >&2
+ exit 1
+ )
+ pwd
+ )"
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+ javaExecutable="$(which javac)"
+ if [ -n "$javaExecutable" ] && ! [ "$(expr "$javaExecutable" : '\([^ ]*\)')" = "no" ]; then
+ # readlink(1) is not available as standard on Solaris 10.
+ readLink=$(which readlink)
+ if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then
+ if $darwin; then
+ javaHome="$(dirname "$javaExecutable")"
+ javaExecutable="$(cd "$javaHome" && pwd -P)/javac"
+ else
+ javaExecutable="$(readlink -f "$javaExecutable")"
+ fi
+ javaHome="$(dirname "$javaExecutable")"
+ javaHome=$(expr "$javaHome" : '\(.*\)/bin')
+ JAVA_HOME="$javaHome"
+ export JAVA_HOME
+ fi
+ fi
+fi
+
+if [ -z "$JAVACMD" ]; then
+ if [ -n "$JAVA_HOME" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ else
+ JAVACMD="$(
+ \unset -f command 2>/dev/null
+ \command -v java
+ )"
+ fi
+fi
+
+if [ ! -x "$JAVACMD" ]; then
+ echo "Error: JAVA_HOME is not defined correctly." >&2
+ echo " We cannot execute $JAVACMD" >&2
+ exit 1
+fi
+
+if [ -z "$JAVA_HOME" ]; then
+ echo "Warning: JAVA_HOME environment variable is not set." >&2
+fi
+
+# traverses directory structure from process work directory to filesystem root
+# first directory with .mvn subdirectory is considered project base directory
+find_maven_basedir() {
+ if [ -z "$1" ]; then
+ echo "Path not specified to find_maven_basedir" >&2
+ return 1
+ fi
+
+ basedir="$1"
+ wdir="$1"
+ while [ "$wdir" != '/' ]; do
+ if [ -d "$wdir"/.mvn ]; then
+ basedir=$wdir
+ break
+ fi
+ # workaround for JBEAP-8937 (on Solaris 10/Sparc)
+ if [ -d "${wdir}" ]; then
+ wdir=$(
+ cd "$wdir/.." || exit 1
+ pwd
+ )
+ fi
+ # end of workaround
+ done
+ printf '%s' "$(
+ cd "$basedir" || exit 1
+ pwd
+ )"
+}
+
+# concatenates all lines of a file
+concat_lines() {
+ if [ -f "$1" ]; then
+ # Remove \r in case we run on Windows within Git Bash
+ # and check out the repository with auto CRLF management
+ # enabled. Otherwise, we may read lines that are delimited with
+ # \r\n and produce $'-Xarg\r' rather than -Xarg due to word
+ # splitting rules.
+ tr -s '\r\n' ' ' <"$1"
+ fi
+}
+
+log() {
+ if [ "$MVNW_VERBOSE" = true ]; then
+ printf '%s\n' "$1"
+ fi
+}
+
+BASE_DIR=$(find_maven_basedir "$(dirname "$0")")
+if [ -z "$BASE_DIR" ]; then
+ exit 1
+fi
+
+MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}
+export MAVEN_PROJECTBASEDIR
+log "$MAVEN_PROJECTBASEDIR"
+
+##########################################################################################
+# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+# This allows using the maven wrapper in projects that prohibit checking in binary data.
+##########################################################################################
+wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar"
+if [ -r "$wrapperJarPath" ]; then
+ log "Found $wrapperJarPath"
+else
+ log "Couldn't find $wrapperJarPath, downloading it ..."
+
+ if [ -n "$MVNW_REPOURL" ]; then
+ wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar"
+ else
+ wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar"
+ fi
+ while IFS="=" read -r key value; do
+ # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' )
+ safeValue=$(echo "$value" | tr -d '\r')
+ case "$key" in wrapperUrl)
+ wrapperUrl="$safeValue"
+ break
+ ;;
+ esac
+ done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
+ log "Downloading from: $wrapperUrl"
+
+ if $cygwin; then
+ wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath")
+ fi
+
+ if command -v wget >/dev/null; then
+ log "Found wget ... using wget"
+ [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet"
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+ else
+ wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath"
+ fi
+ elif command -v curl >/dev/null; then
+ log "Found curl ... using curl"
+ [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent"
+ if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then
+ curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
+ else
+ curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath"
+ fi
+ else
+ log "Falling back to using Java to download"
+ javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java"
+ javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class"
+ # For Cygwin, switch paths to Windows format before running javac
+ if $cygwin; then
+ javaSource=$(cygpath --path --windows "$javaSource")
+ javaClass=$(cygpath --path --windows "$javaClass")
+ fi
+ if [ -e "$javaSource" ]; then
+ if [ ! -e "$javaClass" ]; then
+ log " - Compiling MavenWrapperDownloader.java ..."
+ ("$JAVA_HOME/bin/javac" "$javaSource")
+ fi
+ if [ -e "$javaClass" ]; then
+ log " - Running MavenWrapperDownloader.java ..."
+ ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath"
+ fi
+ fi
+ fi
+fi
+##########################################################################################
+# End of extension
+##########################################################################################
+
+# If specified, validate the SHA-256 sum of the Maven wrapper jar file
+wrapperSha256Sum=""
+while IFS="=" read -r key value; do
+ case "$key" in wrapperSha256Sum)
+ wrapperSha256Sum=$value
+ break
+ ;;
+ esac
+done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties"
+if [ -n "$wrapperSha256Sum" ]; then
+ wrapperSha256Result=false
+ if command -v sha256sum >/dev/null; then
+ if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c >/dev/null 2>&1; then
+ wrapperSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c >/dev/null 2>&1; then
+ wrapperSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $wrapperSha256Result = false ]; then
+ echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2
+ echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2
+ echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS"
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin; then
+ [ -n "$JAVA_HOME" ] \
+ && JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME")
+ [ -n "$CLASSPATH" ] \
+ && CLASSPATH=$(cygpath --path --windows "$CLASSPATH")
+ [ -n "$MAVEN_PROJECTBASEDIR" ] \
+ && MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR")
+fi
+
+# Provide a "standardized" way to retrieve the CLI args that will
+# work with both Windows and non-Windows executions.
+MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*"
+export MAVEN_CMD_LINE_ARGS
+
+WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+# shellcheck disable=SC2086 # safe args
+exec "$JAVACMD" \
+ $MAVEN_OPTS \
+ $MAVEN_DEBUG_OPTS \
+ -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \
+ "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \
+ ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..4136715
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,206 @@
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Required ENV vars:
+@REM JAVA_HOME - location of a JDK home dir
+@REM
+@REM Optional ENV vars
+@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands
+@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending
+@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven
+@REM e.g. to debug Maven itself, use
+@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000
+@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files
+@REM ----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO%
+
+@REM set %HOME% to equivalent of $HOME
+if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%")
+
+@REM Execute a user defined script before this one
+if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre
+@REM check for pre script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %*
+if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %*
+:skipRcPre
+
+@setlocal
+
+set ERROR_CODE=0
+
+@REM To isolate internal variables from possible post scripts, we use another setlocal
+@setlocal
+
+@REM ==== START VALIDATION ====
+if not "%JAVA_HOME%" == "" goto OkJHome
+
+echo. >&2
+echo Error: JAVA_HOME not found in your environment. >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo. >&2
+goto error
+
+:OkJHome
+if exist "%JAVA_HOME%\bin\java.exe" goto init
+
+echo. >&2
+echo Error: JAVA_HOME is set to an invalid directory. >&2
+echo JAVA_HOME = "%JAVA_HOME%" >&2
+echo Please set the JAVA_HOME variable in your environment to match the >&2
+echo location of your Java installation. >&2
+echo. >&2
+goto error
+
+@REM ==== END VALIDATION ====
+
+:init
+
+@REM Find the project base dir, i.e. the directory that contains the folder ".mvn".
+@REM Fallback to current working directory if not found.
+
+set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR%
+IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir
+
+set EXEC_DIR=%CD%
+set WDIR=%EXEC_DIR%
+:findBaseDir
+IF EXIST "%WDIR%"\.mvn goto baseDirFound
+cd ..
+IF "%WDIR%"=="%CD%" goto baseDirNotFound
+set WDIR=%CD%
+goto findBaseDir
+
+:baseDirFound
+set MAVEN_PROJECTBASEDIR=%WDIR%
+cd "%EXEC_DIR%"
+goto endDetectBaseDir
+
+:baseDirNotFound
+set MAVEN_PROJECTBASEDIR=%EXEC_DIR%
+cd "%EXEC_DIR%"
+
+:endDetectBaseDir
+
+IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig
+
+@setlocal EnableExtensions EnableDelayedExpansion
+for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a
+@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS%
+
+:endReadAdditionalConfig
+
+SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe"
+set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar"
+set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain
+
+set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar"
+
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+ IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B
+)
+
+@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central
+@REM This allows using the maven wrapper in projects that prohibit checking in binary data.
+if exist %WRAPPER_JAR% (
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Found %WRAPPER_JAR%
+ )
+) else (
+ if not "%MVNW_REPOURL%" == "" (
+ SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar"
+ )
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Couldn't find %WRAPPER_JAR%, downloading it ...
+ echo Downloading from: %WRAPPER_URL%
+ )
+
+ powershell -Command "&{"^
+ "$webclient = new-object System.Net.WebClient;"^
+ "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^
+ "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^
+ "}"^
+ "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^
+ "}"
+ if "%MVNW_VERBOSE%" == "true" (
+ echo Finished downloading %WRAPPER_JAR%
+ )
+)
+@REM End of extension
+
+@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file
+SET WRAPPER_SHA_256_SUM=""
+FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO (
+ IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B
+)
+IF NOT %WRAPPER_SHA_256_SUM%=="" (
+ powershell -Command "&{"^
+ "Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash;"^
+ "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^
+ "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^
+ " Write-Error 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^
+ " Write-Error 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^
+ " Write-Error 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^
+ " exit 1;"^
+ "}"^
+ "}"
+ if ERRORLEVEL 1 goto error
+)
+
+@REM Provide a "standardized" way to retrieve the CLI args that will
+@REM work with both Windows and non-Windows executions.
+set MAVEN_CMD_LINE_ARGS=%*
+
+%MAVEN_JAVA_EXE% ^
+ %JVM_CONFIG_MAVEN_PROPS% ^
+ %MAVEN_OPTS% ^
+ %MAVEN_DEBUG_OPTS% ^
+ -classpath %WRAPPER_JAR% ^
+ "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^
+ %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %*
+if ERRORLEVEL 1 goto error
+goto end
+
+:error
+set ERROR_CODE=1
+
+:end
+@endlocal & set ERROR_CODE=%ERROR_CODE%
+
+if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost
+@REM check for post script, once with legacy .bat ending and once with .cmd ending
+if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat"
+if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd"
+:skipRcPost
+
+@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on'
+if "%MAVEN_BATCH_PAUSE%"=="on" pause
+
+if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE%
+
+cmd /C exit /B %ERROR_CODE%
diff --git a/mvnwDebug b/mvnwDebug
new file mode 100644
index 0000000..f1532cd
--- /dev/null
+++ b/mvnwDebug
@@ -0,0 +1,35 @@
+#!/bin/sh
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -----------------------------------------------------------------------------
+# Apache Maven Wrapper debug script, version 3.3.2
+#
+# Environment Variable Prerequisites
+#
+# JAVA_HOME (Optional) Points to a Java installation.
+# MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+# MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+# MAVEN_DEBUG_ADDRESS (Optional) Set the debug address. Default value is localhost:8000
+# -----------------------------------------------------------------------------
+
+MAVEN_DEBUG_OPTS="-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=${MAVEN_DEBUG_ADDRESS:-localhost:8000}"
+
+echo Preparing to execute Maven Wrapper in debug mode
+
+env MAVEN_OPTS="$MAVEN_OPTS" MAVEN_DEBUG_OPTS="$MAVEN_DEBUG_OPTS" "$(dirname "$0")/mvnw" "$@"
diff --git a/mvnwDebug.cmd b/mvnwDebug.cmd
new file mode 100644
index 0000000..74db267
--- /dev/null
+++ b/mvnwDebug.cmd
@@ -0,0 +1,44 @@
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+
+@REM -----------------------------------------------------------------------------
+@REM Apache Maven Wrapper debug script, version 3.3.2
+@REM
+@REM Environment Variable Prerequisites
+@REM
+@REM JAVA_HOME (Optional) Points to a Java installation.
+@REM MAVEN_BATCH_ECHO (Optional) Set to 'on' to enable the echoing of the batch commands.
+@REM MAVEN_BATCH_PAUSE (Optional) set to 'on' to wait for a key stroke before ending.
+@REM MAVEN_OPTS (Optional) Java runtime options used when Maven is executed.
+@REM MAVEN_SKIP_RC (Optional) Flag to disable loading of mavenrc files.
+@REM MAVEN_DEBUG_ADDRESS (Optional) Set the debug address. Default value is localhost:8000
+@REM -----------------------------------------------------------------------------
+
+@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on'
+@echo off
+@REM set title of command window
+title %0
+@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on'
+@if "%MAVEN_BATCH_ECHO%"=="on" echo %MAVEN_BATCH_ECHO%
+
+@setlocal
+
+IF "%MAVEN_DEBUG_ADDRESS%"=="" @set MAVEN_DEBUG_ADDRESS=localhost:8000
+
+@set MAVEN_DEBUG_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=%MAVEN_DEBUG_ADDRESS%
+
+@call "%~dp0"mvnw.cmd %*
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..79f2163
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,77 @@
+user root;
+worker_processes auto;
+
+error_log /var/log/nginx/error.log notice;
+pid /var/run/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+ sendfile on;
+ keepalive_timeout 65;
+
+ server {
+ listen 80;
+ server_name localhost;
+
+ # Frontend SPA static files
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # Gzip compression
+ gzip on;
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
+
+ # Security headers
+ add_header X-Frame-Options "SAMEORIGIN" always;
+ add_header X-Content-Type-Options "nosniff" always;
+ add_header X-XSS-Protection "1; mode=block" always;
+
+ # API proxy to backend
+ location /api/ {
+ proxy_pass http://publish-backend:8080;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_connect_timeout 60s;
+ proxy_send_timeout 60s;
+ proxy_read_timeout 60s;
+ }
+
+ # Cover images and uploaded files - serve directly from volume mount
+ location /uploads/ {
+ alias /app/uploads/;
+ expires 7d;
+ add_header Cache-Control "public";
+ }
+
+ # Static assets caching
+ location /assets/ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ # SPA fallback - all non-API requests return index.html
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+
+ # Error pages
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+ }
+}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..b24f9b0
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4522 @@
+{
+ "name": "daily-report-frontend",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "daily-report-frontend",
+ "version": "1.0.0",
+ "dependencies": {
+ "@vue-office/pptx": "^1.0.1",
+ "axios": "^1.6.8",
+ "marked": "^12.0.0",
+ "vue": "^3.4.21",
+ "vue-demi": "^0.14.6",
+ "vue-router": "^4.3.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.60.0",
+ "@tailwindcss/postcss": "^4.3.0",
+ "@tailwindcss/vite": "^4.3.0",
+ "@vitejs/plugin-vue": "^5.0.4",
+ "@vue/test-utils": "^2.4.6",
+ "autoprefixer": "^10.5.0",
+ "jsdom": "^24.0.0",
+ "postcss": "^8.5.15",
+ "tailwindcss": "^4.3.0",
+ "vite": "^5.2.0",
+ "vitest": "^1.4.0"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
+ "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/css-calc": "^2.1.3",
+ "@csstools/css-color-parser": "^3.0.9",
+ "@csstools/css-parser-algorithms": "^3.0.4",
+ "@csstools/css-tokenizer": "^3.0.3",
+ "lru-cache": "^10.4.3"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.3",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
+ "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
+ "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz",
+ "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz",
+ "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^5.1.0",
+ "@csstools/css-calc": "^2.1.4"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^3.0.5",
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz",
+ "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^3.0.4"
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
+ "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+ "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+ "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+ "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+ "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+ "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+ "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+ "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+ "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+ "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+ "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+ "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+ "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+ "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+ "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+ "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+ "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+ "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+ "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+ "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+ "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+ "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@isaacs/cliui": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
+ "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^5.1.2",
+ "string-width-cjs": "npm:string-width@^4.2.0",
+ "strip-ansi": "^7.0.1",
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
+ "wrap-ansi": "^8.1.0",
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/@jest/schemas": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz",
+ "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@sinclair/typebox": "^0.27.8"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@one-ini/wasm": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz",
+ "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
+ "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz",
+ "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz",
+ "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz",
+ "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz",
+ "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz",
+ "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz",
+ "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz",
+ "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz",
+ "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz",
+ "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz",
+ "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz",
+ "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz",
+ "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz",
+ "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz",
+ "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz",
+ "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz",
+ "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz",
+ "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz",
+ "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz",
+ "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz",
+ "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz",
+ "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz",
+ "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz",
+ "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz",
+ "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@sinclair/typebox": {
+ "version": "0.27.10",
+ "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz",
+ "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz",
+ "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.21.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
+ "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.0",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.0",
+ "@tailwindcss/oxide-darwin-x64": "4.3.0",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.0",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.0",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.0",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
+ "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
+ "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
+ "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
+ "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
+ "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
+ "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
+ "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
+ "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
+ "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
+ "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.10.0",
+ "@emnapi/runtime": "^1.10.0",
+ "@emnapi/wasi-threads": "^1.2.1",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
+ "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
+ "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz",
+ "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.3.0",
+ "@tailwindcss/oxide": "4.3.0",
+ "postcss": "^8.5.10",
+ "tailwindcss": "4.3.0"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz",
+ "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.0",
+ "@tailwindcss/oxide": "4.3.0",
+ "tailwindcss": "4.3.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "5.2.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
+ "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
+ "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "chai": "^4.3.10"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz",
+ "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "1.6.1",
+ "p-limit": "^5.0.0",
+ "pathe": "^1.1.1"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz",
+ "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz",
+ "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyspy": "^2.2.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz",
+ "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "diff-sequences": "^29.6.3",
+ "estree-walker": "^3.0.3",
+ "loupe": "^2.3.7",
+ "pretty-format": "^29.7.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vue-office/pptx": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@vue-office/pptx/-/pptx-1.0.1.tgz",
+ "integrity": "sha512-+V7Kctzl6f6+Yk4NaD/wQGRIkqLWcowe0jEhPexWQb8Oilbzt1OyhWRWcMsxNDTdrgm6aMLP+0/tmw27cxddMg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@vue/composition-api": "^1.7.1",
+ "vue": "^2.0.0 || >=3.0.0",
+ "vue-demi": "^0.14.6"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
+ "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/shared": "3.5.34",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
+ "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz",
+ "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.3",
+ "@vue/compiler-core": "3.5.34",
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.14",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz",
+ "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz",
+ "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz",
+ "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/shared": "3.5.34"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz",
+ "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.34",
+ "@vue/runtime-core": "3.5.34",
+ "@vue/shared": "3.5.34",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz",
+ "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "vue": "3.5.34"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
+ "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
+ "license": "MIT"
+ },
+ "node_modules/@vue/test-utils": {
+ "version": "2.4.10",
+ "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.10.tgz",
+ "integrity": "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-beautify": "^1.14.9",
+ "vue-component-type-helpers": "^3.0.0"
+ },
+ "peerDependencies": {
+ "@vue/compiler-dom": "3.x",
+ "@vue/server-renderer": "3.x",
+ "vue": "3.x"
+ },
+ "peerDependenciesMeta": {
+ "@vue/server-renderer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/abbrev": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+ "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-walk": {
+ "version": "8.3.5",
+ "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
+ "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.11.0"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
+ "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-regex?sponsor=1"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+ "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/asynckit": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "license": "MIT"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/axios": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz",
+ "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==",
+ "license": "MIT",
+ "dependencies": {
+ "follow-redirects": "^1.16.0",
+ "form-data": "^4.0.5",
+ "https-proxy-agent": "^5.0.1",
+ "proxy-from-env": "^2.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.32",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
+ "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+ "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/cac": {
+ "version": "6.7.14",
+ "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
+ "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001793",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
+ "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
+ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "assertion-error": "^1.1.0",
+ "check-error": "^1.0.3",
+ "deep-eql": "^4.1.3",
+ "get-func-name": "^2.0.2",
+ "loupe": "^2.3.6",
+ "pathval": "^1.1.1",
+ "type-detect": "^4.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/check-error": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
+ "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.2"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/combined-stream": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "license": "MIT",
+ "dependencies": {
+ "delayed-stream": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/commander": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
+ "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/confbox": {
+ "version": "0.1.8",
+ "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz",
+ "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/config-chain": {
+ "version": "1.1.13",
+ "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz",
+ "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ini": "^1.3.4",
+ "proto-list": "~1.2.1"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssstyle": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
+ "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^3.2.0",
+ "rrweb-cssom": "^0.8.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cssstyle/node_modules/rrweb-cssom": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
+ "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
+ "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-eql": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
+ "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "type-detect": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/delayed-stream": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/diff-sequences": {
+ "version": "29.6.3",
+ "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz",
+ "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/eastasianwidth": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
+ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/editorconfig": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz",
+ "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@one-ini/wasm": "0.1.1",
+ "commander": "^10.0.0",
+ "minimatch": "^9.0.1",
+ "semver": "^7.5.3"
+ },
+ "bin": {
+ "editorconfig": "bin/editorconfig"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.361",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz",
+ "integrity": "sha512-Q6Hts7N9FnJc5LeGRINFvLhCI9xZmNtTDe5ZbcVezQz7cU4a8Aua3GH1b8J2XY8Al9PF+OCwYqhgsOOheMdvkA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/emoji-regex": {
+ "version": "9.2.2",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.22.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.22.0.tgz",
+ "integrity": "sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-set-tostringtag": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.6",
+ "has-tostringtag": "^1.0.2",
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.21.5",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
+ "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.21.5",
+ "@esbuild/android-arm": "0.21.5",
+ "@esbuild/android-arm64": "0.21.5",
+ "@esbuild/android-x64": "0.21.5",
+ "@esbuild/darwin-arm64": "0.21.5",
+ "@esbuild/darwin-x64": "0.21.5",
+ "@esbuild/freebsd-arm64": "0.21.5",
+ "@esbuild/freebsd-x64": "0.21.5",
+ "@esbuild/linux-arm": "0.21.5",
+ "@esbuild/linux-arm64": "0.21.5",
+ "@esbuild/linux-ia32": "0.21.5",
+ "@esbuild/linux-loong64": "0.21.5",
+ "@esbuild/linux-mips64el": "0.21.5",
+ "@esbuild/linux-ppc64": "0.21.5",
+ "@esbuild/linux-riscv64": "0.21.5",
+ "@esbuild/linux-s390x": "0.21.5",
+ "@esbuild/linux-x64": "0.21.5",
+ "@esbuild/netbsd-x64": "0.21.5",
+ "@esbuild/openbsd-x64": "0.21.5",
+ "@esbuild/sunos-x64": "0.21.5",
+ "@esbuild/win32-arm64": "0.21.5",
+ "@esbuild/win32-ia32": "0.21.5",
+ "@esbuild/win32-x64": "0.21.5"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/execa": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz",
+ "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.3",
+ "get-stream": "^8.0.1",
+ "human-signals": "^5.0.0",
+ "is-stream": "^3.0.0",
+ "merge-stream": "^2.0.0",
+ "npm-run-path": "^5.1.0",
+ "onetime": "^6.0.0",
+ "signal-exit": "^4.1.0",
+ "strip-final-newline": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=16.17"
+ },
+ "funding": {
+ "url": "https://github.com/sindresorhus/execa?sponsor=1"
+ }
+ },
+ "node_modules/follow-redirects": {
+ "version": "1.16.0",
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/RubenVerborgh"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0"
+ },
+ "peerDependenciesMeta": {
+ "debug": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/foreground-child": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
+ "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "cross-spawn": "^7.0.6",
+ "signal-exit": "^4.0.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/form-data": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+ "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "hasown": "^2.0.2",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-func-name": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz",
+ "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/get-stream": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
+ "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/glob": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
+ "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "foreground-child": "^3.1.0",
+ "jackspeak": "^3.1.2",
+ "minimatch": "^9.0.4",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^1.11.1"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-tostringtag": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "license": "MIT",
+ "dependencies": {
+ "has-symbols": "^1.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz",
+ "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-encoding": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/http-proxy-agent/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/human-signals": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
+ "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-stream": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
+ "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jackspeak": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
+ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^8.0.2"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ },
+ "optionalDependencies": {
+ "@pkgjs/parseargs": "^0.11.0"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-beautify": {
+ "version": "1.15.4",
+ "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
+ "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "config-chain": "^1.1.13",
+ "editorconfig": "^1.0.4",
+ "glob": "^10.4.2",
+ "js-cookie": "^3.0.5",
+ "nopt": "^7.2.1"
+ },
+ "bin": {
+ "css-beautify": "js/bin/css-beautify.js",
+ "html-beautify": "js/bin/html-beautify.js",
+ "js-beautify": "js/bin/js-beautify.js"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/js-cookie": {
+ "version": "3.0.7",
+ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz",
+ "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "24.1.3",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz",
+ "integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssstyle": "^4.0.1",
+ "data-urls": "^5.0.0",
+ "decimal.js": "^10.4.3",
+ "form-data": "^4.0.0",
+ "html-encoding-sniffer": "^4.0.0",
+ "http-proxy-agent": "^7.0.2",
+ "https-proxy-agent": "^7.0.5",
+ "is-potential-custom-element-name": "^1.0.1",
+ "nwsapi": "^2.2.12",
+ "parse5": "^7.1.2",
+ "rrweb-cssom": "^0.7.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^4.1.4",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^7.0.0",
+ "whatwg-encoding": "^3.1.1",
+ "whatwg-mimetype": "^4.0.0",
+ "whatwg-url": "^14.0.0",
+ "ws": "^8.18.0",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "canvas": "^2.11.2"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/agent-base": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
+ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/jsdom/node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/local-pkg": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz",
+ "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mlly": "^1.7.3",
+ "pkg-types": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/loupe": {
+ "version": "2.3.7",
+ "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
+ "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "get-func-name": "^2.0.1"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "10.4.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
+ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/marked": {
+ "version": "12.0.2",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
+ "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/merge-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+ "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mimic-fn": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
+ "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/mlly": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
+ "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "pathe": "^2.0.3",
+ "pkg-types": "^1.3.1",
+ "ufo": "^1.6.3"
+ }
+ },
+ "node_modules/mlly/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.46",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
+ "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/nopt": {
+ "version": "7.2.1",
+ "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz",
+ "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "abbrev": "^2.0.0"
+ },
+ "bin": {
+ "nopt": "bin/nopt.js"
+ },
+ "engines": {
+ "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
+ }
+ },
+ "node_modules/npm-run-path": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz",
+ "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^4.0.0"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/npm-run-path/node_modules/path-key": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz",
+ "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/nwsapi": {
+ "version": "2.2.23",
+ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz",
+ "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/onetime": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz",
+ "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mimic-fn": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz",
+ "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/package-json-from-dist": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
+ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0"
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-scurry": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
+ "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^10.2.0",
+ "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
+ "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathval": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+ "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/pkg-types": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz",
+ "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "confbox": "^0.1.8",
+ "mlly": "^1.7.4",
+ "pathe": "^2.0.1"
+ }
+ },
+ "node_modules/pkg-types/node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/playwright": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
+ "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.60.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.60.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
+ "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pretty-format": {
+ "version": "29.7.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
+ "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jest/schemas": "^29.6.3",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^18.0.0"
+ },
+ "engines": {
+ "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
+ }
+ },
+ "node_modules/proto-list": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
+ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/proxy-from-env": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
+ "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/psl": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
+ "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/lupomontero"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/querystringify": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
+ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/react-is": {
+ "version": "18.3.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
+ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/requires-port": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
+ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.60.4",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz",
+ "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.60.4",
+ "@rollup/rollup-android-arm64": "4.60.4",
+ "@rollup/rollup-darwin-arm64": "4.60.4",
+ "@rollup/rollup-darwin-x64": "4.60.4",
+ "@rollup/rollup-freebsd-arm64": "4.60.4",
+ "@rollup/rollup-freebsd-x64": "4.60.4",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.60.4",
+ "@rollup/rollup-linux-arm-musleabihf": "4.60.4",
+ "@rollup/rollup-linux-arm64-gnu": "4.60.4",
+ "@rollup/rollup-linux-arm64-musl": "4.60.4",
+ "@rollup/rollup-linux-loong64-gnu": "4.60.4",
+ "@rollup/rollup-linux-loong64-musl": "4.60.4",
+ "@rollup/rollup-linux-ppc64-gnu": "4.60.4",
+ "@rollup/rollup-linux-ppc64-musl": "4.60.4",
+ "@rollup/rollup-linux-riscv64-gnu": "4.60.4",
+ "@rollup/rollup-linux-riscv64-musl": "4.60.4",
+ "@rollup/rollup-linux-s390x-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-gnu": "4.60.4",
+ "@rollup/rollup-linux-x64-musl": "4.60.4",
+ "@rollup/rollup-openbsd-x64": "4.60.4",
+ "@rollup/rollup-openharmony-arm64": "4.60.4",
+ "@rollup/rollup-win32-arm64-msvc": "4.60.4",
+ "@rollup/rollup-win32-ia32-msvc": "4.60.4",
+ "@rollup/rollup-win32-x64-gnu": "4.60.4",
+ "@rollup/rollup-win32-x64-msvc": "4.60.4",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/rrweb-cssom": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
+ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz",
+ "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
+ "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eastasianwidth": "^0.2.0",
+ "emoji-regex": "^9.2.2",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/string-width-cjs": {
+ "name": "string-width",
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/string-width-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/string-width-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
+ "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^6.2.2"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/strip-ansi?sponsor=1"
+ }
+ },
+ "node_modules/strip-ansi-cjs": {
+ "name": "strip-ansi",
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-final-newline": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz",
+ "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/strip-literal": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz",
+ "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "js-tokens": "^9.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
+ "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinypool": {
+ "version": "0.8.4",
+ "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz",
+ "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tinyspy": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz",
+ "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tough-cookie": {
+ "version": "4.1.4",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz",
+ "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "psl": "^1.1.33",
+ "punycode": "^2.1.1",
+ "universalify": "^0.2.0",
+ "url-parse": "^1.5.3"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/type-detect": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
+ "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ufo": {
+ "version": "1.6.4",
+ "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
+ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/universalify": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
+ "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/url-parse": {
+ "version": "1.5.10",
+ "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
+ "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "querystringify": "^2.1.1",
+ "requires-port": "^1.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "5.4.21",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
+ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.21.3",
+ "postcss": "^8.4.43",
+ "rollup": "^4.20.0"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.4.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite-node": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz",
+ "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cac": "^6.7.14",
+ "debug": "^4.3.4",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "vite": "^5.0.0"
+ },
+ "bin": {
+ "vite-node": "vite-node.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz",
+ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "1.6.1",
+ "@vitest/runner": "1.6.1",
+ "@vitest/snapshot": "1.6.1",
+ "@vitest/spy": "1.6.1",
+ "@vitest/utils": "1.6.1",
+ "acorn-walk": "^8.3.2",
+ "chai": "^4.3.10",
+ "debug": "^4.3.4",
+ "execa": "^8.0.1",
+ "local-pkg": "^0.5.0",
+ "magic-string": "^0.30.5",
+ "pathe": "^1.1.1",
+ "picocolors": "^1.0.0",
+ "std-env": "^3.5.0",
+ "strip-literal": "^2.0.0",
+ "tinybench": "^2.5.1",
+ "tinypool": "^0.8.3",
+ "vite": "^5.0.0",
+ "vite-node": "1.6.1",
+ "why-is-node-running": "^2.2.2"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^18.0.0 || >=20.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@types/node": "^18.0.0 || >=20.0.0",
+ "@vitest/browser": "1.6.1",
+ "@vitest/ui": "1.6.1",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue": {
+ "version": "3.5.34",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz",
+ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.34",
+ "@vue/compiler-sfc": "3.5.34",
+ "@vue/runtime-dom": "3.5.34",
+ "@vue/server-renderer": "3.5.34",
+ "@vue/shared": "3.5.34"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-component-type-helpers": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.3.1.tgz",
+ "integrity": "sha512-pu58kqxmVyEH6VfNYW1UyEfR3XAnJ27ZXT3yzXxxpjLxVzAbyC35Zk/nm/RMs7ijWnJNSd9fWkeex2OhUsx3MA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue-demi": {
+ "version": "0.14.6",
+ "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.6.tgz",
+ "integrity": "sha512-8QA7wrYSHKaYgUxDA5ZC24w+eHm3sYCbp0EzcDwKqN3p6HqtTCGR/GVsPyZW92unff4UlcSh++lmqDWN3ZIq4w==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "vue-demi-fix": "bin/vue-demi-fix.js",
+ "vue-demi-switch": "bin/vue-demi-switch.js"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vue/composition-api": "^1.0.0-rc.1",
+ "vue": "^3.0.0-0 || ^2.6.0"
+ },
+ "peerDependenciesMeta": {
+ "@vue/composition-api": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "14.2.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tr46": "^5.1.0",
+ "webidl-conversions": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
+ "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^6.1.0",
+ "string-width": "^5.0.1",
+ "strip-ansi": "^7.0.1"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs": {
+ "name": "wrap-ansi",
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/wrap-ansi/node_modules/ansi-styles": {
+ "version": "6.2.3",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
+ "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yocto-queue": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
+ "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..00ecc48
--- /dev/null
+++ b/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "daily-report-frontend",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test": "vitest",
+ "test:run": "vitest run",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui"
+ },
+ "dependencies": {
+ "@vue-office/pptx": "^1.0.1",
+ "axios": "^1.6.8",
+ "marked": "^12.0.0",
+ "vue": "^3.4.21",
+ "vue-demi": "^0.14.6",
+ "vue-router": "^4.3.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.60.0",
+ "@tailwindcss/postcss": "^4.3.0",
+ "@tailwindcss/vite": "^4.3.0",
+ "@vitejs/plugin-vue": "^5.0.4",
+ "@vue/test-utils": "^2.4.6",
+ "autoprefixer": "^10.5.0",
+ "jsdom": "^24.0.0",
+ "postcss": "^8.5.15",
+ "tailwindcss": "^4.3.0",
+ "vite": "^5.2.0",
+ "vitest": "^1.4.0"
+ }
+}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..9b642f3
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,33 @@
+import { defineConfig, devices } from '@playwright/test'
+
+export default defineConfig({
+ testDir: './tests/e2e',
+ fullyParallel: false,
+ forbidOnly: !!process.env.CI,
+ retries: 0,
+ workers: 1,
+ reporter: 'list',
+ timeout: 60000,
+ expect: {
+ timeout: 10000
+ },
+ use: {
+ baseURL: 'http://127.0.0.1:41733',
+ trace: 'on-first-retry',
+ screenshot: 'only-on-failure'
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] }
+ }
+ ],
+ webServer: {
+ command: 'npm run dev',
+ url: 'http://127.0.0.1:41733',
+ reuseExistingServer: true,
+ timeout: 120000,
+ stdout: 'pipe',
+ stderr: 'pipe'
+ }
+})
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..abd5136
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,140 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ com.reportdist
+ daily-report-distribution
+ 1.0.0
+ daily-report-distribution
+ Daily Report Distribution Backend Service
+
+
+ 17
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.xerial
+ sqlite-jdbc
+ 3.45.1.0
+
+
+
+
+ org.hibernate.orm
+ hibernate-community-dialects
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+ com.h2database
+ h2
+ test
+
+
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.2.5
+
+
+
+
+ org.apache.pdfbox
+ pdfbox
+ 2.0.31
+
+
+
+
+ org.apache.poi
+ poi-scratchpad
+ 5.2.5
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ 17
+ 17
+
+
+ org.projectlombok
+ lombok
+ 1.18.30
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+ true
+
+
+
+
+
+
\ No newline at end of file
diff --git a/postcss.config.cjs b/postcss.config.cjs
new file mode 100644
index 0000000..bdc1439
--- /dev/null
+++ b/postcss.config.cjs
@@ -0,0 +1 @@
+/* empty - backup that can be deleted */
\ No newline at end of file
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..2e1d5ec
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1 @@
+// Not used anymore - using @tailwindcss/vite plugin instead
\ No newline at end of file
diff --git a/run-backend.bat b/run-backend.bat
new file mode 100644
index 0000000..e80076f
--- /dev/null
+++ b/run-backend.bat
@@ -0,0 +1,3 @@
+@echo off
+cd /d "D:\Idea Project\publish"
+"C:\Program Files\Java\jdk-21.0.11\bin\java.exe" -jar target\daily-report-distribution-1.0.0.jar
\ No newline at end of file
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8bf5987
--- /dev/null
+++ b/server.js
@@ -0,0 +1,117 @@
+// Frontend: Node.js static file server with API proxy
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const url = require('url');
+
+const PORT = 80;
+const BACKEND = process.env.BACKEND_URL || 'http://publish-backend:8080';
+const STATIC_DIR = '/app/dist';
+const UPLOADS_DIR = '/app/uploads';
+
+const MIME_TYPES = {
+ '.html': 'text/html',
+ '.js': 'application/javascript',
+ '.css': 'text/css',
+ '.json': 'application/json',
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.ico': 'image/x-icon',
+ '.woff': 'font/woff',
+ '.woff2': 'font/woff2',
+ '.ttf': 'font/ttf',
+ '.eot': 'application/vnd.ms-fontobject',
+ '.pdf': 'application/pdf',
+ '.md': 'text/markdown',
+ '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+ '.ppt': 'application/vnd.ms-powerpoint',
+};
+
+function serveStatic(req, res) {
+ let filePath = path.join(STATIC_DIR, req.url === '/' ? '/index.html' : req.url);
+ // Remove query string
+ filePath = filePath.split('?')[0];
+
+ const ext = path.extname(filePath).toLowerCase();
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
+
+ fs.readFile(filePath, (err, data) => {
+ if (err) {
+ // SPA fallback: serve index.html
+ if (req.url.startsWith('/api') || req.url.startsWith('/uploads')) {
+ proxyRequest(req, res);
+ } else {
+ fs.readFile(path.join(STATIC_DIR, 'index.html'), (err2, data2) => {
+ if (err2) {
+ res.writeHead(404);
+ res.end('Not Found');
+ } else {
+ res.writeHead(200, { 'Content-Type': 'text/html' });
+ res.end(data2);
+ }
+ });
+ }
+ } else {
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(data);
+ }
+ });
+}
+
+function proxyRequest(req, res) {
+ const targetUrl = BACKEND + req.url;
+ const parsedUrl = url.parse(req.url);
+ const options = {
+ hostname: url.parse(BACKEND).hostname,
+ port: url.parse(BACKEND).port || 80,
+ path: req.url,
+ method: req.method,
+ headers: {}
+ };
+
+ // Forward relevant headers
+ ['content-type', 'authorization', 'accept', 'x-requested-with', 'host'].forEach(h => {
+ if (req.headers[h]) options.headers[h] = req.headers[h];
+ });
+
+ const proxyReq = http.request(options, (proxyRes) => {
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res);
+ });
+
+ proxyReq.on('error', (e) => {
+ res.writeHead(502);
+ res.end('Backend error: ' + e.message);
+ });
+
+ if (['POST', 'PUT', 'DELETE'].includes(req.method)) {
+ req.pipe(proxyReq);
+ } else {
+ proxyReq.end();
+ }
+}
+
+const server = http.createServer((req, res) => {
+ // CORS headers
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ if (req.method === 'OPTIONS') {
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+
+ if (req.url.startsWith('/api') || req.url.startsWith('/uploads')) {
+ proxyRequest(req, res);
+ } else {
+ serveStatic(req, res);
+ }
+});
+
+server.listen(PORT, () => {
+ console.log(`Server running on port ${PORT}`);
+ console.log(`Backend: ${BACKEND}`);
+});
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..c8a8340
--- /dev/null
+++ b/server.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Simple static file server with /api proxy to backend."""
+import os
+import http.server
+import socketserver
+import urllib.request
+import urllib.error
+from pathlib import Path
+
+PORT = 80
+BACKEND = os.environ.get('BACKEND_URL', 'http://publish-backend:8080')
+STATIC_DIR = '/app/dist'
+UPLOADS_DIR = '/app/uploads'
+STATIC_PATH = Path(STATIC_DIR)
+UPLOADS_PATH = Path(UPLOADS_DIR)
+
+
+class ProxyHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
+ """Serve static files + proxy /api/ requests to backend."""
+
+ def do_GET(self):
+ if self.path.startswith('/api/') or self.path.startswith('/uploads/'):
+ self.proxy_request()
+ else:
+ super().do_GET()
+
+ def do_POST(self):
+ if self.path.startswith('/api/'):
+ self.proxy_request()
+ else:
+ self.send_error(404)
+
+ def do_PUT(self):
+ if self.path.startswith('/api/'):
+ self.proxy_request()
+ else:
+ self.send_error(404)
+
+ def do_DELETE(self):
+ if self.path.startswith('/api/'):
+ self.proxy_request()
+ else:
+ self.send_error(404)
+
+ def proxy_request(self):
+ """Forward request to backend and return response."""
+ url = BACKEND + self.path
+ content_length = int(self.headers.get('Content-Length', 0))
+ body = self.rfile.read(content_length) if content_length else None
+
+ headers = {}
+ for key in ('Content-Type', 'Authorization', 'Accept', 'X-Requested-With'):
+ if key in self.headers:
+ headers[key] = self.headers[key]
+
+ try:
+ req = urllib.request.Request(url, data=body, headers=headers, method=self.command)
+ with urllib.request.urlopen(req, timeout=30) as response:
+ self.send_response(response.status)
+ self.send_headers(response.headers)
+ self.wfile.write(response.read())
+ except urllib.error.HTTPError as e:
+ self.send_response(e.code)
+ self.send_header('Content-Type', 'application/json')
+ self.end_headers()
+ self.wfile.write(e.read())
+ except Exception as e:
+ self.send_response(502)
+ self.send_header('Content-Type', 'application/json')
+ self.end_headers()
+ self.wfile.write(f'{{"error": "{e}"}}'.encode())
+
+ def send_headers(self, headers):
+ """Copy headers from backend response."""
+ for key, value in headers.items():
+ if key not in ('Transfer-Encoding', 'Connection'):
+ self.send_header(key, value)
+ self.end_headers()
+
+ def translate_path(self, path):
+ """Serve from /app/dist for frontend, /app/uploads for files."""
+ if path.startswith('/uploads/'):
+ return str(UPLOADS_PATH / path[9:])
+ return str(STATIC_PATH / path.lstrip('/'))
+
+
+class ReuseAddrTCPServer(socketserver.TCPServer):
+ allow_reuse_address = True
+
+
+if __name__ == '__main__':
+ os.chdir('/app')
+ with ReuseAddrTCPServer(('', PORT), ProxyHTTPRequestHandler) as httpd:
+ print(f'Serving on port {PORT}, backend at {BACKEND}')
+ httpd.serve_forever()
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..6dfac77
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/FilePreview.vue b/src/components/FilePreview.vue
new file mode 100644
index 0000000..ab37eb8
--- /dev/null
+++ b/src/components/FilePreview.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+
+
+
+
{{ report.fileName }}
+
+ {{ fileTypeLabel }}
+ {{ report.reportDate }}
+ ·
+ {{ report.size }}
+
+
+
+
+
+
+
+
+
+ 下载
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Header.vue b/src/components/Header.vue
new file mode 100644
index 0000000..1b8da5b
--- /dev/null
+++ b/src/components/Header.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+ {{ title }}
+
+
{{ subtitle }}
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ProjectCard.vue b/src/components/ProjectCard.vue
new file mode 100644
index 0000000..a07750f
--- /dev/null
+++ b/src/components/ProjectCard.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ title }}
+
+
+
+
+ {{ description || '暂无描述' }}
+
+
+
+
+
+
+ {{ reportCount }} 份报告
+
+
+ {{ formatDate(createdAt) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ReportCard.vue b/src/components/ReportCard.vue
new file mode 100644
index 0000000..3ba5164
--- /dev/null
+++ b/src/components/ReportCard.vue
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+ {{ report.fileName }}
+
+
+
+ {{ fileTypeLabel }}
+
+
+ {{ report.size }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ report.reportDate || '未知时间' }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/Sidebar.vue b/src/components/Sidebar.vue
new file mode 100644
index 0000000..1747167
--- /dev/null
+++ b/src/components/Sidebar.vue
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
项目目录
+
+
+
+ {{ project.name }}
+ {{ project.reportCount }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/composables/useApi.js b/src/composables/useApi.js
new file mode 100644
index 0000000..335611b
--- /dev/null
+++ b/src/composables/useApi.js
@@ -0,0 +1,144 @@
+import axios from 'axios'
+import { ref } from 'vue'
+
+const api = axios.create({
+ baseURL: '/api',
+ timeout: 10000
+})
+
+// Mock data for development
+const mockProjects = [
+ { id: 1, name: '项目一', description: '主要产品线', reportCount: 15, todayNewReports: 2 },
+ { id: 2, name: '项目二', description: '内部工具', reportCount: 8, todayNewReports: 1 },
+ { id: 3, name: '项目三', description: '客户定制', reportCount: 12, todayNewReports: 0 }
+]
+
+const mockReports = {
+ 1: [
+ { id: 101, fileName: '2026-05-22 日报.html', fileType: 'html', reportDate: '2026-05-22', size: '15KB' },
+ { id: 102, fileName: '2026-05-21 日报.md', fileType: 'md', reportDate: '2026-05-21', size: '8KB' },
+ { id: 103, fileName: '2026-05-20 周报.pptx', fileType: 'pptx', reportDate: '2026-05-20', size: '256KB' }
+ ],
+ 2: [
+ { id: 201, fileName: '2026-05-22 开发日报.html', fileType: 'html', reportDate: '2026-05-22', size: '12KB' },
+ { id: 202, fileName: '2026-05-21 开发日报.html', fileType: 'html', reportDate: '2026-05-21', size: '11KB' }
+ ],
+ 3: [
+ { id: 301, fileName: '2026-05-22 进度报告.md', fileType: 'md', reportDate: '2026-05-22', size: '10KB' },
+ { id: 302, fileName: '2026-05-21 进度报告.md', fileType: 'md', reportDate: '2026-05-21', size: '9KB' }
+ ]
+}
+
+const mockReportContent = {
+ html: '日报内容 这是一份HTML格式的日报。
',
+ md: '# 日报标题\n\n## 工作内容\n\n1. 完成功能A\n2. 进行代码审查\n3. 修复Bug\n\n## 明日计划\n\n- 继续开发功能B\n- 优化性能',
+ pptx: null
+}
+
+export function useApi() {
+ const loading = ref(false)
+ const error = ref(null)
+
+ const fetchProjects = async () => {
+ loading.value = true
+ error.value = null
+ try {
+ const response = await api.get('/projects')
+ return response.data
+ } catch (e) {
+ // Use mock data if API fails
+ console.warn('API not available, using mock data')
+ return mockProjects
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const fetchReports = async (projectId) => {
+ loading.value = true
+ error.value = null
+ try {
+ const response = await api.get(`/reports?projectId=${projectId}`)
+ return response.data
+ } catch (e) {
+ console.warn('API not available, using mock data')
+ return mockReports[projectId] || []
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const fetchReportContent = async (reportId) => {
+ loading.value = true
+ error.value = null
+ try {
+ // Backend GET /api/reports/{id} returns ReportResponse with fileContent field
+ const response = await api.get(`/reports/${reportId}`)
+ return { content: response.data.fileContent, type: response.data.fileType }
+ } catch (e) {
+ console.warn('API not available, using mock data')
+ // Find the report type from mock data
+ for (const reports of Object.values(mockReports)) {
+ const report = reports.find(r => r.id === reportId)
+ if (report) {
+ return { content: mockReportContent[report.fileType], type: report.fileType }
+ }
+ }
+ return null
+ } finally {
+ loading.value = false
+ }
+ }
+
+ const fetchReportBytes = async (reportId) => {
+ try {
+ const response = await api.get(`/reports/${reportId}/download`, { responseType: 'arraybuffer' })
+ return response.data
+ } catch (e) {
+ console.warn('Failed to fetch report bytes:', e)
+ return null
+ }
+ }
+
+ const fetchReportPdf = async (reportId) => {
+ try {
+ const response = await api.get(`/reports/${reportId}/pdf`, { responseType: 'arraybuffer' })
+ return new Blob([response.data], { type: 'application/pdf' })
+ } catch (e) {
+ console.warn('Failed to fetch report PDF:', e)
+ return null
+ }
+ }
+
+ const updateProject = async (id, data) => {
+ loading.value = true
+ error.value = null
+ try {
+ let response
+ if (data instanceof FormData) {
+ response = await api.put(`/projects/${id}`, data, {
+ headers: { 'Content-Type': 'multipart/form-data' }
+ })
+ } else {
+ response = await api.put(`/projects/${id}`, data)
+ }
+ return response.data
+ } catch (e) {
+ console.warn('API not available, failed to update project:', e)
+ return null
+ } finally {
+ loading.value = false
+ }
+ }
+
+ return {
+ loading,
+ error,
+ fetchProjects,
+ fetchReports,
+ fetchReportContent,
+ fetchReportBytes,
+ fetchReportPdf,
+ updateProject
+ }
+}
\ No newline at end of file
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..ce6b73b
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,8 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import './styles/main.css'
+
+const app = createApp(App)
+app.use(router)
+app.mount('#app')
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/DailyReportDistributionApplication.java b/src/main/java/com/reportdist/DailyReportDistributionApplication.java
new file mode 100644
index 0000000..50a035c
--- /dev/null
+++ b/src/main/java/com/reportdist/DailyReportDistributionApplication.java
@@ -0,0 +1,12 @@
+package com.reportdist;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class DailyReportDistributionApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(DailyReportDistributionApplication.class, args);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/config/WebConfig.java b/src/main/java/com/reportdist/config/WebConfig.java
new file mode 100644
index 0000000..07586b9
--- /dev/null
+++ b/src/main/java/com/reportdist/config/WebConfig.java
@@ -0,0 +1,24 @@
+package com.reportdist.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/api/**")
+ .allowedOrigins("*")
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ .allowedHeaders("*");
+ }
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ registry.addResourceHandler("/uploads/**")
+ .addResourceLocations("file:./uploads/");
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/controller/ProjectController.java b/src/main/java/com/reportdist/controller/ProjectController.java
new file mode 100644
index 0000000..b10e740
--- /dev/null
+++ b/src/main/java/com/reportdist/controller/ProjectController.java
@@ -0,0 +1,57 @@
+package com.reportdist.controller;
+
+import com.reportdist.dto.ProjectRequest;
+import com.reportdist.dto.ProjectResponse;
+import com.reportdist.service.ProjectService;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/projects")
+@CrossOrigin(origins = "*")
+public class ProjectController {
+
+ private final ProjectService projectService;
+
+ public ProjectController(ProjectService projectService) {
+ this.projectService = projectService;
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllProjects() {
+ return ResponseEntity.ok(projectService.getAllProjects());
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getProjectById(@PathVariable Long id) {
+ return ResponseEntity.ok(projectService.getProjectById(id));
+ }
+
+ @PostMapping
+ public ResponseEntity createProject(@Valid @RequestBody ProjectRequest request) {
+ ProjectResponse response = projectService.createProject(request);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ }
+
+ @PutMapping(value = "/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ public ResponseEntity updateProject(
+ @PathVariable Long id,
+ @RequestParam(value = "name", required = false) String name,
+ @RequestParam(value = "description", required = false) String description,
+ @RequestParam(value = "coverImage", required = false) MultipartFile coverImage) {
+ ProjectResponse response = projectService.updateProject(id, name, description, coverImage);
+ return ResponseEntity.ok(response);
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteProject(@PathVariable Long id) {
+ projectService.deleteProject(id);
+ return ResponseEntity.noContent().build();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/controller/ReportController.java b/src/main/java/com/reportdist/controller/ReportController.java
new file mode 100644
index 0000000..3ffcdb0
--- /dev/null
+++ b/src/main/java/com/reportdist/controller/ReportController.java
@@ -0,0 +1,135 @@
+package com.reportdist.controller;
+
+import com.reportdist.dto.ReportRequest;
+import com.reportdist.dto.ReportResponse;
+import com.reportdist.service.ReportService;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseStatus;
+
+@RestController
+@RequestMapping("/api/reports")
+@CrossOrigin(origins = "*")
+public class ReportController {
+
+ private final ReportService reportService;
+
+ public ReportController(ReportService reportService) {
+ this.reportService = reportService;
+ }
+
+ @GetMapping("/ping")
+ public ResponseEntity> ping() {
+ return ResponseEntity.ok(java.util.Map.of("version", "v4-diag", "timestamp", java.time.Instant.now().toString()));
+ }
+
+ @GetMapping
+ public ResponseEntity> getAllReports(@RequestParam(required = false) Long projectId) {
+ return ResponseEntity.ok(reportService.getAllReports(projectId));
+ }
+
+ @GetMapping("/{id}")
+ public ResponseEntity getReportById(@PathVariable Long id) {
+ return ResponseEntity.ok(reportService.getReportById(id));
+ }
+
+ @GetMapping("/{id}/preview")
+ public ResponseEntity previewReport(@PathVariable Long id) {
+ ReportResponse report = reportService.getReportById(id);
+ String contentType = "application/octet-stream";
+ switch (report.getFileType().toLowerCase()) {
+ case "html": contentType = "text/html; charset=utf-8"; break;
+ case "md": contentType = "text/markdown; charset=utf-8"; break;
+ case "pdf": contentType = "application/pdf"; break;
+ case "pptx": contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; break;
+ case "ppt": contentType = "application/vnd.ms-powerpoint"; break;
+ }
+ byte[] bytes = reportService.getReportBytes(id);
+ return ResponseEntity.ok()
+ .header("Content-Type", contentType)
+ .header("Content-Disposition", "inline; filename=\"" + report.getFileName() + "\"")
+ .body(bytes);
+ }
+
+ @GetMapping("/{id}/download")
+ public ResponseEntity downloadReport(@PathVariable Long id) {
+ ReportResponse report = reportService.getReportById(id);
+ byte[] bytes = reportService.getReportBytes(id);
+ return ResponseEntity.ok()
+ .header("Content-Type", "application/octet-stream")
+ .header("Content-Disposition", "attachment; filename=\"" + report.getFileName() + "\"")
+ .body(bytes);
+ }
+
+ @GetMapping("/{id}/pdf")
+ public ResponseEntity getReportAsPdf(@PathVariable Long id) {
+ byte[] pdfBytes = reportService.convertReportToPdf(id);
+ return ResponseEntity.ok()
+ .header("Content-Type", "application/pdf")
+ .header("Content-Disposition", "inline; filename=preview.pdf")
+ .body(pdfBytes);
+ }
+
+ @PostMapping
+ public ResponseEntity uploadReport(
+ @RequestParam("file") MultipartFile file,
+ @RequestParam("projectId") Long projectId,
+ @RequestParam("fileType") String fileType) {
+ System.err.println("=== UPLOAD CALLED: file=" + file + " projectId=" + projectId + " fileType=" + fileType);
+ try {
+ String filename = file.getOriginalFilename();
+ if (filename != null) {
+ String extension = getFileExtension(filename).toLowerCase();
+ if (!isValidExtension(extension)) {
+ return ResponseEntity.badRequest().body(null);
+ }
+ }
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+ return ResponseEntity.status(HttpStatus.CREATED).body(response);
+ } catch (Exception e) {
+ System.err.println("=== UPLOAD EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
+ e.printStackTrace();
+ return ResponseEntity.status(500).body(new ReportResponse(null, projectId, "ERROR", fileType, "ERROR", null, "ERROR:" + e.getClass().getSimpleName() + ":" + e.getMessage()));
+ }
+ }
+
+ @PutMapping("/{id}")
+ public ResponseEntity updateReport(
+ @PathVariable Long id,
+ @RequestBody ReportRequest request) {
+ return ResponseEntity.ok(reportService.updateReport(id, request));
+ }
+
+ @DeleteMapping("/{id}")
+ public ResponseEntity deleteReport(@PathVariable Long id) {
+ reportService.deleteReport(id);
+ return ResponseEntity.noContent().build();
+ }
+
+ private String getFileExtension(String filename) {
+ int lastDotIndex = filename.lastIndexOf('.');
+ if (lastDotIndex > 0) {
+ return filename.substring(lastDotIndex + 1);
+ }
+ return "";
+ }
+
+ private boolean isValidExtension(String extension) {
+ return extension.equals("html") || extension.equals("md") ||
+ extension.equals("ppt") || extension.equals("pptx") || extension.equals("pdf");
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleError(Exception e) {
+ String msg = e.getClass().getSimpleName() + ": " + e.getMessage();
+ System.err.println("CONTROLLER ERROR: " + msg);
+ e.printStackTrace();
+ return ResponseEntity.status(500).body(new java.util.LinkedHashMap() {{
+ put("error", msg);
+ put("type", e.getClass().getName());
+ }});
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/dto/ProjectRequest.java b/src/main/java/com/reportdist/dto/ProjectRequest.java
new file mode 100644
index 0000000..c72b518
--- /dev/null
+++ b/src/main/java/com/reportdist/dto/ProjectRequest.java
@@ -0,0 +1,29 @@
+package com.reportdist.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public class ProjectRequest {
+
+ @NotBlank(message = "Project name is required")
+ private String name;
+
+ private String description;
+
+ private String coverImage;
+
+ public ProjectRequest() {}
+
+ public ProjectRequest(String name, String description) {
+ this.name = name;
+ this.description = description;
+ }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public String getCoverImage() { return coverImage; }
+ public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/dto/ProjectResponse.java b/src/main/java/com/reportdist/dto/ProjectResponse.java
new file mode 100644
index 0000000..d8c738a
--- /dev/null
+++ b/src/main/java/com/reportdist/dto/ProjectResponse.java
@@ -0,0 +1,80 @@
+package com.reportdist.dto;
+
+import com.reportdist.entity.Project;
+import java.time.LocalDateTime;
+
+public class ProjectResponse {
+
+ private Long id;
+ private String name;
+ private String description;
+ private LocalDateTime createdAt;
+ private String coverImage;
+ private long reportCount;
+ private long todayNewReports;
+
+ public ProjectResponse() {}
+
+ public ProjectResponse(Long id, String name, String description, LocalDateTime createdAt) {
+ this(id, name, description, createdAt, null, 0, 0);
+ }
+
+ public ProjectResponse(Long id, String name, String description, LocalDateTime createdAt, String coverImage) {
+ this(id, name, description, createdAt, coverImage, 0, 0);
+ }
+
+ public ProjectResponse(Long id, String name, String description, LocalDateTime createdAt, String coverImage, long reportCount) {
+ this(id, name, description, createdAt, coverImage, reportCount, 0);
+ }
+
+ public ProjectResponse(Long id, String name, String description, LocalDateTime createdAt, String coverImage, long reportCount, long todayNewReports) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ this.createdAt = createdAt;
+ this.coverImage = coverImage;
+ this.reportCount = reportCount;
+ this.todayNewReports = todayNewReports;
+ }
+
+ public static ProjectResponse fromEntity(Project project) {
+ return fromEntity(project, 0, 0);
+ }
+
+ public static ProjectResponse fromEntity(Project project, long reportCount) {
+ return fromEntity(project, reportCount, 0);
+ }
+
+ public static ProjectResponse fromEntity(Project project, long reportCount, long todayNewReports) {
+ return new ProjectResponse(
+ project.getId(),
+ project.getName(),
+ project.getDescription(),
+ project.getCreatedAt(),
+ project.getCoverImage(),
+ reportCount,
+ todayNewReports
+ );
+ }
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+
+ public String getCoverImage() { return coverImage; }
+ public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
+
+ public long getReportCount() { return reportCount; }
+ public void setReportCount(long reportCount) { this.reportCount = reportCount; }
+
+ public long getTodayNewReports() { return todayNewReports; }
+ public void setTodayNewReports(long todayNewReports) { this.todayNewReports = todayNewReports; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/dto/ReportRequest.java b/src/main/java/com/reportdist/dto/ReportRequest.java
new file mode 100644
index 0000000..2190762
--- /dev/null
+++ b/src/main/java/com/reportdist/dto/ReportRequest.java
@@ -0,0 +1,30 @@
+package com.reportdist.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+public class ReportRequest {
+
+ @NotBlank(message = "File name is required")
+ private String fileName;
+
+ private Long projectId;
+
+ private String fileType;
+
+ public ReportRequest() {}
+
+ public ReportRequest(String fileName, Long projectId, String fileType) {
+ this.fileName = fileName;
+ this.projectId = projectId;
+ this.fileType = fileType;
+ }
+
+ public String getFileName() { return fileName; }
+ public void setFileName(String fileName) { this.fileName = fileName; }
+
+ public Long getProjectId() { return projectId; }
+ public void setProjectId(Long projectId) { this.projectId = projectId; }
+
+ public String getFileType() { return fileType; }
+ public void setFileType(String fileType) { this.fileType = fileType; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/dto/ReportResponse.java b/src/main/java/com/reportdist/dto/ReportResponse.java
new file mode 100644
index 0000000..c54d005
--- /dev/null
+++ b/src/main/java/com/reportdist/dto/ReportResponse.java
@@ -0,0 +1,72 @@
+package com.reportdist.dto;
+
+import com.reportdist.entity.Report;
+import java.time.LocalDateTime;
+
+public class ReportResponse {
+
+ private Long id;
+ private Long projectId;
+ private String fileName;
+ private String fileType;
+ private String filePath;
+ private LocalDateTime uploadTime;
+ private String fileContent;
+
+ public ReportResponse() {}
+
+ public ReportResponse(Long id, Long projectId, String fileName, String fileType, String filePath, LocalDateTime uploadTime, String fileContent) {
+ this.id = id;
+ this.projectId = projectId;
+ this.fileName = fileName;
+ this.fileType = fileType;
+ this.filePath = filePath;
+ this.uploadTime = uploadTime;
+ this.fileContent = fileContent;
+ }
+
+ public static ReportResponse fromEntity(Report report) {
+ return new ReportResponse(
+ report.getId(),
+ report.getProjectId(),
+ report.getFileName(),
+ report.getFileType().name(),
+ report.getFilePath(),
+ report.getUploadTime(),
+ null
+ );
+ }
+
+ public static ReportResponse fromEntityWithContent(Report report, String fileContent) {
+ return new ReportResponse(
+ report.getId(),
+ report.getProjectId(),
+ report.getFileName(),
+ report.getFileType().name(),
+ report.getFilePath(),
+ report.getUploadTime(),
+ fileContent
+ );
+ }
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public Long getProjectId() { return projectId; }
+ public void setProjectId(Long projectId) { this.projectId = projectId; }
+
+ public String getFileName() { return fileName; }
+ public void setFileName(String fileName) { this.fileName = fileName; }
+
+ public String getFileType() { return fileType; }
+ public void setFileType(String fileType) { this.fileType = fileType; }
+
+ public String getFilePath() { return filePath; }
+ public void setFilePath(String filePath) { this.filePath = filePath; }
+
+ public LocalDateTime getUploadTime() { return uploadTime; }
+ public void setUploadTime(LocalDateTime uploadTime) { this.uploadTime = uploadTime; }
+
+ public String getFileContent() { return fileContent; }
+ public void setFileContent(String fileContent) { this.fileContent = fileContent; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/entity/Project.java b/src/main/java/com/reportdist/entity/Project.java
new file mode 100644
index 0000000..79ce8aa
--- /dev/null
+++ b/src/main/java/com/reportdist/entity/Project.java
@@ -0,0 +1,53 @@
+package com.reportdist.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "projects")
+public class Project {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column(columnDefinition = "TEXT")
+ private String description;
+
+ @Column(name = "created_at", nullable = false)
+ private LocalDateTime createdAt;
+
+ private String coverImage;
+
+ @PrePersist
+ protected void onCreate() {
+ createdAt = LocalDateTime.now();
+ }
+
+ public Project() {}
+
+ public Project(Long id, String name, String description, LocalDateTime createdAt) {
+ this.id = id;
+ this.name = name;
+ this.description = description;
+ this.createdAt = createdAt;
+ }
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public String getName() { return name; }
+ public void setName(String name) { this.name = name; }
+
+ public String getDescription() { return description; }
+ public void setDescription(String description) { this.description = description; }
+
+ public LocalDateTime getCreatedAt() { return createdAt; }
+ public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
+
+ public String getCoverImage() { return coverImage; }
+ public void setCoverImage(String coverImage) { this.coverImage = coverImage; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/entity/Report.java b/src/main/java/com/reportdist/entity/Report.java
new file mode 100644
index 0000000..b0baebe
--- /dev/null
+++ b/src/main/java/com/reportdist/entity/Report.java
@@ -0,0 +1,79 @@
+package com.reportdist.entity;
+
+import jakarta.persistence.*;
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "reports")
+public class Report {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(name = "project_id", nullable = false)
+ private Long projectId;
+
+ @Column(name = "file_name", nullable = false)
+ private String fileName;
+
+ @Column(name = "file_type", nullable = false)
+ @Enumerated(EnumType.STRING)
+ private FileType fileType;
+
+ @Column(name = "file_path", nullable = false)
+ private String filePath;
+
+ @Column(name = "upload_time", nullable = false)
+ private LocalDateTime uploadTime;
+
+ @Column(name = "pdf_path")
+ private String pdfPath;
+
+ @Column(name = "pdf_ready", nullable = false)
+ private boolean pdfReady = false;
+
+ @PrePersist
+ protected void onCreate() {
+ uploadTime = LocalDateTime.now();
+ }
+
+ public enum FileType {
+ HTML, MD, PPT, PPTX, PDF
+ }
+
+ public Report() {}
+
+ public Report(Long id, Long projectId, String fileName, FileType fileType, String filePath, LocalDateTime uploadTime) {
+ this.id = id;
+ this.projectId = projectId;
+ this.fileName = fileName;
+ this.fileType = fileType;
+ this.filePath = filePath;
+ this.uploadTime = uploadTime;
+ }
+
+ public Long getId() { return id; }
+ public void setId(Long id) { this.id = id; }
+
+ public Long getProjectId() { return projectId; }
+ public void setProjectId(Long projectId) { this.projectId = projectId; }
+
+ public String getFileName() { return fileName; }
+ public void setFileName(String fileName) { this.fileName = fileName; }
+
+ public FileType getFileType() { return fileType; }
+ public void setFileType(FileType fileType) { this.fileType = fileType; }
+
+ public String getFilePath() { return filePath; }
+ public void setFilePath(String filePath) { this.filePath = filePath; }
+
+ public LocalDateTime getUploadTime() { return uploadTime; }
+ public void setUploadTime(LocalDateTime uploadTime) { this.uploadTime = uploadTime; }
+
+ public String getPdfPath() { return pdfPath; }
+ public void setPdfPath(String pdfPath) { this.pdfPath = pdfPath; }
+
+ public boolean isPdfReady() { return pdfReady; }
+ public void setPdfReady(boolean pdfReady) { this.pdfReady = pdfReady; }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java b/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java
new file mode 100644
index 0000000..0e7f122
--- /dev/null
+++ b/src/main/java/com/reportdist/exception/GlobalExceptionHandler.java
@@ -0,0 +1,65 @@
+package com.reportdist.exception;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+import org.springframework.web.multipart.MultipartException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(MultipartException.class)
+ public ResponseEntity> handleMultipart(MultipartException ex) {
+ System.err.println("=== MULTIPART ERROR ===");
+ ex.printStackTrace();
+ return ResponseEntity.status(500).body(java.util.Map.of(
+ "error", "MULTIPART:" + ex.getClass().getSimpleName() + ":" + ex.getMessage(),
+ "type", ex.getClass().getName()
+ ));
+ }
+
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ public ResponseEntity> handleSize(MaxUploadSizeExceededException ex) {
+ System.err.println("=== SIZE ERROR ===");
+ ex.printStackTrace();
+ return ResponseEntity.status(500).body(java.util.Map.of(
+ "error", "SIZE_LIMIT:" + ex.getMessage(),
+ "type", ex.getClass().getName()
+ ));
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ @ResponseStatus(HttpStatus.BAD_REQUEST)
+ public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) {
+ System.err.println("=== TYPE MISMATCH ===");
+ ex.printStackTrace();
+ return ResponseEntity.status(400).body(java.util.Map.of(
+ "error", "TYPE_MISMATCH:" + ex.getName() + " cannot parse " + ex.getValue(),
+ "type", ex.getClass().getName()
+ ));
+ }
+
+ @ExceptionHandler(RuntimeException.class)
+ public ResponseEntity> handleRuntimeException(RuntimeException ex) {
+ System.err.println("=== RUNTIME ERROR ===");
+ ex.printStackTrace();
+ return ResponseEntity.status(500).body(java.util.Map.of(
+ "error", ex.getMessage() != null ? ex.getMessage() : "null",
+ "type", ex.getClass().getName()
+ ));
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ResponseEntity> handleGeneric(Exception ex) {
+ System.err.println("=== GENERIC ERROR ===");
+ ex.printStackTrace();
+ return ResponseEntity.status(500).body(java.util.Map.of(
+ "error", ex.getClass().getSimpleName() + ":" + ex.getMessage(),
+ "type", ex.getClass().getName()
+ ));
+ }
+}
diff --git a/src/main/java/com/reportdist/repository/ProjectRepository.java b/src/main/java/com/reportdist/repository/ProjectRepository.java
new file mode 100644
index 0000000..0585afa
--- /dev/null
+++ b/src/main/java/com/reportdist/repository/ProjectRepository.java
@@ -0,0 +1,9 @@
+package com.reportdist.repository;
+
+import com.reportdist.entity.Project;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface ProjectRepository extends JpaRepository {
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/repository/ReportRepository.java b/src/main/java/com/reportdist/repository/ReportRepository.java
new file mode 100644
index 0000000..5224ee4
--- /dev/null
+++ b/src/main/java/com/reportdist/repository/ReportRepository.java
@@ -0,0 +1,19 @@
+package com.reportdist.repository;
+
+import com.reportdist.entity.Report;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.stereotype.Repository;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+@Repository
+public interface ReportRepository extends JpaRepository {
+
+ List findByProjectId(Long projectId);
+ long countByProjectId(Long projectId);
+ long countByUploadTimeAfter(LocalDateTime time);
+ long countByProjectIdAndUploadTimeAfter(Long projectId, LocalDateTime time);
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/service/PptxToPdfService.java b/src/main/java/com/reportdist/service/PptxToPdfService.java
new file mode 100644
index 0000000..63513d2
--- /dev/null
+++ b/src/main/java/com/reportdist/service/PptxToPdfService.java
@@ -0,0 +1,143 @@
+package com.reportdist.service;
+
+import org.apache.poi.xslf.usermodel.XMLSlideShow;
+import org.apache.poi.xslf.usermodel.XSLFSlide;
+import org.apache.pdfbox.pdmodel.PDDocument;
+import org.apache.pdfbox.pdmodel.PDPage;
+import org.apache.pdfbox.pdmodel.PDPageContentStream;
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
+import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
+import org.apache.pdfbox.rendering.PDFRenderer;
+import org.apache.pdfbox.rendering.RenderDestination;
+
+import javax.imageio.ImageIO;
+import java.awt.*;
+import java.awt.image.BufferedImage;
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+public class PptxToPdfService {
+
+ public static byte[] convert(String pptxFilePath) throws IOException {
+ Path path = Path.of(pptxFilePath);
+ if (!Files.exists(path)) {
+ throw new IOException("PPTX file not found: " + pptxFilePath);
+ }
+
+ try (InputStream is = Files.newInputStream(path);
+ XMLSlideShow pptx = new XMLSlideShow(is)) {
+
+ List slides = pptx.getSlides();
+ if (slides.isEmpty()) {
+ throw new IOException("No slides found in PPTX file");
+ }
+
+ Dimension slideSize = pptx.getPageSize();
+ int scale = 2;
+ int imgWidth = (int) slideSize.getWidth() * scale;
+ int imgHeight = (int) slideSize.getHeight() * scale;
+
+ // First render all slides to PNG images
+ byte[][] slideImages = new byte[slides.size()][];
+ for (int i = 0; i < slides.size(); i++) {
+ XSLFSlide slide = slides.get(i);
+
+ BufferedImage slideImage = new BufferedImage(imgWidth, imgHeight, BufferedImage.TYPE_INT_ARGB);
+ Graphics2D graphics = slideImage.createGraphics();
+
+ graphics.setColor(Color.WHITE);
+ graphics.fillRect(0, 0, imgWidth, imgHeight);
+
+ graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
+ graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
+ graphics.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_NORMALIZE);
+
+ graphics.scale(scale, scale);
+
+ try {
+ slide.draw(graphics);
+ } catch (Exception e) {
+ drawTextBoxes(graphics, slide);
+ }
+
+ graphics.dispose();
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ ImageIO.write(slideImage, "PNG", baos);
+ slideImages[i] = baos.toByteArray();
+ }
+
+ // Create PDF with each slide as a page
+ PDDocument document = new PDDocument();
+
+ for (int i = 0; i < slideImages.length; i++) {
+ byte[] pngBytes = slideImages[i];
+
+ // Calculate page size to match slide aspect ratio
+ float imgAspect = (float) imgWidth / imgHeight;
+ float pageWidth = PDRectangle.A4.getWidth();
+ float pageHeight = pageWidth / imgAspect;
+
+ if (pageHeight > PDRectangle.A4.getHeight()) {
+ pageHeight = PDRectangle.A4.getHeight();
+ pageWidth = pageHeight * imgAspect;
+ }
+
+ PDPage page = new PDPage(new PDRectangle(pageWidth, pageHeight));
+ document.addPage(page);
+
+ // Draw image on page
+ try (PDPageContentStream cs = new PDPageContentStream(document, page)) {
+ PDImageXObject pdImage = PDImageXObject.createFromByteArray(document, pngBytes, "slide_" + i);
+ cs.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
+ }
+ }
+
+ ByteArrayOutputStream pdfBaos = new ByteArrayOutputStream();
+ document.save(pdfBaos);
+ document.close();
+
+ return pdfBaos.toByteArray();
+ }
+ }
+
+ private static void drawTextBoxes(Graphics2D g, XSLFSlide slide) {
+ for (org.apache.poi.xslf.usermodel.XSLFShape shape : slide.getShapes()) {
+ if (shape instanceof org.apache.poi.xslf.usermodel.XSLFTextShape) {
+ org.apache.poi.xslf.usermodel.XSLFTextShape ts = (org.apache.poi.xslf.usermodel.XSLFTextShape) shape;
+ java.awt.geom.Rectangle2D rect = ts.getAnchor();
+ if (rect == null) continue;
+
+ g.setColor(Color.WHITE);
+ g.fill(rect);
+
+ g.setColor(Color.LIGHT_GRAY);
+ g.draw(rect);
+
+ StringBuilder sb = new StringBuilder();
+ for (org.apache.poi.xslf.usermodel.XSLFTextParagraph para : ts.getTextParagraphs()) {
+ for (org.apache.poi.xslf.usermodel.XSLFTextRun run : para.getTextRuns()) {
+ String text = run.getRawText();
+ if (text != null) sb.append(text);
+ }
+ sb.append("\n");
+ }
+
+ String text = sb.toString().trim();
+ if (!text.isEmpty()) {
+ g.setColor(Color.BLACK);
+ g.setFont(new Font("Arial", Font.PLAIN, 10));
+ int x = (int) rect.getX() + 5;
+ int y = (int) rect.getY() + 15;
+ for (String line : text.split("\n")) {
+ g.drawString(line, x, y);
+ y += 12;
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/service/ProjectService.java b/src/main/java/com/reportdist/service/ProjectService.java
new file mode 100644
index 0000000..211767b
--- /dev/null
+++ b/src/main/java/com/reportdist/service/ProjectService.java
@@ -0,0 +1,122 @@
+package com.reportdist.service;
+
+import com.reportdist.dto.ProjectRequest;
+import com.reportdist.dto.ProjectResponse;
+import com.reportdist.entity.Project;
+import com.reportdist.repository.ProjectRepository;
+import com.reportdist.repository.ReportRepository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+import java.util.stream.Collectors;
+
+@Service
+@Transactional
+public class ProjectService {
+
+ private final ProjectRepository projectRepository;
+ private final ReportRepository reportRepository;
+ private String uploadDir;
+
+ public ProjectService(ProjectRepository projectRepository, ReportRepository reportRepository) {
+ this.projectRepository = projectRepository;
+ this.reportRepository = reportRepository;
+ }
+
+ @Value("${file.upload.dir}")
+ public void setUploadDir(String uploadDir) {
+ this.uploadDir = uploadDir;
+ }
+
+ public List getAllProjects() {
+ LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay();
+ return projectRepository.findAll().stream()
+ .map(project -> {
+ long count = reportRepository.countByProjectId(project.getId());
+ long todayNew = reportRepository.countByProjectIdAndUploadTimeAfter(project.getId(), startOfDay);
+ return ProjectResponse.fromEntity(project, count, todayNew);
+ })
+ .collect(Collectors.toList());
+ }
+
+ public ProjectResponse getProjectById(Long id) {
+ Project project = projectRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
+ LocalDateTime startOfDay = LocalDateTime.now().toLocalDate().atStartOfDay();
+ long count = reportRepository.countByProjectId(id);
+ long todayNew = reportRepository.countByProjectIdAndUploadTimeAfter(id, startOfDay);
+ return ProjectResponse.fromEntity(project, count, todayNew);
+ }
+
+ public ProjectResponse createProject(ProjectRequest request) {
+ Project project = new Project();
+ project.setName(request.getName());
+ project.setDescription(request.getDescription());
+ if (request.getCoverImage() != null) {
+ project.setCoverImage(request.getCoverImage());
+ }
+ Project saved = projectRepository.save(project);
+ return ProjectResponse.fromEntity(saved, 0);
+ }
+
+ public ProjectResponse updateProject(Long id, String name, String description, MultipartFile coverImage) {
+ Project project = projectRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Project not found with id: " + id));
+
+ if (name != null) {
+ project.setName(name);
+ }
+ if (description != null) {
+ project.setDescription(description);
+ }
+ if (coverImage != null && !coverImage.isEmpty()) {
+ try {
+ // Save cover image to uploads/covers/{projectId}/
+ Path coverDir = Paths.get(uploadDir, "covers", String.valueOf(id));
+ Files.createDirectories(coverDir);
+
+ String originalFilename = coverImage.getOriginalFilename();
+ String extension = "";
+ if (originalFilename != null && originalFilename.contains(".")) {
+ extension = originalFilename.substring(originalFilename.lastIndexOf("."));
+ }
+ String uniqueFileName = UUID.randomUUID().toString() + extension;
+ Path coverPath = coverDir.resolve(uniqueFileName);
+
+ Files.write(coverPath, coverImage.getBytes());
+
+ // Store relative path for serving
+ String coverUrl = "/uploads/covers/" + id + "/" + uniqueFileName;
+ project.setCoverImage(coverUrl);
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to save cover image: " + e.getMessage(), e);
+ }
+ }
+
+ Project updated = projectRepository.save(project);
+ long count = reportRepository.countByProjectId(id);
+ long todayNew = reportRepository.countByProjectIdAndUploadTimeAfter(id, LocalDateTime.now().toLocalDate().atStartOfDay());
+ return ProjectResponse.fromEntity(updated, count, todayNew);
+ }
+
+ // Keep old method for backward compatibility
+ public ProjectResponse updateProject(Long id, ProjectRequest request) {
+ return updateProject(id, request.getName(), request.getDescription(), null);
+ }
+
+ public void deleteProject(Long id) {
+ if (!projectRepository.existsById(id)) {
+ throw new RuntimeException("Project not found with id: " + id);
+ }
+ projectRepository.deleteById(id);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/reportdist/service/ReportService.java b/src/main/java/com/reportdist/service/ReportService.java
new file mode 100644
index 0000000..d7d0743
--- /dev/null
+++ b/src/main/java/com/reportdist/service/ReportService.java
@@ -0,0 +1,188 @@
+package com.reportdist.service;
+
+import com.reportdist.dto.ReportRequest;
+import com.reportdist.dto.ReportResponse;
+import com.reportdist.entity.Report;
+import com.reportdist.repository.ReportRepository;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Service
+@Transactional
+public class ReportService {
+
+ private final ReportRepository reportRepository;
+ private String uploadDir;
+
+ public ReportService(ReportRepository reportRepository) {
+ this.reportRepository = reportRepository;
+ }
+
+ @Value("${file.upload.dir}")
+ public void setUploadDir(String uploadDir) {
+ this.uploadDir = uploadDir;
+ }
+
+ public List getAllReports(Long projectId) {
+ List reports;
+ if (projectId != null) {
+ reports = reportRepository.findByProjectId(projectId);
+ } else {
+ reports = reportRepository.findAll();
+ }
+ return reports.stream()
+ .map(ReportResponse::fromEntity)
+ .collect(Collectors.toList());
+ }
+
+ public ReportResponse getReportById(Long id) {
+ Report report = reportRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Report not found with id: " + id));
+
+ String fileContent = readFileContent(report.getFilePath());
+ return ReportResponse.fromEntityWithContent(report, fileContent);
+ }
+
+ public ReportResponse uploadReport(MultipartFile file, Long projectId, String fileType) {
+ try {
+ // Create project subdirectory if needed
+ Path projectDir = Paths.get(uploadDir, String.valueOf(projectId));
+ Files.createDirectories(projectDir);
+
+ // Generate unique filename
+ String originalFilename = file.getOriginalFilename();
+ String uniqueFileName = System.currentTimeMillis() + "_" + originalFilename;
+ Path filePath = projectDir.resolve(uniqueFileName);
+
+ // Save file
+ file.transferTo(filePath.toFile());
+
+ // Create report entity
+ Report report = new Report();
+ report.setProjectId(projectId);
+ report.setFileName(originalFilename);
+ report.setFileType(Report.FileType.valueOf(fileType.toUpperCase()));
+ report.setFilePath(filePath.toString());
+ report.setPdfReady(false);
+
+ // Pre-render PDF for PPTX files
+ if (fileType.equalsIgnoreCase("pptx") || fileType.equalsIgnoreCase("ppt")) {
+ try {
+ byte[] pdfBytes = PptxToPdfService.convert(filePath.toString());
+ Path pdfDir = projectDir.resolve("pdfs");
+ Files.createDirectories(pdfDir);
+ String pdfFileName = uniqueFileName.replaceAll("\\.(pptx?|PPT|X)$", ".pdf");
+ Path pdfPath = pdfDir.resolve(pdfFileName);
+ Files.write(pdfPath, pdfBytes);
+ report.setPdfPath(pdfPath.toString());
+ report.setPdfReady(true);
+ System.out.println("PDF pre-rendered successfully: " + pdfPath);
+ } catch (Exception e) {
+ System.err.println("Failed to pre-render PDF: " + e.getMessage());
+ // Continue without PDF - not critical
+ }
+ }
+
+ Report saved = reportRepository.save(report);
+ return ReportResponse.fromEntity(saved);
+ } catch (IOException e) {
+ System.err.println("=== UPLOAD ERROR ===");
+ e.printStackTrace();
+ throw new RuntimeException("UPLOAD_FAILED:" + e.getClass().getName() + ":" + e.getMessage(), e);
+ }
+ }
+
+ public ReportResponse updateReport(Long id, ReportRequest request) {
+ Report report = reportRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Report not found with id: " + id));
+
+ if (request.getFileName() != null) {
+ report.setFileName(request.getFileName());
+ }
+ if (request.getProjectId() != null) {
+ report.setProjectId(request.getProjectId());
+ }
+ if (request.getFileType() != null) {
+ report.setFileType(Report.FileType.valueOf(request.getFileType().toUpperCase()));
+ }
+
+ Report updated = reportRepository.save(report);
+ return ReportResponse.fromEntity(updated);
+ }
+
+ public void deleteReport(Long id) {
+ Report report = reportRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Report not found with id: " + id));
+
+ // Delete the file
+ try {
+ Path filePath = Paths.get(report.getFilePath());
+ Files.deleteIfExists(filePath);
+ } catch (IOException e) {
+ // Log but don't fail the delete operation
+ System.err.println("Failed to delete file: " + e.getMessage());
+ }
+
+ reportRepository.deleteById(id);
+ }
+
+ private String readFileContent(String filePath) {
+ try {
+ Path path = Paths.get(filePath);
+ if (Files.exists(path)) {
+ return Files.readString(path);
+ }
+ return null;
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ public byte[] getReportBytes(Long id) {
+ Report report = reportRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Report not found with id: " + id));
+ try {
+ return Files.readAllBytes(Paths.get(report.getFilePath()));
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to read file: " + e.getMessage(), e);
+ }
+ }
+
+ public byte[] convertReportToPdf(Long id) {
+ Report report = reportRepository.findById(id)
+ .orElseThrow(() -> new RuntimeException("Report not found with id: " + id));
+
+ String fileType = report.getFileType().name().toLowerCase();
+ if (!fileType.equals("pptx") && !fileType.equals("ppt")) {
+ throw new RuntimeException("Only PPTX files can be converted to PDF. Current file type: " + fileType);
+ }
+
+ // Return pre-rendered PDF if available
+ if (report.isPdfReady() && report.getPdfPath() != null) {
+ try {
+ Path pdfPath = Paths.get(report.getPdfPath());
+ if (Files.exists(pdfPath)) {
+ return Files.readAllBytes(pdfPath);
+ }
+ } catch (IOException e) {
+ System.err.println("Failed to read pre-rendered PDF: " + e.getMessage());
+ }
+ }
+
+ // Fallback: render on demand
+ try {
+ return PptxToPdfService.convert(report.getFilePath());
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to convert PPTX to PDF: " + e.getMessage(), e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..1f9035a
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,46 @@
+server:
+ port: 8080
+
+spring:
+ application:
+ name: daily-report-distribution
+
+ datasource:
+ url: jdbc:sqlite:./database.db
+ driver-class-name: org.sqlite.JDBC
+
+ jpa:
+ database-platform: org.hibernate.community.dialect.SQLiteDialect
+ hibernate:
+ ddl-auto: update
+ show-sql: false
+ properties:
+ hibernate:
+ format_sql: true
+
+ servlet:
+ multipart:
+ enabled: true
+ max-file-size: 100MB
+ max-request-size: 100MB
+
+ # Serve uploaded files statically
+ web:
+ resources:
+ static-locations: file:./uploads/
+
+file:
+ upload:
+ dir: ${UPLOAD_DIR:${user.dir}/uploads}
+
+# Spring Boot Actuator (Docker healthcheck)
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,health-liveness,health-readiness
+ endpoint:
+ health:
+ show-details: always
+ probes:
+ enabled: true
\ No newline at end of file
diff --git a/src/pages/ProjectDetail.vue b/src/pages/ProjectDetail.vue
new file mode 100644
index 0000000..458cf7f
--- /dev/null
+++ b/src/pages/ProjectDetail.vue
@@ -0,0 +1,178 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ 返回项目列表
+
+
+
+
+
{{ projectName }}
+
点击编辑项目
+
+
+
+
+
+
+
封面图片
+
+
+
+
+
+
+
+ 保存
+
+
+ 取消
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/ProjectList.vue b/src/pages/ProjectList.vue
new file mode 100644
index 0000000..fcab9fe
--- /dev/null
+++ b/src/pages/ProjectList.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+ 选择项目
+
+
查看和管理您的日报、周报文件
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ projects.length }}
+
个项目
+
+
+
+
+
+
+
+
+
+
{{ totalReports }}
+
份报告
+
+
+
+
+
+
+
+
+
+
{{ todayNewReportsTotal }}
+
今日新增
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
暂无项目
+
创建一个新项目开始管理您的日报
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/router/index.js b/src/router/index.js
new file mode 100644
index 0000000..a7d3c03
--- /dev/null
+++ b/src/router/index.js
@@ -0,0 +1,24 @@
+import { createRouter, createWebHistory } from 'vue-router'
+import ProjectList from '../pages/ProjectList.vue'
+import ProjectDetail from '../pages/ProjectDetail.vue'
+
+const routes = [
+ {
+ path: '/',
+ name: 'home',
+ component: ProjectList
+ },
+ {
+ path: '/project/:id',
+ name: 'project',
+ component: ProjectDetail,
+ props: true
+ }
+]
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes
+})
+
+export default router
\ No newline at end of file
diff --git a/src/styles/main.css b/src/styles/main.css
new file mode 100644
index 0000000..30b08e0
--- /dev/null
+++ b/src/styles/main.css
@@ -0,0 +1,129 @@
+@import "tailwindcss";
+
+/* Design System Variables - Orange Theme */
+:root {
+ --background: #FFF7E6;
+ --foreground: #1a1a1a;
+ --card: rgba(255, 255, 255, 0.7);
+ --card-foreground: #1a1a1a;
+ --popover: rgba(255, 255, 255, 0.9);
+ --popover-foreground: #1a1a1a;
+ --primary: #FF7A45;
+ --primary-foreground: #ffffff;
+ --secondary: rgba(255, 122, 69, 0.1);
+ --secondary-foreground: #FF7A45;
+ --muted: #f5ede0;
+ --muted-foreground: #6b6b6b;
+ --accent: #FF9F6B;
+ --accent-foreground: #1a1a1a;
+ --destructive: #ef4444;
+ --destructive-foreground: #ffffff;
+ --border: rgba(255, 122, 69, 0.2);
+ --input: rgba(255, 122, 69, 0.15);
+ --ring: #FF7A45;
+ --radius: 1rem;
+ --sidebar: rgba(255, 255, 255, 0.6);
+ --sidebar-foreground: #1a1a1a;
+ --sidebar-primary: #FF7A45;
+ --sidebar-border: rgba(255, 122, 69, 0.15);
+}
+
+/* Base Styles */
+@layer base {
+ * {
+ border-color: var(--border);
+ outline-color: var(--ring);
+ }
+
+ html, body {
+ background-color: var(--background);
+ color: var(--foreground);
+ margin: 0;
+ padding: 0;
+ min-height: 100vh;
+ font-family: 'Inter', system-ui, -apple-system, sans-serif;
+ }
+}
+
+/* Custom Scrollbar */
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--primary);
+ opacity: 0.3;
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--primary);
+ opacity: 0.5;
+}
+
+/* Hide scrollbar utility */
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
+}
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+/* Glass effect utility */
+.glass {
+ background: var(--card);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+/* Extended glass variants */
+.glass-light {
+ background: rgba(255, 255, 255, 0.5);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+
+.glass-dark {
+ background: rgba(0, 0, 0, 0.2);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+}
+
+.glass-strong {
+ background: var(--card);
+ backdrop-filter: blur(20px);
+ -webkit-backdrop-filter: blur(20px);
+}
+
+/* Backdrop blur utilities */
+.backdrop-blur-xl {
+ backdrop-filter: blur(24px);
+ -webkit-backdrop-filter: blur(24px);
+}
+
+/* Shadow utilities */
+.shadow-primary\/20 {
+ --tw-shadow-color: rgba(255, 122, 69, 0.2);
+}
+
+/* Animation utilities */
+.transition-all {
+ transition-property: all;
+ transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+ transition-duration: 300ms;
+}
+
+/* Card hover effect */
+.card-hover {
+ transition: all 300ms ease;
+}
+.card-hover:hover {
+ transform: translateY(-4px);
+ box-shadow: 0 20px 25px -5px rgba(255, 122, 69, 0.15), 0 8px 10px -6px rgba(255, 122, 69, 0.1);
+}
diff --git a/src/test/java/com/reportdist/CompleteApiFlowIntegrationTest.java b/src/test/java/com/reportdist/CompleteApiFlowIntegrationTest.java
new file mode 100644
index 0000000..2d45a1d
--- /dev/null
+++ b/src/test/java/com/reportdist/CompleteApiFlowIntegrationTest.java
@@ -0,0 +1,252 @@
+package com.reportdist;
+
+import com.reportdist.dto.ProjectRequest;
+import com.reportdist.dto.ReportResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.*;
+import org.junit.jupiter.api.DisplayName;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Complete API flow integration test.
+ * Tests: Create Project → Upload Report → Query Report → Delete Report → Delete Project (cascade)
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class CompleteApiFlowIntegrationTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ baseUrl = "http://localhost:" + port;
+ // Ensure upload directory exists
+ Path uploadPath = Paths.get(System.getProperty("java.io.tmpdir"), "report-dist-test-uploads");
+ if (!Files.exists(uploadPath)) {
+ Files.createDirectories(uploadPath);
+ }
+ }
+
+ @AfterEach
+ void tearDown() {
+ // Cleanup is handled by H2 create-drop and file cleanup in each test
+ }
+
+ @Test
+ @DisplayName("Complete API flow: Create Project → Upload Report → Query Report → Delete Report → Delete Project")
+ void completeApiFlow_shouldSucceed() {
+ // Step 1: Create a project
+ ProjectRequest projectRequest = new ProjectRequest("Integration Test Project", "Testing complete flow");
+ ResponseEntity createResponse = restTemplate.postForEntity(
+ baseUrl + "/api/projects",
+ projectRequest,
+ Map.class
+ );
+
+ assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
+ assertNotNull(createResponse.getBody());
+ Long projectId = ((Number) createResponse.getBody().get("id")).longValue();
+ assertNotNull(projectId);
+ assertEquals("Integration Test Project", createResponse.getBody().get("name"));
+ System.out.println("[Step 1] Created project with ID: " + projectId);
+
+ // Step 2: Verify project exists
+ ResponseEntity getProjectResponse = restTemplate.getForEntity(
+ baseUrl + "/api/projects/" + projectId,
+ Map.class
+ );
+ assertEquals(HttpStatus.OK, getProjectResponse.getStatusCode());
+ assertEquals("Integration Test Project", getProjectResponse.getBody().get("name"));
+ System.out.println("[Step 2] Verified project exists");
+
+ // Step 3: Upload a report to the project
+ String htmlContent = "Integration Test Report Content here
";
+ MultiValueMap reportParts = new LinkedMultiValueMap<>();
+ reportParts.add("file", new ByteArrayResource(htmlContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "test-report.html";
+ }
+ });
+ reportParts.add("projectId", projectId.toString());
+ reportParts.add("fileType", "HTML");
+
+ HttpHeaders reportHeaders = new HttpHeaders();
+ reportHeaders.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> reportRequest = new HttpEntity<>(reportParts, reportHeaders);
+
+ ResponseEntity uploadResponse = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ reportRequest,
+ Map.class
+ );
+
+ assertEquals(HttpStatus.CREATED, uploadResponse.getStatusCode());
+ assertNotNull(uploadResponse.getBody());
+ Long reportId = ((Number) uploadResponse.getBody().get("id")).longValue();
+ assertNotNull(reportId);
+ assertEquals("test-report.html", uploadResponse.getBody().get("fileName"));
+ assertEquals("HTML", uploadResponse.getBody().get("fileType"));
+ String filePath = (String) uploadResponse.getBody().get("filePath");
+ System.out.println("[Step 3] Uploaded report with ID: " + reportId + ", path: " + filePath);
+
+ // Verify file was actually saved
+ Path savedFile = Paths.get(filePath);
+ assertTrue(Files.exists(savedFile), "File should exist on disk");
+
+ // Step 4: Query reports by projectId
+ ResponseEntity reportsResponse = restTemplate.getForEntity(
+ baseUrl + "/api/reports?projectId=" + projectId,
+ List.class
+ );
+ assertEquals(HttpStatus.OK, reportsResponse.getStatusCode());
+ assertNotNull(reportsResponse.getBody());
+ assertEquals(1, reportsResponse.getBody().size());
+ Map reportInList = (Map) reportsResponse.getBody().get(0);
+ assertEquals(reportId, ((Number) reportInList.get("id")).longValue());
+ System.out.println("[Step 4] Queried reports, found: " + reportsResponse.getBody().size());
+
+ // Step 5: Get report by ID with content
+ ResponseEntity getReportResponse = restTemplate.getForEntity(
+ baseUrl + "/api/reports/" + reportId,
+ Map.class
+ );
+ assertEquals(HttpStatus.OK, getReportResponse.getStatusCode());
+ assertEquals("test-report.html", getReportResponse.getBody().get("fileName"));
+ assertEquals(htmlContent, getReportResponse.getBody().get("fileContent"));
+ System.out.println("[Step 5] Retrieved report with content");
+
+ // Step 6: Delete the report
+ ResponseEntity deleteReportResponse = restTemplate.exchange(
+ baseUrl + "/api/reports/" + reportId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+ assertEquals(HttpStatus.NO_CONTENT, deleteReportResponse.getStatusCode());
+
+ // Verify report file is deleted
+ assertFalse(Files.exists(savedFile), "File should be deleted from disk");
+
+ // Verify report is gone
+ ResponseEntity getDeletedReport = restTemplate.getForEntity(
+ baseUrl + "/api/reports/" + reportId,
+ Map.class
+ );
+ assertEquals(HttpStatus.NOT_FOUND, getDeletedReport.getStatusCode());
+ System.out.println("[Step 6] Deleted report");
+
+ // Step 7: Delete the project
+ ResponseEntity deleteProjectResponse = restTemplate.exchange(
+ baseUrl + "/api/projects/" + projectId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+ assertEquals(HttpStatus.NO_CONTENT, deleteProjectResponse.getStatusCode());
+
+ // Verify project is gone
+ ResponseEntity getDeletedProject = restTemplate.getForEntity(
+ baseUrl + "/api/projects/" + projectId,
+ Map.class
+ );
+ assertEquals(HttpStatus.NOT_FOUND, getDeletedProject.getStatusCode());
+ System.out.println("[Step 7] Deleted project");
+
+ System.out.println("[SUCCESS] Complete API flow test passed!");
+ }
+
+ @Test
+ @DisplayName("Cascade delete: Deleting project should not delete reports (manual cleanup)")
+ void deleteProject_shouldNotAutoDeleteReports() {
+ // Create project
+ ProjectRequest projectRequest = new ProjectRequest("Cascade Test Project", "Testing cascade");
+ ResponseEntity createResponse = restTemplate.postForEntity(
+ baseUrl + "/api/projects",
+ projectRequest,
+ Map.class
+ );
+ assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
+ Long projectId = ((Number) createResponse.getBody().get("id")).longValue();
+
+ // Upload two reports
+ for (int i = 1; i <= 2; i++) {
+ final int reportIndex = i;
+ String content = "Report " + i;
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(content.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "report" + reportIndex + ".html";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "HTML");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ Map.class
+ );
+ assertEquals(HttpStatus.CREATED, response.getStatusCode());
+ }
+
+ // Verify reports exist
+ ResponseEntity reportsResponse = restTemplate.getForEntity(
+ baseUrl + "/api/reports?projectId=" + projectId,
+ List.class
+ );
+ assertEquals(2, reportsResponse.getBody().size());
+
+ // Delete project
+ ResponseEntity deleteResponse = restTemplate.exchange(
+ baseUrl + "/api/projects/" + projectId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+ assertEquals(HttpStatus.NO_CONTENT, deleteResponse.getStatusCode());
+
+ // Project should be gone
+ ResponseEntity getProject = restTemplate.getForEntity(
+ baseUrl + "/api/projects/" + projectId,
+ Map.class
+ );
+ assertEquals(HttpStatus.NOT_FOUND, getProject.getStatusCode());
+
+ // Reports still exist (they're orphaned - not cascade deleted)
+ // This confirms reports are independent entities
+ ResponseEntity orphanedReports = restTemplate.getForEntity(
+ baseUrl + "/api/reports",
+ List.class
+ );
+ assertTrue(orphanedReports.getBody().size() >= 2);
+ System.out.println("[INFO] Project deleted. Reports remain in database (manual cleanup required).");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/ErrorHandlingIntegrationTest.java b/src/test/java/com/reportdist/ErrorHandlingIntegrationTest.java
new file mode 100644
index 0000000..3d98c8e
--- /dev/null
+++ b/src/test/java/com/reportdist/ErrorHandlingIntegrationTest.java
@@ -0,0 +1,369 @@
+package com.reportdist;
+
+import com.reportdist.dto.ProjectRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.*;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Error handling integration test.
+ * Tests:
+ * - Upload oversized file (>100MB) returns 413
+ * - Project doesn't exist: GET /api/reports?projectId=999 returns empty list
+ * - Report doesn't exist: GET /api/reports/999 returns 404
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class ErrorHandlingIntegrationTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Value("${file.upload.dir}")
+ private String uploadDir;
+
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ baseUrl = "http://localhost:" + port;
+ // Ensure upload directory exists
+ Path uploadPath = Paths.get(uploadDir);
+ if (!Files.exists(uploadPath)) {
+ Files.createDirectories(uploadPath);
+ }
+ }
+
+ /**
+ * Helper method to create a project for tests.
+ */
+ private Long createProject(String name) {
+ ProjectRequest projectRequest = new ProjectRequest(name, "Test project for error handling");
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/projects",
+ projectRequest,
+ Map.class
+ );
+ if (response.getStatusCode() == HttpStatus.CREATED) {
+ return ((Number) response.getBody().get("id")).longValue();
+ }
+ return null;
+ }
+
+ // ==================== Oversized File Upload Tests ====================
+
+ @Test
+ @DisplayName("Upload moderately large file (10MB) - should succeed")
+ void uploadModeratelyLargeFile_shouldSucceed() {
+ // Given
+ Long projectId = createProject("Size Limit Test Project");
+ assertNotNull(projectId, "Should create project for test");
+
+ // Create content 10MB (well within limit but tests larger file handling)
+ int largeSize = 10 * 1024 * 1024; // 10MB
+ byte[] largeContent = new byte[largeSize];
+ // Fill with some data
+ for (int i = 0; i < largeContent.length; i++) {
+ largeContent[i] = (byte) (i % 256);
+ }
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(largeContent) {
+ @Override
+ public String getFilename() {
+ return "large-file.html";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "HTML");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ Map.class
+ );
+
+ // Then - should succeed for files under the limit
+ assertEquals(HttpStatus.CREATED, response.getStatusCode(),
+ "File under 100MB limit should be accepted");
+ }
+
+ // ==================== Project Not Found Tests ====================
+
+ @Test
+ @DisplayName("GET /api/reports?projectId=999 - should return empty list for non-existent project")
+ void getReportsForNonExistentProject_shouldReturnEmptyList() {
+ // Given - non-existent project ID (999)
+ Long nonExistentProjectId = 999L;
+
+ // When
+ ResponseEntity response = restTemplate.getForEntity(
+ baseUrl + "/api/reports?projectId=" + nonExistentProjectId,
+ List.class
+ );
+
+ // Then - should return empty list, not 404
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertTrue(response.getBody().isEmpty(),
+ "Non-existent project should return empty list, but got: " + response.getBody());
+ }
+
+ @Test
+ @DisplayName("GET /api/reports?projectId for deleted project - should return empty list")
+ void getReportsForDeletedProject_shouldReturnEmptyList() {
+ // Given - create and then delete a project
+ Long projectId = createProject("To Be Deleted Project");
+ assertNotNull(projectId);
+
+ // Upload a report first
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource("content".getBytes()) {
+ @Override
+ public String getFilename() {
+ return "report.html";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "HTML");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> uploadRequest = new HttpEntity<>(parts, headers);
+
+ restTemplate.postForEntity(baseUrl + "/api/reports", uploadRequest, Map.class);
+
+ // Delete the project (reports remain orphaned)
+ restTemplate.exchange(
+ baseUrl + "/api/projects/" + projectId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+
+ // When - query reports for the deleted project
+ ResponseEntity response = restTemplate.getForEntity(
+ baseUrl + "/api/reports?projectId=" + projectId,
+ List.class
+ );
+
+ // Then - should still return reports (orphaned) or empty if properly cascade deleted
+ assertEquals(HttpStatus.OK, response.getStatusCode());
+ // Note: Since there's no cascade delete, orphaned reports still exist
+ }
+
+ @Test
+ @DisplayName("GET /api/projects/999 - should return 404 for non-existent project")
+ void getNonExistentProject_shouldReturn404() {
+ // Given
+ Long nonExistentProjectId = 999L;
+
+ // When
+ ResponseEntity response = restTemplate.getForEntity(
+ baseUrl + "/api/projects/" + nonExistentProjectId,
+ Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+
+ // ==================== Report Not Found Tests ====================
+
+ @Test
+ @DisplayName("GET /api/reports/999 - should return 404 for non-existent report")
+ void getNonExistentReport_shouldReturn404() {
+ // Given
+ Long nonExistentReportId = 999L;
+
+ // When
+ ResponseEntity response = restTemplate.getForEntity(
+ baseUrl + "/api/reports/" + nonExistentReportId,
+ Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("DELETE /api/reports/999 - should return 404 for non-existent report")
+ void deleteNonExistentReport_shouldReturn404() {
+ // Given
+ Long nonExistentReportId = 999L;
+
+ // When
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/reports/" + nonExistentReportId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+
+ // ==================== Validation Error Tests ====================
+
+ @Test
+ @DisplayName("POST /api/projects - should return 400 for empty project name")
+ void createProjectWithEmptyName_shouldReturn400() {
+ // Given
+ ProjectRequest invalidRequest = new ProjectRequest("", "Some description");
+
+ // When
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/projects",
+ invalidRequest,
+ Map.class
+ );
+
+ // Then - should fail validation
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("POST /api/projects - should return 400 for missing project name")
+ void createProjectWithMissingName_shouldReturn400() {
+ // Given
+ String jsonWithoutName = "{\"description\": \"Some description\"}";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity request = new HttpEntity<>(jsonWithoutName, headers);
+
+ // When
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/projects",
+ HttpMethod.POST,
+ request,
+ String.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ // ==================== PUT Update Tests ====================
+
+ @Test
+ @DisplayName("PUT /api/reports/999 - should return 404 for non-existent report")
+ void updateNonExistentReport_shouldReturn404() {
+ // Given
+ Long nonExistentReportId = 999L;
+ String updateJson = "{\"fileName\": \"updated.html\"}";
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ HttpEntity request = new HttpEntity<>(updateJson, headers);
+
+ // When
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/reports/" + nonExistentReportId,
+ HttpMethod.PUT,
+ request,
+ Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("PUT /api/projects/999 - should return 404 for non-existent project")
+ void updateNonExistentProject_shouldReturn404() {
+ // Given
+ Long nonExistentProjectId = 999L;
+
+ // When - send multipart form (matching actual endpoint format)
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("name", "Updated Name");
+ parts.add("description", "Updated Description");
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/projects/" + nonExistentProjectId,
+ HttpMethod.PUT,
+ new HttpEntity<>(parts, headers),
+ Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+
+ // ==================== Upload Without File Tests ====================
+
+ @Test
+ @DisplayName("POST /api/reports without file - should return 400")
+ void uploadWithoutFile_shouldReturn400() {
+ // Given
+ Long projectId = createProject("Upload Without File Test");
+ assertNotNull(projectId);
+
+ // When - send request without file part
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "HTML");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/reports",
+ HttpMethod.POST,
+ request,
+ String.class
+ );
+
+ // Then - should fail due to missing required file
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ // ==================== Delete Non-Existent Project Tests ====================
+
+ @Test
+ @DisplayName("DELETE /api/projects/999 - should return 404 for non-existent project")
+ void deleteNonExistentProject_shouldReturn404() {
+ // Given
+ Long nonExistentProjectId = 999L;
+
+ // When
+ ResponseEntity response = restTemplate.exchange(
+ baseUrl + "/api/projects/" + nonExistentProjectId,
+ HttpMethod.DELETE,
+ null,
+ Void.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/FileUploadIntegrationTest.java b/src/test/java/com/reportdist/FileUploadIntegrationTest.java
new file mode 100644
index 0000000..7cec5ba
--- /dev/null
+++ b/src/test/java/com/reportdist/FileUploadIntegrationTest.java
@@ -0,0 +1,460 @@
+package com.reportdist;
+
+import com.reportdist.dto.ProjectRequest;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.*;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class FileUploadIntegrationTest {
+
+ private static final Logger log = LoggerFactory.getLogger(FileUploadIntegrationTest.class);
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Value("${file.upload.dir}")
+ private String uploadDir;
+
+ private String baseUrl;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ baseUrl = "http://localhost:" + port;
+ log.info("Upload directory configured: {}", uploadDir);
+
+ // Ensure upload directory exists
+ Path uploadPath = Paths.get(uploadDir);
+ if (!Files.exists(uploadPath)) {
+ Files.createDirectories(uploadPath);
+ log.info("Created upload directory: {}", uploadPath.toAbsolutePath());
+ }
+
+ // Verify we can write to the directory
+ Path testFile = uploadPath.resolve(".upload-test");
+ Files.writeString(testFile, "test");
+ Files.deleteIfExists(testFile);
+ log.info("Upload directory is writable: {}", uploadPath.toAbsolutePath());
+ }
+
+ /**
+ * Helper method to create a project for file upload tests.
+ */
+ private Long createProject(String name) {
+ ProjectRequest projectRequest = new ProjectRequest(name, "Test project for file upload");
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/projects",
+ projectRequest,
+ java.util.Map.class
+ );
+ assertEquals(HttpStatus.CREATED, response.getStatusCode());
+ return ((Number) response.getBody().get("id")).longValue();
+ }
+
+ // ==================== HTML File Upload Tests ====================
+
+ @Test
+ @DisplayName("Upload HTML file - should succeed and store file")
+ void uploadHtmlFile_shouldSucceed() {
+ // Given
+ Long projectId = createProject("HTML Test Project");
+ String htmlContent = """
+
+
+
+
+ Test Report
+
+
+ Daily Report
+ This is a test report content.
+
+ Item 1: Completed
+ Item 2: In progress
+
+
+
+ """;
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(htmlContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "daily-report.html";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "HTML");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ log.info("Uploading HTML file to project {} via URL: {}/api/reports", projectId, baseUrl);
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ log.info("Upload response: status={}, body={}", response.getStatusCode(), response.getBody());
+
+ // Then
+ assertEquals(HttpStatus.CREATED, response.getStatusCode(),
+ "Upload should succeed. Response body: " + response.getBody());
+ assertNotNull(response.getBody());
+ assertEquals("daily-report.html", response.getBody().get("fileName"));
+ assertEquals("HTML", response.getBody().get("fileType"));
+
+ // Verify file exists on disk
+ String filePath = (String) response.getBody().get("filePath");
+ assertNotNull(filePath);
+ Path savedFile = Paths.get(filePath);
+ assertTrue(Files.exists(savedFile), "HTML file should exist on disk: " + filePath);
+
+ // Verify content can be read back
+ try {
+ String readContent = Files.readString(savedFile);
+ assertEquals(htmlContent, readContent);
+ } catch (IOException e) {
+ fail("Should be able to read HTML file content: " + e.getMessage());
+ }
+ }
+
+ // ==================== MD File Upload Tests ====================
+
+ @Test
+ @DisplayName("Upload MD file - should succeed and store file")
+ void uploadMdFile_shouldSucceed() {
+ // Given
+ Long projectId = createProject("MD Test Project");
+ String mdContent = """
+ # Project Report
+
+ ## Overview
+ This is a markdown report for testing purposes.
+
+ ## Features
+ - Feature A
+ - Feature B
+ - Feature C
+
+ ## Conclusion
+ All features implemented successfully.
+ """;
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(mdContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "project-report.md";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "MD");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.CREATED, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertEquals("project-report.md", response.getBody().get("fileName"));
+ assertEquals("MD", response.getBody().get("fileType"));
+
+ // Verify file exists on disk
+ String filePath = (String) response.getBody().get("filePath");
+ assertNotNull(filePath);
+ Path savedFile = Paths.get(filePath);
+ assertTrue(Files.exists(savedFile), "MD file should exist on disk");
+
+ // Verify content can be read back
+ try {
+ String readContent = Files.readString(savedFile);
+ assertEquals(mdContent, readContent);
+ } catch (IOException e) {
+ fail("Should be able to read MD file content: " + e.getMessage());
+ }
+ }
+
+ // ==================== PPTX File Upload Tests ====================
+
+ @Test
+ @DisplayName("Upload PPTX file - should succeed and file exists")
+ void uploadPptxFile_shouldSucceed() {
+ // Given
+ Long projectId = createProject("PPTX Test Project");
+ // Create a minimal PPTX-like binary content for testing
+ // Real PPTX files are ZIP archives, but for upload test we just verify it stores
+ byte[] pptxContent = "PK".getBytes(); // Minimal PPTX header (ZIP signature)
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(pptxContent) {
+ @Override
+ public String getFilename() {
+ return "presentation.pptx";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "PPTX");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.CREATED, response.getStatusCode());
+ assertNotNull(response.getBody());
+ assertEquals("presentation.pptx", response.getBody().get("fileName"));
+ assertEquals("PPTX", response.getBody().get("fileType"));
+
+ // Verify file exists on disk
+ String filePath = (String) response.getBody().get("filePath");
+ assertNotNull(filePath);
+ Path savedFile = Paths.get(filePath);
+ assertTrue(Files.exists(savedFile), "PPTX file should exist on disk");
+
+ // Verify file size matches
+ try {
+ long fileSize = Files.size(savedFile);
+ assertEquals(pptxContent.length, fileSize, "File size should match uploaded content");
+ } catch (IOException e) {
+ fail("Should be able to get file size: " + e.getMessage());
+ }
+ }
+
+ @Test
+ @DisplayName("Upload PPT file - should succeed")
+ void uploadPptFile_shouldSucceed() {
+ // Given
+ Long projectId = createProject("PPT Test Project");
+ byte[] pptContent = "D0CF11E0".getBytes(); // OLE2 signature
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(pptContent) {
+ @Override
+ public String getFilename() {
+ return "legacy-presentation.ppt";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "PPT");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.CREATED, response.getStatusCode());
+ assertEquals("PPT", response.getBody().get("fileType"));
+ }
+
+ // ==================== Reject Illegal File Types Tests ====================
+
+ @Test
+ @DisplayName("Reject EXE file - should return 400")
+ void uploadExeFile_shouldReject() {
+ // Given
+ Long projectId = createProject("Security Test Project");
+ byte[] exeContent = "MZ".getBytes(); // DOS/Windows executable signature
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(exeContent) {
+ @Override
+ public String getFilename() {
+ return "malware.exe";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "EXE");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then - should be rejected before processing
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Reject BAT file - should return 400")
+ void uploadBatFile_shouldReject() {
+ // Given
+ Long projectId = createProject("Security Test Project 2");
+ String batContent = "@echo off\necho Hello";
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(batContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "script.bat";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "BAT");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then - should be rejected before processing
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Reject SH file - should return 400")
+ void uploadShFile_shouldReject() {
+ // Given
+ Long projectId = createProject("Security Test Project 3");
+ String shContent = "#!/bin/bash\necho Hello";
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(shContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "script.sh";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "SH");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then - should be rejected before processing
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Reject DLL file - should return 400")
+ void uploadDllFile_shouldReject() {
+ // Given
+ Long projectId = createProject("Security Test Project 4");
+ byte[] dllContent = "MZ".getBytes();
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(dllContent) {
+ @Override
+ public String getFilename() {
+ return "library.dll";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "DLL");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+
+ @Test
+ @DisplayName("Reject JS file - should return 400")
+ void uploadJsFile_shouldReject() {
+ // Given
+ Long projectId = createProject("Security Test Project 5");
+ String jsContent = "console.log('Hello');";
+
+ // When
+ MultiValueMap parts = new LinkedMultiValueMap<>();
+ parts.add("file", new ByteArrayResource(jsContent.getBytes()) {
+ @Override
+ public String getFilename() {
+ return "script.js";
+ }
+ });
+ parts.add("projectId", projectId.toString());
+ parts.add("fileType", "JS");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.MULTIPART_FORM_DATA);
+ HttpEntity> request = new HttpEntity<>(parts, headers);
+
+ ResponseEntity response = restTemplate.postForEntity(
+ baseUrl + "/api/reports",
+ request,
+ java.util.Map.class
+ );
+
+ // Then
+ assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/controller/ProjectControllerTest.java b/src/test/java/com/reportdist/controller/ProjectControllerTest.java
new file mode 100644
index 0000000..7233b63
--- /dev/null
+++ b/src/test/java/com/reportdist/controller/ProjectControllerTest.java
@@ -0,0 +1,188 @@
+package com.reportdist.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.reportdist.dto.ProjectRequest;
+import com.reportdist.dto.ProjectResponse;
+import com.reportdist.service.ProjectService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
+
+@WebMvcTest(ProjectController.class)
+class ProjectControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @MockBean
+ private ProjectService projectService;
+
+ // ==================== GET /api/projects Tests ====================
+
+ @Test
+ void getAllProjects_shouldReturnProjectList() throws Exception {
+ // Given
+ List projects = Arrays.asList(
+ new ProjectResponse(1L, "Project One", "Description 1", LocalDateTime.now()),
+ new ProjectResponse(2L, "Project Two", "Description 2", LocalDateTime.now())
+ );
+ when(projectService.getAllProjects()).thenReturn(projects);
+
+ // When & Then
+ mockMvc.perform(get("/api/projects"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(2))
+ .andExpect(jsonPath("$[0].name").value("Project One"))
+ .andExpect(jsonPath("$[1].name").value("Project Two"));
+
+ verify(projectService, times(1)).getAllProjects();
+ }
+
+ @Test
+ void getAllProjects_shouldReturnEmptyList() throws Exception {
+ // Given
+ when(projectService.getAllProjects()).thenReturn(Collections.emptyList());
+
+ // When & Then
+ mockMvc.perform(get("/api/projects"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(0));
+ }
+
+ // ==================== POST /api/projects Tests ====================
+
+ @Test
+ void createProject_shouldCreateAndReturn201() throws Exception {
+ // Given
+ ProjectRequest request = new ProjectRequest("New Project", "New Description");
+ ProjectResponse response = new ProjectResponse(1L, "New Project", "New Description", LocalDateTime.now());
+
+ when(projectService.createProject(any(ProjectRequest.class))).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(post("/api/projects")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.name").value("New Project"))
+ .andExpect(jsonPath("$.description").value("New Description"));
+
+ verify(projectService, times(1)).createProject(any(ProjectRequest.class));
+ }
+
+ @Test
+ void createProject_shouldReturn400WhenNameIsBlank() throws Exception {
+ // Given - empty name
+ ProjectRequest request = new ProjectRequest("", "Description");
+
+ // When & Then
+ mockMvc.perform(post("/api/projects")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(request)))
+ .andExpect(status().isBadRequest());
+
+ verify(projectService, never()).createProject(any(ProjectRequest.class));
+ }
+
+ @Test
+ void createProject_shouldReturn400WhenNameIsNull() throws Exception {
+ // Given - null name
+ String jsonRequest = "{\"description\": \"Some description\"}";
+
+ // When & Then
+ mockMvc.perform(post("/api/projects")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(jsonRequest))
+ .andExpect(status().isBadRequest());
+
+ verify(projectService, never()).createProject(any(ProjectRequest.class));
+ }
+
+ // ==================== PUT /api/projects/{id} Tests ====================
+
+ @Test
+ void updateProject_shouldUpdateAndReturn200() throws Exception {
+ // Given
+ Long projectId = 1L;
+ ProjectResponse response = new ProjectResponse(projectId, "Updated Project", "Updated Description", LocalDateTime.now());
+
+ when(projectService.updateProject(eq(projectId), any(), any(), any())).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(put("/api/projects/{id}", projectId)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .param("name", "Updated Project")
+ .param("description", "Updated Description"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(projectId))
+ .andExpect(jsonPath("$.name").value("Updated Project"))
+ .andExpect(jsonPath("$.description").value("Updated Description"));
+
+ verify(projectService, times(1)).updateProject(eq(projectId), any(), any(), any());
+ }
+
+ @Test
+ void updateProject_shouldReturn404WhenNotFound() throws Exception {
+ // Given
+ Long projectId = 999L;
+
+ when(projectService.updateProject(eq(projectId), any(), any(), any()))
+ .thenThrow(new RuntimeException("Project not found with id: " + projectId));
+
+ // When & Then
+ mockMvc.perform(put("/api/projects/{id}", projectId)
+ .contentType(MediaType.MULTIPART_FORM_DATA)
+ .param("name", "Updated Project"))
+ .andExpect(status().isNotFound());
+
+ verify(projectService, times(1)).updateProject(eq(projectId), any(), any(), any());
+ }
+
+ // ==================== DELETE /api/projects/{id} Tests ====================
+
+ @Test
+ void deleteProject_shouldReturn204() throws Exception {
+ // Given
+ Long projectId = 1L;
+ doNothing().when(projectService).deleteProject(projectId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/projects/{id}", projectId))
+ .andExpect(status().isNoContent());
+
+ verify(projectService, times(1)).deleteProject(projectId);
+ }
+
+ @Test
+ void deleteProject_shouldReturn500WhenNotFound() throws Exception {
+ // Given
+ Long projectId = 999L;
+ doThrow(new RuntimeException("Project not found with id: " + projectId))
+ .when(projectService).deleteProject(projectId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/projects/{id}", projectId))
+ .andExpect(status().isNotFound());
+
+ verify(projectService, times(1)).deleteProject(projectId);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/controller/ReportControllerTest.java b/src/test/java/com/reportdist/controller/ReportControllerTest.java
new file mode 100644
index 0000000..b06717a
--- /dev/null
+++ b/src/test/java/com/reportdist/controller/ReportControllerTest.java
@@ -0,0 +1,314 @@
+package com.reportdist.controller;
+
+import com.reportdist.dto.ReportResponse;
+import com.reportdist.service.ReportService;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@WebMvcTest(ReportController.class)
+class ReportControllerTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @MockBean
+ private ReportService reportService;
+
+ // ==================== GET /api/reports Tests ====================
+
+ @Test
+ void getAllReports_shouldReturnAllReports() throws Exception {
+ // Given
+ List reports = Arrays.asList(
+ new ReportResponse(1L, 1L, "report1.html", "HTML", "/path/report1.html", LocalDateTime.now(), null),
+ new ReportResponse(2L, 1L, "report2.md", "MD", "/path/report2.md", LocalDateTime.now(), null)
+ );
+ when(reportService.getAllReports(null)).thenReturn(reports);
+
+ // When & Then
+ mockMvc.perform(get("/api/reports"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(2))
+ .andExpect(jsonPath("$[0].fileName").value("report1.html"))
+ .andExpect(jsonPath("$[1].fileName").value("report2.md"));
+
+ verify(reportService, times(1)).getAllReports(null);
+ }
+
+ @Test
+ void getAllReports_shouldReturnEmptyListWhenNoReports() throws Exception {
+ // Given
+ when(reportService.getAllReports(null)).thenReturn(Collections.emptyList());
+
+ // When & Then
+ mockMvc.perform(get("/api/reports"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(0));
+ }
+
+ // ==================== GET /api/reports?projectId= Tests ====================
+
+ @Test
+ void getAllReports_withProjectId_shouldFilterByProject() throws Exception {
+ // Given
+ Long projectId = 1L;
+ List reports = Arrays.asList(
+ new ReportResponse(1L, projectId, "report1.html", "HTML", "/path/report1.html", LocalDateTime.now(), null),
+ new ReportResponse(2L, projectId, "report2.md", "MD", "/path/report2.md", LocalDateTime.now(), null)
+ );
+ when(reportService.getAllReports(projectId)).thenReturn(reports);
+
+ // When & Then
+ mockMvc.perform(get("/api/reports")
+ .param("projectId", String.valueOf(projectId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(2))
+ .andExpect(jsonPath("$[0].projectId").value(projectId));
+
+ verify(reportService, times(1)).getAllReports(projectId);
+ }
+
+ @Test
+ void getAllReports_withProjectId_shouldReturnEmptyListForNonExistentProject() throws Exception {
+ // Given
+ Long projectId = 999L;
+ when(reportService.getAllReports(projectId)).thenReturn(Collections.emptyList());
+
+ // When & Then
+ mockMvc.perform(get("/api/reports")
+ .param("projectId", String.valueOf(projectId)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.length()").value(0));
+
+ verify(reportService, times(1)).getAllReports(projectId);
+ }
+
+ // ==================== GET /api/reports/{id} Tests ====================
+
+ @Test
+ void getReportById_shouldReturnReport() throws Exception {
+ // Given
+ Long reportId = 1L;
+ ReportResponse report = new ReportResponse(reportId, 1L, "report.html", "HTML",
+ "/path/report.html", LocalDateTime.now(), "Content");
+ when(reportService.getReportById(reportId)).thenReturn(report);
+
+ // When & Then
+ mockMvc.perform(get("/api/reports/{id}", reportId))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.id").value(reportId))
+ .andExpect(jsonPath("$.fileName").value("report.html"))
+ .andExpect(jsonPath("$.fileContent").value("Content"));
+
+ verify(reportService, times(1)).getReportById(reportId);
+ }
+
+ @Test
+ void getReportById_shouldReturn404WhenNotFound() throws Exception {
+ // Given
+ Long reportId = 999L;
+ when(reportService.getReportById(reportId))
+ .thenThrow(new RuntimeException("Report not found with id: " + reportId));
+
+ // When & Then
+ mockMvc.perform(get("/api/reports/{id}", reportId))
+ .andExpect(status().isNotFound());
+
+ verify(reportService, times(1)).getReportById(reportId);
+ }
+
+ // ==================== POST /api/reports (file upload) Tests ====================
+
+ @Test
+ void uploadReport_shouldUploadHtmlFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "report.html",
+ "text/html",
+ "Test Report".getBytes()
+ );
+
+ ReportResponse response = new ReportResponse(1L, projectId, "report.html", "HTML",
+ "/path/1/report.html", LocalDateTime.now(), null);
+ when(reportService.uploadReport(any(), eq(projectId), eq("HTML"))).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "HTML"))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.id").value(1))
+ .andExpect(jsonPath("$.fileName").value("report.html"))
+ .andExpect(jsonPath("$.fileType").value("HTML"));
+
+ verify(reportService, times(1)).uploadReport(any(), eq(projectId), eq("HTML"));
+ }
+
+ @Test
+ void uploadReport_shouldUploadMdFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "readme.md",
+ "text/markdown",
+ "# Test Report".getBytes()
+ );
+
+ ReportResponse response = new ReportResponse(1L, projectId, "readme.md", "MD",
+ "/path/1/readme.md", LocalDateTime.now(), null);
+ when(reportService.uploadReport(any(), eq(projectId), eq("MD"))).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "MD"))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.fileType").value("MD"));
+
+ verify(reportService, times(1)).uploadReport(any(), eq(projectId), eq("MD"));
+ }
+
+ @Test
+ void uploadReport_shouldUploadPptFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "presentation.ppt",
+ "application/vnd.ms-powerpoint",
+ "dummy ppt content".getBytes()
+ );
+
+ ReportResponse response = new ReportResponse(1L, projectId, "presentation.ppt", "PPT",
+ "/path/1/presentation.ppt", LocalDateTime.now(), null);
+ when(reportService.uploadReport(any(), eq(projectId), eq("PPT"))).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "PPT"))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.fileType").value("PPT"));
+
+ verify(reportService, times(1)).uploadReport(any(), eq(projectId), eq("PPT"));
+ }
+
+ @Test
+ void uploadReport_shouldUploadPptxFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "presentation.pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "dummy pptx content".getBytes()
+ );
+
+ ReportResponse response = new ReportResponse(1L, projectId, "presentation.pptx", "PPTX",
+ "/path/1/presentation.pptx", LocalDateTime.now(), null);
+ when(reportService.uploadReport(any(), eq(projectId), eq("PPTX"))).thenReturn(response);
+
+ // When & Then
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "PPTX"))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.fileType").value("PPTX"));
+
+ verify(reportService, times(1)).uploadReport(any(), eq(projectId), eq("PPTX"));
+ }
+
+ @Test
+ void uploadReport_shouldRejectExeFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "malware.exe",
+ "application/octet-stream",
+ "malicious content".getBytes()
+ );
+
+ // When & Then - Controller validates extension and returns 400
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "EXE"))
+ .andExpect(status().isBadRequest());
+
+ verify(reportService, never()).uploadReport(any(), any(), any());
+ }
+
+ @Test
+ void uploadReport_shouldRejectPdfFile() throws Exception {
+ // Given
+ Long projectId = 1L;
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "document.pdf",
+ "application/pdf",
+ "pdf content".getBytes()
+ );
+
+ // When & Then - Controller validates extension and returns 400
+ mockMvc.perform(multipart("/api/reports")
+ .file(file)
+ .param("projectId", String.valueOf(projectId))
+ .param("fileType", "PDF"))
+ .andExpect(status().isBadRequest());
+
+ verify(reportService, never()).uploadReport(any(), any(), any());
+ }
+
+ // ==================== DELETE /api/reports/{id} Tests ====================
+
+ @Test
+ void deleteReport_shouldReturn204() throws Exception {
+ // Given
+ Long reportId = 1L;
+ doNothing().when(reportService).deleteReport(reportId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/reports/{id}", reportId))
+ .andExpect(status().isNoContent());
+
+ verify(reportService, times(1)).deleteReport(reportId);
+ }
+
+ @Test
+ void deleteReport_shouldReturn500WhenNotFound() throws Exception {
+ // Given
+ Long reportId = 999L;
+ doThrow(new RuntimeException("Report not found with id: " + reportId))
+ .when(reportService).deleteReport(reportId);
+
+ // When & Then
+ mockMvc.perform(delete("/api/reports/{id}", reportId))
+ .andExpect(status().isNotFound());
+
+ verify(reportService, times(1)).deleteReport(reportId);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/service/ProjectServiceTest.java b/src/test/java/com/reportdist/service/ProjectServiceTest.java
new file mode 100644
index 0000000..f143f83
--- /dev/null
+++ b/src/test/java/com/reportdist/service/ProjectServiceTest.java
@@ -0,0 +1,276 @@
+package com.reportdist.service;
+
+import com.reportdist.dto.ProjectRequest;
+import com.reportdist.dto.ProjectResponse;
+import com.reportdist.entity.Project;
+import com.reportdist.repository.ProjectRepository;
+import com.reportdist.repository.ReportRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ProjectServiceTest {
+
+ @Mock
+ private ProjectRepository projectRepository;
+
+ @Mock
+ private ReportRepository reportRepository;
+
+ private ProjectService projectService;
+
+ @BeforeEach
+ void setUp() {
+ projectService = new ProjectService(projectRepository, reportRepository);
+ }
+
+ // ==================== createProject Tests ====================
+
+ @Test
+ void createProject_shouldCreateAndReturnProject() {
+ // Given
+ ProjectRequest request = new ProjectRequest("Test Project", "Test Description");
+ Project savedProject = new Project(1L, "Test Project", "Test Description", LocalDateTime.now());
+
+ when(projectRepository.save(any(Project.class))).thenReturn(savedProject);
+
+ // When
+ ProjectResponse response = projectService.createProject(request);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(1L, response.getId());
+ assertEquals("Test Project", response.getName());
+ assertEquals("Test Description", response.getDescription());
+ verify(projectRepository, times(1)).save(any(Project.class));
+ }
+
+ @Test
+ void createProject_shouldHandleDuplicateName() {
+ // Given - Note: The service doesn't explicitly handle duplicates,
+ // but we test the normal flow still works
+ ProjectRequest request = new ProjectRequest("Duplicate Project", "Description");
+ Project savedProject = new Project(2L, "Duplicate Project", "Description", LocalDateTime.now());
+
+ when(projectRepository.save(any(Project.class))).thenReturn(savedProject);
+
+ // When
+ ProjectResponse response = projectService.createProject(request);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("Duplicate Project", response.getName());
+ verify(projectRepository, times(1)).save(any(Project.class));
+ }
+
+ // ==================== getAllProjects Tests ====================
+
+ @Test
+ void getAllProjects_shouldReturnEmptyList() {
+ // Given
+ when(projectRepository.findAll()).thenReturn(Collections.emptyList());
+
+ // When
+ List result = projectService.getAllProjects();
+
+ // Then
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getAllProjects_shouldReturnSingleProject() {
+ // Given
+ Project project = new Project(1L, "Single Project", "Description", LocalDateTime.now());
+ when(projectRepository.findAll()).thenReturn(Collections.singletonList(project));
+
+ // When
+ List result = projectService.getAllProjects();
+
+ // Then
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals("Single Project", result.get(0).getName());
+ }
+
+ @Test
+ void getAllProjects_shouldReturnMultipleProjects() {
+ // Given
+ Project project1 = new Project(1L, "Project One", "Description 1", LocalDateTime.now());
+ Project project2 = new Project(2L, "Project Two", "Description 2", LocalDateTime.now());
+ Project project3 = new Project(3L, "Project Three", "Description 3", LocalDateTime.now());
+ when(projectRepository.findAll()).thenReturn(Arrays.asList(project1, project2, project3));
+
+ // When
+ List result = projectService.getAllProjects();
+
+ // Then
+ assertNotNull(result);
+ assertEquals(3, result.size());
+ assertEquals("Project One", result.get(0).getName());
+ assertEquals("Project Two", result.get(1).getName());
+ assertEquals("Project Three", result.get(2).getName());
+ }
+
+ // ==================== updateProject Tests ====================
+
+ @Test
+ void updateProject_shouldUpdateAndReturnProject() {
+ // Given
+ Long projectId = 1L;
+ ProjectRequest request = new ProjectRequest("Updated Name", "Updated Description");
+ Project existingProject = new Project(1L, "Original Name", "Original Description", LocalDateTime.now());
+ Project updatedProject = new Project(1L, "Updated Name", "Updated Description", existingProject.getCreatedAt());
+
+ when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(existingProject));
+ when(projectRepository.save(any(Project.class))).thenReturn(updatedProject);
+
+ // When
+ ProjectResponse response = projectService.updateProject(projectId, request);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("Updated Name", response.getName());
+ assertEquals("Updated Description", response.getDescription());
+ verify(projectRepository, times(1)).findById(projectId);
+ verify(projectRepository, times(1)).save(any(Project.class));
+ }
+
+ @Test
+ void updateProject_shouldThrowExceptionWhenNotFound() {
+ // Given
+ Long projectId = 999L;
+ ProjectRequest request = new ProjectRequest("Updated Name", "Updated Description");
+
+ when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.empty());
+
+ // When & Then
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> {
+ projectService.updateProject(projectId, request);
+ });
+
+ assertTrue(exception.getMessage().contains("Project not found"));
+ verify(projectRepository, times(1)).findById(projectId);
+ verify(projectRepository, never()).save(any(Project.class));
+ }
+
+ @Test
+ void getAllProjects_shouldIncludeReportCount() {
+ // Given
+ Project project1 = new Project(1L, "Project One", "Description 1", LocalDateTime.now());
+ Project project2 = new Project(2L, "Project Two", "Description 2", LocalDateTime.now());
+ when(projectRepository.findAll()).thenReturn(Arrays.asList(project1, project2));
+ when(reportRepository.countByProjectId(1L)).thenReturn(5L);
+ when(reportRepository.countByProjectId(2L)).thenReturn(3L);
+
+ // When
+ List result = projectService.getAllProjects();
+
+ // Then
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertEquals(5L, result.get(0).getReportCount());
+ assertEquals(3L, result.get(1).getReportCount());
+ verify(reportRepository, times(1)).countByProjectId(1L);
+ verify(reportRepository, times(1)).countByProjectId(2L);
+ }
+
+ @Test
+ void getProjectById_shouldIncludeReportCount() {
+ // Given
+ Long projectId = 1L;
+ Project project = new Project(1L, "Test Project", "Description", LocalDateTime.now());
+ when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(project));
+ when(reportRepository.countByProjectId(projectId)).thenReturn(10L);
+
+ // When
+ ProjectResponse response = projectService.getProjectById(projectId);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(10L, response.getReportCount());
+ verify(reportRepository, times(1)).countByProjectId(projectId);
+ }
+
+ @Test
+ void getAllProjects_withNoReports_shouldHaveZeroReportCount() {
+ // Given
+ Project project = new Project(1L, "Empty Project", "No reports", LocalDateTime.now());
+ when(projectRepository.findAll()).thenReturn(Collections.singletonList(project));
+ when(reportRepository.countByProjectId(1L)).thenReturn(0L);
+
+ // When
+ List result = projectService.getAllProjects();
+
+ // Then
+ assertNotNull(result);
+ assertEquals(1, result.size());
+ assertEquals(0L, result.get(0).getReportCount());
+ }
+
+ @Test
+ void updateProject_withMultipartCoverImage_shouldReturnUpdatedProjectWithReportCount() {
+ // Given
+ Long projectId = 1L;
+ Project existingProject = new Project(1L, "Original Name", "Description", LocalDateTime.now());
+ Project updatedProject = new Project(1L, "Updated Name", "Description", existingProject.getCreatedAt());
+ updatedProject.setCoverImage("/uploads/covers/1/test.png");
+
+ when(projectRepository.findById(projectId)).thenReturn(java.util.Optional.of(existingProject));
+ when(projectRepository.save(any(Project.class))).thenReturn(updatedProject);
+ when(reportRepository.countByProjectId(projectId)).thenReturn(5L);
+
+ // When
+ ProjectResponse response = projectService.updateProject(projectId, "Updated Name", null, null);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(5L, response.getReportCount());
+ verify(reportRepository, times(1)).countByProjectId(projectId);
+ }
+
+ // ==================== deleteProject Tests ====================
+
+ @Test
+ void deleteProject_shouldDeleteSuccessfully() {
+ // Given
+ Long projectId = 1L;
+ when(projectRepository.existsById(projectId)).thenReturn(true);
+ doNothing().when(projectRepository).deleteById(projectId);
+
+ // When
+ projectService.deleteProject(projectId);
+
+ // Then
+ verify(projectRepository, times(1)).existsById(projectId);
+ verify(projectRepository, times(1)).deleteById(projectId);
+ }
+
+ @Test
+ void deleteProject_shouldThrowExceptionWhenNotFound() {
+ // Given
+ Long projectId = 999L;
+ when(projectRepository.existsById(projectId)).thenReturn(false);
+
+ // When & Then
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> {
+ projectService.deleteProject(projectId);
+ });
+
+ assertTrue(exception.getMessage().contains("Project not found"));
+ verify(projectRepository, times(1)).existsById(projectId);
+ verify(projectRepository, never()).deleteById(any());
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/reportdist/service/ReportServiceTest.java b/src/test/java/com/reportdist/service/ReportServiceTest.java
new file mode 100644
index 0000000..036a76d
--- /dev/null
+++ b/src/test/java/com/reportdist/service/ReportServiceTest.java
@@ -0,0 +1,350 @@
+package com.reportdist.service;
+
+import com.reportdist.dto.ReportResponse;
+import com.reportdist.entity.Report;
+import com.reportdist.repository.ReportRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.*;
+
+@ExtendWith(MockitoExtension.class)
+class ReportServiceTest {
+
+ @Mock
+ private ReportRepository reportRepository;
+
+ private ReportService reportService;
+
+ @TempDir
+ Path tempDir;
+
+ @BeforeEach
+ void setUp() {
+ reportService = new ReportService(reportRepository);
+ ReflectionTestUtils.setField(reportService, "uploadDir", tempDir.toString());
+ }
+
+ // ==================== uploadReport Tests ====================
+
+ @Test
+ void uploadReport_shouldUploadSuccessfully() throws Exception {
+ // Given
+ Long projectId = 1L;
+ String fileType = "HTML";
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "report.html",
+ "text/html",
+ "Test Report".getBytes()
+ );
+
+ Report savedReport = new Report(1L, projectId, "report.html", Report.FileType.HTML,
+ tempDir.resolve("1/report.html").toString(), LocalDateTime.now());
+
+ when(reportRepository.save(any(Report.class))).thenReturn(savedReport);
+
+ // When
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(1L, response.getId());
+ assertEquals(projectId, response.getProjectId());
+ assertEquals("report.html", response.getFileName());
+ verify(reportRepository, times(1)).save(any(Report.class));
+ }
+
+ @Test
+ void uploadReport_shouldAcceptHtmlExtension() throws Exception {
+ // Given
+ Long projectId = 1L;
+ String fileType = "HTML";
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "report.html",
+ "text/html",
+ "Test".getBytes()
+ );
+
+ Report savedReport = new Report(1L, projectId, "report.html", Report.FileType.HTML,
+ tempDir.resolve("1/report.html").toString(), LocalDateTime.now());
+
+ when(reportRepository.save(any(Report.class))).thenReturn(savedReport);
+
+ // When
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("HTML", response.getFileType());
+ }
+
+ @Test
+ void uploadReport_shouldAcceptMdExtension() throws Exception {
+ // Given
+ Long projectId = 1L;
+ String fileType = "MD";
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "readme.md",
+ "text/markdown",
+ "# Test Report".getBytes()
+ );
+
+ Report savedReport = new Report(1L, projectId, "readme.md", Report.FileType.MD,
+ tempDir.resolve("1/readme.md").toString(), LocalDateTime.now());
+
+ when(reportRepository.save(any(Report.class))).thenReturn(savedReport);
+
+ // When
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("MD", response.getFileType());
+ }
+
+ @Test
+ void uploadReport_shouldAcceptPptExtension() throws Exception {
+ // Given
+ Long projectId = 1L;
+ String fileType = "PPT";
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "presentation.ppt",
+ "application/vnd.ms-powerpoint",
+ "dummy ppt content".getBytes()
+ );
+
+ Report savedReport = new Report(1L, projectId, "presentation.ppt", Report.FileType.PPT,
+ tempDir.resolve("1/presentation.ppt").toString(), LocalDateTime.now());
+
+ when(reportRepository.save(any(Report.class))).thenReturn(savedReport);
+
+ // When
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("PPT", response.getFileType());
+ }
+
+ @Test
+ void uploadReport_shouldAcceptPptxExtension() throws Exception {
+ // Given
+ Long projectId = 1L;
+ String fileType = "PPTX";
+ MockMultipartFile file = new MockMultipartFile(
+ "file",
+ "presentation.pptx",
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
+ "dummy pptx content".getBytes()
+ );
+
+ Report savedReport = new Report(1L, projectId, "presentation.pptx", Report.FileType.PPTX,
+ tempDir.resolve("1/presentation.pptx").toString(), LocalDateTime.now());
+
+ when(reportRepository.save(any(Report.class))).thenReturn(savedReport);
+
+ // When
+ ReportResponse response = reportService.uploadReport(file, projectId, fileType);
+
+ // Then
+ assertNotNull(response);
+ assertEquals("PPTX", response.getFileType());
+ }
+
+ // Note: File type validation is done in Controller, not Service
+ // Service accepts all file types passed from Controller
+
+ // ==================== getReportsByProject Tests ====================
+
+ @Test
+ void getReportsByProject_shouldFilterCorrectly() {
+ // Given
+ Long projectId = 1L;
+ Report report1 = new Report(1L, projectId, "report1.html", Report.FileType.HTML,
+ "/path/report1.html", LocalDateTime.now());
+ Report report2 = new Report(2L, projectId, "report2.md", Report.FileType.MD,
+ "/path/report2.md", LocalDateTime.now());
+
+ when(reportRepository.findByProjectId(projectId)).thenReturn(Arrays.asList(report1, report2));
+
+ // When
+ List result = reportService.getAllReports(projectId);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ assertEquals("report1.html", result.get(0).getFileName());
+ assertEquals("report2.md", result.get(1).getFileName());
+ verify(reportRepository, times(1)).findByProjectId(projectId);
+ verify(reportRepository, never()).findAll();
+ }
+
+ @Test
+ void getReportsByProject_shouldReturnEmptyListForNonExistentProject() {
+ // Given
+ Long projectId = 999L;
+ when(reportRepository.findByProjectId(projectId)).thenReturn(Collections.emptyList());
+
+ // When
+ List result = reportService.getAllReports(projectId);
+
+ // Then
+ assertNotNull(result);
+ assertTrue(result.isEmpty());
+ }
+
+ @Test
+ void getAllReports_shouldReturnAllReportsWhenProjectIdIsNull() {
+ // Given
+ Report report1 = new Report(1L, 1L, "report1.html", Report.FileType.HTML,
+ "/path/report1.html", LocalDateTime.now());
+ Report report2 = new Report(2L, 2L, "report2.md", Report.FileType.MD,
+ "/path/report2.md", LocalDateTime.now());
+
+ when(reportRepository.findAll()).thenReturn(Arrays.asList(report1, report2));
+
+ // When
+ List result = reportService.getAllReports(null);
+
+ // Then
+ assertNotNull(result);
+ assertEquals(2, result.size());
+ verify(reportRepository, times(1)).findAll();
+ verify(reportRepository, never()).findByProjectId(any());
+ }
+
+ // ==================== getReportById Tests ====================
+
+ @Test
+ void getReportById_shouldReturnReportWithContent() throws Exception {
+ // Given
+ Long reportId = 1L;
+ String filePath = tempDir.resolve("test_report.html").toString();
+ Report report = new Report(reportId, 1L, "test_report.html", Report.FileType.HTML,
+ filePath, LocalDateTime.now());
+
+ // Create the test file
+ java.nio.file.Files.writeString(java.nio.file.Path.of(filePath), "Content");
+
+ when(reportRepository.findById(reportId)).thenReturn(Optional.of(report));
+
+ // When
+ ReportResponse response = reportService.getReportById(reportId);
+
+ // Then
+ assertNotNull(response);
+ assertEquals(reportId, response.getId());
+ assertEquals("Content", response.getFileContent());
+ }
+
+ @Test
+ void getReportById_shouldReturnNullWhenFileNotExists() {
+ // Given
+ Long reportId = 1L;
+ String filePath = tempDir.resolve("non_existent.html").toString();
+ Report report = new Report(reportId, 1L, "non_existent.html", Report.FileType.HTML,
+ filePath, LocalDateTime.now());
+
+ when(reportRepository.findById(reportId)).thenReturn(Optional.of(report));
+
+ // When
+ ReportResponse response = reportService.getReportById(reportId);
+
+ // Then
+ assertNotNull(response);
+ assertNull(response.getFileContent());
+ }
+
+ @Test
+ void getReportById_shouldThrowExceptionWhenNotFound() {
+ // Given
+ Long reportId = 999L;
+ when(reportRepository.findById(reportId)).thenReturn(Optional.empty());
+
+ // When & Then
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> {
+ reportService.getReportById(reportId);
+ });
+
+ assertTrue(exception.getMessage().contains("Report not found"));
+ }
+
+ // ==================== deleteReport Tests ====================
+
+ @Test
+ void deleteReport_shouldDeleteSuccessfully() throws Exception {
+ // Given
+ Long reportId = 1L;
+ String filePath = tempDir.resolve("delete_test.html").toString();
+ Report report = new Report(reportId, 1L, "delete_test.html", Report.FileType.HTML,
+ filePath, LocalDateTime.now());
+
+ // Create the test file
+ java.nio.file.Files.writeString(java.nio.file.Path.of(filePath), "To be deleted");
+
+ when(reportRepository.findById(reportId)).thenReturn(Optional.of(report));
+ doNothing().when(reportRepository).deleteById(reportId);
+
+ // When
+ reportService.deleteReport(reportId);
+
+ // Then
+ verify(reportRepository, times(1)).findById(reportId);
+ verify(reportRepository, times(1)).deleteById(reportId);
+ assertFalse(java.nio.file.Files.exists(java.nio.file.Path.of(filePath)));
+ }
+
+ @Test
+ void deleteReport_shouldHandleNonExistentFile() throws Exception {
+ // Given
+ Long reportId = 1L;
+ String filePath = tempDir.resolve("non_existent_file.html").toString();
+ Report report = new Report(reportId, 1L, "non_existent_file.html", Report.FileType.HTML,
+ filePath, LocalDateTime.now());
+
+ when(reportRepository.findById(reportId)).thenReturn(Optional.of(report));
+ doNothing().when(reportRepository).deleteById(reportId);
+
+ // When - Should not throw even if file doesn't exist
+ assertDoesNotThrow(() -> reportService.deleteReport(reportId));
+
+ // Then
+ verify(reportRepository, times(1)).findById(reportId);
+ verify(reportRepository, times(1)).deleteById(reportId);
+ }
+
+ @Test
+ void deleteReport_shouldThrowExceptionWhenNotFound() {
+ // Given
+ Long reportId = 999L;
+ when(reportRepository.findById(reportId)).thenReturn(Optional.empty());
+
+ // When & Then
+ RuntimeException exception = assertThrows(RuntimeException.class, () -> {
+ reportService.deleteReport(reportId);
+ });
+
+ assertTrue(exception.getMessage().contains("Report not found"));
+ verify(reportRepository, times(1)).findById(reportId);
+ verify(reportRepository, never()).deleteById(any());
+ }
+}
\ No newline at end of file
diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml
new file mode 100644
index 0000000..2349793
--- /dev/null
+++ b/src/test/resources/application.yml
@@ -0,0 +1,37 @@
+spring:
+ datasource:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+
+ jpa:
+ database-platform: org.hibernate.dialect.H2Dialect
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+
+ servlet:
+ multipart:
+ enabled: true
+ max-file-size: 100MB
+ max-request-size: 100MB
+
+file:
+ upload:
+ # Use absolute path for test upload directory to ensure it exists
+ dir: ${java.io.tmpdir}/report-dist-test-uploads
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health
+
+logging:
+ level:
+ com.reportdist: DEBUG
+ org.springframework.web: DEBUG
\ No newline at end of file
diff --git a/src/test/vue/components/FilePreview.test.js b/src/test/vue/components/FilePreview.test.js
new file mode 100644
index 0000000..7030202
--- /dev/null
+++ b/src/test/vue/components/FilePreview.test.js
@@ -0,0 +1,247 @@
+import { describe, it, expect, vi } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { ref } from 'vue'
+import FilePreview from '@/components/FilePreview.vue'
+
+// Mock useApi
+vi.mock('@/composables/useApi', () => ({
+ useApi: () => ({
+ fetchReportBytes: vi.fn().mockResolvedValue(null),
+ fetchReportPdf: vi.fn().mockResolvedValue(null)
+ })
+}))
+
+describe('FilePreview.vue', () => {
+ describe('HTML preview', () => {
+ it('should render iframe with srcdoc for HTML files', () => {
+ const report = {
+ id: 1,
+ fileName: '2026-05-22 日报.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+ const content = 'Test '
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content }
+ })
+
+ const iframe = wrapper.find('iframe')
+ expect(iframe.exists()).toBe(true)
+ expect(iframe.attributes('srcdoc')).toBe(content)
+ })
+
+ it('should have sandbox attribute on iframe', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: 'Content' }
+ })
+
+ const iframe = wrapper.find('iframe')
+ expect(iframe.attributes('sandbox')).toBe('allow-same-origin')
+ })
+ })
+
+ describe('Markdown preview', () => {
+ it('should render marked content for MD files', () => {
+ const report = {
+ id: 1,
+ fileName: '2026-05-22 日报.md',
+ fileType: 'md',
+ reportDate: '2026-05-22',
+ size: '8KB'
+ }
+ const content = '# Test Header\n\nThis is a test.'
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content }
+ })
+
+ // Should render h1 from markdown
+ const h1 = wrapper.find('h1')
+ expect(h1.exists()).toBe(true)
+ expect(h1.text()).toBe('Test Header')
+ })
+
+ it('should show empty content when markdown is empty', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.md',
+ fileType: 'md',
+ reportDate: '2026-05-22',
+ size: '8KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: '' }
+ })
+
+ // Should show prose container (even if empty)
+ const proseDiv = wrapper.find('.prose')
+ expect(proseDiv.exists()).toBe(true)
+ })
+ })
+
+ describe('PPTX download', () => {
+ it('should show download button for PPTX files', () => {
+ const report = {
+ id: 1,
+ fileName: '2026-05-20 周报.pptx',
+ fileType: 'pptx',
+ reportDate: '2026-05-20',
+ size: '256KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: '' }
+ })
+
+ // Should show download button (orange gradient style in new design)
+ const buttons = wrapper.findAll('button')
+ const downloadButton = buttons.find(b => b.text().includes('下载'))
+ expect(downloadButton).toBeDefined()
+ })
+
+ it('should have orange styling for download button', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.pptx',
+ fileType: 'pptx',
+ reportDate: '2026-05-20',
+ size: '256KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: '' }
+ })
+
+ // Should have gradient download button
+ const buttons = wrapper.findAll('button')
+ const downloadButton = buttons.find(b => b.text().includes('下载'))
+ expect(downloadButton).toBeDefined()
+ })
+
+ it('should trigger downloadReport on button click', async () => {
+ const report = {
+ id: 1,
+ fileName: 'test.pptx',
+ fileType: 'pptx',
+ reportDate: '2026-05-20',
+ size: '256KB'
+ }
+
+ vi.spyOn(window, 'alert').mockImplementation(() => {})
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: '' }
+ })
+
+ const buttons = wrapper.findAll('button')
+ const downloadButton = buttons.find(b => b.text().includes('下载'))
+ await downloadButton.trigger('click')
+
+ // Should show alert (either '文件不存在或无法读取' or '下载失败')
+ expect(window.alert).toHaveBeenCalled()
+ })
+ })
+
+ describe('Empty state', () => {
+ it('should show empty state when no report is selected', () => {
+ const wrapper = mount(FilePreview, {
+ props: { report: null, content: '' }
+ })
+
+ expect(wrapper.text()).toContain('选择一份报告以预览')
+ })
+
+ it('should not show iframe when report is null', () => {
+ const wrapper = mount(FilePreview, {
+ props: { report: null, content: '' }
+ })
+
+ const iframe = wrapper.find('iframe')
+ expect(iframe.exists()).toBe(false)
+ })
+ })
+
+ describe('Report header', () => {
+ it('should display report file name in header', () => {
+ const report = {
+ id: 1,
+ fileName: '2026-05-22 日报.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: 'Test' }
+ })
+
+ expect(wrapper.text()).toContain('2026-05-22 日报.html')
+ })
+
+ it('should display report date and size in header', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: 'Test' }
+ })
+
+ expect(wrapper.text()).toContain('2026-05-22')
+ expect(wrapper.text()).toContain('15KB')
+ })
+
+ it('should show download button for HTML files', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: 'Test' }
+ })
+
+ // Should show download button
+ const buttons = wrapper.findAll('button')
+ const downloadButton = buttons.find(b => b.text().includes('下载'))
+ expect(downloadButton).toBeDefined()
+ })
+ })
+
+ describe('File type display', () => {
+ it('should show file type badge', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.pptx',
+ fileType: 'pptx',
+ reportDate: '2026-05-20',
+ size: '256KB'
+ }
+
+ const wrapper = mount(FilePreview, {
+ props: { report, content: '' }
+ })
+
+ // Should show PowerPoint badge
+ expect(wrapper.text()).toContain('PowerPoint')
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/test/vue/components/ReportCard.test.js b/src/test/vue/components/ReportCard.test.js
new file mode 100644
index 0000000..55f10c8
--- /dev/null
+++ b/src/test/vue/components/ReportCard.test.js
@@ -0,0 +1,174 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ReportCard from '@/components/ReportCard.vue'
+
+describe('ReportCard.vue', () => {
+ it('should render file name', () => {
+ const report = {
+ id: 1,
+ fileName: '2026-05-22 日报.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ expect(wrapper.text()).toContain('2026-05-22 日报.html')
+ })
+
+ it('should render file type label', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ // Should show HTML label for html type
+ expect(wrapper.text()).toContain('HTML')
+ })
+
+ it('should render markdown label for md type', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.md',
+ fileType: 'md',
+ reportDate: '2026-05-22',
+ size: '8KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ expect(wrapper.text()).toContain('Markdown')
+ })
+
+ it('should render PowerPoint label for pptx type', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.pptx',
+ fileType: 'pptx',
+ reportDate: '2026-05-22',
+ size: '256KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ expect(wrapper.text()).toContain('PowerPoint')
+ })
+
+ it('should render file date', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ expect(wrapper.text()).toContain('2026-05-22')
+ })
+
+ it('should render file size', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ expect(wrapper.text()).toContain('15KB')
+ })
+
+ it('should emit select event on click', async () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ await wrapper.trigger('click')
+
+ expect(wrapper.emitted('select')).toBeTruthy()
+ expect(wrapper.emitted('select')[0]).toEqual([report])
+ })
+
+ it('should have selected state styling when isSelected is true', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report, isSelected: true }
+ })
+
+ // Check for gradient background class when selected (new design)
+ const card = wrapper.find('.bg-gradient-to-r')
+ expect(card.exists()).toBe(true)
+ })
+
+ it('should display icon for each file type', () => {
+ const htmlReport = {
+ id: 1,
+ fileName: 'test.html',
+ fileType: 'html',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report: htmlReport }
+ })
+
+ // Check that icon container exists (w-12 h-12 rounded-xl)
+ const iconContainer = wrapper.find('.w-12')
+ expect(iconContainer.exists()).toBe(true)
+ })
+
+ it('should use default file type for unknown types', () => {
+ const report = {
+ id: 1,
+ fileName: 'test.xyz',
+ fileType: 'xyz',
+ reportDate: '2026-05-22',
+ size: '15KB'
+ }
+
+ const wrapper = mount(ReportCard, {
+ props: { report }
+ })
+
+ // Should display XYZ as uppercase (unknown type defaults)
+ expect(wrapper.text()).toContain('XYZ')
+ })
+})
\ No newline at end of file
diff --git a/src/test/vue/composables/useApi.test.js b/src/test/vue/composables/useApi.test.js
new file mode 100644
index 0000000..aea384e
--- /dev/null
+++ b/src/test/vue/composables/useApi.test.js
@@ -0,0 +1,161 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import axios from 'axios'
+
+// Mock axios
+vi.mock('axios')
+vi.mocked(axios.create).mockReturnValue({
+ get: vi.fn(),
+ post: vi.fn(),
+ put: vi.fn(),
+ delete: vi.fn(),
+ interceptors: {
+ request: { use: vi.fn(), eject: vi.fn() },
+ response: { use: vi.fn(), eject: vi.fn() }
+ }
+})
+
+import { useApi } from '@/composables/useApi'
+
+const mockProjects = [
+ { id: 1, name: '项目一', description: '主要产品线', reportCount: 15, todayNewReports: 2 },
+ { id: 2, name: '项目二', description: '内部工具', reportCount: 8, todayNewReports: 1 },
+ { id: 3, name: '项目三', description: '客户定制', reportCount: 12, todayNewReports: 0 }
+]
+
+const mockReports = {
+ 1: [
+ { id: 101, fileName: '2026-05-22 日报.html', fileType: 'html', reportDate: '2026-05-22', size: '15KB' },
+ { id: 102, fileName: '2026-05-21 日报.md', fileType: 'md', reportDate: '2026-05-21', size: '8KB' },
+ { id: 103, fileName: '2026-05-20 周报.pptx', fileType: 'pptx', reportDate: '2026-05-20', size: '256KB' }
+ ],
+ 2: [
+ { id: 201, fileName: '2026-05-22 开发日报.html', fileType: 'html', reportDate: '2026-05-22', size: '12KB' },
+ { id: 202, fileName: '2026-05-21 开发日报.html', fileType: 'html', reportDate: '2026-05-21', size: '11KB' }
+ ]
+}
+
+describe('useApi composable', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('fetchProjects', () => {
+ it('should return mock data when API succeeds', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockResolvedValue({ data: mockProjects })
+
+ const { fetchProjects } = useApi()
+ const result = await fetchProjects()
+
+ expect(result).toEqual(mockProjects)
+ })
+
+ it('should return mock data when API fails (fallback)', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockRejectedValue(new Error('API not available'))
+
+ const { fetchProjects } = useApi()
+ const result = await fetchProjects()
+
+ // Should return hardcoded mock data on API failure
+ expect(result).toHaveLength(3)
+ expect(result[0]).toHaveProperty('id')
+ expect(result[0]).toHaveProperty('name')
+ })
+ })
+
+ describe('fetchReports', () => {
+ it('should filter reports by projectId', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockResolvedValue({ data: mockReports[1] })
+
+ const { fetchReports } = useApi()
+ const result = await fetchReports(1)
+
+ expect(result).toEqual(mockReports[1])
+ })
+
+ it('should return mock data when API fails', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockRejectedValue(new Error('API not available'))
+
+ const { fetchReports } = useApi()
+ const result = await fetchReports(1)
+
+ // Should return mock reports for project 1
+ expect(result).toHaveLength(3)
+ expect(result[0].fileType).toBe('html')
+ })
+
+ it('should return empty array for unknown projectId', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockRejectedValue(new Error('API not available'))
+
+ const { fetchReports } = useApi()
+ const result = await fetchReports(999)
+
+ expect(result).toEqual([])
+ })
+ })
+
+ describe('fetchReportContent', () => {
+ it('should return report content and type when API succeeds', async () => {
+ const mockResponse = {
+ data: {
+ fileContent: 'Content',
+ fileType: 'html'
+ }
+ }
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockResolvedValue(mockResponse)
+
+ const { fetchReportContent } = useApi()
+ const result = await fetchReportContent(101)
+
+ expect(result).toHaveProperty('content')
+ expect(result).toHaveProperty('type', 'html')
+ })
+
+ it('should return mock content when API returns 404 (fallback)', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockRejectedValue({ response: { status: 404 } })
+
+ const { fetchReportContent } = useApi()
+ const result = await fetchReportContent(101)
+
+ // Should find report in mock data and return its content
+ expect(result).not.toBeNull()
+ expect(result).toHaveProperty('type')
+ })
+
+ it('should return null for unknown reportId', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockRejectedValue(new Error('API not available'))
+
+ const { fetchReportContent } = useApi()
+ const result = await fetchReportContent(99999)
+
+ expect(result).toBeNull()
+ })
+ })
+
+ describe('loading state', () => {
+ it('should toggle loading state during fetch', async () => {
+ const apiInstance = axios.create()
+ vi.mocked(apiInstance.get).mockImplementation(() =>
+ new Promise(resolve => setTimeout(() => resolve({ data: mockProjects }), 100))
+ )
+
+ const { loading, fetchProjects } = useApi()
+
+ expect(loading.value).toBe(false)
+
+ const fetchPromise = fetchProjects()
+ // Note: loading value changes too fast in sync, check after
+ const result = await fetchPromise
+
+ expect(result).toEqual(mockProjects)
+ expect(loading.value).toBe(false)
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/test/vue/pages/ProjectDetail.test.js b/src/test/vue/pages/ProjectDetail.test.js
new file mode 100644
index 0000000..0a40a02
--- /dev/null
+++ b/src/test/vue/pages/ProjectDetail.test.js
@@ -0,0 +1,201 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import { ref } from 'vue'
+
+// Mock data
+const mockProjects = [
+ { id: 1, name: '项目一', description: '主要产品线', reportCount: 15 },
+ { id: 2, name: '项目二', description: '内部工具', reportCount: 8 }
+]
+
+const mockReports = [
+ { id: 101, fileName: '2026-05-22 日报.html', fileType: 'html', reportDate: '2026-05-22', size: '15KB' },
+ { id: 102, fileName: '2026-05-21 日报.md', fileType: 'md', reportDate: '2026-05-21', size: '8KB' },
+ { id: 103, fileName: '2026-05-20 周报.pptx', fileType: 'pptx', reportDate: '2026-05-20', size: '256KB' }
+]
+
+// Mock the useApi composable
+vi.mock('@/composables/useApi', () => ({
+ useApi: () => ({
+ loading: ref(false),
+ error: ref(null),
+ fetchProjects: vi.fn().mockResolvedValue(mockProjects),
+ fetchReports: vi.fn().mockResolvedValue(mockReports),
+ fetchReportContent: vi.fn().mockResolvedValue({
+ content: 'Test Content',
+ type: 'html'
+ })
+ })
+}))
+
+// Mock components
+vi.mock('@/components/ReportCard.vue', () => ({
+ default: {
+ template: '{{ report.fileName }}
',
+ props: ['report', 'isSelected'],
+ emits: ['select']
+ }
+}))
+
+vi.mock('@/components/FilePreview.vue', () => ({
+ default: {
+ template: '{{ report?.fileName }}
',
+ props: ['report', 'content']
+ }
+}))
+
+import ProjectDetail from '@/pages/ProjectDetail.vue'
+
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: 'Home
' } },
+ { path: '/project/:id', component: ProjectDetail }
+ ]
+})
+
+describe('ProjectDetail.vue', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ router.push('/project/1')
+ await router.isReady()
+ })
+
+ it('should render report card list', async () => {
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: {
+ template: '{{ report.fileName }}
',
+ props: ['report', 'isSelected'],
+ emits: ['select']
+ },
+ FilePreview: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Should render report cards
+ const cards = wrapper.findAll('.report-card-mock')
+ expect(cards.length).toBe(3)
+ })
+
+ it('should show preview when report is selected', async () => {
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: {
+ template: '{{ report.fileName }}
',
+ props: ['report', 'isSelected'],
+ emits: ['select']
+ },
+ FilePreview: {
+ template: '{{ report?.fileName }}
',
+ props: ['report', 'content']
+ }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Select a report
+ const cards = wrapper.findAll('.report-card-mock')
+ await cards[0].trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // FilePreview should show the selected report
+ const preview = wrapper.find('.file-preview-mock')
+ expect(preview.exists()).toBe(true)
+ })
+
+ it('should display report types via ReportCard', async () => {
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: {
+ template: '{{ report.fileType }}
',
+ props: ['report', 'isSelected'],
+ emits: ['select']
+ },
+ FilePreview: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Check that report types are passed to ReportCard
+ const cards = wrapper.findAll('.report-card-mock')
+ expect(cards[0].text()).toBe('html')
+ expect(cards[1].text()).toBe('md')
+ expect(cards[2].text()).toBe('pptx')
+ })
+
+ it('should show loading state', async () => {
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: { template: '
' },
+ FilePreview: { template: '
' }
+ }
+ }
+ })
+
+ // Initially no loading spinner (mocked to false)
+ const spinner = wrapper.find('.animate-spin')
+ expect(spinner.exists()).toBe(false)
+ })
+
+ it('should display project name', async () => {
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: { template: '
' },
+ FilePreview: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Should display project name from route param
+ expect(wrapper.text()).toContain('项目一')
+ })
+
+ it('should display report count from mock data', async () => {
+ // Note: The actual component does not display report count text.
+ // This test verifies that reports are loaded correctly
+ const wrapper = mount(ProjectDetail, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ReportCard: {
+ template: '{{ report.fileName }}
',
+ props: ['report', 'isSelected']
+ },
+ FilePreview: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Component should display the reports loaded from mock data
+ const cards = wrapper.findAll('.report-card-mock')
+ expect(cards.length).toBe(3)
+ })
+})
\ No newline at end of file
diff --git a/src/test/vue/pages/ProjectList.test.js b/src/test/vue/pages/ProjectList.test.js
new file mode 100644
index 0000000..c3415da
--- /dev/null
+++ b/src/test/vue/pages/ProjectList.test.js
@@ -0,0 +1,215 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { mount } from '@vue/test-utils'
+import { createRouter, createMemoryHistory } from 'vue-router'
+import { ref } from 'vue'
+
+// Mock the useApi composable
+vi.mock('@/composables/useApi', () => ({
+ useApi: () => ({
+ loading: ref(false),
+ error: ref(null),
+ fetchProjects: vi.fn().mockResolvedValue([
+ { id: 1, name: '项目一', description: '主要产品线', reportCount: 15 },
+ { id: 2, name: '项目二', description: '内部工具', reportCount: 8 },
+ { id: 3, name: '项目三', description: '客户定制', reportCount: 12 }
+ ]),
+ fetchReports: vi.fn(),
+ fetchReportContent: vi.fn()
+ })
+}))
+
+// Mock ProjectCard component
+vi.mock('@/components/ProjectCard.vue', () => ({
+ default: {
+ template: '{{ title }}
',
+ props: ['title', 'description', 'imageUrl', 'reportCount', 'createdAt'],
+ emits: ['click']
+ }
+}))
+
+import ProjectList from '@/pages/ProjectList.vue'
+
+const router = createRouter({
+ history: createMemoryHistory(),
+ routes: [
+ { path: '/', component: { template: 'Home
' } },
+ { path: '/project/:id', component: { template: 'Project Detail
' } }
+ ]
+})
+
+describe('ProjectList.vue', () => {
+ beforeEach(async () => {
+ vi.clearAllMocks()
+ router.push('/')
+ await router.isReady()
+ })
+
+ it('should render project list', async () => {
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: {
+ template: '{{ title }}
',
+ props: ['title', 'description', 'imageUrl', 'reportCount', 'createdAt'],
+ emits: ['click']
+ }
+ }
+ }
+ })
+
+ // Wait for onMounted to complete
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Should render project cards
+ const cards = wrapper.findAll('.project-card-mock')
+ expect(cards.length).toBe(3)
+ })
+
+ it('should show loading state initially', async () => {
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: { template: '
' }
+ }
+ }
+ })
+
+ // Check loading spinner exists (initial state)
+ const spinner = wrapper.find('.animate-spin')
+ expect(spinner.exists()).toBe(false) // Already loaded since we mock fetchProjects
+ })
+
+ it('should navigate to project on click', async () => {
+ const pushSpy = vi.spyOn(router, 'push')
+
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: {
+ template: '{{ title }}
',
+ props: ['title', 'description', 'imageUrl', 'reportCount', 'createdAt'],
+ emits: ['click']
+ }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Click on a project card (first project div)
+ const cards = wrapper.findAll('.project-card-mock')
+ expect(cards.length).toBe(3)
+
+ // Trigger click on first card
+ await cards[0].trigger('click')
+ await wrapper.vm.$nextTick()
+
+ // Should have navigated to project/1
+ expect(pushSpy).toHaveBeenCalledWith('/project/1')
+ })
+
+ it('should display project information', async () => {
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: {
+ template: '{{ title }}
',
+ props: ['title', 'description', 'imageUrl', 'reportCount', 'createdAt'],
+ emits: ['click']
+ }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Check that projects are rendered
+ const cards = wrapper.findAll('.project-card-mock')
+ expect(cards.length).toBe(3)
+
+ // Check first project card content
+ const firstCard = cards[0]
+ expect(firstCard.text()).toContain('项目一')
+ })
+
+ it('should display report count in stats cards', async () => {
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Check total reports count (15 + 8 + 12 = 35)
+ const statsText = wrapper.text()
+ expect(statsText).toContain('35') // total reports
+ })
+
+ it('should display report count from backend on stats cards', async () => {
+ // The stats card should show total report count from all projects
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: { template: '
' }
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ // Total is 35 (15 + 8 + 12) from default mock data
+ expect(wrapper.find('.text-4xl').isVisible()).toBe(true)
+ const totalReportsText = wrapper.text()
+ expect(totalReportsText).toMatch(/\d+/)
+ })
+
+ it('should render project cards with correct props structure', async () => {
+ const ProjectCardStub = {
+ template: '{{ title }}:{{ reportCount }}:{{ imageUrl }}
',
+ props: ['title', 'description', 'imageUrl', 'reportCount', 'createdAt']
+ }
+
+ const wrapper = mount(ProjectList, {
+ global: {
+ plugins: [router],
+ stubs: {
+ ProjectCard: ProjectCardStub
+ }
+ }
+ })
+
+ await wrapper.vm.$nextTick()
+ await new Promise(resolve => setTimeout(resolve, 0))
+
+ const cards = wrapper.findAll('.project-card-stub')
+ expect(cards.length).toBe(3)
+ expect(cards[0].text()).toContain('项目一')
+ })
+
+ it('should aggregate totalReports correctly', async () => {
+ const { computed } = await import('vue')
+ const totalReports = computed(() => {
+ const projects = [
+ { id: 1, reportCount: 10 },
+ { id: 2, reportCount: 5 },
+ { id: 3, reportCount: 3 }
+ ]
+ return projects.reduce((sum, p) => sum + (p.reportCount || 0), 0)
+ })
+ expect(totalReports.value).toBe(18)
+ })
+})
\ No newline at end of file
diff --git a/start-local.bat b/start-local.bat
new file mode 100644
index 0000000..4b99d41
--- /dev/null
+++ b/start-local.bat
@@ -0,0 +1,65 @@
+@echo off
+title Daily Report Distribution - Local Start
+
+echo ==========================================
+echo Daily Report Distribution - Local Start
+echo ==========================================
+echo.
+
+echo [1/5] Checking port status...
+
+netstat -ano | findstr ":37821.*LISTENING" >nul
+if %errorlevel% equ 0 (
+ echo [WARN] Port 37821 is in use, trying to stop...
+ for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":37821.*LISTENING"') do taskkill /PID %%a /F >nul 2>&1
+) else (
+ echo [OK] Port 37821 is available
+)
+
+echo.
+echo [2/5] Building backend JAR (this ensures latest code)...
+
+set "JAVA_HOME=C:\Program Files\Java\jdk-21.0.11"
+set "PATH=%JAVA_HOME%\bin;%PATH%"
+call mvnw.cmd package -DskipTests -q
+if %errorlevel% neq 0 (
+ echo [ERROR] Build failed!
+ pause
+ exit /b 1
+)
+echo [OK] Build complete
+
+echo.
+echo [3/5] Starting backend Spring Boot (port 37821)...
+
+set "JAVA_HOME=C:\Program Files\Java\jdk-21.0.11"
+set "PATH=%JAVA_HOME%\bin;%PATH%"
+start "Backend [37821]" /min cmd /c "cd /d ""%~dp0"" && java.exe -jar target\daily-report-distribution-1.0.0.jar"
+
+echo Waiting for backend to start (10s)...
+timeout /t 10 /nobreak >nul
+
+echo [OK] Backend launched
+echo.
+echo [4/5] Starting frontend Vite Dev Server (port 41733)...
+
+start "Frontend [41733]" /min cmd /c "cd /d ""%~dp0"" && npm run dev"
+
+echo.
+echo [5/5] Starting frontend E2E server (port 41735)...
+
+start "E2E Server [41735]" /min cmd /c "cd /d ""%~dp0"" && npm run dev -- --port 41735"
+
+echo.
+echo ==========================================
+echo Done!
+echo ==========================================
+echo.
+echo Backend: http://localhost:37821
+echo Frontend: http://localhost:41733
+echo E2E: http://localhost:41735
+echo.
+echo Minimize these windows - keep them running
+echo ==========================================
+echo.
+pause
\ No newline at end of file
diff --git a/stop-local.bat b/stop-local.bat
new file mode 100644
index 0000000..f28b5ce
--- /dev/null
+++ b/stop-local.bat
@@ -0,0 +1,36 @@
+@echo off
+chcp 65001 >nul
+title 日报分发平台 - 停止服务
+
+echo ==========================================
+echo 停止日报分发平台服务
+echo ==========================================
+echo.
+
+echo 正在停止所有相关服务...
+
+:: 停止所有占用 8080/5173 端口的进程
+for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8080.*LISTENING"') do (
+ taskkill /PID %%a /F >nul 2>&1
+ echo - 已停止 8080 端口 PID %%a
+)
+
+for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":5173.*LISTENING"') do (
+ taskkill /PID %%a /F >nul 2>&1
+ echo - 已停止 5173 端口 PID %%a
+)
+
+:: 也尝试停止标记了"日报分发"标题的窗口
+taskkill /FI "WINDOWTITLE eq 日报分发*" /F >nul 2>&1
+
+:: 也停掉 Java 和 Node 进程 (谨慎)
+for /f "tokens=2" %%a in ('tasklist /FI "WINDOWTITLE eq *日报分发*" /FO TABLE /NH') do (
+ echo - 已停止: %%a
+)
+
+echo.
+echo ==========================================
+echo 所有服务已停止
+echo ==========================================
+echo.
+pause
\ No newline at end of file
diff --git a/tailwind.config.js b/tailwind.config.js
new file mode 100644
index 0000000..baa35af
--- /dev/null
+++ b/tailwind.config.js
@@ -0,0 +1,3 @@
+// Tailwind CSS v4 no longer uses tailwind.config.js
+// Configuration is done via CSS @import "tailwindcss" directive
+export default {}
\ No newline at end of file
diff --git a/test-api.py b/test-api.py
new file mode 100644
index 0000000..d80c0e3
--- /dev/null
+++ b/test-api.py
@@ -0,0 +1,11 @@
+import urllib.request
+import json
+
+# Get all projects
+req = urllib.request.Request('http://localhost:37821/api/projects')
+resp = urllib.request.urlopen(req)
+projects = json.loads(resp.read().decode())
+print('Total projects:', len(projects))
+for p in projects:
+ print(' id={} name={} coverImage={} reportCount={}'.format(
+ p['id'], p['name'], p['coverImage'], p['reportCount']))
\ No newline at end of file
diff --git a/test-backend-status.py b/test-backend-status.py
new file mode 100644
index 0000000..abd6355
--- /dev/null
+++ b/test-backend-status.py
@@ -0,0 +1,26 @@
+import urllib.request
+import json
+
+# 1. Check if backend is running
+try:
+ req = urllib.request.Request('http://localhost:37821/api/projects')
+ resp = urllib.request.urlopen(req, timeout=3)
+ projects = json.loads(resp.read().decode())
+ print('Backend is running!')
+ print('Projects:', len(projects))
+ for p in projects:
+ print(' id={} name={} coverImage={}'.format(p['id'], p['name'], p['coverImage']))
+except Exception as e:
+ print('Backend NOT running or error:', e)
+
+# 2. Check if uploads directory exists
+import os
+uploads_dir = 'D:/Idea Project/publish/uploads'
+if os.path.exists(uploads_dir):
+ print('Uploads dir exists:', uploads_dir)
+ for root, dirs, files in os.walk(uploads_dir):
+ for f in files:
+ fpath = os.path.join(root, f)
+ print(' File:', fpath)
+else:
+ print('Uploads dir NOT found:', uploads_dir)
\ No newline at end of file
diff --git a/test-upload.py b/test-upload.py
new file mode 100644
index 0000000..2de1a23
--- /dev/null
+++ b/test-upload.py
@@ -0,0 +1,39 @@
+import urllib.request
+import json
+import io
+
+# Get first project
+req = urllib.request.Request('http://localhost:37821/api/projects/1')
+resp = urllib.request.urlopen(req)
+project = json.loads(resp.read().decode())
+print('Project before:', project)
+
+# Create a small test PNG image (1x1 pixel)
+png_data = b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x00\x01\x00\x01\x00\x05\xfe\x02\xfe\x00\x00\x00\x00IEND\xaeB`\x82'
+
+# Upload cover image
+boundary = '----FormBoundary7MA4YWxkTrZu0gW'
+data = '--{}\r\nContent-Disposition: form-data; name="name"\r\n\r\nTest Project\r\n--{}\r\nContent-Disposition: form-data; name="coverImage"; filename="test.png"\r\nContent-Type: image/png\r\n\r\n'.format(boundary, boundary).encode() + png_data + '\r\n--{}--\r\n'.format(boundary).encode()
+
+req = urllib.request.Request(
+ 'http://localhost:37821/api/projects/1',
+ data=data,
+ headers={
+ 'Content-Type': 'multipart/form-data; boundary=' + boundary,
+ },
+ method='PUT'
+)
+
+try:
+ resp = urllib.request.urlopen(req)
+ result = json.loads(resp.read().decode())
+ print('Upload response:', result)
+ print('coverImage:', result.get('coverImage'))
+except Exception as e:
+ print('Error uploading:', e)
+
+# Check updated project
+req2 = urllib.request.Request('http://localhost:37821/api/projects/1')
+resp2 = urllib.request.urlopen(req2)
+project2 = json.loads(resp2.read().decode())
+print('Project after:', project2)
\ No newline at end of file
diff --git a/tests/e2e/cover-image.spec.js b/tests/e2e/cover-image.spec.js
new file mode 100644
index 0000000..30f6c73
--- /dev/null
+++ b/tests/e2e/cover-image.spec.js
@@ -0,0 +1,131 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Cover Image Upload E2E Tests
+ * Tests for the cover image upload functionality on ProjectDetail page
+ */
+test.describe('Cover Image Upload', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+ })
+
+ test('should navigate to project detail page', async ({ page }) => {
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ // Click the first project card by text
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+ })
+
+ test('should enter project edit mode by clicking project name', async ({ page }) => {
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Click on project name to enter edit mode
+ const projectName = page.locator('h2').first()
+ await expect(projectName).toBeVisible({ timeout: 5000 })
+ await projectName.click()
+
+ // Should show edit form with file input
+ await expect(page.locator('input[type="file"]').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should show save and cancel buttons in edit mode', async ({ page }) => {
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Enter edit mode
+ const projectName = page.locator('h2').first()
+ await projectName.click()
+ await page.waitForTimeout(300)
+
+ // Should show save button
+ const saveButton = page.locator('button:has-text("保存")')
+ await expect(saveButton).toBeVisible({ timeout: 5000 })
+
+ // Should show cancel button
+ const cancelButton = page.locator('button:has-text("取消")')
+ await expect(cancelButton).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should have image file input accepting image types', async ({ page }) => {
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Enter edit mode
+ const projectName = page.locator('h2').first()
+ await projectName.click()
+ await page.waitForTimeout(300)
+
+ // File input should accept images
+ const fileInput = page.locator('input[type="file"]').first()
+ await expect(fileInput).toBeVisible({ timeout: 5000 })
+ const accept = await fileInput.getAttribute('accept')
+ expect(accept).toBe('image/*')
+ })
+
+ test('should cancel edit mode and restore original state', async ({ page }) => {
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Enter edit mode
+ const projectName = page.locator('h2').first()
+ await projectName.click()
+ await page.waitForTimeout(300)
+
+ // Click cancel
+ const cancelButton = page.locator('button:has-text("取消")')
+ await cancelButton.click()
+ await page.waitForTimeout(300)
+
+ // Edit form should be hidden, back to display mode
+ const fileInput = page.locator('input[type="file"]')
+ await expect(fileInput).toHaveCount(0, { timeout: 5000 })
+ })
+})
+
+/**
+ * Cover Image Refresh E2E Tests
+ * Tests that cover image changes reflect on project list after saving
+ */
+test.describe('Cover Image - List Refresh', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+ })
+
+ test('should refresh project list after navigating back from detail', async ({ page }) => {
+ // Get initial project count
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 15000 })
+
+ // Navigate to a project
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await page.locator('text=项目一').first().click()
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Navigate back to project list
+ await page.locator('a[href="/"]').click()
+ await expect(page.locator('text=选择项目').first()).toBeVisible({ timeout: 10000 })
+
+ // Should still show correct project count (list refreshed)
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display report count on stats cards', async ({ page }) => {
+ // Stats cards should show actual counts
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 15000 })
+ await expect(page.locator('text=份报告').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=文件类型').first()).toBeVisible({ timeout: 5000 })
+
+ // Check that the stats numbers are visible (4xl font size)
+ const statsNumbers = page.locator('.text-4xl')
+ const count = await statsNumbers.count()
+ expect(count).toBeGreaterThanOrEqual(3)
+ })
+})
\ No newline at end of file
diff --git a/tests/e2e/project.spec.js b/tests/e2e/project.spec.js
new file mode 100644
index 0000000..04eb140
--- /dev/null
+++ b/tests/e2e/project.spec.js
@@ -0,0 +1,141 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Project Management E2E Tests
+ * Tests for the UI redesigned project list and detail pages
+ */
+test.describe('Project Management (New UI)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/')
+ // Wait for the page to fully load
+ await page.waitForLoadState('networkidle')
+ // Wait for content to render
+ await page.waitForTimeout(1000)
+ })
+
+ test('should display project list with mock data', async ({ page }) => {
+ // Verify that projects are displayed
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ await expect(page.locator('text=项目二').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=项目三').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should navigate to project detail page when clicking a project card', async ({ page }) => {
+ // Wait for project to be visible
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on the project card (large card with background image)
+ // The new UI uses a carousel with ProjectCard components
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ await projectCards.first().click()
+
+ // Verify navigation to project detail
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+ })
+
+ test('should display stats cards with correct counts', async ({ page }) => {
+ // Wait for projects to load
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 15000 })
+
+ // Check stats cards are visible
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=份报告').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=文件类型').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display project carousel with navigation arrows', async ({ page }) => {
+ // Wait for projects to load
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+
+ // Check carousel navigation arrows exist
+ const leftArrow = page.locator('button').filter({ has: page.locator('svg path[d*="M15 19l-7-7 7-7"]') })
+ const rightArrow = page.locator('button').filter({ has: page.locator('svg path[d*="M9 5l7 7-7 7"]') })
+
+ // At least one navigation button should be visible
+ await expect(page.locator('button').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display project cards with report count badges', async ({ page }) => {
+ // Check that project cards show report count
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ // Report count badge should be visible in cards
+ await expect(page.locator('text=份报告').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should navigate to project detail and show reports', async ({ page }) => {
+ // Navigate to a project
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on a project card
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ await projectCards.first().click()
+
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Should show the project name in detail page
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should navigate back to project list from project detail', async ({ page }) => {
+ // Navigate to a project
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ await projectCards.first().click()
+
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Click back button (router-link to="/")
+ await page.locator('a[href="/"]').click()
+
+ // Verify we're back on the project list
+ await expect(page.locator('text=选择项目').first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should display glass effect sidebar in project detail', async ({ page }) => {
+ // Navigate to a project
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ await projectCards.first().click()
+
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Check that the sidebar has glass effect class
+ const sidebar = page.locator('.glass-light')
+ await expect(sidebar).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display report cards in project detail sidebar', async ({ page }) => {
+ // Navigate to a project
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ await projectCards.first().click()
+
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Wait for reports to load (look for report file type badges)
+ await expect(page.locator('text=HTML').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should show loading state while fetching', async ({ page }) => {
+ // The loading spinner should appear briefly
+ // But we can verify the content eventually loads
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ })
+
+ test('should display correct report count from backend', async ({ page }) => {
+ // Wait for projects to load
+ await expect(page.locator('text=个项目').first()).toBeVisible({ timeout: 15000 })
+ // Stats cards show actual report counts from backend
+ await expect(page.locator('.text-4xl').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display cover image on project cards when available', async ({ page }) => {
+ // Wait for project cards to load
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ // ProjectCard should render with background image style when coverImage is set
+ // Cards with cover image will have background-image CSS property
+ const projectCards = page.locator('.group.relative.h-\\[420px\\]')
+ const cardCount = await projectCards.count()
+ expect(cardCount).toBeGreaterThan(0)
+ })
+})
\ No newline at end of file
diff --git a/tests/e2e/report-view.spec.js b/tests/e2e/report-view.spec.js
new file mode 100644
index 0000000..b96fd42
--- /dev/null
+++ b/tests/e2e/report-view.spec.js
@@ -0,0 +1,121 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Report View E2E Tests (New UI)
+ * Tests report viewing, rendering, and download functionality
+ */
+test.describe('Report View (New UI)', () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto('/project/1')
+ await page.waitForLoadState('networkidle')
+ // Wait for content to render
+ await page.waitForTimeout(1000)
+ })
+
+ test('should render HTML content in iframe preview', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on HTML report
+ await page.click('text=2026-05-22 日报.html')
+
+ // Wait for iframe to be visible
+ const iframe = page.locator('iframe[srcdoc]')
+ await expect(iframe).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should render Markdown content with proper formatting', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on MD report
+ await page.click('text=2026-05-21 日报.md')
+
+ // Verify markdown is rendered (not raw markdown)
+ // The header should be rendered as h1
+ const header = page.locator('h1:has-text("日报标题")')
+ await expect(header).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should show download button for PPTX reports', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on PPTX report
+ await page.click('text=2026-05-20 周报.pptx')
+
+ // Verify download button is visible (new UI has single download button for all types)
+ const downloadBtn = page.locator('button:has-text("下载")')
+ await expect(downloadBtn).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should show report name in preview header when report is selected', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on HTML report
+ await page.click('text=2026-05-22 日报.html')
+
+ // Verify report name appears in preview header
+ await expect(page.locator('h3:has-text("2026-05-22 日报.html")').first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should show report date and size in preview header', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on HTML report
+ await page.click('text=2026-05-22 日报.html')
+
+ // Verify date and size appear
+ await expect(page.locator('text=2026-05-22').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=15KB').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should have download button for all report types', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on HTML report
+ await page.click('text=2026-05-22 日报.html')
+ await expect(page.locator('button:has-text("下载")').first()).toBeVisible({ timeout: 5000 })
+
+ // Click on MD report
+ await page.click('text=2026-05-21 日报.md')
+ await expect(page.locator('button:has-text("下载")').first()).toBeVisible({ timeout: 5000 })
+
+ // Click on PPTX report
+ await page.click('text=2026-05-20 周报.pptx')
+ await expect(page.locator('button:has-text("下载")').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should navigate between reports smoothly', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Select first report
+ await page.click('text=2026-05-22 日报.html')
+ await expect(page.locator('h3:has-text("2026-05-22 日报.html")').first()).toBeVisible({ timeout: 5000 })
+
+ // Select second report
+ await page.click('text=2026-05-21 日报.md')
+ await expect(page.locator('h3:has-text("2026-05-21 日报.md")').first()).toBeVisible({ timeout: 5000 })
+
+ // Select third report
+ await page.click('text=2026-05-20 周报.pptx')
+ await expect(page.locator('h3:has-text("2026-05-20 周报.pptx")').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should show empty state for non-existent project', async ({ page }) => {
+ // Navigate to project with no reports (project ID 999 - mock data returns empty)
+ await page.goto('/project/999')
+ await page.waitForLoadState('networkidle')
+
+ // Should show the project detail page with "项目 999" header
+ await expect(page.locator('h2:has-text("项目 999")')).toBeVisible({ timeout: 15000 })
+ // With no reports, should show empty state in list
+ await expect(page.locator('text=暂无报告').first()).toBeVisible({ timeout: 5000 })
+ // Should show empty state in preview
+ await expect(page.locator('text=选择一份报告以预览')).toBeVisible({ timeout: 5000 })
+ })
+})
\ No newline at end of file
diff --git a/tests/e2e/report.spec.js b/tests/e2e/report.spec.js
new file mode 100644
index 0000000..8a86678
--- /dev/null
+++ b/tests/e2e/report.spec.js
@@ -0,0 +1,100 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Report Upload E2E Tests (New UI)
+ * Tests report upload functionality in the project detail view
+ */
+test.describe('Report Upload (New UI)', () => {
+ test.beforeEach(async ({ page }) => {
+ // Navigate directly to a project detail page
+ await page.goto('/project/1')
+ await page.waitForLoadState('networkidle')
+ // Wait for content to render
+ await page.waitForTimeout(1000)
+ })
+
+ test('should display existing reports in the report list', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Verify HTML report appears in list
+ await expect(page.locator('text=2026-05-22 日报.html')).toBeVisible({ timeout: 5000 })
+
+ // Verify MD report appears in list
+ await expect(page.locator('text=2026-05-21 日报.md')).toBeVisible({ timeout: 5000 })
+
+ // Verify PPTX report appears in list
+ await expect(page.locator('text=2026-05-20 周报.pptx')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display report type badges in sidebar', async ({ page }) => {
+ // Check that file type badges are visible
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // File type labels should be visible
+ await expect(page.locator('text=HTML').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=Markdown').first()).toBeVisible({ timeout: 5000 })
+ await expect(page.locator('text=PowerPoint').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should select and preview HTML report', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on HTML report
+ await page.click('text=2026-05-22 日报.html')
+
+ // Verify preview appears (HTML reports show in iframe)
+ await expect(page.locator('iframe[srcdoc]')).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should select and preview MD report', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on MD report
+ await page.click('text=2026-05-21 日报.md')
+
+ // Verify markdown content is rendered
+ await expect(page.locator('text=日报标题')).toBeVisible({ timeout: 10000 })
+ await expect(page.locator('text=工作内容')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should show download button for PPTX report', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on PPTX report
+ await page.click('text=2026-05-20 周报.pptx')
+
+ // Verify download UI appears (new UI has unified download button)
+ await expect(page.locator('button:has-text("下载")').first()).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should show empty state when no report is selected', async ({ page }) => {
+ // The page starts without a selected report
+ // Empty state should be visible (select prompt)
+ await expect(page.locator('text=选择一份报告以预览')).toBeVisible({ timeout: 15000 })
+ })
+
+ test('should highlight selected report in the list', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Click on a report
+ await page.click('text=2026-05-22 日报.html')
+
+ // The selected report should have different styling (we can't easily test color, but we can test it's clickable)
+ // Report should remain selected
+ await expect(page.locator('iframe[srcdoc]')).toBeVisible({ timeout: 10000 })
+ })
+
+ test('should show glass effect sidebar', async ({ page }) => {
+ // Wait for project to load
+ await expect(page.locator('h2').first()).toBeVisible({ timeout: 15000 })
+
+ // Check that the sidebar has glass effect class
+ const sidebar = page.locator('.glass-light')
+ await expect(sidebar).toBeVisible({ timeout: 5000 })
+ })
+})
\ No newline at end of file
diff --git a/tests/e2e/responsive.spec.js b/tests/e2e/responsive.spec.js
new file mode 100644
index 0000000..7d7f106
--- /dev/null
+++ b/tests/e2e/responsive.spec.js
@@ -0,0 +1,119 @@
+import { test, expect } from '@playwright/test'
+
+/**
+ * Responsive Layout E2E Tests
+ * Tests responsive design for PC (1920px) and Mobile (375px) viewports
+ * Note: Sidebar () exists only on ProjectDetail.vue, not ProjectList.vue
+ */
+test.describe('Responsive Layout', () => {
+ test('should display main content on PC width (1920px)', async ({ page }) => {
+ // Set PC viewport
+ await page.setViewportSize({ width: 1920, height: 1080 })
+
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Main content should be visible
+ const mainContent = page.locator('main')
+ await expect(mainContent).toBeVisible({ timeout: 15000 })
+
+ // Page title should be visible
+ await expect(page.locator('text=选择项目')).toBeVisible({ timeout: 5000 })
+
+ // Projects should be displayed
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display stats cards on mobile width (375px)', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Main content should be visible
+ const mainContent = page.locator('main')
+ await expect(mainContent).toBeVisible({ timeout: 10000 })
+
+ // Stats cards should be visible
+ await expect(page.locator('text=个项目')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should display project cards in single column on mobile', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Project cards should still be visible
+ await expect(page.locator('text=项目一').first()).toBeVisible({ timeout: 15000 })
+ })
+
+ test('should navigate to project detail on mobile', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Click on a project to navigate to detail (use force to bypass overlay)
+ await page.locator('text=项目一').first().click({ force: true })
+
+ // Should navigate to project detail
+ await expect(page).toHaveURL(/\/project\/\d+/, { timeout: 10000 })
+ })
+
+ test('should display sidebar in project detail on PC width', async ({ page }) => {
+ // Set PC viewport
+ await page.setViewportSize({ width: 1920, height: 1080 })
+
+ // First go to home page to load mock data
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Navigate to project detail via click
+ await page.locator('text=项目一').first().click({ force: true })
+ await page.waitForURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Wait for the sidebar to appear
+ await expect(page.locator('text=返回项目列表')).toBeVisible({ timeout: 15000 })
+ })
+
+ test('should display reports list in project detail on mobile width', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+
+ // First go to home page to load mock data
+ await page.goto('/')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Navigate to project detail via click
+ await page.locator('text=项目一').first().click({ force: true })
+ await page.waitForURL(/\/project\/\d+/, { timeout: 10000 })
+
+ // Reports should be visible (from mock data)
+ await expect(page.locator('text=2026-05-22 日报.html')).toBeVisible({ timeout: 5000 })
+ })
+
+ test('should close sidebar after navigation on mobile', async ({ page }) => {
+ // Set mobile viewport
+ await page.setViewportSize({ width: 375, height: 667 })
+
+ await page.goto('/project/1')
+ await page.waitForLoadState('networkidle')
+ await page.waitForTimeout(1000)
+
+ // Navigate back to project list
+ await page.locator('text=返回项目列表').click()
+
+ // Should be back on project list page
+ await expect(page).toHaveURL(/\/$/, { timeout: 10000 })
+ })
+})
\ No newline at end of file
diff --git a/uploads/.gitkeep b/uploads/.gitkeep
new file mode 100644
index 0000000..535666a
--- /dev/null
+++ b/uploads/.gitkeep
@@ -0,0 +1 @@
+# Keep uploads directory in git
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..68eb434
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'path'
+import tailwindcss from '@tailwindcss/vite'
+
+export default defineConfig({
+ plugins: [
+ vue(),
+ tailwindcss()
+ ],
+ server: {
+ port: 41733,
+ host: '0.0.0.0',
+ proxy: {
+ '/api': {
+ target: 'http://localhost:37821',
+ changeOrigin: true
+ },
+ '/uploads': {
+ target: 'http://localhost:37821',
+ changeOrigin: true
+ }
+ }
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src')
+ }
+ }
+})
\ No newline at end of file
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..0ef69df
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,17 @@
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+import path from 'path'
+
+export default defineConfig({
+ plugins: [vue()],
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ include: ['src/**/*.test.js', 'src/**/*.spec.js']
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src')
+ }
+ }
+})
\ No newline at end of file
diff --git a/修复报告_20260524.html b/修复报告_20260524.html
new file mode 100644
index 0000000..2751ade
--- /dev/null
+++ b/修复报告_20260524.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+ 日报分发平台 - 问题修复报告 2026-05-24
+
+
+
+
+
+
+
+
+
+
+
1 问题概览
+
+ 优先级 问题描述 文件 状态
+
+ P1 HTML/MD 预览和下载按钮全部失效 FilePreview.vue ✅ 已修复
+ P2 下载按钮图标背景显示透明 FilePreview.vue ✅ 已修复
+ P2 选中报告高亮背景显示透明 ReportCard.vue ✅ 已修复
+
+
+
+
+
+
+
2 根因分析与修复
+
+
+
🪲 Bug #1:HTML/MD 预览和下载按钮失效 P1
+
Before(错误代码)
+ // 后端返回 "HTML",前端直接比较小写
+ v-if="report.fileType === 'html'" // ❌ 永远为 false
+ v-if="report.fileType === 'md'" // ❌ 永远为 false
+ v-if="report.fileType === 'pptx'" // ❌ 永远为 false
+
+
After(修复代码)
+ const normalizedFileType = computed(() =>
+ (props.report?.fileType || '').toLowerCase())
+ v-if="normalizedFileType === 'html'" // ✅ 正常激活
+ v-if="normalizedFileType === 'md'" // ✅ 正常激活
+
+
+
+
+
🪲 Bug #2:下载按钮图标背景透明 P2
+
Before(错误代码)
+ bg-gradient-to-br from-orange-500 to-orange-600
+ // Tailwind v4 + CSS 变量 → gradient 颜色被解析为透明
+
+
After(修复代码)
+ bg-orange-500 // 改用实色,绕过 gradient 透明问题
+
+
+
+
+
🪲 Bug #3:选中报告高亮背景透明 P2
+
Before(错误代码)
+ bg-gradient-to-r from-orange-600 to-amber-600
+ // 同样因 Tailwind v4 gradient bug 导致背景透明
+
+
After(修复代码)
+ bg-orange-600 // 实色背景,正常显示高亮
+
+
+
+
+
+
+
3 测试验证结果
+
+ 测试项 环境 结果
+
+ HTML iframe 预览渲染 本地 (41734) + NAS (41733) ✅ PASS
+ MD Markdown 内容预览 本地 ✅ PASS
+ 下载按钮橙色背景 本地 + NAS ✅ PASS
+ 选中报告侧边栏高亮 本地 + NAS ✅ PASS
+ Console 无报错 本地 + NAS ✅ PASS
+ Docker 镜像构建 FnOS NAS ✅ PASS
+ API 报告列表 + 上传 NAS (192.168.31.240:41733) ✅ PASS
+
+
+
+
+
+
+
4 经验沉淀
+
+ ⚡ Tailwind v4 gradient + CSS 变量 bug: 在 Tailwind v4 下,CSS 变量定义的橙色(如 --color-orange-500)搭配 bg-gradient-to-* 使用时,gradient 颜色会被解析为透明。解法:统一使用实色类名如 bg-orange-500,避免 gradient。此 bug 影响所有使用 gradient + CSS 自定义颜色组合的 Tailwind v4 项目。
+ ⚡ 前后端 fileType 大小写不一致: 后端返回 "HTML"/"MD"(大写),前端若直接与字符串字面量比较,需注意大小写。建议:后端统一小写返回,或前端用 toLowerCase() 归一化。
+ ⚡ Playwright E2E 环境准备: 本地 Playwright 测试需要确认:后端端口(8080 非 37821)、Vite proxy 目标端口、测试数据存在。测试文件(如 report.spec.js)依赖的测试数据需预先通过 API 上传,否则报告列表为空导致所有断言超时。
+ ⚡ Docker 部署端口差异: 本地开发后端在 8080,Docker compose 中定义为 37821 暴露,但实际 JAR 运行在容器内 8080。建议:确认实际监听端口,避免 proxy 配置指向错误端口。
+ ⚡ NAS Docker 构建缓存: Docker COPY dist/ 会缓存构建结果,文件内容变了但步骤不重新执行。解法:重命名 dist 目录(如 dist_new/)+ 更新 Dockerfile 对应路径,强制使 COPY 步骤 cache miss。
+ ⚡ Playwright ESM vs CJS: publish 项目 package.json 含有 "type": "module",直接用 .js 运行 Node Playwright 脚本会报 require is not defined。解法:使用 .cjs 扩展名,或改用 ESM import 语法。
+
+
+
+
+
+
5 变更文件
+
+ src/components/FilePreview.vue
+ src/components/ReportCard.vue
+ dist/(构建产物)
+
+
+
+
+
+
+