Ok
This commit is contained in:
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Flask application secret key for session protection.
|
||||
# IMPORTANT: Change this to a long, random string in your .env file.
|
||||
# You can generate one using: python -c "import secrets; print(secrets.token_hex(24))"
|
||||
SECRET_KEY='change-me-to-a-real-secret-key'
|
||||
|
||||
# Database connection URL.
|
||||
# Format: mysql+pymysql://<user>:<password>@<host>:<port>/<database_name>
|
||||
# Example for a local MySQL server:
|
||||
DATABASE_URL='mysql+pymysql://root:your_password@localhost:3306/temp_spec_db'
|
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# --- 敏感資訊 (Sensitive Information) ---
|
||||
# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。
|
||||
.env
|
||||
|
||||
# --- Python 相關 (Python Related) ---
|
||||
# 忽略虛擬環境目錄。
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 忽略 Python 的位元組碼和快取檔案。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) ---
|
||||
# 忽略上傳的已簽核文件 (PDFs)。
|
||||
/uploads/
|
||||
|
||||
# 忽略系統自動產生的暫時規範文件 (Word, PDF)。
|
||||
/generated/
|
||||
|
||||
# 忽略使用者在編輯器中上傳的圖片。
|
||||
/static/uploads/
|
||||
|
||||
# --- IDE / 編輯器設定 (IDE / Editor Settings) ---
|
||||
# 忽略 Visual Studio Code 的本機設定。
|
||||
.vscode/
|
||||
|
||||
# --- 作業系統相關 (Operating System) ---
|
||||
# 忽略 macOS 的系統檔案。
|
||||
.DS_Store
|
||||
|
||||
# 忽略 Windows 的縮圖快取。
|
||||
Thumbs.db
|
||||
|
||||
# --- Log 檔案 ---
|
||||
# 忽略所有日誌檔案。
|
||||
*.log
|
||||
logs/
|
134
README.md
Normal file
134
README.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# TEMP Spec System - 暫時規範管理系統
|
||||
|
||||
這是一個使用 Flask 開發的 Web 應用程式,旨在管理、追蹤和存檔暫時性的工程規範。系統支援完整的生命週期管理,從建立、審核、生效到終止,並能自動生成標準化文件。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **使用者權限管理**: 內建三種角色 (`viewer`, `editor`, `admin`),各角色擁有不同操作權限。
|
||||
- **規範生命週期**: 支援暫時規範的建立、啟用、展延、終止與刪除。
|
||||
- **文件自動生成**: 可根據 Word 模板 (`.docx`) 自動填入內容並生成 PDF 與 Word 文件。
|
||||
- **檔案管理**: 支援上傳簽核後的文件,並與對應的規範進行關聯。
|
||||
- **歷史紀錄**: 詳細記錄每一份規範的所有變更歷史,方便追蹤與稽核。
|
||||
- **內容編輯**: 支援 Markdown 語法及圖片上傳,提供豐富的內容編輯體驗。
|
||||
|
||||
---
|
||||
|
||||
## 環境要求
|
||||
|
||||
在部署此應用程式之前,請確保您的系統已安裝以下軟體:
|
||||
|
||||
1. **Python**: 建議使用 `Python 3.10` 或更高版本。
|
||||
2. **MySQL**: 需要一個 MySQL 資料庫來儲存所有應用程式資料。
|
||||
3. **Microsoft Office / LibreOffice**:
|
||||
- **[重要]** 本專案使用 `docx2pdf` 套件來將 Word 文件轉換為 PDF。此套件依賴於系統上安裝的 Microsoft Office (Windows) 或 LibreOffice (跨平台)。請務必確保伺服器上已安裝其中之一,否則 PDF 生成功能將會失敗。
|
||||
4. **Git**: 用於從版本控制系統下載程式碼。
|
||||
|
||||
---
|
||||
|
||||
## 安裝與設定步驟
|
||||
|
||||
請依照以下步驟來設定您的開發或生產環境:
|
||||
|
||||
### 1. 下載程式碼
|
||||
|
||||
```bash
|
||||
git clone <your-repository-url>
|
||||
cd TEMP_spec_system
|
||||
```
|
||||
|
||||
### 2. 建立並啟用虛擬環境
|
||||
|
||||
建議使用虛擬環境來隔離專案的相依套件。
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
python -m venv .venv
|
||||
.\.venv\Scripts\activate
|
||||
|
||||
# macOS / Linux
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. 安裝相依套件
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. 設定環境變數
|
||||
|
||||
專案的敏感設定(如資料庫連線資訊、密鑰)是透過 `.env` 檔案管理的。
|
||||
|
||||
首先,複製範例檔案:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
copy .env.example .env
|
||||
|
||||
# macOS / Linux
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
然後,編輯 `.env` 檔案,填入您的實際設定:
|
||||
|
||||
```dotenv
|
||||
# Flask 應用程式的密鑰,用於保護 session,請務必修改為一個隨機的長字串
|
||||
SECRET_KEY='your-super-secret-and-random-string'
|
||||
|
||||
# 資料庫連線 URL
|
||||
# 格式: mysql+pymysql://<使用者名稱>:<密碼>@<主機地址>:<埠號>/<資料庫名稱>
|
||||
DATABASE_URL='mysql+pymysql://user:password@localhost:3306/temp_spec_db'
|
||||
```
|
||||
|
||||
**注意**: 請先在您的 MySQL 中手動建立一個名為 `temp_spec_db` (或您自訂的名稱) 的資料庫。
|
||||
|
||||
### 5. 初始化資料庫
|
||||
|
||||
執行初始化腳本來建立所有需要的資料表,並產生一個預設的管理員帳號。
|
||||
|
||||
```bash
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
腳本會提示您確認操作。輸入 `yes` 後,它會建立資料表並在終端機中顯示預設 `admin` 帳號的隨機密碼。**請務必記下此密碼**。
|
||||
|
||||
---
|
||||
|
||||
## 執行應用程式
|
||||
|
||||
### 開發模式
|
||||
|
||||
在開發環境中,您可以直接執行 `app.py`:
|
||||
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
應用程式預設會在 `http://127.0.0.1:5000` 上執行。
|
||||
|
||||
### 生產環境
|
||||
|
||||
在生產環境中,**不應**使用 Flask 內建的開發伺服器。建議使用生產級的 WSGI 伺服器,例如 `Gunicorn` (Linux) 或 `Waitress` (Windows)。
|
||||
|
||||
**使用 Waitress (Windows) 的範例:**
|
||||
|
||||
1. 安裝 Waitress: `pip install waitress`
|
||||
2. 執行應用程式: `waitress-serve --host=0.0.0.0 --port=8000 app:app`
|
||||
|
||||
---
|
||||
|
||||
## 使用者角色說明
|
||||
|
||||
- **Viewer (檢視者)**:
|
||||
- 只能瀏覽和搜尋暫時規範。
|
||||
- 可以下載已生效或待生效的 PDF 文件。
|
||||
- **Editor (編輯者)**:
|
||||
- 擁有 `Viewer` 的所有權限。
|
||||
- 可以建立新的暫時規範,並下載待簽核的 Word 文件。
|
||||
- 可以展延或終止已生效的規範。
|
||||
- **Admin (管理者)**:
|
||||
- 擁有 `Editor` 的所有權限。
|
||||
- 可以管理使用者帳號 (新增、編輯、刪除)。
|
||||
- **可以上傳簽核後的文件,正式啟用一份規範**。
|
||||
- 可以永久刪除一份規範及其所有相關檔案。
|
119
USER_MANUAL.md
Normal file
119
USER_MANUAL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 系統操作說明書 (User Manual)
|
||||
|
||||
歡迎使用「暫時規範管理系統」。本說明書將引導您如何操作本系統的各項功能。
|
||||
|
||||
## 1. 系統簡介
|
||||
|
||||
本系統旨在提供一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、簽核、生效到最終歸檔的完整生命週期,確保所有流程都有據可查。
|
||||
|
||||
---
|
||||
|
||||
## 2. 登入與主畫面
|
||||
|
||||
### 2.1 登入
|
||||
|
||||
請使用管理員提供的帳號和密碼,在首頁進行登入。
|
||||
|
||||
### 2.2 主畫面 (暫時規範總表)
|
||||
|
||||
登入後,您會看到系統的主畫面,這裡會列出所有的暫時規範。主畫面包含以下幾個重要部分:
|
||||
|
||||
- **建立新規範按鈕**: (僅 Editor/Admin 可見) 點擊此處開始建立一份新的規範。
|
||||
- **搜尋與篩選區**:
|
||||
- **搜尋框**: 您可以輸入規範的「編號」或「主題」關鍵字來快速找到目標。
|
||||
- **狀態篩選器**: 您可以根據規範的狀態 (如:待生效、已生效) 來篩選列表。
|
||||
- **規範列表**: 顯示了每份規範的關鍵資訊,包括編號、主題、建立日期、結束日期和目前狀態。
|
||||
- **操作按鈕**: 針對每一份規範,您可以看到一組操作按鈕,這些按鈕會根據您的「角色」和規範的「狀態」而有所不同。
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心操作流程
|
||||
|
||||
### 3.1 流程總覽
|
||||
|
||||
本系統的標準工作流程如下:
|
||||
|
||||
1. **Editor** 建立一份新的暫時規範草稿。
|
||||
2. 系統自動產生一份標準格式的 **Word (.docx) 文件**。
|
||||
3. **Editor** 將此 Word 文件下載,進行線下簽核流程。
|
||||
4. 簽核完成後,將文件轉為 **PDF (.pdf) 格式**。
|
||||
5. **Admin** 登入系統,上傳簽核完成的 PDF 檔案,正式**啟用**該規範。
|
||||
|
||||
### 3.2 建立新的暫時規範 (Editor / Admin)
|
||||
|
||||
1. 在主畫面點擊右上角的 **[+ 建立新規範]** 按鈕。
|
||||
2. 在「建立暫時規範」頁面,填寫所有必填欄位。
|
||||
- **內容編輯**: 「變更前」、「變更後」等欄位支援 Markdown 語法,您可以點擊工具列按鈕來插入表格、清單,或上傳圖片。
|
||||
3. 填寫完成後,點擊頁面下方的 **[預覽]** 按鈕,可以即時查看生成後 PDF 的樣式。
|
||||
4. 確認無誤後,點擊 **[產生並下載 Word]** 按鈕。
|
||||
5. 系統會自動產生一份 `.docx` 檔案供您下載。此時,這份新規範會出現在總表中,狀態為「**待生效**」。
|
||||
|
||||
### 3.3 啟用暫時規範 (僅限 Admin)
|
||||
|
||||
當一份規範的線下簽核流程完成後,管理員需執行以下操作使其生效:
|
||||
|
||||
1. 在總表中找到狀態為「**待生效**」的目標規範。
|
||||
2. 點擊該規範右側的 **啟用圖示 (✅)**。
|
||||
3. 在「啟用暫時規範」頁面,點擊 **[選擇檔案]**,並上傳**已簽核完成的 PDF 檔案**。
|
||||
4. 點擊 **[啟用規範]** 按鈕。
|
||||
5. 完成後,該規範的狀態會變為「**已生效**」,表示此規範已在效期內。
|
||||
|
||||
### 3.4 管理已生效的規範 (Editor / Admin)
|
||||
|
||||
對於「**已生效**」的規範,您可以進行展延或提早終止:
|
||||
|
||||
- **展延**:
|
||||
1. 點擊 **展延圖示 (📅+**)。
|
||||
2. 選擇新的結束日期,並可選擇性上傳新的佐證文件。
|
||||
3. 點擊儲存,規範的效期將會延長。
|
||||
- **終止**:
|
||||
1. 點擊 **終止圖示 (❌)**。
|
||||
2. 填寫提早終止的原因。
|
||||
3. 提交後,規範狀態將變為「**已終止**」。
|
||||
|
||||
### 3.5 搜尋、篩選與下載
|
||||
|
||||
- **搜尋**: 在主畫面的搜尋框輸入關鍵字,按下「篩選」即可。
|
||||
- **篩選**: 在下拉選單中選擇您想看的狀態,按下「篩選」即可。
|
||||
- **下載**:
|
||||
- **待生效規範**:
|
||||
- 所有角色都可下載 **PDF** 版本。
|
||||
- Editor 和 Admin 還可以額外下載 **Word** 原始檔。
|
||||
- **已生效/已終止/已過期規範**:
|
||||
- 所有角色都可以下載由 Admin 上傳的**最終簽核版 PDF**。
|
||||
|
||||
### 3.6 檢視歷史紀錄
|
||||
|
||||
若要查看某份規範的所有變更紀錄 (如誰建立、誰啟用、誰展延),可以點擊該規範最右側的 **歷史紀錄圖示 (🕒)**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 使用者管理 (僅限 Admin)
|
||||
|
||||
管理員可以點擊頁面頂端導覽列的 **[後台管理]** 來進入使用者管理頁面。在此頁面中,您可以:
|
||||
|
||||
- **建立新使用者**: 設定新使用者的帳號、密碼和角色。
|
||||
- **編輯現有使用者**: 修改使用者的角色或重設其密碼。
|
||||
- **刪除使用者**: 從系統中移除某個使用者。
|
||||
|
||||
---
|
||||
|
||||
## 5. 名詞解釋
|
||||
|
||||
- **待生效 (Pending Approval)**: 規範已建立,但尚未上傳簽核後的正式文件,還未生效。
|
||||
- **已生效 (Active)**: 規範已由管理員啟用,目前在有效期內。
|
||||
- **已終止 (Terminated)**: 規範在有效期結束前,被人為提早結束。
|
||||
- **已過期 (Expired)**: 規範已超過其設定的結束日期,自動失效。
|
||||
|
||||
---
|
||||
|
||||
## 6. 常見問題 (FAQ)
|
||||
|
||||
**Q: 為什麼我看不到「建立規範」或「啟用」的按鈕?**
|
||||
A: 您的帳號權限不足。建立規範需要 `Editor` 或 `Admin` 權限;啟用規範僅限 `Admin`。如有需要,請聯繫系統管理員。
|
||||
|
||||
**Q: 我忘記密碼了怎麼辦?**
|
||||
A: 請聯繫系統管理員,請他/她為您重設密碼。
|
||||
|
||||
**Q: 我可以上傳 Word 檔案來啟用規範嗎?**
|
||||
A: 不行。為了確保文件的最終性和不可修改性,系統規定必須上傳已簽核的 **PDF 檔案**來啟用規範。
|
48
app.py
Normal file
48
app.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from flask import Flask, redirect, url_for, render_template
|
||||
from flask_login import LoginManager, current_user
|
||||
from models import db, User
|
||||
from routes.auth import auth_bp
|
||||
from routes.temp_spec import temp_spec_bp
|
||||
from routes.upload import upload_bp
|
||||
from routes.admin import admin_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('config.Config')
|
||||
|
||||
# 初始化資料庫
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化登入管理
|
||||
login_manager = LoginManager()
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = 'auth.login'
|
||||
login_manager.login_message = "請先登入以存取此頁面。"
|
||||
login_manager.login_message_category = "info"
|
||||
|
||||
# 預設首頁導向登入畫面
|
||||
@app.route('/')
|
||||
def index():
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
# 載入登入使用者
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# 註冊 Blueprint 模組路由
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(temp_spec_bp)
|
||||
app.register_blueprint(upload_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
|
||||
# 註冊錯誤處理函式
|
||||
@app.errorhandler(404)
|
||||
def not_found_error(error):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden_error(error):
|
||||
return render_template('403.html'), 403
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(debug=True)
|
13
config.py
Normal file
13
config.py
Normal file
@@ -0,0 +1,13 @@
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 載入 .env 檔案中的環境變數
|
||||
load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'a_default_secret_key_for_development')
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL')
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
UPLOAD_FOLDER = 'uploads'
|
||||
GENERATED_FOLDER = 'generated'
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024
|
72
init_db.py
Normal file
72
init_db.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
from flask import Flask
|
||||
from werkzeug.security import generate_password_hash
|
||||
from models import db, User
|
||||
from config import Config
|
||||
|
||||
def create_default_admin(app):
|
||||
"""在應用程式上下文中建立一個預設的管理員帳號。"""
|
||||
with app.app_context():
|
||||
# 檢查管理員是否已存在
|
||||
if User.query.filter_by(username='admin').first():
|
||||
print("ℹ️ 'admin' 使用者已存在,跳過建立程序。")
|
||||
return
|
||||
|
||||
# 產生一個安全隨機的密碼
|
||||
password_length = 12
|
||||
alphabet = string.ascii_letters + string.digits + '!@#$%^&*()'
|
||||
password = ''.join(secrets.choice(alphabet) for i in range(password_length))
|
||||
|
||||
# 建立新使用者
|
||||
admin_user = User(
|
||||
username='admin',
|
||||
password_hash=generate_password_hash(password),
|
||||
role='admin'
|
||||
)
|
||||
db.session.add(admin_user)
|
||||
db.session.commit()
|
||||
|
||||
print("✅ 預設管理員帳號已建立!")
|
||||
print(" ===================================")
|
||||
print(f" 👤 使用者名稱: admin")
|
||||
print(f" 🔑 密碼: {password}")
|
||||
print(" ===================================")
|
||||
print(" 請妥善保管此密碼,並在首次登入後考慮變更。")
|
||||
|
||||
def init_database(app):
|
||||
"""初始化資料庫:刪除所有現有資料表並重新建立。"""
|
||||
with app.app_context():
|
||||
print("🔄 開始進行資料庫初始化...")
|
||||
# 為了安全,先刪除所有表格,再重新建立
|
||||
db.drop_all()
|
||||
print(" - 所有舊資料表已刪除。")
|
||||
db.create_all()
|
||||
print(" - 所有新資料表已根據 models.py 建立。")
|
||||
print("✅ 資料庫結構已成功初始化!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 建立一個暫時的 Flask app 來提供資料庫操作所需的應用程式上下文
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
|
||||
# 將資料庫物件與 app 綁定
|
||||
db.init_app(app)
|
||||
|
||||
print("=================================================")
|
||||
print(" ⚠️ 資料庫初始化腳本 ⚠️")
|
||||
print("=================================================")
|
||||
print("此腳本將會刪除所有現有的資料,並重新建立資料庫結構。")
|
||||
print("這個操作是不可逆的!")
|
||||
|
||||
# 讓使用者確認操作
|
||||
confirmation = input("👉 您確定要繼續嗎? (yes/no): ")
|
||||
|
||||
if confirmation.lower() == 'yes':
|
||||
init_database(app)
|
||||
create_default_admin(app)
|
||||
print("\n🎉 全部完成!")
|
||||
else:
|
||||
print("❌ 操作已取消。")
|
51
models.py
Normal file
51
models.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
__tablename__ = 'user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(50), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.Enum('viewer', 'editor', 'admin'), nullable=False)
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
class TempSpec(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
spec_code = db.Column(db.String(20), nullable=False)
|
||||
applicant = db.Column(db.String(50))
|
||||
title = db.Column(db.String(100))
|
||||
content = db.Column(db.Text)
|
||||
start_date = db.Column(db.Date)
|
||||
end_date = db.Column(db.Date)
|
||||
status = db.Column(db.Enum('pending_approval', 'active', 'expired', 'terminated'), nullable=False, default='pending_approval')
|
||||
created_at = db.Column(db.DateTime)
|
||||
extension_count = db.Column(db.Integer, default=0)
|
||||
termination_reason = db.Column(db.Text, nullable=True)
|
||||
|
||||
# 關聯到 Upload 和 SpecHistory,並設定級聯刪除
|
||||
uploads = db.relationship('Upload', back_populates='spec', cascade='all, delete-orphan')
|
||||
history = db.relationship('SpecHistory', back_populates='spec', cascade='all, delete-orphan')
|
||||
|
||||
class Upload(db.Model):
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
temp_spec_id = db.Column(db.Integer, db.ForeignKey('temp_spec.id', ondelete='CASCADE'), nullable=False)
|
||||
filename = db.Column(db.String(200))
|
||||
upload_time = db.Column(db.DateTime)
|
||||
|
||||
spec = db.relationship('TempSpec', back_populates='uploads')
|
||||
|
||||
class SpecHistory(db.Model):
|
||||
__tablename__ = 'SpecHistory'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
spec_id = db.Column(db.Integer, db.ForeignKey('temp_spec.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True)
|
||||
action = db.Column(db.String(50), nullable=False)
|
||||
details = db.Column(db.Text, nullable=True)
|
||||
timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# 建立與 User 和 TempSpec 的關聯,方便查詢
|
||||
user = db.relationship('User')
|
||||
spec = db.relationship('TempSpec', back_populates='history')
|
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
flask
|
||||
flask-login
|
||||
flask-sqlalchemy
|
||||
pymysql
|
||||
werkzeug
|
||||
docx2pdf
|
||||
python-docx
|
||||
docxtpl
|
||||
beautifulsoup4
|
||||
lxml
|
||||
python-dotenv
|
||||
mistune
|
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
76
routes/admin.py
Normal file
76
routes/admin.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.security import generate_password_hash
|
||||
from models import User, db
|
||||
from utils import admin_required
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@admin_bp.before_request
|
||||
@login_required
|
||||
@admin_required
|
||||
def before_request():
|
||||
"""在處理此藍圖中的任何請求之前,確保使用者是已登入的管理員。"""
|
||||
pass
|
||||
|
||||
@admin_bp.route('/users')
|
||||
def user_list():
|
||||
users = User.query.all()
|
||||
return render_template('user_management.html', users=users)
|
||||
|
||||
@admin_bp.route('/users/create', methods=['POST'])
|
||||
def create_user():
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
role = request.form.get('role')
|
||||
|
||||
if not all([username, password, role]):
|
||||
flash('所有欄位都是必填的!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('該使用者名稱已存在!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
new_user = User(
|
||||
username=username,
|
||||
password_hash=generate_password_hash(password),
|
||||
role=role
|
||||
)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
flash('新使用者已成功建立!', 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
@admin_bp.route('/users/edit/<int:user_id>', methods=['POST'])
|
||||
def edit_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
new_role = request.form.get('role')
|
||||
new_password = request.form.get('password')
|
||||
|
||||
if new_role:
|
||||
# 防止 admin 修改自己的角色,導致失去管理權限
|
||||
if user.id == current_user.id and user.role == 'admin' and new_role != 'admin':
|
||||
flash('無法變更自己的管理員角色!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
user.role = new_role
|
||||
|
||||
if new_password:
|
||||
user.password_hash = generate_password_hash(new_password)
|
||||
|
||||
db.session.commit()
|
||||
flash(f"使用者 '{user.username}' 的資料已更新。", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
@admin_bp.route('/users/delete/<int:user_id>', methods=['POST'])
|
||||
def delete_user(user_id):
|
||||
# 避免 admin 刪除自己
|
||||
if user_id == current_user.id:
|
||||
flash('無法刪除自己的帳號!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f"使用者 '{user.username}' 已被刪除。", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
37
routes/auth.py
Normal file
37
routes/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_user, logout_user, login_required
|
||||
from werkzeug.security import check_password_hash
|
||||
from models import User, db
|
||||
from datetime import datetime
|
||||
|
||||
auth_bp = Blueprint('auth', __name__)
|
||||
|
||||
@auth_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user:
|
||||
print(f"🔍 嘗試登入使用者:{username}")
|
||||
else:
|
||||
print("⚠️ 使用者不存在")
|
||||
|
||||
if user and check_password_hash(user.password_hash, password):
|
||||
login_user(user)
|
||||
user.last_login = datetime.now()
|
||||
db.session.commit()
|
||||
print("✅ 登入成功")
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
else:
|
||||
print("❌ 登入失敗,帳號或密碼錯誤")
|
||||
flash('帳號或密碼錯誤,請重新輸入', 'danger')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
379
routes/temp_spec.py
Normal file
379
routes/temp_spec.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
from datetime import datetime, timedelta
|
||||
from models import TempSpec, db, Upload, SpecHistory
|
||||
from utils import fill_template, editor_or_admin_required, add_history_log, admin_required
|
||||
import os
|
||||
import tempfile
|
||||
from werkzeug.utils import secure_filename
|
||||
from bs4 import BeautifulSoup
|
||||
import re
|
||||
import mistune
|
||||
|
||||
temp_spec_bp = Blueprint('temp_spec', __name__)
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@temp_spec_bp.before_request
|
||||
@login_required
|
||||
def before_request():
|
||||
"""在處理此藍圖中的任何請求之前,確保使用者已登入。"""
|
||||
pass
|
||||
|
||||
def _generate_next_spec_code():
|
||||
"""
|
||||
產生下一個暫時規範編號。
|
||||
規則: PE + 民國年(3碼) + 月份(2碼) + 流水號(2碼)
|
||||
"""
|
||||
now = datetime.now()
|
||||
roc_year = now.year - 1911
|
||||
prefix = f"PE{roc_year}{now.strftime('%m')}"
|
||||
|
||||
latest_spec = TempSpec.query.filter(
|
||||
TempSpec.spec_code.startswith(prefix)
|
||||
).order_by(TempSpec.spec_code.desc()).first()
|
||||
|
||||
if latest_spec:
|
||||
last_seq = int(latest_spec.spec_code[-2:])
|
||||
new_seq = last_seq + 1
|
||||
else:
|
||||
new_seq = 1
|
||||
|
||||
return f"{prefix}{new_seq:02d}"
|
||||
|
||||
@temp_spec_bp.route('/preview', methods=['POST'])
|
||||
def preview_spec():
|
||||
"""產生預覽 PDF 並返回"""
|
||||
data = request.json
|
||||
|
||||
values = {
|
||||
'serial_number': data.get('serial_number', 'PREVIEW-SN'),
|
||||
'theme': data.get('theme', 'PREVIEW-THEME'),
|
||||
'applicant': data.get('applicant', ''),
|
||||
'applicant_phone': data.get('applicant_phone', ''),
|
||||
'station': data.get('station', ''),
|
||||
'tccs_info': data.get('tccs_info', ''),
|
||||
'start_date': data.get('start_date', datetime.today().strftime('%Y-%m-%d')),
|
||||
'end_date': (datetime.today() + timedelta(days=30)).strftime('%Y-%m-%d'),
|
||||
'package': data.get('package', ''),
|
||||
'lot_number': data.get('lot_number', ''),
|
||||
'equipment_type': data.get('equipment_type', ''),
|
||||
'change_before': data.get('change_before', ''),
|
||||
'change_after': data.get('change_after', ''),
|
||||
'data_needs': data.get('data_needs', ''),
|
||||
}
|
||||
|
||||
temp_docx_path = tempfile.mktemp(suffix=".docx")
|
||||
temp_pdf_path = tempfile.mktemp(suffix=".pdf")
|
||||
|
||||
try:
|
||||
template_path = os.path.join(BASE_DIR, 'template_with_placeholders.docx')
|
||||
fill_template(values, template_path, temp_docx_path, temp_pdf_path)
|
||||
|
||||
with open(temp_pdf_path, 'rb') as f:
|
||||
pdf_data = f.read()
|
||||
|
||||
import io
|
||||
return_data = io.BytesIO(pdf_data)
|
||||
|
||||
try:
|
||||
if os.path.exists(temp_docx_path):
|
||||
os.remove(temp_docx_path)
|
||||
if os.path.exists(temp_pdf_path):
|
||||
os.remove(temp_pdf_path)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"無法刪除暫存檔: {e}")
|
||||
|
||||
return send_file(return_data, mimetype='application/pdf')
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"預覽生成失敗: {e}")
|
||||
if os.path.exists(temp_docx_path):
|
||||
os.remove(temp_docx_path)
|
||||
if os.path.exists(temp_pdf_path):
|
||||
os.remove(temp_pdf_path)
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@temp_spec_bp.route('/create', methods=['GET', 'POST'])
|
||||
@editor_or_admin_required
|
||||
def create_temp_spec():
|
||||
if request.method == 'POST':
|
||||
data = request.form
|
||||
now = datetime.now()
|
||||
serial_number = _generate_next_spec_code()
|
||||
stations = request.form.getlist('station')
|
||||
if '其他' in stations and data.get('station_other'):
|
||||
stations[stations.index('其他')] = data.get('station_other')
|
||||
station_str = ', '.join(stations)
|
||||
tccs_info = f"{data.get('tccs_level', '')} ({data.get('tccs_4m', '')})"
|
||||
|
||||
values = {
|
||||
'serial_number': serial_number,
|
||||
'theme': data['theme'],
|
||||
'applicant': data.get('applicant', ''),
|
||||
'applicant_phone': data.get('applicant_phone', ''),
|
||||
'station': station_str,
|
||||
'tccs_info': tccs_info,
|
||||
'start_date': data.get('start_date', ''),
|
||||
'package': data.get('package', ''),
|
||||
'lot_number': data.get('lot_number', ''),
|
||||
'equipment_type': data.get('equipment_type', ''),
|
||||
'change_before': data.get('change_before', ''),
|
||||
'change_after': data.get('change_after', ''),
|
||||
'data_needs': data.get('data_needs', ''),
|
||||
}
|
||||
|
||||
generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER'])
|
||||
os.makedirs(generated_folder, exist_ok=True)
|
||||
word_path = os.path.join(generated_folder, f"{values['serial_number']}.docx")
|
||||
pdf_path = os.path.join(generated_folder, f"{values['serial_number']}.pdf")
|
||||
|
||||
db_content_parts = []
|
||||
db_content_parts.append("變更前:\n")
|
||||
db_content_parts.append(values['change_before'])
|
||||
db_content_parts.append("\n\n變更後:\n")
|
||||
db_content_parts.append(values['change_after'])
|
||||
db_content_parts.append("\n\n資料收集需求:\n")
|
||||
db_content_parts.append(values['data_needs'])
|
||||
db_content = "".join(db_content_parts)
|
||||
|
||||
try:
|
||||
start_date_obj = datetime.strptime(values['start_date'], '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
start_date_obj = datetime.today().date()
|
||||
|
||||
end_date_obj = start_date_obj + timedelta(days=30)
|
||||
values['end_date'] = end_date_obj.strftime('%Y-%m-%d')
|
||||
|
||||
spec = TempSpec(
|
||||
spec_code=values['serial_number'],
|
||||
applicant=values['applicant'],
|
||||
title=values['theme'],
|
||||
content=db_content,
|
||||
start_date=start_date_obj,
|
||||
end_date=end_date_obj,
|
||||
created_at=now,
|
||||
status='pending_approval'
|
||||
)
|
||||
db.session.add(spec)
|
||||
db.session.flush()
|
||||
add_history_log(spec.id, '建立', f"建立暫時規範,編號為 {spec.spec_code}")
|
||||
db.session.commit()
|
||||
|
||||
# 在產生用於下載的 PDF 前,需將 Markdown 轉為 HTML
|
||||
values['change_before'] = mistune.html(values['change_before'])
|
||||
values['change_after'] = mistune.html(values['change_after'])
|
||||
try:
|
||||
fill_template(values, os.path.join(BASE_DIR, 'template_with_placeholders.docx'), word_path, pdf_path)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"檔案生成失敗: {e}")
|
||||
flash('檔案生成失敗,可能是 Word 模板或 PDF 轉換器問題,請聯絡管理員。', 'danger')
|
||||
return redirect(url_for('temp_spec.create_temp_spec'))
|
||||
|
||||
return send_file(word_path, as_attachment=True)
|
||||
|
||||
next_spec_code = _generate_next_spec_code()
|
||||
return render_template('create_temp_spec.html', next_spec_code=next_spec_code)
|
||||
|
||||
@temp_spec_bp.route('/list')
|
||||
def spec_list():
|
||||
page = request.args.get('page', 1, type=int)
|
||||
query = request.args.get('query', '')
|
||||
status_filter = request.args.get('status', '')
|
||||
specs_query = TempSpec.query
|
||||
|
||||
if query:
|
||||
search_term = f"%{query}%"
|
||||
specs_query = specs_query.filter(
|
||||
db.or_(
|
||||
TempSpec.spec_code.ilike(search_term),
|
||||
TempSpec.title.ilike(search_term)
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
specs_query = specs_query.filter(TempSpec.status == status_filter)
|
||||
|
||||
pagination = specs_query.order_by(TempSpec.created_at.desc()).paginate(
|
||||
page=page, per_page=15, error_out=False
|
||||
)
|
||||
|
||||
specs = pagination.items
|
||||
return render_template('spec_list.html', specs=specs, pagination=pagination, query=query, status=status_filter)
|
||||
|
||||
@temp_spec_bp.route('/activate/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def activate_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
if request.method == 'POST':
|
||||
uploaded_file = request.files.get('signed_file')
|
||||
if not uploaded_file or uploaded_file.filename == '':
|
||||
flash('您必須上傳一個檔案。', 'danger')
|
||||
return redirect(url_for('temp_spec.activate_spec', spec_id=spec.id))
|
||||
|
||||
filename = secure_filename(f"{spec.spec_code}_signed_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf")
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
uploaded_file.save(file_path)
|
||||
|
||||
new_upload = Upload(
|
||||
temp_spec_id=spec.id,
|
||||
filename=filename,
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
db.session.add(new_upload)
|
||||
|
||||
spec.status = 'active'
|
||||
add_history_log(spec.id, '啟用', f"上傳已簽核檔案 '{filename}'")
|
||||
db.session.commit()
|
||||
flash(f"規範 '{spec.spec_code}' 已生效!", 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return render_template('activate_spec.html', spec=spec)
|
||||
|
||||
@temp_spec_bp.route('/terminate/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@editor_or_admin_required
|
||||
def terminate_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
if request.method == 'POST':
|
||||
reason = request.form.get('reason')
|
||||
if not reason:
|
||||
flash('請填寫提早結束的原因。', 'danger')
|
||||
return redirect(url_for('temp_spec.terminate_spec', spec_id=spec.id))
|
||||
|
||||
spec.status = 'terminated'
|
||||
spec.termination_reason = reason
|
||||
spec.end_date = datetime.today().date()
|
||||
add_history_log(spec.id, '終止', f"原因: {reason}")
|
||||
db.session.commit()
|
||||
flash(f"規範 '{spec.spec_code}' 已被提早終止。", 'warning')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return render_template('terminate_spec.html', spec=spec)
|
||||
|
||||
@temp_spec_bp.route('/download_initial/<int:spec_id>')
|
||||
def download_initial_pdf(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER'])
|
||||
pdf_path = os.path.join(generated_folder, f"{spec.spec_code}.pdf")
|
||||
|
||||
if not os.path.exists(pdf_path):
|
||||
flash('找不到最初產生的 PDF 檔案,可能已被刪除或移動。', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return send_file(pdf_path, as_attachment=True)
|
||||
|
||||
@temp_spec_bp.route('/download_initial_word/<int:spec_id>')
|
||||
@login_required
|
||||
def download_initial_word(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
# 安全性檢查:只有 editor 和 admin 可以下載 word
|
||||
if current_user.role not in ['editor', 'admin']:
|
||||
flash('權限不足,無法下載 Word 檔案。', 'danger')
|
||||
abort(403)
|
||||
|
||||
generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER'])
|
||||
word_path = os.path.join(generated_folder, f"{spec.spec_code}.docx")
|
||||
|
||||
if not os.path.exists(word_path):
|
||||
flash('找不到最初產生的 Word 檔案,可能已被刪除或移動。', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return send_file(word_path, as_attachment=True)
|
||||
|
||||
@temp_spec_bp.route('/download_signed/<int:spec_id>')
|
||||
def download_signed_pdf(spec_id):
|
||||
latest_upload = Upload.query.filter_by(temp_spec_id=spec_id).order_by(Upload.upload_time.desc()).first()
|
||||
|
||||
if not latest_upload:
|
||||
flash('找不到任何已上傳的簽核檔案。', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
return send_file(os.path.join(upload_folder, latest_upload.filename), as_attachment=True)
|
||||
|
||||
@temp_spec_bp.route('/extend/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@editor_or_admin_required
|
||||
def extend_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
new_end_date_str = request.form.get('new_end_date')
|
||||
uploaded_file = request.files.get('new_file')
|
||||
|
||||
if not new_end_date_str:
|
||||
flash('請選擇新的結束日期', 'danger')
|
||||
return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id))
|
||||
|
||||
spec.end_date = datetime.strptime(new_end_date_str, '%Y-%m-%d').date()
|
||||
spec.extension_count += 1
|
||||
spec.status = 'active'
|
||||
|
||||
if uploaded_file and uploaded_file.filename != '':
|
||||
filename = secure_filename(uploaded_file.filename)
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
uploaded_file.save(file_path)
|
||||
|
||||
new_upload = Upload(
|
||||
temp_spec_id=spec.id,
|
||||
filename=filename,
|
||||
upload_time=datetime.now()
|
||||
)
|
||||
db.session.add(new_upload)
|
||||
|
||||
details = f"展延結束日期至 {spec.end_date.strftime('%Y-%m-%d')}"
|
||||
if 'new_upload' in locals():
|
||||
details += f",並上傳新檔案 '{new_upload.filename}'"
|
||||
add_history_log(spec.id, '展延', details)
|
||||
|
||||
db.session.commit()
|
||||
flash(f"規範 '{spec.spec_code}' 已成功展延!", 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
default_new_end_date = spec.end_date + timedelta(days=30)
|
||||
return render_template('extend_spec.html', spec=spec, default_new_end_date=default_new_end_date)
|
||||
|
||||
@temp_spec_bp.route('/history/<int:spec_id>')
|
||||
def spec_history(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all()
|
||||
return render_template('spec_history.html', spec=spec, history=history)
|
||||
|
||||
@temp_spec_bp.route('/delete/<int:spec_id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
spec_code = spec.spec_code
|
||||
|
||||
files_to_delete = []
|
||||
generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER'])
|
||||
files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx"))
|
||||
files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.pdf"))
|
||||
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
for upload_record in spec.uploads:
|
||||
files_to_delete.append(os.path.join(upload_folder, upload_record.filename))
|
||||
|
||||
image_folder = os.path.join(BASE_DIR, 'static', 'uploads', 'images')
|
||||
if spec.content:
|
||||
image_urls = re.findall(r'!\[.*?\]\((.*?)\)', spec.content)
|
||||
for url in image_urls:
|
||||
if url.startswith('/static/uploads/images/'):
|
||||
img_filename = os.path.basename(url)
|
||||
files_to_delete.append(os.path.join(image_folder, img_filename))
|
||||
|
||||
for f_path in files_to_delete:
|
||||
try:
|
||||
if os.path.exists(f_path):
|
||||
os.remove(f_path)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"刪除檔案失敗: {f_path}, 原因: {e}")
|
||||
|
||||
db.session.delete(spec)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"規範 '{spec_code}' 及其所有相關檔案已成功刪除。", 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
29
routes/upload.py
Normal file
29
routes/upload.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import time
|
||||
|
||||
upload_bp = Blueprint('upload', __name__)
|
||||
|
||||
@upload_bp.route('/image', methods=['POST'])
|
||||
def upload_image():
|
||||
file = request.files.get('file')
|
||||
if not file:
|
||||
return jsonify({'error': 'No file part'}), 400
|
||||
|
||||
# 建立一個獨特的檔名
|
||||
extension = os.path.splitext(file.filename)[1]
|
||||
filename = f"{int(time.time())}_{secure_filename(file.filename)}"
|
||||
|
||||
# 確保上傳資料夾存在
|
||||
# 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下
|
||||
image_folder = os.path.join(current_app.static_folder, 'uploads', 'images')
|
||||
os.makedirs(image_folder, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(image_folder, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# 回傳 TinyMCE 需要的 JSON 格式
|
||||
# 路徑必須是相對於網域根目錄的 URL
|
||||
location = f"/static/uploads/images/{filename}"
|
||||
return jsonify({'location': location})
|
35
static/css/style.css
Normal file
35
static/css/style.css
Normal file
@@ -0,0 +1,35 @@
|
||||
body {
|
||||
background-color: #f0f2f5; /* 一個柔和的淺灰色作為備用 */
|
||||
background-image: linear-gradient(to top, #dfe9f3 0%, white 100%);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 頁面切換的淡入效果 */
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
main.container {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
BIN
template_with_placeholders.docx
Normal file
BIN
template_with_placeholders.docx
Normal file
Binary file not shown.
12
templates/403.html
Normal file
12
templates/403.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}權限不足{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2 class="mb-4">權限不足 (Forbidden)</h2>
|
||||
<p class="lead">抱歉,您沒有權限存取此頁面。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
12
templates/404.html
Normal file
12
templates/404.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}找不到頁面{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2 class="mb-4">找不到頁面 (Not Found)</h2>
|
||||
<p class="lead">抱歉,您要找的頁面不存在。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
27
templates/activate_spec.html
Normal file
27
templates/activate_spec.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}啟用暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">上傳簽核檔案以啟用規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-info">
|
||||
請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="signed_file" class="form-label"><strong>已簽核的 PDF 檔案</strong></label>
|
||||
<input class="form-control" type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">上傳並啟用</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
94
templates/base.html
Normal file
94
templates/base.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}暫時規範系統{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Toast UI Editor Core CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
|
||||
<!-- Plugins CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-image-editor/latest/tui-image-editor.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('temp_spec.spec_list') }}">暫時規範系統</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.create_temp_spec') }}">暫時規範建立</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.spec_list') }}">總表檢視</a>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.user_list') }}">帳號管理</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">登出</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">登入</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
<strong class="me-auto">通知</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Toast UI Editor Dependencies & Core -->
|
||||
<script src="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<!-- Plugins JS -->
|
||||
<script src="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 啟用所有 Toast
|
||||
const toastElList = document.querySelectorAll('.toast');
|
||||
const toastList = [...toastElList].map(toastEl => new bootstrap.Toast(toastEl).show());
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
332
templates/create_temp_spec.html
Normal file
332
templates/create_temp_spec.html
Normal file
@@ -0,0 +1,332 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}暫時規範建立 - 暫時規範系統{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
|
||||
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-image-editor/latest/tui-image-editor.css" />
|
||||
|
||||
<style>
|
||||
#tui-image-editor-container {
|
||||
width: 100%;
|
||||
height: 80vh; /* 增加高度佔比 */
|
||||
}
|
||||
|
||||
.modal-dialog.modal-xl {
|
||||
max-width: 98vw;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#tui-image-editor-container .tui-image-editor {
|
||||
height: 100% !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h2 class="mb-4">暫時規範建立</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="spec-form" method="post">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="serial_number" class="form-label">暫時規範編號</label>
|
||||
<input type="text" class="form-control" id="serial_number" name="serial_number" value="{{ next_spec_code }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="theme" class="form-label">主題/目的</label>
|
||||
<input type="text" class="form-control" id="theme" name="theme" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="applicant" class="form-label">申請者</label>
|
||||
<input type="text" class="form-control" id="applicant" name="applicant" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="applicant_phone" class="form-label">電話(分機)</label>
|
||||
<input type="text" class="form-control" id="applicant_phone" name="applicant_phone">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">站別 (可多選)</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="點測"> <label>點測</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="切割"> <label>切割</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="晶粒黏著"> <label>晶粒黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="銲線黏著"> <label>銲線黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="錫膏焊接"> <label>錫膏焊接</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="成型"> <label>成型</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="去膠"> <label>去膠</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="吹砂"> <label>吹砂</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="電鍍"> <label>電鍍</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="切彎腳"> <label>切彎腳</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="印字"> <label>印字</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="測試"> <label>測試</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="其他" id="station_other_checkbox"> <label>其他</label></div>
|
||||
</div>
|
||||
<div class="mt-2" id="station_other_input_div" style="display: none;"><input type="text" class="form-control" name="station_other" placeholder="請輸入其他站別"></div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">TCCS</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" name="tccs_level"><option selected>請選擇 Level...</option><option value="Level 1">Level 1</option><option value="Level 2">Level 2</option><option value="Level 3">Level 3</option><option value="Level 4">Level 4</option></select>
|
||||
<div class="input-group-text">
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="人"> <label>人</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="機"> <label>機</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="料"> <label>料</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="法"> <label>法</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="start_date" class="form-label">實施起始日</label>
|
||||
<input type="date" class="form-control" id="start_date" name="start_date" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3"><label for="package" class="form-label">Package</label><input type="text" class="form-control" id="package" name="package"></div>
|
||||
<div class="col-md-4 mb-3"><label for="lot_number" class="form-label">工單批號</label><input type="text" class="form-control" id="lot_number" name="lot_number"></div>
|
||||
<div class="col-md-4 mb-3"><label for="equipment_type" class="form-label">設備型(編)號</label><input type="text" class="form-control" id="equipment_type" name="equipment_type"></div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">變更前內容</label>
|
||||
<div id="editor-before"></div>
|
||||
<textarea name="change_before" id="textarea-before" style="display: none;"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">變更後內容</label>
|
||||
<div id="editor-after"></div>
|
||||
<textarea name="change_after" id="textarea-after" style="display: none;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="data_needs" class="form-label">資料收集需求</label>
|
||||
<textarea class="form-control" id="data_needs" name="data_needs" rows="3" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="button" id="preview-btn" class="btn btn-info me-2">預覽</button>
|
||||
<button type="submit" class="btn btn-primary">建立暫時規範</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="imageEditorModal" tabindex="-1" aria-labelledby="imageEditorModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-centered modal-fullscreen">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="imageEditorModalLabel">編輯圖片</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="tui-image-editor-container"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-primary" id="save-image-btn">儲存變更</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/tui-image-editor/latest/tui-image-editor.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const imageEditorModal = new bootstrap.Modal(document.getElementById('imageEditorModal'));
|
||||
const Editor = toastui.Editor;
|
||||
const ImageEditor = tui.ImageEditor;
|
||||
|
||||
let activeEditorInstance = null;
|
||||
let imageEditorInstance = null;
|
||||
let targetImageElement = null;
|
||||
|
||||
// 上傳圖片至後端,回傳正式 URL
|
||||
const uploadImage = async (blob) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', blob, 'edited-image.png');
|
||||
|
||||
try {
|
||||
const response = await fetch("{{ url_for('upload.upload_image') }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`);
|
||||
const result = await response.json();
|
||||
return result.location;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('圖片上傳失敗,請檢查網路或聯絡管理員。');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 判斷圖片來源是否為 blob/base64
|
||||
const isBlobOrBase64 = (url) => url.startsWith('data:') || url.startsWith('blob:');
|
||||
|
||||
// 啟動圖片編輯器(支援本地圖片自動轉正式 URL)
|
||||
const launchImageEditor = async (rawSrc) => {
|
||||
let imageUrl = rawSrc;
|
||||
|
||||
if (isBlobOrBase64(rawSrc)) {
|
||||
try {
|
||||
const blob = await (await fetch(rawSrc)).blob();
|
||||
const uploadedUrl = await uploadImage(blob);
|
||||
if (!uploadedUrl) return;
|
||||
imageUrl = uploadedUrl;
|
||||
} catch (err) {
|
||||
alert("圖片格式無法載入編輯器。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (imageEditorInstance) imageEditorInstance.destroy();
|
||||
|
||||
imageEditorInstance = new ImageEditor('#tui-image-editor-container', {
|
||||
includeUI: {
|
||||
loadImage: {
|
||||
path: imageUrl,
|
||||
name: 'image'
|
||||
},
|
||||
menuBarPosition: 'bottom'
|
||||
},
|
||||
cssMaxWidth: 1200,
|
||||
cssMaxHeight: 800,
|
||||
usageStatistics: false
|
||||
});
|
||||
|
||||
imageEditorModal.show();
|
||||
};
|
||||
|
||||
// 建立 Markdown 編輯器並綁定圖片點擊
|
||||
const createEditor = (containerId) => {
|
||||
const editor = new Editor({
|
||||
el: document.querySelector(containerId),
|
||||
height: '300px',
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: 'vertical',
|
||||
hooks: {
|
||||
addImageBlobHook: async (blob, callback) => {
|
||||
const newUrl = await uploadImage(blob);
|
||||
if (newUrl) callback(newUrl, 'image');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 綁定圖片點擊 → 進入編輯器
|
||||
editor.getEditorElements().wwEditor.addEventListener('click', (event) => {
|
||||
if (event.target.tagName === 'IMG') {
|
||||
activeEditorInstance = editor;
|
||||
targetImageElement = event.target;
|
||||
launchImageEditor(event.target.src);
|
||||
}
|
||||
});
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
const editorBefore = createEditor('#editor-before');
|
||||
const editorAfter = createEditor('#editor-after');
|
||||
|
||||
// 儲存按鈕處理編輯後圖片
|
||||
document.getElementById('save-image-btn').addEventListener('click', async () => {
|
||||
if (!imageEditorInstance) return;
|
||||
|
||||
const dataURL = imageEditorInstance.toDataURL({ multiplier: 2 });
|
||||
const blob = await (await fetch(dataURL)).blob();
|
||||
const newUrl = await uploadImage(blob);
|
||||
|
||||
if (newUrl && activeEditorInstance && targetImageElement) {
|
||||
targetImageElement.src = newUrl;
|
||||
|
||||
// 強制同步內容讓 Markdown 也更新
|
||||
const updatedHtml = activeEditorInstance.getHTML();
|
||||
activeEditorInstance.setHTML(updatedHtml);
|
||||
}
|
||||
|
||||
imageEditorModal.hide();
|
||||
});
|
||||
|
||||
// 表單送出前同步 Markdown 內容
|
||||
document.getElementById('spec-form').addEventListener('submit', function () {
|
||||
if (editorBefore) document.getElementById('textarea-before').value = editorBefore.getMarkdown();
|
||||
if (editorAfter) document.getElementById('textarea-after').value = editorAfter.getMarkdown();
|
||||
});
|
||||
|
||||
// 預覽產生邏輯
|
||||
document.getElementById('preview-btn').addEventListener('click', async function () {
|
||||
let isPreviewing = false;
|
||||
if (isPreviewing) return;
|
||||
isPreviewing = true;
|
||||
this.disabled = true;
|
||||
this.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> 產生中...';
|
||||
|
||||
const form = document.getElementById('spec-form');
|
||||
const formData = new FormData(form);
|
||||
|
||||
if (editorBefore) formData.set('change_before', editorBefore.getMarkdown());
|
||||
if (editorAfter) formData.set('change_after', editorAfter.getMarkdown());
|
||||
|
||||
let data = Object.fromEntries(formData.entries());
|
||||
const stations = Array.from(form.querySelectorAll('input[name="station"]:checked')).map(el => el.value);
|
||||
if (stations.includes('其他') && data.station_other) {
|
||||
stations[stations.indexOf('其他')] = data.station_other;
|
||||
}
|
||||
|
||||
data.station = stations.join(', ');
|
||||
data.tccs_info = data.tccs_level ? `${data.tccs_level}${data.tccs_4m ? ' (' + data.tccs_4m + ')' : ''}` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch("{{ url_for('temp_spec.preview_spec') }}", {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) throw new Error(`Server error: ${response.status}`);
|
||||
const pdfBlob = await response.blob();
|
||||
const pdfUrl = URL.createObjectURL(pdfBlob);
|
||||
window.open(pdfUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert('預覽失敗,請檢查表單內容或網路。');
|
||||
} finally {
|
||||
isPreviewing = false;
|
||||
this.disabled = false;
|
||||
this.innerHTML = '預覽';
|
||||
}
|
||||
});
|
||||
|
||||
// 顯示其他站別欄位
|
||||
document.getElementById('station_other_checkbox').addEventListener('change', function () {
|
||||
document.getElementById('station_other_input_div').style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
43
templates/extend_spec.html
Normal file
43
templates/extend_spec.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}展延暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">展延暫時規範</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<p><strong>原結束日期:</strong> {{ spec.end_date.strftime('%Y-%m-%d') }}</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_end_date" class="form-label"><strong>新的結束日期</strong></label>
|
||||
<input type="date" class="form-control" id="new_end_date" name="new_end_date"
|
||||
value="{{ default_new_end_date.strftime('%Y-%m-%d') }}" required>
|
||||
<div class="form-text">預設為原結束日期後一個月。</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_file" class="form-label"><strong>重新上傳檔案 (選填)</strong></label>
|
||||
<input class="form-control" type="file" id="new_file" name="new_file">
|
||||
<div class="form-text">如果本次展延有新的文件版本,請在此上傳。</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">確認展延</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
40
templates/login.html
Normal file
40
templates/login.html
Normal file
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登入 - 暫時規範系統{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<h2 class="text-center mb-4">登入</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">使用者帳號</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">登入</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
34
templates/spec_history.html
Normal file
34
templates/spec_history.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-0">操作歷史紀錄</h2>
|
||||
<p class="lead text-muted">規範編號: {{ spec.spec_code }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>返回總表</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for entry in history %}
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<span class="badge bg-primary rounded-pill me-2">{{ entry.action }}</span>
|
||||
由 <strong>{{ entry.user.username if entry.user else '[已刪除的使用者]' }}</strong> 執行
|
||||
</h5>
|
||||
<small>{{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<p class="mb-1 mt-2">{{ entry.details }}</p>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">沒有任何歷史紀錄。</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
143
templates/spec_list.html
Normal file
143
templates/spec_list.html
Normal file
@@ -0,0 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}暫時規範總表{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">暫時規範總表</h2>
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.create_temp_spec') }}" class="btn btn-primary"><i class="bi bi-plus-circle-fill me-2"></i>建立新規範</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{# This block is now handled by the Toast container in base.html #}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- 搜尋與篩選表單 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('temp_spec.spec_list') }}" class="row g-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="query" class="form-control" placeholder="搜尋編號或主題..." value="{{ query or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select name="status" class="form-select">
|
||||
<option value="">所有狀態</option>
|
||||
<option value="pending_approval" {% if status == 'pending_approval' %}selected{% endif %}>待生效</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>已生效</option>
|
||||
<option value="terminated" {% if status == 'terminated' %}selected{% endif %}>已終止</option>
|
||||
<option value="expired" {% if status == 'expired' %}selected{% endif %}>已過期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">篩選</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<table class="table table-hover table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>編號</th>
|
||||
<th>主題</th>
|
||||
<th>申請者</th>
|
||||
<th>建立日期</th>
|
||||
<th>結束日期</th>
|
||||
<th>狀態</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for spec in specs %}
|
||||
<tr>
|
||||
<td>{{ spec.spec_code }}</td>
|
||||
<td>{{ spec.title }}</td>
|
||||
<td>{{ spec.applicant }}</td>
|
||||
<td>{{ spec.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ spec.end_date.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{% if spec.status == 'active' %}
|
||||
<span class="badge fs-6 bg-success bg-opacity-75"><i class="bi bi-check-circle-fill me-1"></i>已生效</span>
|
||||
{% elif spec.status == 'pending_approval' %}
|
||||
<span class="badge fs-6 bg-info bg-opacity-75 text-dark"><i class="bi bi-hourglass-split me-1"></i>待生效</span>
|
||||
{% elif spec.status == 'terminated' %}
|
||||
<span class="badge fs-6 bg-warning bg-opacity-75 text-dark"><i class="bi bi-slash-circle-fill me-1"></i>已終止</span>
|
||||
{% else %}
|
||||
<span class="badge fs-6 bg-secondary bg-opacity-75"><i class="bi bi-calendar-x-fill me-1"></i>已過期</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{# 只有 admin 才能看到啟用按鈕 #}
|
||||
{% if current_user.role == 'admin' %}
|
||||
{% if spec.status == 'pending_approval' %}
|
||||
<a href="{{ url_for('temp_spec.activate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="啟用"><i class="bi bi-check2-circle"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# editor 或 admin 都能看到展延跟終止按鈕 #}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
{% if spec.status == 'active' %}
|
||||
<a href="{{ url_for('temp_spec.extend_spec', spec_id=spec.id) }}" class="btn btn-sm btn-secondary" title="展延"><i class="bi bi-calendar-plus"></i></a>
|
||||
<a href="{{ url_for('temp_spec.terminate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-danger" title="終止"><i class="bi bi-x-circle"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{# Admin 專屬的刪除按鈕 #}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<form action="{{ url_for('temp_spec.delete_spec', spec_id=spec.id) }}" method="post" class="d-inline" onsubmit="return confirm('您確定要永久刪除這份規範及其所有相關檔案嗎?此操作無法復原。');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="永久刪除"><i class="bi bi-trash-fill"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{# 下載按鈕 #}
|
||||
{% if spec.status == 'pending_approval' %}
|
||||
{# 待生效狀態的下載按鈕 #}
|
||||
<a href="{{ url_for('temp_spec.download_initial_pdf', spec_id=spec.id) }}" class="btn btn-sm btn-info" title="下載 PDF"><i class="bi bi-file-earmark-pdf-fill"></i></a>
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.download_initial_word', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="下載 Word"><i class="bi bi-file-earmark-word-fill"></i></a>
|
||||
{% endif %}
|
||||
{% elif spec.uploads %}
|
||||
{# 其他狀態(已生效、終止等),只提供已簽核的 PDF 下載 #}
|
||||
<a href="{{ url_for('temp_spec.download_signed_pdf', spec_id=spec.id) }}" class="btn btn-sm btn-success" title="下載已簽核 PDF"><i class="bi bi-file-earmark-check-fill"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('temp_spec.spec_history', spec_id=spec.id) }}" class="btn btn-sm btn-outline-secondary" title="檢視歷史紀錄"><i class="bi bi-clock-history"></i></a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 分頁導覽 -->
|
||||
<div class="card-footer bg-white">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.prev_num, query=query, status=status) }}">上一頁</a>
|
||||
</li>
|
||||
{% for page_num in pagination.iter_pages() %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=page_num, query=query, status=status) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.next_num, query=query, status=status) }}">下一頁</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
27
templates/terminate_spec.html
Normal file
27
templates/terminate_spec.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}提早結束暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">提早結束暫時規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-warning">
|
||||
執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="reason" class="form-label"><strong>提早結束原因</strong></label>
|
||||
<textarea class="form-control" id="reason" name="reason" rows="4" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">確認終止</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
91
templates/user_management.html
Normal file
91
templates/user_management.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}帳號管理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">帳號管理</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- 新增使用者表單 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
新增使用者
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.create_user') }}" method="post" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="username" class="form-control" placeholder="使用者名稱" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="password" name="password" class="form-control" placeholder="密碼" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="role" class="form-select" required>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">建立</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用者列表 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
現有使用者列表
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>使用者名稱</th>
|
||||
<th>權限</th>
|
||||
<th>上次登入</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<form action="{{ url_for('admin.edit_user', user_id=user.id) }}" method="post">
|
||||
<td>
|
||||
<select name="role" class="form-select form-select-sm">
|
||||
<option value="viewer" {% if user.role == 'viewer' %}selected{% endif %}>Viewer</option>
|
||||
<option value="editor" {% if user.role == 'editor' %}selected{% endif %}>Editor</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '從未' }}</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-sm btn-success">更新權限</button>
|
||||
</td>
|
||||
</form>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="post" onsubmit="return confirm('確定要刪除這位使用者嗎?');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" {% if user.id == current_user.id %}disabled{% endif %}>刪除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
160
utils.py
Normal file
160
utils.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from docxtpl import DocxTemplate, InlineImage
|
||||
from docx.shared import Mm
|
||||
from docx2pdf import convert
|
||||
import os
|
||||
import re
|
||||
from functools import wraps
|
||||
from flask_login import current_user
|
||||
from flask import abort
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
import pythoncom
|
||||
import mistune
|
||||
from PIL import Image
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
def _resolve_image_path(src: str) -> str:
|
||||
"""
|
||||
將 HTML 圖片 src 轉換為本地檔案絕對路徑
|
||||
支援 /static/... 路徑與相對路徑
|
||||
"""
|
||||
if src.startswith('/'):
|
||||
static_index = src.find('/static/')
|
||||
if static_index != -1:
|
||||
img_path_rel = src[static_index+1:] # 移除開頭斜線
|
||||
return os.path.join(BASE_DIR, img_path_rel)
|
||||
return os.path.join(BASE_DIR, src.lstrip('/'))
|
||||
|
||||
import logging
|
||||
|
||||
DEBUG_LOG = True # 設定為 False 可關閉 debug 訊息
|
||||
|
||||
def _process_markdown_sections(doc, md_content):
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from PIL import Image
|
||||
from docxtpl import InlineImage
|
||||
from docx.shared import Mm
|
||||
|
||||
def log(msg):
|
||||
if DEBUG_LOG:
|
||||
print(f"[DEBUG] {msg}")
|
||||
|
||||
def resolve_image(src):
|
||||
if src.startswith('/'):
|
||||
static_index = src.find('/static/')
|
||||
if static_index != -1:
|
||||
path_rel = src[static_index + 1:]
|
||||
return os.path.join(BASE_DIR, path_rel)
|
||||
return os.path.join(BASE_DIR, src.lstrip('/'))
|
||||
|
||||
def extract_table_text(table_tag):
|
||||
lines = []
|
||||
for i, row in enumerate(table_tag.find_all("tr")):
|
||||
cells = row.find_all(["td", "th"])
|
||||
row_text = " | ".join(cell.get_text(strip=True) for cell in cells)
|
||||
lines.append(row_text)
|
||||
if i == 0:
|
||||
lines.append(" | ".join(["---"] * len(cells)))
|
||||
return "\n".join(lines)
|
||||
|
||||
results = []
|
||||
if not md_content:
|
||||
log("Markdown content is empty")
|
||||
return results
|
||||
|
||||
html = mistune.html(md_content)
|
||||
soup = BeautifulSoup(html, 'lxml')
|
||||
|
||||
for elem in soup.body.children:
|
||||
if isinstance(elem, Tag):
|
||||
if elem.name == 'table':
|
||||
table_text = extract_table_text(elem)
|
||||
log(f"[表格] {table_text}")
|
||||
results.append({'text': table_text, 'image': None})
|
||||
continue
|
||||
|
||||
if elem.name in ['p', 'div']:
|
||||
for child in elem.children:
|
||||
if isinstance(child, Tag) and child.name == 'img' and child.has_attr('src'):
|
||||
try:
|
||||
img_path = resolve_image(child['src'])
|
||||
if os.path.exists(img_path):
|
||||
with Image.open(img_path) as im:
|
||||
width_px = im.width
|
||||
width_mm = min(width_px * 25.4 / 96, 130)
|
||||
image = InlineImage(doc, img_path, width=Mm(width_mm))
|
||||
log(f"[圖片] {img_path}, 寬: {width_mm:.2f} mm")
|
||||
results.append({'text': None, 'image': image})
|
||||
else:
|
||||
log(f"[警告] 圖片不存在: {img_path}")
|
||||
except Exception as e:
|
||||
log(f"[錯誤] 圖片處理失敗: {e}")
|
||||
else:
|
||||
text = child.get_text(strip=True) if hasattr(child, 'get_text') else str(child).strip()
|
||||
if text:
|
||||
log(f"[文字] {text}")
|
||||
results.append({'text': text, 'image': None})
|
||||
return results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def fill_template(values, template_path, output_word_path, output_pdf_path):
|
||||
from docxtpl import DocxTemplate
|
||||
import pythoncom
|
||||
from docx2pdf import convert
|
||||
|
||||
doc = DocxTemplate(template_path)
|
||||
|
||||
# 填入 context,None 改為空字串
|
||||
context = {k: (v if v is not None else '') for k, v in values.items()}
|
||||
|
||||
# 更新後版本:處理 Markdown → sections(支援圖片+表格+段落)
|
||||
context["change_before_sections"] = _process_markdown_sections(doc, context.get("change_before", ""))
|
||||
context["change_after_sections"] = _process_markdown_sections(doc, context.get("change_after", ""))
|
||||
|
||||
# 渲染
|
||||
doc.render(context)
|
||||
doc.save(output_word_path)
|
||||
|
||||
# 轉 PDF
|
||||
try:
|
||||
pythoncom.CoInitialize()
|
||||
convert(output_word_path, output_pdf_path)
|
||||
except Exception as e:
|
||||
print(f"PDF conversion failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
pythoncom.CoUninitialize()
|
||||
|
||||
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role != 'admin':
|
||||
abort(403) # Forbidden
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def editor_or_admin_required(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role not in ['editor', 'admin']:
|
||||
abort(403) # Forbidden
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
def add_history_log(spec_id, action, details=""):
|
||||
"""新增一筆操作歷史紀錄"""
|
||||
from models import db, SpecHistory
|
||||
|
||||
history_entry = SpecHistory(
|
||||
spec_id=spec_id,
|
||||
user_id=current_user.id,
|
||||
action=action,
|
||||
details=details
|
||||
)
|
||||
db.session.add(history_entry)
|
Reference in New Issue
Block a user