commit d1d68e66a74560ba1e9ee6e69794e29013764a31 Author: beabigegg Date: Tue Jul 29 20:24:40 2025 +0800 Ok diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47bdfb1 --- /dev/null +++ b/.env.example @@ -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://:@:/ +# Example for a local MySQL server: +DATABASE_URL='mysql+pymysql://root:your_password@localhost:3306/temp_spec_db' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7083204 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1a86d2 --- /dev/null +++ b/README.md @@ -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 +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` 的所有權限。 + - 可以管理使用者帳號 (新增、編輯、刪除)。 + - **可以上傳簽核後的文件,正式啟用一份規範**。 + - 可以永久刪除一份規範及其所有相關檔案。 diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..f821bd7 --- /dev/null +++ b/USER_MANUAL.md @@ -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 檔案**來啟用規範。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..c9fbca1 --- /dev/null +++ b/app.py @@ -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) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1ed092b --- /dev/null +++ b/config.py @@ -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 diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..37d4e3b --- /dev/null +++ b/init_db.py @@ -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("❌ 操作已取消。") \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..ad81e39 --- /dev/null +++ b/models.py @@ -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') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81c5564 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +flask +flask-login +flask-sqlalchemy +pymysql +werkzeug +docx2pdf +python-docx +docxtpl +beautifulsoup4 +lxml +python-dotenv +mistune diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..8087186 --- /dev/null +++ b/routes/admin.py @@ -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/', 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/', 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')) diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..403edd6 --- /dev/null +++ b/routes/auth.py @@ -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')) diff --git a/routes/temp_spec.py b/routes/temp_spec.py new file mode 100644 index 0000000..76e93e7 --- /dev/null +++ b/routes/temp_spec.py @@ -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/', 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/', 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/') +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/') +@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/') +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/', 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/') +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/', 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')) \ No newline at end of file diff --git a/routes/upload.py b/routes/upload.py new file mode 100644 index 0000000..989dc92 --- /dev/null +++ b/routes/upload.py @@ -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}) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b00fcd3 --- /dev/null +++ b/static/css/style.css @@ -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; +} diff --git a/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000..6be8005 Binary files /dev/null and b/template_with_placeholders.docx differ diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..87e6be7 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}權限不足{% endblock %} + +{% block content %} +
+

403

+

權限不足 (Forbidden)

+

抱歉,您沒有權限存取此頁面。

+ 返回總表 +
+{% endblock %} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..8daa179 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}找不到頁面{% endblock %} + +{% block content %} +
+

404

+

找不到頁面 (Not Found)

+

抱歉,您要找的頁面不存在。

+ 返回總表 +
+{% endblock %} diff --git a/templates/activate_spec.html b/templates/activate_spec.html new file mode 100644 index 0000000..56745d7 --- /dev/null +++ b/templates/activate_spec.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}啟用暫時規範{% endblock %} + +{% block content %} +

上傳簽核檔案以啟用規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。 +
+
+ + +
+ + 取消 +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4d77af0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,94 @@ + + + + + + {% block title %}暫時規範系統{% endblock %} + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/create_temp_spec.html b/templates/create_temp_spec.html new file mode 100644 index 0000000..5caa930 --- /dev/null +++ b/templates/create_temp_spec.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} + +{% block title %}暫時規範建立 - 暫時規範系統{% endblock %} + +{% block head %} +{{ super() }} + + + + + + + +{% endblock %} + +{% block content %} +
+
+

暫時規範建立

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/templates/extend_spec.html b/templates/extend_spec.html new file mode 100644 index 0000000..a99f7d4 --- /dev/null +++ b/templates/extend_spec.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}展延暫時規範{% endblock %} + +{% block content %} +

展延暫時規範

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} +{% endwith %} + +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+

原結束日期: {{ spec.end_date.strftime('%Y-%m-%d') }}

+ +
+ + +
預設為原結束日期後一個月。
+
+ +
+ + +
如果本次展延有新的文件版本,請在此上傳。
+
+ + + 取消 +
+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..88b4178 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}登入 - 暫時規範系統{% endblock %} + +{% block content %} +
+
+

登入

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/templates/spec_history.html b/templates/spec_history.html new file mode 100644 index 0000000..b37ccd6 --- /dev/null +++ b/templates/spec_history.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

操作歷史紀錄

+

規範編號: {{ spec.spec_code }}

+
+ 返回總表 +
+ +
+
+
    + {% for entry in history %} +
  • +
    +
    + {{ entry.action }} + 由 {{ entry.user.username if entry.user else '[已刪除的使用者]' }} 執行 +
    + {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
    +

    {{ entry.details }}

    +
  • + {% else %} +
  • 沒有任何歷史紀錄。
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/spec_list.html b/templates/spec_list.html new file mode 100644 index 0000000..cc0d3f9 --- /dev/null +++ b/templates/spec_list.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}暫時規範總表{% endblock %} + +{% block content %} +
+

暫時規範總表

+ {% if current_user.role in ['editor', 'admin'] %} + 建立新規範 + {% endif %} +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {# This block is now handled by the Toast container in base.html #} + {% endif %} +{% endwith %} + + +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + {% for spec in specs %} + + + + + + + + + + {% endfor %} + +
編號主題申請者建立日期結束日期狀態操作
{{ spec.spec_code }}{{ spec.title }}{{ spec.applicant }}{{ spec.created_at.strftime('%Y-%m-%d') }}{{ spec.end_date.strftime('%Y-%m-%d') }} + {% if spec.status == 'active' %} + 已生效 + {% elif spec.status == 'pending_approval' %} + 待生效 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {# 只有 admin 才能看到啟用按鈕 #} + {% if current_user.role == 'admin' %} + {% if spec.status == 'pending_approval' %} + + {% endif %} + {% endif %} + + {# editor 或 admin 都能看到展延跟終止按鈕 #} + {% if current_user.role in ['editor', 'admin'] %} + {% if spec.status == 'active' %} + + + {% endif %} + {% endif %} + + {# Admin 專屬的刪除按鈕 #} + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {# 下載按鈕 #} + {% if spec.status == 'pending_approval' %} + {# 待生效狀態的下載按鈕 #} + + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + {# 其他狀態(已生效、終止等),只提供已簽核的 PDF 下載 #} + + {% endif %} + + +
+
+ + +
+{% endblock %} diff --git a/templates/terminate_spec.html b/templates/terminate_spec.html new file mode 100644 index 0000000..62db896 --- /dev/null +++ b/templates/terminate_spec.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}提早結束暫時規範{% endblock %} + +{% block content %} +

提早結束暫時規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。 +
+
+ + +
+ + 取消 +
+
+
+{% endblock %} diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..43c42a9 --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block title %}帳號管理{% endblock %} + +{% block content %} +

帳號管理

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} +{% endwith %} + + +
+
+ 新增使用者 +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ 現有使用者列表 +
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% endfor %} + +
ID使用者名稱權限上次登入操作
{{ user.id }}{{ user.username }}
+ + {{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '從未' }} + + +
+ +
+
+
+
+{% endblock %} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..a90ce0c --- /dev/null +++ b/utils.py @@ -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)